mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-12 09:24:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ec53c5b1c | ||
|
|
8ac71e1c86 | ||
|
|
b49a78db42 | ||
|
|
f896627e82 |
No files matched your search
@@ -130,6 +130,7 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
|
||||
- **分割ペイン** — 水平・垂直分割でマルチタスク
|
||||
- **セッション管理** — 複数の接続を並行して扱う
|
||||
- **インライン画像** — リモートプログラムが出力する Kitty グラフィックス、SIXEL、iTerm インライン画像を描画
|
||||
- **アーカイブショートカット** — Linux セッションでは、出力された `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz`、`.zip` の名前を Ctrl/Cmd+クリックすると、アーカイブの絶対パスを含む展開コマンドがカーソル位置に挿入されます(入力済みの文字は残ります)。Enter で実行します。カレントディレクトリからの相対名にはディレクトリ追跡(OSC 7、ツールバーの Configure directory tracking)が必要です。絶対パスは常に対象です。Windows、WSL、macOS のセッションは対象外です。
|
||||
|
||||
### 📁 SFTP + 内蔵エディタ
|
||||
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
|
||||
|
||||
@@ -133,6 +133,7 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
- **Session management** — run multiple connections side-by-side
|
||||
- **Save current screen** — right-click a terminal to save the visible final text, without replaying keystrokes or overwritten output; scroll first to save an earlier screen.
|
||||
- **Inline images** — render Kitty graphics, SIXEL and iTerm inline images from remote programs
|
||||
- **Archive shortcuts** — on Linux sessions, Ctrl/Cmd+click a printed `.tar`, `.tar.gz`/`.tgz`, `.tar.bz2`/`.tbz2`, `.tar.xz` or `.zip` name to fill an extraction command with the archive's absolute path into the prompt (kept next to any typed text); press Enter to run it. Names relative to the current directory need directory tracking (OSC 7, see the toolbar's Configure directory tracking); absolute paths always work. Windows, WSL, and macOS sessions are excluded.
|
||||
|
||||
### 📁 SFTP + Built-in Editor
|
||||
- **File workflows** — drag & drop uploads/downloads
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
- **分屏** —— 水平/垂直分割,多任务并行
|
||||
- **多会话管理** —— 多连接并排处理
|
||||
- **内联图片** —— 直接渲染远端程序输出的 Kitty 图形、SIXEL 与 iTerm 内联图片
|
||||
- **压缩包快捷填充** —— 在 Linux 会话里,按住 Ctrl/Cmd 点击输出中的 `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz` 或 `.zip` 文件名,会在光标处填入带绝对路径的解压命令(保留已经输入的文字);按 Enter 才会执行。相对当前目录的文件名需要目录追踪(OSC 7,见工具栏「配置目录追踪」);绝对路径始终可点。Windows、WSL 与 macOS 会话不在范围内。
|
||||
|
||||
### 📁 SFTP + 内置编辑器
|
||||
- **文件工作流** —— 拖拽上传/下载更直观
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
- **分割窗格** —— 水平與垂直分割,方便多工
|
||||
- **工作階段管理** —— 多條連線並排處理
|
||||
- **內嵌圖片** —— 直接顯示遠端程式輸出的 Kitty 圖形、SIXEL 與 iTerm 內嵌圖片
|
||||
- **壓縮檔捷徑** —— 在 Linux 工作階段的終端機裡,按住 Ctrl/Cmd 點選輸出中的 `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz` 或 `.zip` 檔名,會在游標處填入含絕對路徑的解壓縮命令(保留已輸入的文字);按 Enter 才會執行。相對於目前目錄的檔名需要目錄追蹤(OSC 7,見工具列「設定目錄追蹤」);絕對路徑一律可點。Windows、WSL 與 macOS 工作階段不支援。
|
||||
|
||||
### 📁 SFTP + 內建編輯器
|
||||
- **檔案工作流** —— 拖放上傳/下載
|
||||
|
||||
+13
-4
@@ -163,7 +163,10 @@ import {
|
||||
import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast";
|
||||
import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry";
|
||||
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
||||
import { isIdleShellReadyForCommandInjection } from "../domain/terminalCommandInjectionReady";
|
||||
import {
|
||||
isIdleShellReadyForCommandInjection,
|
||||
isShellPromptReadyForInsertion,
|
||||
} from "../domain/terminalCommandInjectionReady";
|
||||
import { detectPrompt } from "./terminal/autocomplete/promptDetector";
|
||||
import {
|
||||
applyUserCursorPreference,
|
||||
@@ -599,18 +602,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
||||
const onSessionExitRef = useRef(onSessionExit);
|
||||
const commandBufferRef = useRef<string>("");
|
||||
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, () => {
|
||||
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, (options) => {
|
||||
const term = termRef.current;
|
||||
if (!term) return false;
|
||||
const prompt = detectPrompt(term);
|
||||
return isIdleShellReadyForCommandInjection({
|
||||
const readiness = {
|
||||
sensitiveInputActive: passwordPromptActiveRef.current,
|
||||
hasLiveTerminal: true,
|
||||
alternateScreenActive: isTerminalAlternateScreenActive(term),
|
||||
isAtPrompt: prompt.isAtPrompt,
|
||||
userInputLength: prompt.userInput.length,
|
||||
pendingTypedInputLength: commandBufferRef.current.length,
|
||||
});
|
||||
};
|
||||
return options?.allowDraft
|
||||
? isShellPromptReadyForInsertion(readiness)
|
||||
: isIdleShellReadyForCommandInjection(readiness);
|
||||
}), [sessionId]);
|
||||
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
|
||||
const [hasMouseTracking, setHasMouseTracking] = useState(false);
|
||||
@@ -2341,6 +2347,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}), []);
|
||||
|
||||
const xTermRuntimeContextRef = useRef<Omit<CreateXTermRuntimeContext, "container" | "initiallyVisible"> | null>(null);
|
||||
const archiveHostRef = useRef(host);
|
||||
archiveHostRef.current = host;
|
||||
const pluginDecorationRulesRef = useRef<readonly PluginTerminalDecorationRule[]>(Object.freeze([]));
|
||||
const pluginDecorationRefreshRef = useRef<(reason: string) => void>(() => {});
|
||||
|
||||
@@ -4336,6 +4344,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
xTermRuntimeContextRef.current = {
|
||||
host,
|
||||
getHost: () => archiveHostRef.current,
|
||||
fontFamilyId,
|
||||
resolvedFontFamily,
|
||||
fontSize: effectiveFontSize,
|
||||
|
||||
@@ -8,6 +8,10 @@ import { SearchAddon } from "@xterm/addon-search";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { supportsTerminalArchiveCommands } from "../../../domain/terminalArchives";
|
||||
import { registerTerminalArchiveLinks } from "./terminalArchiveLinks";
|
||||
import { createTerminalCwdMarkers } from "./terminalCwdMarkers";
|
||||
import { isTerminalReadyForCommandInjection } from "./terminalCommandInjectionReadyRegistry";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { RefObject } from "react";
|
||||
@@ -315,6 +319,8 @@ export const resetKittyKeyboardModeStateForSession = (
|
||||
export type CreateXTermRuntimeContext = {
|
||||
container: HTMLDivElement;
|
||||
host: Host;
|
||||
/** Host OS facts can arrive after the runtime is created. */
|
||||
getHost?: () => Host;
|
||||
fontFamilyId: string;
|
||||
resolvedFontFamily: string;
|
||||
fontSize: number;
|
||||
@@ -959,6 +965,16 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
terminalLinkHandler.activate(event, uri);
|
||||
});
|
||||
term.loadAddon(webLinksAddon);
|
||||
const cwdMarkers = createTerminalCwdMarkers(term);
|
||||
const archiveLinks = registerTerminalArchiveLinks({
|
||||
term,
|
||||
cwdMarkers,
|
||||
isConnected: () => ctx.statusRef.current === "connected",
|
||||
isBroadcasting: () => ctx.isBroadcastEnabledRef.current === true,
|
||||
isHostEligible: () => supportsTerminalArchiveCommands(ctx.getHost?.() ?? ctx.host, platform),
|
||||
isInputReady: () => isTerminalReadyForCommandInjection(ctx.sessionId, { allowDraft: true }),
|
||||
linkModifier: () => ctx.terminalSettingsRef.current?.linkModifier ?? "none",
|
||||
});
|
||||
const pluginLinkProviderHost = ctx.requestPluginTerminalProviders
|
||||
? registerPluginTerminalLinkProvider({
|
||||
term,
|
||||
@@ -3229,12 +3245,14 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (path && path.length > 0) {
|
||||
currentCwd = path;
|
||||
ctx.onCwdChange?.(path);
|
||||
cwdMarkers.note({ cwd: path, host: url.hostname });
|
||||
logger.debug('[XTerm] OSC 7 CWD update:', path);
|
||||
}
|
||||
} else if (data.startsWith('/')) {
|
||||
// Some shells send just the path without file:// prefix
|
||||
currentCwd = data;
|
||||
ctx.onCwdChange?.(data);
|
||||
cwdMarkers.note({ cwd: data, host: '' });
|
||||
logger.debug('[XTerm] OSC 7 CWD update (raw path):', data);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -3483,6 +3501,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
keywordHighlighter.dispose();
|
||||
cursorLineHighlighter.dispose();
|
||||
pluginLinkProviderHost?.dispose();
|
||||
archiveLinks.dispose();
|
||||
cwdMarkers.dispose();
|
||||
pluginProviderHost?.dispose();
|
||||
eraseScrollbackDisposable.dispose();
|
||||
for (const disposable of cursorPositionReportRequestDisposables) {
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { ILink, ILinkProvider, Terminal } from "@xterm/xterm";
|
||||
|
||||
import { createTerminalCwdMarkers } from "./terminalCwdMarkers.ts";
|
||||
import { registerTerminalArchiveLinks } from "./terminalArchiveLinks.ts";
|
||||
import type { LinkModifier } from "../../../domain/models/terminal.ts";
|
||||
|
||||
type PrintedLine = { text: string; wrapped: boolean };
|
||||
|
||||
function click(partial: Partial<MouseEvent> = {}): MouseEvent {
|
||||
return {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
...partial,
|
||||
} as MouseEvent;
|
||||
}
|
||||
|
||||
function makeBufferLine(line: PrintedLine) {
|
||||
const chars = Array.from(line.text);
|
||||
return {
|
||||
isWrapped: line.wrapped,
|
||||
length: chars.length,
|
||||
translateToString(trim?: boolean) {
|
||||
return trim ? line.text.trimEnd() : line.text;
|
||||
},
|
||||
getCell(index: number) {
|
||||
const ch = chars[index];
|
||||
if (ch === undefined) return undefined;
|
||||
return {
|
||||
getChars: () => ch,
|
||||
getWidth: () => 1,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
let lines: PrintedLine[] = [{ text: "/tmp/backup.tar.gz", wrapped: false }];
|
||||
let cursorX = 0;
|
||||
let cursorY = 0;
|
||||
let baseY = 0;
|
||||
let cols = 80;
|
||||
let cursorLine = 0;
|
||||
let type: "normal" | "alternate" = "normal";
|
||||
let connected = true;
|
||||
let broadcast = false;
|
||||
let eligible = true;
|
||||
let ready = true;
|
||||
let modifier: LinkModifier = "none";
|
||||
const pasted: string[] = [];
|
||||
let provider!: ILinkProvider;
|
||||
|
||||
const term = {
|
||||
get cols() {
|
||||
return cols;
|
||||
},
|
||||
buffer: {
|
||||
active: {
|
||||
get type() {
|
||||
return type;
|
||||
},
|
||||
get cursorX() {
|
||||
return cursorX;
|
||||
},
|
||||
get cursorY() {
|
||||
return cursorY;
|
||||
},
|
||||
get baseY() {
|
||||
return baseY;
|
||||
},
|
||||
getLine(index: number) {
|
||||
const line = lines[index];
|
||||
return line ? makeBufferLine(line) : undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
registerMarker(offset: number) {
|
||||
return {
|
||||
line: cursorLine + offset,
|
||||
isDisposed: false,
|
||||
dispose() {
|
||||
this.isDisposed = true;
|
||||
},
|
||||
};
|
||||
},
|
||||
paste(value: string) {
|
||||
pasted.push(value);
|
||||
},
|
||||
focus() {},
|
||||
registerLinkProvider(next: ILinkProvider) {
|
||||
provider = next;
|
||||
return { dispose() {} };
|
||||
},
|
||||
};
|
||||
|
||||
const cwdMarkers = createTerminalCwdMarkers(term as never);
|
||||
registerTerminalArchiveLinks({
|
||||
term: term as unknown as Terminal,
|
||||
cwdMarkers,
|
||||
isConnected: () => connected,
|
||||
isBroadcasting: () => broadcast,
|
||||
isHostEligible: () => eligible,
|
||||
isInputReady: () => ready,
|
||||
linkModifier: () => modifier,
|
||||
});
|
||||
|
||||
const linksAt = (y: number) => {
|
||||
let value: ILink[] | undefined;
|
||||
provider.provideLinks(y, (result) => {
|
||||
value = result;
|
||||
});
|
||||
return value;
|
||||
};
|
||||
|
||||
return {
|
||||
pasted,
|
||||
linksAt,
|
||||
noteAt(line: number, report: { cwd: string; host: string }, x = 0) {
|
||||
cursorLine = line;
|
||||
const previousX = cursorX;
|
||||
cursorX = x;
|
||||
cwdMarkers.note(report);
|
||||
cursorX = previousX;
|
||||
},
|
||||
setLines(next: PrintedLine[]) {
|
||||
lines = next;
|
||||
},
|
||||
setLine(index: number, text: string, wrapped = false) {
|
||||
lines[index] = { text, wrapped };
|
||||
},
|
||||
setCursor(line: number, x: number) {
|
||||
cursorY = line;
|
||||
cursorX = x;
|
||||
baseY = 0;
|
||||
},
|
||||
atPrompt() {
|
||||
const prompt = lines.length;
|
||||
lines = [...lines, { text: "", wrapped: false }];
|
||||
cursorY = prompt;
|
||||
cursorX = 0;
|
||||
baseY = 0;
|
||||
},
|
||||
setBroadcast(value: boolean) {
|
||||
broadcast = value;
|
||||
},
|
||||
setReady(value: boolean) {
|
||||
ready = value;
|
||||
},
|
||||
setEligible(value: boolean) {
|
||||
eligible = value;
|
||||
},
|
||||
setConnected(value: boolean) {
|
||||
connected = value;
|
||||
},
|
||||
setModifier(value: LinkModifier) {
|
||||
modifier = value;
|
||||
},
|
||||
setAltBuffer(value: boolean) {
|
||||
type = value ? "alternate" : "normal";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ctrl = click({ ctrlKey: true });
|
||||
|
||||
test("no modifier is a no-op, Ctrl fills one command, and Cmd works on macOS", () => {
|
||||
const linux = fixture();
|
||||
const link = linux.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
assert.deepEqual(link.range, { start: { x: 1, y: 1 }, end: { x: 18, y: 1 } });
|
||||
linux.atPrompt();
|
||||
link.activate(click(), link.text);
|
||||
assert.deepEqual(linux.pasted, []);
|
||||
link.activate(ctrl, link.text);
|
||||
assert.deepEqual(linux.pasted, ["tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'"]);
|
||||
assert.equal(linux.pasted[0]?.includes("\r"), false);
|
||||
assert.equal(linux.pasted[0]?.includes("\n"), false);
|
||||
|
||||
const mac = fixture();
|
||||
const macLink = mac.linksAt(1)?.[0];
|
||||
assert.ok(macLink);
|
||||
mac.atPrompt();
|
||||
macLink.activate(click({ metaKey: true }), macLink.text);
|
||||
assert.deepEqual(mac.pasted, ["tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'"]);
|
||||
});
|
||||
|
||||
test("a configured alt modifier is required for archive links", () => {
|
||||
const f = fixture();
|
||||
f.setModifier("alt");
|
||||
const link = f.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
link.activate(ctrl, link.text);
|
||||
assert.deepEqual(f.pasted, []);
|
||||
link.activate(click({ altKey: true }), link.text);
|
||||
assert.equal(f.pasted.length, 1);
|
||||
assert.equal(f.pasted[0]?.includes("\r"), false);
|
||||
});
|
||||
|
||||
test("broadcast, readiness, host eligibility, buffer, and stale text gate provide and activate", () => {
|
||||
const f = fixture();
|
||||
const link = f.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
|
||||
f.setBroadcast(true);
|
||||
assert.equal(f.linksAt(1), undefined);
|
||||
link.activate(ctrl, link.text);
|
||||
f.setBroadcast(false);
|
||||
|
||||
f.setReady(false);
|
||||
assert.equal(f.linksAt(1), undefined);
|
||||
link.activate(ctrl, link.text);
|
||||
f.setReady(true);
|
||||
|
||||
f.setEligible(false);
|
||||
assert.equal(f.linksAt(1), undefined);
|
||||
link.activate(ctrl, link.text);
|
||||
f.setEligible(true);
|
||||
|
||||
f.setConnected(false);
|
||||
assert.equal(f.linksAt(1), undefined);
|
||||
link.activate(ctrl, link.text);
|
||||
f.setConnected(true);
|
||||
|
||||
f.setAltBuffer(true);
|
||||
assert.equal(f.linksAt(1), undefined);
|
||||
link.activate(ctrl, link.text);
|
||||
f.setAltBuffer(false);
|
||||
|
||||
f.setLine(0, "changed.tar.gz");
|
||||
link.activate(ctrl, link.text);
|
||||
assert.deepEqual(f.pasted, []);
|
||||
});
|
||||
|
||||
test("a non-space before the cursor is kept and separated with one space", () => {
|
||||
const draft = fixture();
|
||||
draft.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: "echo", wrapped: false },
|
||||
]);
|
||||
draft.setCursor(1, 4);
|
||||
const link = draft.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
link.activate(ctrl, link.text);
|
||||
assert.equal(draft.pasted[0], " tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'");
|
||||
|
||||
const spaced = fixture();
|
||||
spaced.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: "sudo ", wrapped: false },
|
||||
]);
|
||||
spaced.setCursor(1, 5);
|
||||
const spacedLink = spaced.linksAt(1)?.[0];
|
||||
assert.ok(spacedLink);
|
||||
spacedLink.activate(ctrl, spacedLink.text);
|
||||
assert.equal(spaced.pasted[0], "tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'");
|
||||
});
|
||||
|
||||
test("a non-space after the cursor is separated with one space", () => {
|
||||
const command = "tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'";
|
||||
const before = fixture();
|
||||
before.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: "echo", wrapped: false },
|
||||
]);
|
||||
before.setCursor(1, 0);
|
||||
const beforeLink = before.linksAt(1)?.[0];
|
||||
assert.ok(beforeLink);
|
||||
beforeLink.activate(ctrl, beforeLink.text);
|
||||
assert.equal(before.pasted[0], `${command} `);
|
||||
assert.equal(before.pasted[0]?.includes("\r"), false);
|
||||
|
||||
const middle = fixture();
|
||||
middle.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: "echo", wrapped: false },
|
||||
]);
|
||||
middle.setCursor(1, 2);
|
||||
const middleLink = middle.linksAt(1)?.[0];
|
||||
assert.ok(middleLink);
|
||||
middleLink.activate(ctrl, middleLink.text);
|
||||
assert.equal(middle.pasted[0], ` ${command} `);
|
||||
|
||||
const already = fixture();
|
||||
already.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: "echo foo", wrapped: false },
|
||||
]);
|
||||
already.setCursor(1, 4);
|
||||
const alreadyLink = already.linksAt(1)?.[0];
|
||||
assert.ok(alreadyLink);
|
||||
alreadyLink.activate(ctrl, alreadyLink.text);
|
||||
assert.equal(already.pasted[0], ` ${command}`);
|
||||
});
|
||||
|
||||
test("a wrapped draft separates the command from the previous row", () => {
|
||||
const command = "tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'";
|
||||
const full = " ".repeat(79);
|
||||
|
||||
const glued = fixture();
|
||||
glued.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: `${full}o`, wrapped: false },
|
||||
{ text: " ", wrapped: true },
|
||||
]);
|
||||
glued.setCursor(2, 0);
|
||||
const gluedLink = glued.linksAt(1)?.[0];
|
||||
assert.ok(gluedLink);
|
||||
gluedLink.activate(ctrl, gluedLink.text);
|
||||
assert.equal(glued.pasted[0], ` ${command}`);
|
||||
|
||||
const spaced = fixture();
|
||||
spaced.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: `${full} `, wrapped: false },
|
||||
{ text: " ", wrapped: true },
|
||||
]);
|
||||
spaced.setCursor(2, 0);
|
||||
const spacedLink = spaced.linksAt(1)?.[0];
|
||||
assert.ok(spacedLink);
|
||||
spacedLink.activate(ctrl, spacedLink.text);
|
||||
assert.equal(spaced.pasted[0], command);
|
||||
|
||||
const separate = fixture();
|
||||
separate.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: `${full}o`, wrapped: false },
|
||||
{ text: " ", wrapped: false },
|
||||
]);
|
||||
separate.setCursor(2, 0);
|
||||
const separateLink = separate.linksAt(1)?.[0];
|
||||
assert.ok(separateLink);
|
||||
separateLink.activate(ctrl, separateLink.text);
|
||||
assert.equal(separate.pasted[0], command);
|
||||
|
||||
const continued = fixture();
|
||||
continued.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: "e".repeat(80), wrapped: false },
|
||||
{ text: "x", wrapped: true },
|
||||
]);
|
||||
continued.setCursor(1, 80);
|
||||
const continuedLink = continued.linksAt(1)?.[0];
|
||||
assert.ok(continuedLink);
|
||||
continuedLink.activate(ctrl, continuedLink.text);
|
||||
assert.equal(continued.pasted[0], ` ${command} `);
|
||||
});
|
||||
|
||||
test("a name that starts on the next wrapped row is not anchored on the previous row", () => {
|
||||
const f = fixture();
|
||||
f.setLines([
|
||||
{ text: "x ", wrapped: false },
|
||||
{ text: "/tmp/a.tar", wrapped: true },
|
||||
]);
|
||||
const link = f.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
assert.equal(link.text, "/tmp/a.tar");
|
||||
assert.deepEqual(link.range, {
|
||||
start: { x: 1, y: 2 },
|
||||
end: { x: 10, y: 2 },
|
||||
});
|
||||
|
||||
const ended = fixture();
|
||||
ended.setLines([
|
||||
{ text: "/a.tar", wrapped: false },
|
||||
{ text: " rest", wrapped: true },
|
||||
]);
|
||||
const endedLink = ended.linksAt(1)?.[0];
|
||||
assert.ok(endedLink);
|
||||
assert.equal(endedLink.text, "/a.tar");
|
||||
assert.deepEqual(endedLink.range, {
|
||||
start: { x: 1, y: 1 },
|
||||
end: { x: 6, y: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("a wrapped logical line is one multi-row link and fills the full path", () => {
|
||||
const f = fixture();
|
||||
f.setLines([
|
||||
{ text: "/tmp/back", wrapped: false },
|
||||
{ text: "up.tar.gz", wrapped: true },
|
||||
]);
|
||||
const link = f.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
assert.deepEqual(link.range, {
|
||||
start: { x: 1, y: 1 },
|
||||
end: { x: 9, y: 2 },
|
||||
});
|
||||
f.atPrompt();
|
||||
link.activate(ctrl, link.text);
|
||||
assert.deepEqual(f.pasted, ["tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'"]);
|
||||
});
|
||||
|
||||
test("absolute names link without markers and relative names do not", () => {
|
||||
const relative = fixture();
|
||||
relative.setLine(0, "bundle.tar.gz");
|
||||
assert.equal(relative.linksAt(1), undefined);
|
||||
|
||||
const absolute = fixture();
|
||||
absolute.setLine(0, "/var/pack.zip");
|
||||
const link = absolute.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
absolute.atPrompt();
|
||||
link.activate(ctrl, link.text);
|
||||
assert.deepEqual(absolute.pasted, ["unzip '/var/pack.zip' -d '/var'"]);
|
||||
|
||||
const open = fixture();
|
||||
open.setLines([
|
||||
{ text: "prompt", wrapped: false },
|
||||
{ text: "/var/pack.zip", wrapped: false },
|
||||
]);
|
||||
open.noteAt(0, { cwd: "/var", host: "box" });
|
||||
const openLink = open.linksAt(2)?.[0];
|
||||
assert.ok(openLink);
|
||||
open.atPrompt();
|
||||
openLink.activate(ctrl, openLink.text);
|
||||
assert.equal(open.pasted[0], "unzip '/var/pack.zip' -d '/var'");
|
||||
open.setLine(1, "pack.zip");
|
||||
assert.equal(open.linksAt(2), undefined);
|
||||
|
||||
const mismatched = fixture();
|
||||
mismatched.setLines([
|
||||
{ text: "prompt", wrapped: false },
|
||||
{ text: "/tmp/a.tar.gz", wrapped: false },
|
||||
{ text: "prompt", wrapped: false },
|
||||
]);
|
||||
mismatched.noteAt(0, { cwd: "/tmp", host: "box" });
|
||||
mismatched.noteAt(2, { cwd: "/other", host: "box" });
|
||||
const mismatchLink = mismatched.linksAt(2)?.[0];
|
||||
assert.ok(mismatchLink);
|
||||
mismatched.atPrompt();
|
||||
mismatchLink.activate(ctrl, mismatchLink.text);
|
||||
assert.equal(mismatched.pasted[0], "tar -xvzf '/tmp/a.tar.gz' -C '/tmp'");
|
||||
mismatched.setLine(1, "a.tar.gz");
|
||||
assert.equal(mismatched.linksAt(2), undefined);
|
||||
});
|
||||
|
||||
test("an absolute path from another host is not a link", () => {
|
||||
const f = fixture();
|
||||
f.setLines([
|
||||
{ text: "prompt", wrapped: false },
|
||||
{ text: "/tmp/a.tar.gz", wrapped: false },
|
||||
{ text: "prompt", wrapped: false },
|
||||
]);
|
||||
f.noteAt(0, { cwd: "/tmp", host: "outer" });
|
||||
f.noteAt(2, { cwd: "/tmp", host: "outer" });
|
||||
f.noteAt(4, { cwd: "/home", host: "inner" });
|
||||
assert.equal(f.linksAt(2), undefined);
|
||||
});
|
||||
|
||||
test("ls -l links only the archive name and a symlink line links both names", () => {
|
||||
const listing = fixture();
|
||||
listing.setLines([
|
||||
{ text: "$ ls -l", wrapped: false },
|
||||
{ text: "-rw-r--r-- 1 me me 10 Jan 1 00:00 bundle.tar.gz", wrapped: false },
|
||||
{ text: "$", wrapped: false },
|
||||
]);
|
||||
listing.noteAt(0, { cwd: "/data", host: "box" });
|
||||
listing.noteAt(2, { cwd: "/data", host: "box" });
|
||||
const links = listing.linksAt(2);
|
||||
assert.deepEqual(links?.map((link) => link.text), ["bundle.tar.gz"]);
|
||||
listing.atPrompt();
|
||||
links?.[0]?.activate(ctrl, links[0].text);
|
||||
assert.deepEqual(listing.pasted, ["tar -xvzf '/data/bundle.tar.gz' -C '/data'"]);
|
||||
|
||||
const symlink = fixture();
|
||||
symlink.setLines([
|
||||
{ text: "$ ls", wrapped: false },
|
||||
{ text: "a.tar.gz -> b.tar.gz", wrapped: false },
|
||||
{ text: "$", wrapped: false },
|
||||
]);
|
||||
symlink.noteAt(0, { cwd: "/data", host: "box" });
|
||||
symlink.noteAt(2, { cwd: "/data", host: "box" });
|
||||
assert.deepEqual(symlink.linksAt(2)?.map((link) => link.text), ["a.tar.gz", "b.tar.gz"]);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { IDisposable, ILink, ILinkProvider, Terminal } from "@xterm/xterm";
|
||||
|
||||
import {
|
||||
buildArchiveExtractCommand,
|
||||
findTerminalArchiveTokens,
|
||||
resolveArchivePath,
|
||||
} from "../../../domain/terminalArchives";
|
||||
import type { LinkModifier } from "../../../domain/models/terminal";
|
||||
import {
|
||||
readPluginTerminalBufferText,
|
||||
type PluginTerminalBufferText,
|
||||
} from "../pluginTerminalBufferText";
|
||||
import type { TerminalCwdInspection, TerminalCwdMarkers } from "./terminalCwdMarkers";
|
||||
|
||||
const MAX_LOGICAL_LINE_CHARS = 8192;
|
||||
|
||||
export interface TerminalArchiveLinkHost {
|
||||
term: Terminal;
|
||||
cwdMarkers: TerminalCwdMarkers;
|
||||
isConnected(): boolean;
|
||||
isBroadcasting(): boolean;
|
||||
isHostEligible(): boolean;
|
||||
isInputReady(): boolean;
|
||||
linkModifier(): LinkModifier;
|
||||
}
|
||||
|
||||
interface LogicalSegment {
|
||||
lineNo: number;
|
||||
text: PluginTerminalBufferText;
|
||||
stringStart: number;
|
||||
}
|
||||
|
||||
interface LogicalLine {
|
||||
text: string;
|
||||
segments: LogicalSegment[];
|
||||
}
|
||||
|
||||
interface BufferCell {
|
||||
line: number;
|
||||
cell: number;
|
||||
}
|
||||
|
||||
function readLogicalLine(term: Terminal, oneBasedY: number): LogicalLine | null {
|
||||
const buffer = term.buffer.active;
|
||||
const index = oneBasedY - 1;
|
||||
if (!buffer.getLine(index)) return null;
|
||||
let start = index;
|
||||
while (start > 0 && buffer.getLine(start)?.isWrapped) start -= 1;
|
||||
|
||||
const segments: LogicalSegment[] = [];
|
||||
let total = 0;
|
||||
for (let lineNo = start; ; lineNo += 1) {
|
||||
const line = buffer.getLine(lineNo);
|
||||
if (!line) break;
|
||||
if (lineNo > start && !line.isWrapped) break;
|
||||
const next = buffer.getLine(lineNo + 1);
|
||||
const isLast = !next?.isWrapped;
|
||||
const text = readPluginTerminalBufferText(line, isLast);
|
||||
if (total + text.text.length > MAX_LOGICAL_LINE_CHARS) return null;
|
||||
segments.push({ lineNo, text, stringStart: total });
|
||||
total += text.text.length;
|
||||
if (isLast) break;
|
||||
}
|
||||
if (segments.length === 0) return null;
|
||||
return {
|
||||
text: segments.map((segment) => segment.text.text).join(""),
|
||||
segments,
|
||||
};
|
||||
}
|
||||
|
||||
function cellAt(
|
||||
segments: readonly LogicalSegment[],
|
||||
offset: number,
|
||||
bound: "inclusive" | "exclusive",
|
||||
): BufferCell | null {
|
||||
for (const segment of segments) {
|
||||
const local = offset - segment.stringStart;
|
||||
if (local < 0) return null;
|
||||
const length = segment.text.text.length;
|
||||
// A boundary offset is the next row's first cell for a token start, and the
|
||||
// previous row's exclusive end for a match that stops on that row.
|
||||
if (bound === "inclusive" ? local >= length : local > length) continue;
|
||||
const cell = segment.text.cellAtStringOffset[local];
|
||||
if (!Number.isInteger(cell)) return null;
|
||||
return { line: segment.lineNo, cell };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sameSpan(left: TerminalCwdInspection, right: TerminalCwdInspection): boolean {
|
||||
if (left.kind !== right.kind) return false;
|
||||
if (left.kind === "ambiguous") return false;
|
||||
if (left.kind === "outside") return true;
|
||||
if (left.kind === "open") return right.kind === "open" && left.host === right.host;
|
||||
return right.kind === "closed" && left.key === right.key;
|
||||
}
|
||||
|
||||
function modifierMatches(event: MouseEvent, modifier: LinkModifier): boolean {
|
||||
if (event.button !== 0) return false;
|
||||
if (modifier === "ctrl") return event.ctrlKey;
|
||||
if (modifier === "alt") return event.altKey;
|
||||
if (modifier === "meta") return event.metaKey;
|
||||
// "none" still requires Ctrl or Cmd. A configured modifier replaces that default.
|
||||
return event.ctrlKey || event.metaKey;
|
||||
}
|
||||
|
||||
function neighborNeedsSpace(term: Terminal, direction: -1 | 1): boolean {
|
||||
const buffer = term.buffer.active;
|
||||
let lineIndex = buffer.baseY + buffer.cursorY;
|
||||
let line = buffer.getLine(lineIndex);
|
||||
if (!line || typeof line.getCell !== "function") return false;
|
||||
let index = buffer.cursorX + (direction < 0 ? -1 : 0);
|
||||
for (let steps = 0; steps < term.cols + 1; steps += 1) {
|
||||
if (index < 0 || index >= term.cols) {
|
||||
if (direction < 0) {
|
||||
if (!line.isWrapped) return false;
|
||||
lineIndex -= 1;
|
||||
} else {
|
||||
lineIndex += 1;
|
||||
const next = buffer.getLine(lineIndex);
|
||||
if (!next?.isWrapped) return false;
|
||||
}
|
||||
line = buffer.getLine(lineIndex);
|
||||
if (!line || typeof line.getCell !== "function") return false;
|
||||
index = direction < 0 ? term.cols - 1 : 0;
|
||||
continue;
|
||||
}
|
||||
const cell = line.getCell(index);
|
||||
if (!cell) return false;
|
||||
if (cell.getWidth() === 0) {
|
||||
index += direction;
|
||||
continue;
|
||||
}
|
||||
return (cell.getChars() || " ") !== " ";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ctrl/Cmd-click archive names to paste an absolute extract command.
|
||||
* Directory labels come only from OSC 7 markers; input is never observed.
|
||||
*/
|
||||
export function registerTerminalArchiveLinks(options: TerminalArchiveLinkHost): IDisposable {
|
||||
let disposed = false;
|
||||
const { term, cwdMarkers } = options;
|
||||
|
||||
const sessionAllowsLinks = (): boolean => options.isConnected()
|
||||
&& !options.isBroadcasting()
|
||||
&& options.isHostEligible()
|
||||
&& options.isInputReady()
|
||||
&& term.buffer.active.type === "normal";
|
||||
|
||||
const cwdFor = (path: string, start: BufferCell, end: BufferCell): string | null | undefined => {
|
||||
const left = cwdMarkers.inspectCell(start.line, start.cell);
|
||||
const right = cwdMarkers.inspectCell(end.line, end.cell);
|
||||
if (!sameSpan(left, right) || left.kind === "ambiguous") return undefined;
|
||||
const absolute = path.startsWith("/");
|
||||
if (absolute) {
|
||||
if (left.kind === "outside") return null;
|
||||
const latest = cwdMarkers.latestHost();
|
||||
if (latest != null && left.host !== latest) return undefined;
|
||||
return null;
|
||||
}
|
||||
if (left.kind !== "closed" || !left.trusted) return undefined;
|
||||
const latest = cwdMarkers.latestHost();
|
||||
if (latest != null && left.host !== latest) return undefined;
|
||||
return left.cwd;
|
||||
};
|
||||
|
||||
const provider: ILinkProvider = {
|
||||
provideLinks(bufferLineNumber, callback) {
|
||||
if (disposed || !sessionAllowsLinks()) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
const logical = readLogicalLine(term, bufferLineNumber);
|
||||
if (!logical || logical.text.length === 0) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
const links: ILink[] = [];
|
||||
for (const token of findTerminalArchiveTokens(logical.text)) {
|
||||
const start = cellAt(logical.segments, token.start, "inclusive");
|
||||
const endExclusive = cellAt(logical.segments, token.start + token.length, "exclusive");
|
||||
const last = cellAt(logical.segments, token.start + token.length - 1, "inclusive");
|
||||
if (!start || !endExclusive || !last) continue;
|
||||
const cwd = cwdFor(token.path, start, last);
|
||||
if (cwd === undefined) continue;
|
||||
const abs = resolveArchivePath(token.path, cwd);
|
||||
const command = abs ? buildArchiveExtractCommand(abs) : null;
|
||||
if (!command) continue;
|
||||
const capturedText = logical.text;
|
||||
links.push({
|
||||
range: {
|
||||
start: { x: start.cell + 1, y: start.line + 1 },
|
||||
end: { x: endExclusive.cell, y: endExclusive.line + 1 },
|
||||
},
|
||||
text: token.text,
|
||||
decorations: { pointerCursor: true, underline: true },
|
||||
activate(event) {
|
||||
if (disposed || !modifierMatches(event, options.linkModifier())) return;
|
||||
if (!sessionAllowsLinks()) return;
|
||||
const fresh = readLogicalLine(term, start.line + 1);
|
||||
if (!fresh || fresh.text !== capturedText) return;
|
||||
const freshStart = cellAt(fresh.segments, token.start, "inclusive");
|
||||
const freshLast = cellAt(fresh.segments, token.start + token.length - 1, "inclusive");
|
||||
if (!freshStart || !freshLast) return;
|
||||
const freshCwd = cwdFor(token.path, freshStart, freshLast);
|
||||
if (freshCwd === undefined) return;
|
||||
const freshAbs = resolveArchivePath(token.path, freshCwd);
|
||||
const freshCommand = freshAbs ? buildArchiveExtractCommand(freshAbs) : null;
|
||||
if (!freshCommand || /[\r\n]/u.test(freshCommand)) return;
|
||||
const lead = neighborNeedsSpace(term, -1) ? " " : "";
|
||||
const trail = neighborNeedsSpace(term, 1) ? " " : "";
|
||||
term.paste(`${lead}${freshCommand}${trail}`);
|
||||
term.focus();
|
||||
},
|
||||
});
|
||||
}
|
||||
callback(links.length > 0 ? links : undefined);
|
||||
},
|
||||
};
|
||||
|
||||
const registration = term.registerLinkProvider(provider);
|
||||
return {
|
||||
dispose() {
|
||||
disposed = true;
|
||||
registration.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
const commandInjectionReadyReaders = new Map<string, () => boolean>();
|
||||
export interface TerminalCommandInjectionReadyOptions {
|
||||
/** Keep a typed draft. Sensitive prompts, alt-screen, and non-prompts still block. */
|
||||
allowDraft?: boolean;
|
||||
}
|
||||
|
||||
const commandInjectionReadyReaders = new Map<
|
||||
string,
|
||||
(options?: TerminalCommandInjectionReadyOptions) => boolean
|
||||
>();
|
||||
|
||||
export function registerTerminalCommandInjectionReadyReader(
|
||||
sessionId: string,
|
||||
reader: () => boolean,
|
||||
reader: (options?: TerminalCommandInjectionReadyOptions) => boolean,
|
||||
): () => void {
|
||||
commandInjectionReadyReaders.set(sessionId, reader);
|
||||
return () => {
|
||||
@@ -12,7 +20,10 @@ export function registerTerminalCommandInjectionReadyReader(
|
||||
};
|
||||
}
|
||||
|
||||
/** True when the live terminal reports an idle shell prompt ready for injection. */
|
||||
export function isTerminalReadyForCommandInjection(sessionId: string): boolean {
|
||||
return commandInjectionReadyReaders.get(sessionId)?.() === true;
|
||||
/** True when the live terminal reports a shell prompt ready for injection. */
|
||||
export function isTerminalReadyForCommandInjection(
|
||||
sessionId: string,
|
||||
options?: TerminalCommandInjectionReadyOptions,
|
||||
): boolean {
|
||||
return commandInjectionReadyReaders.get(sessionId)?.(options) === true;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createTerminalCwdMarkers } from "./terminalCwdMarkers.ts";
|
||||
|
||||
type FakeMarker = {
|
||||
line: number;
|
||||
isDisposed: boolean;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
function harness() {
|
||||
let cursorLine = 0;
|
||||
let cursorX = 0;
|
||||
let cols = 80;
|
||||
let type: "normal" | "alternate" = "normal";
|
||||
const created: FakeMarker[] = [];
|
||||
const term = {
|
||||
get cols() {
|
||||
return cols;
|
||||
},
|
||||
buffer: {
|
||||
active: {
|
||||
get type() {
|
||||
return type;
|
||||
},
|
||||
get cursorX() {
|
||||
return cursorX;
|
||||
},
|
||||
},
|
||||
},
|
||||
registerMarker(offset: number) {
|
||||
const marker: FakeMarker = {
|
||||
line: cursorLine + offset,
|
||||
isDisposed: false,
|
||||
dispose() {
|
||||
this.isDisposed = true;
|
||||
},
|
||||
};
|
||||
created.push(marker);
|
||||
return marker;
|
||||
},
|
||||
};
|
||||
const markers = createTerminalCwdMarkers(term as never);
|
||||
return {
|
||||
markers,
|
||||
created,
|
||||
setCursor(line: number, x = 0) {
|
||||
cursorLine = line;
|
||||
cursorX = x;
|
||||
},
|
||||
setCols(next: number) {
|
||||
cols = next;
|
||||
},
|
||||
setType(next: "normal" | "alternate") {
|
||||
type = next;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("prompt-only intervals are trusted only when both ends report the same cwd", () => {
|
||||
const same = harness();
|
||||
same.setCursor(0);
|
||||
same.markers.note({ cwd: "/a", host: "box" });
|
||||
same.setCursor(3);
|
||||
same.markers.note({ cwd: "/a", host: "box" });
|
||||
assert.deepEqual(same.markers.resolveCell(1, 0), { cwd: "/a", host: "box" });
|
||||
|
||||
const changed = harness();
|
||||
changed.setCursor(0);
|
||||
changed.markers.note({ cwd: "/a", host: "box" });
|
||||
changed.setCursor(3);
|
||||
changed.markers.note({ cwd: "/a/x", host: "box" });
|
||||
assert.equal(changed.markers.resolveCell(1, 0), null);
|
||||
|
||||
const thenCd = harness();
|
||||
thenCd.setCursor(0);
|
||||
thenCd.markers.note({ cwd: "/a", host: "box" });
|
||||
thenCd.setCursor(4);
|
||||
thenCd.markers.note({ cwd: "/a/x", host: "box" });
|
||||
assert.equal(thenCd.markers.resolveCell(2, 0), null);
|
||||
});
|
||||
|
||||
test("a chpwd emitter trusts the interval after the directory change", () => {
|
||||
const h = harness();
|
||||
h.setCursor(0);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
h.setCursor(2);
|
||||
h.markers.note({ cwd: "/a/x", host: "box" });
|
||||
h.setCursor(5);
|
||||
h.markers.note({ cwd: "/a/x", host: "box" });
|
||||
assert.equal(h.markers.resolveCell(1, 0), null);
|
||||
assert.deepEqual(h.markers.resolveCell(3, 0), { cwd: "/a/x", host: "box" });
|
||||
});
|
||||
|
||||
test("the open interval after the latest marker is not trusted", () => {
|
||||
const h = harness();
|
||||
h.setCursor(0);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
h.setCursor(2);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
assert.deepEqual(h.markers.resolveCell(1, 0), { cwd: "/a", host: "box" });
|
||||
assert.equal(h.markers.resolveCell(4, 0), null);
|
||||
});
|
||||
|
||||
test("a host mismatch between interval ends is not trusted and latestHost follows the last marker", () => {
|
||||
const h = harness();
|
||||
h.setCursor(0);
|
||||
h.markers.note({ cwd: "/a", host: "outer" });
|
||||
h.setCursor(2);
|
||||
h.markers.note({ cwd: "/a", host: "inner" });
|
||||
assert.equal(h.markers.resolveCell(1, 0), null);
|
||||
assert.equal(h.markers.latestHost(), "inner");
|
||||
});
|
||||
|
||||
test("a disposed start or end marker drops the interval", () => {
|
||||
const start = harness();
|
||||
start.setCursor(0);
|
||||
start.markers.note({ cwd: "/a", host: "box" });
|
||||
start.setCursor(2);
|
||||
start.markers.note({ cwd: "/a", host: "box" });
|
||||
start.created[0]?.dispose();
|
||||
assert.equal(start.markers.resolveCell(1, 0), null);
|
||||
|
||||
const end = harness();
|
||||
end.setCursor(0);
|
||||
end.markers.note({ cwd: "/a", host: "box" });
|
||||
end.setCursor(2);
|
||||
end.markers.note({ cwd: "/a", host: "box" });
|
||||
end.created[1]?.dispose();
|
||||
assert.equal(end.markers.resolveCell(1, 0), null);
|
||||
});
|
||||
|
||||
test("a new marker above existing ones drops the later markers", () => {
|
||||
const h = harness();
|
||||
h.setCursor(0);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
h.setCursor(5);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
h.setCursor(10);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
h.setCursor(3);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
assert.equal(h.created[1]?.isDisposed, true);
|
||||
assert.equal(h.created[2]?.isDisposed, true);
|
||||
assert.equal(h.created[0]?.isDisposed, false);
|
||||
assert.deepEqual(h.markers.resolveCell(1, 0), { cwd: "/a", host: "box" });
|
||||
assert.equal(h.markers.resolveCell(6, 0), null);
|
||||
});
|
||||
|
||||
test("cells left of a marker belong to the previous interval and a cols change makes that row ambiguous", () => {
|
||||
const split = harness();
|
||||
split.setCursor(0, 0);
|
||||
split.markers.note({ cwd: "/old", host: "box" });
|
||||
split.setCursor(5, 10);
|
||||
split.markers.note({ cwd: "/new", host: "box" });
|
||||
split.setCursor(9, 0);
|
||||
split.markers.note({ cwd: "/new", host: "box" });
|
||||
assert.equal(split.markers.resolveCell(5, 9), null);
|
||||
assert.deepEqual(split.markers.resolveCell(5, 10), { cwd: "/new", host: "box" });
|
||||
assert.deepEqual(split.markers.resolveCell(7, 0), { cwd: "/new", host: "box" });
|
||||
|
||||
const resized = harness();
|
||||
resized.setCursor(0, 0);
|
||||
resized.markers.note({ cwd: "/a", host: "box" });
|
||||
resized.setCursor(5, 4);
|
||||
resized.markers.note({ cwd: "/a", host: "box" });
|
||||
resized.setCursor(9, 0);
|
||||
resized.markers.note({ cwd: "/a", host: "box" });
|
||||
resized.setCols(100);
|
||||
assert.equal(resized.markers.resolveCell(5, 0), null);
|
||||
assert.equal(resized.markers.resolveCell(5, 4), null);
|
||||
assert.equal(resized.markers.resolveCell(0, 1), null);
|
||||
assert.deepEqual(resized.markers.resolveCell(3, 0), { cwd: "/a", host: "box" });
|
||||
});
|
||||
|
||||
test("the alternate buffer does not record cwd markers", () => {
|
||||
const h = harness();
|
||||
h.setType("alternate");
|
||||
h.setCursor(0);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
assert.equal(h.created.length, 0);
|
||||
assert.equal(h.markers.resolveCell(0, 0), null);
|
||||
assert.equal(h.markers.latestHost(), null);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
const MAX_CWD_MARKERS = 5000;
|
||||
|
||||
interface CwdMarker {
|
||||
readonly line: number;
|
||||
readonly isDisposed: boolean;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface TerminalCwdMarkerTerminal {
|
||||
readonly cols: number;
|
||||
readonly buffer: {
|
||||
readonly active: {
|
||||
readonly type: "normal" | "alternate";
|
||||
readonly cursorX: number;
|
||||
};
|
||||
};
|
||||
registerMarker(cursorYOffset: number): CwdMarker | undefined;
|
||||
}
|
||||
|
||||
interface CwdMarkerEntry {
|
||||
id: number;
|
||||
marker: CwdMarker;
|
||||
x: number;
|
||||
cols: number;
|
||||
cwd: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
export interface TerminalCwdCell {
|
||||
cwd: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
export type TerminalCwdInspection =
|
||||
| { kind: "outside" }
|
||||
| { kind: "ambiguous" }
|
||||
| { kind: "open"; host: string; cwd: string }
|
||||
| { kind: "closed"; host: string; cwd: string; trusted: boolean; key: string };
|
||||
|
||||
export interface TerminalCwdMarkers {
|
||||
note(report: { cwd: string; host: string }): void;
|
||||
resolveCell(row: number, col: number): TerminalCwdCell | null;
|
||||
inspectCell(row: number, col: number): TerminalCwdInspection;
|
||||
latestHost(): string | null;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* OSC 7 cwd labels. Output is trusted only between two markers that agree.
|
||||
* Nothing here watches keyboard or paste input.
|
||||
*/
|
||||
export function createTerminalCwdMarkers(term: TerminalCwdMarkerTerminal): TerminalCwdMarkers {
|
||||
let entries: CwdMarkerEntry[] = [];
|
||||
let nextId = 1;
|
||||
let disposed = false;
|
||||
|
||||
const liveEntries = (): CwdMarkerEntry[] => {
|
||||
if (entries.some((entry) => entry.marker.isDisposed)) {
|
||||
entries = entries.filter((entry) => !entry.marker.isDisposed);
|
||||
}
|
||||
for (let index = 1; index < entries.length; index += 1) {
|
||||
if (entries[index - 1].marker.line > entries[index].marker.line) {
|
||||
entries.sort((left, right) => left.marker.line - right.marker.line);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
|
||||
const relation = (
|
||||
row: number,
|
||||
col: number,
|
||||
entry: CwdMarkerEntry,
|
||||
): "before" | "atOrAfter" | "ambiguous" => {
|
||||
const line = entry.marker.line;
|
||||
if (row < line) return "before";
|
||||
if (row > line) return "atOrAfter";
|
||||
if (term.cols !== entry.cols) return "ambiguous";
|
||||
return col < entry.x ? "before" : "atOrAfter";
|
||||
};
|
||||
|
||||
const inspectCell = (row: number, col: number): TerminalCwdInspection => {
|
||||
const list = liveEntries();
|
||||
if (list.length === 0) return { kind: "outside" };
|
||||
let start: CwdMarkerEntry | null = null;
|
||||
for (const entry of list) {
|
||||
const where = relation(row, col, entry);
|
||||
if (where === "ambiguous") return { kind: "ambiguous" };
|
||||
if (where === "before") {
|
||||
if (!start) return { kind: "outside" };
|
||||
if (start.host !== entry.host) return { kind: "ambiguous" };
|
||||
return {
|
||||
kind: "closed",
|
||||
host: start.host,
|
||||
cwd: start.cwd,
|
||||
trusted: start.cwd === entry.cwd,
|
||||
key: `${start.id}:${entry.id}`,
|
||||
};
|
||||
}
|
||||
start = entry;
|
||||
}
|
||||
if (!start) return { kind: "outside" };
|
||||
return { kind: "open", host: start.host, cwd: start.cwd };
|
||||
};
|
||||
|
||||
return {
|
||||
note(report) {
|
||||
if (disposed || term.buffer.active.type !== "normal") return;
|
||||
const marker = term.registerMarker(0);
|
||||
if (!marker) return;
|
||||
const fresh: CwdMarkerEntry = {
|
||||
id: nextId,
|
||||
marker,
|
||||
x: term.buffer.active.cursorX,
|
||||
cols: term.cols,
|
||||
cwd: report.cwd,
|
||||
host: report.host,
|
||||
};
|
||||
nextId += 1;
|
||||
const kept: CwdMarkerEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.marker.isDisposed || entry.marker.line >= marker.line) {
|
||||
if (!entry.marker.isDisposed) entry.marker.dispose();
|
||||
continue;
|
||||
}
|
||||
kept.push(entry);
|
||||
}
|
||||
kept.push(fresh);
|
||||
while (kept.length > MAX_CWD_MARKERS) {
|
||||
const removed = kept.shift();
|
||||
if (removed && !removed.marker.isDisposed) removed.marker.dispose();
|
||||
}
|
||||
entries = kept;
|
||||
},
|
||||
inspectCell,
|
||||
resolveCell(row, col) {
|
||||
const inspection = inspectCell(row, col);
|
||||
if (inspection.kind !== "closed" || !inspection.trusted) return null;
|
||||
return { cwd: inspection.cwd, host: inspection.host };
|
||||
},
|
||||
latestHost() {
|
||||
const list = liveEntries();
|
||||
return list.length > 0 ? list[list.length - 1].host : null;
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
for (const entry of entries) {
|
||||
if (!entry.marker.isDisposed) entry.marker.dispose();
|
||||
}
|
||||
entries = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -214,6 +214,8 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
|
||||
|
||||
export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
|
||||
const archiveHostRef = useRef(host);
|
||||
archiveHostRef.current = host;
|
||||
const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host);
|
||||
const hibernateHiddenTabs = resolveTerminalHibernateEnabledForProtocol(
|
||||
terminalSettings,
|
||||
@@ -529,6 +531,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const runtime = createXTermRuntime({
|
||||
container: containerRef.current,
|
||||
host,
|
||||
getHost: () => archiveHostRef.current,
|
||||
fontFamilyId,
|
||||
resolvedFontFamily,
|
||||
fontSize,
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import test from "node:test";
|
||||
|
||||
import type { Host } from "./models";
|
||||
import {
|
||||
buildArchiveExtractCommand,
|
||||
findTerminalArchiveTokens,
|
||||
resolveArchivePath,
|
||||
supportsTerminalArchiveCommands,
|
||||
} from "./terminalArchives.ts";
|
||||
|
||||
const host = {
|
||||
id: "test",
|
||||
label: "Test",
|
||||
hostname: "host",
|
||||
username: "me",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
} as Host;
|
||||
|
||||
function commandFor(printed: string, cwd: string | null = "/work"): string | null {
|
||||
const token = findTerminalArchiveTokens(printed)[0];
|
||||
if (!token) return null;
|
||||
const abs = resolveArchivePath(token.path, cwd);
|
||||
if (!abs) return null;
|
||||
return buildArchiveExtractCommand(abs);
|
||||
}
|
||||
|
||||
function splitShell(shell: "sh" | "fish", command: string): string[] | null {
|
||||
if (shell === "fish") {
|
||||
const probe = spawnSync("fish", ["-c", "true"], { encoding: "utf8" });
|
||||
if (probe.error || probe.status !== 0) return null;
|
||||
const out = execFileSync(
|
||||
"fish",
|
||||
["-c", `set argv ${command}; printf '%s\\0' $argv`],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
return out.split("\0").slice(0, -1);
|
||||
}
|
||||
const out = execFileSync(
|
||||
"/bin/sh",
|
||||
["-c", `set -- ${command}; printf '%s\\0' "$@"`],
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
return out.split("\0").slice(0, -1);
|
||||
}
|
||||
|
||||
test("Linux archive commands exclude Windows, WSL, macOS, and non-shell protocols", () => {
|
||||
assert.equal(supportsTerminalArchiveCommands(host, "darwin"), false);
|
||||
assert.equal(supportsTerminalArchiveCommands(host, "win32"), false);
|
||||
assert.equal(supportsTerminalArchiveCommands({ ...host, distro: "ubuntu" }, "darwin"), true);
|
||||
assert.equal(supportsTerminalArchiveCommands({ ...host, osOverride: "linux" }, "darwin"), true);
|
||||
for (const osOverride of ["unknown", "windows", "macos", "freebsd"] as const) {
|
||||
assert.equal(
|
||||
supportsTerminalArchiveCommands({ ...host, osOverride, distro: "ubuntu" }, "linux"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert.equal(
|
||||
supportsTerminalArchiveCommands({ ...host, distro: "ubuntu", deviceType: "network" }, "linux"),
|
||||
false,
|
||||
);
|
||||
for (const protocol of ["telnet", "serial", "plugin:test"] as const) {
|
||||
assert.equal(
|
||||
supportsTerminalArchiveCommands({ ...host, protocol, osOverride: "linux" }, "linux"),
|
||||
false,
|
||||
);
|
||||
}
|
||||
assert.equal(supportsTerminalArchiveCommands({ ...host, protocol: "local" }, "darwin"), false);
|
||||
assert.equal(supportsTerminalArchiveCommands({ ...host, protocol: "local" }, "win32"), false);
|
||||
assert.equal(supportsTerminalArchiveCommands({ ...host, protocol: "local", os: "linux" }, "linux"), true);
|
||||
});
|
||||
|
||||
test("each agreed suffix maps to the right extraction command, including upper case", () => {
|
||||
const cases: Array<[string, string]> = [
|
||||
["a.tar", "tar -xvf '/work/a.tar' -C '/work'"],
|
||||
["b.tar.gz", "tar -xvzf '/work/b.tar.gz' -C '/work'"],
|
||||
["c.tgz", "tar -xvzf '/work/c.tgz' -C '/work'"],
|
||||
["d.tar.bz2", "tar -xvjf '/work/d.tar.bz2' -C '/work'"],
|
||||
["e.tbz2", "tar -xvjf '/work/e.tbz2' -C '/work'"],
|
||||
["f.tar.xz", "tar -xvJf '/work/f.tar.xz' -C '/work'"],
|
||||
["h.zip", "unzip '/work/h.zip' -d '/work'"],
|
||||
["A.TAR", "tar -xvf '/work/A.TAR' -C '/work'"],
|
||||
["B.TAR.GZ", "tar -xvzf '/work/B.TAR.GZ' -C '/work'"],
|
||||
["C.TGZ", "tar -xvzf '/work/C.TGZ' -C '/work'"],
|
||||
["D.TAR.BZ2", "tar -xvjf '/work/D.TAR.BZ2' -C '/work'"],
|
||||
["E.TBZ2", "tar -xvjf '/work/E.TBZ2' -C '/work'"],
|
||||
["F.TAR.XZ", "tar -xvJf '/work/F.TAR.XZ' -C '/work'"],
|
||||
["H.ZIP", "unzip '/work/H.ZIP' -d '/work'"],
|
||||
];
|
||||
for (const [name, expected] of cases) {
|
||||
assert.equal(commandFor(name), expected, name);
|
||||
assert.equal(/[\r\n]/u.test(expected), false);
|
||||
}
|
||||
for (const name of ["a.gz", "b.rar", "c.7z", "d.txz", "tool.exe", ".tar", ".zip", ".tar.gz"]) {
|
||||
assert.deepEqual(findTerminalArchiveTokens(name), [], name);
|
||||
}
|
||||
});
|
||||
|
||||
test("quoted, Unicode, and command-substitution names stay one literal argument", () => {
|
||||
const line = `"it's $(touch NO).tar.gz" 'my file.tar.gz' 文件.tar "it's.zip"`;
|
||||
const tokens = findTerminalArchiveTokens(line);
|
||||
assert.deepEqual(tokens.map((token) => token.path), [
|
||||
"it's $(touch NO).tar.gz",
|
||||
"my file.tar.gz",
|
||||
"文件.tar",
|
||||
"it's.zip",
|
||||
]);
|
||||
for (const token of tokens) {
|
||||
assert.equal(line.slice(token.start, token.start + token.length), token.text);
|
||||
const abs = resolveArchivePath(token.path, "/opt");
|
||||
assert.ok(abs);
|
||||
const command = buildArchiveExtractCommand(abs);
|
||||
assert.ok(command);
|
||||
assert.equal(/[\r\n]/u.test(command), false);
|
||||
for (const shell of ["sh", "fish"] as const) {
|
||||
const words = splitShell(shell, command);
|
||||
if (!words) continue;
|
||||
assert.ok(words.includes(abs), `${shell} ${command}`);
|
||||
assert.equal(words.filter((word) => word === abs).length, 1);
|
||||
}
|
||||
}
|
||||
const escaped = findTerminalArchiveTokens(String.raw`my\ file.tar.gz`);
|
||||
assert.equal(escaped.length, 1);
|
||||
assert.equal(escaped[0]?.path, "my file.tar.gz");
|
||||
assert.equal(escaped[0]?.text, String.raw`my\ file.tar.gz`);
|
||||
});
|
||||
|
||||
test("rejects home, urls, control characters, backslashes, history, zip globs, and open quotes", () => {
|
||||
for (const printed of [
|
||||
"~/a.tar.gz",
|
||||
"~user/a.tar",
|
||||
"http://example.com/a.zip",
|
||||
"user@h:/a.tar",
|
||||
"file\u001b.tar.gz",
|
||||
String.raw`fish\name.tar.gz`,
|
||||
"'fish\\name.tar.gz'",
|
||||
"hist!.tar.gz",
|
||||
"a*.zip",
|
||||
"a?.zip",
|
||||
"a[.zip",
|
||||
'"unfinished.tar.gz',
|
||||
]) {
|
||||
assert.deepEqual(findTerminalArchiveTokens(printed), [], printed);
|
||||
}
|
||||
assert.equal(findTerminalArchiveTokens("a*.tar.gz").length, 1);
|
||||
assert.equal(findTerminalArchiveTokens("“pack.zip”")[0]?.path, "pack.zip");
|
||||
assert.equal(findTerminalArchiveTokens("‘a.tar’")[0]?.path, "a.tar");
|
||||
});
|
||||
|
||||
test("resolveArchivePath normalizes against the interval cwd and keeps absolute paths", () => {
|
||||
assert.equal(resolveArchivePath("sub/../a.tar.gz", "/x"), "/x/a.tar.gz");
|
||||
assert.equal(resolveArchivePath("a.tar.gz", null), null);
|
||||
assert.equal(resolveArchivePath("/opt/a.tar.gz", null), "/opt/a.tar.gz");
|
||||
assert.equal(resolveArchivePath("/opt/a.tar.gz", "/elsewhere"), "/opt/a.tar.gz");
|
||||
assert.equal(buildArchiveExtractCommand("/a.tar"), "tar -xvf '/a.tar' -C '/'");
|
||||
assert.equal(
|
||||
buildArchiveExtractCommand("/abs/a.zip"),
|
||||
"unzip '/abs/a.zip' -d '/abs'",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { resolveHostOs } from "./host";
|
||||
import type { Host } from "./models";
|
||||
|
||||
const REMOTE_SHELL_PROTOCOLS = new Set(["ssh", "mosh", "et"]);
|
||||
|
||||
/**
|
||||
* Linux shells only. Windows, including WSL local shells (a win32 app hosting
|
||||
* a Linux guest), macOS, telnet, serial, plugin protocols, and network devices
|
||||
* are excluded.
|
||||
*/
|
||||
export function supportsTerminalArchiveCommands(host: Host, localPlatform: string): boolean {
|
||||
if (host.protocol === "local") return localPlatform === "linux";
|
||||
if (host.protocol && !REMOTE_SHELL_PROTOCOLS.has(host.protocol)) return false;
|
||||
if (host.deviceType === "network") return false;
|
||||
return resolveHostOs(host) === "linux";
|
||||
}
|
||||
|
||||
const ARCHIVE_SUFFIXES = [
|
||||
".tar.gz",
|
||||
".tar.bz2",
|
||||
".tar.xz",
|
||||
".tbz2",
|
||||
".tgz",
|
||||
".tar",
|
||||
".zip",
|
||||
] as const;
|
||||
|
||||
const OPEN_TO_CLOSE: Record<string, string> = {
|
||||
"'": "'",
|
||||
'"': '"',
|
||||
"\u201c": "\u201d",
|
||||
"\u2018": "\u2019",
|
||||
};
|
||||
|
||||
export interface TerminalArchiveToken {
|
||||
start: number;
|
||||
length: number;
|
||||
text: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function isWhitespace(char: string): boolean {
|
||||
return char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f" || char === "\v";
|
||||
}
|
||||
|
||||
function hasControlChar(value: string): boolean {
|
||||
for (const char of value) {
|
||||
const code = char.codePointAt(0) ?? 0;
|
||||
if (code < 32 || code === 127) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function archiveSuffix(name: string): string | null {
|
||||
const lower = name.toLowerCase();
|
||||
for (const suffix of ARCHIVE_SUFFIXES) {
|
||||
if (lower.endsWith(suffix) && name.length > suffix.length) return suffix;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rejectedArchivePath(path: string, suffix: string): boolean {
|
||||
if (!path || hasControlChar(path)) return true;
|
||||
if (path.startsWith("~")) return true;
|
||||
const slash = path.indexOf("/");
|
||||
const head = slash === -1 ? path : path.slice(0, slash);
|
||||
if (head.includes(":")) return true;
|
||||
if (path.includes("\\")) return true;
|
||||
if (path.includes("!")) return true;
|
||||
if (suffix === ".zip" && /[*?[]/u.test(path)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whitespace-delimited archive names, including one matched quote pair or escaped spaces. */
|
||||
export function findTerminalArchiveTokens(line: string): TerminalArchiveToken[] {
|
||||
const tokens: TerminalArchiveToken[] = [];
|
||||
let index = 0;
|
||||
while (index < line.length) {
|
||||
const char = line[index] ?? "";
|
||||
if (isWhitespace(char)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const closer = OPEN_TO_CLOSE[char];
|
||||
if (closer) {
|
||||
const end = line.indexOf(closer, index + 1);
|
||||
if (end < 0) {
|
||||
while (index < line.length && !isWhitespace(line[index] ?? "")) index += 1;
|
||||
continue;
|
||||
}
|
||||
const path = line.slice(index + 1, end);
|
||||
const text = line.slice(index, end + 1);
|
||||
const suffix = archiveSuffix(path);
|
||||
if (suffix && !rejectedArchivePath(path, suffix)) {
|
||||
tokens.push({ start: index, length: text.length, text, path });
|
||||
}
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let cursor = index;
|
||||
let path = "";
|
||||
while (cursor < line.length && !isWhitespace(line[cursor] ?? "")) {
|
||||
if (line[cursor] === "\\" && line[cursor + 1] === " ") {
|
||||
path += " ";
|
||||
cursor += 2;
|
||||
continue;
|
||||
}
|
||||
path += line[cursor] ?? "";
|
||||
cursor += 1;
|
||||
}
|
||||
const text = line.slice(index, cursor);
|
||||
const suffix = archiveSuffix(path);
|
||||
if (suffix && !rejectedArchivePath(path, suffix)) {
|
||||
tokens.push({ start: index, length: text.length, text, path });
|
||||
}
|
||||
index = cursor;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function posixNormalize(input: string): string {
|
||||
const absolute = input.startsWith("/");
|
||||
const parts: string[] = [];
|
||||
for (const part of input.split("/")) {
|
||||
if (part === "" || part === ".") continue;
|
||||
if (part === "..") {
|
||||
if (parts.length > 0) parts.pop();
|
||||
continue;
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
if (absolute) return parts.length > 0 ? `/${parts.join("/")}` : "/";
|
||||
return parts.length > 0 ? parts.join("/") : ".";
|
||||
}
|
||||
|
||||
function posixDirname(absPath: string): string {
|
||||
const normalized = posixNormalize(absPath);
|
||||
if (!normalized.startsWith("/") || normalized === "/") return "/";
|
||||
const index = normalized.lastIndexOf("/");
|
||||
if (index <= 0) return "/";
|
||||
return normalized.slice(0, index);
|
||||
}
|
||||
|
||||
/** Resolve a printed name against an OSC 7 cwd. Relative names without a cwd are unknown. */
|
||||
export function resolveArchivePath(filePath: string, cwd: string | null): string | null {
|
||||
if (!filePath) return null;
|
||||
if (filePath.startsWith("/")) return posixNormalize(filePath);
|
||||
if (!cwd || !cwd.startsWith("/")) return null;
|
||||
const normalized = posixNormalize(`${cwd}/${filePath}`);
|
||||
return normalized.startsWith("/") ? normalized : null;
|
||||
}
|
||||
|
||||
export function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/** Fill-only extract command. The archive is unpacked next to itself. */
|
||||
export function buildArchiveExtractCommand(absPath: string): string | null {
|
||||
const suffix = archiveSuffix(absPath);
|
||||
if (!suffix) return null;
|
||||
const quotedPath = shellQuote(posixNormalize(absPath));
|
||||
const quotedDir = shellQuote(posixDirname(absPath));
|
||||
switch (suffix) {
|
||||
case ".tar":
|
||||
return `tar -xvf ${quotedPath} -C ${quotedDir}`;
|
||||
case ".tar.gz":
|
||||
case ".tgz":
|
||||
return `tar -xvzf ${quotedPath} -C ${quotedDir}`;
|
||||
case ".tar.bz2":
|
||||
case ".tbz2":
|
||||
return `tar -xvjf ${quotedPath} -C ${quotedDir}`;
|
||||
case ".tar.xz":
|
||||
return `tar -xvJf ${quotedPath} -C ${quotedDir}`;
|
||||
case ".zip":
|
||||
return `unzip ${quotedPath} -d ${quotedDir}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isIdleShellReadyForCommandInjection } from "./terminalCommandInjectionReady.ts";
|
||||
import {
|
||||
isIdleShellReadyForCommandInjection,
|
||||
isShellPromptReadyForInsertion,
|
||||
} from "./terminalCommandInjectionReady.ts";
|
||||
|
||||
test("idle shell is ready only at a clean primary-screen prompt", () => {
|
||||
assert.equal(
|
||||
@@ -47,3 +50,28 @@ test("rejects sensitive prompts, alt-screen TUIs, partial input, and missing ter
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("draft insertion still blocks sensitive prompts, alt-screen, and a missing prompt", () => {
|
||||
const draft = {
|
||||
hasLiveTerminal: true,
|
||||
alternateScreenActive: false,
|
||||
isAtPrompt: true,
|
||||
userInputLength: 4,
|
||||
pendingTypedInputLength: 2,
|
||||
};
|
||||
assert.equal(isShellPromptReadyForInsertion(draft), true);
|
||||
assert.equal(isIdleShellReadyForCommandInjection(draft), false);
|
||||
assert.equal(
|
||||
isShellPromptReadyForInsertion({ ...draft, sensitiveInputActive: true }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isShellPromptReadyForInsertion({ ...draft, alternateScreenActive: true }),
|
||||
false,
|
||||
);
|
||||
assert.equal(isShellPromptReadyForInsertion({ ...draft, isAtPrompt: false }), false);
|
||||
assert.equal(
|
||||
isShellPromptReadyForInsertion({ ...draft, hasLiveTerminal: false }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -1,19 +1,30 @@
|
||||
/**
|
||||
* Whether an automated PTY write (e.g. SFTP "open path in terminal") may run
|
||||
* at a clean interactive shell prompt -- not mid-line, not in an editor/TUI.
|
||||
*/
|
||||
export function isIdleShellReadyForCommandInjection(options: {
|
||||
export interface ShellPromptReadiness {
|
||||
sensitiveInputActive?: boolean;
|
||||
hasLiveTerminal: boolean;
|
||||
alternateScreenActive: boolean;
|
||||
isAtPrompt: boolean;
|
||||
userInputLength: number;
|
||||
pendingTypedInputLength?: number;
|
||||
}): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt is showing on the primary screen and is not a password/TUI.
|
||||
* A typed draft is allowed; archive clicks insert at the cursor.
|
||||
*/
|
||||
export function isShellPromptReadyForInsertion(options: ShellPromptReadiness): boolean {
|
||||
if (options.sensitiveInputActive) return false;
|
||||
if (!options.hasLiveTerminal) return false;
|
||||
if (options.alternateScreenActive) return false;
|
||||
if (!options.isAtPrompt) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an automated PTY write (e.g. SFTP "open path in terminal") may run
|
||||
* at a clean interactive shell prompt -- not mid-line, not in an editor/TUI.
|
||||
*/
|
||||
export function isIdleShellReadyForCommandInjection(options: ShellPromptReadiness): boolean {
|
||||
if (!isShellPromptReadyForInsertion(options)) return false;
|
||||
if (options.userInputLength > 0) return false;
|
||||
if ((options.pendingTypedInputLength ?? 0) > 0) return false;
|
||||
return true;
|
||||
|
||||
Reference in new issue
Block a user