Files
nyaterm/src/lib/actionLinksAddon.ts
T
Kang 03a78245c4 feat(terminal): implement backend output pause/resume functionality (#105)
- Added support for pausing and resuming backend output in the terminal component.
- Introduced new state management for unacknowledged output characters and backend output pause status.
- Enhanced output handling to sync backpressure based on terminal visibility and queued output.
- Updated performance configuration to define high and low watermarks for output management.
- Implemented corresponding commands in the Tauri backend to control session output flow.
2026-06-14 12:05:52 +08:00

986 lines
28 KiB
TypeScript

import type {
IBufferCell,
IBufferLine,
IBufferRange,
IDecoration,
IDisposable,
ILink,
ILinkProvider,
IMarker,
ITerminalAddon,
Terminal,
} from "@xterm/xterm";
/* -------------------------------------------------------------------------- */
/* Types */
/* -------------------------------------------------------------------------- */
export type EntityKind = "url" | "ip" | "hostPort" | "archive" | "file" | "custom";
export type ExecutionTrigger =
| "plainClick"
| "ctrlOrMetaClick"
| "altClick"
| "menu"
| "programmatic";
export type ModifierKey = "ctrl" | "meta" | "alt" | "shift";
export interface BufferPosition {
x: number;
y: number;
}
export interface ViewportRangeLike {
start: BufferPosition;
end: BufferPosition;
}
export interface WindowedLineResult {
lines: string[];
startLineIndex: number;
text: string;
}
export interface MatchInput {
text: string;
startLineIndex: number;
terminal: Terminal;
viewportLine: number;
}
export interface MatchResult {
text: string;
startIndex: number;
endIndex: number;
kind?: EntityKind | string;
value?: string;
data?: Record<string, string>;
priority?: number;
}
export interface ActionContext {
matcherId: string;
matcherLabel: string;
kind: EntityKind | string;
text: string;
value: string;
data: Record<string, string>;
range: IBufferRange;
terminal: Terminal;
}
export interface ResolvedAction {
id: string;
label: string;
command: string;
isDefault?: boolean;
danger?: boolean;
hidden?: boolean;
}
export interface ActionDefinition {
id: string;
label: string;
isDefault?: boolean;
danger?: boolean;
buildCommand: (ctx: ActionContext) => string | null;
when?: (ctx: ActionContext) => boolean;
}
export interface ActionLink {
text: string;
range: IBufferRange;
ctx: ActionContext;
actions: ResolvedAction[];
}
/* -------------------------------------------------------------------------- */
/* Matcher Interface */
/* -------------------------------------------------------------------------- */
export interface ActionMatcher {
id: string;
label: string;
priority?: number;
match: (input: MatchInput) => MatchResult[];
getActions: (ctx: ActionContext) => ActionDefinition[];
getTooltip?: (ctx: ActionContext) => string;
prefilter?: (input: MatchInput) => boolean;
}
export interface RegexMatcherOptions {
id: string;
label: string;
regex: RegExp;
priority?: number;
kind?: EntityKind | string;
validate?: (text: string, match: RegExpExecArray) => boolean;
normalize?: (text: string, match: RegExpExecArray) => string;
mapData?: (text: string, match: RegExpExecArray) => Record<string, string>;
getActions: (ctx: ActionContext) => ActionDefinition[];
getTooltip?: (ctx: ActionContext) => string;
prefilter?: (input: MatchInput) => boolean;
}
/* -------------------------------------------------------------------------- */
/* Plugin options */
/* -------------------------------------------------------------------------- */
export interface TooltipShowArgs {
event: MouseEvent;
text: string;
range: IBufferRange;
link: ActionLink;
}
export interface MenuShowArgs {
event: MouseEvent;
text: string;
range: IBufferRange;
link: ActionLink;
actions: ResolvedAction[];
execute: (actionId: string) => void;
}
export interface ExecutionPolicy {
beforeExecute?: (
action: ResolvedAction,
ctx: ActionContext,
trigger: ExecutionTrigger,
) => boolean | Promise<boolean>;
transformCommand?: (command: string, ctx: ActionContext, trigger: ExecutionTrigger) => string;
resolveAliasCommand?: (command: string, ctx: ActionContext, trigger: ExecutionTrigger) => string;
onExecutionError?: (
error: unknown,
action: ResolvedAction,
ctx: ActionContext,
trigger: ExecutionTrigger,
) => void;
}
export interface ActionLinksAddonOptions {
sendInput?: (data: string) => void;
executeCommand?: (
command: string,
action: ResolvedAction,
ctx: ActionContext,
trigger: ExecutionTrigger,
) => void | Promise<void>;
showTooltip?: (args: TooltipShowArgs) => void;
hideTooltip?: () => void;
showMenu?: (args: MenuShowArgs) => void;
allowPlainClickExecute?: boolean;
allowCtrlOrMetaClickExecute?: boolean;
allowAltClickMenu?: boolean;
fallbackAltClickToDefaultAction?: boolean;
maxScanLength?: number;
policy?: ExecutionPolicy;
}
/* -------------------------------------------------------------------------- */
/* Built-in matcher option types */
/* -------------------------------------------------------------------------- */
export interface CommonMatcherOptions {
priority?: number;
label?: string;
tooltip?: (ctx: ActionContext) => string;
defaultAction?: string;
}
export interface IPv4MatcherOptions extends CommonMatcherOptions {
actions?: Array<"ping" | "traceroute" | "ssh" | "curl-http">;
}
export interface ArchiveMatcherOptions extends CommonMatcherOptions {
actions?: Array<"extract" | "list">;
}
export interface HostPortMatcherOptions extends CommonMatcherOptions {
actions?: Array<"curl-http" | "curl-https" | "nc" | "telnet">;
}
/* -------------------------------------------------------------------------- */
/* Helpers */
/* -------------------------------------------------------------------------- */
function resolveActions(matcher: ActionMatcher, ctx: ActionContext): ResolvedAction[] {
const defs = matcher.getActions(ctx);
return defs
.filter((d) => !d.when || d.when(ctx))
.map((d) => {
const command = d.buildCommand(ctx) ?? "";
return {
id: d.id,
label: d.label,
command,
isDefault: d.isDefault,
danger: d.danger,
hidden: !command,
};
})
.filter((a) => !a.hidden);
}
function buildStringToCellMap(
line: IBufferLine,
stringLength: number,
maxCols: number,
scratchCell: IBufferCell,
): number[] {
const map: number[] = [];
let col = 0;
let cellEndCol = 0;
while (col < maxCols && map.length < stringLength) {
const cell = line.getCell(col, scratchCell);
if (!cell) break;
const chars = cell.getChars();
const width = cell.getWidth();
const stride = width || 1;
if (chars.length === 0) {
map.push(col);
} else {
for (let i = 0; i < chars.length; i++) {
map.push(col);
}
}
cellEndCol = col + stride;
col += stride;
}
map.push(cellEndCol);
return map;
}
interface ParsedLine {
full: string;
offset: number;
lineText: string;
cellMap: number[] | null;
endY: number;
}
function readLogicalLineAtAbsoluteY(terminal: Terminal, absY: number): ParsedLine | null {
const buffer = terminal.buffer.active;
const line = buffer.getLine(absY);
if (!line) return null;
// Walk backwards to find the start of a wrapped group
let startY = absY;
while (startY > 0) {
const prev = buffer.getLine(startY - 1);
if (!prev?.isWrapped) break;
startY--;
}
let endY = absY;
while (endY + 1 < buffer.length) {
const next = buffer.getLine(endY + 1);
if (!next?.isWrapped) break;
endY++;
}
// Rebuild the full logical line from startY up through wrapped continuations
let full = "";
let offsetChars = 0;
let lineText = "";
let cellMap: number[] | null = null;
const scratchCell = buffer.getNullCell();
for (let y = startY; y <= endY; y++) {
const l = buffer.getLine(y);
if (!l) break;
const maxCols = Math.min(l.length, terminal.cols);
const text = l.translateToString(y === endY, 0, maxCols);
if (y < absY) {
offsetChars += text.length;
} else if (y === absY) {
lineText = text;
cellMap =
/[^\u0000-\u00FF]/.test(text) && text.length > 0
? buildStringToCellMap(l, text.length, maxCols, scratchCell)
: null;
}
full += text;
}
return { full, offset: offsetChars, lineText, cellMap, endY };
}
/* -------------------------------------------------------------------------- */
/* Addon */
/* -------------------------------------------------------------------------- */
export class ActionLinksAddon implements ITerminalAddon, ILinkProvider {
private _terminal: Terminal | null = null;
private _matchers: ActionMatcher[] = [];
private _options: ActionLinksAddonOptions;
private _disposables: IDisposable[] = [];
private _providerDisposable: IDisposable | null = null;
private _suspended = false;
// Link computation cache: logical-line text → ActionLink[]
private _cache = new Map<string, ActionLink[]>();
// Decoration layer: key → {deco, marker}, line → keys, scanned line set
private _decoCache = new Map<string, { deco: IDecoration; marker: IMarker }>();
private _lineToDecoKeys = new Map<number, string[]>();
private _scannedAbsLines = new Set<number>();
private _writeDecoTimer: ReturnType<typeof setTimeout> | null = null;
private _scrollThrottleTimer: ReturnType<typeof setTimeout> | null = null;
private _scrollThrottlePending = false;
private _lastViewportY = -1;
private _sentinelMarker: IMarker | null = null;
private _sentinelDisposable: IDisposable | null = null;
private _bufferTrimmed = false;
private static readonly OVERSCAN_LINES = 120;
private static readonly DECORATION_DEBOUNCE_MS = 50;
private static readonly DECORATION_THROTTLE_MS = 80;
private static readonly MAX_CACHE_ENTRIES = 2000;
private static readonly MAX_LOGICAL_LINE_SCAN_CHARS = 16 * 1024;
constructor(matchers: ActionMatcher[] = [], options: ActionLinksAddonOptions = {}) {
this._matchers = [...matchers].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
this._options = {
allowCtrlOrMetaClickExecute: true,
allowAltClickMenu: true,
fallbackAltClickToDefaultAction: true,
...options,
};
}
activate(terminal: Terminal): void {
this._terminal = terminal;
this._providerDisposable = terminal.registerLinkProvider(this);
this._disposables.push(
terminal.onWriteParsed(() => {
this._cache.clear();
this._scheduleWriteDecoRefresh();
}),
terminal.onResize(() => {
this._clearAllDecorations();
this._scheduleWriteDecoRefresh();
}),
terminal.onRender(() => {
const currentViewportY = terminal.buffer.active?.viewportY ?? 0;
if (currentViewportY !== this._lastViewportY) {
this._lastViewportY = currentViewportY;
this._scheduleScrollDecoRefresh();
}
}),
);
}
dispose(): void {
this._clearAllDecorations();
this._providerDisposable?.dispose();
for (const d of this._disposables) d.dispose();
this._disposables = [];
this._cache.clear();
this._terminal = null;
}
registerMatcher(matcher: ActionMatcher): IDisposable {
this._matchers.push(matcher);
this._matchers.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
this._cache.clear();
this._clearAllDecorations();
this._scheduleWriteDecoRefresh();
return { dispose: () => this.unregisterMatcher(matcher.id) };
}
unregisterMatcher(id: string): void {
this._matchers = this._matchers.filter((m) => m.id !== id);
this._cache.clear();
this._clearAllDecorations();
this._scheduleWriteDecoRefresh();
}
setMatchers(matchers: ActionMatcher[]): void {
this._matchers = [...matchers].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
this._cache.clear();
this._clearAllDecorations();
this._scheduleWriteDecoRefresh();
}
getMatchers(): readonly ActionMatcher[] {
return this._matchers;
}
setSuspended(suspended: boolean): void {
if (this._suspended === suspended) return;
this._suspended = suspended;
if (suspended) {
this._options.hideTooltip?.();
this._clearAllDecorations();
return;
}
this._cache.clear();
this._scheduleWriteDecoRefresh();
}
setOptions(options: Partial<ActionLinksAddonOptions>): void {
this._options = { ...this._options, ...options };
}
getOptions(): Readonly<ActionLinksAddonOptions> {
return this._options;
}
/* ILinkProvider */
provideLinks(bufferLineNumber: number, callback: (links: ILink[] | undefined) => void): void {
const terminal = this._terminal;
if (
!terminal ||
this._suspended ||
this._matchers.length === 0 ||
terminal.buffer.active.type === "alternate"
) {
callback(undefined);
return;
}
const parsed = this._getLogicalLineByBufferLine(terminal, bufferLineNumber);
if (!parsed) {
callback(undefined);
return;
}
if (parsed.full.length > ActionLinksAddon.MAX_LOGICAL_LINE_SCAN_CHARS) {
callback(undefined);
return;
}
const actionLinks = this._getCachedActionLinks(terminal, parsed.full, bufferLineNumber);
const ilinks: ILink[] = [];
for (const al of actionLinks) {
const ilink = this._actionLinkToILink(al, parsed, bufferLineNumber);
if (ilink) ilinks.push(ilink);
}
callback(ilinks.length > 0 ? ilinks : undefined);
}
/* ── Decoration layer ────────────────────────────────────────────────────── */
private _clearAllDecoTimers(): void {
if (this._writeDecoTimer) {
clearTimeout(this._writeDecoTimer);
this._writeDecoTimer = null;
}
if (this._scrollThrottleTimer) {
clearTimeout(this._scrollThrottleTimer);
this._scrollThrottleTimer = null;
}
this._scrollThrottlePending = false;
}
private _disposeSentinel(): void {
this._sentinelDisposable?.dispose();
this._sentinelMarker?.dispose();
this._sentinelDisposable = null;
this._sentinelMarker = null;
}
private _installSentinel(terminal: Terminal): void {
this._disposeSentinel();
const buffer = terminal.buffer.active;
if (!buffer || buffer.length === 0) return;
const cursorAbsY = buffer.baseY + buffer.cursorY;
const marker = terminal.registerMarker(-cursorAbsY);
if (!marker || marker.line < 0) return;
this._sentinelMarker = marker;
this._sentinelDisposable = marker.onDispose(() => {
this._bufferTrimmed = true;
this._sentinelMarker = null;
this._sentinelDisposable = null;
});
}
private _canRefreshDeco(): boolean {
return !this._suspended && this._matchers.length > 0;
}
/** Debounced refresh for write/resize/matcher-change events. */
private _scheduleWriteDecoRefresh(): void {
if (!this._canRefreshDeco()) return;
if (this._writeDecoTimer) clearTimeout(this._writeDecoTimer);
this._writeDecoTimer = setTimeout(() => {
this._writeDecoTimer = null;
const term = this._terminal;
if (term) this._refreshDecorations(term);
}, ActionLinksAddon.DECORATION_DEBOUNCE_MS);
}
/** Leading+trailing throttle for scroll events. */
private _scheduleScrollDecoRefresh(): void {
if (!this._canRefreshDeco()) return;
if (this._scrollThrottleTimer !== null) {
this._scrollThrottlePending = true;
return;
}
const term = this._terminal;
if (term) this._refreshDecorations(term);
this._scrollThrottleTimer = setTimeout(() => {
this._scrollThrottleTimer = null;
if (this._scrollThrottlePending) {
this._scrollThrottlePending = false;
this._scheduleScrollDecoRefresh();
}
}, ActionLinksAddon.DECORATION_THROTTLE_MS);
}
private _refreshDecorations(terminal: Terminal): void {
if (this._suspended || !terminal.buffer?.active || this._matchers.length === 0) return;
if (terminal.buffer.active.type === "alternate") {
this._clearAllDecorations();
return;
}
if (this._bufferTrimmed) {
this._clearAllDecorations();
}
if (!this._sentinelMarker) {
this._installSentinel(terminal);
}
const buffer = terminal.buffer.active;
const cursorAbsY = buffer.baseY + buffer.cursorY;
const rows = terminal.rows;
const viewportY = buffer.viewportY;
const totalLines = buffer.length;
const scanStart = Math.max(0, viewportY - ActionLinksAddon.OVERSCAN_LINES);
const scanEnd = Math.min(
totalLines - 1,
viewportY + rows - 1 + ActionLinksAddon.OVERSCAN_LINES,
);
const requiredKeys = new Set<string>();
for (let absLineY = scanStart; absLineY <= scanEnd; absLineY++) {
const line = buffer.getLine(absLineY);
if (!line) continue;
// Reuse memoized result for immutable scrollback lines
if (this._scannedAbsLines.has(absLineY)) {
for (const k of this._lineToDecoKeys.get(absLineY) ?? []) requiredKeys.add(k);
continue;
}
const parsed = this._getLogicalLineByAbsoluteLine(terminal, absLineY);
if (!parsed) {
if (absLineY < buffer.baseY) {
this._scannedAbsLines.add(absLineY);
}
continue;
}
if (parsed.full.length > ActionLinksAddon.MAX_LOGICAL_LINE_SCAN_CHARS) {
if (parsed.endY < buffer.baseY) {
this._scannedAbsLines.add(absLineY);
this._lineToDecoKeys.delete(absLineY);
}
continue;
}
const actionLinks = this._getCachedActionLinks(terminal, parsed.full, absLineY + 1);
const lineKeys: string[] = [];
for (const al of actionLinks) {
const span = this._getActionLinkCellSpan(al, parsed);
if (!span) continue;
const key = this._ensureDecoration(absLineY, span.cellStart, span.cellWidth, cursorAbsY);
if (key) {
lineKeys.push(key);
requiredKeys.add(key);
}
}
// Only memoize fully-scrollback logical lines. A wrapped logical line
// touching the live screen can still change through shell redraws.
if (parsed.endY < buffer.baseY) {
this._scannedAbsLines.add(absLineY);
if (lineKeys.length > 0) {
this._lineToDecoKeys.set(absLineY, lineKeys);
} else {
this._lineToDecoKeys.delete(absLineY);
}
}
}
// Evict decorations that scrolled out of the current viewport
const staleKeys: string[] = [];
for (const key of this._decoCache.keys()) {
if (!requiredKeys.has(key)) staleKeys.push(key);
}
for (const key of staleKeys) {
const entry = this._decoCache.get(key);
if (entry) {
this._decoCache.delete(key);
entry.deco.dispose();
entry.marker.dispose();
}
}
for (const absLineY of this._scannedAbsLines) {
if (absLineY < scanStart || absLineY > scanEnd) {
this._scannedAbsLines.delete(absLineY);
this._lineToDecoKeys.delete(absLineY);
}
}
}
private _ensureDecoration(
absLineY: number,
colStart: number,
width: number,
cursorAbsY: number,
): string | null {
if (width <= 0 || !this._terminal) return null;
const key = `${absLineY}:${colStart}:${width}`;
if (this._decoCache.has(key)) return key;
const marker = this._terminal.registerMarker(absLineY - cursorAbsY);
if (!marker) return null;
const deco = this._terminal.registerDecoration({
marker,
x: colStart,
width,
layer: "top",
});
if (!deco) {
marker.dispose();
return null;
}
deco.onRender((el) => {
el.style.borderBottom = "1px dashed rgba(100, 160, 255, 0.4)";
el.style.boxSizing = "border-box";
el.style.pointerEvents = "none";
});
deco.onDispose(() => {
this._decoCache.delete(key);
const keys = this._lineToDecoKeys.get(absLineY);
if (keys) {
const filtered = keys.filter((k) => k !== key);
if (filtered.length === 0) {
this._lineToDecoKeys.delete(absLineY);
this._scannedAbsLines.delete(absLineY);
} else {
this._lineToDecoKeys.set(absLineY, filtered);
}
}
});
this._decoCache.set(key, { deco, marker });
return key;
}
private _clearAllDecorations(): void {
this._clearAllDecoTimers();
const entries = [...this._decoCache.values()];
this._cache.clear();
this._decoCache.clear();
this._lineToDecoKeys.clear();
this._scannedAbsLines.clear();
this._lastViewportY = -1;
this._disposeSentinel();
this._bufferTrimmed = false;
for (const { deco, marker } of entries) {
deco.dispose();
marker.dispose();
}
}
/* ── Link provider internals ─────────────────────────────────────────────── */
private _getLogicalLineByAbsoluteLine(
terminal: Terminal,
absoluteLine: number,
): ParsedLine | null {
return readLogicalLineAtAbsoluteY(terminal, absoluteLine);
}
private _getLogicalLineByBufferLine(
terminal: Terminal,
bufferLineNumber: number,
): ParsedLine | null {
if (bufferLineNumber <= 0) return null;
return this._getLogicalLineByAbsoluteLine(terminal, bufferLineNumber - 1);
}
private _getLogicalLineByViewportLine(
terminal: Terminal,
viewportLine: number,
): ParsedLine | null {
const absoluteLine = terminal.buffer.active.viewportY + viewportLine;
return this._getLogicalLineByAbsoluteLine(terminal, absoluteLine);
}
private _getCachedActionLinks(
terminal: Terminal,
logicalText: string,
bufferLineNumber: number,
): ActionLink[] {
const cached = this._cache.get(logicalText);
if (cached) return cached;
const actionLinks = this._computeActionLinks(terminal, logicalText, bufferLineNumber);
this._cache.set(logicalText, actionLinks);
if (this._cache.size > ActionLinksAddon.MAX_CACHE_ENTRIES) {
const oldestKey = this._cache.keys().next().value;
if (oldestKey !== undefined) {
this._cache.delete(oldestKey);
}
}
return actionLinks;
}
private _computeActionLinks(
terminal: Terminal,
logicalText: string,
bufferLineNumber: number,
): ActionLink[] {
const viewportLine = bufferLineNumber - terminal.buffer.active.viewportY - 1;
const input: MatchInput = {
text: logicalText,
startLineIndex: 0,
terminal,
viewportLine,
};
const results: ActionLink[] = [];
const covered: Array<[number, number]> = [];
const sorted = [...this._matchers].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
for (const matcher of sorted) {
if (matcher.prefilter && !matcher.prefilter(input)) continue;
const matches = matcher.match(input);
for (const m of matches) {
const overlaps = covered.some(([s, e]) => m.startIndex < e && m.endIndex > s);
if (overlaps) continue;
covered.push([m.startIndex, m.endIndex]);
const placeholderRange: IBufferRange = {
start: { x: 1, y: Math.max(1, bufferLineNumber) },
end: { x: 1, y: Math.max(1, bufferLineNumber) },
};
const ctx: ActionContext = {
matcherId: matcher.id,
matcherLabel: matcher.label,
kind: m.kind ?? "custom",
text: m.text,
value: m.value ?? m.text,
data: m.data ?? {},
range: placeholderRange,
terminal,
};
const actions = resolveActions(matcher, ctx);
if (actions.length === 0) continue;
results.push({
text: m.text,
range: placeholderRange,
ctx: {
...ctx,
data: {
...ctx.data,
_startIndex: String(m.startIndex),
_endIndex: String(m.endIndex),
},
},
actions,
});
}
}
return results;
}
private _getActionLinkCellSpan(
al: ActionLink,
parsed: ParsedLine,
): { cellStart: number; cellWidth: number } | null {
const startIndex = Number(al.ctx.data._startIndex ?? "0");
const endIndex = Number(al.ctx.data._endIndex ?? "0");
const lineStart = parsed.offset;
const lineEnd = parsed.offset + parsed.lineText.length;
if (endIndex <= lineStart || startIndex >= lineEnd) return null;
const segmentStart = Math.max(startIndex - parsed.offset, 0);
const segmentEnd = Math.min(endIndex - parsed.offset, parsed.lineText.length);
if (segmentEnd <= segmentStart) return null;
const cellStart = parsed.cellMap
? (parsed.cellMap[segmentStart] ?? segmentStart)
: segmentStart;
const cellEnd = parsed.cellMap ? (parsed.cellMap[segmentEnd] ?? segmentEnd) : segmentEnd;
const cellWidth = cellEnd - cellStart;
if (cellWidth <= 0) return null;
return { cellStart, cellWidth };
}
private _actionLinkToILink(
al: ActionLink,
parsed: ParsedLine,
bufferLineNumber: number,
): ILink | null {
const span = this._getActionLinkCellSpan(al, parsed);
if (!span) return null;
const range: IBufferRange = {
start: { x: span.cellStart + 1, y: bufferLineNumber },
end: { x: span.cellStart + span.cellWidth, y: bufferLineNumber },
};
const ctxWithRange: ActionContext = { ...al.ctx, range };
const alWithRange: ActionLink = { ...al, range, ctx: ctxWithRange };
return {
range,
text: al.text,
decorations: { pointerCursor: true, underline: false },
activate: (event: MouseEvent) => {
this._handleActivate(event, alWithRange);
},
hover: (event: MouseEvent) => {
this._handleHover(event, alWithRange);
},
leave: () => {
this._options.hideTooltip?.();
},
};
}
private _handleHover(event: MouseEvent, al: ActionLink): void {
const { showTooltip } = this._options;
if (!showTooltip) return;
showTooltip({ event, text: al.text, range: al.range, link: al });
}
private async _handleActivate(event: MouseEvent, al: ActionLink): Promise<void> {
const {
allowCtrlOrMetaClickExecute,
allowAltClickMenu,
allowPlainClickExecute,
fallbackAltClickToDefaultAction,
showMenu,
} = this._options;
const isCtrlMeta = event.ctrlKey || event.metaKey;
const isAlt = event.altKey;
if (isAlt && allowAltClickMenu) {
if (showMenu) {
showMenu({
event,
text: al.text,
range: al.range,
link: al,
actions: al.actions,
execute: (actionId) => this._executeById(al, actionId, "menu"),
});
return;
}
if (fallbackAltClickToDefaultAction) {
await this._executeDefault(al, "altClick");
}
return;
}
if (isCtrlMeta && allowCtrlOrMetaClickExecute) {
await this._executeDefault(al, "ctrlOrMetaClick");
return;
}
if (!isCtrlMeta && !isAlt && allowPlainClickExecute) {
await this._executeDefault(al, "plainClick");
}
}
private async _executeDefault(al: ActionLink, trigger: ExecutionTrigger): Promise<void> {
const defaultAction = al.actions.find((a) => a.isDefault) ?? al.actions[0];
if (defaultAction) {
await this._executeById(al, defaultAction.id, trigger);
}
}
private async _executeById(
al: ActionLink,
actionId: string,
trigger: ExecutionTrigger,
): Promise<void> {
const action = al.actions.find((a) => a.id === actionId);
if (!action || !action.command) return;
const { policy, sendInput, executeCommand } = this._options;
try {
if (policy?.beforeExecute) {
const ok = await policy.beforeExecute(action, al.ctx, trigger);
if (!ok) return;
}
let cmd = action.command;
if (policy?.resolveAliasCommand) cmd = policy.resolveAliasCommand(cmd, al.ctx, trigger);
if (policy?.transformCommand) cmd = policy.transformCommand(cmd, al.ctx, trigger);
if (executeCommand) {
await executeCommand(cmd, action, al.ctx, trigger);
} else if (sendInput) {
sendInput(`${cmd}\r`);
}
} catch (err) {
policy?.onExecutionError?.(err, action, al.ctx, trigger);
}
}
async executeAction(
link: ActionLink,
actionId?: string,
trigger: ExecutionTrigger = "programmatic",
): Promise<void> {
if (actionId) {
await this._executeById(link, actionId, trigger);
} else {
await this._executeDefault(link, trigger);
}
}
computeLinksForLine(viewportLine: number): ActionLink[] {
const terminal = this._terminal;
if (!terminal) return [];
const parsed = this._getLogicalLineByViewportLine(terminal, viewportLine);
if (!parsed) return [];
return this._getCachedActionLinks(
terminal,
parsed.full,
terminal.buffer.active.viewportY + viewportLine + 1,
);
}
}