[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
187
components/terminal/CustomThemeEditor.tsx
Normal file
187
components/terminal/CustomThemeEditor.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Custom Theme Editor Panel
|
||||
* Inline color editor for creating/editing custom terminal themes.
|
||||
* Uses native <input type="color"> for zero-dependency color picking.
|
||||
*/
|
||||
|
||||
import React, { useCallback, memo } from 'react';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
|
||||
|
||||
interface ColorFieldDef {
|
||||
key: keyof TerminalTheme['colors'];
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
const GENERAL_COLORS: ColorFieldDef[] = [
|
||||
{ key: 'background', labelKey: 'terminal.customTheme.color.background' },
|
||||
{ key: 'foreground', labelKey: 'terminal.customTheme.color.foreground' },
|
||||
{ key: 'cursor', labelKey: 'terminal.customTheme.color.cursor' },
|
||||
{ key: 'selection', labelKey: 'terminal.customTheme.color.selection' },
|
||||
];
|
||||
|
||||
const NORMAL_COLORS: ColorFieldDef[] = [
|
||||
{ key: 'black', labelKey: 'terminal.customTheme.color.black' },
|
||||
{ key: 'red', labelKey: 'terminal.customTheme.color.red' },
|
||||
{ key: 'green', labelKey: 'terminal.customTheme.color.green' },
|
||||
{ key: 'yellow', labelKey: 'terminal.customTheme.color.yellow' },
|
||||
{ key: 'blue', labelKey: 'terminal.customTheme.color.blue' },
|
||||
{ key: 'magenta', labelKey: 'terminal.customTheme.color.magenta' },
|
||||
{ key: 'cyan', labelKey: 'terminal.customTheme.color.cyan' },
|
||||
{ key: 'white', labelKey: 'terminal.customTheme.color.white' },
|
||||
];
|
||||
|
||||
const BRIGHT_COLORS: ColorFieldDef[] = [
|
||||
{ key: 'brightBlack', labelKey: 'terminal.customTheme.color.brightBlack' },
|
||||
{ key: 'brightRed', labelKey: 'terminal.customTheme.color.brightRed' },
|
||||
{ key: 'brightGreen', labelKey: 'terminal.customTheme.color.brightGreen' },
|
||||
{ key: 'brightYellow', labelKey: 'terminal.customTheme.color.brightYellow' },
|
||||
{ key: 'brightBlue', labelKey: 'terminal.customTheme.color.brightBlue' },
|
||||
{ key: 'brightMagenta', labelKey: 'terminal.customTheme.color.brightMagenta' },
|
||||
{ key: 'brightCyan', labelKey: 'terminal.customTheme.color.brightCyan' },
|
||||
{ key: 'brightWhite', labelKey: 'terminal.customTheme.color.brightWhite' },
|
||||
];
|
||||
|
||||
const ColorInput = memo(({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) => {
|
||||
// Local state for text input — allows partial hex while typing
|
||||
const [textValue, setTextValue] = React.useState(value);
|
||||
// Sync external value changes into local state
|
||||
React.useEffect(() => { setTextValue(value); }, [value]);
|
||||
|
||||
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const v = e.target.value;
|
||||
if (!/^#[0-9a-fA-F]{0,6}$/.test(v)) return;
|
||||
setTextValue(v);
|
||||
// Only commit complete hex values (#rgb or #rrggbb)
|
||||
if (/^#[0-9a-fA-F]{3}$/.test(v) || /^#[0-9a-fA-F]{6}$/.test(v)) {
|
||||
// Normalize #rgb to #rrggbb
|
||||
const normalized = v.length === 4
|
||||
? `#${v[1]}${v[1]}${v[2]}${v[2]}${v[3]}${v[3]}`
|
||||
: v;
|
||||
onChange(normalized);
|
||||
}
|
||||
};
|
||||
|
||||
// On blur, revert to the last committed value if incomplete
|
||||
const handleBlur = () => { setTextValue(value); };
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-6 h-6 rounded cursor-pointer border border-border/50 p-0"
|
||||
style={{ appearance: 'none', WebkitAppearance: 'none', background: value }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground flex-1 truncate">{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={textValue}
|
||||
onChange={handleTextChange}
|
||||
onBlur={handleBlur}
|
||||
className="w-[68px] text-[10px] font-mono px-1.5 py-0.5 rounded border border-border bg-background text-foreground uppercase"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
ColorInput.displayName = 'ColorInput';
|
||||
|
||||
interface CustomThemeEditorProps {
|
||||
theme: TerminalTheme;
|
||||
onChange: (theme: TerminalTheme) => void;
|
||||
onBack?: () => void; // kept for API compat but no longer rendered
|
||||
isNew?: boolean;
|
||||
}
|
||||
|
||||
export const CustomThemeEditor: React.FC<CustomThemeEditorProps> = ({
|
||||
theme,
|
||||
onChange,
|
||||
onBack: _onBack,
|
||||
isNew: _isNew,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const updateColor = useCallback((key: keyof TerminalTheme['colors'], value: string) => {
|
||||
onChange({
|
||||
...theme,
|
||||
colors: { ...theme.colors, [key]: value },
|
||||
});
|
||||
}, [theme, onChange]);
|
||||
|
||||
const updateName = useCallback((name: string) => {
|
||||
onChange({ ...theme, name });
|
||||
}, [theme, onChange]);
|
||||
|
||||
const toggleType = useCallback(() => {
|
||||
onChange({ ...theme, type: theme.type === 'dark' ? 'light' : 'dark' });
|
||||
}, [theme, onChange]);
|
||||
|
||||
const renderColorGroup = (title: string, fields: ColorFieldDef[]) => (
|
||||
<div>
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mb-1.5 font-semibold">
|
||||
{title}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{fields.map(({ key, labelKey }) => (
|
||||
<ColorInput
|
||||
key={key}
|
||||
label={t(labelKey)}
|
||||
value={theme.colors[key]}
|
||||
onChange={(v) => updateColor(key, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Name + Type */}
|
||||
<div className="p-2 space-y-2 border-b border-border shrink-0">
|
||||
<div>
|
||||
<label className="text-[9px] uppercase tracking-wider text-muted-foreground font-semibold">
|
||||
{t('terminal.customTheme.name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={theme.name}
|
||||
onChange={(e) => updateName(e.target.value)}
|
||||
className="w-full mt-1 text-xs px-2 py-1.5 rounded border border-border bg-background text-foreground"
|
||||
placeholder={t('terminal.customTheme.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-[9px] uppercase tracking-wider text-muted-foreground font-semibold flex-1">
|
||||
{t('terminal.customTheme.type')}
|
||||
</label>
|
||||
<button
|
||||
onClick={toggleType}
|
||||
className="text-[10px] px-2 py-0.5 rounded border border-border bg-muted/30 text-foreground hover:bg-muted transition-colors capitalize"
|
||||
>
|
||||
{theme.type}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Color Groups */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-3">
|
||||
{renderColorGroup(t('terminal.customTheme.group.general'), GENERAL_COLORS)}
|
||||
{renderColorGroup(t('terminal.customTheme.group.normal'), NORMAL_COLORS)}
|
||||
{renderColorGroup(t('terminal.customTheme.group.bright'), BRIGHT_COLORS)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
232
components/terminal/CustomThemeModal.tsx
Normal file
232
components/terminal/CustomThemeModal.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Dedicated Custom Theme Editor Modal
|
||||
* Standalone modal with two-column layout: editor (left) + preview (right)
|
||||
* Opens on top of ThemeCustomizeModal for creating/editing custom themes.
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Trash2, X } from 'lucide-react';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { CustomThemeEditor } from './CustomThemeEditor';
|
||||
import { Button } from '../ui/button';
|
||||
import { isAppLockOverlayActive } from '../../infrastructure/appLockOverlayDom';
|
||||
|
||||
|
||||
interface CustomThemeModalProps {
|
||||
open: boolean;
|
||||
theme: TerminalTheme;
|
||||
isNew: boolean;
|
||||
onSave: (theme: TerminalTheme) => void;
|
||||
onDelete?: (themeId: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// Minimal terminal preview for the right panel
|
||||
const MiniPreview: React.FC<{ theme: TerminalTheme }> = ({ theme }) => (
|
||||
<div
|
||||
className="rounded-lg border border-border/50 overflow-hidden font-mono text-[11px] leading-relaxed flex-1"
|
||||
style={{ backgroundColor: theme.colors.background, color: theme.colors.foreground }}
|
||||
>
|
||||
{/* Title bar */}
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-black/20">
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-red-500/80" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-yellow-500/80" />
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-green-500/80" />
|
||||
<span className="flex-1 text-center text-[10px] opacity-50">Terminal Preview</span>
|
||||
</div>
|
||||
<div className="p-3 space-y-0.5">
|
||||
<div>
|
||||
<span style={{ color: theme.colors.green }}>user@server</span>
|
||||
<span style={{ color: theme.colors.foreground }}>:</span>
|
||||
<span style={{ color: theme.colors.blue }}>~</span>
|
||||
<span style={{ color: theme.colors.foreground }}>$ neofetch</span>
|
||||
</div>
|
||||
<div style={{ color: theme.colors.cyan }}>{' ,g$$P" """Y$$."". '}</div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.cyan }}>{` ,$$P' `}</span>
|
||||
<span style={{ color: theme.colors.blue }}>OS</span>
|
||||
<span>: Ubuntu 22.04 LTS</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.cyan }}>{` '',$$P `}</span>
|
||||
<span style={{ color: theme.colors.blue }}>Kernel</span>
|
||||
<span>: 5.15.0-generic</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.cyan }}>{` d$$' `}</span>
|
||||
<span style={{ color: theme.colors.blue }}>Shell</span>
|
||||
<span>: bash 5.1.16</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.cyan }}>{` $$P `}</span>
|
||||
<span style={{ color: theme.colors.blue }}>Memory</span>
|
||||
<span>: 4.2G / 16G (26%)</span>
|
||||
</div>
|
||||
<div> </div>
|
||||
{/* ANSI color palette */}
|
||||
<div className="flex gap-0.5">
|
||||
{[theme.colors.black, theme.colors.red, theme.colors.green, theme.colors.yellow,
|
||||
theme.colors.blue, theme.colors.magenta, theme.colors.cyan, theme.colors.white].map((c, i) => (
|
||||
<div key={i} className="w-3.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-0.5">
|
||||
{[theme.colors.brightBlack, theme.colors.brightRed, theme.colors.brightGreen, theme.colors.brightYellow,
|
||||
theme.colors.brightBlue, theme.colors.brightMagenta, theme.colors.brightCyan, theme.colors.brightWhite].map((c, i) => (
|
||||
<div key={i} className="w-3.5 h-2.5 rounded-sm" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
<div> </div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.green }}>user@server</span>
|
||||
<span>:</span>
|
||||
<span style={{ color: theme.colors.blue }}>~</span>
|
||||
<span>$ </span>
|
||||
<span style={{ backgroundColor: theme.colors.cursor, color: theme.colors.background }}> </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CustomThemeModal: React.FC<CustomThemeModalProps> = ({
|
||||
open,
|
||||
theme: initialTheme,
|
||||
isNew,
|
||||
onSave,
|
||||
onDelete,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [editingTheme, setEditingTheme] = useState<TerminalTheme>(initialTheme);
|
||||
|
||||
// Reset when opened with a new theme
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setEditingTheme({ ...initialTheme, colors: { ...initialTheme.colors } });
|
||||
}
|
||||
}, [open, initialTheme]);
|
||||
|
||||
const handleChange = useCallback((theme: TerminalTheme) => {
|
||||
setEditingTheme(theme);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(editingTheme);
|
||||
}, [editingTheme, onSave]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
onDelete?.(editingTheme.id);
|
||||
}, [editingTheme.id, onDelete]);
|
||||
|
||||
// Dummy back handler — in the standalone modal, back = cancel
|
||||
const handleBack = useCallback(() => {
|
||||
onCancel();
|
||||
}, [onCancel]);
|
||||
|
||||
const themeInfo = useMemo(() => {
|
||||
return `${editingTheme.name} • ${editingTheme.type.toUpperCase()}`;
|
||||
}, [editingTheme.name, editingTheme.type]);
|
||||
|
||||
// Handle Escape key — close child editor
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (isAppLockOverlayActive()) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown, true); // capture phase
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [open, onCancel]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 z-[300] flex items-center justify-center"
|
||||
>
|
||||
{/* Backdrop — clicking it dismisses the modal */}
|
||||
<div className="absolute inset-0 bg-black/60 supports-[backdrop-filter]:backdrop-blur-sm" onClick={onCancel} />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative z-10 bg-popover/95 supports-[backdrop-filter]:backdrop-blur-sm rounded-xl shadow-2xl border border-border/50 flex flex-col"
|
||||
style={{ width: 'min(820px, 90vw)', height: 'min(600px, 85vh)' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 shrink-0 border-b border-border">
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
{isNew ? t('terminal.customTheme.newTitle') : t('terminal.customTheme.editTitle')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body: Editor (left) + Preview (right) */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Left: Editor */}
|
||||
<div className="w-[300px] shrink-0 border-r border-border flex flex-col min-h-0">
|
||||
<CustomThemeEditor
|
||||
theme={editingTheme}
|
||||
onChange={handleChange}
|
||||
onBack={handleBack}
|
||||
isNew={isNew}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Preview */}
|
||||
<div className="flex-1 flex flex-col p-4 min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
|
||||
{t('terminal.themeModal.livePreview')}
|
||||
</div>
|
||||
<MiniPreview theme={editingTheme} />
|
||||
<div className="mt-2 text-xs text-muted-foreground text-center">
|
||||
{themeInfo}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-3 px-5 py-3 shrink-0 border-t border-border bg-muted/20">
|
||||
{/* Delete button (only for existing themes) */}
|
||||
{!isNew && onDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleDelete}
|
||||
className="h-9 text-destructive hover:text-destructive hover:bg-destructive/10 gap-1.5"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('terminal.customTheme.delete')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="h-9 px-5"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="h-9 px-6"
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(modalContent, document.body);
|
||||
};
|
||||
|
||||
export default CustomThemeModal;
|
||||
22
components/terminal/GhostSuggestionPolicy.test.ts
Normal file
22
components/terminal/GhostSuggestionPolicy.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { decideGhostSuggestion } from "./autocomplete/ghostSuggestionPolicy.ts";
|
||||
|
||||
test("keeps the active ghost suggestion while input still fits it", () => {
|
||||
const decision = decideGhostSuggestion("docker ps -a", "doc", "docker compose ls");
|
||||
|
||||
assert.deepEqual(decision, { type: "keep" });
|
||||
});
|
||||
|
||||
test("switches to a new suggestion once the active one no longer matches", () => {
|
||||
const decision = decideGhostSuggestion("docker ps -a", "dog", "dogstatsd");
|
||||
|
||||
assert.deepEqual(decision, { type: "show", suggestion: "dogstatsd" });
|
||||
});
|
||||
|
||||
test("hides the ghost when neither the active nor next suggestion matches", () => {
|
||||
const decision = decideGhostSuggestion("docker ps -a", "dog", null);
|
||||
|
||||
assert.deepEqual(decision, { type: "hide" });
|
||||
});
|
||||
918
components/terminal/GhostTextAddon.test.ts
Normal file
918
components/terminal/GhostTextAddon.test.ts
Normal file
@@ -0,0 +1,918 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { GhostTextAddon } from "./autocomplete/GhostTextAddon.ts";
|
||||
|
||||
type RenderListener = () => void;
|
||||
type ResizeListener = () => void;
|
||||
|
||||
class FakeElement {
|
||||
public readonly style: Record<string, string> = {};
|
||||
public textContent = "";
|
||||
public className = "";
|
||||
public children: FakeElement[] = [];
|
||||
|
||||
appendChild(child: FakeElement): FakeElement {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
insertBefore(child: FakeElement, referenceNode: FakeElement | null): FakeElement {
|
||||
if (!referenceNode) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
const index = this.children.indexOf(referenceNode);
|
||||
if (index < 0) {
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
this.children.splice(index, 0, child);
|
||||
return child;
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
// No-op for tests.
|
||||
}
|
||||
|
||||
querySelector(selector: string): FakeElement | null {
|
||||
if (selector === ".xterm-screen") {
|
||||
return this.children.find((child) => child.className === "xterm-screen") ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function installFakeDocument(): () => void {
|
||||
const previousDocument = globalThis.document;
|
||||
const fakeDocument = {
|
||||
createElement() {
|
||||
return new FakeElement();
|
||||
},
|
||||
} as unknown as Document;
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: fakeDocument,
|
||||
});
|
||||
return () => {
|
||||
if (previousDocument === undefined) {
|
||||
delete (globalThis as { document?: Document }).document;
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: previousDocument,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function createFakeTerm() {
|
||||
const renderListeners: RenderListener[] = [];
|
||||
const resizeListeners: ResizeListener[] = [];
|
||||
const element = new FakeElement();
|
||||
const screen = new FakeElement();
|
||||
screen.className = "xterm-screen";
|
||||
element.appendChild(screen);
|
||||
|
||||
const term = {
|
||||
element,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
options: {
|
||||
fontSize: 14,
|
||||
fontFamily: "monospace",
|
||||
},
|
||||
buffer: {
|
||||
active: {
|
||||
cursorX: 2,
|
||||
cursorY: 0,
|
||||
},
|
||||
},
|
||||
_core: {
|
||||
_renderService: {
|
||||
dimensions: {
|
||||
css: {
|
||||
cell: {
|
||||
width: 9,
|
||||
height: 18,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
onRender(listener: RenderListener) {
|
||||
renderListeners.push(listener);
|
||||
return {
|
||||
dispose() {
|
||||
const index = renderListeners.indexOf(listener);
|
||||
if (index >= 0) renderListeners.splice(index, 1);
|
||||
},
|
||||
};
|
||||
},
|
||||
onResize(listener: ResizeListener) {
|
||||
resizeListeners.push(listener);
|
||||
return {
|
||||
dispose() {
|
||||
const index = resizeListeners.indexOf(listener);
|
||||
if (index >= 0) resizeListeners.splice(index, 1);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
term,
|
||||
ghostElement: () => screen.children[0]?.children[0] ?? null,
|
||||
fireRender() {
|
||||
for (const listener of [...renderListeners]) listener();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("shifts ghost to predicted cursor column as matching input is typed", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("docker", "do");
|
||||
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.style.display, "block");
|
||||
assert.equal(ghost.textContent, "cker");
|
||||
// show() anchored at cursorX=2, cell width=9 → left=18.
|
||||
assert.equal(ghost.style.left, "18px");
|
||||
|
||||
addon.adjustToInput("doc");
|
||||
|
||||
// After one matching char, the ghost predicts the cursor has moved
|
||||
// to column 3 and trims "c" from the tail so the next char starts
|
||||
// where the echo will land. Not waiting for xterm's render keeps
|
||||
// ghost + real input aligned across SSH echo latency.
|
||||
assert.equal(ghost.style.display, "block");
|
||||
assert.equal(ghost.textContent, "ker");
|
||||
assert.equal(ghost.style.left, "27px");
|
||||
assert.equal(addon.getGhostText(), "ker");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("walks the anchor column backwards on backspace so the ghost re-aligns", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("docker", "do");
|
||||
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
|
||||
addon.adjustToInput("doc");
|
||||
assert.equal(ghost.textContent, "ker");
|
||||
assert.equal(ghost.style.left, "27px");
|
||||
|
||||
// Backspace below the anchor input — the ghost should shift *left*,
|
||||
// not stay pinned at the show-time anchor column. Pinning would
|
||||
// leave a visual gap between the real cursor and the ghost.
|
||||
addon.adjustToInput("d");
|
||||
assert.equal(ghost.textContent, "ocker");
|
||||
// anchor was cursorX=2 captured at show(); "d" is 1 char below
|
||||
// anchorInputLength=2 → predicted cursor column = 1.
|
||||
assert.equal(ghost.style.left, "9px");
|
||||
|
||||
// Backspace past the anchor back to empty: left is clamped at 0.
|
||||
addon.adjustToInput("");
|
||||
assert.equal(ghost.textContent, "docker");
|
||||
assert.equal(ghost.style.left, "0px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("advances the anchor by two cells when a CJK glyph is typed", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
// Suggestion starts with a CJK char so the prefix-match survives
|
||||
// the next keystroke.
|
||||
addon.show("你好世界", "");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
// show() anchored at cursorX=2. Input length 0 → delta 0 → left=18.
|
||||
assert.equal(ghost.style.left, "18px");
|
||||
|
||||
addon.adjustToInput("你");
|
||||
|
||||
// One CJK char = 2 cells. Predicted col = 2 + 2 = 4 → left 36px.
|
||||
assert.equal(ghost.textContent, "好世界");
|
||||
assert.equal(ghost.style.left, "36px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("wraps the ghost to the next row when the predicted column crosses cols", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
// Shrink the terminal to 10 cols to keep the math obvious. Anchor at
|
||||
// col 8 with 5 ASCII chars to type → predicted col = 13, which should
|
||||
// wrap to col 3 of row 1.
|
||||
term.cols = 10;
|
||||
term.buffer.active.cursorX = 8;
|
||||
addon.activate(term as never);
|
||||
addon.show("abcdefghij", "ab");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.style.top, "0px");
|
||||
|
||||
addon.adjustToInput("abcde");
|
||||
|
||||
// Predicted col = 8 + (5-2) = 11 → wraps to col 1 on next row.
|
||||
// cellWidth=9, cellHeight=18.
|
||||
assert.equal(ghost.textContent, "fghij");
|
||||
assert.equal(ghost.style.left, "9px");
|
||||
assert.equal(ghost.style.top, "18px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("self-heals a stale anchor on render while no adjustToInput has fired", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
// show() captures cursorX=2 — simulate this firing during the
|
||||
// keystroke→echo gap by later advancing the live cursor and
|
||||
// verifying the ghost anchor snaps to the echoed position.
|
||||
addon.show("docker", "do");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.style.left, "18px");
|
||||
|
||||
term.buffer.active.cursorX = 5;
|
||||
fireRender();
|
||||
|
||||
// Input hasn't moved from the show-time baseline, so updatePosition
|
||||
// re-reads live cursor: new left = 5 * 9 = 45px.
|
||||
assert.equal(ghost.style.left, "45px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("self-heal adopts live X/Y when echo wraps instead of double-counting", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
// Prompt ends at col 8; four pending cells predict X=12 on a 10-col
|
||||
// terminal (wraps to col 2 / row 1). When echo lands there, Math.max
|
||||
// on X alone would keep 12 and paint the ghost on row 2.
|
||||
term.cols = 10;
|
||||
term.buffer.active.cursorX = 8;
|
||||
term.buffer.active.cursorY = 0;
|
||||
term.buffer.active.baseY = 0;
|
||||
term.buffer.active.getLine = () => ({
|
||||
translateToString: () => "$ ",
|
||||
});
|
||||
addon.activate(term as never);
|
||||
addon.show("abcdefghij", "abcd");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
// Predicted wrap: col 2 on row 1 → left 18px, top 18px.
|
||||
assert.equal(ghost.style.left, "18px");
|
||||
assert.equal(ghost.style.top, "18px");
|
||||
|
||||
term.buffer.active.cursorX = 2;
|
||||
term.buffer.active.cursorY = 1;
|
||||
term.buffer.active.getLine = () => ({
|
||||
isWrapped: true,
|
||||
translateToString: () => "abcd",
|
||||
});
|
||||
fireRender();
|
||||
|
||||
assert.equal(ghost.style.left, "18px");
|
||||
assert.equal(ghost.style.top, "18px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("self-heal adopts live X when a bottom-row wrap scrolls instead of changing Y", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
// Bottom-row prediction: prompt at col 8 + 4 pending cells → X=12.
|
||||
// Echo scrolls the buffer so Y stays on the last row while live X
|
||||
// becomes the normalized wrap column (2). Math.max would keep 12 and
|
||||
// paint the ghost one row below the visible screen.
|
||||
term.cols = 10;
|
||||
term.rows = 24;
|
||||
term.buffer.active.cursorX = 8;
|
||||
term.buffer.active.cursorY = 23;
|
||||
term.buffer.active.baseY = 0;
|
||||
term.buffer.active.getLine = () => ({
|
||||
translateToString: () => "$ ",
|
||||
});
|
||||
addon.activate(term as never);
|
||||
addon.show("abcdefghij", "abcd");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
// Pre-echo: col 2 on predicted row 24 → top 24*18.
|
||||
assert.equal(ghost.style.left, "18px");
|
||||
assert.equal(ghost.style.top, "432px");
|
||||
|
||||
term.buffer.active.cursorX = 2;
|
||||
term.buffer.active.cursorY = 23;
|
||||
term.buffer.active.getLine = () => ({
|
||||
isWrapped: true,
|
||||
translateToString: () => "abcd",
|
||||
});
|
||||
fireRender();
|
||||
|
||||
assert.equal(ghost.style.left, "18px");
|
||||
assert.equal(ghost.style.top, "414px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("anchors ghost after wide pre-echo input when the line has not echoed yet", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
term.buffer.active.baseY = 0;
|
||||
term.buffer.active.getLine = () => ({
|
||||
translateToString: () => "$ ",
|
||||
});
|
||||
addon.activate(term as never);
|
||||
// Live cursor still at the prompt; typed "部署" is only in the keystroke buffer.
|
||||
addon.show("部署脚本", "部署");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.textContent, "脚本");
|
||||
// cursorX=2 + 4 wide cells → column 6 → left 54px.
|
||||
assert.equal(ghost.style.left, "54px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("anchors ghost using only the unechoed suffix after a partial shell echo", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
// Shell has echoed "$ doc"; buffered input is still the full "docker".
|
||||
term.buffer.active.cursorX = 5;
|
||||
term.buffer.active.baseY = 0;
|
||||
term.buffer.active.getLine = () => ({
|
||||
translateToString: () => "$ doc",
|
||||
});
|
||||
addon.activate(term as never);
|
||||
addon.show("docker compose", "docker");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.textContent, " compose");
|
||||
// Unechoed "ker" is 3 cells → column 8 → left 72px (not 5+6=11).
|
||||
assert.equal(ghost.style.left, "72px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("anchors ghost by cell columns when the prompt is a multi-code-unit emoji", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
// Family ZWJ emoji is many UTF-16 units but only 2 terminal cells.
|
||||
// A UTF-16 slice(0, cursorX) would stop inside the emoji and treat the
|
||||
// fully-echoed "docker" as unechoed, shifting the ghost right.
|
||||
const emoji = "👨👩👧👦";
|
||||
const cells: Array<{ chars: string; width: number }> = [
|
||||
{ chars: emoji, width: 2 },
|
||||
{ chars: " ", width: 1 },
|
||||
{ chars: "$", width: 1 },
|
||||
{ chars: " ", width: 1 },
|
||||
...Array.from("docker", (ch) => ({ chars: ch, width: 1 })),
|
||||
];
|
||||
const totalCols = cells.reduce((sum, cell) => sum + cell.width, 0);
|
||||
term.buffer.active.cursorX = totalCols;
|
||||
term.buffer.active.baseY = 0;
|
||||
term.buffer.active.getLine = () => ({
|
||||
translateToString: (
|
||||
_trimRight?: boolean,
|
||||
startColumn = 0,
|
||||
endColumn = totalCols,
|
||||
) => {
|
||||
let col = 0;
|
||||
let text = "";
|
||||
for (const cell of cells) {
|
||||
if (col >= endColumn) break;
|
||||
if (col >= startColumn) text += cell.chars;
|
||||
col += cell.width;
|
||||
}
|
||||
return text;
|
||||
},
|
||||
});
|
||||
addon.activate(term as never);
|
||||
addon.show("docker compose", "docker");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.textContent, " compose");
|
||||
// Fully echoed → stay at live cursor (11 cells → 99px), not 11+6.
|
||||
assert.equal(ghost.style.left, "99px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not overshoot the ghost when the echoed command already wrapped", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
// cols=10, prompt "$ " + "docker com" wraps; cursor sits on the
|
||||
// continuation row after a fully-echoed "docker compose".
|
||||
// Physical rows: "$ docker c" | "ompose|"
|
||||
term.cols = 10;
|
||||
term.buffer.active.baseY = 0;
|
||||
term.buffer.active.cursorY = 1;
|
||||
term.buffer.active.cursorX = 6;
|
||||
const lines: Record<number, { isWrapped?: boolean; text: string }> = {
|
||||
0: { text: "$ docker c" },
|
||||
1: { isWrapped: true, text: "ompose" },
|
||||
};
|
||||
term.buffer.active.getLine = (y: number) => {
|
||||
const row = lines[y];
|
||||
if (!row) return undefined;
|
||||
return {
|
||||
isWrapped: row.isWrapped,
|
||||
translateToString: (
|
||||
_trimRight?: boolean,
|
||||
startColumn?: number,
|
||||
endColumn?: number,
|
||||
) => {
|
||||
if (startColumn !== undefined && endColumn !== undefined) {
|
||||
return row.text.padEnd(endColumn).slice(startColumn, endColumn);
|
||||
}
|
||||
return row.text;
|
||||
},
|
||||
};
|
||||
};
|
||||
addon.activate(term as never);
|
||||
addon.show("docker compose up", "docker compose");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.textContent, " up");
|
||||
// Fully echoed across the wrap → anchor stays at live cursor (col 6),
|
||||
// not liveX + full input width.
|
||||
assert.equal(ghost.style.left, "54px");
|
||||
assert.equal(ghost.style.top, "18px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("wraps the ghost to the previous row when deletion crosses a row boundary", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
term.cols = 10;
|
||||
term.buffer.active.cursorX = 1;
|
||||
term.buffer.active.cursorY = 1;
|
||||
addon.activate(term as never);
|
||||
// Anchored at row 1 col 1 with 5 chars already typed.
|
||||
addon.show("abcdefghij", "abcde");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
|
||||
// Backspace back to 2 chars — delta = -3 across a row boundary.
|
||||
addon.adjustToInput("ab");
|
||||
|
||||
// targetCol = 1 - 3 = -2 → col = 8 (wrapped) on row 0.
|
||||
assert.equal(ghost.textContent, "cdefghij");
|
||||
assert.equal(ghost.style.left, "72px");
|
||||
assert.equal(ghost.style.top, "0px");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides ghost immediately when input no longer matches suggestion", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("docker", "do");
|
||||
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.style.display, "block");
|
||||
|
||||
addon.adjustToInput("dox");
|
||||
|
||||
assert.equal(ghost.style.display, "none");
|
||||
assert.equal(ghost.textContent, "");
|
||||
assert.equal(addon.isActive(), false);
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("applyKeystroke: printable char trims ghost tail when buffer is unreliable (issue #906)", () => {
|
||||
// Repro for issue #906: after Tab passes to shell and the typed-buffer
|
||||
// is flagged unreliable, the ghost addon's currentInput is the only
|
||||
// source of truth for what the user has typed since the last show().
|
||||
// Without applyKeystroke, line 798's reliability gate prevents
|
||||
// adjustToInput from firing and the ghost retains its show-time tail
|
||||
// — when the next keystroke advances the cursor, the stale tail
|
||||
// overlaps the just-typed glyph (e.g., typing 't' after 'systemctl s'
|
||||
// makes the screen read 'systemctl sttop firewalld').
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("systemctl stop firewalld", "systemctl s");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.textContent, "top firewalld");
|
||||
|
||||
addon.applyKeystroke("t");
|
||||
|
||||
// Ghost tail must shrink by exactly one char so when the shell
|
||||
// echoes 't', the next visible glyph after the cursor is 'o', not
|
||||
// 't' (which would render as 'sttop').
|
||||
assert.equal(ghost.textContent, "op firewalld");
|
||||
assert.equal(addon.isActive(), true);
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("applyKeystroke: backspace re-grows ghost tail by one char", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("docker", "doc");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.textContent, "ker");
|
||||
|
||||
addon.applyKeystroke("\x7f");
|
||||
|
||||
assert.equal(ghost.textContent, "cker");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("applyKeystroke: Ctrl+W word-erases trailing word from currentInput", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
// Mid-suggestion: user has typed two words; Ctrl+W should drop the
|
||||
// tail word and let the ghost regrow to cover what was erased.
|
||||
addon.show("git commit -m wip", "git com");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
assert.equal(ghost.textContent, "mit -m wip");
|
||||
|
||||
addon.applyKeystroke("\x17");
|
||||
|
||||
// The same /\s*\S+\s*$/ regex used by handleInput consumes the
|
||||
// leading whitespace too, so "git com" → "git"; the ghost regrows
|
||||
// to cover the now-uncovered leading space + remainder.
|
||||
assert.equal(ghost.textContent, " commit -m wip");
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("applyKeystroke: hides ghost when next char diverges from suggestion", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("docker", "do");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
|
||||
// 'x' breaks the prefix invariant — ghost must hide immediately so
|
||||
// a → -accept after this point can't pull a stale tail onto a line
|
||||
// that no longer matches the suggestion.
|
||||
addon.applyKeystroke("x");
|
||||
|
||||
assert.equal(ghost.style.display, "none");
|
||||
assert.equal(addon.isActive(), false);
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("applyKeystroke: ignores non-typing data (escape sequences, control codes)", () => {
|
||||
// Escape sequences and other control codes are routed through
|
||||
// clearState() in handleInput, not propagated to the ghost — but we
|
||||
// want applyKeystroke to be a safe no-op if accidentally called with
|
||||
// them (defense in depth).
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("docker", "do");
|
||||
const ghost = ghostElement();
|
||||
assert.ok(ghost);
|
||||
const tailBefore = ghost.textContent;
|
||||
|
||||
addon.applyKeystroke("\x1b[A"); // up-arrow escape sequence
|
||||
addon.applyKeystroke("\x01"); // Ctrl+A
|
||||
addon.applyKeystroke(""); // empty
|
||||
|
||||
assert.equal(ghost.textContent, tailBefore);
|
||||
assert.equal(addon.isActive(), true);
|
||||
} finally {
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides the ghost on render when the device echoed untracked input (#1013)", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
// We believe only "network in" is typed; suggestion is the full command.
|
||||
addon.show("network interface show", "network in");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
// The real line shows MORE than we tracked: a bastion host echoed the
|
||||
// next char ("t") that our client-side buffer never recorded.
|
||||
const line = "ecOS# network int";
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = line.length;
|
||||
active.getLine = () => ({ translateToString: () => line });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), false);
|
||||
assert.equal(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides the ghost when TopsecOS-style backspace leaves stale text after the cursor (#1060)", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
// The user deleted back from "system..." to "syst", so the tracked
|
||||
// input is shorter and the ghost regrows to "em license show".
|
||||
addon.show("system license show", "syst");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
// TopsecOS devices shown in #1060 leave the old suffix visible after
|
||||
// the cursor while processing Backspace, so the real buffer looks like:
|
||||
// TopsecOS# syst|em license show
|
||||
const beforeCursor = "TopsecOS# syst";
|
||||
const line = `${beforeCursor}em license show`;
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = beforeCursor.length;
|
||||
active.getLine = () => ({ translateToString: () => line });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), false);
|
||||
assert.equal(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides the ghost when only the deleted prefix remains after the cursor (#1060)", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("system license show", "syst");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
const beforeCursor = "TopsecOS# syst";
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = beforeCursor.length;
|
||||
active.getLine = () => ({ translateToString: () => `${beforeCursor}em` });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), false);
|
||||
assert.equal(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides the ghost when TopsecOS-style backspace leaves stale text after empty input (#1060)", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("system license show", "");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
const beforeCursor = "TopsecOS# ";
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = beforeCursor.length;
|
||||
active.getLine = () => ({ translateToString: () => `${beforeCursor}system license show` });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), false);
|
||||
assert.equal(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides the ghost when stale text is followed by right-side status text (#1060)", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("system license show", "syst");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
const beforeCursor = "TopsecOS# syst";
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = beforeCursor.length;
|
||||
active.getLine = () => ({ translateToString: () => `${beforeCursor}em 12:34 ok` });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), false);
|
||||
assert.equal(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides the ghost when a stale argument suffix starts with a space (#1060)", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("system license show", "system");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
const beforeCursor = "TopsecOS# system";
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = beforeCursor.length;
|
||||
active.getLine = () => ({ translateToString: () => `${beforeCursor} license show` });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), false);
|
||||
assert.equal(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the ghost when unrelated right-side prompt text is visible", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("system license show", "syst");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
const beforeCursor = "host# syst";
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = beforeCursor.length;
|
||||
active.getLine = () => ({ translateToString: () => `${beforeCursor} 12:34 ok` });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), true);
|
||||
assert.notEqual(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the ghost when only right-side spacing overlaps a space-prefixed suffix", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("system license show", "system");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
const beforeCursor = "host# system";
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = beforeCursor.length;
|
||||
active.getLine = () => ({ translateToString: () => `${beforeCursor} 12:34 ok` });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), true);
|
||||
assert.notEqual(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the ghost when adjacent right-side text only shares the first character", () => {
|
||||
const restoreDocument = installFakeDocument();
|
||||
const { term, ghostElement, fireRender } = createFakeTerm();
|
||||
const addon = new GhostTextAddon();
|
||||
|
||||
try {
|
||||
addon.activate(term as never);
|
||||
addon.show("system license show", "syst");
|
||||
assert.equal(addon.isActive(), true);
|
||||
|
||||
const beforeCursor = "host# syst";
|
||||
const active = term.buffer.active as Record<string, unknown>;
|
||||
active.baseY = 0;
|
||||
active.cursorX = beforeCursor.length;
|
||||
active.getLine = () => ({ translateToString: () => `${beforeCursor}error` });
|
||||
|
||||
fireRender();
|
||||
|
||||
assert.equal(addon.isActive(), true);
|
||||
assert.notEqual(ghostElement()?.style.display, "none");
|
||||
} finally {
|
||||
addon.dispose();
|
||||
restoreDocument();
|
||||
}
|
||||
});
|
||||
42
components/terminal/HostKeywordHighlightPopover.test.ts
Normal file
42
components/terminal/HostKeywordHighlightPopover.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { Host, KeywordHighlightRule } from "../../types.ts";
|
||||
import { addHostKeywordHighlightRule } from "./HostKeywordHighlightPopover.tsx";
|
||||
|
||||
const baseHost: Host = {
|
||||
id: "host-1",
|
||||
label: "Production",
|
||||
hostname: "prod.example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
keywordHighlightEnabled: false,
|
||||
keywordHighlightRules: [
|
||||
{
|
||||
id: "old-rule",
|
||||
label: "Old rule",
|
||||
patterns: ["OLD"],
|
||||
color: "#FBBF24",
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const newRule: KeywordHighlightRule = {
|
||||
id: "new-rule",
|
||||
label: "Deploy",
|
||||
patterns: ["DEPLOY"],
|
||||
color: "#F87171",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
test("adding a host keyword highlight rule enables host highlighting", () => {
|
||||
const updated = addHostKeywordHighlightRule(baseHost, newRule);
|
||||
|
||||
assert.equal(updated.keywordHighlightEnabled, true);
|
||||
assert.deepEqual(updated.keywordHighlightRules, [
|
||||
...(baseHost.keywordHighlightRules ?? []),
|
||||
newRule,
|
||||
]);
|
||||
});
|
||||
318
components/terminal/HostKeywordHighlightPopover.tsx
Normal file
318
components/terminal/HostKeywordHighlightPopover.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Host Keyword Highlight Popover
|
||||
* Allows users to manage host-specific keyword highlighting rules in the terminal statusbar
|
||||
*/
|
||||
import { Highlighter, Plus, Trash2, RotateCcw } from 'lucide-react';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Host, KeywordHighlightRule } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
export interface HostKeywordHighlightPopoverProps {
|
||||
host?: Host;
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
buttonClassName?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_NEW_RULE_COLOR = '#F87171';
|
||||
|
||||
export function addHostKeywordHighlightRule(host: Host, rule: KeywordHighlightRule): Host {
|
||||
return {
|
||||
...host,
|
||||
keywordHighlightRules: [...(host.keywordHighlightRules ?? []), rule],
|
||||
keywordHighlightEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
export const HostKeywordHighlightPopover: React.FC<HostKeywordHighlightPopoverProps> = ({
|
||||
host,
|
||||
onUpdateHost,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
buttonClassName = '',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [newRuleLabel, setNewRuleLabel] = useState('');
|
||||
const [newRulePattern, setNewRulePattern] = useState('');
|
||||
const [newRuleColor, setNewRuleColor] = useState(DEFAULT_NEW_RULE_COLOR);
|
||||
const [patternError, setPatternError] = useState<string | null>(null);
|
||||
|
||||
const rules = useMemo(() => host?.keywordHighlightRules ?? [], [host?.keywordHighlightRules]);
|
||||
const enabled = host?.keywordHighlightEnabled ?? false;
|
||||
|
||||
const updateRules = useCallback((newRules: KeywordHighlightRule[]) => {
|
||||
if (!host || !onUpdateHost) return;
|
||||
onUpdateHost({ ...host, keywordHighlightRules: newRules });
|
||||
}, [host, onUpdateHost]);
|
||||
|
||||
const toggleEnabled = useCallback(() => {
|
||||
if (!host || !onUpdateHost) return;
|
||||
onUpdateHost({ ...host, keywordHighlightEnabled: !enabled });
|
||||
}, [host, onUpdateHost, enabled]);
|
||||
|
||||
const validatePattern = (pattern: string): boolean => {
|
||||
try {
|
||||
new RegExp(pattern, 'gi');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRule = useCallback(() => {
|
||||
if (!newRuleLabel.trim() || !newRulePattern.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validatePattern(newRulePattern)) {
|
||||
setPatternError(t('terminal.toolbar.hostHighlight.invalidPattern'));
|
||||
return;
|
||||
}
|
||||
|
||||
const newRule: KeywordHighlightRule = {
|
||||
id: crypto.randomUUID(),
|
||||
label: newRuleLabel.trim(),
|
||||
patterns: [newRulePattern.trim()],
|
||||
color: newRuleColor,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
if (host && onUpdateHost) {
|
||||
onUpdateHost(addHostKeywordHighlightRule(host, newRule));
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setNewRuleLabel('');
|
||||
setNewRulePattern('');
|
||||
setNewRuleColor(DEFAULT_NEW_RULE_COLOR);
|
||||
setPatternError(null);
|
||||
}, [newRuleLabel, newRulePattern, newRuleColor, host, onUpdateHost, t]);
|
||||
|
||||
const handleDeleteRule = useCallback((ruleId: string) => {
|
||||
updateRules(rules.filter((r) => r.id !== ruleId));
|
||||
}, [rules, updateRules]);
|
||||
|
||||
const handleColorChange = useCallback((ruleId: string, color: string) => {
|
||||
updateRules(rules.map((r) => (r.id === ruleId ? { ...r, color } : r)));
|
||||
}, [rules, updateRules]);
|
||||
|
||||
const handleToggleRule = useCallback((ruleId: string) => {
|
||||
updateRules(rules.map((r) => (r.id === ruleId ? { ...r, enabled: !r.enabled } : r)));
|
||||
}, [rules, updateRules]);
|
||||
|
||||
const handleClearAll = useCallback(() => {
|
||||
if (!host || !onUpdateHost) return;
|
||||
onUpdateHost({ ...host, keywordHighlightRules: [], keywordHighlightEnabled: false });
|
||||
}, [host, onUpdateHost]);
|
||||
|
||||
const handlePatternChange = (value: string) => {
|
||||
setNewRulePattern(value);
|
||||
if (patternError && validatePattern(value)) {
|
||||
setPatternError(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Disable if no host (local/serial terminal sessions)
|
||||
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
|
||||
const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-');
|
||||
const isDisabled = !host || !onUpdateHost || isLocalTerminal || isSerialTerminal;
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
{/* Force-close tooltip while the panel is open so the blue label does not
|
||||
sit on top of the trigger/panel (especially in the compact top-right
|
||||
cluster when the host info bar is hidden). */}
|
||||
<Tooltip open={isOpen ? false : undefined}>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonClassName}
|
||||
aria-label={t('terminal.toolbar.hostHighlight.title')}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<Highlighter size={12} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
{/* Toolbar sits at the top of the terminal pane; open below the trigger
|
||||
so the label is not clipped by the window/title edge (especially in
|
||||
compact top-right action cluster when the host info bar is hidden). */}
|
||||
<TooltipContent side="bottom" sideOffset={8} className="whitespace-nowrap max-w-none">
|
||||
{t('terminal.toolbar.hostHighlight.title')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent className="w-80 p-0" align="end" side="bottom" sideOffset={8} collisionPadding={12}>
|
||||
<div className="px-3 py-2 border-b bg-muted/30 flex items-center justify-between gap-2 min-w-0">
|
||||
<span className="text-xs font-semibold uppercase text-muted-foreground truncate min-w-0">
|
||||
{t('terminal.toolbar.hostHighlight.title')}
|
||||
</span>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{enabled ? t('common.enabled') : t('common.disabled')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={enabled}
|
||||
onClick={toggleEnabled}
|
||||
className={`
|
||||
relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent
|
||||
transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2
|
||||
${enabled ? 'bg-primary' : 'bg-muted-foreground/30'}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`
|
||||
pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0
|
||||
transition duration-200 ease-in-out
|
||||
${enabled ? 'translate-x-4' : 'translate-x-0'}
|
||||
`}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="max-h-64">
|
||||
<div className="p-2 space-y-1.5">
|
||||
{rules.length === 0 ? (
|
||||
<div className="px-2 py-4 text-xs text-muted-foreground text-center italic">
|
||||
{t('terminal.toolbar.hostHighlight.noRules')}
|
||||
</div>
|
||||
) : (
|
||||
rules.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-accent/50 group"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleRule(rule.id)}
|
||||
className={`
|
||||
flex-shrink-0 w-3 h-3 rounded-sm border transition-colors
|
||||
${rule.enabled
|
||||
? 'bg-primary border-primary'
|
||||
: 'bg-transparent border-muted-foreground/50'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{rule.enabled ? t('common.enabled') : t('common.disabled')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-xs font-medium truncate"
|
||||
style={{ color: rule.enabled ? rule.color : 'inherit' }}
|
||||
>
|
||||
{rule.label}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono truncate">
|
||||
{rule.patterns.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative flex-shrink-0">
|
||||
<input
|
||||
type="color"
|
||||
value={rule.color}
|
||||
onChange={(e) => handleColorChange(rule.id, e.target.value)}
|
||||
className="sr-only"
|
||||
aria-label={`${t('terminal.toolbar.hostHighlight.changeColor')} ${rule.label}`}
|
||||
/>
|
||||
<span
|
||||
className="block w-6 h-4 rounded cursor-pointer border border-border/50 hover:border-border"
|
||||
style={{ backgroundColor: rule.color }}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 opacity-0 group-hover:opacity-100 transition-opacity text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Add new rule form */}
|
||||
<div className="p-2 border-t bg-muted/20 space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">
|
||||
{t('terminal.toolbar.hostHighlight.addRule')}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
placeholder={t('terminal.toolbar.hostHighlight.labelPlaceholder')}
|
||||
value={newRuleLabel}
|
||||
onChange={(e) => setNewRuleLabel(e.target.value)}
|
||||
className="h-7 text-xs flex-1"
|
||||
/>
|
||||
<label className="relative flex-shrink-0">
|
||||
<input
|
||||
type="color"
|
||||
value={newRuleColor}
|
||||
onChange={(e) => setNewRuleColor(e.target.value)}
|
||||
className="sr-only"
|
||||
aria-label={t('terminal.toolbar.hostHighlight.selectColor')}
|
||||
/>
|
||||
<span
|
||||
className="block w-7 h-7 rounded cursor-pointer border border-border/50 hover:border-border"
|
||||
style={{ backgroundColor: newRuleColor }}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Input
|
||||
placeholder={t('terminal.toolbar.hostHighlight.patternPlaceholder')}
|
||||
value={newRulePattern}
|
||||
onChange={(e) => handlePatternChange(e.target.value)}
|
||||
className={`h-7 text-xs font-mono flex-1 ${patternError ? 'border-destructive' : ''}`}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0"
|
||||
onClick={handleAddRule}
|
||||
disabled={!newRuleLabel.trim() || !newRulePattern.trim()}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
{patternError && (
|
||||
<div className="text-[10px] text-destructive">{patternError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
{rules.length > 0 && (
|
||||
<div className="p-2 border-t flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs text-muted-foreground hover:text-destructive"
|
||||
onClick={handleClearAll}
|
||||
>
|
||||
<RotateCcw size={10} className="mr-1" />
|
||||
{t('terminal.toolbar.hostHighlight.clearAll')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default HostKeywordHighlightPopover;
|
||||
279
components/terminal/PasswordCredentialPicker.tsx
Normal file
279
components/terminal/PasswordCredentialPicker.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Floating credential list for sudo/su password-prompt assist (picker mode).
|
||||
* Positioned next to the terminal cursor using the same anchor/placement
|
||||
* helpers as AutocompletePopup. Secrets are never shown.
|
||||
*/
|
||||
import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { PasswordPromptPickerItem } from "./runtime/terminalSudoAutofill";
|
||||
import {
|
||||
clampAutocompletePopupGeometry,
|
||||
computeAutocompletePopupPlacement,
|
||||
resolveAutocompleteAnchorInViewport,
|
||||
resolveAutocompleteClampViewport,
|
||||
} from "./autocomplete/terminalAutocompleteLayout";
|
||||
|
||||
export type PasswordCredentialPickerProps = {
|
||||
items: PasswordPromptPickerItem[];
|
||||
selectedIndex: number;
|
||||
visible: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
title: string;
|
||||
emptyText: string;
|
||||
themeColors?: {
|
||||
background?: string;
|
||||
foreground?: string;
|
||||
selection?: string;
|
||||
cursor?: string;
|
||||
};
|
||||
termRef?: React.RefObject<XTerm | null>;
|
||||
containerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
const ROW_HEIGHT = 28;
|
||||
const HEADER_HEIGHT = 32;
|
||||
const LIST_PADDING = 8;
|
||||
const MAX_LIST_HEIGHT = 192;
|
||||
const POPUP_MIN_WIDTH = 240;
|
||||
const POPUP_MAX_WIDTH = 360;
|
||||
|
||||
const PasswordCredentialPicker: React.FC<PasswordCredentialPickerProps> = ({
|
||||
items,
|
||||
selectedIndex,
|
||||
visible,
|
||||
onSelect,
|
||||
title,
|
||||
emptyText,
|
||||
themeColors,
|
||||
termRef,
|
||||
containerRef,
|
||||
}) => {
|
||||
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [anchorTick, setAnchorTick] = useState(0);
|
||||
const [measuredSize, setMeasuredSize] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
const requestReposition = useCallback(() => {
|
||||
setAnchorTick((n) => n + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
selectedRef.current?.scrollIntoView({ block: "nearest" });
|
||||
}, [visible, selectedIndex]);
|
||||
|
||||
// Recalculate when the terminal/container resizes or the window moves.
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
|
||||
let frameId = 0;
|
||||
const schedule = () => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
frameId = requestAnimationFrame(() => {
|
||||
frameId = 0;
|
||||
requestReposition();
|
||||
});
|
||||
};
|
||||
|
||||
const container = containerRef?.current;
|
||||
const observer = container ? new ResizeObserver(schedule) : null;
|
||||
if (container) observer?.observe(container);
|
||||
window.addEventListener("resize", schedule);
|
||||
window.addEventListener("scroll", schedule, true);
|
||||
|
||||
// Two rAFs so xterm has finished layout after the password line paints.
|
||||
let first = 0;
|
||||
let second = 0;
|
||||
first = requestAnimationFrame(() => {
|
||||
requestReposition();
|
||||
second = requestAnimationFrame(requestReposition);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
if (first) cancelAnimationFrame(first);
|
||||
if (second) cancelAnimationFrame(second);
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", schedule);
|
||||
window.removeEventListener("scroll", schedule, true);
|
||||
};
|
||||
}, [visible, containerRef, requestReposition, items.length]);
|
||||
|
||||
const itemCount = Math.max(1, items.length);
|
||||
const estimatedListHeight = Math.min(MAX_LIST_HEIGHT, itemCount * ROW_HEIGHT + LIST_PADDING);
|
||||
const estimatedPopupHeight = estimatedListHeight + HEADER_HEIGHT;
|
||||
|
||||
const placement = useMemo(() => {
|
||||
// anchorTick forces recompute when the cursor/container moves.
|
||||
void anchorTick;
|
||||
const term = termRef?.current ?? null;
|
||||
const container = containerRef?.current ?? null;
|
||||
const clampViewport = resolveAutocompleteClampViewport(container);
|
||||
const empty = {
|
||||
left: clampViewport.left + 8,
|
||||
top: clampViewport.top + 8,
|
||||
maxHeight: MAX_LIST_HEIGHT,
|
||||
renderUpward: true,
|
||||
};
|
||||
if (!term || !visible) return empty;
|
||||
|
||||
const anchor = resolveAutocompleteAnchorInViewport(term, container, itemCount);
|
||||
const result = computeAutocompletePopupPlacement({
|
||||
anchorTop: anchor.anchorTop,
|
||||
anchorBottom: anchor.anchorBottom,
|
||||
anchorLeft: anchor.anchorLeft,
|
||||
viewportWidth: clampViewport.width,
|
||||
viewportHeight: clampViewport.height,
|
||||
clampViewport,
|
||||
desiredHeight: estimatedPopupHeight,
|
||||
totalWidth: POPUP_MAX_WIDTH,
|
||||
clampWidth: POPUP_MAX_WIDTH,
|
||||
maxHeight: estimatedPopupHeight,
|
||||
anchorGap: 8,
|
||||
viewportPadding: 8,
|
||||
// Password prompts sit on the input line; prefer opening upward so the
|
||||
// list does not cover what the user is about to type.
|
||||
expandUpwardHint: true,
|
||||
});
|
||||
return {
|
||||
left: result.left,
|
||||
top: result.top,
|
||||
maxHeight: Math.max(ROW_HEIGHT + LIST_PADDING, result.maxHeight - HEADER_HEIGHT),
|
||||
renderUpward: result.renderUpward,
|
||||
};
|
||||
}, [
|
||||
anchorTick,
|
||||
termRef,
|
||||
containerRef,
|
||||
visible,
|
||||
itemCount,
|
||||
estimatedPopupHeight,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!visible) {
|
||||
setMeasuredSize((current) => (current === null ? current : null));
|
||||
return;
|
||||
}
|
||||
const rect = wrapperRef.current?.getBoundingClientRect();
|
||||
if (!rect || rect.width <= 0 || rect.height <= 0) return;
|
||||
setMeasuredSize((current) => {
|
||||
if (
|
||||
current
|
||||
&& Math.abs(current.width - rect.width) < 0.5
|
||||
&& Math.abs(current.height - rect.height) < 0.5
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return { width: rect.width, height: rect.height };
|
||||
});
|
||||
}, [visible, placement.left, placement.top, items.length, selectedIndex]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const clampViewport = resolveAutocompleteClampViewport(containerRef?.current ?? null);
|
||||
const finalGeometry = measuredSize
|
||||
? clampAutocompletePopupGeometry({
|
||||
left: placement.left,
|
||||
top: placement.top,
|
||||
width: measuredSize.width,
|
||||
height: measuredSize.height,
|
||||
clampViewport,
|
||||
viewportPadding: 8,
|
||||
})
|
||||
: { left: placement.left, top: placement.top };
|
||||
|
||||
const background = themeColors?.background ?? "hsl(var(--popover))";
|
||||
const foreground = themeColors?.foreground ?? "hsl(var(--popover-foreground))";
|
||||
const selection = themeColors?.selection ?? "hsl(var(--accent))";
|
||||
const border = themeColors?.cursor
|
||||
? `${themeColors.cursor}55`
|
||||
: "hsl(var(--border))";
|
||||
|
||||
const node = (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
role="listbox"
|
||||
aria-label={title}
|
||||
data-testid="password-credential-picker"
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: `${finalGeometry.left}px`,
|
||||
top: `${finalGeometry.top}px`,
|
||||
zIndex: 10000,
|
||||
minWidth: POPUP_MIN_WIDTH,
|
||||
maxWidth: POPUP_MAX_WIDTH,
|
||||
overflow: "hidden",
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${border}`,
|
||||
background,
|
||||
color: foreground,
|
||||
boxShadow: placement.renderUpward
|
||||
? "0 -2px 6px rgba(0, 0, 0, 0.15)"
|
||||
: "0 2px 6px rgba(0, 0, 0, 0.15)",
|
||||
fontSize: 13,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
// Keep terminal focus; prevent selection loss before click select.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1.5 border-b px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide opacity-70"
|
||||
style={{ borderColor: border, height: HEADER_HEIGHT, boxSizing: "border-box" }}
|
||||
>
|
||||
<KeyRound size={12} />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div
|
||||
ref={listRef}
|
||||
style={{
|
||||
maxHeight: `${placement.maxHeight}px`,
|
||||
overflowY: "auto",
|
||||
padding: "4px 0",
|
||||
}}
|
||||
>
|
||||
{items.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs opacity-70">{emptyText}</div>
|
||||
) : (
|
||||
items.map((item, index) => {
|
||||
const selected = index === selectedIndex;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
ref={selected ? selectedRef : undefined}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm transition-colors"
|
||||
style={{
|
||||
background: selected ? selection : undefined,
|
||||
height: ROW_HEIGHT,
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{item.label}</span>
|
||||
{item.username ? (
|
||||
<span className="shrink-0 font-mono text-xs opacity-70">{item.username}</span>
|
||||
) : null}
|
||||
<span className="shrink-0 font-mono text-xs opacity-50">••••••••</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Portal to body so overflow:hidden on terminal chrome cannot clip the list
|
||||
// (same pattern as AutocompletePopup).
|
||||
return ReactDOM.createPortal(node, document.body);
|
||||
};
|
||||
|
||||
export default memo(PasswordCredentialPicker);
|
||||
687
components/terminal/PromptDetector.fallback.test.ts
Normal file
687
components/terminal/PromptDetector.fallback.test.ts
Normal file
@@ -0,0 +1,687 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getAlignedPrompt } from "./autocomplete/promptDetector.ts";
|
||||
import { getCommandToRecordOnEnter } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
|
||||
function createFakeTerm(lineText: string, cursorX: number) {
|
||||
return {
|
||||
buffer: {
|
||||
active: {
|
||||
cursorX,
|
||||
cursorY: 0,
|
||||
baseY: 0,
|
||||
getLine(line: number) {
|
||||
if (line !== 0) return undefined;
|
||||
return {
|
||||
isWrapped: false,
|
||||
translateToString() {
|
||||
return lineText;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createWrappedFakeTerm(rows: string[], cursorY: number, cursorX: number, cols: number) {
|
||||
return {
|
||||
cols,
|
||||
buffer: {
|
||||
active: {
|
||||
cursorX,
|
||||
cursorY,
|
||||
baseY: 0,
|
||||
getLine(line: number) {
|
||||
const lineText = rows[line];
|
||||
if (lineText === undefined) return undefined;
|
||||
return {
|
||||
isWrapped: line > 0,
|
||||
translateToString() {
|
||||
return lineText;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
test("records aligned short commands when standard prompt echo lags by one character", () => {
|
||||
const cases = [
|
||||
{ lineText: "$ l", typedInput: "ls" },
|
||||
{ lineText: "$ c", typedInput: "cd" },
|
||||
{ lineText: "prod-web> l", typedInput: "ls", promptText: "prod-web> " },
|
||||
{ lineText: "prod> l", typedInput: "ls", promptText: "prod> " },
|
||||
{ lineText: "prod.web> l", typedInput: "ls", promptText: "prod.web> " },
|
||||
{ lineText: "user@host:~$ l", typedInput: "ls", promptText: "user@host:~$ " },
|
||||
{ lineText: "[user@host ~]$ l", typedInput: "ls", promptText: "[user@host ~]$ " },
|
||||
{ lineText: "➜ netcatty $ l", typedInput: "ls", promptText: "➜ netcatty $ " },
|
||||
{ lineText: "➜ git l", typedInput: "ls", promptText: "➜ git " },
|
||||
{ lineText: "➜ git np", typedInput: "npm", promptText: "➜ git " },
|
||||
];
|
||||
|
||||
for (const { lineText, typedInput, promptText = "$ " } of cases) {
|
||||
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, lineText);
|
||||
assert.equal(result.prompt.promptText, promptText, lineText);
|
||||
assert.equal(result.prompt.userInput, typedInput, lineText);
|
||||
assert.equal(result.alignedTyped, typedInput, lineText);
|
||||
assert.equal(
|
||||
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
|
||||
typedInput,
|
||||
lineText,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("records aligned typed input instead of lagging standard prompt input on Enter", () => {
|
||||
const typedInput = "git status";
|
||||
const term = createFakeTerm("$ git ", "$ git ".length);
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(
|
||||
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
|
||||
typedInput,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not record themed prompt decorations when typed input is unreliable", () => {
|
||||
const cases = [
|
||||
{
|
||||
lineText: "➜ ~ git status",
|
||||
promptText: "➜ ",
|
||||
expectedUserInput: " ~ git status",
|
||||
},
|
||||
{
|
||||
lineText: "➜ netcatty git:(main) ✗ git status",
|
||||
promptText: "➜ ",
|
||||
expectedUserInput: " netcatty git:(main) ✗ git status",
|
||||
},
|
||||
{
|
||||
lineText: " ~ git status",
|
||||
promptText: " ",
|
||||
expectedUserInput: " ~ git status",
|
||||
},
|
||||
];
|
||||
|
||||
for (const { lineText, promptText, expectedUserInput } of cases) {
|
||||
const result = getAlignedPrompt(
|
||||
createFakeTerm(lineText, lineText.length) as never,
|
||||
"",
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, lineText);
|
||||
assert.equal(result.prompt.promptText, promptText, lineText);
|
||||
assert.equal(result.prompt.userInput, expectedUserInput, lineText);
|
||||
assert.equal(
|
||||
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, "", false),
|
||||
null,
|
||||
lineText,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("records recognized themed prompts when typed input is unreliable", () => {
|
||||
const cases = [
|
||||
"➜ git status",
|
||||
" git status",
|
||||
"➜ netcatty $ git status",
|
||||
"➜ netcatty git:(main) ✗ $ git status",
|
||||
" ~ $ git status",
|
||||
];
|
||||
|
||||
for (const lineText of cases) {
|
||||
const result = getAlignedPrompt(
|
||||
createFakeTerm(lineText, lineText.length) as never,
|
||||
"",
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, lineText);
|
||||
assert.equal(result.prompt.userInput, "git status", lineText);
|
||||
assert.equal(
|
||||
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, "", false),
|
||||
"git status",
|
||||
lineText,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("aligns themed bare directory prompts with reliable typed input", () => {
|
||||
const cases = [
|
||||
{ dir: "netcatty", typedInput: "ls" },
|
||||
{ dir: "git", typedInput: "ls" },
|
||||
{ dir: "git", typedInput: "npm" },
|
||||
{ dir: "git", typedInput: "git status" },
|
||||
{ dir: "git", typedInput: "npm test" },
|
||||
{ dir: "make", typedInput: "sudo" },
|
||||
{ dir: "make", typedInput: "make build" },
|
||||
{ dir: "make", typedInput: "git status" },
|
||||
{ dir: "node", typedInput: "yarn" },
|
||||
{ dir: "node", typedInput: "npm test" },
|
||||
{ dir: "docker", typedInput: "git status" },
|
||||
{ dir: "go", typedInput: "test" },
|
||||
{ dir: "go", typedInput: "npm test" },
|
||||
{ dir: "kubectl", typedInput: "sudo" },
|
||||
{ dir: "kubectl", typedInput: "git status" },
|
||||
];
|
||||
|
||||
for (const { dir, typedInput } of cases) {
|
||||
const lineText = `➜ ${dir} ${typedInput}`;
|
||||
const result = getAlignedPrompt(
|
||||
createFakeTerm(lineText, lineText.length) as never,
|
||||
typedInput,
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, dir);
|
||||
assert.equal(result.prompt.promptText, `➜ ${dir} `, dir);
|
||||
assert.equal(result.prompt.userInput, typedInput, dir);
|
||||
assert.equal(result.alignedTyped, typedInput, dir);
|
||||
assert.equal(
|
||||
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
|
||||
typedInput,
|
||||
dir,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("records reliable typed input before shell echo appears", () => {
|
||||
const cases = [
|
||||
{ lineText: "$ ", typedInput: "ls" },
|
||||
{ lineText: "server> ", typedInput: "exit" },
|
||||
{ lineText: "staging> ", typedInput: "show dbs" },
|
||||
{ lineText: "test> ", typedInput: "exit" },
|
||||
{ lineText: "test> ", typedInput: "help" },
|
||||
{ lineText: "test> ", typedInput: "show dbs" },
|
||||
{ lineText: "➜ git ", typedInput: "npm" },
|
||||
{ lineText: "➜ make ", typedInput: "sudo" },
|
||||
{ lineText: "➜ node ", typedInput: "yarn" },
|
||||
];
|
||||
|
||||
for (const { lineText, typedInput } of cases) {
|
||||
const result = getAlignedPrompt(
|
||||
createFakeTerm(lineText, lineText.length) as never,
|
||||
typedInput,
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, lineText);
|
||||
assert.equal(
|
||||
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
|
||||
typedInput,
|
||||
lineText,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not record reliable typed input before interactive echo appears", () => {
|
||||
const cases = [
|
||||
{ lineText: "test> ", typedInput: "const x = 1" },
|
||||
{ lineText: "test> ", typedInput: "await db.users.findOne()" },
|
||||
{ lineText: "test> ", typedInput: "db" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", typedInput: "const x = 1" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", typedInput: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> ", typedInput: "db.stats()" },
|
||||
{ lineText: "Atlas a [primary] reporting> ", typedInput: "db.stats()" },
|
||||
];
|
||||
|
||||
for (const { lineText, typedInput } of cases) {
|
||||
const result = getAlignedPrompt(
|
||||
createFakeTerm(lineText, lineText.length) as never,
|
||||
typedInput,
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
|
||||
null,
|
||||
lineText,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("detects themed bare directory prompts with standard terminators", () => {
|
||||
const cases = [
|
||||
{ lineText: "➜ git $ npm test", promptText: "➜ git $ ", typedInput: "npm test" },
|
||||
{ lineText: "➜ make $ git status", promptText: "➜ make $ ", typedInput: "git status" },
|
||||
];
|
||||
|
||||
for (const { lineText, promptText, typedInput } of cases) {
|
||||
const result = getAlignedPrompt(
|
||||
createFakeTerm(lineText, lineText.length) as never,
|
||||
"",
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, lineText);
|
||||
assert.equal(result.prompt.promptText, promptText, lineText);
|
||||
assert.equal(result.prompt.userInput, typedInput, lineText);
|
||||
assert.equal(
|
||||
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, "", false),
|
||||
typedInput,
|
||||
lineText,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not record path-decorated themed prompts when typed input is unreliable", () => {
|
||||
const cases = [
|
||||
"➜ ~/repo git status",
|
||||
" ~/repo git status",
|
||||
];
|
||||
|
||||
for (const lineText of cases) {
|
||||
const result = getAlignedPrompt(
|
||||
createFakeTerm(lineText, lineText.length) as never,
|
||||
"",
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, lineText);
|
||||
assert.equal(
|
||||
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, "", false),
|
||||
null,
|
||||
lineText,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not record partial themed prompt decorations when short command echo lags", () => {
|
||||
const cases = [
|
||||
{ lineText: "➜ ~ l", typedInput: "ls" },
|
||||
{ lineText: "➜ ~ c", typedInput: "cd" },
|
||||
{ lineText: "➜ ~ s", typedInput: "sudo" },
|
||||
];
|
||||
|
||||
for (const { lineText, typedInput } of cases) {
|
||||
const result = getAlignedPrompt(
|
||||
createFakeTerm(lineText, lineText.length) as never,
|
||||
typedInput,
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, lineText);
|
||||
assert.equal(
|
||||
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
|
||||
null,
|
||||
lineText,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("aligns typed input after a no-space root prompt when a short command echo lags by a word", () => {
|
||||
const prompt = "root@host:~#";
|
||||
const cases = [
|
||||
{ echoedInput: "ls ", typedInput: "ls -la" },
|
||||
{ echoedInput: "cd ", typedInput: "cd /tmp" },
|
||||
];
|
||||
|
||||
for (const { echoedInput, typedInput } of cases) {
|
||||
const lineText = `${prompt}${echoedInput}`;
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, typedInput);
|
||||
assert.equal(result.prompt.promptText, prompt, typedInput);
|
||||
assert.equal(result.prompt.userInput, typedInput, typedInput);
|
||||
assert.equal(result.alignedTyped, typedInput, typedInput);
|
||||
}
|
||||
});
|
||||
|
||||
test("aligns typed input after a no-space root prompt when a short command echo lags by one character", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const cases = [
|
||||
{ echoedInput: "l", typedInput: "ls" },
|
||||
{ echoedInput: "c", typedInput: "cd" },
|
||||
];
|
||||
|
||||
for (const { echoedInput, typedInput } of cases) {
|
||||
const lineText = `${prompt}${echoedInput}`;
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, typedInput);
|
||||
assert.equal(result.prompt.promptText, prompt, typedInput);
|
||||
assert.equal(result.prompt.userInput, typedInput, typedInput);
|
||||
assert.equal(result.alignedTyped, typedInput, typedInput);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not align stale typed input against unrelated prompt text", () => {
|
||||
const term = createFakeTerm("$ ls", 4);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "sudo", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, "$ ");
|
||||
assert.equal(result.prompt.userInput, "ls");
|
||||
assert.equal(result.alignedTyped, null);
|
||||
});
|
||||
|
||||
test("does not align stale typed input when the live command ends with it", () => {
|
||||
const term = createFakeTerm("$ echo sudo", "$ echo sudo".length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "sudo", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, "$ ");
|
||||
assert.equal(result.prompt.userInput, "echo sudo");
|
||||
assert.equal(result.alignedTyped, null);
|
||||
});
|
||||
|
||||
test("does not align stale typed input after host prompt command symbols", () => {
|
||||
const prompt = "user@host:~$ ";
|
||||
const cases = [
|
||||
`${prompt}echo # sudo`,
|
||||
`${prompt}printf % sudo`,
|
||||
`${prompt}echo $ sudo`,
|
||||
];
|
||||
|
||||
for (const lineText of cases) {
|
||||
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, "sudo", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, lineText);
|
||||
assert.equal(result.prompt.promptText, prompt, lineText);
|
||||
assert.equal(result.prompt.userInput, lineText.slice(prompt.length), lineText);
|
||||
assert.equal(result.alignedTyped, null, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not align stale typed input when the live path ends with it", () => {
|
||||
const cases = [
|
||||
"$ cd ~/sudo",
|
||||
"$ echo /tmp/sudo",
|
||||
"$ printf foo:sudo",
|
||||
"$ cat ./sudo",
|
||||
"$ run [sudo",
|
||||
"$ cat > sudo",
|
||||
"$ echo path#sudo",
|
||||
"$ echo 100%sudo",
|
||||
];
|
||||
|
||||
for (const lineText of cases) {
|
||||
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, "sudo", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, lineText);
|
||||
assert.equal(result.prompt.promptText, "$ ", lineText);
|
||||
assert.equal(result.prompt.userInput, lineText.slice(2), lineText);
|
||||
assert.equal(result.alignedTyped, null, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not align stale typed input from partial echoes after a no-space prompt", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const cases = [
|
||||
`${prompt}s`,
|
||||
`${prompt}sud`,
|
||||
];
|
||||
|
||||
for (const lineText of cases) {
|
||||
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, "sudo", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, false, lineText);
|
||||
assert.equal(result.alignedTyped, null, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not align stale typed input after no-space prompt command suffixes", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const cases = [
|
||||
`${prompt}cat > sudo`,
|
||||
`${prompt}echo # sudo`,
|
||||
`${prompt}echo $ sudo`,
|
||||
`${prompt}printf % sudo`,
|
||||
`${prompt}echo path#sudo`,
|
||||
`${prompt}> sudo`,
|
||||
`${prompt}# sudo`,
|
||||
`${prompt}% sudo`,
|
||||
`${prompt}$ sudo`,
|
||||
];
|
||||
cases.push("root#echo $ sudo", "root@host:~#make $ sudo");
|
||||
|
||||
for (const lineText of cases) {
|
||||
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, "sudo", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, false, lineText);
|
||||
assert.equal(result.alignedTyped, null, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not align stale typed input from short standard prompt prefixes", () => {
|
||||
for (const lineText of ["$ s", "$ su", "$ sud"]) {
|
||||
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, "sudo", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, lineText);
|
||||
assert.equal(result.prompt.promptText, "$ ", lineText);
|
||||
assert.equal(result.prompt.userInput, lineText.slice(2), lineText);
|
||||
assert.equal(result.alignedTyped, null, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("aligns wrapped typed input after a no-space root prompt", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const typedInput = "printf 1234567890";
|
||||
const cols = 20;
|
||||
const firstInputSegmentLength = cols - prompt.length;
|
||||
const rows = [
|
||||
`${prompt}${typedInput.slice(0, firstInputSegmentLength)}`,
|
||||
typedInput.slice(firstInputSegmentLength),
|
||||
];
|
||||
const term = createWrappedFakeTerm(rows, 1, rows[1].length, cols);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, prompt);
|
||||
assert.equal(result.prompt.userInput, typedInput);
|
||||
assert.equal(result.alignedTyped, typedInput);
|
||||
});
|
||||
|
||||
test("aligns wrapped typed input after a no-space root prompt when shell echo lags", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const typedInput = "printf 1234567890";
|
||||
const echoedInput = typedInput.slice(0, -2);
|
||||
const cols = 20;
|
||||
const firstInputSegmentLength = cols - prompt.length;
|
||||
const rows = [
|
||||
`${prompt}${echoedInput.slice(0, firstInputSegmentLength)}`,
|
||||
echoedInput.slice(firstInputSegmentLength),
|
||||
];
|
||||
const term = createWrappedFakeTerm(rows, 1, rows[1].length, cols);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, prompt);
|
||||
assert.equal(result.prompt.userInput, typedInput);
|
||||
assert.equal(result.alignedTyped, typedInput);
|
||||
});
|
||||
|
||||
test("does not resurrect python REPL prompts during fallback alignment", () => {
|
||||
const typedInput = "print('ok')";
|
||||
const lineText = `>>> ${typedInput}`;
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, false);
|
||||
assert.equal(result.alignedTyped, null);
|
||||
});
|
||||
|
||||
test("does not resurrect mysql REPL prompts during fallback alignment", () => {
|
||||
const typedInput = "select 1";
|
||||
const lineText = `mysql> ${typedInput}`;
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, false);
|
||||
assert.equal(result.alignedTyped, null);
|
||||
});
|
||||
|
||||
test("does not resurrect mysql continuation prompts during fallback alignment", () => {
|
||||
const prompts = [
|
||||
" -> ",
|
||||
" '> ",
|
||||
" \"> ",
|
||||
" `> ",
|
||||
];
|
||||
|
||||
for (const prompt of prompts) {
|
||||
const typedInput = "select 1";
|
||||
const term = createFakeTerm(`${prompt}${typedInput}`, prompt.length + typedInput.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, false, prompt);
|
||||
assert.equal(result.alignedTyped, null, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not resurrect redis-cli REPL prompts during fallback alignment", () => {
|
||||
const prompts = [
|
||||
"redis-cli> ",
|
||||
"redis> ",
|
||||
"127.0.0.1:6379> ",
|
||||
"127.0.0.1:6379[1]> ",
|
||||
"localhost:6379> ",
|
||||
];
|
||||
|
||||
for (const prompt of prompts) {
|
||||
const typedInput = "get key";
|
||||
const term = createFakeTerm(`${prompt}${typedInput}`, prompt.length + typedInput.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, false, prompt);
|
||||
assert.equal(result.alignedTyped, null, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not resurrect mariadb REPL prompts during fallback alignment", () => {
|
||||
const typedInput = "select 1";
|
||||
const prompt = "MariaDB [(none)]> ";
|
||||
const term = createFakeTerm(`${prompt}${typedInput}`, prompt.length + typedInput.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, false);
|
||||
assert.equal(result.alignedTyped, null);
|
||||
});
|
||||
|
||||
test("does not resurrect postgres REPL prompts during fallback alignment", () => {
|
||||
for (const prompt of [
|
||||
"postgres=# ",
|
||||
"postgres=> ",
|
||||
"postgres-# ",
|
||||
"postgres'# ",
|
||||
"postgres(# ",
|
||||
"postgres*# ",
|
||||
"postgres!# ",
|
||||
"postgres^# ",
|
||||
"postgres$tag$# ",
|
||||
"postgres(> ",
|
||||
"postgres*> ",
|
||||
"postgres!> ",
|
||||
"postgres^> ",
|
||||
"postgres$tag$> ",
|
||||
]) {
|
||||
const typedInput = "select 1";
|
||||
const term = createFakeTerm(`${prompt}${typedInput}`, prompt.length + typedInput.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, false, prompt);
|
||||
assert.equal(result.alignedTyped, null, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps host-style greater-than shell prompts", () => {
|
||||
const prompt = "prod-web> ";
|
||||
for (const typedInput of ["deploy", "exit", "show dbs", "use app", "it", "help", "print(1)"]) {
|
||||
const term = createFakeTerm(`${prompt}${typedInput}`, prompt.length + typedInput.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, typedInput);
|
||||
assert.equal(result.prompt.promptText, prompt, typedInput);
|
||||
assert.equal(result.prompt.userInput, typedInput, typedInput);
|
||||
assert.equal(result.alignedTyped, typedInput, typedInput);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not resurrect shell continuation prompts during fallback alignment", () => {
|
||||
const typedInput = "echo ok";
|
||||
const lineText = `> ${typedInput}`;
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, false);
|
||||
assert.equal(result.alignedTyped, null);
|
||||
});
|
||||
|
||||
test("does not resurrect no-space python REPL prompts during fallback alignment", () => {
|
||||
const typedInput = "print(1)";
|
||||
const lineText = `>>>${typedInput}`;
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, false);
|
||||
assert.equal(result.alignedTyped, null);
|
||||
});
|
||||
|
||||
test("does not resurrect no-space mysql REPL prompts during fallback alignment", () => {
|
||||
const typedInput = "select 1";
|
||||
const lineText = `mysql>${typedInput}`;
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, false);
|
||||
assert.equal(result.alignedTyped, null);
|
||||
});
|
||||
|
||||
test("does not resurrect host-like no-space REPL prompts during fallback alignment", () => {
|
||||
const typedInput = "select 1";
|
||||
const lineText = `user@db>${typedInput}`;
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, false);
|
||||
assert.equal(result.alignedTyped, null);
|
||||
});
|
||||
|
||||
test("does not resurrect no-space shell continuation prompts during fallback alignment", () => {
|
||||
const typedInput = "echo ok";
|
||||
const lineText = `>${typedInput}`;
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, false);
|
||||
assert.equal(result.alignedTyped, null);
|
||||
});
|
||||
|
||||
test("keeps typed command intact for PUA-only prompts when command text contains Powerline glyphs", () => {
|
||||
const typedInput = "echo foo";
|
||||
const lineText = ` root ~ ${typedInput}`;
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, " root ~ ");
|
||||
assert.equal(result.prompt.userInput, typedInput);
|
||||
assert.equal(result.alignedTyped, typedInput);
|
||||
});
|
||||
819
components/terminal/PromptDetector.test.ts
Normal file
819
components/terminal/PromptDetector.test.ts
Normal file
@@ -0,0 +1,819 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { detectPrompt, getAlignedPrompt } from "./autocomplete/promptDetector.ts";
|
||||
import { resolveAutocompleteQueryInput } from "./autocomplete/terminalAutocompletePrompt.ts";
|
||||
import { getSnippetSuggestions } from "./autocomplete/snippetCompleter.ts";
|
||||
import { stringCellWidth } from "./autocomplete/terminalStringCellWidth.ts";
|
||||
import { getCommandToRecordOnEnter } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
|
||||
function createFakeTerm(lineText: string, cursorX: number) {
|
||||
return {
|
||||
buffer: {
|
||||
active: {
|
||||
cursorX,
|
||||
cursorY: 0,
|
||||
baseY: 0,
|
||||
getLine(line: number) {
|
||||
if (line !== 0) return undefined;
|
||||
return {
|
||||
isWrapped: false,
|
||||
translateToString() {
|
||||
return lineText;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Simulates xterm padding empty cells as trailing spaces after wide glyphs. */
|
||||
function createPaddedFakeTerm(content: string, cursorX: number, cols = 80) {
|
||||
const pad = Math.max(0, cols - stringCellWidth(content));
|
||||
const lineText = content + " ".repeat(pad);
|
||||
return createFakeTerm(lineText, cursorX);
|
||||
}
|
||||
|
||||
function createWrappedFakeTerm(rows: string[], cursorY: number, cursorX: number, cols: number) {
|
||||
return {
|
||||
cols,
|
||||
buffer: {
|
||||
active: {
|
||||
cursorX,
|
||||
cursorY,
|
||||
baseY: 0,
|
||||
getLine(line: number) {
|
||||
const lineText = rows[line];
|
||||
if (lineText === undefined) return undefined;
|
||||
return {
|
||||
isWrapped: line > 0,
|
||||
translateToString() {
|
||||
return lineText;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("keeps raw input when a standard shell prompt echo is still behind", () => {
|
||||
const term = createFakeTerm("$ do", 4);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "doc", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, "$ ");
|
||||
assert.equal(result.prompt.userInput, "do");
|
||||
assert.equal(result.prompt.cursorOffset, 2);
|
||||
assert.equal(result.alignedTyped, null);
|
||||
});
|
||||
|
||||
test("uses reliable typed buffer when prompt echo has not started (IME / high-latency commit)", () => {
|
||||
const term = createFakeTerm("$ ", 2);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "部署", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, "$ ");
|
||||
assert.equal(result.prompt.userInput, "部署");
|
||||
assert.equal(result.prompt.cursorOffset, 2);
|
||||
// Pre-echo input is surfaced for alignment, but empty echo alone must not
|
||||
// authorize history recording or third-party completion providers until echo
|
||||
// validates the line. Local history/fig/snippet popups may still query from
|
||||
// the keystroke buffer (#2830).
|
||||
assert.equal(result.alignedTyped, null);
|
||||
assert.equal(result.allowExternalProviders, false);
|
||||
});
|
||||
|
||||
test("uses typed buffer before echo on padded themed prompts", () => {
|
||||
// robbyrussell-style PS1 pads after the arrow: detectPrompt keeps the
|
||||
// trailing space in userInput, which must still count as visually empty.
|
||||
const term = createFakeTerm("➜ ", 3);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "部署", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.userInput, "部署");
|
||||
assert.equal(result.alignedTyped, null);
|
||||
assert.equal(result.allowExternalProviders, false);
|
||||
});
|
||||
|
||||
test("uses typed buffer before echo on Nerd Font themed terminators", () => {
|
||||
const term = createFakeTerm(" ", 2);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "部署", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, " ");
|
||||
assert.equal(result.prompt.userInput, "部署");
|
||||
assert.equal(result.alignedTyped, null);
|
||||
assert.equal(result.allowExternalProviders, false);
|
||||
});
|
||||
|
||||
test("does not treat empty-echo shell-shaped prompts as validated typed input", () => {
|
||||
const term = createFakeTerm("$ ", 2);
|
||||
const secret = "s3cret-token";
|
||||
|
||||
const result = getAlignedPrompt(term as never, secret, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
// Keystroke buffer is still visible on the prompt view, but empty echo
|
||||
// must not authorize history recording or external providers.
|
||||
assert.equal(result.prompt.userInput, secret);
|
||||
assert.equal(result.alignedTyped, null);
|
||||
assert.equal(result.allowExternalProviders, false);
|
||||
});
|
||||
test("still trims prompt decorations out of the detected input", () => {
|
||||
const term = createFakeTerm("➜ ~ do", 7);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "do", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, "➜ ~ ");
|
||||
assert.equal(result.prompt.userInput, "do");
|
||||
assert.equal(result.prompt.cursorOffset, 2);
|
||||
assert.equal(result.alignedTyped, "do");
|
||||
});
|
||||
|
||||
test("detects oh-my-posh Nerd Font chevron (U+F105) prompt terminator", () => {
|
||||
// Real-world PS1 captured from oh-my-posh themed bash on a server:
|
||||
// "<U+F31B> root@oracle ~ <U+F105> " then user input
|
||||
const term = createFakeTerm(" root@oracle ~ ls", 21);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "ls", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, " root@oracle ~ ");
|
||||
assert.equal(result.prompt.userInput, "ls");
|
||||
});
|
||||
|
||||
test("detects Powerline right-arrow (U+E0B0) prompt terminator", () => {
|
||||
// oh-my-posh agnoster-style: colored block ending with U+E0B0 + space
|
||||
const term = createFakeTerm(" root ~ git", 16);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "git", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.userInput, "git");
|
||||
assert.ok(result.prompt.promptText.endsWith(" "));
|
||||
});
|
||||
|
||||
test("PUA char without trailing space is not a prompt boundary", () => {
|
||||
// A bare PUA glyph mid-token (e.g. paste artifact) should not trigger detection.
|
||||
const term = createFakeTerm("echo foo", 13);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, false);
|
||||
});
|
||||
|
||||
test("keeps typed command intact when command text contains Powerline glyphs", () => {
|
||||
const typedInput = "echo foo";
|
||||
const lineText = `$ ${typedInput}`;
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, "$ ");
|
||||
assert.equal(result.prompt.userInput, typedInput);
|
||||
assert.equal(result.alignedTyped, typedInput);
|
||||
});
|
||||
|
||||
test("does not treat a mid-line dollar as a prompt boundary", () => {
|
||||
const lineText = "$ echo $HOME";
|
||||
const term = createFakeTerm(lineText, "$ echo $".length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, "$ ");
|
||||
assert.equal(result.prompt.userInput, "echo $");
|
||||
assert.equal(result.prompt.cursorOffset, "echo $".length);
|
||||
});
|
||||
|
||||
test("does not treat a mid-line redirection as a prompt boundary", () => {
|
||||
const lineText = "$ cat >file";
|
||||
const term = createFakeTerm(lineText, "$ cat >".length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, "$ ");
|
||||
assert.equal(result.prompt.userInput, "cat >");
|
||||
assert.equal(result.prompt.cursorOffset, "cat >".length);
|
||||
});
|
||||
|
||||
test("does not treat a spaced redirection as a prompt boundary", () => {
|
||||
const lineText = "$ cat > file";
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, "$ ");
|
||||
assert.equal(result.prompt.userInput, "cat > file");
|
||||
});
|
||||
|
||||
test("does not treat common interactive program prompts as shell prompts", () => {
|
||||
const cases = [
|
||||
{ lineText: "sftp> get file", typedInput: "get file" },
|
||||
{ lineText: "ftp> ls", typedInput: "ls" },
|
||||
{ lineText: "ghci> :t map", typedInput: ":t map" },
|
||||
{ lineText: "node> .help", typedInput: ".help" },
|
||||
{ lineText: "mongo> db.stats()", typedInput: "db.stats()" },
|
||||
{ lineText: "rs0:PRIMARY> db.stats()", typedInput: "db.stats()" },
|
||||
{ lineText: "rs0 [direct: primary] test> db.stats()", typedInput: "db.stats()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> db.stats()", typedInput: "db.stats()" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> const x = 1", typedInput: "const x = 1" },
|
||||
{ lineText: "rs0 [direct: primary] reporting> await db.users.findOne()", typedInput: "await db.users.findOne()" },
|
||||
{ lineText: "Atlas a [primary] reporting> db.stats()", typedInput: "db.stats()" },
|
||||
{ lineText: "Atlas a [primary] reporting> await db.users.findOne()", typedInput: "await db.users.findOne()" },
|
||||
{ lineText: "rs0 primary reporting> exit", typedInput: "exit" },
|
||||
{ lineText: "irb(main):001> puts 1", typedInput: "puts 1" },
|
||||
{ lineText: "pry(main)> whereami", typedInput: "whereami" },
|
||||
{ lineText: "[1] pry(main)> whereami", typedInput: "whereami" },
|
||||
{ lineText: "SQL> select 1", typedInput: "select 1" },
|
||||
{ lineText: "cqlsh> select * from users", typedInput: "select * from users" },
|
||||
{ lineText: "hive> select 1", typedInput: "select 1" },
|
||||
{ lineText: "spark-sql> select 1", typedInput: "select 1" },
|
||||
{ lineText: "jshell> /help", typedInput: "/help" },
|
||||
{ lineText: " ...> System.out.println(1)", typedInput: "System.out.println(1)" },
|
||||
{ lineText: "ksql> select 1", typedInput: "select 1" },
|
||||
{ lineText: "trino> select 1", typedInput: "select 1" },
|
||||
{ lineText: "trino:tpch> select 1", typedInput: "select 1" },
|
||||
{ lineText: "presto> show catalogs", typedInput: "show catalogs" },
|
||||
{ lineText: "presto:default> show tables", typedInput: "show tables" },
|
||||
{ lineText: "duckdb> select 1", typedInput: "select 1" },
|
||||
{ lineText: "lftp user@example.com:~> ls", typedInput: "ls" },
|
||||
{ lineText: "cqlsh:cycling> select * from cyclist", typedInput: "select * from cyclist" },
|
||||
{ lineText: "hive (default)> select 1", typedInput: "select 1" },
|
||||
{ lineText: "0: jdbc:hive2://localhost:10000/default> select 1", typedInput: "select 1" },
|
||||
{ lineText: "spark-sql (default)> select 1", typedInput: "select 1" },
|
||||
{ lineText: "test> db.stats()", typedInput: "db.stats()" },
|
||||
{ lineText: "test> const x = 1", typedInput: "const x = 1" },
|
||||
{ lineText: "test> await db.users.findOne()", typedInput: "await db.users.findOne()" },
|
||||
{ lineText: "test> db", typedInput: "db" },
|
||||
{ lineText: "rs0 primary test> db.stats()", typedInput: "db.stats()" },
|
||||
{ lineText: "test> rs.status()", typedInput: "rs.status()" },
|
||||
{ lineText: "test> print(1)", typedInput: "print(1)" },
|
||||
{ lineText: "test> 1 + 1", typedInput: "1 + 1" },
|
||||
{ lineText: "admin@localhost:27017> db.stats()", typedInput: "db.stats()" },
|
||||
];
|
||||
|
||||
for (const { lineText, typedInput } of cases) {
|
||||
const result = getAlignedPrompt(
|
||||
createFakeTerm(lineText, lineText.length) as never,
|
||||
typedInput,
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, false, lineText);
|
||||
assert.equal(result.alignedTyped, null, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not treat sensitive authentication challenges as shell prompts", () => {
|
||||
for (const lineText of [
|
||||
"OTP> 123456",
|
||||
"Verification code> 123456",
|
||||
"Duo passcode: 123456",
|
||||
"验证码> 123456",
|
||||
]) {
|
||||
const typedInput = lineText.slice(lineText.lastIndexOf(" ") + 1);
|
||||
const result = getAlignedPrompt(
|
||||
createFakeTerm(lineText, lineText.length) as never,
|
||||
typedInput,
|
||||
true,
|
||||
);
|
||||
assert.equal(result.prompt.isAtPrompt, false, lineText);
|
||||
assert.equal(result.alignedTyped, null, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not treat wrapped interactive program prompts as shell prompts", () => {
|
||||
const cases = [
|
||||
{ rows: ["sftp> get very-long-", "remote-file"], typedInput: "get very-long-remote-file" },
|
||||
{ rows: ["node> console.", "log('ok')"], typedInput: "console.log('ok')" },
|
||||
{ rows: ["mongo> db.", "stats()"], typedInput: "db.stats()" },
|
||||
{ rows: ["cqlsh> select *", " from users"], typedInput: "select * from users" },
|
||||
{ rows: ["jshell> System.out.", "println(1)"], typedInput: "System.out.println(1)" },
|
||||
{ rows: [" ...> System.out.", "println(1)"], typedInput: "System.out.println(1)" },
|
||||
{ rows: ["trino> select", " 1"], typedInput: "select 1" },
|
||||
{ rows: ["trino:tpch> select", " 1"], typedInput: "select 1" },
|
||||
{ rows: ["duckdb> select", " 1"], typedInput: "select 1" },
|
||||
{ rows: ["cqlsh:cycling> select *", " from cyclist"], typedInput: "select * from cyclist" },
|
||||
{ rows: ["hive (default)> select", " 1"], typedInput: "select 1" },
|
||||
{ rows: ["0: jdbc:hive2://localhost:10000/default> select", " 1"], typedInput: "select 1" },
|
||||
{ rows: ["test> db.", "stats()"], typedInput: "db.stats()" },
|
||||
{ rows: ["test> d", "b"], typedInput: "db" },
|
||||
{ rows: ["rs0:PRIMARY> db.", "stats()"], typedInput: "db.stats()" },
|
||||
{ rows: ["rs0 [direct: primary] test> db.", "stats()"], typedInput: "db.stats()" },
|
||||
{ rows: ["rs0 [direct: primary]", " test> db.stats()"], typedInput: "db.stats()" },
|
||||
{ rows: ["rs0 [direct: primary]", " reporting> db.stats()"], typedInput: "db.stats()" },
|
||||
{ rows: ["rs0 [direct: primary]", " reporting> const x = 1"], typedInput: "const x = 1" },
|
||||
{ rows: ["Atlas a [primary]", " reporting> db.stats()"], typedInput: "db.stats()" },
|
||||
{ rows: ["rs0 primary test> db.", "stats()"], typedInput: "db.stats()" },
|
||||
{ rows: ["test> print", "(1)"], typedInput: "print(1)" },
|
||||
{ rows: ["test> 1 ", "+ 1"], typedInput: "1 + 1" },
|
||||
{ rows: ["admin@localhost:27017> db.", "stats()"], typedInput: "db.stats()" },
|
||||
];
|
||||
|
||||
for (const { rows, typedInput } of cases) {
|
||||
const result = getAlignedPrompt(
|
||||
createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never,
|
||||
typedInput,
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, false, rows[0]);
|
||||
assert.equal(result.alignedTyped, null, rows[0]);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps non-Mongo-looking default-name greater-than prompts usable", () => {
|
||||
const prompts = ["test> ", "admin> ", "local> ", "config> "];
|
||||
const commands = ["deploy", "exit", "help", "show dbs"];
|
||||
|
||||
for (const prompt of prompts) {
|
||||
for (const typedInput of commands) {
|
||||
const lineText = `${prompt}${typedInput}`;
|
||||
const result = getAlignedPrompt(
|
||||
createFakeTerm(lineText, lineText.length) as never,
|
||||
typedInput,
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, lineText);
|
||||
assert.equal(result.prompt.promptText, prompt, lineText);
|
||||
assert.equal(result.prompt.userInput, typedInput, lineText);
|
||||
assert.equal(result.alignedTyped, typedInput, lineText);
|
||||
assert.equal(
|
||||
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
|
||||
typedInput,
|
||||
lineText,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps wrapped non-Mongo-looking default-name greater-than prompts usable", () => {
|
||||
const cases = [
|
||||
{ rows: ["test> hel", "p"], typedInput: "help", promptText: "test> " },
|
||||
{ rows: ["test> show ", "dbs"], typedInput: "show dbs", promptText: "test> " },
|
||||
{ rows: ["admin> ex", "it"], typedInput: "exit", promptText: "admin> " },
|
||||
{ rows: ["local> dep", "loy"], typedInput: "deploy", promptText: "local> " },
|
||||
];
|
||||
|
||||
for (const { rows, typedInput, promptText } of cases) {
|
||||
const result = getAlignedPrompt(
|
||||
createWrappedFakeTerm(rows, 1, rows[1].length, 20) as never,
|
||||
typedInput,
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, rows[0]);
|
||||
assert.equal(result.prompt.promptText, promptText, rows[0]);
|
||||
assert.equal(result.prompt.userInput, typedInput, rows[0]);
|
||||
assert.equal(result.alignedTyped, typedInput, rows[0]);
|
||||
assert.equal(
|
||||
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
|
||||
typedInput,
|
||||
rows[0],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps host-style greater-than prompts usable", () => {
|
||||
const prompts = [
|
||||
"prod-web> ",
|
||||
"prod> ",
|
||||
"prod.web> ",
|
||||
"server> ",
|
||||
"staging> ",
|
||||
"webdb> ",
|
||||
"prod.db> ",
|
||||
];
|
||||
const commands = [
|
||||
"deploy",
|
||||
"exit",
|
||||
"show dbs",
|
||||
"use app",
|
||||
"it",
|
||||
"help",
|
||||
"print(1)",
|
||||
"db.stats()",
|
||||
];
|
||||
|
||||
for (const prompt of prompts) {
|
||||
for (const typedInput of commands) {
|
||||
const lineText = `${prompt}${typedInput}`;
|
||||
const result = getAlignedPrompt(
|
||||
createFakeTerm(lineText, lineText.length) as never,
|
||||
typedInput,
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, lineText);
|
||||
assert.equal(result.prompt.promptText, prompt, lineText);
|
||||
assert.equal(result.prompt.userInput, typedInput, lineText);
|
||||
assert.equal(result.alignedTyped, typedInput, lineText);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps strong bare Mongo prompt signals out of shell prompts", () => {
|
||||
const cases = [
|
||||
{ lineText: "test> db.stats()", typedInput: "db.stats()" },
|
||||
{ lineText: "test> db", typedInput: "db" },
|
||||
{ lineText: "test> const x = 1", typedInput: "const x = 1" },
|
||||
{ lineText: "test> await db.users.findOne()", typedInput: "await db.users.findOne()" },
|
||||
{ lineText: "test> print(1)", typedInput: "print(1)" },
|
||||
{ lineText: "test> 1 + 1", typedInput: "1 + 1" },
|
||||
];
|
||||
|
||||
for (const { lineText, typedInput } of cases) {
|
||||
const result = getAlignedPrompt(
|
||||
createFakeTerm(lineText, lineText.length) as never,
|
||||
typedInput,
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, false, lineText);
|
||||
assert.equal(result.alignedTyped, null, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not align stale typed input after themed prompt command suffixes", () => {
|
||||
const cases = [
|
||||
"➜ ~ echo sudo",
|
||||
"➜ echo sudo",
|
||||
"➜ make sudo",
|
||||
"➜ docker sudo",
|
||||
"➜ ./script sudo",
|
||||
"➜ ./script sudo",
|
||||
"➜ ~ echo # sudo",
|
||||
];
|
||||
|
||||
for (const lineText of cases) {
|
||||
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, "sudo", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, lineText);
|
||||
assert.equal(result.prompt.promptText, "➜ ", lineText);
|
||||
assert.equal(result.prompt.userInput, lineText.slice("➜ ".length), lineText);
|
||||
assert.equal(result.alignedTyped, null, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("aligns themed prompt decorations when command echo lags", () => {
|
||||
const typedInput = "git status";
|
||||
const cases = [
|
||||
{ lineText: "➜ ~ git ", promptText: "➜ ~ " },
|
||||
{ lineText: "➜ ~ git st", promptText: "➜ ~ " },
|
||||
{
|
||||
lineText: "➜ netcatty git:(main) ✗ git ",
|
||||
promptText: "➜ netcatty git:(main) ✗ ",
|
||||
},
|
||||
{
|
||||
lineText: "➜ netcatty git:(main) ✗ git st",
|
||||
promptText: "➜ netcatty git:(main) ✗ ",
|
||||
},
|
||||
];
|
||||
|
||||
for (const { lineText, promptText } of cases) {
|
||||
const result = getAlignedPrompt(
|
||||
createFakeTerm(lineText, lineText.length) as never,
|
||||
typedInput,
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, lineText);
|
||||
assert.equal(result.prompt.promptText, promptText, lineText);
|
||||
assert.equal(result.prompt.userInput, typedInput, lineText);
|
||||
assert.equal(result.alignedTyped, typedInput, lineText);
|
||||
assert.equal(
|
||||
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
|
||||
typedInput,
|
||||
lineText,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("trims single-space themed prompt decorations out of the detected input", () => {
|
||||
const cases = [
|
||||
{ lineText: "➜ ~/repo do", typedInput: "do", promptText: "➜ ~/repo " },
|
||||
{
|
||||
lineText: "➜ netcatty git:(main) ✗ ls",
|
||||
typedInput: "ls",
|
||||
promptText: "➜ netcatty git:(main) ✗ ",
|
||||
},
|
||||
{
|
||||
lineText: "➜ netcatty git:(main) ✗ + ls",
|
||||
typedInput: "ls",
|
||||
promptText: "➜ netcatty git:(main) ✗ + ",
|
||||
},
|
||||
{ lineText: "➜ netcatty ✗ $ ls", typedInput: "ls", promptText: "➜ netcatty ✗ $ " },
|
||||
{ lineText: "➜ netcatty $ ls", typedInput: "ls", promptText: "➜ netcatty $ " },
|
||||
];
|
||||
|
||||
for (const { lineText, typedInput, promptText } of cases) {
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, lineText);
|
||||
assert.equal(result.prompt.promptText, promptText, lineText);
|
||||
assert.equal(result.prompt.userInput, typedInput, lineText);
|
||||
assert.equal(result.alignedTyped, typedInput, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not treat later shell symbols followed by spaces as prompt boundaries", () => {
|
||||
const cases = [
|
||||
"$ echo # comment",
|
||||
"$ printf % value",
|
||||
"$ echo $ value",
|
||||
];
|
||||
|
||||
for (const lineText of cases) {
|
||||
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, "", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, lineText);
|
||||
assert.equal(result.prompt.promptText, "$ ", lineText);
|
||||
assert.equal(result.prompt.userInput, lineText.slice(2), lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not treat command-leading shell symbols as prompt boundaries", () => {
|
||||
const cases = [
|
||||
"$ # comment",
|
||||
"$ > file",
|
||||
"$ % value",
|
||||
"$ $ value",
|
||||
"root@host:~# foo $ value",
|
||||
];
|
||||
|
||||
for (const lineText of cases) {
|
||||
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, "", false);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, lineText);
|
||||
const expectedPrompt = lineText.startsWith("root@host:~#") ? "root@host:~# " : "$ ";
|
||||
assert.equal(result.prompt.promptText, expectedPrompt, lineText);
|
||||
assert.equal(result.prompt.userInput, lineText.slice(expectedPrompt.length), lineText);
|
||||
assert.equal(result.alignedTyped, null, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps prompt symbols that are part of the prompt text", () => {
|
||||
const prompts = [
|
||||
"user@host ~/foo#bar $ ",
|
||||
"user@host ~/foo# bar $ ",
|
||||
"user@host:~/foo# bar $ ",
|
||||
"user@host ~/foo% bar $ ",
|
||||
"user@host ~/foo> bar $ ",
|
||||
];
|
||||
const typedInput = "ls";
|
||||
|
||||
for (const prompt of prompts) {
|
||||
const term = createFakeTerm(`${prompt}${typedInput}`, prompt.length + typedInput.length);
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, prompt);
|
||||
assert.equal(result.prompt.promptText, prompt, prompt);
|
||||
assert.equal(result.prompt.userInput, typedInput, prompt);
|
||||
assert.equal(result.alignedTyped, typedInput, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps prompt symbols in prompt text without typed-buffer alignment", () => {
|
||||
const prompts = [
|
||||
"user@host ~/foo# bar $ ",
|
||||
"user@host ~/foo# git $ ",
|
||||
"user@host ~/foo#git $ ",
|
||||
"root@host ~/foo# bar # ",
|
||||
"root@host ~/foo#bar # ",
|
||||
"fish@host ~/foo# bar % ",
|
||||
"fish@host ~/foo%bar % ",
|
||||
"user@host:~/foo# bar $ ",
|
||||
"user@host ~/repo # $ ",
|
||||
"➜ ~ $ ",
|
||||
"user@host ~/foo% bar $ ",
|
||||
"user@host ~/foo> bar $ ",
|
||||
"user@host ~/foo# bar> ",
|
||||
"user@host ~/foo# bar› ",
|
||||
"user@host ~/foo#bar> ",
|
||||
];
|
||||
|
||||
for (const prompt of prompts) {
|
||||
const lineText = `${prompt}ls`;
|
||||
const result = getAlignedPrompt(createFakeTerm(lineText, lineText.length) as never, "", false);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, prompt);
|
||||
assert.equal(result.prompt.promptText, prompt, prompt);
|
||||
assert.equal(result.prompt.userInput, "ls", prompt);
|
||||
assert.equal(result.alignedTyped, null, prompt);
|
||||
}
|
||||
});
|
||||
|
||||
test("prefers standard prompt terminator over later Powerline glyphs", () => {
|
||||
const lineText = "$ echo foo";
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, "$ ");
|
||||
assert.equal(result.prompt.userInput, "echo foo");
|
||||
});
|
||||
|
||||
test("ignores xterm row padding after a no-space root prompt", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const term = createFakeTerm(`${prompt} `, prompt.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, "", true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, prompt);
|
||||
assert.equal(result.prompt.userInput, "");
|
||||
});
|
||||
|
||||
test("aligns typed input after a no-space root prompt", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const typedInput = "printf ok";
|
||||
const lineText = `${prompt}${typedInput}`;
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, prompt);
|
||||
assert.equal(result.prompt.userInput, typedInput);
|
||||
assert.equal(result.alignedTyped, typedInput);
|
||||
});
|
||||
|
||||
test("aligns typed input after a no-space root prompt when shell echo lags", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const typedInput = "printf ok";
|
||||
const echoedInput = typedInput.slice(0, -1);
|
||||
const lineText = `${prompt}${echoedInput}`;
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, prompt);
|
||||
assert.equal(result.prompt.userInput, typedInput);
|
||||
assert.equal(result.alignedTyped, typedInput);
|
||||
});
|
||||
|
||||
test("aligns typed input after a no-space root prompt when shell echo lags by a word", () => {
|
||||
const prompt = " root@stwo:~#";
|
||||
const typedInput = "printf ok";
|
||||
const echoedInput = "printf ";
|
||||
const lineText = `${prompt}${echoedInput}`;
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, prompt);
|
||||
assert.equal(result.prompt.userInput, typedInput);
|
||||
assert.equal(result.alignedTyped, typedInput);
|
||||
});
|
||||
|
||||
test("aligns typed input after a no-space root prompt when a longer command echo lags by a word", () => {
|
||||
const prompt = "root@host:~#";
|
||||
const typedInput = "git status";
|
||||
const echoedInput = "git ";
|
||||
const lineText = `${prompt}${echoedInput}`;
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, prompt);
|
||||
assert.equal(result.prompt.userInput, typedInput);
|
||||
assert.equal(result.alignedTyped, typedInput);
|
||||
});
|
||||
|
||||
test("aligns typed input after a no-space root prompt when command echo lags mid-word", () => {
|
||||
const prompt = "root@host:~#";
|
||||
const typedInput = "git status";
|
||||
const echoedInput = "git st";
|
||||
const lineText = `${prompt}${echoedInput}`;
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, prompt);
|
||||
assert.equal(result.prompt.userInput, typedInput);
|
||||
assert.equal(result.alignedTyped, typedInput);
|
||||
});
|
||||
|
||||
test("aligns reliable typed input when standard prompt echo lags near completion", () => {
|
||||
const typedInput = "git status";
|
||||
const term = createFakeTerm("$ git statu", "$ git statu".length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, "$ ");
|
||||
assert.equal(result.prompt.userInput, typedInput);
|
||||
assert.equal(result.alignedTyped, typedInput);
|
||||
});
|
||||
|
||||
test("aligns reliable typed input when standard prompt echo lags after a word boundary", () => {
|
||||
const typedInput = "git status";
|
||||
const cases = ["$ git ", "$ git st"];
|
||||
|
||||
for (const lineText of cases) {
|
||||
const term = createFakeTerm(lineText, lineText.length);
|
||||
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true, lineText);
|
||||
assert.equal(result.prompt.promptText, "$ ", lineText);
|
||||
assert.equal(result.prompt.userInput, typedInput, lineText);
|
||||
assert.equal(result.alignedTyped, typedInput, lineText);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not record partial standard prompt input while reliable typed input is still echoing", () => {
|
||||
const typedInput = "sudo";
|
||||
const term = createFakeTerm("$ s", "$ s".length);
|
||||
const result = getAlignedPrompt(term as never, typedInput, true);
|
||||
|
||||
assert.equal(result.prompt.isAtPrompt, true);
|
||||
assert.equal(result.prompt.promptText, "$ ");
|
||||
assert.equal(result.prompt.userInput, "s");
|
||||
assert.equal(result.alignedTyped, null);
|
||||
assert.equal(
|
||||
getCommandToRecordOnEnter(result.prompt, result.alignedTyped, typedInput, true),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("CMD path prompts keep pre-echo Chinese input usable for snippet matching (#2813)", () => {
|
||||
const prompts = [
|
||||
String.raw`C:\Users\foo>`,
|
||||
String.raw`C:\Users\用户>`,
|
||||
String.raw`PS C:\Users\foo> `,
|
||||
String.raw`PS C:\Users\用户> `,
|
||||
];
|
||||
const typedInput = "部署";
|
||||
const snippet = { id: "zh", label: "部署服务", command: "echo deploy" };
|
||||
|
||||
for (const prompt of prompts) {
|
||||
const term = createPaddedFakeTerm(prompt, stringCellWidth(prompt));
|
||||
const raw = detectPrompt(term as never);
|
||||
assert.equal(raw.isAtPrompt, true, prompt);
|
||||
assert.equal(raw.userInput.trim(), "", prompt);
|
||||
|
||||
const aligned = getAlignedPrompt(term as never, typedInput, true);
|
||||
assert.equal(aligned.prompt.isAtPrompt, true, prompt);
|
||||
assert.equal(aligned.prompt.userInput, typedInput, prompt);
|
||||
assert.equal(aligned.alignedTyped, null, prompt);
|
||||
assert.equal(aligned.allowExternalProviders, false, prompt);
|
||||
|
||||
const query = resolveAutocompleteQueryInput(
|
||||
aligned.prompt,
|
||||
typedInput,
|
||||
true,
|
||||
);
|
||||
assert.equal(query, typedInput, prompt);
|
||||
assert.equal(
|
||||
getSnippetSuggestions(query ?? "", [snippet as never], {})[0]?.snippet?.id,
|
||||
"zh",
|
||||
prompt,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("CMD path prompts with CJK directories do not absorb padding into echoed Chinese input", () => {
|
||||
const prompt = String.raw`C:\Users\用户>`;
|
||||
const typedInput = "部署";
|
||||
const content = `${prompt}${typedInput}`;
|
||||
const term = createPaddedFakeTerm(content, stringCellWidth(content));
|
||||
|
||||
const raw = detectPrompt(term as never);
|
||||
assert.equal(raw.isAtPrompt, true);
|
||||
assert.equal(raw.promptText, prompt);
|
||||
assert.equal(raw.userInput, typedInput);
|
||||
assert.equal(raw.cursorOffset, typedInput.length);
|
||||
|
||||
// Unreliable typed buffer must still match snippets from the live line
|
||||
// (fresh local CMD sessions often clear keystroke reliability on startup).
|
||||
const query = resolveAutocompleteQueryInput(raw, "", false);
|
||||
assert.equal(query, typedInput);
|
||||
assert.equal(
|
||||
getSnippetSuggestions(query ?? "", [{
|
||||
id: "zh",
|
||||
label: "部署服务",
|
||||
command: "echo deploy",
|
||||
} as never], {})[0]?.snippet?.id,
|
||||
"zh",
|
||||
);
|
||||
});
|
||||
87
components/terminal/ScriptExecutionOverlay.test.ts
Normal file
87
components/terminal/ScriptExecutionOverlay.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mock, test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import React from "react";
|
||||
import { act, create } from "react-test-renderer";
|
||||
|
||||
import {
|
||||
SCRIPT_OVERLAY_TOP_COMPACT_PX,
|
||||
SCRIPT_OVERLAY_TOP_DEFAULT_PX,
|
||||
SCRIPT_OVERLAY_FINISHED_DISMISS_DELAY_MS,
|
||||
ScriptExecutionOverlay,
|
||||
} from "./ScriptExecutionOverlay.tsx";
|
||||
import type { ScriptRun } from "@/types/global/netcatty-bridge-script.d.ts";
|
||||
|
||||
const completedRun: ScriptRun = {
|
||||
runId: "completed-run",
|
||||
sessionId: "session-1",
|
||||
status: "completed",
|
||||
startedAt: 0,
|
||||
endedAt: 1_000,
|
||||
logs: [],
|
||||
};
|
||||
|
||||
test("script overlay sits lower under the full host toolbar than under compact chrome", () => {
|
||||
assert.equal(SCRIPT_OVERLAY_TOP_DEFAULT_PX, 34);
|
||||
assert.equal(SCRIPT_OVERLAY_TOP_COMPACT_PX, 8);
|
||||
assert.ok(SCRIPT_OVERLAY_TOP_COMPACT_PX < SCRIPT_OVERLAY_TOP_DEFAULT_PX);
|
||||
});
|
||||
|
||||
test("script overlay covers compact speed-dial full-width instead of reserving a right gutter", () => {
|
||||
const overlaySource = readFileSync(
|
||||
fileURLToPath(new URL("./ScriptExecutionOverlay.tsx", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
const terminalSource = readFileSync(
|
||||
fileURLToPath(new URL("../Terminal.tsx", import.meta.url)),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(overlaySource, /compactTopChrome/);
|
||||
assert.match(overlaySource, /left-2 right-2/);
|
||||
assert.match(overlaySource, /z-40/);
|
||||
assert.doesNotMatch(overlaySource, /right-10/);
|
||||
assert.match(overlaySource, /SCRIPT_OVERLAY_TOP_COMPACT_PX/);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/compactTopChrome=\{terminalSettings\?\.showHostInfoBar === false\}/,
|
||||
);
|
||||
});
|
||||
|
||||
test("script overlay dismisses a completed run after five seconds", () => {
|
||||
mock.timers.enable({ apis: ["setTimeout"] });
|
||||
let dismissCount = 0;
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
|
||||
const renderOverlay = (onDismiss: () => void) => React.createElement(ScriptExecutionOverlay, {
|
||||
run: completedRun,
|
||||
onPause: () => {},
|
||||
onResume: () => {},
|
||||
onStop: () => {},
|
||||
onDismiss,
|
||||
});
|
||||
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(renderOverlay(() => { dismissCount += 1; }));
|
||||
});
|
||||
|
||||
mock.timers.tick(SCRIPT_OVERLAY_FINISHED_DISMISS_DELAY_MS - 1_000);
|
||||
assert.equal(dismissCount, 0);
|
||||
|
||||
// Script run broadcasts replace the callback without changing this run.
|
||||
act(() => {
|
||||
renderer?.update(renderOverlay(() => { dismissCount += 1; }));
|
||||
});
|
||||
|
||||
mock.timers.tick(999);
|
||||
assert.equal(dismissCount, 0);
|
||||
|
||||
mock.timers.tick(1);
|
||||
assert.equal(dismissCount, 1);
|
||||
} finally {
|
||||
renderer?.unmount();
|
||||
mock.timers.reset();
|
||||
}
|
||||
});
|
||||
285
components/terminal/ScriptExecutionOverlay.tsx
Normal file
285
components/terminal/ScriptExecutionOverlay.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { Check, Loader2, Pause, Play, Square, X } from 'lucide-react';
|
||||
import React, { useEffect, useEffectEvent, useMemo, useState } from 'react';
|
||||
import { useI18n } from '@/application/i18n/I18nProvider';
|
||||
import type { ScriptRun } from '@/types/global/netcatty-bridge-script.d.ts';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils.ts';
|
||||
|
||||
export interface ScriptExecutionOverlayProps {
|
||||
run: ScriptRun;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onStop: () => void;
|
||||
onDismiss: () => void;
|
||||
/**
|
||||
* Host info bar is hidden: no full toolbar. Sit the banner higher and stack
|
||||
* above the compact speed-dial (cover it for the run duration).
|
||||
*/
|
||||
compactTopChrome?: boolean;
|
||||
}
|
||||
|
||||
/** Default top offset under the full host-info toolbar. */
|
||||
export const SCRIPT_OVERLAY_TOP_DEFAULT_PX = 34;
|
||||
/** Top offset when only the compact speed-dial is present. */
|
||||
export const SCRIPT_OVERLAY_TOP_COMPACT_PX = 8;
|
||||
/** Completed script results remain visible briefly before dismissing themselves. */
|
||||
export const SCRIPT_OVERLAY_FINISHED_DISMISS_DELAY_MS = 5_000;
|
||||
|
||||
function formatElapsed(ms: number) {
|
||||
const seconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = seconds % 60;
|
||||
if (minutes > 0) {
|
||||
return `${minutes}:${String(rest).padStart(2, '0')}`;
|
||||
}
|
||||
return `${rest}s`;
|
||||
}
|
||||
|
||||
function resolveWaitingPattern(
|
||||
run: ScriptRun,
|
||||
t: (key: string, params?: Record<string, string | number>) => string,
|
||||
) {
|
||||
if (!run.waitingFor) return undefined;
|
||||
if (run.waitingFor === 'shell prompt' || run.waitingFor.includes(' | ')) {
|
||||
return t('scripts.running.waitingForShellPrompt');
|
||||
}
|
||||
return run.waitingFor;
|
||||
}
|
||||
|
||||
function isLowValueActivityLabel(label?: string) {
|
||||
if (!label) return true;
|
||||
const normalized = label.trim().toLowerCase();
|
||||
return normalized === 'log' || normalized.startsWith('sleep ');
|
||||
}
|
||||
|
||||
function DotSeparator() {
|
||||
return <span className="text-muted-foreground/35 px-0.5">·</span>;
|
||||
}
|
||||
|
||||
function Muted({ children }: { children: React.ReactNode }) {
|
||||
return <span className="text-muted-foreground">{children}</span>;
|
||||
}
|
||||
|
||||
function Accent({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<span className={cn('text-primary font-semibold tabular-nums', className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ScriptStatusIcon({ status }: { status: ScriptRun['status'] }) {
|
||||
const iconClass = 'block';
|
||||
const boxClass = 'inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center';
|
||||
if (status === 'completed') {
|
||||
return (
|
||||
<span className={boxClass} aria-hidden>
|
||||
<Check size={14} className={cn(iconClass, 'text-emerald-500')} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return (
|
||||
<span className={boxClass} aria-hidden>
|
||||
<X size={14} className={cn(iconClass, 'text-destructive')} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={boxClass} aria-hidden>
|
||||
<Loader2 size={14} className={cn(iconClass, 'animate-spin text-primary')} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ScriptStatusLine({
|
||||
run,
|
||||
elapsedMs,
|
||||
lastSent,
|
||||
t,
|
||||
}: {
|
||||
run: ScriptRun;
|
||||
elapsedMs: number;
|
||||
lastSent?: string;
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
}) {
|
||||
const label = run.scriptLabel || t('scripts.running.unnamed');
|
||||
const opCount = run.stepIndex ?? 0;
|
||||
const elapsed = formatElapsed(elapsedMs);
|
||||
const waitingPattern = resolveWaitingPattern(run, t);
|
||||
const isFinished = run.status === 'completed' || run.status === 'failed';
|
||||
|
||||
const opsSegment = (
|
||||
<>
|
||||
<Muted>{t('scripts.running.opsPrefix')}</Muted>
|
||||
<Accent>{opCount}</Accent>
|
||||
<Muted>{t('scripts.running.opsSuffix')}</Muted>
|
||||
</>
|
||||
);
|
||||
|
||||
const elapsedSegment = <Accent>{elapsed}</Accent>;
|
||||
|
||||
const activitySegment = !isLowValueActivityLabel(run.activityLabel) ? (
|
||||
<>
|
||||
<DotSeparator />
|
||||
<span className="text-foreground/90">{run.activityLabel}</span>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
const progressSegment = run.progressMode === 'determinate' && run.progressTotal ? (
|
||||
<>
|
||||
<DotSeparator />
|
||||
<Muted>{run.progressLabel || t('scripts.running.progressFallback')}</Muted>
|
||||
{' '}
|
||||
<Accent>
|
||||
{run.progressCurrent ?? 0}
|
||||
/
|
||||
{run.progressTotal}
|
||||
</Accent>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
const pausedSegment = run.status === 'paused' ? (
|
||||
<>
|
||||
<DotSeparator />
|
||||
<span className="text-amber-500 font-medium">{t('scripts.running.status.paused')}</span>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
const waitingSegment = waitingPattern ? (
|
||||
<>
|
||||
<DotSeparator />
|
||||
<Muted>{t('scripts.running.waitingForLabel')}</Muted>
|
||||
{' '}
|
||||
<span className="text-amber-500 font-medium">{waitingPattern}</span>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
const lastSentSegment = !waitingPattern && lastSent ? (
|
||||
<>
|
||||
<DotSeparator />
|
||||
<Muted>{t('scripts.running.lastSentLabel')}</Muted>
|
||||
{' '}
|
||||
<span className="text-primary/90 font-mono">{lastSent}</span>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 leading-4">
|
||||
<ScriptStatusIcon status={run.status} />
|
||||
<span className="shrink-0 whitespace-nowrap font-semibold text-foreground">{label}</span>
|
||||
<span className="inline-flex min-w-0 flex-1 items-center truncate">
|
||||
{(isFinished || opCount > 0) ? (
|
||||
<>
|
||||
<DotSeparator />
|
||||
{opsSegment}
|
||||
</>
|
||||
) : null}
|
||||
<DotSeparator />
|
||||
{elapsedSegment}
|
||||
{!isFinished ? (
|
||||
<>
|
||||
{progressSegment}
|
||||
{activitySegment}
|
||||
{pausedSegment}
|
||||
{waitingSegment}
|
||||
{lastSentSegment}
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export const ScriptExecutionOverlay: React.FC<ScriptExecutionOverlayProps> = ({
|
||||
run,
|
||||
onPause,
|
||||
onResume,
|
||||
onStop,
|
||||
onDismiss,
|
||||
compactTopChrome = false,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [tick, setTick] = useState(0);
|
||||
const isFinished = run.status === 'completed' || run.status === 'failed';
|
||||
const dismissFinishedRun = useEffectEvent(onDismiss);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFinished) return undefined;
|
||||
const timer = window.setInterval(() => setTick((value) => value + 1), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [isFinished, run.runId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFinished) return undefined;
|
||||
const timer = setTimeout(dismissFinishedRun, SCRIPT_OVERLAY_FINISHED_DISMISS_DELAY_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isFinished, run.runId]);
|
||||
|
||||
void tick;
|
||||
|
||||
const elapsedMs = run.elapsedMs
|
||||
?? (run.endedAt ? run.endedAt - run.startedAt : Date.now() - run.startedAt);
|
||||
const lastSent = [...(run.logs || [])].reverse().find((entry) => entry.message.startsWith('→ '))?.message.slice(2);
|
||||
const errorMessage = run.status === 'failed' ? run.error : undefined;
|
||||
|
||||
const statusLine = useMemo(
|
||||
() => (
|
||||
<ScriptStatusLine run={run} elapsedMs={elapsedMs} lastSent={lastSent} t={t} />
|
||||
),
|
||||
[run, elapsedMs, lastSent, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
// z-40 sits above the compact speed-dial (z-30) so the full-width banner
|
||||
// covers the toggle while a script is running — no right-edge gutter.
|
||||
className="absolute left-2 right-2 z-40 rounded-md border shadow-md backdrop-blur-md pointer-events-auto px-3 py-2"
|
||||
style={{
|
||||
top: compactTopChrome ? SCRIPT_OVERLAY_TOP_COMPACT_PX : SCRIPT_OVERLAY_TOP_DEFAULT_PX,
|
||||
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg) 92%, transparent)',
|
||||
borderColor: 'var(--terminal-ui-border)',
|
||||
color: 'var(--terminal-ui-fg)',
|
||||
}}
|
||||
data-section="script-execution-overlay"
|
||||
data-compact-top-chrome={compactTopChrome ? "true" : "false"}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex min-w-0 flex-1 items-center text-[11px] leading-4">
|
||||
{statusLine}
|
||||
</div>
|
||||
{errorMessage ? (
|
||||
<div
|
||||
className="min-w-0 max-w-[42%] shrink truncate text-right text-[11px] leading-4 text-destructive"
|
||||
title={errorMessage}
|
||||
>
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{isFinished ? (
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onDismiss} title={t('scripts.running.dismiss')}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{run.status === 'running' ? (
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onPause}>
|
||||
<Pause size={14} />
|
||||
</Button>
|
||||
) : null}
|
||||
{run.status === 'paused' ? (
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onResume}>
|
||||
<Play size={14} />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onStop}>
|
||||
<Square size={14} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
51
components/terminal/ScriptRecordingIndicator.tsx
Normal file
51
components/terminal/ScriptRecordingIndicator.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Pause, Play, Square } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '@/application/i18n/I18nProvider';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils.ts';
|
||||
|
||||
export interface ScriptRecordingIndicatorProps {
|
||||
elapsedMs: number;
|
||||
isPaused: boolean;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
function formatElapsed(ms: number) {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = seconds % 60;
|
||||
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export const ScriptRecordingIndicator: React.FC<ScriptRecordingIndicatorProps> = ({
|
||||
elapsedMs,
|
||||
isPaused,
|
||||
onPause,
|
||||
onResume,
|
||||
onStop,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="flex items-center gap-1 text-red-500 font-medium">
|
||||
<span className={cn('inline-block h-2 w-2 rounded-full bg-red-500', !isPaused && 'animate-pulse')} />
|
||||
REC
|
||||
</span>
|
||||
<span className="tabular-nums text-muted-foreground">{formatElapsed(elapsedMs)}</span>
|
||||
{isPaused ? (
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onResume} title={t('scripts.recording.resume')}>
|
||||
<Play size={14} />
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onPause} title={t('scripts.recording.pause')}>
|
||||
<Pause size={14} />
|
||||
</Button>
|
||||
)}
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={onStop} title={t('scripts.recording.stop')}>
|
||||
<Square size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
81
components/terminal/SessionInlineRenameInput.tsx
Normal file
81
components/terminal/SessionInlineRenameInput.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
type SessionInlineRenameInputProps = {
|
||||
initialName: string;
|
||||
onCommit: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export const SessionInlineRenameInput: React.FC<SessionInlineRenameInputProps> = ({
|
||||
initialName,
|
||||
onCommit,
|
||||
onCancel,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState(initialName);
|
||||
const committedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const input = inputRef.current;
|
||||
if (!input) return;
|
||||
input.focus();
|
||||
input.select();
|
||||
}, []);
|
||||
|
||||
const commit = () => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
onCommit(value);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
if (committedRef.current) return;
|
||||
committedRef.current = true;
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
data-session-inline-rename="true"
|
||||
value={value}
|
||||
draggable={false}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={() => {
|
||||
queueMicrotask(() => {
|
||||
commit();
|
||||
});
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onDoubleClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onDragStart={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'min-w-0 flex-1 truncate select-text rounded-sm border border-primary/50 bg-background/80 px-1 py-0 text-sm font-medium outline-none ring-1 ring-primary/30',
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
};
|
||||
311
components/terminal/TerminalAuthDialog.tsx
Normal file
311
components/terminal/TerminalAuthDialog.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Terminal Authentication Dialog
|
||||
* Displays auth form with password/key selection for SSH connection
|
||||
*/
|
||||
import { BadgeCheck, ChevronDown, Eye, EyeOff, Key, Lock, Unplug } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { SSHKey } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from '../ui/dropdown';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
|
||||
export type TerminalAuthMethod = 'password' | 'key' | 'certificate';
|
||||
|
||||
export interface TerminalAuthDialogProps {
|
||||
authMethod: TerminalAuthMethod;
|
||||
setAuthMethod: (method: TerminalAuthMethod) => void;
|
||||
authUsername: string;
|
||||
setAuthUsername: (username: string) => void;
|
||||
authPassword: string;
|
||||
setAuthPassword: (password: string) => void;
|
||||
authKeyId: string | null;
|
||||
setAuthKeyId: (keyId: string | null) => void;
|
||||
authPassphrase: string;
|
||||
setAuthPassphrase: (passphrase: string) => void;
|
||||
showAuthPassphrase: boolean;
|
||||
setShowAuthPassphrase: (show: boolean) => void;
|
||||
showAuthPassword: boolean;
|
||||
setShowAuthPassword: (show: boolean) => void;
|
||||
authRetryMessage: string | null;
|
||||
keys: SSHKey[];
|
||||
onSubmit: () => void;
|
||||
onSubmitWithoutSave?: () => void;
|
||||
onCancel: () => void;
|
||||
isValid: boolean;
|
||||
}
|
||||
|
||||
export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
|
||||
authMethod,
|
||||
setAuthMethod,
|
||||
authUsername,
|
||||
setAuthUsername,
|
||||
authPassword,
|
||||
setAuthPassword,
|
||||
authKeyId,
|
||||
setAuthKeyId,
|
||||
authPassphrase,
|
||||
setAuthPassphrase,
|
||||
showAuthPassphrase,
|
||||
setShowAuthPassphrase,
|
||||
showAuthPassword,
|
||||
setShowAuthPassword,
|
||||
authRetryMessage,
|
||||
keys,
|
||||
onSubmit,
|
||||
onSubmitWithoutSave,
|
||||
onCancel,
|
||||
isValid,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const handleContinue = onSubmitWithoutSave ?? onSubmit;
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && isValid) {
|
||||
handleContinue();
|
||||
}
|
||||
};
|
||||
|
||||
// Show all keys (both regular keys and certificates) in the single key picker.
|
||||
const selectableKeys = React.useMemo(
|
||||
() => keys.filter((k) => k.category === 'key' || Boolean(k.certificate?.trim())),
|
||||
[keys],
|
||||
);
|
||||
|
||||
const [keyDropdownOpen, setKeyDropdownOpen] = React.useState(false);
|
||||
const [submitOptionsOpen, setSubmitOptionsOpen] = React.useState(false);
|
||||
|
||||
const selectedKey = authKeyId ? keys.find((k) => k.id === authKeyId) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Auth method tabs */}
|
||||
<div className="flex gap-1 p-1 bg-secondary/65 rounded-xl border border-border/50">
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs font-medium rounded-lg transition-all",
|
||||
authMethod === 'password'
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/40"
|
||||
)}
|
||||
onClick={() => setAuthMethod('password')}
|
||||
>
|
||||
<Lock size={13} />
|
||||
{t("terminal.auth.password")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-1.5 py-1.5 text-xs font-medium rounded-lg transition-all",
|
||||
authMethod === 'key' || authMethod === 'certificate'
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-background/40"
|
||||
)}
|
||||
onClick={() => setAuthMethod('key')}
|
||||
>
|
||||
<Key size={13} />
|
||||
{t("terminal.auth.sshKey")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Auth retry error message */}
|
||||
{authRetryMessage && (
|
||||
<div className="flex items-center gap-2.5 rounded-xl border border-destructive/20 bg-destructive/7 px-3 py-2.5 text-xs text-foreground/90">
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-destructive/12 text-destructive">
|
||||
<Unplug size={11} />
|
||||
</div>
|
||||
<div className="min-w-0 leading-4 text-destructive/95">
|
||||
{authRetryMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth-username">{t("terminal.auth.username")}</Label>
|
||||
<Input
|
||||
id="auth-username"
|
||||
value={authUsername}
|
||||
onChange={(e) => setAuthUsername(e.target.value)}
|
||||
placeholder={t("terminal.auth.username.placeholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{authMethod === 'password' ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth-password">{t("terminal.auth.passwordLabel")}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="auth-password"
|
||||
type={showAuthPassword ? 'text' : 'password'}
|
||||
value={authPassword}
|
||||
onChange={(e) => setAuthPassword(e.target.value)}
|
||||
placeholder={t("terminal.auth.password.placeholder")}
|
||||
className={cn("pr-10", authRetryMessage && "border-destructive/50")}
|
||||
autoFocus={!!authRetryMessage}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowAuthPassword(!showAuthPassword)}
|
||||
>
|
||||
{showAuthPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("terminal.auth.selectKey")}</Label>
|
||||
{selectableKeys.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground p-3 border border-dashed border-border/60 rounded-lg text-center">
|
||||
{t("terminal.auth.noKeysHint")}
|
||||
</div>
|
||||
) : (
|
||||
<Popover open={keyDropdownOpen} onOpenChange={setKeyDropdownOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-colors text-left",
|
||||
selectedKey
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border/50 hover:bg-secondary/50"
|
||||
)}
|
||||
>
|
||||
{selectedKey ? (
|
||||
<>
|
||||
<div className={cn(
|
||||
"h-8 w-8 rounded-lg flex items-center justify-center shrink-0",
|
||||
selectedKey.certificate?.trim()
|
||||
? "bg-emerald-500/20 text-emerald-500"
|
||||
: "bg-primary/20 text-primary"
|
||||
)}>
|
||||
{selectedKey.certificate?.trim()
|
||||
? <BadgeCheck size={14} />
|
||||
: <Key size={14} />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{selectedKey.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{selectedKey.certificate?.trim() ? t("terminal.auth.certificate") : selectedKey.type}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">{t("hostForm.auth.selectKey")}</span>
|
||||
)}
|
||||
<ChevronDown size={16} className="text-muted-foreground shrink-0 ml-auto" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-1" align="start" style={{ width: 'var(--radix-popover-trigger-width)' }}>
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{selectableKeys.map((key) => (
|
||||
<button
|
||||
key={key.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2 rounded-md transition-colors text-left",
|
||||
authKeyId === key.id
|
||||
? "bg-primary/10 text-primary"
|
||||
: "hover:bg-secondary/80"
|
||||
)}
|
||||
onClick={() => {
|
||||
setAuthKeyId(key.id);
|
||||
setAuthMethod(key.certificate?.trim() ? 'certificate' : 'key');
|
||||
setAuthPassphrase(key.passphrase || '');
|
||||
setKeyDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className={cn(
|
||||
"h-7 w-7 rounded-md flex items-center justify-center shrink-0",
|
||||
key.certificate?.trim()
|
||||
? "bg-emerald-500/20 text-emerald-500"
|
||||
: "bg-primary/20 text-primary"
|
||||
)}>
|
||||
{key.certificate?.trim()
|
||||
? <BadgeCheck size={12} />
|
||||
: <Key size={12} />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">{key.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{key.certificate?.trim() ? t("terminal.auth.certificate") : key.type}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth-passphrase">{t("terminal.auth.passphrase")}</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="auth-passphrase"
|
||||
type={showAuthPassphrase ? 'text' : 'password'}
|
||||
value={authPassphrase}
|
||||
onChange={(e) => setAuthPassphrase(e.target.value)}
|
||||
placeholder={t("terminal.auth.passphrase.placeholder")}
|
||||
className="pr-10"
|
||||
disabled={!selectedKey}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground disabled:opacity-50"
|
||||
onClick={() => setShowAuthPassphrase(!showAuthPassphrase)}
|
||||
disabled={!selectedKey}
|
||||
>
|
||||
{showAuthPassphrase ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
<Dropdown open={submitOptionsOpen} onOpenChange={setSubmitOptionsOpen}>
|
||||
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
|
||||
<Button
|
||||
disabled={!isValid}
|
||||
onClick={handleContinue}
|
||||
className="rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
|
||||
>
|
||||
{t("common.continue")}
|
||||
</Button>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
disabled={!isValid}
|
||||
aria-label={t("terminal.auth.continueSave")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={submitOptionsOpen}
|
||||
className="px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
<DropdownContent className="w-44 p-1 z-50" align="end">
|
||||
<button
|
||||
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
|
||||
onClick={onSubmit}
|
||||
disabled={!isValid}
|
||||
>
|
||||
{t("terminal.auth.continueSave")}
|
||||
</button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalAuthDialog;
|
||||
193
components/terminal/TerminalAutocomplete.tsx
Normal file
193
components/terminal/TerminalAutocomplete.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import ReactDOM from "react-dom";
|
||||
import { useCallback, type ComponentProps, type RefObject } from "react";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import {
|
||||
useTerminalAutocomplete,
|
||||
AutocompletePopup,
|
||||
type AutocompleteSettings,
|
||||
} from "./autocomplete";
|
||||
import type { Snippet } from "../../domain/models";
|
||||
import { usePaneVisible } from "./paneVisibilityStore";
|
||||
import { getWindowPluginTerminalProviderRegistry } from "../../application/state/pluginTerminalProviderRegistry";
|
||||
import { provideTerminalCompletions } from "./autocomplete/terminalCompletionProviders";
|
||||
import { shouldUsePluginTerminalCompletionProvider } from "../../domain/terminalPromptSecurity";
|
||||
|
||||
type PopupProps = ComponentProps<typeof AutocompletePopup>;
|
||||
|
||||
/** A mutable handler ref Terminal hands down for the xterm runtime to call. */
|
||||
type HandlerRef<T> = { current: T | undefined };
|
||||
|
||||
interface TerminalAutocompleteProps {
|
||||
termRef: RefObject<XTerm | null>;
|
||||
sessionId: string;
|
||||
hostId: string;
|
||||
hostGroup?: string;
|
||||
hostOs: "linux" | "windows" | "macos";
|
||||
settings?: Partial<AutocompleteSettings>;
|
||||
protocol?: string;
|
||||
workspaceId?: string;
|
||||
status?: "connecting" | "connected" | "disconnected";
|
||||
/** Pane visibility fallback when paneVisibilityStore has no entry (popup terminals). */
|
||||
isVisible?: boolean;
|
||||
getCwd?: () => string | undefined;
|
||||
onAcceptText: (text: string) => void;
|
||||
snippets?: Snippet[];
|
||||
onAcceptSnippet?: (snippet: Snippet) => void;
|
||||
themeColors: PopupProps["themeColors"];
|
||||
containerRef: PopupProps["containerRef"];
|
||||
searchBarOffset: number;
|
||||
// Handlers exposed back to Terminal so createXTermRuntime can drive them.
|
||||
keyEventRef: HandlerRef<(e: KeyboardEvent) => boolean>;
|
||||
inputRef: HandlerRef<(data: string) => void>;
|
||||
repositionRef: HandlerRef<() => void>;
|
||||
closeRef: HandlerRef<() => void>;
|
||||
sudoHintRef: HandlerRef<(active: boolean) => boolean>;
|
||||
sudoHintText: string;
|
||||
isPluginCompletionProviderAvailable?: () => boolean;
|
||||
sensitiveInputActiveRef: RefObject<boolean>;
|
||||
allowHostStyleGreaterThanPrompt?: boolean;
|
||||
/** Vendor CLI / network-device session: skip live-preview PTY rewrites (#1193). */
|
||||
isNetworkDevice?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the terminal autocomplete hook and renders its popup.
|
||||
*
|
||||
* Kept as its own component so the frequent autocomplete state updates
|
||||
* (suggestions, selection, live-preview navigation) re-render only this small
|
||||
* subtree rather than the whole Terminal component. The hook's handlers are
|
||||
* surfaced back to Terminal through refs so the xterm runtime can call them.
|
||||
*
|
||||
* Must be mounted unconditionally for the terminal session's lifetime: the hook
|
||||
* records command history on Enter and intercepts completion keys even while no
|
||||
* popup is visible. Visibility only gates the rendered popup, not the hook.
|
||||
*/
|
||||
export function TerminalAutocomplete({
|
||||
termRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostGroup,
|
||||
hostOs,
|
||||
settings,
|
||||
protocol,
|
||||
workspaceId,
|
||||
status = "connected",
|
||||
isVisible = true,
|
||||
getCwd,
|
||||
onAcceptText,
|
||||
snippets,
|
||||
onAcceptSnippet,
|
||||
themeColors,
|
||||
containerRef,
|
||||
searchBarOffset,
|
||||
keyEventRef,
|
||||
inputRef,
|
||||
repositionRef,
|
||||
closeRef,
|
||||
sudoHintRef,
|
||||
sudoHintText,
|
||||
isPluginCompletionProviderAvailable,
|
||||
sensitiveInputActiveRef,
|
||||
allowHostStyleGreaterThanPrompt = false,
|
||||
isNetworkDevice = false,
|
||||
}: TerminalAutocompleteProps) {
|
||||
// Self-subscribe to this pane's visibility so toggling it doesn't have to
|
||||
// flow through (and re-render) the TerminalView ctx. Popup / standalone
|
||||
// Terminal mounts never publish the store — fall back to the isVisible prop
|
||||
// (same contract as hibernate).
|
||||
const visible = usePaneVisible(sessionId, isVisible);
|
||||
const provideCompletions = useCallback(async (
|
||||
input: string,
|
||||
options: Parameters<typeof import("./autocomplete/completionEngine").getCompletions>[1] & {
|
||||
promptText: string;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
) => {
|
||||
const normalizedProtocol: NetcattyTerminalSessionSnapshot['protocol'] = protocol ?? "ssh";
|
||||
const pluginRegistry = isPluginCompletionProviderAvailable?.() === false
|
||||
|| options.allowExternalProviders === false
|
||||
|| !shouldUsePluginTerminalCompletionProvider({
|
||||
sensitiveInputActive: sensitiveInputActiveRef.current === true,
|
||||
promptText: options.promptText,
|
||||
allowHostStyleGreaterThan: allowHostStyleGreaterThanPrompt,
|
||||
})
|
||||
? null
|
||||
: getWindowPluginTerminalProviderRegistry();
|
||||
return provideTerminalCompletions(pluginRegistry, {
|
||||
input,
|
||||
session: {
|
||||
sessionId,
|
||||
...(hostId ? { hostId } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
protocol: normalizedProtocol,
|
||||
status,
|
||||
...(options.cwd ? { cwd: options.cwd } : {}),
|
||||
},
|
||||
hostOs,
|
||||
hostGroup,
|
||||
cwdSource: options.cwdSource,
|
||||
snippets: options.snippets,
|
||||
maximum: options.maxResults ?? 15,
|
||||
historyScope: options.historyScope ?? settings?.historyScope,
|
||||
signal: options.signal,
|
||||
onLatePathSuggestions: options.onLatePathSuggestions,
|
||||
});
|
||||
}, [allowHostStyleGreaterThanPrompt, hostGroup, hostId, hostOs, isPluginCompletionProviderAvailable, protocol, sensitiveInputActiveRef, sessionId, settings?.historyScope, status, workspaceId]);
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
containerRef,
|
||||
sessionId,
|
||||
hostId,
|
||||
hostGroup,
|
||||
hostOs,
|
||||
settings,
|
||||
onAcceptText,
|
||||
snippets,
|
||||
onAcceptSnippet,
|
||||
protocol,
|
||||
getCwd,
|
||||
sensitiveInputActiveRef,
|
||||
provideCompletions,
|
||||
isNetworkDevice,
|
||||
});
|
||||
|
||||
// Surface the handlers for runtime wiring. They have stable identities
|
||||
// (useCallback over refs), so assigning during render is cheap and mirrors
|
||||
// the wiring Terminal did inline before this was extracted.
|
||||
keyEventRef.current = autocomplete.handleKeyEvent;
|
||||
inputRef.current = autocomplete.handleInput;
|
||||
repositionRef.current = autocomplete.repositionPopup;
|
||||
closeRef.current = autocomplete.closePopup;
|
||||
sudoHintRef.current = (active: boolean): boolean => {
|
||||
if (!active) {
|
||||
autocomplete.hideSudoHint();
|
||||
return false;
|
||||
}
|
||||
return autocomplete.showSudoHint(sudoHintText);
|
||||
};
|
||||
|
||||
const { state } = autocomplete;
|
||||
if (!visible || !state.popupVisible || state.suggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Portal to body so the popup escapes the terminal container's overflow.
|
||||
return ReactDOM.createPortal(
|
||||
<AutocompletePopup
|
||||
suggestions={state.suggestions}
|
||||
selectedIndex={state.selectedIndex}
|
||||
anchorViewport={state.popupAnchorViewport}
|
||||
visible={state.popupVisible}
|
||||
expandUpward={state.expandUpward}
|
||||
themeColors={themeColors}
|
||||
onSelect={autocomplete.selectSuggestion}
|
||||
subDirPanels={state.subDirPanels}
|
||||
subDirFocusLevel={state.subDirFocusLevel}
|
||||
containerRef={containerRef}
|
||||
onRequestReposition={autocomplete.repositionPopup}
|
||||
searchBarOffset={searchBarOffset}
|
||||
onDismiss={autocomplete.closePopup}
|
||||
/>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
526
components/terminal/TerminalComposeBar.tsx
Normal file
526
components/terminal/TerminalComposeBar.tsx
Normal file
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* Terminal Compose Bar
|
||||
* An immersive prompt bar below the terminal with a quick-snippet strip,
|
||||
* user-resizable height, and terminal-matched chrome.
|
||||
*/
|
||||
import { GripHorizontal, Pin, Plus, Radio, Search, X } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useComposeBarHeight } from '../../application/state/useComposeBarHeight';
|
||||
import { useComposeBarPinnedSnippets } from '../../application/state/useComposeBarPinnedSnippets';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { resolveSnippetCommand } from '../SnippetExecutionProvider';
|
||||
import { Snippet } from '../../types';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import {
|
||||
buildSnippetIdKey,
|
||||
filterComposeBarSnippets,
|
||||
mergeComposeBarSnippetMap,
|
||||
resolveComposeBarDefaultSeedIds,
|
||||
} from './composeBarHelpers';
|
||||
|
||||
const SNIPPET_STRIP_HEIGHT = 30;
|
||||
const RESIZE_HANDLE_HEIGHT = 6;
|
||||
|
||||
type ComposeBarTheme = {
|
||||
resolvedBg: string;
|
||||
resolvedFg: string;
|
||||
borderColor: string;
|
||||
mutedFg: string;
|
||||
hoverBg: string;
|
||||
chipBg: string;
|
||||
chipHoverBg: string;
|
||||
};
|
||||
|
||||
function buildTheme(themeColors?: { background: string; foreground: string }): ComposeBarTheme {
|
||||
const bg = themeColors?.background ?? '#0a0a0a';
|
||||
const fg = themeColors?.foreground ?? '#d4d4d4';
|
||||
const resolvedBg = 'var(--terminal-ui-bg, ' + bg + ')';
|
||||
const resolvedFg = 'var(--terminal-ui-fg, ' + fg + ')';
|
||||
return {
|
||||
resolvedBg,
|
||||
resolvedFg,
|
||||
borderColor: `color-mix(in srgb, ${resolvedFg} 8%, ${resolvedBg} 92%)`,
|
||||
mutedFg: `color-mix(in srgb, ${resolvedFg} 55%, ${resolvedBg} 45%)`,
|
||||
hoverBg: `color-mix(in srgb, ${resolvedFg} 10%, ${resolvedBg} 90%)`,
|
||||
chipBg: `color-mix(in srgb, ${resolvedFg} 6%, ${resolvedBg} 94%)`,
|
||||
chipHoverBg: `color-mix(in srgb, ${resolvedFg} 12%, ${resolvedBg} 88%)`,
|
||||
};
|
||||
}
|
||||
|
||||
interface ComposeBarSnippetChipProps {
|
||||
snippet: Snippet;
|
||||
theme: ComposeBarTheme;
|
||||
onActivate: (snippet: Snippet, sendImmediately: boolean) => void;
|
||||
onUnpin: (id: string) => void;
|
||||
unpinLabel: string;
|
||||
clickHint: string;
|
||||
}
|
||||
|
||||
const ComposeBarSnippetChip = memo(function ComposeBarSnippetChip({
|
||||
snippet,
|
||||
theme,
|
||||
onActivate,
|
||||
onUnpin,
|
||||
unpinLabel,
|
||||
clickHint,
|
||||
}: ComposeBarSnippetChipProps) {
|
||||
const commandPreview = snippet.command.split('\n')[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/chip flex items-stretch h-6 max-w-[168px] rounded overflow-hidden flex-shrink-0 transition-colors duration-150"
|
||||
style={{ backgroundColor: theme.chipBg, color: theme.resolvedFg }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = theme.chipHoverBg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = theme.chipBg;
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 min-w-0 px-2 text-[10px] font-mono truncate text-left"
|
||||
onClick={(e) => { void onActivate(snippet, e.shiftKey); }}
|
||||
>
|
||||
{snippet.label}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="font-medium">{snippet.label}</p>
|
||||
<p className="text-[10px] opacity-80 mt-0.5 font-mono line-clamp-2">
|
||||
{commandPreview}
|
||||
</p>
|
||||
<p className="text-[10px] opacity-60 mt-1">{clickHint}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center justify-center w-5 shrink-0',
|
||||
'opacity-40 hover:opacity-100 group-hover/chip:opacity-70',
|
||||
'transition-opacity duration-150',
|
||||
)}
|
||||
style={{ color: theme.mutedFg }}
|
||||
aria-label={unpinLabel}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onUnpin(snippet.id);
|
||||
}}
|
||||
>
|
||||
<X size={9} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface ComposeBarSnippetManagePopoverProps {
|
||||
snippets: Snippet[];
|
||||
pinnedCount: number;
|
||||
theme: ComposeBarTheme;
|
||||
isPinned: (id: string) => boolean;
|
||||
onTogglePin: (id: string) => void;
|
||||
manageLabel: string;
|
||||
searchPlaceholder: string;
|
||||
noSnippetsLabel: string;
|
||||
noMatchingLabel: string;
|
||||
pinnedCountLabel: string;
|
||||
}
|
||||
|
||||
const ComposeBarSnippetManagePopover = memo(function ComposeBarSnippetManagePopover({
|
||||
snippets,
|
||||
pinnedCount,
|
||||
theme,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
manageLabel,
|
||||
searchPlaceholder,
|
||||
noSnippetsLabel,
|
||||
noMatchingLabel,
|
||||
pinnedCountLabel,
|
||||
}: ComposeBarSnippetManagePopoverProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredSnippets = useMemo(
|
||||
() => filterComposeBarSnippets(snippets, search),
|
||||
[snippets, search],
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
setOpen(next);
|
||||
if (!next) setSearch('');
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="h-6 w-6 flex-shrink-0 flex items-center justify-center rounded transition-colors duration-150"
|
||||
style={{ color: theme.mutedFg }}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = theme.hoverBg;
|
||||
e.currentTarget.style.color = theme.resolvedFg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = 'transparent';
|
||||
e.currentTarget.style.color = theme.mutedFg;
|
||||
}}
|
||||
aria-label={manageLabel}
|
||||
title={manageLabel}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-72 p-0"
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
style={{
|
||||
backgroundColor: theme.resolvedBg,
|
||||
borderColor: theme.borderColor,
|
||||
color: theme.resolvedFg,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="px-2.5 py-2 border-b"
|
||||
style={{ borderColor: theme.borderColor }}
|
||||
>
|
||||
<p className="text-[11px] font-semibold mb-1.5">{manageLabel}</p>
|
||||
<div
|
||||
className="flex items-center gap-1.5 rounded px-2 h-7"
|
||||
style={{ backgroundColor: theme.chipBg }}
|
||||
>
|
||||
<Search size={11} style={{ color: theme.mutedFg }} className="shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="flex-1 min-w-0 bg-transparent text-[11px] font-mono outline-none placeholder:opacity-60"
|
||||
style={{ color: theme.resolvedFg }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-52 overflow-y-auto p-1">
|
||||
{snippets.length === 0 ? (
|
||||
<p className="text-[11px] px-2 py-3 text-center" style={{ color: theme.mutedFg }}>
|
||||
{noSnippetsLabel}
|
||||
</p>
|
||||
) : filteredSnippets.length === 0 ? (
|
||||
<p className="text-[11px] px-2 py-3 text-center" style={{ color: theme.mutedFg }}>
|
||||
{noMatchingLabel}
|
||||
</p>
|
||||
) : (
|
||||
filteredSnippets.map((snippet) => {
|
||||
const pinned = isPinned(snippet.id);
|
||||
return (
|
||||
<button
|
||||
key={snippet.id}
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-left transition-colors duration-150"
|
||||
style={{
|
||||
color: theme.resolvedFg,
|
||||
backgroundColor: pinned ? theme.chipHoverBg : 'transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!pinned) e.currentTarget.style.backgroundColor = theme.hoverBg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = pinned ? theme.chipHoverBg : 'transparent';
|
||||
}}
|
||||
onClick={() => onTogglePin(snippet.id)}
|
||||
>
|
||||
<Pin
|
||||
size={11}
|
||||
className="shrink-0"
|
||||
style={{
|
||||
color: pinned ? theme.resolvedFg : theme.mutedFg,
|
||||
fill: pinned ? 'currentColor' : 'none',
|
||||
}}
|
||||
/>
|
||||
<span className="flex-1 min-w-0 truncate text-[11px] font-mono">
|
||||
{snippet.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{pinnedCount > 0 && (
|
||||
<div
|
||||
className="px-2.5 py-1.5 border-t text-[10px]"
|
||||
style={{ borderColor: theme.borderColor, color: theme.mutedFg }}
|
||||
>
|
||||
{pinnedCountLabel}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
});
|
||||
|
||||
export interface TerminalComposeBarProps {
|
||||
onSend: (text: string) => void;
|
||||
onClose: () => void;
|
||||
onSnippetClick?: (snippet: Snippet) => void;
|
||||
snippets?: Snippet[];
|
||||
isBroadcastEnabled?: boolean;
|
||||
themeColors?: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const TerminalComposeBar: React.FC<TerminalComposeBarProps> = ({
|
||||
onSend,
|
||||
onClose,
|
||||
onSnippetClick,
|
||||
snippets = [],
|
||||
isBroadcastEnabled,
|
||||
themeColors,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isComposingRef = useRef(false);
|
||||
const resizeCleanupRef = useRef<(() => void) | null>(null);
|
||||
const [barHeight, setBarHeight, persistBarHeight] = useComposeBarHeight();
|
||||
const heightRef = useRef(barHeight);
|
||||
|
||||
const snippetIdKey = useMemo(
|
||||
() => buildSnippetIdKey(snippets.map((snippet) => snippet.id)),
|
||||
[snippets],
|
||||
);
|
||||
const defaultSeedIds = useMemo(
|
||||
() => resolveComposeBarDefaultSeedIds(snippets),
|
||||
[snippets],
|
||||
);
|
||||
const { pinnedIds, unpin, toggle, isPinned } = useComposeBarPinnedSnippets(
|
||||
snippetIdKey,
|
||||
defaultSeedIds,
|
||||
);
|
||||
|
||||
heightRef.current = barHeight;
|
||||
|
||||
const theme = useMemo(() => buildTheme(themeColors), [themeColors]);
|
||||
|
||||
const snippetsById = useMemo(
|
||||
() => mergeComposeBarSnippetMap(snippets),
|
||||
[snippets],
|
||||
);
|
||||
|
||||
const pinnedSnippets = useMemo(
|
||||
() => pinnedIds
|
||||
.map((id) => snippetsById.get(id))
|
||||
.filter((snippet): snippet is Snippet => Boolean(snippet)),
|
||||
[pinnedIds, snippetsById],
|
||||
);
|
||||
|
||||
const clickHint = t('terminal.composeBar.snippetClickHint');
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => textareaRef.current?.focus(), 50);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
resizeCleanupRef.current?.();
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
const text = el.value;
|
||||
if (!text) return;
|
||||
onSend(text);
|
||||
el.value = '';
|
||||
el.focus();
|
||||
}, [onSend]);
|
||||
|
||||
const insertCommand = useCallback((command: string) => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
const prefix = el.value && !el.value.endsWith('\n') ? '\n' : '';
|
||||
el.value = el.value ? `${el.value}${prefix}${command}` : command;
|
||||
el.focus();
|
||||
}, []);
|
||||
|
||||
const handleSnippetActivate = useCallback(async (snippet: Snippet, sendImmediately: boolean) => {
|
||||
if (sendImmediately) {
|
||||
if (onSnippetClick) {
|
||||
onSnippetClick(snippet);
|
||||
} else {
|
||||
const command = await resolveSnippetCommand(snippet);
|
||||
if (command !== null) onSend(command);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const command = await resolveSnippetCommand(snippet);
|
||||
if (command === null) return;
|
||||
insertCommand(command);
|
||||
}, [insertCommand, onSend, onSnippetClick]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isComposingRef.current) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}, [handleSend, onClose]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
resizeCleanupRef.current?.();
|
||||
|
||||
const startY = e.clientY;
|
||||
const startHeight = heightRef.current;
|
||||
|
||||
document.body.style.cursor = 'ns-resize';
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
const onMove = (moveEvent: MouseEvent) => {
|
||||
const delta = moveEvent.clientY - startY;
|
||||
setBarHeight(startHeight - delta);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
resizeCleanupRef.current = null;
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
persistBarHeight(heightRef.current);
|
||||
cleanup();
|
||||
};
|
||||
|
||||
resizeCleanupRef.current = cleanup;
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}, [persistBarHeight, setBarHeight]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 flex flex-col"
|
||||
style={{
|
||||
height: barHeight,
|
||||
backgroundColor: theme.resolvedBg,
|
||||
borderTop: `1px solid ${theme.borderColor}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
aria-label={t('terminal.composeBar.resize')}
|
||||
className="flex-shrink-0 flex items-center justify-center cursor-ns-resize group"
|
||||
style={{ height: RESIZE_HANDLE_HEIGHT }}
|
||||
onMouseDown={handleResizeStart}
|
||||
>
|
||||
<GripHorizontal
|
||||
size={12}
|
||||
className="opacity-0 group-hover:opacity-60 transition-opacity"
|
||||
style={{ color: theme.mutedFg }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center gap-1 px-2 min-w-0"
|
||||
style={{ height: SNIPPET_STRIP_HEIGHT }}
|
||||
>
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1 overflow-x-auto scrollbar-thin">
|
||||
{pinnedSnippets.map((snippet) => (
|
||||
<ComposeBarSnippetChip
|
||||
key={snippet.id}
|
||||
snippet={snippet}
|
||||
theme={theme}
|
||||
clickHint={clickHint}
|
||||
unpinLabel={t('terminal.composeBar.unpinSnippet', { label: snippet.label })}
|
||||
onUnpin={unpin}
|
||||
onActivate={handleSnippetActivate}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ComposeBarSnippetManagePopover
|
||||
snippets={snippets}
|
||||
pinnedCount={pinnedSnippets.length}
|
||||
theme={theme}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={toggle}
|
||||
manageLabel={t('terminal.composeBar.manageSnippets')}
|
||||
searchPlaceholder={t('terminal.composeBar.searchSnippets')}
|
||||
noSnippetsLabel={t('terminal.toolbar.noSnippets')}
|
||||
noMatchingLabel={t('terminal.composeBar.noMatchingSnippets')}
|
||||
pinnedCountLabel={t('terminal.composeBar.pinnedCount', { count: pinnedSnippets.length })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 px-3 pt-1.5 pb-2 flex flex-col">
|
||||
<div className="flex flex-1 min-h-0 items-start gap-1.5">
|
||||
{isBroadcastEnabled && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center cursor-default pt-0.5 flex-shrink-0">
|
||||
<Radio size={14} className="text-amber-400 animate-pulse" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('terminal.composeBar.broadcasting')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className={cn(
|
||||
'flex-1 min-w-0 min-h-0 h-full resize-none bg-transparent border-none px-0 py-0',
|
||||
'text-xs font-mono leading-relaxed outline-none',
|
||||
'placeholder:opacity-70 overflow-y-auto',
|
||||
)}
|
||||
style={{ color: theme.resolvedFg }}
|
||||
placeholder={t('terminal.composeBar.placeholder')}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={() => { isComposingRef.current = true; }}
|
||||
onCompositionEnd={() => { isComposingRef.current = false; }}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="h-6 w-6 flex items-center justify-center rounded-md transition-colors duration-150 flex-shrink-0"
|
||||
style={{
|
||||
color: theme.mutedFg,
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = theme.hoverBg;
|
||||
e.currentTarget.style.color = theme.resolvedFg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = theme.mutedFg;
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('terminal.composeBar.close')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalComposeBar;
|
||||
261
components/terminal/TerminalConnectionDialog.test.ts
Normal file
261
components/terminal/TerminalConnectionDialog.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import type { Host } from "../../types.ts";
|
||||
import { TerminalConnectionDialog } from "./TerminalConnectionDialog.tsx";
|
||||
|
||||
const host: Host = {
|
||||
id: "host-1",
|
||||
label: "10.2.0.32",
|
||||
hostname: "10.2.0.32",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
};
|
||||
|
||||
const renderDialog = (
|
||||
props: Partial<React.ComponentProps<typeof TerminalConnectionDialog>> = {},
|
||||
) => renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(TerminalConnectionDialog, {
|
||||
host,
|
||||
status: "connecting",
|
||||
error: null,
|
||||
progressValue: 55,
|
||||
chainProgress: null,
|
||||
needsAuth: false,
|
||||
showLogs: false,
|
||||
_setShowLogs: () => {},
|
||||
keys: [],
|
||||
authProps: {
|
||||
authMethod: "password",
|
||||
setAuthMethod: () => {},
|
||||
authUsername: "root",
|
||||
setAuthUsername: () => {},
|
||||
authPassword: "",
|
||||
setAuthPassword: () => {},
|
||||
authKeyId: null,
|
||||
setAuthKeyId: () => {},
|
||||
authPassphrase: "",
|
||||
setAuthPassphrase: () => {},
|
||||
showAuthPassphrase: false,
|
||||
setShowAuthPassphrase: () => {},
|
||||
showAuthPassword: false,
|
||||
setShowAuthPassword: () => {},
|
||||
authRetryMessage: null,
|
||||
onSubmit: () => {},
|
||||
onCancel: () => {},
|
||||
isValid: true,
|
||||
},
|
||||
progressProps: {
|
||||
timeLeft: 20,
|
||||
isCancelling: false,
|
||||
progressLogs: ["Host key verification required for 10.2.0.32."],
|
||||
onCancelConnect: () => {},
|
||||
onCloseSession: () => {},
|
||||
onRetry: () => {},
|
||||
},
|
||||
...props,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
test("renders host key confirmation inside the connection dialog", () => {
|
||||
const markup = renderDialog({
|
||||
showLogs: true,
|
||||
hostKeyVerification: {
|
||||
hostKeyInfo: {
|
||||
hostname: "10.2.0.32",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
fingerprint: "abc123",
|
||||
status: "unknown",
|
||||
},
|
||||
onClose: () => {},
|
||||
onContinue: () => {},
|
||||
onAddAndContinue: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(markup, /Confirm this host key/);
|
||||
assert.match(markup, /abc123/);
|
||||
assert.match(markup, /Add and continue/);
|
||||
assert.match(markup, /Host key verification required for 10\.2\.0\.32\./);
|
||||
assert.equal(markup.includes("Timeout in"), false);
|
||||
});
|
||||
|
||||
test("does not show a countdown while waiting for user input", () => {
|
||||
const markup = renderDialog({
|
||||
progressProps: {
|
||||
timeLeft: 20,
|
||||
isAwaitingUserInput: true,
|
||||
isCancelling: false,
|
||||
progressLogs: ["Waiting for passphrase."],
|
||||
onCancelConnect: () => {},
|
||||
onCloseSession: () => {},
|
||||
onRetry: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(markup, /Waiting for user input/);
|
||||
assert.equal(markup.includes("Timeout in"), false);
|
||||
});
|
||||
|
||||
test("shows enter reconnect hint when disconnected reconnect is available", () => {
|
||||
const markup = renderDialog({
|
||||
status: "disconnected",
|
||||
error: null,
|
||||
showEnterReconnectHint: true,
|
||||
});
|
||||
|
||||
assert.match(markup, /Press Enter to reconnect/);
|
||||
// Focus sink so Enter still reaches the overlay after body/document blur (#2544).
|
||||
assert.match(markup, /data-terminal-disconnected-dialog="true"/);
|
||||
assert.match(markup, /tabindex="-1"/);
|
||||
});
|
||||
|
||||
test("does not show enter reconnect hint until the caller marks enter reconnect available", () => {
|
||||
const markup = renderDialog({
|
||||
status: "disconnected",
|
||||
error: null,
|
||||
});
|
||||
|
||||
assert.equal(markup.includes("Press Enter to reconnect"), false);
|
||||
assert.equal(markup.includes("data-terminal-disconnected-dialog"), false);
|
||||
});
|
||||
|
||||
test("renders changed host key warning in the same connection dialog", () => {
|
||||
const markup = renderDialog({
|
||||
hostKeyVerification: {
|
||||
hostKeyInfo: {
|
||||
hostname: "10.2.0.32",
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
fingerprint: "new-fingerprint",
|
||||
knownFingerprint: "old-fingerprint",
|
||||
status: "changed",
|
||||
},
|
||||
onClose: () => {},
|
||||
onContinue: () => {},
|
||||
onAddAndContinue: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(markup, /Host key changed/);
|
||||
assert.match(markup, /new-fingerprint/);
|
||||
assert.match(markup, /Saved fingerprint/);
|
||||
assert.match(markup, /old-fingerprint/);
|
||||
assert.match(markup, /Update and continue/);
|
||||
});
|
||||
|
||||
test("keeps the second progress segment parked until the first segment finishes", () => {
|
||||
const markup = renderDialog({ progressValue: 75 });
|
||||
|
||||
assert.match(markup, /style="width:100%"/);
|
||||
assert.match(markup, /style="width:0%"/);
|
||||
});
|
||||
|
||||
test("fills both progress segments for disconnected states", () => {
|
||||
const markup = renderDialog({
|
||||
status: "disconnected",
|
||||
error: "Connection timed out.",
|
||||
progressValue: 5,
|
||||
});
|
||||
|
||||
const fullSegments = markup.match(/style="width:100%"/g) ?? [];
|
||||
assert.equal(fullSegments.length >= 2, true);
|
||||
});
|
||||
|
||||
test("keeps connection log padding inside the scrollable content", () => {
|
||||
const markup = renderDialog({
|
||||
status: "disconnected",
|
||||
error: "Connection timed out.",
|
||||
showLogs: true,
|
||||
progressProps: {
|
||||
timeLeft: 0,
|
||||
isCancelling: false,
|
||||
progressLogs: Array.from({ length: 12 }, (_, index) => `Log line ${index + 1}`),
|
||||
onCancelConnect: () => {},
|
||||
onCloseSession: () => {},
|
||||
onRetry: () => {},
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(markup, /class="[^"]*max-h-44/);
|
||||
assert.doesNotMatch(markup, /class="[^"]*max-h-44[^"]*p-2\.5/);
|
||||
assert.match(markup, /class="[^"]*p-2\.5[^"]*pb-4[^"]*pr-4/);
|
||||
});
|
||||
|
||||
test("shows the ET server port (not the SSH port) for an ET host with a custom etPort", () => {
|
||||
const markup = renderDialog({
|
||||
host: { ...host, etEnabled: true, port: 22, etPort: 9022 },
|
||||
});
|
||||
|
||||
// ET connectivity hinges on the etserver port, so the dialog must show it.
|
||||
assert.match(markup, /10\.2\.0\.32:9022/);
|
||||
assert.equal(markup.includes("10.2.0.32:22"), false);
|
||||
});
|
||||
|
||||
test("defaults the displayed ET port to 2022 when no etPort is set", () => {
|
||||
const markup = renderDialog({
|
||||
host: { ...host, etEnabled: true, port: 22 },
|
||||
});
|
||||
|
||||
assert.match(markup, /10\.2\.0\.32:2022/);
|
||||
assert.equal(markup.includes("10.2.0.32:22"), false);
|
||||
});
|
||||
|
||||
test("labels plugin transports without presenting them as SSH endpoints", () => {
|
||||
const providerId = "com.example.transport.connection";
|
||||
const markup = renderDialog({
|
||||
host: {
|
||||
...host,
|
||||
hostname: providerId,
|
||||
port: 22,
|
||||
protocol: `plugin:${providerId}`,
|
||||
pluginConnection: { providerId, configuration: {} },
|
||||
},
|
||||
});
|
||||
|
||||
assert.match(markup, /Plugin connection/);
|
||||
assert.equal(markup.includes(`${providerId}:22`), false);
|
||||
});
|
||||
|
||||
test("shows restored session copy for disconnected restored placeholders", () => {
|
||||
const markup = renderDialog({
|
||||
status: "disconnected",
|
||||
error: null,
|
||||
restoreState: "restored-disconnected",
|
||||
} as Partial<React.ComponentProps<typeof TerminalConnectionDialog>>);
|
||||
|
||||
assert.match(markup, /Restored session/);
|
||||
assert.match(markup, /This terminal is disconnected/);
|
||||
assert.match(markup, /Reconnect/);
|
||||
});
|
||||
|
||||
test("disconnected observer surfaces do not advertise a reconnect they cannot perform", () => {
|
||||
const markup = renderDialog({
|
||||
status: "disconnected",
|
||||
error: "Observed session ended.",
|
||||
showEnterReconnectHint: false,
|
||||
progressProps: {
|
||||
timeLeft: 0,
|
||||
isCancelling: false,
|
||||
progressLogs: [],
|
||||
onCancelConnect: () => {},
|
||||
onCloseSession: () => {},
|
||||
onRetry: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(markup.includes("Press Enter to reconnect"), false);
|
||||
assert.equal(markup.includes("Start over"), false);
|
||||
assert.match(markup, /Close session/);
|
||||
});
|
||||
457
components/terminal/TerminalConnectionDialog.tsx
Normal file
457
components/terminal/TerminalConnectionDialog.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Terminal Connection Dialog
|
||||
* Full connection overlay with host info, progress indicator, and auth/progress content
|
||||
*/
|
||||
import { Fingerprint, Loader2, Plug, TerminalSquare, X } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Host, SSHKey } from '../../types';
|
||||
import { formatHostPort, resolveTelnetPort } from '../../domain/host';
|
||||
import { isPluginHostProtocol } from '../../domain/pluginConnection';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { Button } from '../ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { TerminalAuthDialog, TerminalAuthDialogProps } from './TerminalAuthDialog';
|
||||
import { TerminalConnectionProgress, TerminalConnectionProgressProps } from './TerminalConnectionProgress';
|
||||
import { HostKeyInfo, TerminalHostKeyVerification } from './TerminalHostKeyVerification';
|
||||
import {
|
||||
resolveDisconnectedDialogTerminalRoot,
|
||||
restoreTerminalFocusFromDisconnectedDialog,
|
||||
shouldClaimDisconnectedDialogFocus,
|
||||
shouldReconnectDisconnectedDialogOnEnterKey,
|
||||
shouldRestoreDisconnectedDialogTerminalFocus,
|
||||
} from './terminalHelpers';
|
||||
|
||||
export interface ChainProgress {
|
||||
currentHop: number;
|
||||
totalHops: number;
|
||||
currentHostLabel: string;
|
||||
}
|
||||
|
||||
export interface TerminalConnectionDialogProps {
|
||||
host: Host;
|
||||
status: 'connecting' | 'connected' | 'disconnected';
|
||||
restoreState?: 'restored-disconnected';
|
||||
error: string | null;
|
||||
progressValue: number;
|
||||
chainProgress: ChainProgress | null;
|
||||
needsAuth: boolean;
|
||||
showLogs: boolean;
|
||||
_setShowLogs: (show: boolean) => void;
|
||||
// Auth dialog props
|
||||
authProps: Omit<TerminalAuthDialogProps, 'keys'>;
|
||||
keys: SSHKey[];
|
||||
onDismissDisconnected?: () => void;
|
||||
showEnterReconnectHint?: boolean;
|
||||
/** False for unfocused split siblings — do not claim body/document focus. */
|
||||
isFocusedPane?: boolean;
|
||||
hostKeyVerification?: {
|
||||
hostKeyInfo: HostKeyInfo;
|
||||
onClose: () => void;
|
||||
onContinue: () => void;
|
||||
onAddAndContinue: () => void;
|
||||
};
|
||||
// Progress props
|
||||
progressProps: Omit<TerminalConnectionProgressProps, 'status' | 'error' | 'showLogs' | 'showEnterReconnectHint'>;
|
||||
}
|
||||
|
||||
// Helper to get protocol display info
|
||||
const getProtocolInfo = (host: Host): { i18nKey: string; showPort: boolean; port: number } => {
|
||||
// Check moshEnabled first since mosh uses protocol: "ssh" with moshEnabled: true
|
||||
if (host.moshEnabled) {
|
||||
return { i18nKey: 'terminal.connection.protocol.mosh', showPort: true, port: host.port || 22 };
|
||||
}
|
||||
// ET likewise uses protocol: "ssh" with etEnabled: true. Show the ET
|
||||
// server port (default 2022) rather than the SSH port: ET connectivity
|
||||
// hinges on the etserver port, so surfacing the SSH port (22) here is
|
||||
// misleading when troubleshooting a connection that is actually stuck on
|
||||
// the ET port.
|
||||
if (host.etEnabled) {
|
||||
return { i18nKey: 'terminal.connection.protocol.et', showPort: true, port: host.etPort || 2022 };
|
||||
}
|
||||
const protocol = host.protocol || 'ssh';
|
||||
if (isPluginHostProtocol(protocol)) {
|
||||
return { i18nKey: 'terminal.connection.protocol.plugin', showPort: false, port: 0 };
|
||||
}
|
||||
switch (protocol) {
|
||||
case 'local':
|
||||
return { i18nKey: 'terminal.connection.protocol.local', showPort: false, port: 0 };
|
||||
case 'telnet':
|
||||
// Telnet uses telnetPort, not port (which is SSH port)
|
||||
return { i18nKey: 'terminal.connection.protocol.telnet', showPort: true, port: resolveTelnetPort(host) };
|
||||
case 'mosh':
|
||||
return { i18nKey: 'terminal.connection.protocol.mosh', showPort: true, port: host.port || 22 };
|
||||
case 'serial':
|
||||
return { i18nKey: 'terminal.connection.protocol.serial', showPort: false, port: 0 };
|
||||
case 'ssh':
|
||||
default:
|
||||
return { i18nKey: 'terminal.connection.protocol.ssh', showPort: true, port: host.port || 22 };
|
||||
}
|
||||
};
|
||||
|
||||
export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> = ({
|
||||
host,
|
||||
status,
|
||||
restoreState,
|
||||
error,
|
||||
progressValue,
|
||||
chainProgress,
|
||||
needsAuth,
|
||||
showLogs,
|
||||
_setShowLogs: setShowLogs, // Rename back to setShowLogs for internal use
|
||||
authProps,
|
||||
keys,
|
||||
onDismissDisconnected,
|
||||
showEnterReconnectHint,
|
||||
isFocusedPane,
|
||||
hostKeyVerification,
|
||||
progressProps,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hasError = Boolean(error);
|
||||
const isRestoredDisconnected = status === 'disconnected' && restoreState === 'restored-disconnected';
|
||||
const isConnecting = status === 'connecting';
|
||||
const canDismissDisconnected = status === 'disconnected' && !needsAuth && !!onDismissDisconnected;
|
||||
const protocolInfo = getProtocolInfo(host);
|
||||
const isVerifyingHostKey = Boolean(hostKeyVerification);
|
||||
const isHostKeyChanged = hostKeyVerification?.hostKeyInfo.status === 'changed';
|
||||
const shouldCompleteProgress = hasError || (!isConnecting && !needsAuth);
|
||||
// When the disconnected overlay is up and Enter-reconnect is advertised,
|
||||
// keep a focus sink on the overlay itself so body/document focus loss cannot
|
||||
// make the hint lie (#2544). Auth/host-key flows keep their own inputs.
|
||||
const onRetry = progressProps.onRetry;
|
||||
const canEnterReconnectFromDialog = Boolean(
|
||||
showEnterReconnectHint
|
||||
&& status === 'disconnected'
|
||||
&& !needsAuth
|
||||
&& !isVerifyingHostKey
|
||||
&& onRetry,
|
||||
);
|
||||
const dialogFocusRef = useRef<HTMLDivElement | null>(null);
|
||||
// Unmount cleanup keeps [] deps; read the latest pane ownership then.
|
||||
const isFocusedPaneRef = useRef(isFocusedPane);
|
||||
isFocusedPaneRef.current = isFocusedPane;
|
||||
|
||||
// Claim focus only when Enter-reconnect mode turns on — not on every
|
||||
// showLogs/error rerender (those would steal keyboard focus off buttons).
|
||||
useEffect(() => {
|
||||
if (!canEnterReconnectFromDialog) return;
|
||||
const node = dialogFocusRef.current;
|
||||
if (!node) return;
|
||||
const sessionRoot = node.closest("[data-session-id]");
|
||||
const focusOverlay = () => {
|
||||
if (typeof document === "undefined") return;
|
||||
if (!shouldClaimDisconnectedDialogFocus({
|
||||
activeElement: document.activeElement,
|
||||
dialogNode: node,
|
||||
sessionRoot,
|
||||
documentBody: document.body,
|
||||
documentElement: document.documentElement,
|
||||
isFocusedPane,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
node.focus({ preventScroll: true });
|
||||
};
|
||||
focusOverlay();
|
||||
// Re-assert after paint/microtasks so late blur from xterm teardown
|
||||
// cannot leave focus on document.body — still never steals other panes.
|
||||
const timer = window.setTimeout(focusOverlay, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [canEnterReconnectFromDialog, isFocusedPane]);
|
||||
|
||||
// Restore xterm focus only when this overlay unmounts (connected / dismiss).
|
||||
// Do not key on Enter-reconnect mode: reconnect may keep the dialog mounted
|
||||
// for connecting / auth / host-key, and restoring then routes keyboard behind
|
||||
// the blocking overlay.
|
||||
useEffect(() => {
|
||||
const node = dialogFocusRef.current;
|
||||
if (!node) return;
|
||||
// Capture the terminal root while the dialog is still mounted — after
|
||||
// unmount, parentElement is null and popup trees have no data-session-id.
|
||||
const sessionRoot = resolveDisconnectedDialogTerminalRoot(
|
||||
node,
|
||||
node.closest("[data-session-id]"),
|
||||
);
|
||||
return () => {
|
||||
if (typeof document === "undefined") return;
|
||||
if (!shouldRestoreDisconnectedDialogTerminalFocus(node)) return;
|
||||
restoreTerminalFocusFromDisconnectedDialog({
|
||||
activeElement: document.activeElement,
|
||||
dialogNode: node,
|
||||
sessionRoot,
|
||||
documentBody: document.body,
|
||||
documentElement: document.documentElement,
|
||||
isFocusedPane: isFocusedPaneRef.current,
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDialogKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!shouldReconnectDisconnectedDialogOnEnterKey({
|
||||
key: event.key,
|
||||
enabled: canEnterReconnectFromDialog,
|
||||
altKey: event.altKey,
|
||||
ctrlKey: event.ctrlKey,
|
||||
metaKey: event.metaKey,
|
||||
shiftKey: event.shiftKey,
|
||||
isComposing: event.nativeEvent.isComposing,
|
||||
target: event.target,
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onRetry?.();
|
||||
}, [canEnterReconnectFromDialog, onRetry]);
|
||||
const targetFirstSegmentWidth = isVerifyingHostKey || shouldCompleteProgress
|
||||
? 100
|
||||
: Math.min(100, progressValue * 2);
|
||||
const targetSecondSegmentWidth = isVerifyingHostKey
|
||||
? 0
|
||||
: shouldCompleteProgress
|
||||
? 100
|
||||
: Math.max(0, Math.min(100, (progressValue - 50) * 2));
|
||||
const [secondSegmentUnlocked, setSecondSegmentUnlocked] = React.useState(
|
||||
() => shouldCompleteProgress || targetSecondSegmentWidth <= 0
|
||||
);
|
||||
const secondSegmentUnlockTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (secondSegmentUnlockTimerRef.current) {
|
||||
clearTimeout(secondSegmentUnlockTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (needsAuth || isVerifyingHostKey || targetSecondSegmentWidth <= 0 || shouldCompleteProgress) {
|
||||
if (secondSegmentUnlockTimerRef.current) {
|
||||
clearTimeout(secondSegmentUnlockTimerRef.current);
|
||||
secondSegmentUnlockTimerRef.current = null;
|
||||
}
|
||||
setSecondSegmentUnlocked(shouldCompleteProgress);
|
||||
return;
|
||||
}
|
||||
|
||||
if (secondSegmentUnlocked || secondSegmentUnlockTimerRef.current) return;
|
||||
|
||||
secondSegmentUnlockTimerRef.current = setTimeout(() => {
|
||||
secondSegmentUnlockTimerRef.current = null;
|
||||
setSecondSegmentUnlocked(true);
|
||||
}, 320);
|
||||
}, [isVerifyingHostKey, needsAuth, secondSegmentUnlocked, shouldCompleteProgress, targetSecondSegmentWidth]);
|
||||
|
||||
const firstSegmentWidth = targetFirstSegmentWidth;
|
||||
const secondSegmentWidth = shouldCompleteProgress || secondSegmentUnlocked ? targetSecondSegmentWidth : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-20 flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: needsAuth
|
||||
? 'var(--terminal-ui-bg, var(--background))'
|
||||
: 'color-mix(in srgb, var(--terminal-ui-bg, var(--background)) 35%, transparent)',
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
// Clicking the dimmed backdrop (not a control) should park focus
|
||||
// on the overlay so the next Enter still reconnects.
|
||||
if (!canEnterReconnectFromDialog) return;
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
if (target.closest("button, a, input, textarea, select, [contenteditable='true'], [role='button'], [role='menuitem'], [role='textbox']")) {
|
||||
return;
|
||||
}
|
||||
dialogFocusRef.current?.focus({ preventScroll: true });
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={dialogFocusRef}
|
||||
tabIndex={canEnterReconnectFromDialog ? -1 : undefined}
|
||||
data-terminal-disconnected-dialog={canEnterReconnectFromDialog ? 'true' : undefined}
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
className="w-[540px] max-w-[88vw] rounded-xl shadow-xl p-4 space-y-3 transition-all duration-200 outline-none"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, var(--background)) 95%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--terminal-ui-fg, var(--foreground)) 12%, var(--terminal-ui-bg, var(--background)) 88%)',
|
||||
color: 'var(--terminal-ui-fg, var(--foreground))',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} size="md" className="shrink-0" />
|
||||
<div className="min-w-0">
|
||||
{chainProgress ? (
|
||||
<>
|
||||
<div className="text-xs font-semibold truncate">
|
||||
<span className="text-muted-foreground">
|
||||
{t('terminal.connection.chainOf', {
|
||||
current: chainProgress.currentHop,
|
||||
total: chainProgress.totalHops,
|
||||
})}
|
||||
{': '}
|
||||
</span>
|
||||
<span>{chainProgress.currentHostLabel}</span>
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] font-mono truncate"
|
||||
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, var(--foreground)) 58%, transparent)' }}
|
||||
>
|
||||
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-base font-semibold truncate">{host.label}</div>
|
||||
<div
|
||||
className="text-[10px] font-mono truncate"
|
||||
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, var(--foreground)) 58%, transparent)' }}
|
||||
>
|
||||
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-3">
|
||||
{!needsAuth && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-3 text-[11px]"
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
>
|
||||
{showLogs ? t('terminal.connection.hideLogs') : t('terminal.connection.showLogs')}
|
||||
</Button>
|
||||
)}
|
||||
{status === 'connecting' && !needsAuth && !isVerifyingHostKey && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-3 text-[11px]"
|
||||
onClick={progressProps.onCancelConnect}
|
||||
disabled={progressProps.isCancelling}
|
||||
>
|
||||
{progressProps.isCancelling ? t('terminal.progress.cancelling') : t('common.close')}
|
||||
</Button>
|
||||
)}
|
||||
{canDismissDisconnected && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
aria-label={t('terminal.connection.dismissDisconnectedDialog')}
|
||||
onClick={onDismissDisconnected}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('terminal.connection.dismissDisconnectedDialog')}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"h-7 w-7 rounded-md flex items-center justify-center flex-shrink-0",
|
||||
needsAuth || isVerifyingHostKey
|
||||
? "bg-primary text-primary-foreground"
|
||||
: hasError
|
||||
? "bg-destructive/20 text-destructive"
|
||||
: isConnecting
|
||||
? "bg-primary/15 text-primary"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
<Plug size={13} />
|
||||
</div>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-border/60 overflow-hidden relative">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 rounded-full transition-all duration-300",
|
||||
error ? "bg-destructive" : "bg-primary"
|
||||
)}
|
||||
style={{ width: needsAuth ? '0%' : `${firstSegmentWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"h-7 w-7 rounded-md flex items-center justify-center flex-shrink-0 transition-all duration-200",
|
||||
isHostKeyChanged
|
||||
? "bg-destructive/15 text-destructive ring-2 ring-destructive/25 animate-pulse"
|
||||
: isVerifyingHostKey
|
||||
? "bg-amber-500/15 text-amber-400 ring-2 ring-amber-400/25 animate-pulse"
|
||||
: progressValue > 50 && !hasError
|
||||
? "bg-primary/15 text-primary"
|
||||
: hasError
|
||||
? "bg-destructive/20 text-destructive"
|
||||
: "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
<Fingerprint size={13} />
|
||||
</div>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-border/60 overflow-hidden relative">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 left-0 rounded-full transition-all duration-300",
|
||||
error ? "bg-destructive" : "bg-primary"
|
||||
)}
|
||||
style={{ width: needsAuth || isVerifyingHostKey ? '0%' : `${secondSegmentWidth}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"h-7 w-7 rounded-md flex items-center justify-center flex-shrink-0",
|
||||
hasError ? "bg-destructive/20 text-destructive" : "bg-muted text-muted-foreground"
|
||||
)}>
|
||||
{isConnecting ? (
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
) : (
|
||||
<TerminalSquare size={13} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needsAuth ? (
|
||||
<TerminalAuthDialog {...authProps} keys={keys} />
|
||||
) : hostKeyVerification ? (
|
||||
<TerminalHostKeyVerification
|
||||
hostKeyInfo={hostKeyVerification.hostKeyInfo}
|
||||
showLogs={showLogs}
|
||||
progressLogs={progressProps.progressLogs}
|
||||
onClose={hostKeyVerification.onClose}
|
||||
onContinue={hostKeyVerification.onContinue}
|
||||
onAddAndContinue={hostKeyVerification.onAddAndContinue}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{isRestoredDisconnected && (
|
||||
<div className="rounded-md border border-border/35 bg-background/35 p-3 text-xs leading-5">
|
||||
<div className="font-semibold">{t('terminal.restore.placeholder.title')}</div>
|
||||
<div
|
||||
className="mt-1"
|
||||
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, var(--foreground)) 68%, transparent)' }}
|
||||
>
|
||||
{t('terminal.restore.placeholder.desc')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<TerminalConnectionProgress
|
||||
status={status}
|
||||
error={error}
|
||||
showLogs={showLogs}
|
||||
showEnterReconnectHint={showEnterReconnectHint}
|
||||
reconnectLabel={isRestoredDisconnected ? t('terminal.restore.placeholder.reconnect') : undefined}
|
||||
{...progressProps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalConnectionDialog;
|
||||
122
components/terminal/TerminalConnectionProgress.tsx
Normal file
122
components/terminal/TerminalConnectionProgress.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Terminal Connection Progress
|
||||
* Displays connection progress with logs and timeout
|
||||
*/
|
||||
import { Loader2, Play } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Button } from '../ui/button';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
|
||||
export interface TerminalConnectionProgressProps {
|
||||
status: 'connecting' | 'connected' | 'disconnected';
|
||||
error: string | null;
|
||||
timeLeft: number;
|
||||
isAwaitingUserInput?: boolean;
|
||||
showEnterReconnectHint?: boolean;
|
||||
isCancelling: boolean;
|
||||
showLogs: boolean;
|
||||
progressLogs: string[];
|
||||
onCancelConnect: () => void;
|
||||
onCloseSession: () => void;
|
||||
onRetry?: () => void;
|
||||
reconnectLabel?: string;
|
||||
}
|
||||
|
||||
export interface TerminalConnectionLogListProps {
|
||||
progressLogs: string[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export const TerminalConnectionLogList: React.FC<TerminalConnectionLogListProps> = ({
|
||||
progressLogs,
|
||||
error,
|
||||
}) => (
|
||||
<div className="rounded-md border border-border/35 bg-background/40">
|
||||
<ScrollArea className="max-h-44">
|
||||
<div className="space-y-1 p-2.5 pb-4 pr-4 text-xs text-foreground/90">
|
||||
{progressLogs.map((line, idx) => (
|
||||
<div key={idx} className="flex items-start gap-2">
|
||||
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-emerald-500" />
|
||||
<div className="min-w-0 break-words leading-5">{line}</div>
|
||||
</div>
|
||||
))}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 text-destructive">
|
||||
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-destructive" />
|
||||
<div className="min-w-0 break-words leading-5">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProps> = ({
|
||||
status,
|
||||
error,
|
||||
timeLeft,
|
||||
isAwaitingUserInput = false,
|
||||
showEnterReconnectHint = false,
|
||||
isCancelling: _isCancelling,
|
||||
showLogs,
|
||||
progressLogs,
|
||||
onCancelConnect: _onCancelConnect,
|
||||
onCloseSession,
|
||||
onRetry,
|
||||
reconnectLabel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-3 text-[11px] text-muted-foreground">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
{status === 'connecting' ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 mt-0.5 flex-shrink-0 animate-spin" />
|
||||
<span className="min-w-0 whitespace-pre-wrap break-words leading-5">
|
||||
{isAwaitingUserInput
|
||||
? t('terminal.progress.waitingForUserInput')
|
||||
: t('terminal.progress.timeoutIn', { seconds: timeLeft })}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-[0.4rem] h-1.5 w-1.5 flex-shrink-0 rounded-full bg-destructive" />
|
||||
<span className="min-w-0 whitespace-pre-wrap break-words leading-5 text-destructive">
|
||||
{error || t('terminal.progress.disconnected')}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showLogs && (
|
||||
<TerminalConnectionLogList progressLogs={progressLogs} error={error} />
|
||||
)}
|
||||
|
||||
{status !== 'connecting' && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
{showEnterReconnectHint && (
|
||||
<div className="min-w-0 break-words text-[11px] leading-5 text-muted-foreground">
|
||||
{t('terminal.progress.enterReconnectHint')}
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-3 text-[11px]" onClick={onCloseSession}>
|
||||
{t('terminal.toolbar.closeSession')}
|
||||
</Button>
|
||||
{onRetry && (
|
||||
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={onRetry}>
|
||||
<Play className="h-3 w-3 mr-1.5" /> {reconnectLabel ?? t('terminal.progress.startOver')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalConnectionProgress;
|
||||
141
components/terminal/TerminalContextMenu.splitActions.test.tsx
Normal file
141
components/terminal/TerminalContextMenu.splitActions.test.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('split menu keeps custom shortcuts aligned with the matching split actions', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
|
||||
pretendToBeVisual: true,
|
||||
url: 'http://localhost',
|
||||
});
|
||||
const window = dom.window;
|
||||
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
|
||||
const installGlobal = (key: string, value: unknown) => {
|
||||
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value,
|
||||
});
|
||||
};
|
||||
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
installGlobal('window', window);
|
||||
installGlobal('document', window.document);
|
||||
installGlobal('navigator', window.navigator);
|
||||
installGlobal('HTMLElement', window.HTMLElement);
|
||||
installGlobal('HTMLInputElement', window.HTMLInputElement);
|
||||
installGlobal('HTMLTextAreaElement', window.HTMLTextAreaElement);
|
||||
installGlobal('Element', window.Element);
|
||||
installGlobal('SVGElement', window.SVGElement);
|
||||
installGlobal('Node', window.Node);
|
||||
installGlobal('NodeFilter', window.NodeFilter);
|
||||
installGlobal('MutationObserver', window.MutationObserver);
|
||||
installGlobal('CustomEvent', window.CustomEvent);
|
||||
installGlobal('DOMRect', window.DOMRect);
|
||||
installGlobal('Event', window.Event);
|
||||
installGlobal('KeyboardEvent', window.KeyboardEvent);
|
||||
installGlobal('MouseEvent', window.MouseEvent);
|
||||
installGlobal('getComputedStyle', window.getComputedStyle.bind(window));
|
||||
installGlobal('requestAnimationFrame', window.requestAnimationFrame.bind(window));
|
||||
installGlobal('cancelAnimationFrame', window.cancelAnimationFrame.bind(window));
|
||||
installGlobal('ResizeObserver', ResizeObserverStub);
|
||||
installGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../../application/i18n/I18nProvider.tsx');
|
||||
const { TerminalContextMenu } = await import('./TerminalContextMenu.tsx');
|
||||
const rootNode = window.document.getElementById('root');
|
||||
assert.ok(rootNode);
|
||||
const root = createRoot(rootNode);
|
||||
const actions: string[] = [];
|
||||
|
||||
const openMenu = async () => {
|
||||
const surface = window.document.querySelector<HTMLElement>('[data-testid="terminal-surface"]');
|
||||
assert.ok(surface);
|
||||
await act(async () => {
|
||||
surface.dispatchEvent(new window.MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
button: 2,
|
||||
clientX: 20,
|
||||
clientY: 20,
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
const findMenuItem = (label: string): HTMLElement => {
|
||||
const item = Array.from(window.document.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
.find((candidate) => candidate.textContent?.includes(label));
|
||||
assert.ok(item, `${label} menu item should be visible`);
|
||||
return item;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<I18nProvider locale="zh-CN">
|
||||
<TerminalContextMenu
|
||||
sessionId="issue-3082"
|
||||
status="connected"
|
||||
hotkeyScheme="mac"
|
||||
keyBindings={[
|
||||
{
|
||||
id: 'split-horizontal',
|
||||
action: 'splitHorizontal',
|
||||
label: 'Split Horizontal',
|
||||
mac: '⌘ + H',
|
||||
pc: 'Ctrl + H',
|
||||
category: 'navigation',
|
||||
},
|
||||
{
|
||||
id: 'split-vertical',
|
||||
action: 'splitVertical',
|
||||
label: 'Split Vertical',
|
||||
mac: '⌘ + V',
|
||||
pc: 'Ctrl + V',
|
||||
category: 'navigation',
|
||||
},
|
||||
]}
|
||||
onSplitHorizontal={() => actions.push('horizontal')}
|
||||
onSplitVertical={() => actions.push('vertical')}
|
||||
>
|
||||
<div data-testid="terminal-surface">Terminal</div>
|
||||
</TerminalContextMenu>
|
||||
</I18nProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
await openMenu();
|
||||
const horizontalItem = findMenuItem('水平分屏');
|
||||
assert.match(horizontalItem.textContent ?? '', /水平分屏\s*⌘ H/);
|
||||
const horizontalDivider = horizontalItem.querySelector('svg line');
|
||||
assert.ok(horizontalDivider);
|
||||
assert.equal(horizontalDivider.getAttribute('y1'), horizontalDivider.getAttribute('y2'));
|
||||
assert.notEqual(horizontalDivider.getAttribute('x1'), horizontalDivider.getAttribute('x2'));
|
||||
await act(async () => horizontalItem.click());
|
||||
|
||||
await openMenu();
|
||||
const verticalItem = findMenuItem('垂直分屏');
|
||||
assert.match(verticalItem.textContent ?? '', /垂直分屏\s*⌘ V/);
|
||||
const verticalDivider = verticalItem.querySelector('svg line');
|
||||
assert.ok(verticalDivider);
|
||||
assert.equal(verticalDivider.getAttribute('x1'), verticalDivider.getAttribute('x2'));
|
||||
assert.notEqual(verticalDivider.getAttribute('y1'), verticalDivider.getAttribute('y2'));
|
||||
await act(async () => verticalItem.click());
|
||||
|
||||
assert.deepEqual(actions, ['horizontal', 'vertical']);
|
||||
} finally {
|
||||
await act(async () => root.unmount());
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previousGlobals) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete (globalThis as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
491
components/terminal/TerminalContextMenu.test.ts
Normal file
491
components/terminal/TerminalContextMenu.test.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import en from "../../application/i18n/locales/en.ts";
|
||||
import ru from "../../application/i18n/locales/ru.ts";
|
||||
import es from "../../application/i18n/locales/es.ts";
|
||||
import zhCN from "../../application/i18n/locales/zh-CN.ts";
|
||||
import { markMiddleClickContextMenuEvent } from "./runtime/middleClickBehavior.ts";
|
||||
import * as terminalContextMenu from "./TerminalContextMenu.tsx";
|
||||
import { shouldEnableYmodemAction } from "./TerminalView.tsx";
|
||||
|
||||
const shouldShowReconnectAction = (
|
||||
terminalContextMenu as {
|
||||
shouldShowReconnectAction?: (options: {
|
||||
isReconnectable?: boolean;
|
||||
onReconnect?: () => void;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldShowReconnectAction;
|
||||
const shouldSuppressMouseTrackingContextMenu = (
|
||||
terminalContextMenu as {
|
||||
shouldSuppressMouseTrackingContextMenu?: (options: {
|
||||
isAlternateScreen?: boolean;
|
||||
terminalMouseTrackingMode?: string;
|
||||
showReconnectAction?: boolean;
|
||||
forceMenuInAlternateScreen?: boolean;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldSuppressMouseTrackingContextMenu;
|
||||
const shouldShowAddSelectionToAIContextMenuAction = (
|
||||
terminalContextMenu as {
|
||||
shouldShowAddSelectionToAIContextMenuAction?: (onAddSelectionToAI?: () => void) => boolean;
|
||||
}
|
||||
).shouldShowAddSelectionToAIContextMenuAction;
|
||||
const shouldShowUploadClipboardImageContextMenuAction = (
|
||||
terminalContextMenu as {
|
||||
shouldShowUploadClipboardImageContextMenuAction?: (onUploadClipboardImage?: () => void) => boolean;
|
||||
}
|
||||
).shouldShowUploadClipboardImageContextMenuAction;
|
||||
const shouldOpenTerminalContextMenu = (
|
||||
terminalContextMenu as {
|
||||
shouldOpenTerminalContextMenu?: (options: {
|
||||
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
|
||||
rightClickBehavior?: "context-menu" | "paste" | "select-word";
|
||||
isAlternateScreen?: boolean;
|
||||
terminalMouseTrackingMode?: string;
|
||||
showReconnectAction?: boolean;
|
||||
forceMenuInAlternateScreen?: boolean;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldOpenTerminalContextMenu;
|
||||
const shouldRenderTerminalContextMenuContent = (
|
||||
terminalContextMenu as {
|
||||
shouldRenderTerminalContextMenuContent?: (options: {
|
||||
isAlternateScreen?: boolean;
|
||||
terminalMouseTrackingMode?: string;
|
||||
showReconnectAction?: boolean;
|
||||
allowSuppressedMenuContent?: boolean;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldRenderTerminalContextMenuContent;
|
||||
const shouldAllowSuppressedTerminalContextMenuContent = (
|
||||
terminalContextMenu as {
|
||||
shouldAllowSuppressedTerminalContextMenuContent?: (options: {
|
||||
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
|
||||
isAlternateScreen?: boolean;
|
||||
terminalMouseTrackingMode?: string;
|
||||
showReconnectAction?: boolean;
|
||||
}) => boolean;
|
||||
}
|
||||
).shouldAllowSuppressedTerminalContextMenuContent;
|
||||
|
||||
test("shows reconnect only for reconnectable terminals with a handler", () => {
|
||||
assert.equal(typeof shouldShowReconnectAction, "function");
|
||||
if (typeof shouldShowReconnectAction !== "function") return;
|
||||
|
||||
assert.equal(
|
||||
shouldShowReconnectAction({
|
||||
isReconnectable: true,
|
||||
onReconnect: () => {},
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowReconnectAction({
|
||||
isReconnectable: false,
|
||||
onReconnect: () => {},
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldShowReconnectAction({ isReconnectable: true }), false);
|
||||
});
|
||||
|
||||
test("localizes the reconnect context menu label", () => {
|
||||
assert.equal(en["terminal.menu.reconnect"], "Reconnect");
|
||||
assert.equal(zhCN["terminal.menu.reconnect"], "重新连接");
|
||||
});
|
||||
|
||||
test("shows add selection to AI context menu action when a handler exists", () => {
|
||||
assert.equal(typeof shouldShowAddSelectionToAIContextMenuAction, "function");
|
||||
if (typeof shouldShowAddSelectionToAIContextMenuAction !== "function") return;
|
||||
|
||||
assert.equal(shouldShowAddSelectionToAIContextMenuAction(() => {}), true);
|
||||
assert.equal(shouldShowAddSelectionToAIContextMenuAction(), false);
|
||||
});
|
||||
|
||||
test("shows upload clipboard image context menu action when a handler exists", () => {
|
||||
assert.equal(typeof shouldShowUploadClipboardImageContextMenuAction, "function");
|
||||
if (typeof shouldShowUploadClipboardImageContextMenuAction !== "function") return;
|
||||
|
||||
assert.equal(shouldShowUploadClipboardImageContextMenuAction(() => {}), true);
|
||||
assert.equal(shouldShowUploadClipboardImageContextMenuAction(), false);
|
||||
});
|
||||
|
||||
test("localizes the upload clipboard image context menu label", () => {
|
||||
const locales = { en, ru, es, "zh-CN": zhCN };
|
||||
const keys = [
|
||||
"terminal.menu.uploadClipboardImage",
|
||||
"terminal.clipboardImageUpload.noImage",
|
||||
"terminal.clipboardImageUpload.failed",
|
||||
] as const;
|
||||
|
||||
for (const [locale, messages] of Object.entries(locales)) {
|
||||
for (const key of keys) {
|
||||
assert.equal(
|
||||
typeof messages[key],
|
||||
"string",
|
||||
`${locale} should include ${key}`,
|
||||
);
|
||||
assert.notEqual(messages[key], "", `${locale} should not leave ${key} empty`);
|
||||
assert.notEqual(messages[key], key, `${locale} should translate ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(en["terminal.menu.uploadClipboardImage"], "Upload clipboard image");
|
||||
assert.equal(zhCN["terminal.menu.uploadClipboardImage"], "上传剪贴板图片");
|
||||
assert.equal(ru["terminal.menu.uploadClipboardImage"], "Загрузить изображение из буфера");
|
||||
});
|
||||
|
||||
test("localizes the YMODEM serial send actions", () => {
|
||||
assert.equal(en["terminal.menu.sendYmodem"], "Send with YMODEM");
|
||||
assert.equal(en["terminal.menu.receiveYmodem"], "Receive with YMODEM");
|
||||
assert.equal(en["terminal.toolbar.sendYmodem"], "Send with YMODEM");
|
||||
assert.equal(en["terminal.toolbar.receiveYmodem"], "Receive with YMODEM");
|
||||
assert.equal(zhCN["terminal.menu.sendYmodem"], "YMODEM 发送");
|
||||
assert.equal(zhCN["terminal.menu.receiveYmodem"], "YMODEM 接收");
|
||||
assert.equal(zhCN["terminal.toolbar.sendYmodem"], "YMODEM 发送");
|
||||
assert.equal(zhCN["terminal.toolbar.receiveYmodem"], "YMODEM 接收");
|
||||
});
|
||||
|
||||
test("enables YMODEM action only for connected serial terminals", () => {
|
||||
const handler = () => {};
|
||||
|
||||
assert.equal(shouldEnableYmodemAction({
|
||||
isSerialConnection: true,
|
||||
status: "connected",
|
||||
handleSendYmodem: handler,
|
||||
}), true);
|
||||
assert.equal(shouldEnableYmodemAction({
|
||||
isSerialConnection: true,
|
||||
status: "connected",
|
||||
handleReceiveYmodem: handler,
|
||||
}), true);
|
||||
assert.equal(shouldEnableYmodemAction({
|
||||
isSerialConnection: true,
|
||||
status: "disconnected",
|
||||
handleReceiveYmodem: handler,
|
||||
}), false);
|
||||
assert.equal(shouldEnableYmodemAction({
|
||||
isSerialConnection: true,
|
||||
status: "disconnected",
|
||||
handleSendYmodem: handler,
|
||||
}), false);
|
||||
assert.equal(shouldEnableYmodemAction({
|
||||
isSerialConnection: false,
|
||||
status: "connected",
|
||||
handleSendYmodem: handler,
|
||||
}), false);
|
||||
assert.equal(shouldEnableYmodemAction({
|
||||
isSerialConnection: true,
|
||||
status: "connected",
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("allows reconnect menu while stale mouse tracking is still active", () => {
|
||||
assert.equal(typeof shouldSuppressMouseTrackingContextMenu, "function");
|
||||
if (typeof shouldSuppressMouseTrackingContextMenu !== "function") return;
|
||||
|
||||
assert.equal(
|
||||
shouldSuppressMouseTrackingContextMenu({
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldSuppressMouseTrackingContextMenu({
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("forceMenuInAlternateScreen opts out of alternate-screen suppression", () => {
|
||||
assert.equal(typeof shouldSuppressMouseTrackingContextMenu, "function");
|
||||
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
|
||||
if (
|
||||
typeof shouldSuppressMouseTrackingContextMenu !== "function" ||
|
||||
typeof shouldOpenTerminalContextMenu !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Setting on: no suppression, right-click opens the app menu in tmux/vim.
|
||||
assert.equal(
|
||||
shouldSuppressMouseTrackingContextMenu({
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
forceMenuInAlternateScreen: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event: { shiftKey: false, nativeEvent: {} as MouseEvent },
|
||||
rightClickBehavior: "context-menu",
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
forceMenuInAlternateScreen: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// Setting off (default): alternate screen still suppresses the menu.
|
||||
assert.equal(
|
||||
shouldSuppressMouseTrackingContextMenu({
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
forceMenuInAlternateScreen: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldSuppressMouseTrackingContextMenu({
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
forceMenuInAlternateScreen: false,
|
||||
isHistoryPreviewTarget: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event: { shiftKey: false, nativeEvent: {} as MouseEvent },
|
||||
rightClickBehavior: "paste",
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
isHistoryPreviewTarget: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("opens a middle-click menu even when right-click is configured to paste", () => {
|
||||
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
|
||||
if (typeof shouldOpenTerminalContextMenu !== "function") return;
|
||||
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event: {
|
||||
shiftKey: false,
|
||||
nativeEvent: markMiddleClickContextMenuEvent({} as MouseEvent),
|
||||
},
|
||||
rightClickBehavior: "paste",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event: {
|
||||
shiftKey: false,
|
||||
nativeEvent: {} as MouseEvent,
|
||||
},
|
||||
rightClickBehavior: "paste",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("opens and renders middle-click menu while alternate-screen mouse tracking suppresses right-click menus", () => {
|
||||
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
|
||||
assert.equal(typeof shouldRenderTerminalContextMenuContent, "function");
|
||||
assert.equal(typeof shouldAllowSuppressedTerminalContextMenuContent, "function");
|
||||
if (
|
||||
typeof shouldOpenTerminalContextMenu !== "function" ||
|
||||
typeof shouldRenderTerminalContextMenuContent !== "function" ||
|
||||
typeof shouldAllowSuppressedTerminalContextMenuContent !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const middleClickEvent = {
|
||||
shiftKey: false,
|
||||
nativeEvent: markMiddleClickContextMenuEvent({} as MouseEvent),
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event: middleClickEvent,
|
||||
rightClickBehavior: "paste",
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
const allowSuppressedMenuContent = shouldAllowSuppressedTerminalContextMenuContent({
|
||||
event: middleClickEvent,
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
});
|
||||
assert.equal(allowSuppressedMenuContent, true);
|
||||
assert.equal(
|
||||
shouldRenderTerminalContextMenuContent({
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
allowSuppressedMenuContent,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event: {
|
||||
shiftKey: false,
|
||||
nativeEvent: {} as MouseEvent,
|
||||
},
|
||||
rightClickBehavior: "context-menu",
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAllowSuppressedTerminalContextMenuContent({
|
||||
event: {
|
||||
nativeEvent: {} as MouseEvent,
|
||||
},
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRenderTerminalContextMenuContent({
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
allowSuppressedMenuContent: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the current mouse tracking mode when the cached state is stale", () => {
|
||||
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
|
||||
assert.equal(typeof shouldRenderTerminalContextMenuContent, "function");
|
||||
if (
|
||||
typeof shouldOpenTerminalContextMenu !== "function" ||
|
||||
typeof shouldRenderTerminalContextMenuContent !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = {
|
||||
nativeEvent: {} as MouseEvent,
|
||||
};
|
||||
|
||||
// xterm has already stopped reporting mouse events, but React still has
|
||||
// the previous tracking state: paste/select-word must not be dropped.
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event,
|
||||
rightClickBehavior: "paste",
|
||||
isAlternateScreen: true,
|
||||
terminalMouseTrackingMode: "none",
|
||||
showReconnectAction: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRenderTerminalContextMenuContent({
|
||||
isAlternateScreen: true,
|
||||
terminalMouseTrackingMode: "none",
|
||||
showReconnectAction: false,
|
||||
allowSuppressedMenuContent: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
// Conversely, a newly active xterm mode must still suppress the app menu
|
||||
// while the cached React state has not caught up.
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event,
|
||||
rightClickBehavior: "context-menu",
|
||||
isAlternateScreen: false,
|
||||
terminalMouseTrackingMode: "vt200",
|
||||
showReconnectAction: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldRenderTerminalContextMenuContent({
|
||||
isAlternateScreen: false,
|
||||
terminalMouseTrackingMode: "vt200",
|
||||
showReconnectAction: false,
|
||||
allowSuppressedMenuContent: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("opens Shift right-click menu content for all right-click modes while mouse tracking suppresses unmodified menus", () => {
|
||||
assert.equal(typeof shouldOpenTerminalContextMenu, "function");
|
||||
assert.equal(typeof shouldRenderTerminalContextMenuContent, "function");
|
||||
assert.equal(typeof shouldAllowSuppressedTerminalContextMenuContent, "function");
|
||||
if (
|
||||
typeof shouldOpenTerminalContextMenu !== "function" ||
|
||||
typeof shouldRenderTerminalContextMenuContent !== "function" ||
|
||||
typeof shouldAllowSuppressedTerminalContextMenuContent !== "function"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = {
|
||||
shiftKey: true,
|
||||
nativeEvent: {} as MouseEvent,
|
||||
};
|
||||
|
||||
for (const rightClickBehavior of ["context-menu", "paste", "select-word"] as const) {
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event,
|
||||
rightClickBehavior,
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
const allowSuppressedMenuContent = shouldAllowSuppressedTerminalContextMenuContent({
|
||||
event,
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
});
|
||||
|
||||
assert.equal(allowSuppressedMenuContent, true);
|
||||
assert.equal(
|
||||
shouldRenderTerminalContextMenuContent({
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
allowSuppressedMenuContent,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
shouldOpenTerminalContextMenu({
|
||||
event: {
|
||||
nativeEvent: {} as MouseEvent,
|
||||
},
|
||||
rightClickBehavior: "context-menu",
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAllowSuppressedTerminalContextMenuContent({
|
||||
event: {
|
||||
nativeEvent: {} as MouseEvent,
|
||||
},
|
||||
isAlternateScreen: true,
|
||||
showReconnectAction: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
505
components/terminal/TerminalContextMenu.tsx
Normal file
505
components/terminal/TerminalContextMenu.tsx
Normal file
@@ -0,0 +1,505 @@
|
||||
/**
|
||||
* Terminal Context Menu
|
||||
* Right-click menu for terminal with split, copy/paste, and other actions
|
||||
*/
|
||||
import {
|
||||
ClipboardPaste,
|
||||
Copy,
|
||||
Download,
|
||||
Pencil,
|
||||
RefreshCcw,
|
||||
Sparkles,
|
||||
SquareArrowOutUpRight,
|
||||
SplitSquareHorizontal,
|
||||
SplitSquareVertical,
|
||||
Terminal as TerminalIcon,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { KeyBinding, RightClickBehavior } from '../../domain/models';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuTrigger,
|
||||
} from '../ui/context-menu';
|
||||
import { isMiddleClickContextMenuEvent, isMouseTrackingActive } from './runtime/middleClickBehavior';
|
||||
import { isHistoryPreviewContextMenuTarget } from './runtime/terminalHistoryScrollOverride';
|
||||
import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } from '../../application/state/usePluginContributions';
|
||||
import { buildTerminalPluginContributionContext } from '../../application/state/pluginContributionContexts';
|
||||
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
|
||||
|
||||
export interface TerminalContextMenuProps {
|
||||
children: React.ReactNode;
|
||||
sessionId: string;
|
||||
workspaceId?: string;
|
||||
status: 'connecting' | 'connected' | 'disconnected';
|
||||
hostId?: string;
|
||||
hostProtocol?: string;
|
||||
hasSelection?: boolean;
|
||||
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
|
||||
keyBindings?: KeyBinding[];
|
||||
rightClickBehavior?: RightClickBehavior;
|
||||
isAlternateScreen?: boolean;
|
||||
/** Read the current xterm mouse-tracking mode when handling a right-click. */
|
||||
getMouseTrackingMode?: () => string | undefined;
|
||||
/** When true, show the app context menu even while a fullscreen app (tmux/vim) holds mouse tracking. */
|
||||
showContextMenuOverFullscreenApps?: boolean;
|
||||
onCopy?: () => void;
|
||||
onPaste?: () => void;
|
||||
onUploadClipboardImage?: () => void;
|
||||
onPasteSelection?: () => void;
|
||||
onSelectAll?: () => void;
|
||||
onClear?: () => void;
|
||||
onSplitHorizontal?: () => void;
|
||||
onSplitVertical?: () => void;
|
||||
onSendYmodem?: () => void;
|
||||
onReceiveYmodem?: () => void;
|
||||
isReconnectable?: boolean;
|
||||
onReconnect?: () => void;
|
||||
onClose?: () => void;
|
||||
onSelectWord?: () => void;
|
||||
onAddSelectionToAI?: () => void;
|
||||
onRename?: () => void;
|
||||
onDetach?: () => void;
|
||||
}
|
||||
|
||||
export const shouldShowReconnectAction = ({
|
||||
isReconnectable,
|
||||
onReconnect,
|
||||
}: {
|
||||
isReconnectable?: boolean;
|
||||
onReconnect?: () => void;
|
||||
}): boolean => Boolean(isReconnectable && onReconnect);
|
||||
|
||||
export const shouldSuppressMouseTrackingContextMenu = ({
|
||||
isAlternateScreen,
|
||||
terminalMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
forceMenuInAlternateScreen,
|
||||
isHistoryPreviewTarget,
|
||||
}: {
|
||||
isAlternateScreen?: boolean;
|
||||
terminalMouseTrackingMode?: string;
|
||||
showReconnectAction?: boolean;
|
||||
forceMenuInAlternateScreen?: boolean;
|
||||
isHistoryPreviewTarget?: boolean;
|
||||
}): boolean => Boolean(
|
||||
!isHistoryPreviewTarget
|
||||
&& isMouseTrackingActive({
|
||||
mouseTracking: Boolean(isAlternateScreen),
|
||||
terminalMouseTrackingMode,
|
||||
})
|
||||
&& !showReconnectAction
|
||||
&& !forceMenuInAlternateScreen,
|
||||
);
|
||||
|
||||
export const shouldShowAddSelectionToAIContextMenuAction = (
|
||||
onAddSelectionToAI?: () => void,
|
||||
): boolean => Boolean(onAddSelectionToAI);
|
||||
|
||||
export const shouldShowUploadClipboardImageContextMenuAction = (
|
||||
onUploadClipboardImage?: () => void,
|
||||
): boolean => Boolean(onUploadClipboardImage);
|
||||
|
||||
export const shouldRenderTerminalContextMenuContent = ({
|
||||
isAlternateScreen,
|
||||
terminalMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
allowSuppressedMenuContent,
|
||||
forceMenuInAlternateScreen,
|
||||
isHistoryPreviewTarget,
|
||||
}: {
|
||||
isAlternateScreen?: boolean;
|
||||
terminalMouseTrackingMode?: string;
|
||||
showReconnectAction?: boolean;
|
||||
allowSuppressedMenuContent?: boolean;
|
||||
forceMenuInAlternateScreen?: boolean;
|
||||
isHistoryPreviewTarget?: boolean;
|
||||
}): boolean =>
|
||||
allowSuppressedMenuContent ||
|
||||
!shouldSuppressMouseTrackingContextMenu({
|
||||
isAlternateScreen,
|
||||
terminalMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
forceMenuInAlternateScreen,
|
||||
isHistoryPreviewTarget,
|
||||
});
|
||||
|
||||
export const shouldAllowSuppressedTerminalContextMenuContent = ({
|
||||
event,
|
||||
isAlternateScreen,
|
||||
terminalMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
forceMenuInAlternateScreen,
|
||||
isHistoryPreviewTarget,
|
||||
}: {
|
||||
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
|
||||
isAlternateScreen?: boolean;
|
||||
terminalMouseTrackingMode?: string;
|
||||
showReconnectAction?: boolean;
|
||||
forceMenuInAlternateScreen?: boolean;
|
||||
isHistoryPreviewTarget?: boolean;
|
||||
}): boolean =>
|
||||
isMiddleClickContextMenuEvent(event.nativeEvent)
|
||||
|| Boolean(isHistoryPreviewTarget)
|
||||
|| Boolean(event.shiftKey && shouldSuppressMouseTrackingContextMenu({
|
||||
isAlternateScreen,
|
||||
terminalMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
forceMenuInAlternateScreen,
|
||||
}));
|
||||
|
||||
export const shouldOpenTerminalContextMenu = ({
|
||||
event,
|
||||
rightClickBehavior = 'context-menu',
|
||||
isAlternateScreen,
|
||||
terminalMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
forceMenuInAlternateScreen,
|
||||
isHistoryPreviewTarget,
|
||||
}: {
|
||||
event: { shiftKey?: boolean; nativeEvent: MouseEvent };
|
||||
rightClickBehavior?: RightClickBehavior;
|
||||
isAlternateScreen?: boolean;
|
||||
terminalMouseTrackingMode?: string;
|
||||
showReconnectAction?: boolean;
|
||||
forceMenuInAlternateScreen?: boolean;
|
||||
isHistoryPreviewTarget?: boolean;
|
||||
}): boolean => {
|
||||
if (isMiddleClickContextMenuEvent(event.nativeEvent)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.shiftKey || isHistoryPreviewTarget) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shouldSuppressMouseTrackingContextMenu({
|
||||
isAlternateScreen,
|
||||
terminalMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
forceMenuInAlternateScreen,
|
||||
isHistoryPreviewTarget,
|
||||
})) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return rightClickBehavior === 'context-menu';
|
||||
};
|
||||
|
||||
export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
children,
|
||||
sessionId,
|
||||
workspaceId,
|
||||
status,
|
||||
hostId,
|
||||
hostProtocol,
|
||||
hasSelection = false,
|
||||
hotkeyScheme = 'mac',
|
||||
keyBindings,
|
||||
rightClickBehavior = 'context-menu',
|
||||
isAlternateScreen = false,
|
||||
getMouseTrackingMode,
|
||||
showContextMenuOverFullscreenApps = false,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onUploadClipboardImage,
|
||||
onPasteSelection,
|
||||
onSelectAll,
|
||||
onClear,
|
||||
onSplitHorizontal,
|
||||
onSplitVertical,
|
||||
onSendYmodem,
|
||||
onReceiveYmodem,
|
||||
isReconnectable,
|
||||
onReconnect,
|
||||
onClose,
|
||||
onSelectWord,
|
||||
onAddSelectionToAI,
|
||||
onRename,
|
||||
onDetach,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const terminalContext = buildTerminalPluginContributionContext({
|
||||
surface: 'terminal/context',
|
||||
sessionId,
|
||||
status,
|
||||
hostId,
|
||||
hostProtocol,
|
||||
workspaceId,
|
||||
hasSelection,
|
||||
alternateScreen: isAlternateScreen,
|
||||
reconnectable: Boolean(isReconnectable),
|
||||
});
|
||||
const pluginContributions = usePluginContributions(
|
||||
{ context: terminalContext },
|
||||
{ enabled: menuOpen },
|
||||
);
|
||||
const pluginMenus = collectOwnedPluginMenus(pluginContributions.snapshot.plugins)
|
||||
.filter((menu) => menu.location === 'terminal/context' && menu.visible)
|
||||
.sort(comparePluginMenus);
|
||||
const isMac = hotkeyScheme === 'mac';
|
||||
// Tracks the .workspace-pane whose context menu is currently open so we can
|
||||
// keep its `:focus-within`-driven opacity stable while focus is in the
|
||||
// menu portal (otherwise the pane dims for the menu's lifetime).
|
||||
const markedPaneRef = useRef<HTMLElement | null>(null);
|
||||
const [allowSuppressedMenuContent, setAllowSuppressedMenuContent] = useState(false);
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
setMenuOpen(open);
|
||||
if (!open) {
|
||||
markedPaneRef.current?.removeAttribute('data-menu-open');
|
||||
markedPaneRef.current = null;
|
||||
setAllowSuppressedMenuContent(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Helper to get shortcut from keyBindings and format for display
|
||||
const getShortcut = (bindingId: string): string => {
|
||||
const binding = keyBindings?.find(b => b.id === bindingId);
|
||||
if (!binding) return '';
|
||||
const key = isMac ? binding.mac : binding.pc;
|
||||
if (!key || key === 'Disabled') return '';
|
||||
// Replace " + " with space for cleaner display (e.g., "⌘ + Shift + D" → "⌘ Shift D")
|
||||
return key.replace(/\s*\+\s*/g, ' ').trim();
|
||||
};
|
||||
|
||||
const copyShortcut = getShortcut('copy');
|
||||
const pasteShortcut = getShortcut('paste');
|
||||
const pasteSelectionShortcut = getShortcut('paste-selection');
|
||||
const selectAllShortcut = getShortcut('select-all');
|
||||
const splitHShortcut = getShortcut('split-horizontal');
|
||||
const splitVShortcut = getShortcut('split-vertical');
|
||||
const clearShortcut = getShortcut('clear-buffer');
|
||||
const showReconnectAction = shouldShowReconnectAction({ isReconnectable, onReconnect });
|
||||
|
||||
const terminalMouseTrackingMode = getMouseTrackingMode?.();
|
||||
|
||||
// Handle right-click: intercept for paste/select-word unless Shift is held
|
||||
// or rightClickBehavior is 'context-menu'. The ContextMenuTrigger stays always
|
||||
// enabled so Shift+Right-Click opens the menu on the first click.
|
||||
const handleRightClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// In alternate screen (tmux, vim, etc.), let the terminal application
|
||||
// handle right-click natively to avoid conflicting menus. Reconnect is
|
||||
// still available after disconnect, even if mouse tracking was left on.
|
||||
const currentMouseTrackingMode = getMouseTrackingMode?.();
|
||||
const isHistoryPreviewTarget = isHistoryPreviewContextMenuTarget(e.target);
|
||||
const shouldOpenMenu = shouldOpenTerminalContextMenu({
|
||||
event: e,
|
||||
rightClickBehavior,
|
||||
isAlternateScreen,
|
||||
terminalMouseTrackingMode: currentMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
|
||||
isHistoryPreviewTarget,
|
||||
});
|
||||
|
||||
if (!shouldOpenMenu && shouldSuppressMouseTrackingContextMenu({
|
||||
isAlternateScreen,
|
||||
terminalMouseTrackingMode: currentMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
|
||||
isHistoryPreviewTarget,
|
||||
})) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// Shift+Right-Click or context-menu mode: let Radix open the menu
|
||||
if (shouldOpenMenu) {
|
||||
const pane = (e.target as HTMLElement | null)?.closest<HTMLElement>('.workspace-pane');
|
||||
if (pane) {
|
||||
markedPaneRef.current?.removeAttribute('data-menu-open');
|
||||
pane.setAttribute('data-menu-open', '');
|
||||
markedPaneRef.current = pane;
|
||||
}
|
||||
setAllowSuppressedMenuContent(shouldAllowSuppressedTerminalContextMenuContent({
|
||||
event: e,
|
||||
isAlternateScreen,
|
||||
terminalMouseTrackingMode: currentMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
|
||||
isHistoryPreviewTarget,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Paste / select-word: intercept and prevent the context menu
|
||||
e.preventDefault();
|
||||
if (rightClickBehavior === 'paste') {
|
||||
onPaste?.();
|
||||
} else if (rightClickBehavior === 'select-word') {
|
||||
onSelectWord?.();
|
||||
}
|
||||
},
|
||||
[rightClickBehavior, onPaste, onSelectWord, isAlternateScreen, getMouseTrackingMode, showReconnectAction, showContextMenuOverFullscreenApps],
|
||||
);
|
||||
|
||||
// Always use ContextMenu wrapper to maintain consistent React tree structure
|
||||
// This prevents terminal from unmounting when rightClickBehavior changes
|
||||
return (
|
||||
<ContextMenu onOpenChange={handleOpenChange}>
|
||||
<ContextMenuTrigger
|
||||
asChild
|
||||
onContextMenu={handleRightClick}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuTrigger>
|
||||
{shouldRenderTerminalContextMenuContent({
|
||||
isAlternateScreen,
|
||||
terminalMouseTrackingMode,
|
||||
showReconnectAction,
|
||||
allowSuppressedMenuContent,
|
||||
forceMenuInAlternateScreen: showContextMenuOverFullscreenApps,
|
||||
}) && (
|
||||
<ContextMenuContent className="w-max">
|
||||
<ContextMenuItem onClick={onCopy} disabled={!hasSelection}>
|
||||
<Copy size={14} className="mr-2" />
|
||||
{t('terminal.menu.copy')}
|
||||
<ContextMenuShortcut>{copyShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onPaste}>
|
||||
<ClipboardPaste size={14} className="mr-2" />
|
||||
{t('terminal.menu.paste')}
|
||||
<ContextMenuShortcut>{pasteShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
{shouldShowUploadClipboardImageContextMenuAction(onUploadClipboardImage) && (
|
||||
<ContextMenuItem onClick={onUploadClipboardImage}>
|
||||
<Upload size={14} className="mr-2" />
|
||||
{t('terminal.menu.uploadClipboardImage')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{shouldShowAddSelectionToAIContextMenuAction(onAddSelectionToAI) && (
|
||||
<ContextMenuItem onClick={onAddSelectionToAI} disabled={!hasSelection}>
|
||||
<Sparkles size={14} className="mr-2" />
|
||||
{t('terminal.menu.addSelectionToAI')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onPasteSelection && (
|
||||
<ContextMenuItem onClick={onPasteSelection} disabled={!hasSelection}>
|
||||
<ClipboardPaste size={14} className="mr-2" />
|
||||
{t('terminal.menu.pasteSelection')}
|
||||
<ContextMenuShortcut>{pasteSelectionShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={onSelectAll}>
|
||||
<TerminalIcon size={14} className="mr-2" />
|
||||
{t('terminal.menu.selectAll')}
|
||||
<ContextMenuShortcut>{selectAllShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
|
||||
{showReconnectAction && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onReconnect}>
|
||||
<RefreshCcw size={14} className="mr-2" />
|
||||
{t('terminal.menu.reconnect')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(onSendYmodem || onReceiveYmodem) && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
{onSendYmodem && (
|
||||
<ContextMenuItem onClick={onSendYmodem}>
|
||||
<Upload size={14} className="mr-2" />
|
||||
{t('terminal.menu.sendYmodem')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
{onReceiveYmodem && (
|
||||
<ContextMenuItem onClick={onReceiveYmodem}>
|
||||
<Download size={14} className="mr-2" />
|
||||
{t('terminal.menu.receiveYmodem')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem onClick={onSplitHorizontal}>
|
||||
<SplitSquareVertical size={14} className="mr-2" />
|
||||
{t('terminal.menu.splitHorizontal')}
|
||||
<ContextMenuShortcut>{splitHShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onSplitVertical}>
|
||||
<SplitSquareHorizontal size={14} className="mr-2" />
|
||||
{t('terminal.menu.splitVertical')}
|
||||
<ContextMenuShortcut>{splitVShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
<ContextMenuItem onClick={onClear}>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
{t('terminal.menu.clearBuffer')}
|
||||
<ContextMenuShortcut>{clearShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
|
||||
{onRename && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onRename}>
|
||||
<Pencil size={14} className="mr-2" />
|
||||
{t('terminal.menu.rename')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onDetach && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onDetach}>
|
||||
<SquareArrowOutUpRight size={14} className="mr-2" />
|
||||
{t('terminal.menu.detach')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{pluginMenus.length > 0 && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
{pluginMenus.map((menu) => (
|
||||
<ContextMenuItem
|
||||
key={menu.id}
|
||||
disabled={!menu.enabled}
|
||||
onClick={(event) => void pluginContributions.executeCommand(event.altKey && menu.alt ? menu.alt : menu.command, undefined, {
|
||||
...terminalContext,
|
||||
}).catch(() => {})}
|
||||
>
|
||||
<PluginContributionIcon pluginId={menu.pluginId} icon={menu.icon} className="mr-2" />
|
||||
{menu.title}
|
||||
{menu.checked && <span className="ml-auto pl-4" aria-hidden="true">✓</span>}
|
||||
{menu.shortcut && <ContextMenuShortcut>{menu.shortcut}</ContextMenuShortcut>}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{onClose && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
onClick={onClose}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
{t('terminal.menu.closeTerminal')}
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
)}
|
||||
</ContextMenuContent>
|
||||
)}
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalContextMenu;
|
||||
120
components/terminal/TerminalHostKeyVerification.tsx
Normal file
120
components/terminal/TerminalHostKeyVerification.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { AlertTriangle, Fingerprint } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { HostKeyInfo } from '../../domain/hostKey';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { TerminalConnectionLogList } from './TerminalConnectionProgress';
|
||||
|
||||
export type { HostKeyInfo } from '../../domain/hostKey';
|
||||
|
||||
export interface TerminalHostKeyVerificationProps {
|
||||
hostKeyInfo: HostKeyInfo;
|
||||
showLogs: boolean;
|
||||
progressLogs: string[];
|
||||
onClose: () => void;
|
||||
onContinue: () => void;
|
||||
onAddAndContinue: () => void;
|
||||
}
|
||||
|
||||
export const TerminalHostKeyVerification: React.FC<TerminalHostKeyVerificationProps> = ({
|
||||
hostKeyInfo,
|
||||
showLogs,
|
||||
progressLogs,
|
||||
onClose,
|
||||
onContinue,
|
||||
onAddAndContinue,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isChanged = hostKeyInfo.status === 'changed';
|
||||
const Icon = isChanged ? AlertTriangle : Fingerprint;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 animate-in fade-in-0 slide-in-from-bottom-1 duration-200">
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-2.5",
|
||||
isChanged
|
||||
? "border-destructive/25 bg-destructive/8"
|
||||
: "border-amber-500/20 bg-amber-500/8",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<div
|
||||
className={cn(
|
||||
"mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg",
|
||||
isChanged
|
||||
? "bg-destructive/15 text-destructive"
|
||||
: "bg-amber-500/15 text-amber-400",
|
||||
)}
|
||||
>
|
||||
<Icon size={15} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm font-semibold",
|
||||
isChanged ? "text-destructive" : "text-amber-400",
|
||||
)}
|
||||
>
|
||||
{isChanged
|
||||
? t('terminal.hostKey.changedTitle')
|
||||
: t('terminal.hostKey.unknownTitle')}
|
||||
</div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{isChanged
|
||||
? t('terminal.hostKey.changedDescription', { host: hostKeyInfo.hostname })
|
||||
: t('terminal.hostKey.unknownDescription', { host: hostKeyInfo.hostname })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t('terminal.hostKey.fingerprintLabel', { keyType: hostKeyInfo.keyType })}
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/50 bg-background/45 p-3">
|
||||
<code className="block break-all font-mono text-xs leading-5 text-foreground/90">
|
||||
{hostKeyInfo.fingerprint}
|
||||
</code>
|
||||
</div>
|
||||
{isChanged && hostKeyInfo.knownFingerprint && (
|
||||
<div className="rounded-lg border border-destructive/25 bg-destructive/8 p-3">
|
||||
<div className="mb-1 text-[11px] font-medium text-destructive">
|
||||
{t('terminal.hostKey.savedFingerprintLabel')}
|
||||
</div>
|
||||
<code className="block break-all font-mono text-xs leading-5 text-foreground/90">
|
||||
{hostKeyInfo.knownFingerprint}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{isChanged
|
||||
? t('terminal.hostKey.changedHint')
|
||||
: t('terminal.hostKey.unknownHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showLogs && (
|
||||
<TerminalConnectionLogList progressLogs={progressLogs} />
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 px-3 text-[11px]" onClick={onClose}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 px-3 text-[11px]" onClick={onContinue}>
|
||||
{t('common.continue')}
|
||||
</Button>
|
||||
<Button size="sm" className="h-7 px-3 text-[11px]" onClick={onAddAndContinue}>
|
||||
{isChanged
|
||||
? t('terminal.hostKey.updateAndContinue')
|
||||
: t('terminal.hostKey.addAndContinue')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalHostKeyVerification;
|
||||
192
components/terminal/TerminalSearchBar.tsx
Normal file
192
components/terminal/TerminalSearchBar.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Terminal Search Bar
|
||||
* Provides search functionality within terminal scrollback buffer
|
||||
*/
|
||||
import { ChevronUp, ChevronDown, Search } from 'lucide-react';
|
||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Button } from '../ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
export interface TerminalSearchBarProps {
|
||||
isOpen: boolean;
|
||||
/**
|
||||
* Incremented each time the search hotkey fires while the bar is already
|
||||
* open. Watched by the focus effect so Cmd/Ctrl+F re-grabs focus when it
|
||||
* has moved elsewhere (issue #1789). Ignored while `isOpen` is false.
|
||||
*/
|
||||
focusToken?: number;
|
||||
onClose: () => void;
|
||||
onSearch: (term: string) => boolean;
|
||||
onFindNext: () => boolean;
|
||||
onFindPrevious: () => boolean;
|
||||
matchCount?: { current: number; total: number } | null;
|
||||
}
|
||||
|
||||
export const notifyTerminalSearchTermChange = (
|
||||
searchTerm: string,
|
||||
previousSearchTerm: string,
|
||||
onSearch: (term: string) => boolean,
|
||||
): string => {
|
||||
if (searchTerm === previousSearchTerm) return previousSearchTerm;
|
||||
onSearch(searchTerm);
|
||||
return searchTerm;
|
||||
};
|
||||
|
||||
export const TerminalSearchBar: React.FC<TerminalSearchBarProps> = ({
|
||||
isOpen,
|
||||
focusToken,
|
||||
onClose,
|
||||
onSearch,
|
||||
onFindNext,
|
||||
onFindPrevious,
|
||||
matchCount,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const prevSearchTermRef = useRef('');
|
||||
|
||||
// Focus input when opened, or when the search hotkey re-fires while open
|
||||
// (focusToken bumps) so focus returns to the input after it moved elsewhere.
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isOpen, focusToken]);
|
||||
|
||||
// Trigger search when term changes. When the term is cleared we still call
|
||||
// onSearch('') so the underlying search addon clears its highlights;
|
||||
// otherwise the last match decorations linger after emptying the input.
|
||||
useEffect(() => {
|
||||
prevSearchTermRef.current = notifyTerminalSearchTermChange(
|
||||
searchTerm,
|
||||
prevSearchTermRef.current,
|
||||
onSearch,
|
||||
);
|
||||
}, [searchTerm, onSearch]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
onFindPrevious();
|
||||
} else {
|
||||
onFindNext();
|
||||
}
|
||||
} else if (e.key === 'F3' || (e.key === 'g' && (e.ctrlKey || e.metaKey))) {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
onFindPrevious();
|
||||
} else {
|
||||
onFindNext();
|
||||
}
|
||||
}
|
||||
}, [onClose, onFindNext, onFindPrevious]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-2 pt-0 pb-2 bg-black/50 backdrop-blur-sm"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, #000000) 86%, transparent)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="relative flex-1">
|
||||
<Search
|
||||
size={12}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2"
|
||||
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 40%, transparent)' }}
|
||||
/>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
data-terminal-search-input=""
|
||||
placeholder={t("terminal.search.placeholder")}
|
||||
className="w-full h-6 pl-7 pr-2 text-[11px] border-none rounded placeholder:opacity-40 focus:outline-none"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 5%, transparent)',
|
||||
color: 'var(--terminal-ui-fg, #ffffff)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Match count indicator - only show when no results */}
|
||||
{searchTerm.length > 0 && matchCount?.total === 0 && (
|
||||
<span
|
||||
className="text-[10px] flex-shrink-0"
|
||||
style={{ color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 50%, transparent)' }}
|
||||
>
|
||||
{t("terminal.search.noResults")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 disabled:opacity-30"
|
||||
style={{
|
||||
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFindPrevious();
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
disabled={!searchTerm}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.search.prevMatch")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 disabled:opacity-30"
|
||||
style={{
|
||||
color: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 60%, transparent)',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onFindNext();
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
disabled={!searchTerm}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("terminal.search.nextMatch")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
270
components/terminal/TerminalSelectionAIOverlay.tsx
Normal file
270
components/terminal/TerminalSelectionAIOverlay.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Owns selection-driven "Add to AI" chrome so selection change does not re-render
|
||||
* the whole Terminal / TerminalView tree (common when focus moves to the AI input).
|
||||
*/
|
||||
import React, { memo, useEffect, useState, type RefObject } from 'react';
|
||||
import type { Terminal as XTerm } from '@xterm/xterm';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import {
|
||||
createCopyOnSelectUserGestureTracker,
|
||||
shouldWriteCopyOnSelect,
|
||||
subscribeCopyOnSelectUserCommand,
|
||||
subscribeCopyOnSelectUserGesture,
|
||||
} from './copyOnSelect';
|
||||
import { getTerminalSelectionForClipboard } from './normalizeTerminalSelection';
|
||||
import { resolveSelectionOverlayPosition } from './useTerminalEffects';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { shouldShowSelectionAIOverlay } from './TerminalView';
|
||||
|
||||
type SelectionOverlayPosition = { left: number; top: number } | null;
|
||||
|
||||
const areSelectionOverlayPositionsEqual = (
|
||||
a: SelectionOverlayPosition,
|
||||
b: SelectionOverlayPosition,
|
||||
): boolean => {
|
||||
if (a === b) return true;
|
||||
if (!a || !b) return false;
|
||||
return a.left === b.left && a.top === b.top;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
termRef: RefObject<XTerm | null>;
|
||||
containerRef: RefObject<HTMLElement | null>;
|
||||
showSelectionAIAction?: boolean;
|
||||
onAddSelectionToAI?: () => void;
|
||||
copyOnSelect?: boolean;
|
||||
normalizeTextOnCopy?: boolean;
|
||||
/**
|
||||
* True while createXTermRuntime programmatically restores selection
|
||||
* (preserveSelectionOnInput). Copy-on-select must skip those events.
|
||||
*/
|
||||
isRestoringSelectionRef?: RefObject<boolean>;
|
||||
isVisible?: boolean;
|
||||
};
|
||||
|
||||
function TerminalSelectionAIOverlayInner({
|
||||
termRef,
|
||||
containerRef,
|
||||
showSelectionAIAction,
|
||||
onAddSelectionToAI,
|
||||
copyOnSelect,
|
||||
normalizeTextOnCopy = true,
|
||||
isRestoringSelectionRef,
|
||||
isVisible = true,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
const [selectionOverlayPosition, setSelectionOverlayPosition] = useState<SelectionOverlayPosition>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
let disposed = false;
|
||||
let overlayRafId: number | null = null;
|
||||
let copyTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let waitRafId: number | null = null;
|
||||
let lastHasSelection: boolean | null = null;
|
||||
let lastOverlayPosition: SelectionOverlayPosition = null;
|
||||
let selectionDisposable: { dispose: () => void } | null = null;
|
||||
let scrollDisposable: { dispose: () => void } | null | undefined = null;
|
||||
let resizeDisposable: { dispose: () => void } | null | undefined = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let userGestureUnsubscribe: (() => void) | null = null;
|
||||
let userGestureTracker: ReturnType<typeof createCopyOnSelectUserGestureTracker> | null = null;
|
||||
|
||||
const requestFrame = typeof requestAnimationFrame === 'function'
|
||||
? requestAnimationFrame
|
||||
: (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0) as unknown as number;
|
||||
const cancelFrame = typeof cancelAnimationFrame === 'function'
|
||||
? cancelAnimationFrame
|
||||
: (id: number) => clearTimeout(id);
|
||||
|
||||
const cleanupListeners = () => {
|
||||
if (overlayRafId !== null) {
|
||||
cancelFrame(overlayRafId);
|
||||
overlayRafId = null;
|
||||
}
|
||||
if (copyTimer) {
|
||||
clearTimeout(copyTimer);
|
||||
copyTimer = null;
|
||||
}
|
||||
selectionDisposable?.dispose();
|
||||
selectionDisposable = null;
|
||||
scrollDisposable?.dispose();
|
||||
scrollDisposable = null;
|
||||
resizeDisposable?.dispose();
|
||||
resizeDisposable = null;
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
userGestureUnsubscribe?.();
|
||||
userGestureUnsubscribe = null;
|
||||
userGestureTracker?.dispose();
|
||||
userGestureTracker = null;
|
||||
};
|
||||
|
||||
const attach = (term: XTerm) => {
|
||||
cleanupListeners();
|
||||
userGestureTracker = createCopyOnSelectUserGestureTracker();
|
||||
const unsubscribePointer = subscribeCopyOnSelectUserGesture(term, userGestureTracker);
|
||||
const unsubscribeCommand = subscribeCopyOnSelectUserCommand(term, () => {
|
||||
userGestureTracker?.pulse();
|
||||
});
|
||||
userGestureUnsubscribe = () => {
|
||||
unsubscribePointer();
|
||||
unsubscribeCommand();
|
||||
};
|
||||
|
||||
const publishSelectionOverlayPosition = () => {
|
||||
overlayRafId = null;
|
||||
if (disposed) return;
|
||||
const nextPosition = resolveSelectionOverlayPosition(term, containerRef.current);
|
||||
if (areSelectionOverlayPositionsEqual(lastOverlayPosition, nextPosition)) return;
|
||||
lastOverlayPosition = nextPosition;
|
||||
setSelectionOverlayPosition(nextPosition);
|
||||
};
|
||||
|
||||
const scheduleSelectionOverlayPosition = () => {
|
||||
if (lastHasSelection === false) return;
|
||||
if (overlayRafId !== null) return;
|
||||
overlayRafId = requestFrame(publishSelectionOverlayPosition);
|
||||
};
|
||||
|
||||
const onSelectionChange = (options?: { allowCopy?: boolean }) => {
|
||||
if (disposed) return;
|
||||
const allowCopy = options?.allowCopy !== false;
|
||||
const rawSelection = term.getSelection();
|
||||
const hasText = !!rawSelection && rawSelection.length > 0;
|
||||
if (lastHasSelection !== hasText) {
|
||||
lastHasSelection = hasText;
|
||||
setHasSelection(hasText);
|
||||
}
|
||||
if (copyTimer) {
|
||||
clearTimeout(copyTimer);
|
||||
copyTimer = null;
|
||||
}
|
||||
if (!hasText) {
|
||||
if (lastOverlayPosition !== null) {
|
||||
lastOverlayPosition = null;
|
||||
setSelectionOverlayPosition(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
scheduleSelectionOverlayPosition();
|
||||
|
||||
// Skip programmatic selections: preserveSelectionOnInput restore,
|
||||
// SearchAddon match highlight (issue #3007), and the initial attach
|
||||
// snapshot so those writes cannot clobber a user copy.
|
||||
if (shouldWriteCopyOnSelect({
|
||||
allowCopy,
|
||||
hasText,
|
||||
copyOnSelect: !!copyOnSelect,
|
||||
isRestoringSelection: !!isRestoringSelectionRef?.current,
|
||||
isUserSelection: !!userGestureTracker?.isActive(),
|
||||
})) {
|
||||
const selection = getTerminalSelectionForClipboard(term, normalizeTextOnCopy);
|
||||
if (!selection) return;
|
||||
copyTimer = setTimeout(() => {
|
||||
void navigator.clipboard.writeText(selection).catch(() => {
|
||||
/* ignore clipboard failures */
|
||||
});
|
||||
}, 80);
|
||||
}
|
||||
};
|
||||
|
||||
selectionDisposable = term.onSelectionChange(() => onSelectionChange());
|
||||
scrollDisposable = term.onScroll?.(scheduleSelectionOverlayPosition);
|
||||
resizeDisposable = term.onResize?.(scheduleSelectionOverlayPosition);
|
||||
resizeObserver = typeof ResizeObserver === 'undefined'
|
||||
? null
|
||||
: new ResizeObserver(scheduleSelectionOverlayPosition);
|
||||
if (containerRef.current) {
|
||||
resizeObserver?.observe(containerRef.current);
|
||||
}
|
||||
// Sync UI only; do not write clipboard on reattach.
|
||||
onSelectionChange({ allowCopy: false });
|
||||
};
|
||||
|
||||
// Child effects run before parent useTerminalEffects assigns termRef.
|
||||
// Poll until the xterm runtime exists so copy-on-select / overlay attach
|
||||
// for sessions that mount already visible. Bound the wait so a failed
|
||||
// runtime never leaves a permanent rAF loop.
|
||||
const waitStartedAt = Date.now();
|
||||
const MAX_RUNTIME_WAIT_MS = 15_000;
|
||||
const tryAttach = () => {
|
||||
if (disposed) return;
|
||||
const term = termRef.current;
|
||||
if (!term) {
|
||||
if (Date.now() - waitStartedAt >= MAX_RUNTIME_WAIT_MS) {
|
||||
waitRafId = null;
|
||||
return;
|
||||
}
|
||||
waitRafId = requestFrame(tryAttach);
|
||||
return;
|
||||
}
|
||||
waitRafId = null;
|
||||
attach(term);
|
||||
};
|
||||
tryAttach();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (waitRafId !== null) cancelFrame(waitRafId);
|
||||
cleanupListeners();
|
||||
};
|
||||
}, [
|
||||
termRef,
|
||||
containerRef,
|
||||
copyOnSelect,
|
||||
normalizeTextOnCopy,
|
||||
isRestoringSelectionRef,
|
||||
isVisible,
|
||||
]);
|
||||
|
||||
if (!shouldShowSelectionAIOverlay({
|
||||
hasSelection,
|
||||
selectionOverlayPosition,
|
||||
onAddSelectionToAI,
|
||||
showSelectionAIAction,
|
||||
}) || !onAddSelectionToAI || !selectionOverlayPosition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-30 pointer-events-none"
|
||||
style={{
|
||||
left: selectionOverlayPosition.left,
|
||||
top: selectionOverlayPosition.top,
|
||||
transform: 'translate(-100%, -100%)',
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="pointer-events-auto inline-flex h-7 min-w-max items-center gap-1.5 whitespace-nowrap rounded-md border px-2 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors hover:bg-[color:var(--terminal-toolbar-btn-hover)]"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg) 86%, transparent)',
|
||||
borderColor: 'var(--terminal-ui-border)',
|
||||
color: 'var(--terminal-ui-fg)',
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={onAddSelectionToAI}
|
||||
aria-label={t('terminal.selection.addToAI')}
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
<span>{t('terminal.selection.addToAI')}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('terminal.selection.addToAIDesc')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const TerminalSelectionAIOverlay = memo(TerminalSelectionAIOverlayInner);
|
||||
TerminalSelectionAIOverlay.displayName = 'TerminalSelectionAIOverlay';
|
||||
19
components/terminal/TerminalServerStats.test.ts
Normal file
19
components/terminal/TerminalServerStats.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
test("CPU per-core stats list can scroll when many cores are reported", () => {
|
||||
const source = readFileSync(new URL("./TerminalServerStats.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(
|
||||
source,
|
||||
/className="grid gap-1\.5 max-h-\[\d+px\] overflow-y-auto/,
|
||||
);
|
||||
});
|
||||
|
||||
test("server stats stay subscribed while the terminal is in the background", () => {
|
||||
const source = readFileSync(new URL("./TerminalServerStats.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.doesNotMatch(source, /usePaneVisible/);
|
||||
assert.doesNotMatch(source, /isVisible,/);
|
||||
});
|
||||
381
components/terminal/TerminalServerStats.tsx
Normal file
381
components/terminal/TerminalServerStats.tsx
Normal file
@@ -0,0 +1,381 @@
|
||||
import React from 'react';
|
||||
import { Activity, ArrowDownToLine, ArrowUpFromLine, Cpu, HardDrive, MemoryStick } from 'lucide-react';
|
||||
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '../ui/hover-card';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { formatNetSpeed } from './terminalHelpers';
|
||||
import { useServerStats } from '../../application/state/useServerStats';
|
||||
import { formatDiskCapacityRange, resolveTerminalDiskSummary } from './serverStatsFormat';
|
||||
|
||||
interface TerminalServerStatsProps {
|
||||
sessionId: string;
|
||||
enabled: boolean;
|
||||
refreshInterval: number;
|
||||
isSupportedOs: boolean;
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
const formatLatency = (latencyMs: number | null): string => (
|
||||
typeof latencyMs === 'number' && Number.isFinite(latencyMs) ? `${latencyMs}ms` : '--ms'
|
||||
);
|
||||
|
||||
/**
|
||||
* Self-contained server-stats (CPU / Memory / Disk / Network) indicator.
|
||||
*
|
||||
* Owns the `useServerStats` polling itself so the periodic (~5s) stats refresh
|
||||
* only re-renders this small widget — previously the hook lived at the top of
|
||||
* <Terminal> and `serverStats` was threaded through the giant TerminalView ctx,
|
||||
* so every refresh re-rendered the whole terminal subtree (~45ms each, even
|
||||
* while idle).
|
||||
*/
|
||||
export const TerminalServerStats: React.FC<TerminalServerStatsProps> = ({
|
||||
sessionId,
|
||||
enabled,
|
||||
refreshInterval,
|
||||
isSupportedOs,
|
||||
isConnected,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { stats: serverStats } = useServerStats({
|
||||
sessionId,
|
||||
enabled,
|
||||
refreshInterval,
|
||||
isSupportedOs,
|
||||
isConnected,
|
||||
});
|
||||
const hasNetworkDetails = serverStats.netInterfaces.length > 0;
|
||||
const hasLatency = serverStats.latencyMs !== null;
|
||||
const diskSummary = resolveTerminalDiskSummary(serverStats);
|
||||
|
||||
if (!enabled || !isConnected || !serverStats.lastUpdated) return null;
|
||||
|
||||
return (
|
||||
<div className="terminal-server-stats flex items-center gap-2 ml-1 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0 shrink">
|
||||
{/* CPU with HoverCard for per-core details */}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
|
||||
aria-label={t("terminal.serverStats.cpu")}
|
||||
>
|
||||
<Cpu size={10} className="flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{serverStats.cpu !== null ? `${serverStats.cpu}%` : '--'}
|
||||
{serverStats.cpuCores !== null && ` (${serverStats.cpuCores}C)`}
|
||||
</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-auto p-3"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="text-xs space-y-2">
|
||||
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.cpuCores")}</div>
|
||||
{serverStats.cpuPerCore.length > 0 ? (
|
||||
<div className="grid gap-1.5 max-h-[260px] overflow-y-auto pr-1 overscroll-contain" style={{ gridTemplateColumns: `repeat(${Math.min(4, serverStats.cpuPerCore.length)}, 1fr)` }}>
|
||||
{serverStats.cpuPerCore.map((usage, index) => (
|
||||
<div key={index} className="flex flex-col items-center gap-1 min-w-[48px]">
|
||||
<div className="text-[10px] text-muted-foreground">Core {index}</div>
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
usage >= 90 ? "bg-red-500" : usage >= 70 ? "bg-amber-500" : "bg-emerald-500"
|
||||
)}
|
||||
style={{ width: `${usage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"text-[11px] font-medium",
|
||||
usage >= 90 ? "text-red-400" : usage >= 70 ? "text-amber-400" : "text-emerald-400"
|
||||
)}>
|
||||
{usage}%
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : serverStats.cpu !== null ? (
|
||||
<div className="flex flex-col gap-1.5 min-w-[160px]">
|
||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
serverStats.cpu >= 90 ? "bg-red-500" : serverStats.cpu >= 70 ? "bg-amber-500" : "bg-emerald-500"
|
||||
)}
|
||||
style={{ width: `${serverStats.cpu}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"text-center text-[11px] font-medium",
|
||||
serverStats.cpu >= 90 ? "text-red-400" : serverStats.cpu >= 70 ? "text-amber-400" : "text-emerald-400"
|
||||
)}>
|
||||
{serverStats.cpu}% · {serverStats.cpuCores ?? '?'} cores
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
{/* Memory with HoverCard for htop-style bar and top processes */}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
|
||||
aria-label={t("terminal.serverStats.memory")}
|
||||
>
|
||||
<MemoryStick size={10} className="flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{serverStats.memUsed !== null && serverStats.memTotal !== null
|
||||
? `${(serverStats.memUsed / 1024).toFixed(1)}/${(serverStats.memTotal / 1024).toFixed(1)}G`
|
||||
: '--'}
|
||||
</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-auto p-3"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="text-xs space-y-3 min-w-[280px]">
|
||||
<div className="font-medium text-sm">{t("terminal.serverStats.memoryDetails")}</div>
|
||||
{/* htop-style memory bar */}
|
||||
{serverStats.memTotal !== null && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
|
||||
{/* Used (green) — exact value shown in legend below */}
|
||||
{serverStats.memUsed !== null && serverStats.memUsed > 0 && (
|
||||
<div
|
||||
className="h-full bg-emerald-500"
|
||||
style={{ width: `${(serverStats.memUsed / serverStats.memTotal) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
{/* Buffers (blue) */}
|
||||
{serverStats.memBuffers !== null && serverStats.memBuffers > 0 && (
|
||||
<div
|
||||
className="h-full bg-blue-500"
|
||||
style={{ width: `${(serverStats.memBuffers / serverStats.memTotal) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
{/* Cached (amber/orange) */}
|
||||
{serverStats.memCached !== null && serverStats.memCached > 0 && (
|
||||
<div
|
||||
className="h-full bg-amber-500"
|
||||
style={{ width: `${(serverStats.memCached / serverStats.memTotal) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-emerald-500" />
|
||||
<span>{t("terminal.serverStats.memUsed")}: {serverStats.memUsed !== null ? `${(serverStats.memUsed / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-blue-500" />
|
||||
<span>{t("terminal.serverStats.memBuffers")}: {serverStats.memBuffers !== null ? `${(serverStats.memBuffers / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-amber-500" />
|
||||
<span>{t("terminal.serverStats.memCached")}: {serverStats.memCached !== null ? `${(serverStats.memCached / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
|
||||
<span>{t("terminal.serverStats.memFree")}: {serverStats.memFree !== null ? `${(serverStats.memFree / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Swap bar */}
|
||||
{serverStats.swapTotal !== null && serverStats.swapTotal > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.swap")}</div>
|
||||
<div className="w-full h-3 bg-muted rounded overflow-hidden flex">
|
||||
{serverStats.swapUsed !== null && serverStats.swapUsed > 0 && (
|
||||
<div
|
||||
className="h-full bg-rose-500"
|
||||
style={{ width: `${(serverStats.swapUsed / serverStats.swapTotal) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[10px]">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-rose-500" />
|
||||
<span>{t("terminal.serverStats.swapUsed")}: {serverStats.swapUsed !== null ? `${(serverStats.swapUsed / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-2 h-2 rounded-sm bg-muted border border-border" />
|
||||
<span>{t("terminal.serverStats.swapFree")}: {serverStats.swapTotal !== null && serverStats.swapUsed !== null ? `${((serverStats.swapTotal - serverStats.swapUsed) / 1024).toFixed(1)}G` : '--'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">{t("terminal.serverStats.swapTotal")}: {`${(serverStats.swapTotal / 1024).toFixed(1)}G`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Top 10 processes */}
|
||||
{serverStats.topProcesses.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="font-medium text-[11px] text-muted-foreground">{t("terminal.serverStats.topProcesses")}</div>
|
||||
<div className="space-y-0.5 max-h-[150px] overflow-y-auto">
|
||||
{serverStats.topProcesses.map((proc, index) => (
|
||||
<div key={index} className="flex items-center gap-2 text-[10px]">
|
||||
<span className="w-[32px] text-right text-muted-foreground">{proc.memPercent.toFixed(1)}%</span>
|
||||
<div className="flex-1 h-1 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 rounded-full"
|
||||
style={{ width: `${Math.min(100, proc.memPercent * 2)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex-shrink-0 font-mono truncate max-w-[140px] cursor-default">
|
||||
{proc.command.split('/').pop()?.split(' ')[0] || proc.command}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{proc.command}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
{/* Disk - with HoverCard for disk details */}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-0.5 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
|
||||
aria-label={t("terminal.serverStats.disk")}
|
||||
>
|
||||
<HardDrive size={10} className="flex-shrink-0" />
|
||||
<span className={cn(
|
||||
"truncate",
|
||||
diskSummary.percent !== null && diskSummary.percent >= 90 && "text-red-400",
|
||||
diskSummary.percent !== null && diskSummary.percent >= 80 && diskSummary.percent < 90 && "text-amber-400"
|
||||
)}>
|
||||
{diskSummary.used !== null && diskSummary.total !== null && diskSummary.percent !== null
|
||||
? `${formatDiskCapacityRange(diskSummary.used, diskSummary.total)} (${diskSummary.percent}%)`
|
||||
: diskSummary.percent !== null
|
||||
? `${diskSummary.percent}%`
|
||||
: '--'}
|
||||
</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-auto p-3"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="text-xs space-y-2">
|
||||
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.diskDetails")}</div>
|
||||
{serverStats.disks.length > 0 ? (
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{serverStats.disks.map((disk, index) => (
|
||||
<div key={index} className="flex flex-col gap-1 min-w-[180px]">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[120px] cursor-default">
|
||||
{disk.mountPoint}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{disk.mountPoint}</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className={cn(
|
||||
"text-[11px] font-medium whitespace-nowrap",
|
||||
disk.percent >= 90 ? "text-red-400" : disk.percent >= 80 ? "text-amber-400" : "text-emerald-400"
|
||||
)}>
|
||||
{formatDiskCapacityRange(disk.used, disk.total)} ({disk.percent}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
disk.percent >= 90 ? "bg-red-500" : disk.percent >= 80 ? "bg-amber-500" : "bg-emerald-500"
|
||||
)}
|
||||
style={{ width: `${disk.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
{/* Network - with HoverCard for per-interface details */}
|
||||
{(hasNetworkDetails || hasLatency) && (
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
className="flex items-center gap-1 hover:opacity-100 opacity-80 transition-opacity cursor-pointer min-w-0 shrink"
|
||||
aria-label={t("terminal.serverStats.network")}
|
||||
>
|
||||
<ArrowDownToLine size={9} className="flex-shrink-0 text-emerald-400" />
|
||||
<span className="truncate">{formatNetSpeed(serverStats.netRxSpeed)}</span>
|
||||
<ArrowUpFromLine size={9} className="flex-shrink-0 text-sky-400" />
|
||||
<span className="truncate">{formatNetSpeed(serverStats.netTxSpeed)}</span>
|
||||
<Activity size={9} className="flex-shrink-0 text-violet-400" />
|
||||
<span className="truncate">{formatLatency(serverStats.latencyMs)}</span>
|
||||
</button>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent
|
||||
className="w-auto p-3"
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
>
|
||||
<div className="text-xs space-y-2">
|
||||
<div className="font-medium text-sm mb-2">{t("terminal.serverStats.networkDetails")}</div>
|
||||
<div className="flex items-center justify-between gap-4 min-w-[200px]">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("terminal.serverStats.latency")}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-violet-400">
|
||||
<Activity size={9} />
|
||||
{formatLatency(serverStats.latencyMs)}
|
||||
</span>
|
||||
</div>
|
||||
{hasNetworkDetails ? (
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{serverStats.netInterfaces.map((iface, index) => (
|
||||
<div key={index} className="flex items-center justify-between gap-4 min-w-[200px]">
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{iface.name}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-0.5 text-emerald-400">
|
||||
<ArrowDownToLine size={9} />
|
||||
{formatNetSpeed(iface.rxSpeed)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 text-sky-400">
|
||||
<ArrowUpFromLine size={9} />
|
||||
{formatNetSpeed(iface.txSpeed)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
222
components/terminal/TerminalTimestampGutter.test.ts
Normal file
222
components/terminal/TerminalTimestampGutter.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
TERMINAL_TIMESTAMP_GUTTER_HORIZONTAL_PADDING,
|
||||
TERMINAL_TIMESTAMP_GUTTER_MIN_WIDTH,
|
||||
getTerminalTimestampTypography,
|
||||
resolveTerminalTimestampGutterRenderSignature,
|
||||
resolveTerminalTimestampGutterColor,
|
||||
resolveTerminalTimestampGutterWidth,
|
||||
syncTerminalTimestampGutterRows,
|
||||
} from "./TerminalTimestampGutter.tsx";
|
||||
|
||||
test("timestamp gutter uses a bright color from the active terminal theme", () => {
|
||||
assert.equal(
|
||||
resolveTerminalTimestampGutterColor({
|
||||
brightCyan: "#66e8ff",
|
||||
brightYellow: "#ffe066",
|
||||
foreground: "#dddddd",
|
||||
}),
|
||||
"#66e8ff",
|
||||
);
|
||||
});
|
||||
|
||||
test("timestamp gutter falls back within the terminal theme palette", () => {
|
||||
assert.equal(
|
||||
resolveTerminalTimestampGutterColor({
|
||||
brightYellow: "#ffe066",
|
||||
foreground: "#dddddd",
|
||||
}),
|
||||
"#ffe066",
|
||||
);
|
||||
assert.equal(
|
||||
resolveTerminalTimestampGutterColor({
|
||||
foreground: "#dddddd",
|
||||
}),
|
||||
"#dddddd",
|
||||
);
|
||||
});
|
||||
|
||||
test("timestamp gutter width follows measured timestamp text width", () => {
|
||||
assert.equal(
|
||||
resolveTerminalTimestampGutterWidth({ measuredTextWidth: 84, fontSize: 14 }),
|
||||
84 + TERMINAL_TIMESTAMP_GUTTER_HORIZONTAL_PADDING,
|
||||
);
|
||||
assert.equal(
|
||||
resolveTerminalTimestampGutterWidth({ measuredTextWidth: 1, fontSize: 14 }),
|
||||
TERMINAL_TIMESTAMP_GUTTER_MIN_WIDTH,
|
||||
);
|
||||
});
|
||||
|
||||
test("timestamp gutter typography follows terminal typography", () => {
|
||||
assert.deepEqual(
|
||||
getTerminalTimestampTypography({
|
||||
fontFamily: '"JetBrains Mono", monospace',
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
}),
|
||||
{
|
||||
fontFamily: '"JetBrains Mono", monospace',
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("timestamp gutter uses the terminal background", () => {
|
||||
const source = readFileSync(new URL("./TerminalTimestampGutter.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /backgroundColor: "var\(--terminal-ui-bg\)"/);
|
||||
assert.doesNotMatch(source, /bg-black\/10/);
|
||||
assert.match(source, /boxShadow: "inset -0\.5px 0 0 color-mix\(in srgb, var\(--terminal-ui-fg\) 8%, transparent\)"/);
|
||||
assert.doesNotMatch(source, /border-r/);
|
||||
});
|
||||
|
||||
test("timestamp gutter render signature is stable and changes only for visible inputs", () => {
|
||||
const base = resolveTerminalTimestampGutterRenderSignature({
|
||||
screenTop: 8,
|
||||
cellHeight: 17,
|
||||
color: "#66e8ff",
|
||||
fontFamily: "JetBrains Mono",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
rows: [
|
||||
{ row: 0, label: "10:00:00" },
|
||||
{ row: 2, label: "10:00:02" },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
resolveTerminalTimestampGutterRenderSignature({
|
||||
screenTop: 8,
|
||||
cellHeight: 17,
|
||||
color: "#66e8ff",
|
||||
fontFamily: "JetBrains Mono",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
rows: [
|
||||
{ row: 0, label: "10:00:00" },
|
||||
{ row: 2, label: "10:00:02" },
|
||||
],
|
||||
}),
|
||||
base,
|
||||
);
|
||||
assert.notEqual(
|
||||
resolveTerminalTimestampGutterRenderSignature({
|
||||
screenTop: 8,
|
||||
cellHeight: 17,
|
||||
color: "#66e8ff",
|
||||
fontFamily: "JetBrains Mono",
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
rows: [
|
||||
{ row: 0, label: "10:00:00" },
|
||||
{ row: 3, label: "10:00:02" },
|
||||
],
|
||||
}),
|
||||
base,
|
||||
);
|
||||
});
|
||||
|
||||
test("timestamp gutter flood throttle advances even when the paint signature is unchanged", () => {
|
||||
const source = readFileSync(new URL("./TerminalTimestampGutter.tsx", import.meta.url), "utf8");
|
||||
// lastFloodRenderAt must move on every render attempt, before the signature early-return.
|
||||
const renderStart = source.indexOf("const render = () => {");
|
||||
const signatureReturn = source.indexOf("if (signature === lastRenderSignature) return;", renderStart);
|
||||
const floodAdvance = source.indexOf("lastFloodRenderAt = performance.now();", renderStart);
|
||||
assert.notEqual(renderStart, -1);
|
||||
assert.notEqual(signatureReturn, -1);
|
||||
assert.notEqual(floodAdvance, -1);
|
||||
assert.ok(
|
||||
floodAdvance < signatureReturn,
|
||||
"flood throttle clock must advance before the unchanged-signature early return",
|
||||
);
|
||||
});
|
||||
|
||||
test("timestamp gutter throttles output-driven scroll events under flood pressure", () => {
|
||||
const source = readFileSync(new URL("./TerminalTimestampGutter.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /term\.onScroll\?\.\(\(\) => \{/);
|
||||
assert.match(source, /getTerminalOutputPressure\(term\)/);
|
||||
// largeOutput only (time-bounded); longLine must not sticky-throttle user scrolls.
|
||||
assert.match(source, /scheduleRender\(pressure\.largeOutput \? "normal" : "immediate"\)/);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/onScroll[\s\S]{0,200}pressure\.largeOutput \|\| pressure\.longLine/,
|
||||
);
|
||||
});
|
||||
|
||||
test("timestamp gutter reuses row nodes across paints instead of rebuilding the tree", () => {
|
||||
const gutter = {
|
||||
children: [] as Array<Record<string, unknown>>,
|
||||
appendChild(node: Record<string, unknown>) {
|
||||
this.children.push(node);
|
||||
return node;
|
||||
},
|
||||
};
|
||||
|
||||
const createElement = (tag: string) => {
|
||||
assert.equal(tag, "div");
|
||||
return {
|
||||
textContent: "",
|
||||
className: "",
|
||||
style: {} as Record<string, string>,
|
||||
};
|
||||
};
|
||||
|
||||
const previousCreateElement = globalThis.document?.createElement;
|
||||
(globalThis as { document?: { createElement: typeof createElement } }).document = {
|
||||
createElement,
|
||||
};
|
||||
|
||||
try {
|
||||
const layout = {
|
||||
screenTop: 0,
|
||||
cellHeight: 16,
|
||||
color: "#66e8ff",
|
||||
fontFamily: "monospace",
|
||||
fontSize: 14,
|
||||
fontWeight: 400,
|
||||
};
|
||||
|
||||
syncTerminalTimestampGutterRows(
|
||||
gutter as never,
|
||||
[
|
||||
{ row: 0, label: "10:00:00" },
|
||||
{ row: 1, label: "10:00:01" },
|
||||
],
|
||||
layout,
|
||||
);
|
||||
assert.equal(gutter.children.length, 2);
|
||||
const firstNode = gutter.children[0];
|
||||
assert.equal(firstNode.textContent, "10:00:00");
|
||||
|
||||
syncTerminalTimestampGutterRows(
|
||||
gutter as never,
|
||||
[
|
||||
{ row: 0, label: "10:00:02" },
|
||||
{ row: 2, label: "10:00:03" },
|
||||
],
|
||||
layout,
|
||||
);
|
||||
assert.equal(gutter.children.length, 2);
|
||||
assert.equal(gutter.children[0], firstNode);
|
||||
assert.equal(firstNode.textContent, "10:00:02");
|
||||
assert.equal(gutter.children[1].textContent, "10:00:03");
|
||||
|
||||
syncTerminalTimestampGutterRows(
|
||||
gutter as never,
|
||||
[{ row: 0, label: "10:00:04" }],
|
||||
layout,
|
||||
);
|
||||
assert.equal(gutter.children.length, 2);
|
||||
assert.equal((gutter.children[1].style as Record<string, string>).display, "none");
|
||||
} finally {
|
||||
if (previousCreateElement) {
|
||||
(globalThis as { document: { createElement: typeof previousCreateElement } }).document = {
|
||||
createElement: previousCreateElement,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
458
components/terminal/TerminalTimestampGutter.tsx
Normal file
458
components/terminal/TerminalTimestampGutter.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
import {
|
||||
getVisibleTerminalLineTimestampRows,
|
||||
onTerminalLineTimestampsChange,
|
||||
} from "./runtime/terminalLineTimestamps";
|
||||
import type { TerminalTimestampGutterRow } from "./runtime/terminalLineTimestamps";
|
||||
import { getTerminalOutputPressure } from "./runtime/terminalOutputPressure";
|
||||
|
||||
export const TERMINAL_TIMESTAMP_GUTTER_MIN_WIDTH = 56;
|
||||
export const TERMINAL_TIMESTAMP_GUTTER_HORIZONTAL_PADDING = 16;
|
||||
export const TERMINAL_TIMESTAMP_SAMPLE_LABEL = "88:88:88";
|
||||
/** Cap gutter paint rate while large-output pressure is active (rAF still coalesces). */
|
||||
export const TERMINAL_TIMESTAMP_GUTTER_FLOOD_MIN_INTERVAL_MS = 100;
|
||||
const GUTTER_ROW_CLASS =
|
||||
"absolute left-0 right-0 px-2 text-right tabular-nums whitespace-nowrap";
|
||||
|
||||
type TerminalTimestampGutterProps = {
|
||||
termRef: RefObject<XTerm | null>;
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
enabled: boolean;
|
||||
top: string;
|
||||
left?: number;
|
||||
bottom?: number;
|
||||
sessionId: string;
|
||||
color: string;
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
fontWeight: string | number;
|
||||
width: number;
|
||||
onWidthChange?: (width: number) => void;
|
||||
};
|
||||
|
||||
type DisposableLike = {
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
type TerminalTimestampTypography = {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
fontWeight?: string | number;
|
||||
};
|
||||
|
||||
const getTerminalScreen = (container: HTMLElement): HTMLElement => (
|
||||
container.querySelector<HTMLElement>(".xterm-screen") ?? container
|
||||
);
|
||||
|
||||
const clearElement = (element: HTMLElement) => {
|
||||
while (element.firstChild) {
|
||||
element.removeChild(element.firstChild);
|
||||
}
|
||||
};
|
||||
|
||||
const applyGutterRowStyles = (
|
||||
item: HTMLElement,
|
||||
{
|
||||
row,
|
||||
label,
|
||||
screenTop,
|
||||
cellHeight,
|
||||
color,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
}: {
|
||||
row: number;
|
||||
label: string;
|
||||
screenTop: number;
|
||||
cellHeight: number;
|
||||
color: string;
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
fontWeight: string | number;
|
||||
},
|
||||
) => {
|
||||
if (item.textContent !== label) {
|
||||
item.textContent = label;
|
||||
}
|
||||
item.style.top = `${screenTop + row * cellHeight}px`;
|
||||
item.style.height = `${cellHeight}px`;
|
||||
item.style.lineHeight = `${cellHeight}px`;
|
||||
item.style.color = color;
|
||||
item.style.fontFamily = fontFamily;
|
||||
item.style.fontSize = `${fontSize}px`;
|
||||
item.style.fontWeight = String(fontWeight);
|
||||
item.style.fontVariantNumeric = "tabular-nums";
|
||||
item.style.display = "";
|
||||
};
|
||||
|
||||
/**
|
||||
* Reuse a fixed pool of row divs instead of clear+create on every paint.
|
||||
* Returns the number of visible nodes kept after the update.
|
||||
*/
|
||||
export const syncTerminalTimestampGutterRows = (
|
||||
gutter: HTMLElement,
|
||||
rows: readonly TerminalTimestampGutterRow[],
|
||||
layout: {
|
||||
screenTop: number;
|
||||
cellHeight: number;
|
||||
color: string;
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
fontWeight: string | number;
|
||||
},
|
||||
): number => {
|
||||
const existing = gutter.children;
|
||||
let index = 0;
|
||||
for (; index < rows.length; index += 1) {
|
||||
const { row, label } = rows[index];
|
||||
let item = existing[index] as HTMLElement | undefined;
|
||||
if (!item) {
|
||||
item = document.createElement("div");
|
||||
item.className = GUTTER_ROW_CLASS;
|
||||
gutter.appendChild(item);
|
||||
}
|
||||
applyGutterRowStyles(item, {
|
||||
row,
|
||||
label,
|
||||
screenTop: layout.screenTop,
|
||||
cellHeight: layout.cellHeight,
|
||||
color: layout.color,
|
||||
fontFamily: layout.fontFamily,
|
||||
fontSize: layout.fontSize,
|
||||
fontWeight: layout.fontWeight,
|
||||
});
|
||||
}
|
||||
// Hide surplus pooled nodes (keep them for the next paint).
|
||||
for (; index < existing.length; index += 1) {
|
||||
(existing[index] as HTMLElement).style.display = "none";
|
||||
}
|
||||
return rows.length;
|
||||
};
|
||||
|
||||
export const resolveTerminalTimestampGutterColor = (
|
||||
colors: Partial<Record<"brightCyan" | "brightYellow" | "brightMagenta" | "foreground", string>>,
|
||||
): string => (
|
||||
colors.brightCyan
|
||||
|| colors.brightYellow
|
||||
|| colors.brightMagenta
|
||||
|| colors.foreground
|
||||
|| "currentColor"
|
||||
);
|
||||
|
||||
const normalizeTerminalTimestampFontSize = (fontSize?: number): number => (
|
||||
Number.isFinite(fontSize) && fontSize && fontSize > 0 ? fontSize : 12
|
||||
);
|
||||
|
||||
export const getTerminalTimestampTypography = ({
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
}: TerminalTimestampTypography) => ({
|
||||
fontFamily: fontFamily || "monospace",
|
||||
fontSize: normalizeTerminalTimestampFontSize(fontSize),
|
||||
fontWeight: fontWeight ?? 400,
|
||||
});
|
||||
|
||||
const estimateTerminalTimestampTextWidth = (
|
||||
fontSize: number,
|
||||
label = TERMINAL_TIMESTAMP_SAMPLE_LABEL,
|
||||
): number => (
|
||||
normalizeTerminalTimestampFontSize(fontSize) * label.length * 0.62
|
||||
);
|
||||
|
||||
export const resolveTerminalTimestampGutterWidth = ({
|
||||
measuredTextWidth,
|
||||
fontSize,
|
||||
label = TERMINAL_TIMESTAMP_SAMPLE_LABEL,
|
||||
}: {
|
||||
measuredTextWidth?: number;
|
||||
fontSize?: number;
|
||||
label?: string;
|
||||
}): number => {
|
||||
const textWidth =
|
||||
Number.isFinite(measuredTextWidth) && measuredTextWidth !== undefined && measuredTextWidth > 0
|
||||
? measuredTextWidth
|
||||
: estimateTerminalTimestampTextWidth(normalizeTerminalTimestampFontSize(fontSize), label);
|
||||
return Math.ceil(Math.max(
|
||||
TERMINAL_TIMESTAMP_GUTTER_MIN_WIDTH,
|
||||
textWidth + TERMINAL_TIMESTAMP_GUTTER_HORIZONTAL_PADDING,
|
||||
));
|
||||
};
|
||||
|
||||
export const resolveTerminalTimestampGutterRenderSignature = ({
|
||||
screenTop,
|
||||
cellHeight,
|
||||
color,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
rows,
|
||||
}: {
|
||||
screenTop: number;
|
||||
cellHeight: number;
|
||||
color: string;
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
fontWeight: string | number;
|
||||
rows: readonly TerminalTimestampGutterRow[];
|
||||
}): string => {
|
||||
let signature = `${screenTop}|${cellHeight}|${color}|${fontFamily}|${fontSize}|${fontWeight}`;
|
||||
for (const { row, label } of rows) {
|
||||
signature += `|${row}:${label}`;
|
||||
}
|
||||
return signature;
|
||||
};
|
||||
|
||||
export function TerminalTimestampGutter({
|
||||
termRef,
|
||||
containerRef,
|
||||
enabled,
|
||||
top,
|
||||
left = 0,
|
||||
bottom = 0,
|
||||
sessionId,
|
||||
color,
|
||||
fontFamily,
|
||||
fontSize,
|
||||
fontWeight,
|
||||
width,
|
||||
onWidthChange,
|
||||
}: TerminalTimestampGutterProps) {
|
||||
const gutterRef = useRef<HTMLDivElement>(null);
|
||||
const typography = getTerminalTimestampTypography({ fontFamily, fontSize, fontWeight });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!enabled || !onWidthChange) return;
|
||||
const gutter = gutterRef.current;
|
||||
if (!gutter) return;
|
||||
|
||||
let disposed = false;
|
||||
|
||||
const measure = () => {
|
||||
if (disposed) return;
|
||||
const probe = document.createElement("span");
|
||||
probe.textContent = TERMINAL_TIMESTAMP_SAMPLE_LABEL;
|
||||
probe.style.position = "absolute";
|
||||
probe.style.visibility = "hidden";
|
||||
probe.style.pointerEvents = "none";
|
||||
probe.style.whiteSpace = "nowrap";
|
||||
probe.style.fontFamily = typography.fontFamily;
|
||||
probe.style.fontSize = `${typography.fontSize}px`;
|
||||
probe.style.fontWeight = String(typography.fontWeight);
|
||||
probe.style.fontVariantNumeric = "tabular-nums";
|
||||
gutter.appendChild(probe);
|
||||
const measuredTextWidth = probe.getBoundingClientRect().width;
|
||||
probe.remove();
|
||||
onWidthChange(resolveTerminalTimestampGutterWidth({
|
||||
measuredTextWidth,
|
||||
fontSize: typography.fontSize,
|
||||
}));
|
||||
};
|
||||
|
||||
measure();
|
||||
const fonts = (document as Document & { fonts?: { ready?: Promise<unknown> } }).fonts;
|
||||
void fonts?.ready?.then(measure);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
}, [enabled, onWidthChange, sessionId, typography.fontFamily, typography.fontSize, typography.fontWeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const gutter = gutterRef.current;
|
||||
if (!gutter) return;
|
||||
|
||||
let disposed = false;
|
||||
let rafId: number | null = null;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let floodThrottleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let disposables: DisposableLike[] = [];
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let lastRenderSignature = "";
|
||||
let lastFloodRenderAt = 0;
|
||||
|
||||
const clearGutter = () => {
|
||||
lastRenderSignature = "";
|
||||
clearElement(gutter);
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
rafId = null;
|
||||
// Always advance the flood clock when a render attempt runs — even if the
|
||||
// signature is unchanged. Otherwise same-second labels during sustained
|
||||
// output leave lastFloodRenderAt stale and the throttle stops limiting work.
|
||||
lastFloodRenderAt = performance.now();
|
||||
const term = termRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!enabled || !term || !container) {
|
||||
clearGutter();
|
||||
return;
|
||||
}
|
||||
|
||||
const screen = getTerminalScreen(container);
|
||||
const rows = Math.max(1, term.rows || 1);
|
||||
const cellHeight = screen.clientHeight / rows;
|
||||
if (!Number.isFinite(cellHeight) || cellHeight <= 0) {
|
||||
clearGutter();
|
||||
return;
|
||||
}
|
||||
|
||||
const screenRect = screen.getBoundingClientRect();
|
||||
const gutterRect = gutter.getBoundingClientRect();
|
||||
const screenTop = screenRect.top - gutterRect.top;
|
||||
const visibleRows = getVisibleTerminalLineTimestampRows(term);
|
||||
const signature = resolveTerminalTimestampGutterRenderSignature({
|
||||
screenTop,
|
||||
cellHeight,
|
||||
color,
|
||||
fontFamily: typography.fontFamily,
|
||||
fontSize: typography.fontSize,
|
||||
fontWeight: typography.fontWeight,
|
||||
rows: visibleRows,
|
||||
});
|
||||
if (signature === lastRenderSignature) return;
|
||||
lastRenderSignature = signature;
|
||||
|
||||
syncTerminalTimestampGutterRows(gutter, visibleRows, {
|
||||
screenTop,
|
||||
cellHeight,
|
||||
color,
|
||||
fontFamily: typography.fontFamily,
|
||||
fontSize: typography.fontSize,
|
||||
fontWeight: typography.fontWeight,
|
||||
});
|
||||
};
|
||||
|
||||
const queueRafRender = () => {
|
||||
if (disposed || rafId !== null) return;
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
rafId = requestAnimationFrame(render);
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* immediate: user scroll/resize — paint ASAP.
|
||||
* normal: output/render pressure — throttle while flood pressure is active.
|
||||
*/
|
||||
const scheduleRender = (priority: "immediate" | "normal" = "normal") => {
|
||||
if (disposed) return;
|
||||
if (priority === "normal") {
|
||||
const term = termRef.current;
|
||||
if (term) {
|
||||
const pressure = getTerminalOutputPressure(term);
|
||||
if (pressure.largeOutput || pressure.longLine) {
|
||||
const now = performance.now();
|
||||
const elapsed = now - lastFloodRenderAt;
|
||||
if (elapsed < TERMINAL_TIMESTAMP_GUTTER_FLOOD_MIN_INTERVAL_MS) {
|
||||
if (floodThrottleTimer === null) {
|
||||
floodThrottleTimer = setTimeout(() => {
|
||||
floodThrottleTimer = null;
|
||||
queueRafRender();
|
||||
}, TERMINAL_TIMESTAMP_GUTTER_FLOOD_MIN_INTERVAL_MS - elapsed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (floodThrottleTimer !== null) {
|
||||
clearTimeout(floodThrottleTimer);
|
||||
floodThrottleTimer = null;
|
||||
}
|
||||
queueRafRender();
|
||||
};
|
||||
|
||||
const attach = () => {
|
||||
if (disposed) return;
|
||||
const term = termRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!enabled || !term || !container) {
|
||||
clearGutter();
|
||||
if (enabled) {
|
||||
retryTimer = setTimeout(attach, 50);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
disposables = [
|
||||
// xterm fires onScroll for output-driven scrolling too (not only user
|
||||
// wheel/trackpad). Throttle only while large-output pressure is active
|
||||
// (time-bounded). Do not use longLine here — it sticks until the next
|
||||
// data chunk and would lag genuine user scrolling after output stops.
|
||||
term.onScroll?.(() => {
|
||||
const pressure = getTerminalOutputPressure(term);
|
||||
scheduleRender(pressure.largeOutput ? "normal" : "immediate");
|
||||
}),
|
||||
term.onRender?.(() => scheduleRender("normal")),
|
||||
term.onResize?.(() => scheduleRender("immediate")),
|
||||
].filter(Boolean) as DisposableLike[];
|
||||
disposables.push({
|
||||
dispose: onTerminalLineTimestampsChange(term, () => scheduleRender("normal")),
|
||||
});
|
||||
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(() => scheduleRender("immediate"));
|
||||
resizeObserver.observe(container);
|
||||
resizeObserver.observe(getTerminalScreen(container));
|
||||
}
|
||||
|
||||
scheduleRender("immediate");
|
||||
};
|
||||
|
||||
attach();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (rafId !== null && typeof cancelAnimationFrame === "function") {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
}
|
||||
if (floodThrottleTimer !== null) {
|
||||
clearTimeout(floodThrottleTimer);
|
||||
}
|
||||
for (const disposable of disposables) {
|
||||
disposable.dispose();
|
||||
}
|
||||
resizeObserver?.disconnect();
|
||||
clearElement(gutter);
|
||||
};
|
||||
}, [
|
||||
color,
|
||||
containerRef,
|
||||
enabled,
|
||||
bottom,
|
||||
left,
|
||||
sessionId,
|
||||
termRef,
|
||||
top,
|
||||
typography.fontFamily,
|
||||
typography.fontSize,
|
||||
typography.fontWeight,
|
||||
]);
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={gutterRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute z-[1] overflow-hidden select-none text-[color:var(--terminal-ui-fg)]"
|
||||
style={{
|
||||
top,
|
||||
bottom,
|
||||
left,
|
||||
width,
|
||||
backgroundColor: "var(--terminal-ui-bg)",
|
||||
boxShadow: "inset -0.5px 0 0 color-mix(in srgb, var(--terminal-ui-fg) 8%, transparent)",
|
||||
}}
|
||||
data-section="terminal-timestamp-gutter"
|
||||
/>
|
||||
);
|
||||
}
|
||||
194
components/terminal/TerminalToolbar.test.ts
Normal file
194
components/terminal/TerminalToolbar.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
|
||||
import type { Host } from "../../types.ts";
|
||||
import { TerminalToolbar } from "./TerminalToolbar.tsx";
|
||||
|
||||
const toolbarSource = readFileSync(new URL("./TerminalToolbar.tsx", import.meta.url), "utf8");
|
||||
|
||||
const sshHost: Host = {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
};
|
||||
|
||||
const serialHost: Host = {
|
||||
...sshHost,
|
||||
id: "serial-1",
|
||||
label: "Serial",
|
||||
hostname: "/dev/tty.usbserial",
|
||||
protocol: "serial",
|
||||
};
|
||||
|
||||
const pluginHost: Host = {
|
||||
...sshHost,
|
||||
id: "plugin-1",
|
||||
label: "Plugin",
|
||||
hostname: "com.example.transport.connection",
|
||||
protocol: "plugin:com.example.transport.connection",
|
||||
};
|
||||
|
||||
const renderToolbar = (
|
||||
host: Host,
|
||||
status: "connecting" | "connected" | "disconnected" = "connected",
|
||||
props: Partial<React.ComponentProps<typeof TerminalToolbar>> = {},
|
||||
) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(TerminalToolbar, {
|
||||
sessionId: 'session-1',
|
||||
status,
|
||||
host,
|
||||
onOpenSFTP: () => {},
|
||||
onOpenScripts: () => {},
|
||||
onOpenTheme: () => {},
|
||||
...props,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
test("keeps SFTP visible before the terminal overflow menu for SSH sessions", () => {
|
||||
const markup = renderToolbar(sshHost);
|
||||
|
||||
const sftpIndex = markup.indexOf('aria-label="Open SFTP"');
|
||||
const moreIndex = markup.indexOf('aria-label="More actions"');
|
||||
|
||||
assert.notEqual(sftpIndex, -1);
|
||||
assert.notEqual(moreIndex, -1);
|
||||
assert.ok(sftpIndex < moreIndex);
|
||||
});
|
||||
|
||||
test("keeps Scripts visible before the terminal overflow menu", () => {
|
||||
const markup = renderToolbar(sshHost);
|
||||
|
||||
const scriptsIndex = markup.indexOf('aria-label="Scripts"');
|
||||
const moreIndex = markup.indexOf('aria-label="More actions"');
|
||||
|
||||
assert.notEqual(scriptsIndex, -1);
|
||||
assert.notEqual(moreIndex, -1);
|
||||
assert.ok(scriptsIndex < moreIndex);
|
||||
assert.equal(markup.match(/Scripts/g)?.length, 1);
|
||||
assert.match(markup, /type="button"[^>]*aria-label="Scripts"/);
|
||||
});
|
||||
|
||||
test("shows manual session log button when requested", () => {
|
||||
const markup = renderToolbar(sshHost, "connected", {
|
||||
showLogButton: true,
|
||||
onToggleSessionLog: () => {},
|
||||
});
|
||||
|
||||
const logIndex = markup.indexOf('aria-label="Start session log"');
|
||||
const scriptsIndex = markup.indexOf('aria-label="Scripts"');
|
||||
|
||||
assert.notEqual(logIndex, -1);
|
||||
assert.notEqual(scriptsIndex, -1);
|
||||
assert.ok(logIndex < scriptsIndex);
|
||||
});
|
||||
|
||||
test("marks manual session log button active while logging", () => {
|
||||
const markup = renderToolbar(sshHost, "connected", {
|
||||
showLogButton: true,
|
||||
onToggleSessionLog: () => {},
|
||||
isSessionLogging: true,
|
||||
});
|
||||
|
||||
assert.match(
|
||||
markup,
|
||||
/aria-label="Stop session log"[^>]*aria-pressed="true"[^>]*style="background-color:var\(--terminal-toolbar-btn-active\)"/,
|
||||
);
|
||||
});
|
||||
|
||||
test("hides SFTP for local terminal sessions", () => {
|
||||
const markup = renderToolbar({
|
||||
...sshHost,
|
||||
id: "local-1",
|
||||
protocol: "local",
|
||||
});
|
||||
|
||||
assert.equal(markup.includes('aria-label="Open SFTP"'), false);
|
||||
});
|
||||
|
||||
test("hides SSH history for plugin terminal sessions", () => {
|
||||
const markup = renderToolbar(pluginHost, "connected", {
|
||||
onOpenHistory: () => {},
|
||||
});
|
||||
|
||||
assert.equal(markup.includes('aria-label="Command history"'), false);
|
||||
});
|
||||
|
||||
test("shows YMODEM send only for connected serial sessions", () => {
|
||||
const connectedSerial = renderToolbar(serialHost, "connected", {
|
||||
onSendYmodem: () => {},
|
||||
onReceiveYmodem: () => {},
|
||||
});
|
||||
const disconnectedSerial = renderToolbar(serialHost, "disconnected", {
|
||||
onSendYmodem: () => {},
|
||||
onReceiveYmodem: () => {},
|
||||
});
|
||||
const ssh = renderToolbar(sshHost, "connected", {
|
||||
onSendYmodem: () => {},
|
||||
onReceiveYmodem: () => {},
|
||||
});
|
||||
const local = renderToolbar({
|
||||
...sshHost,
|
||||
id: "local-1",
|
||||
protocol: "local",
|
||||
}, "connected", {
|
||||
onSendYmodem: () => {},
|
||||
onReceiveYmodem: () => {},
|
||||
});
|
||||
|
||||
assert.equal(connectedSerial.includes('aria-label="Send with YMODEM"'), true);
|
||||
assert.equal(connectedSerial.includes('aria-label="Receive with YMODEM"'), true);
|
||||
assert.doesNotMatch(connectedSerial, /aria-label="Send with YMODEM"[^>]*disabled/);
|
||||
assert.equal(disconnectedSerial.includes('aria-label="Send with YMODEM - Available after connect"'), true);
|
||||
assert.equal(disconnectedSerial.includes('aria-label="Receive with YMODEM - Available after connect"'), true);
|
||||
assert.match(disconnectedSerial, /aria-label="Send with YMODEM - Available after connect"[^>]*disabled/);
|
||||
assert.match(disconnectedSerial, /aria-label="Receive with YMODEM - Available after connect"[^>]*disabled/);
|
||||
assert.equal(ssh.includes('aria-label="Send with YMODEM"'), false);
|
||||
assert.equal(ssh.includes('aria-label="Receive with YMODEM"'), false);
|
||||
assert.equal(local.includes('aria-label="Send with YMODEM"'), false);
|
||||
assert.equal(local.includes('aria-label="Receive with YMODEM"'), false);
|
||||
});
|
||||
|
||||
test("uses the terminal active button color for pressed toolbar actions", () => {
|
||||
const markup = renderToolbar(sshHost, "connected", {
|
||||
isSearchOpen: true,
|
||||
onToggleSearch: () => {},
|
||||
});
|
||||
|
||||
assert.match(
|
||||
markup,
|
||||
/aria-label="Search terminal"[^>]*style="background-color:var\(--terminal-toolbar-btn-active\)"/,
|
||||
);
|
||||
});
|
||||
|
||||
test("compact scripts popover hosts bulk-delete confirm outside the popover", () => {
|
||||
// Dialog focus leaves PopoverContent; if confirm stays inside ScriptsSidePanel,
|
||||
// the popover closes, isVisible clears pendingDeleteIds, and the prompt dies.
|
||||
assert.match(toolbarSource, /onBulkDeleteRequest=\{setPendingScriptDeleteIds\}/);
|
||||
assert.match(toolbarSource, /<VaultDeleteConfirmDialog/);
|
||||
// Popup vault mutation goes through the prop; the event only clears popover selection.
|
||||
assert.match(toolbarSource, /onDeleteSnippets\?\.\(new Set\(ids\)\)/);
|
||||
assert.match(toolbarSource, /netcatty:snippets:delete/);
|
||||
const scriptsPopoverIdx = toolbarSource.indexOf("open={scriptsPopoverOpen}");
|
||||
const popoverEndIdx = toolbarSource.indexOf("</Popover>", scriptsPopoverIdx);
|
||||
const dialogIdx = toolbarSource.indexOf("<VaultDeleteConfirmDialog", popoverEndIdx);
|
||||
assert.ok(scriptsPopoverIdx >= 0, "scripts popover open binding");
|
||||
assert.ok(popoverEndIdx > scriptsPopoverIdx, "scripts popover closes");
|
||||
assert.ok(dialogIdx > popoverEndIdx, "confirm dialog is a sibling after the popover");
|
||||
assert.equal(
|
||||
toolbarSource.includes("document.querySelector('[data-vault-delete-confirm=\"true\"]')"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
1078
components/terminal/TerminalToolbar.tsx
Normal file
1078
components/terminal/TerminalToolbar.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
test("hidden terminal tabs stop server stats polling", () => {
|
||||
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
source,
|
||||
/<TerminalServerStats[\s\S]*?enabled=\{\(terminalSettings\?\.showServerStats \?\? true\) && isVisible\}/,
|
||||
);
|
||||
assert.doesNotMatch(source, /shouldKeepTerminalBackgroundWorkActive/);
|
||||
});
|
||||
733
components/terminal/TerminalView.test.tsx
Normal file
733
components/terminal/TerminalView.test.tsx
Normal file
@@ -0,0 +1,733 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import {
|
||||
formatTerminalHostInfoBarTitle,
|
||||
formatTerminalHostInfoBarTooltip,
|
||||
formatTerminalTitleConnectionAddress,
|
||||
focusTerminalFromDisconnectedNotice,
|
||||
getLineTimestampToggleHostUpdate,
|
||||
resolveNetworkDeviceTipRightInset,
|
||||
resolveTerminalRightInset,
|
||||
resolveTerminalTopOffsets,
|
||||
shouldBlockTerminalReconnectForTarget,
|
||||
shouldEnableStatusBarDisconnect,
|
||||
shouldEnableStatusBarReconnect,
|
||||
shouldReconnectTerminalOnEnterKey,
|
||||
shouldShowSelectionAIOverlay,
|
||||
shouldShowLineTimestampToolbarToggle,
|
||||
shouldShowStatusBarConnectionControls,
|
||||
TerminalDisconnectedNotice,
|
||||
resolveTerminalDisconnectedNoticeMessage,
|
||||
} from "./TerminalView.tsx";
|
||||
|
||||
test("terminal disconnected notice keeps the reason and reconnect hint on one compact row", () => {
|
||||
const markup = renderToStaticMarkup(React.createElement(TerminalDisconnectedNotice, {
|
||||
message: "Connection timed out.",
|
||||
reconnectHint: "Press Enter to reconnect",
|
||||
bottom: 4,
|
||||
left: 4,
|
||||
right: 4,
|
||||
}));
|
||||
|
||||
assert.match(markup, /data-terminal-disconnected-notice="true"/);
|
||||
assert.match(markup, /role="status"/);
|
||||
assert.match(markup, /Connection timed out\./);
|
||||
assert.match(markup, /Press Enter to reconnect/);
|
||||
assert.match(markup, /h-7/);
|
||||
assert.doesNotMatch(markup, /pointer-events-none/);
|
||||
});
|
||||
|
||||
test("clicking the disconnected notice preserves terminal keyboard focus", () => {
|
||||
let prevented = false;
|
||||
let focused = false;
|
||||
|
||||
focusTerminalFromDisconnectedNotice(
|
||||
{ preventDefault: () => { prevented = true; } },
|
||||
() => { focused = true; },
|
||||
);
|
||||
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(focused, true);
|
||||
});
|
||||
|
||||
test("automatic reconnect notice uses explicit lifecycle copy instead of a stale log line", () => {
|
||||
assert.equal(
|
||||
resolveTerminalDisconnectedNoticeMessage({
|
||||
status: "connecting",
|
||||
error: null,
|
||||
reconnectMessage: "Auto reconnect attempt 2...",
|
||||
disconnectedLabel: "Disconnected",
|
||||
}),
|
||||
"Auto reconnect attempt 2...",
|
||||
);
|
||||
assert.equal(
|
||||
resolveTerminalDisconnectedNoticeMessage({
|
||||
status: "disconnected",
|
||||
error: "Connection timed out.",
|
||||
reconnectMessage: "Waiting for host key confirmation",
|
||||
disconnectedLabel: "Disconnected",
|
||||
}),
|
||||
"Connection timed out.",
|
||||
);
|
||||
assert.equal(
|
||||
resolveTerminalDisconnectedNoticeMessage({
|
||||
status: "disconnected",
|
||||
error: "Connection timed out.",
|
||||
reconnectMessage: "Reconnecting...",
|
||||
disconnectedLabel: "Disconnected",
|
||||
isReconnectActive: true,
|
||||
}),
|
||||
"Reconnecting...",
|
||||
);
|
||||
});
|
||||
|
||||
test("automatic reconnect switches to attempt copy before waking a hibernated terminal", () => {
|
||||
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const startReconnect = source.indexOf('const startReconnect = async');
|
||||
const updateNotice = source.indexOf('setReconnectNoticeMessage(reconnectAttemptMessage)', startReconnect);
|
||||
const wakeHibernated = source.indexOf('if (!termRef.current && hibernatedRef.current)', startReconnect);
|
||||
|
||||
assert.ok(startReconnect >= 0);
|
||||
assert.ok(updateNotice > startReconnect);
|
||||
assert.ok(wakeHibernated > updateNotice);
|
||||
});
|
||||
|
||||
test("hibernated manual reconnect can continue after its runtime wakes", () => {
|
||||
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const startReconnect = source.indexOf("const startReconnect = async");
|
||||
const markManualReconnect = source.indexOf(
|
||||
'setManualReconnectActive(mode === "manual")',
|
||||
startReconnect,
|
||||
);
|
||||
const initialGuard = source.slice(startReconnect, markManualReconnect);
|
||||
const clearWakeInFlight = source.indexOf(
|
||||
"reconnectWakeInFlightRef.current = false",
|
||||
markManualReconnect,
|
||||
);
|
||||
const continueReconnect = source.indexOf("startReconnectRef.current?.(mode)", clearWakeInFlight);
|
||||
|
||||
assert.ok(startReconnect >= 0);
|
||||
assert.ok(markManualReconnect > startReconnect);
|
||||
assert.match(initialGuard, /reconnectPreparationTokenRef\.current !== null/);
|
||||
assert.match(initialGuard, /reconnectWakeInFlightRef\.current/);
|
||||
assert.doesNotMatch(initialGuard, /manualReconnectActive/);
|
||||
assert.ok(clearWakeInFlight > markManualReconnect);
|
||||
assert.ok(continueReconnect > clearWakeInFlight);
|
||||
});
|
||||
|
||||
test("manual reconnect publishes preparation without starting the connection timeout", () => {
|
||||
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const startReconnect = source.indexOf("const startReconnect = async");
|
||||
const publishPreparation = source.indexOf(
|
||||
"terminalReconnectRegistry.setActive(sessionId, true)",
|
||||
startReconnect,
|
||||
);
|
||||
const cleanupSession = source.indexOf(
|
||||
"await cleanupSession({ retainOwnership: true })",
|
||||
startReconnect,
|
||||
);
|
||||
const restoreDisconnected = source.indexOf(
|
||||
'updateStatus("disconnected")',
|
||||
startReconnect,
|
||||
);
|
||||
|
||||
assert.ok(startReconnect >= 0);
|
||||
assert.ok(publishPreparation > startReconnect);
|
||||
assert.ok(cleanupSession > publishPreparation);
|
||||
assert.equal(
|
||||
source.indexOf('if (mode === "manual") updateStatus("connecting")', startReconnect),
|
||||
-1,
|
||||
);
|
||||
assert.ok(restoreDisconnected > startReconnect);
|
||||
assert.ok(restoreDisconnected < cleanupSession);
|
||||
});
|
||||
|
||||
test("programmatic input during hibernated reconnect does not clear reconnect presentation", () => {
|
||||
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const scrollStart = source.indexOf("const scrollToBottomAfterProgrammaticInput");
|
||||
const scrollEnd = source.indexOf("useEffect(() =>", scrollStart);
|
||||
const scrollBody = source.slice(scrollStart, scrollEnd);
|
||||
|
||||
assert.notEqual(scrollStart, -1);
|
||||
assert.notEqual(scrollEnd, -1);
|
||||
assert.doesNotMatch(scrollBody, /manualReconnectActiveRef|setReconnectNoticeMessage/);
|
||||
});
|
||||
|
||||
test("line timestamp toggle creates a persistent host update", () => {
|
||||
const host = {
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
showLineTimestamps: false,
|
||||
theme: "default",
|
||||
};
|
||||
|
||||
assert.deepEqual(getLineTimestampToggleHostUpdate(host), {
|
||||
id: "host-1",
|
||||
showLineTimestamps: true,
|
||||
});
|
||||
assert.deepEqual(getLineTimestampToggleHostUpdate({ ...host, showLineTimestamps: true }), {
|
||||
id: "host-1",
|
||||
showLineTimestamps: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("line timestamp toolbar toggle is hidden when timestamps are unavailable", () => {
|
||||
assert.equal(shouldShowLineTimestampToolbarToggle(false, () => {}), false);
|
||||
assert.equal(shouldShowLineTimestampToolbarToggle(true, () => {}), true);
|
||||
assert.equal(shouldShowLineTimestampToolbarToggle(undefined, () => {}), true);
|
||||
assert.equal(shouldShowLineTimestampToolbarToggle(true, undefined), false);
|
||||
});
|
||||
|
||||
test("selection AI overlay honors the visibility preference", () => {
|
||||
const overlayPosition = { left: 120, top: 80 };
|
||||
const addSelection = () => {};
|
||||
|
||||
assert.equal(
|
||||
shouldShowSelectionAIOverlay({
|
||||
hasSelection: true,
|
||||
selectionOverlayPosition: overlayPosition,
|
||||
onAddSelectionToAI: addSelection,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowSelectionAIOverlay({
|
||||
hasSelection: true,
|
||||
selectionOverlayPosition: overlayPosition,
|
||||
onAddSelectionToAI: addSelection,
|
||||
showSelectionAIAction: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowSelectionAIOverlay({
|
||||
hasSelection: true,
|
||||
selectionOverlayPosition: overlayPosition,
|
||||
onAddSelectionToAI: addSelection,
|
||||
showSelectionAIAction: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("disconnected terminal reconnects on plain Enter when input is not claimed elsewhere", () => {
|
||||
assert.equal(
|
||||
shouldReconnectTerminalOnEnterKey({
|
||||
key: "Enter",
|
||||
status: "disconnected",
|
||||
hasRetryHandler: true,
|
||||
isComposeBarOpen: false,
|
||||
needsAuth: false,
|
||||
needsHostKeyVerification: false,
|
||||
hasBlockingOverlay: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal enter reconnect ignores active controls and non-disconnected states", () => {
|
||||
const base = {
|
||||
key: "Enter",
|
||||
status: "disconnected" as const,
|
||||
hasRetryHandler: true,
|
||||
isComposeBarOpen: false,
|
||||
needsAuth: false,
|
||||
needsHostKeyVerification: false,
|
||||
hasBlockingOverlay: false,
|
||||
};
|
||||
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, status: "connected" }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, key: "a" }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasRetryHandler: false }), false);
|
||||
// Open search must not globally suppress Enter reconnect / the hint (#2546).
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base }), true);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isComposeBarOpen: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsAuth: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, needsHostKeyVerification: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, hasBlockingOverlay: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, isReconnectActive: true }), false);
|
||||
assert.equal(shouldReconnectTerminalOnEnterKey({ ...base, altKey: true }), false);
|
||||
});
|
||||
|
||||
test("terminal enter reconnect ignores interactive controls outside xterm only", () => {
|
||||
assert.equal(
|
||||
shouldBlockTerminalReconnectForTarget({
|
||||
isWithinXterm: false,
|
||||
hasInteractiveAncestor: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBlockTerminalReconnectForTarget({
|
||||
isWithinXterm: true,
|
||||
hasInteractiveAncestor: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldBlockTerminalReconnectForTarget({
|
||||
isWithinXterm: false,
|
||||
hasInteractiveAncestor: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
// An open terminal search input is interactive, but disconnected Enter must
|
||||
// still reconnect rather than find-next (#2546).
|
||||
assert.equal(
|
||||
shouldBlockTerminalReconnectForTarget({
|
||||
isWithinXterm: false,
|
||||
hasInteractiveAncestor: true,
|
||||
isTerminalSearchInput: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal title formats the connection address for remote sessions", () => {
|
||||
assert.equal(
|
||||
formatTerminalTitleConnectionAddress({
|
||||
protocol: "ssh",
|
||||
username: "root",
|
||||
hostname: "10.1.2.34",
|
||||
port: 2222,
|
||||
}),
|
||||
"root@10.1.2.34:2222",
|
||||
);
|
||||
assert.equal(formatTerminalTitleConnectionAddress({ protocol: "local", hostname: "localhost" }), null);
|
||||
assert.equal(formatTerminalTitleConnectionAddress({
|
||||
protocol: "plugin:com.example.transport.connection",
|
||||
hostname: "com.example.transport.connection",
|
||||
port: 22,
|
||||
}), null);
|
||||
});
|
||||
|
||||
test("host info bar title follows address or label mode", () => {
|
||||
assert.equal(
|
||||
formatTerminalHostInfoBarTitle({
|
||||
serverName: "prod-web",
|
||||
connectionAddress: "root@10.1.2.34:2222",
|
||||
mode: "address",
|
||||
}),
|
||||
"root@10.1.2.34:2222",
|
||||
);
|
||||
assert.equal(
|
||||
formatTerminalHostInfoBarTitle({
|
||||
serverName: "prod-web",
|
||||
connectionAddress: "root@10.1.2.34:2222",
|
||||
mode: "label",
|
||||
}),
|
||||
"prod-web",
|
||||
);
|
||||
assert.equal(
|
||||
formatTerminalHostInfoBarTitle({
|
||||
serverName: " prod-web ",
|
||||
connectionAddress: " root@10.1.2.34:2222 ",
|
||||
mode: "label",
|
||||
}),
|
||||
"prod-web",
|
||||
);
|
||||
assert.equal(
|
||||
formatTerminalHostInfoBarTitle({
|
||||
serverName: "",
|
||||
connectionAddress: "root@10.1.2.34:2222",
|
||||
mode: "label",
|
||||
}),
|
||||
"root@10.1.2.34:2222",
|
||||
);
|
||||
assert.equal(
|
||||
formatTerminalHostInfoBarTitle({
|
||||
serverName: "Local Terminal",
|
||||
connectionAddress: null,
|
||||
mode: "address",
|
||||
}),
|
||||
"Local Terminal",
|
||||
);
|
||||
assert.equal(
|
||||
formatTerminalHostInfoBarTooltip({
|
||||
serverName: "prod-web",
|
||||
connectionAddress: "root@10.1.2.34:2222",
|
||||
}),
|
||||
"prod-web · root@10.1.2.34:2222",
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal title row does not render a status dot beside the address", () => {
|
||||
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
const titleStart = source.indexOf("data-terminal-detach-drag-handle");
|
||||
const titleEnd = source.indexOf("shouldShowLineTimestampToolbarToggle", titleStart);
|
||||
assert.notEqual(titleStart, -1);
|
||||
assert.notEqual(titleEnd, -1);
|
||||
|
||||
assert.doesNotMatch(source.slice(titleStart, titleEnd), /statusDotTone/);
|
||||
});
|
||||
|
||||
test("terminal title keeps the copy host action beside the address", () => {
|
||||
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
const titleStart = source.indexOf("data-terminal-detach-drag-handle");
|
||||
const copyAction = source.indexOf('aria-label={t("terminal.statusbar.copyHostname.label")}', titleStart);
|
||||
const timestampToggle = source.indexOf("shouldShowLineTimestampToolbarToggle", titleStart);
|
||||
|
||||
assert.notEqual(titleStart, -1);
|
||||
assert.notEqual(copyAction, -1);
|
||||
assert.notEqual(timestampToggle, -1);
|
||||
assert.ok(copyAction < timestampToggle);
|
||||
});
|
||||
|
||||
test("focus mode and temporary pane magnification use separate toolbar actions", () => {
|
||||
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
const focusAction = source.indexOf("onClick={onExpandToFocus}");
|
||||
const magnifyAction = source.indexOf("onClick={onTogglePaneMagnification}");
|
||||
|
||||
assert.notEqual(focusAction, -1);
|
||||
assert.notEqual(magnifyAction, -1);
|
||||
assert.ok(focusAction < magnifyAction);
|
||||
assert.match(source.slice(focusAction, magnifyAction), /terminal\.toolbar\.focusMode/);
|
||||
assert.match(source.slice(magnifyAction), /terminal\.paneMagnification\.(restore|magnify)/);
|
||||
});
|
||||
|
||||
test("popup terminals disable line timestamp controls", () => {
|
||||
const source = readFileSync(new URL("../TerminalPopupPage.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /lineTimestampsAvailable=\{false\}/);
|
||||
});
|
||||
|
||||
test("terminal body keeps a slight inset from the surrounding chrome", () => {
|
||||
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /const terminalBodyInset = 4/);
|
||||
assert.match(source, /left: activeLineTimestampGutterWidth \+ terminalBodyInset/);
|
||||
assert.match(source, /right: terminalRightInset/);
|
||||
assert.match(source, /const terminalBottomInset = terminalBodyInset \+ \(showDisconnectedTerminalNotice \? 28 : 0\)/);
|
||||
assert.match(source, /bottom: terminalBottomInset/);
|
||||
assert.match(source, /left=\{terminalBodyInset\}/);
|
||||
assert.match(source, /bottom=\{terminalBottomInset\}/);
|
||||
});
|
||||
|
||||
test("hidden host information bar gives its vertical space back to the terminal", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalTopOffsets({ showHostInfoBar: false, isSearchOpen: false }),
|
||||
{ toolbarOffset: 0, contentTop: "4px" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveTerminalTopOffsets({ showHostInfoBar: true, isSearchOpen: false }),
|
||||
{ toolbarOffset: 30, contentTop: "34px" },
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal search keeps enough space when host information is hidden", () => {
|
||||
assert.deepEqual(
|
||||
resolveTerminalTopOffsets({ showHostInfoBar: false, isSearchOpen: true }),
|
||||
{ toolbarOffset: 64, contentTop: "68px" },
|
||||
);
|
||||
});
|
||||
|
||||
test("network device tip reserves extra top space below the toolbar", () => {
|
||||
// Tip stacks below the toolbar: content shifts down by the tip height, but
|
||||
// the toolbar offset itself is unchanged.
|
||||
assert.deepEqual(
|
||||
resolveTerminalTopOffsets({ showHostInfoBar: true, isSearchOpen: false, networkDeviceTipHeight: 28 }),
|
||||
{ toolbarOffset: 30, contentTop: "62px" },
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveTerminalTopOffsets({ showHostInfoBar: false, isSearchOpen: false, networkDeviceTipHeight: 28 }),
|
||||
{ toolbarOffset: 0, contentTop: "32px" },
|
||||
);
|
||||
});
|
||||
|
||||
test("network device tip clears the compact speed-dial toggle only when it is present", () => {
|
||||
// Speed dial only renders when host info is hidden and search is closed;
|
||||
// reserve right-side room there so the tip cannot cover its click target.
|
||||
assert.equal(resolveNetworkDeviceTipRightInset({ showHostInfoBar: false, isSearchOpen: false }), 40);
|
||||
assert.equal(resolveNetworkDeviceTipRightInset({ showHostInfoBar: true, isSearchOpen: false }), 0);
|
||||
assert.equal(resolveNetworkDeviceTipRightInset({ showHostInfoBar: false, isSearchOpen: true }), 0);
|
||||
});
|
||||
|
||||
test("hidden host information does not reserve a side gutter for its floating action button", () => {
|
||||
// Speed-dial overlays the terminal; scrollbar stays at the pane edge.
|
||||
assert.equal(resolveTerminalRightInset({ showHostInfoBar: false, isSearchOpen: false }), 4);
|
||||
assert.equal(resolveTerminalRightInset({ showHostInfoBar: true, isSearchOpen: false }), 4);
|
||||
assert.equal(resolveTerminalRightInset({ showHostInfoBar: false, isSearchOpen: true }), 4);
|
||||
});
|
||||
|
||||
test("hidden host information keeps terminal actions rendered", () => {
|
||||
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
const hostInfoStart = source.indexOf("{showHostInfoBar && <div");
|
||||
const hostInfoEnd = source.indexOf("</div>}", hostInfoStart);
|
||||
const copyAction = source.indexOf('aria-label={t("terminal.statusbar.copyHostname.label")}');
|
||||
const timestampAction = source.indexOf("shouldShowLineTimestampToolbarToggle", copyAction);
|
||||
const systemAction = source.indexOf('aria-label={t("terminal.layer.system")}', timestampAction);
|
||||
const disconnectAction = source.indexOf('aria-label={t("terminal.statusbar.disconnect.label")}', systemAction);
|
||||
const reconnectAction = source.indexOf('aria-label={t("terminal.statusbar.reconnect.label")}', disconnectAction);
|
||||
const actionsStart = source.indexOf('className="flex items-center gap-0.5 flex-shrink-0"');
|
||||
const controls = source.indexOf("{renderControls({ showClose: inWorkspace, restorePaneLayout: isPaneMagnified })}");
|
||||
const compactDragHandle = source.indexOf('data-terminal-detach-drag-handle="true"');
|
||||
|
||||
assert.notEqual(hostInfoStart, -1);
|
||||
assert.notEqual(hostInfoEnd, -1);
|
||||
assert.notEqual(copyAction, -1);
|
||||
assert.notEqual(timestampAction, -1);
|
||||
assert.notEqual(systemAction, -1);
|
||||
assert.notEqual(disconnectAction, -1);
|
||||
assert.notEqual(reconnectAction, -1);
|
||||
assert.notEqual(actionsStart, -1);
|
||||
assert.notEqual(controls, -1);
|
||||
assert.notEqual(compactDragHandle, -1);
|
||||
// Compact drag handle uses GripVertical, not the old radial-dot “chessboard”.
|
||||
assert.match(source, /GripVertical/);
|
||||
assert.ok(!source.includes("backgroundSize: '4px 4px'"));
|
||||
assert.ok(hostInfoStart < hostInfoEnd);
|
||||
assert.ok(hostInfoEnd < copyAction);
|
||||
assert.ok(copyAction < timestampAction);
|
||||
assert.ok(timestampAction < systemAction);
|
||||
assert.ok(systemAction < disconnectAction);
|
||||
assert.ok(disconnectAction < reconnectAction);
|
||||
assert.ok(reconnectAction < actionsStart);
|
||||
assert.ok(actionsStart < controls);
|
||||
assert.ok(compactDragHandle < hostInfoStart);
|
||||
});
|
||||
|
||||
test("status bar disconnect stays enabled while connected or connecting", () => {
|
||||
assert.equal(shouldEnableStatusBarDisconnect("connected"), true);
|
||||
assert.equal(shouldEnableStatusBarDisconnect("connecting"), true);
|
||||
assert.equal(shouldEnableStatusBarDisconnect("disconnected"), false);
|
||||
assert.equal(shouldEnableStatusBarDisconnect(undefined), false);
|
||||
});
|
||||
|
||||
test("status bar reconnect matches tab-menu reconnect gating", () => {
|
||||
assert.equal(shouldEnableStatusBarReconnect("connected"), true);
|
||||
assert.equal(shouldEnableStatusBarReconnect("disconnected"), true);
|
||||
assert.equal(shouldEnableStatusBarReconnect("connecting"), false);
|
||||
assert.equal(shouldEnableStatusBarReconnect(undefined), false);
|
||||
});
|
||||
|
||||
test("status bar connection controls require an owned session surface", () => {
|
||||
assert.equal(
|
||||
shouldShowStatusBarConnectionControls({
|
||||
showConnectionControls: true,
|
||||
hasDisconnectHandler: true,
|
||||
hasReconnectHandler: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowStatusBarConnectionControls({
|
||||
showConnectionControls: false,
|
||||
hasDisconnectHandler: true,
|
||||
hasReconnectHandler: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldShowStatusBarConnectionControls({
|
||||
showConnectionControls: true,
|
||||
hasDisconnectHandler: false,
|
||||
hasReconnectHandler: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("manual disconnect keeps the session pane for reconnect", () => {
|
||||
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const disconnectStart = source.indexOf("const handleDisconnect = () => {");
|
||||
const disconnectEnd = source.indexOf("const handleDismissDisconnectedDialog", disconnectStart);
|
||||
assert.notEqual(disconnectStart, -1);
|
||||
assert.notEqual(disconnectEnd, -1);
|
||||
const body = source.slice(disconnectStart, disconnectEnd);
|
||||
assert.match(body, /clearAutoReconnect\(\{ stopLoop: true \}\)/);
|
||||
assert.match(body, /reconnectWakeTokenRef\.current = null/);
|
||||
assert.match(body, /reconnectWakeInFlightRef\.current = false/);
|
||||
assert.match(body, /netcatty:terminal-session-disconnected/);
|
||||
assert.match(body, /invalidateBootEpochForClose\(\)/);
|
||||
assert.match(body, /isBootActiveRef\.current = false/);
|
||||
assert.match(body, /setIsCancelling\(true\)/);
|
||||
assert.match(body, /updateStatus\("disconnected"\)/);
|
||||
assert.match(body, /void cleanupSession\(\{ retainOwnership: true \}\)/);
|
||||
assert.match(source, /trackSessionCleanup/);
|
||||
assert.doesNotMatch(body, /onCloseSession/);
|
||||
assert.match(source, /handleDisconnect: \(attachExistingSession \|\| compactToolbar\) \? undefined : handleDisconnect/);
|
||||
assert.match(source, /showConnectionControls: !attachExistingSession && !compactToolbar/);
|
||||
assert.match(source, /setTerminalBootEpoch/);
|
||||
|
||||
const startersSource = readFileSync(
|
||||
new URL("./runtime/createTerminalSessionStarters.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(startersSource, /createBootAttemptGuard\(ctx\)/);
|
||||
assert.match(startersSource, /setTerminalBootEpoch\(ctx\.sessionId, bootEpoch\)/);
|
||||
|
||||
const effectsSource = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
effectsSource,
|
||||
/!isBootActiveRef\.current[\s\S]*statusRef\.current === "disconnected"[\s\S]*bootEpochMismatch/,
|
||||
);
|
||||
assert.match(
|
||||
effectsSource,
|
||||
/respondHostKeyVerification\?\.\(request\.requestId, false\)/,
|
||||
);
|
||||
assert.match(effectsSource, /request\.bootEpoch/);
|
||||
assert.match(
|
||||
startersSource,
|
||||
/if \(!isCurrentAttempt\(\)\) return;/,
|
||||
);
|
||||
assert.match(startersSource, /bootEpoch,/);
|
||||
});
|
||||
|
||||
test("cancel connect invalidates the boot epoch like disconnect", () => {
|
||||
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
const cancelStart = source.indexOf("const handleCancelConnect = () => {");
|
||||
const cancelEnd = source.indexOf("const handleDisconnect = () => {", cancelStart);
|
||||
assert.notEqual(cancelStart, -1);
|
||||
assert.notEqual(cancelEnd, -1);
|
||||
const body = source.slice(cancelStart, cancelEnd);
|
||||
|
||||
assert.match(body, /invalidateBootEpochForClose\(\)/);
|
||||
assert.match(body, /isBootActiveRef\.current = false/);
|
||||
// Both must land before cleanupSession so the close targets the pre-bump epoch.
|
||||
assert.ok(
|
||||
body.indexOf("invalidateBootEpochForClose()") < body.indexOf("void cleanupSession()"),
|
||||
"cancel must invalidate the boot epoch before cleanupSession",
|
||||
);
|
||||
assert.ok(
|
||||
body.indexOf("isBootActiveRef.current = false") < body.indexOf("void cleanupSession()"),
|
||||
"cancel must clear boot-active before cleanupSession",
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal boot is cancelable and closes eagerly on cleanup", () => {
|
||||
const effectsSource = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
|
||||
const startersSource = readFileSync(
|
||||
new URL("./runtime/createTerminalSessionStarters.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(effectsSource, /const bootAbort = new AbortController\(\)/);
|
||||
assert.match(effectsSource, /const bootStartOptions = \{ signal: bootAbort\.signal \}/);
|
||||
assert.match(
|
||||
effectsSource,
|
||||
/queueMicrotask\(\(\) => \{\s*\n\s*if \(disposed\) return;\s*\n\s*void boot\(\);/,
|
||||
"backend boot must defer past StrictMode's synchronous re-invoke",
|
||||
);
|
||||
for (const starter of [
|
||||
"startPluginConnection",
|
||||
"startSerial",
|
||||
"startLocal",
|
||||
"startTelnet",
|
||||
"startMosh",
|
||||
"startEt",
|
||||
"startSSH",
|
||||
]) {
|
||||
assert.match(
|
||||
effectsSource,
|
||||
new RegExp(`sessionStarters\\.${starter}\\(term, bootStartOptions\\)`),
|
||||
`${starter} must receive the boot abort signal`,
|
||||
);
|
||||
}
|
||||
|
||||
// Cleanup order: abort, then eager close + sync dispose for never-connected
|
||||
// boots (StrictMode remount), else the async capture/teardown path.
|
||||
const cleanupStart = effectsSource.indexOf(" disposed = true;");
|
||||
assert.notEqual(cleanupStart, -1);
|
||||
const cleanup = effectsSource.slice(cleanupStart);
|
||||
const abortAt = cleanup.indexOf("bootAbort.abort()");
|
||||
const neverConnectedAt = cleanup.indexOf("if (!hasConnectedRef.current)");
|
||||
assert.ok(abortAt !== -1 && neverConnectedAt !== -1);
|
||||
assert.ok(abortAt < neverConnectedAt, "cleanup must abort before the never-connected close branch");
|
||||
const neverConnectedBranch = cleanup.slice(
|
||||
neverConnectedAt,
|
||||
cleanup.indexOf("const persistCloseCapture", neverConnectedAt),
|
||||
);
|
||||
const closeAt = neverConnectedBranch.indexOf("terminalBackend.closeSession(");
|
||||
const syncDisposeAt = neverConnectedBranch.indexOf("disposeOwnedRuntime();");
|
||||
const earlyReturnAt = neverConnectedBranch.indexOf("return;");
|
||||
assert.ok(closeAt !== -1 && syncDisposeAt !== -1 && earlyReturnAt !== -1);
|
||||
assert.ok(closeAt < syncDisposeAt, "eager close must run before sync runtime dispose");
|
||||
assert.ok(
|
||||
syncDisposeAt < earlyReturnAt,
|
||||
"never-connected boots must sync-dispose before leaving cleanup",
|
||||
);
|
||||
// Owner panes close the pending backend; attach popups only dispose xterm.
|
||||
assert.match(neverConnectedBranch, /if \(!attachExistingSession\)/);
|
||||
assert.match(effectsSource, /let ownedRuntime:/);
|
||||
assert.match(
|
||||
cleanup,
|
||||
/void completeClose\(\)/,
|
||||
"connected boots still use the async capture path",
|
||||
);
|
||||
|
||||
// An aborted boot must stop counting as the current attempt so the existing
|
||||
// orphan-close / attach-refusal guards cover cancellation too.
|
||||
assert.match(
|
||||
startersSource,
|
||||
/options\?\.signal\?\.aborted !== true && isBootEpochCurrent\(\)/,
|
||||
);
|
||||
for (const starter of [
|
||||
"startSSH",
|
||||
"startTelnet",
|
||||
"startMosh",
|
||||
"startEt",
|
||||
"startPluginConnection",
|
||||
"startLocal",
|
||||
"startSerial",
|
||||
]) {
|
||||
assert.match(
|
||||
startersSource,
|
||||
new RegExp(
|
||||
`const ${starter} = async \\(term: XTerm, options\\?: TerminalSessionStartOptions\\)`,
|
||||
),
|
||||
`${starter} must accept an abort signal`,
|
||||
);
|
||||
}
|
||||
// The plugin path holds its own in-flight request controller; the boot abort
|
||||
// has to reach it or the extension request outlives the pane.
|
||||
assert.match(startersSource, /options\?\.signal\?\.addEventListener\("abort", onBootAborted/);
|
||||
assert.match(startersSource, /options\?\.signal\?\.removeEventListener\("abort", onBootAborted\)/);
|
||||
});
|
||||
|
||||
test("hidden host information reveals actions without permanently covering terminal content", () => {
|
||||
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /aria-label=\{t\("terminal\.toolbar\.showActions"\)\}/);
|
||||
assert.match(source, /aria-expanded=\{compactActionsOpen\}/);
|
||||
assert.match(source, /aria-controls=\{`terminal-actions-\$\{sessionId\}`\}/);
|
||||
assert.match(source, /id=\{`terminal-actions-\$\{sessionId\}`\}/);
|
||||
assert.match(source, /onClick=\{\(\) => setCompactActionsOpen/);
|
||||
assert.match(source, /right: terminalRightInset/);
|
||||
// Compact mode is a circular speed-dial: tray springs left via 0fr→1fr grid
|
||||
// (must not use .terminal-topbar — container-type collapses content width).
|
||||
assert.match(source, /flex flex-row-reverse items-center/);
|
||||
assert.match(source, /rounded-full/);
|
||||
assert.match(source, /grid-cols-\[1fr\]/);
|
||||
assert.match(source, /grid-cols-\[0fr\]/);
|
||||
assert.match(source, /ChevronsLeft/);
|
||||
assert.match(source, /h-7/);
|
||||
assert.match(source, /Do NOT use `\.terminal-topbar`|container-type:inline-size|container-type collapses/);
|
||||
assert.match(source, /document\.addEventListener\("pointerdown", handlePointerDown\)/);
|
||||
assert.match(source, /closest\('\[data-radix-popper-content-wrapper\]'\)/);
|
||||
assert.match(source, /event\.key !== "Escape"/);
|
||||
assert.match(source, /compactActionsButtonRef\.current\?\.focus\(\)/);
|
||||
});
|
||||
|
||||
test("compact action toggle preserves terminal focus like the visible toolbar", () => {
|
||||
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
|
||||
const overlayStart = source.indexOf('ref={compactActionsRef}');
|
||||
const toggleStart = source.indexOf('ref={compactActionsButtonRef}', overlayStart);
|
||||
|
||||
assert.notEqual(overlayStart, -1);
|
||||
assert.notEqual(toggleStart, -1);
|
||||
assert.ok(overlayStart < toggleStart);
|
||||
assert.match(source.slice(overlayStart, toggleStart), /onMouseDownCapture=\{handleTopOverlayMouseDownCapture\}/);
|
||||
});
|
||||
|
||||
test("terminal theme updates force xterm renderer to repaint immediately", () => {
|
||||
const source = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
|
||||
const schedulerSource = readFileSync(new URL("./terminalThemeScheduler.ts", import.meta.url), "utf8");
|
||||
|
||||
assert.match(source, /applyTerminalThemeSync\(term, effectiveTheme\)/);
|
||||
assert.match(schedulerSource, /term\.options\.theme = \{/);
|
||||
assert.match(schedulerSource, /forceSyncRenderAfterResize\(term\)/);
|
||||
});
|
||||
1328
components/terminal/TerminalView.tsx
Normal file
1328
components/terminal/TerminalView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
814
components/terminal/ThemeCustomizeModal.tsx
Normal file
814
components/terminal/ThemeCustomizeModal.tsx
Normal file
@@ -0,0 +1,814 @@
|
||||
/**
|
||||
* Terminal Theme Customize Modal
|
||||
* Left-right split design: list on left, large preview on right
|
||||
* Uses React Portal to render at document root for proper z-index
|
||||
*
|
||||
* Features:
|
||||
* - Real-time preview: changes are applied immediately to the terminal
|
||||
* - Save: persists the current settings
|
||||
* - Cancel: reverts to the original settings when modal was opened
|
||||
* - Custom themes: create, edit, delete, import .itermcolors
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState, useCallback, useRef, memo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Check, Download, Minus, Palette, Pencil, Plus, Sparkles, Type, X } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useAvailableFonts } from '../../application/state/fontStore';
|
||||
import { TERMINAL_THEMES, TerminalThemeConfig, USER_VISIBLE_TERMINAL_THEMES, isUiMatchTerminalThemeId } from '../../infrastructure/config/terminalThemes';
|
||||
import { DEFAULT_FONT_SIZE, MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
|
||||
import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore';
|
||||
import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser';
|
||||
import { CustomThemeModal } from './CustomThemeModal';
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
|
||||
type TabType = 'theme' | 'font' | 'custom';
|
||||
|
||||
// Memoized theme item component to prevent unnecessary re-renders
|
||||
const ThemeItem = memo(({
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onEdit,
|
||||
}: {
|
||||
theme: TerminalThemeConfig;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onEdit?: (id: string) => void;
|
||||
}) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(theme.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(theme.id); } }}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all group cursor-pointer',
|
||||
isSelected
|
||||
? 'bg-primary/15 ring-1 ring-primary'
|
||||
: 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
{/* Color swatch */}
|
||||
<div
|
||||
className="w-8 h-8 rounded-md flex-shrink-0 flex flex-col justify-center items-start pl-1 gap-0.5 border border-border/50"
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
>
|
||||
<div className="h-1 w-3 rounded-full" style={{ backgroundColor: theme.colors.green }} />
|
||||
<div className="h-1 w-5 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
|
||||
<div className="h-1 w-2 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={cn('text-xs font-medium truncate', isSelected ? 'text-primary' : 'text-foreground')}>
|
||||
{theme.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground capitalize">
|
||||
{theme.type}
|
||||
{theme.isCustom && ' • custom'}
|
||||
</div>
|
||||
</div>
|
||||
{onEdit && (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(theme.id); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); onEdit(theme.id); } }}
|
||||
className="w-6 h-6 rounded flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/80 opacity-0 group-hover:opacity-100 transition-all"
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !onEdit && (
|
||||
<Check size={14} className="text-primary flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
ThemeItem.displayName = 'ThemeItem';
|
||||
|
||||
// Memoized font item component
|
||||
const FontItem = memo(({
|
||||
font,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
font: TerminalFont;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => onSelect(font.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-left transition-all',
|
||||
isSelected
|
||||
? 'bg-primary/15 ring-1 ring-primary'
|
||||
: 'hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className={cn('text-sm truncate', isSelected ? 'text-primary' : 'text-foreground')}
|
||||
style={{ fontFamily: font.family }}
|
||||
>
|
||||
{font.name}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground truncate">{font.description}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check size={14} className="text-primary flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
));
|
||||
FontItem.displayName = 'FontItem';
|
||||
|
||||
interface ThemeCustomizeModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
currentThemeId?: string;
|
||||
displayThemeId?: string;
|
||||
currentFontFamilyId?: string;
|
||||
currentFontSize?: number;
|
||||
/** Called immediately when user selects a theme (for real-time preview) */
|
||||
onThemeChange?: (themeId: string) => void;
|
||||
/** Called when the theme should return to inherited/default state */
|
||||
onThemeReset?: () => void;
|
||||
/** Called immediately when user selects a font (for real-time preview) */
|
||||
onFontFamilyChange?: (fontFamilyId: string) => void;
|
||||
/** Called immediately when user changes font size (for real-time preview) */
|
||||
onFontSizeChange?: (fontSize: number) => void;
|
||||
/** Called when user clicks Save to persist settings */
|
||||
onSave?: () => void;
|
||||
/** Optional live preview callback for consumers that render outside this modal */
|
||||
onPreviewThemeChange?: (theme: TerminalTheme | null) => void;
|
||||
}
|
||||
|
||||
// Memoized preview component to avoid re-rendering on every state change
|
||||
const TerminalPreview = memo(({
|
||||
theme,
|
||||
font,
|
||||
fontSize
|
||||
}: {
|
||||
theme: TerminalThemeConfig;
|
||||
font: TerminalFont;
|
||||
fontSize: number;
|
||||
}) => (
|
||||
<div
|
||||
className="flex-1 rounded-xl overflow-hidden border border-border flex flex-col"
|
||||
style={{ backgroundColor: theme.colors.background }}
|
||||
>
|
||||
{/* Fake title bar */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 border-b shrink-0"
|
||||
style={{
|
||||
backgroundColor: theme.colors.background,
|
||||
borderColor: `${theme.colors.foreground}15`
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-1.5">
|
||||
<div className="w-3 h-3 rounded-full bg-red-500/80" />
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500/80" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-500/80" />
|
||||
</div>
|
||||
<div
|
||||
className="flex-1 text-center text-xs"
|
||||
style={{ color: theme.colors.foreground, opacity: 0.5, fontFamily: font.family }}
|
||||
>
|
||||
user@server — bash
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal content */}
|
||||
<div
|
||||
className="flex-1 p-4 font-mono overflow-auto"
|
||||
style={{
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: font.family,
|
||||
fontSize: `${fontSize}px`,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<span style={{ color: theme.colors.green }}>user@server</span>
|
||||
<span style={{ color: theme.colors.foreground }}>:</span>
|
||||
<span style={{ color: theme.colors.blue }}>~</span>
|
||||
<span style={{ color: theme.colors.foreground }}>$ </span>
|
||||
<span>neofetch</span>
|
||||
</div>
|
||||
<div style={{ color: theme.colors.cyan }}>
|
||||
{' _,met$$$$$gg. '}
|
||||
</div>
|
||||
<div style={{ color: theme.colors.cyan }}>
|
||||
{' ,g$$$$$$$$$$$$$$$P. '}
|
||||
<span style={{ color: theme.colors.foreground }}>user</span>
|
||||
<span style={{ color: theme.colors.yellow }}>@</span>
|
||||
<span style={{ color: theme.colors.foreground }}>server</span>
|
||||
</div>
|
||||
<div style={{ color: theme.colors.cyan }}>
|
||||
{' ,g$$P" """Y$$."". '}
|
||||
<span style={{ color: theme.colors.foreground }}>-----------</span>
|
||||
</div>
|
||||
<div style={{ color: theme.colors.cyan }}>
|
||||
{` ,$$P' $$$. `}
|
||||
<span style={{ color: theme.colors.blue }}>OS</span>
|
||||
<span style={{ color: theme.colors.foreground }}>: Ubuntu 22.04 LTS</span>
|
||||
</div>
|
||||
<div style={{ color: theme.colors.cyan }}>
|
||||
{`'', $$P, ggs. $$b: `}
|
||||
<span style={{ color: theme.colors.blue }}>Kernel</span>
|
||||
<span style={{ color: theme.colors.foreground }}>: 5.15.0-generic</span>
|
||||
</div>
|
||||
<div style={{ color: theme.colors.cyan }}>
|
||||
{`d$$' ,$P"' . $$$ `}
|
||||
<span style={{ color: theme.colors.blue }}>Uptime</span>
|
||||
<span style={{ color: theme.colors.foreground }}>: 42 days, 3 hours</span>
|
||||
</div>
|
||||
<div style={{ color: theme.colors.cyan }}>
|
||||
{` $$P d$' , $$P `}
|
||||
<span style={{ color: theme.colors.blue }}>Shell</span>
|
||||
<span style={{ color: theme.colors.foreground }}>: bash 5.1.16</span>
|
||||
</div>
|
||||
<div style={{ color: theme.colors.cyan }}>
|
||||
{` $$: $$. - ,d$$' `}
|
||||
<span style={{ color: theme.colors.blue }}>Memory</span>
|
||||
<span style={{ color: theme.colors.foreground }}>: 4.2G / 16G (26%)</span>
|
||||
</div>
|
||||
<div> </div>
|
||||
{/* ANSI color palette preview row */}
|
||||
<div className="flex gap-0.5 mt-1">
|
||||
{[theme.colors.black, theme.colors.red, theme.colors.green, theme.colors.yellow,
|
||||
theme.colors.blue, theme.colors.magenta, theme.colors.cyan, theme.colors.white].map((c, i) => (
|
||||
<div key={i} className="w-4 h-3 rounded-sm" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-0.5">
|
||||
{[theme.colors.brightBlack, theme.colors.brightRed, theme.colors.brightGreen, theme.colors.brightYellow,
|
||||
theme.colors.brightBlue, theme.colors.brightMagenta, theme.colors.brightCyan, theme.colors.brightWhite].map((c, i) => (
|
||||
<div key={i} className="w-4 h-3 rounded-sm" style={{ backgroundColor: c }} />
|
||||
))}
|
||||
</div>
|
||||
<div> </div>
|
||||
<div>
|
||||
<span style={{ color: theme.colors.green }}>user@server</span>
|
||||
<span style={{ color: theme.colors.foreground }}>:</span>
|
||||
<span style={{ color: theme.colors.blue }}>~</span>
|
||||
<span style={{ color: theme.colors.foreground }}>$ </span>
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: theme.colors.cursor || theme.colors.foreground,
|
||||
color: theme.colors.background
|
||||
}}
|
||||
>▋</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
TerminalPreview.displayName = 'TerminalPreview';
|
||||
|
||||
const cloneTheme = (theme: TerminalTheme): TerminalTheme => ({
|
||||
...theme,
|
||||
colors: { ...theme.colors },
|
||||
isCustom: true,
|
||||
});
|
||||
|
||||
const serializeTheme = (theme: TerminalTheme): string => JSON.stringify(theme);
|
||||
|
||||
export const ThemeCustomizeModal: React.FC<ThemeCustomizeModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
currentThemeId,
|
||||
displayThemeId,
|
||||
currentFontFamilyId = 'menlo',
|
||||
currentFontSize = DEFAULT_FONT_SIZE,
|
||||
onThemeChange,
|
||||
onThemeReset,
|
||||
onFontFamilyChange,
|
||||
onFontSizeChange,
|
||||
onSave,
|
||||
onPreviewThemeChange,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const availableFonts = useAvailableFonts();
|
||||
const customThemes = useCustomThemes();
|
||||
const { addTheme, updateTheme, deleteTheme } = useCustomThemeActions();
|
||||
|
||||
const resolvedThemeId = currentThemeId ?? displayThemeId ?? TERMINAL_THEMES[0].id;
|
||||
const [activeTab, setActiveTab] = useState<TabType>('theme');
|
||||
const [selectedTheme, setSelectedTheme] = useState(resolvedThemeId);
|
||||
const [selectedFont, setSelectedFont] = useState(currentFontFamilyId);
|
||||
const [fontSize, setFontSize] = useState(currentFontSize);
|
||||
const [draftCustomThemes, setDraftCustomThemes] = useState<TerminalTheme[]>(() => customThemes.map(cloneTheme));
|
||||
|
||||
// Custom theme editor state
|
||||
const [editingTheme, setEditingTheme] = useState<TerminalTheme | null>(null);
|
||||
const [isNewTheme, setIsNewTheme] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Store original values when modal opens (for cancel/revert)
|
||||
const originalValuesRef = useRef({
|
||||
theme: currentThemeId,
|
||||
font: currentFontFamilyId,
|
||||
fontSize: currentFontSize,
|
||||
});
|
||||
const originalCustomThemesRef = useRef<TerminalTheme[]>([]);
|
||||
const wasOpenRef = useRef(false);
|
||||
|
||||
// Combine built-in + custom themes
|
||||
const allThemes = useMemo(
|
||||
() => [...TERMINAL_THEMES, ...draftCustomThemes],
|
||||
[draftCustomThemes]
|
||||
);
|
||||
|
||||
// Sync state when modal opens
|
||||
useEffect(() => {
|
||||
if (open && !wasOpenRef.current) {
|
||||
// Store original values for potential cancel
|
||||
originalValuesRef.current = {
|
||||
theme: currentThemeId,
|
||||
font: currentFontFamilyId,
|
||||
fontSize: currentFontSize,
|
||||
};
|
||||
originalCustomThemesRef.current = customThemes.map((theme) => ({
|
||||
...cloneTheme(theme),
|
||||
}));
|
||||
// Initialize selected values
|
||||
setSelectedTheme(resolvedThemeId);
|
||||
setSelectedFont(currentFontFamilyId);
|
||||
setFontSize(currentFontSize);
|
||||
setDraftCustomThemes(customThemes.map(cloneTheme));
|
||||
setEditingTheme(null);
|
||||
setIsNewTheme(false);
|
||||
}
|
||||
wasOpenRef.current = open;
|
||||
}, [open, currentThemeId, resolvedThemeId, currentFontFamilyId, currentFontSize, customThemes]);
|
||||
|
||||
const currentFont = useMemo(
|
||||
(): TerminalFont => availableFonts.find(f => f.id === selectedFont) || availableFonts[0],
|
||||
[selectedFont, availableFonts]
|
||||
);
|
||||
const currentTheme = useMemo(
|
||||
() => editingTheme || allThemes.find(t => t.id === selectedTheme) || TERMINAL_THEMES[0],
|
||||
[selectedTheme, allThemes, editingTheme]
|
||||
);
|
||||
const hiddenSelectedTheme = useMemo(
|
||||
() => (isUiMatchTerminalThemeId(selectedTheme)
|
||||
? TERMINAL_THEMES.find((theme) => theme.id === selectedTheme) || null
|
||||
: null),
|
||||
[selectedTheme]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onPreviewThemeChange?.(open ? currentTheme : null);
|
||||
}, [currentTheme, onPreviewThemeChange, open]);
|
||||
|
||||
// Handle theme selection - apply immediately for real-time preview
|
||||
const handleThemeSelect = useCallback((themeId: string) => {
|
||||
setSelectedTheme(themeId);
|
||||
setEditingTheme(null);
|
||||
onThemeChange?.(themeId); // Apply immediately
|
||||
}, [onThemeChange]);
|
||||
|
||||
// Handle font selection - apply immediately for real-time preview
|
||||
const handleFontSelect = useCallback((fontId: string) => {
|
||||
setSelectedFont(fontId);
|
||||
onFontFamilyChange?.(fontId); // Apply immediately
|
||||
}, [onFontFamilyChange]);
|
||||
|
||||
// Handle font size change - apply immediately for real-time preview
|
||||
const handleFontSizeChange = useCallback((delta: number) => {
|
||||
setFontSize(prev => {
|
||||
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, prev + delta));
|
||||
onFontSizeChange?.(newSize); // Apply immediately
|
||||
return newSize;
|
||||
});
|
||||
}, [onFontSizeChange]);
|
||||
|
||||
// ---- Custom Theme Actions ----
|
||||
|
||||
const handleNewTheme = useCallback(() => {
|
||||
// Clone current theme as starting point
|
||||
const base = allThemes.find(t => t.id === selectedTheme) || TERMINAL_THEMES[0];
|
||||
const newTheme: TerminalTheme = {
|
||||
...base,
|
||||
id: `custom-${Date.now()}`,
|
||||
name: `${base.name} (Custom)`,
|
||||
isCustom: true,
|
||||
colors: { ...base.colors },
|
||||
};
|
||||
setEditingTheme(newTheme);
|
||||
setIsNewTheme(true);
|
||||
}, [selectedTheme, allThemes]);
|
||||
|
||||
const handleImportFile = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleFileSelected = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const name = file.name.replace(/\.(itermcolors|xml)$/i, '');
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const xml = reader.result as string;
|
||||
const parsed = parseItermcolors(xml, name);
|
||||
if (parsed) {
|
||||
setDraftCustomThemes((prev) => [...prev, cloneTheme(parsed)]);
|
||||
setSelectedTheme(parsed.id);
|
||||
onThemeChange?.(parsed.id);
|
||||
setActiveTab('theme');
|
||||
} else {
|
||||
console.error('[ThemeCustomize] Failed to parse .itermcolors file:', file.name);
|
||||
window.alert(t('terminal.customTheme.importError') || 'Failed to parse the selected file. Please ensure it is a valid .itermcolors XML file.');
|
||||
}
|
||||
};
|
||||
reader.onerror = () => {
|
||||
console.error('[ThemeCustomize] Failed to read file:', file.name, reader.error);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
// Reset file input so the same file can be re-imported
|
||||
e.target.value = '';
|
||||
}, [onThemeChange, t]);
|
||||
|
||||
const handleEditTheme = useCallback((themeId: string) => {
|
||||
const theme = draftCustomThemes.find(t => t.id === themeId);
|
||||
if (theme) {
|
||||
setEditingTheme({ ...theme, colors: { ...theme.colors } });
|
||||
setIsNewTheme(false);
|
||||
setActiveTab('custom');
|
||||
}
|
||||
}, [draftCustomThemes]);
|
||||
|
||||
|
||||
const handleEditorBack = useCallback(() => {
|
||||
setEditingTheme(null);
|
||||
setIsNewTheme(false);
|
||||
}, []);
|
||||
|
||||
const handleEditorDelete = useCallback((themeId: string) => {
|
||||
setDraftCustomThemes((prev) => prev.filter((theme) => theme.id !== themeId));
|
||||
if (selectedTheme === themeId) {
|
||||
const originalThemeId = originalValuesRef.current.theme;
|
||||
const fallbackThemeId = originalThemeId && originalThemeId !== themeId
|
||||
? originalThemeId
|
||||
: (displayThemeId && displayThemeId !== themeId ? displayThemeId : USER_VISIBLE_TERMINAL_THEMES[0].id);
|
||||
setSelectedTheme(fallbackThemeId);
|
||||
if (originalThemeId == null && displayThemeId && displayThemeId !== themeId) {
|
||||
onThemeReset?.();
|
||||
} else {
|
||||
onThemeChange?.(fallbackThemeId);
|
||||
}
|
||||
}
|
||||
setEditingTheme(null);
|
||||
setIsNewTheme(false);
|
||||
}, [displayThemeId, onThemeChange, onThemeReset, selectedTheme]);
|
||||
|
||||
// Save: just close (changes are already applied)
|
||||
const handleSave = useCallback(() => {
|
||||
const originalThemes = originalCustomThemesRef.current;
|
||||
const originalMap = new Map(originalThemes.map((theme) => [theme.id, theme]));
|
||||
const draftMap = new Map(draftCustomThemes.map((theme) => [theme.id, theme]));
|
||||
|
||||
for (const [id, originalTheme] of originalMap) {
|
||||
if (!draftMap.has(id)) {
|
||||
deleteTheme(id);
|
||||
continue;
|
||||
}
|
||||
const nextTheme = draftMap.get(id)!;
|
||||
if (serializeTheme(originalTheme) !== serializeTheme(nextTheme)) {
|
||||
updateTheme(id, nextTheme);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, draftTheme] of draftMap) {
|
||||
if (!originalMap.has(id)) {
|
||||
addTheme(draftTheme);
|
||||
}
|
||||
}
|
||||
|
||||
onSave?.();
|
||||
onClose();
|
||||
}, [addTheme, deleteTheme, draftCustomThemes, onClose, onSave, updateTheme]);
|
||||
|
||||
// Cancel: revert to original values
|
||||
const handleCancel = useCallback(() => {
|
||||
const original = originalValuesRef.current;
|
||||
// Revert all changes
|
||||
if (original.theme) {
|
||||
onThemeChange?.(original.theme);
|
||||
} else {
|
||||
onThemeReset?.();
|
||||
}
|
||||
onFontFamilyChange?.(original.font);
|
||||
onFontSizeChange?.(original.fontSize);
|
||||
onClose();
|
||||
}, [onThemeChange, onThemeReset, onFontFamilyChange, onFontSizeChange, onClose]);
|
||||
|
||||
// Handle ESC key - same as cancel, but skip when child editor is open
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !editingTheme) handleCancel();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, handleCancel, editingTheme]);
|
||||
|
||||
// Handle backdrop click - same as cancel
|
||||
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) handleCancel();
|
||||
}, [handleCancel]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Separate built-in and custom themes for display in the theme list
|
||||
const builtinThemes = USER_VISIBLE_TERMINAL_THEMES;
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div
|
||||
className="w-[800px] h-[560px] bg-background border border-border rounded-2xl shadow-2xl flex flex-col overflow-hidden animate-in fade-in zoom-in-95 duration-200"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 shrink-0 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-primary/10">
|
||||
<Palette size={16} className="text-primary" />
|
||||
</div>
|
||||
<h2 className="text-sm font-semibold text-foreground">{t('terminal.themeModal.title')}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main Content - Left/Right Split */}
|
||||
<div className="flex-1 flex min-h-0">
|
||||
{/* Left Panel - List */}
|
||||
<div className="w-[280px] border-r border-border flex flex-col shrink-0">
|
||||
{/* Tab Bar */}
|
||||
<div className="flex p-2 gap-1 shrink-0 border-b border-border">
|
||||
<button
|
||||
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-all',
|
||||
activeTab === 'theme'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<Palette size={13} />
|
||||
{t('terminal.themeModal.tab.theme')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('font')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-all',
|
||||
activeTab === 'font'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<Type size={13} />
|
||||
{t('terminal.themeModal.tab.font')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('custom')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 px-2 py-2 rounded-lg text-xs font-medium transition-all',
|
||||
activeTab === 'custom'
|
||||
? 'bg-primary/15 text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<Sparkles size={13} />
|
||||
{t('terminal.themeModal.tab.custom')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List Content */}
|
||||
<>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2">
|
||||
{activeTab === 'theme' && (
|
||||
<div className="space-y-1">
|
||||
{hiddenSelectedTheme && (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2.5 mb-2">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1 font-semibold">
|
||||
{t('terminal.hiddenTheme.title')}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-foreground">{hiddenSelectedTheme.name}</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-1">
|
||||
{t('terminal.hiddenTheme.desc')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Built-in themes */}
|
||||
{builtinThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedTheme === theme.id && !editingTheme}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
{/* Custom themes section */}
|
||||
{draftCustomThemes.length > 0 && (
|
||||
<>
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-3 mb-1.5 px-1 font-semibold">
|
||||
{t('terminal.customTheme.section')}
|
||||
</div>
|
||||
{draftCustomThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedTheme === theme.id && !editingTheme}
|
||||
onSelect={handleThemeSelect}
|
||||
onEdit={handleEditTheme}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'font' && (
|
||||
<div className="space-y-1">
|
||||
{availableFonts.map(font => (
|
||||
<FontItem
|
||||
key={font.id}
|
||||
font={font}
|
||||
isSelected={selectedFont === font.id}
|
||||
onSelect={handleFontSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'custom' && !editingTheme && (
|
||||
<div className="space-y-2">
|
||||
{/* Actions */}
|
||||
<button
|
||||
onClick={handleNewTheme}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-md flex items-center justify-center bg-primary/10 text-primary">
|
||||
<Plus size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-foreground">{t('terminal.customTheme.new')}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('terminal.customTheme.newDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImportFile}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-left hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-md flex items-center justify-center bg-blue-500/10 text-blue-500">
|
||||
<Download size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-foreground">{t('terminal.customTheme.import')}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('terminal.customTheme.importDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".itermcolors"
|
||||
onChange={handleFileSelected}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Custom themes list */}
|
||||
{draftCustomThemes.length > 0 && (
|
||||
<>
|
||||
<div className="text-[9px] uppercase tracking-wider text-muted-foreground mt-3 mb-1 px-1 font-semibold">
|
||||
{t('terminal.customTheme.yourThemes')}
|
||||
</div>
|
||||
{draftCustomThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={selectedTheme === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
onEdit={handleEditTheme}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Font Size Control (only in font tab) */}
|
||||
{activeTab === 'font' && (
|
||||
<div className="p-3 border-t border-border shrink-0">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2 font-semibold">
|
||||
{t('terminal.themeModal.fontSize')}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 bg-muted/30 rounded-lg p-2">
|
||||
<button
|
||||
onClick={() => handleFontSizeChange(-1)}
|
||||
disabled={fontSize <= MIN_FONT_SIZE}
|
||||
className="w-8 h-8 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground hover:text-accent-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
|
||||
>
|
||||
<Minus size={14} />
|
||||
</button>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-xl font-bold text-foreground tabular-nums">{fontSize}</span>
|
||||
<span className="text-[10px] text-muted-foreground">px</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleFontSizeChange(1)}
|
||||
disabled={fontSize >= MAX_FONT_SIZE}
|
||||
className="w-8 h-8 rounded-md flex items-center justify-center bg-background hover:bg-accent text-foreground hover:text-accent-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors border border-border"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Large Preview */}
|
||||
<div className="flex-1 flex flex-col min-w-0 p-4">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-3 font-semibold">
|
||||
{t('terminal.themeModal.livePreview')}
|
||||
</div>
|
||||
<TerminalPreview theme={currentTheme} font={currentFont} fontSize={fontSize} />
|
||||
|
||||
{/* Info line */}
|
||||
<div className="mt-3 text-xs text-muted-foreground flex items-center justify-between">
|
||||
<span>
|
||||
{currentTheme.name} • {currentFont.name} • {fontSize}px
|
||||
</span>
|
||||
<span className="text-[10px] uppercase">
|
||||
{t('terminal.themeModal.themeType', { type: currentTheme.type })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex gap-3 px-5 py-3 shrink-0 border-t border-border bg-muted/20">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleCancel}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Use Portal to render at document root
|
||||
return (
|
||||
<>
|
||||
{createPortal(modalContent, document.body)}
|
||||
{editingTheme && (
|
||||
<CustomThemeModal
|
||||
open={!!editingTheme}
|
||||
theme={editingTheme}
|
||||
isNew={isNewTheme}
|
||||
onSave={(theme) => {
|
||||
setDraftCustomThemes((prev) => {
|
||||
if (isNewTheme) {
|
||||
return [...prev, cloneTheme(theme)];
|
||||
}
|
||||
return prev.map((entry) => entry.id === theme.id ? cloneTheme(theme) : entry);
|
||||
});
|
||||
if (isNewTheme) {
|
||||
setSelectedTheme(theme.id);
|
||||
onThemeChange?.(theme.id);
|
||||
} else {
|
||||
if (selectedTheme === theme.id) {
|
||||
onThemeChange?.(theme.id);
|
||||
}
|
||||
}
|
||||
setEditingTheme(null);
|
||||
setIsNewTheme(false);
|
||||
}}
|
||||
onDelete={isNewTheme ? undefined : handleEditorDelete}
|
||||
onCancel={handleEditorBack}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeCustomizeModal;
|
||||
22
components/terminal/ThemeSidePanel.test.ts
Normal file
22
components/terminal/ThemeSidePanel.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./ThemeSidePanel.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("theme side panel keeps theme selection visible while following app theme", () => {
|
||||
assert.doesNotMatch(source, /const \[activeTab, setActiveTab\] = useState<TabType>\(followAppTerminalTheme \? 'font' : 'theme'\)/);
|
||||
assert.doesNotMatch(source, /!\s*themeEditingLocked\s*&&\s*\(\s*<button[\s\S]*?terminal\.themeModal\.tab\.theme/);
|
||||
});
|
||||
|
||||
test("hidden selected theme uses the normal theme item row", () => {
|
||||
assert.match(source, /hiddenSelectedTheme && \(\s*<ThemeItem/);
|
||||
assert.doesNotMatch(source, /terminal\.hiddenTheme\.title[\s\S]*terminal\.hiddenTheme\.desc/);
|
||||
});
|
||||
|
||||
test("theme side panel tabs are vertically centered in the header", () => {
|
||||
assert.match(
|
||||
source,
|
||||
/TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1\.5 gap-0\.5 border-b'/,
|
||||
);
|
||||
});
|
||||
626
components/terminal/ThemeSidePanel.tsx
Normal file
626
components/terminal/ThemeSidePanel.tsx
Normal file
@@ -0,0 +1,626 @@
|
||||
/**
|
||||
* ThemeSidePanel - Theme/Font customization panel for the terminal side panel
|
||||
*
|
||||
* Adapted from ThemeCustomizeModal's left panel content.
|
||||
* No preview - the actual terminal behind serves as a live preview.
|
||||
* Changes apply in real-time.
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Check, Download, Minus, Palette, Pencil, Plus, Sparkles, Type } from 'lucide-react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useAvailableFonts } from '../../application/state/fontStore';
|
||||
import { TERMINAL_THEMES, TerminalThemeConfig, USER_VISIBLE_TERMINAL_THEMES, getBuiltinTerminalThemeById, isUiMatchTerminalThemeId } from '../../infrastructure/config/terminalThemes';
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE, TerminalFont } from '../../infrastructure/config/fonts';
|
||||
import { useCustomThemes, useCustomThemeActions } from '../../application/state/customThemeStore';
|
||||
import { terminalAppearanceThemePanelVars } from '../../infrastructure/theme/terminalAppearanceTokens';
|
||||
import { parseItermcolors } from '../../infrastructure/parsers/itermcolorsParser';
|
||||
import { CustomThemeModal } from './CustomThemeModal';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { TerminalTheme } from '../../domain/models';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { isFollowAppTerminalThemeId } from '../../domain/terminalAppearance';
|
||||
import { TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS } from '../terminalLayer/terminalSidePanelChrome';
|
||||
|
||||
type TabType = 'theme' | 'font' | 'custom';
|
||||
|
||||
// Memoized theme item component
|
||||
const ThemeItem = memo(({
|
||||
theme,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onEdit,
|
||||
}: {
|
||||
theme: TerminalThemeConfig;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onEdit?: (id: string) => void;
|
||||
}) => (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(theme.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(theme.id); } }}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2.5 px-3 py-2 text-left group cursor-pointer'
|
||||
)}
|
||||
style={{ backgroundColor: isSelected ? 'var(--terminal-panel-active)' : 'transparent' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected) e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
{/* Color swatch */}
|
||||
<div
|
||||
className="h-6 w-8 rounded-[4px] flex-shrink-0 flex flex-col justify-center items-start pl-1 gap-0.5 border-[0.5px]"
|
||||
style={{ backgroundColor: theme.colors.background, borderColor: 'var(--terminal-panel-border)' }}
|
||||
>
|
||||
<div className="h-0.5 w-2.5 rounded-full" style={{ backgroundColor: theme.colors.green }} />
|
||||
<div className="h-0.5 w-4 rounded-full" style={{ backgroundColor: theme.colors.blue }} />
|
||||
<div className="h-0.5 w-1.5 rounded-full" style={{ backgroundColor: theme.colors.yellow }} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium truncate">
|
||||
{theme.name}
|
||||
</div>
|
||||
<div className="text-[10px] capitalize" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{theme.type}
|
||||
{theme.isCustom && ' • custom'}
|
||||
</div>
|
||||
</div>
|
||||
{onEdit && (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => { e.stopPropagation(); onEdit(theme.id); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); e.preventDefault(); onEdit(theme.id); } }}
|
||||
className="w-5 h-5 rounded flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all"
|
||||
style={{ color: 'var(--terminal-panel-muted)' }}
|
||||
>
|
||||
<Pencil size={10} />
|
||||
</div>
|
||||
)}
|
||||
{isSelected && !onEdit && (
|
||||
<Check size={12} className="flex-shrink-0" style={{ color: 'var(--terminal-panel-fg)' }} />
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
ThemeItem.displayName = 'ThemeItem';
|
||||
|
||||
// Memoized font item component
|
||||
const FontItem = memo(({
|
||||
font,
|
||||
isSelected,
|
||||
onSelect
|
||||
}: {
|
||||
font: TerminalFont;
|
||||
isSelected: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={() => onSelect(font.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors'
|
||||
)}
|
||||
style={{ backgroundColor: isSelected ? 'var(--terminal-panel-active)' : 'transparent' }}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected) e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected) e.currentTarget.style.backgroundColor = 'transparent';
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-xs font-medium truncate"
|
||||
style={{ fontFamily: font.family }}
|
||||
>
|
||||
{font.name}
|
||||
</div>
|
||||
<div className="text-[10px] truncate" style={{ color: 'var(--terminal-panel-muted)' }}>{font.description}</div>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<Check size={12} className="flex-shrink-0" style={{ color: 'var(--terminal-panel-fg)' }} />
|
||||
)}
|
||||
</button>
|
||||
));
|
||||
FontItem.displayName = 'FontItem';
|
||||
|
||||
interface ThemeSidePanelProps {
|
||||
followAppTerminalTheme?: boolean;
|
||||
currentThemeId: string;
|
||||
globalThemeId: string;
|
||||
currentFontFamilyId: string;
|
||||
globalFontFamilyId: string;
|
||||
currentFontSize: number;
|
||||
currentFontWeight: number;
|
||||
canResetTheme?: boolean;
|
||||
canResetFontFamily?: boolean;
|
||||
canResetFontSize?: boolean;
|
||||
canResetFontWeight?: boolean;
|
||||
onThemeChange: (themeId: string) => void;
|
||||
onThemeReset?: () => void;
|
||||
onFontFamilyChange: (fontFamilyId: string) => void;
|
||||
onFontFamilyReset?: () => void;
|
||||
onFontSizeChange: (fontSize: number) => void;
|
||||
onFontSizeReset?: () => void;
|
||||
onFontWeightChange: (fontWeight: number) => void;
|
||||
onFontWeightReset?: () => void;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
const ThemeSidePanelInner: React.FC<ThemeSidePanelProps> = ({
|
||||
followAppTerminalTheme = false,
|
||||
currentThemeId,
|
||||
globalThemeId,
|
||||
currentFontFamilyId,
|
||||
globalFontFamilyId,
|
||||
currentFontSize,
|
||||
currentFontWeight,
|
||||
canResetTheme = false,
|
||||
canResetFontFamily = false,
|
||||
canResetFontSize = false,
|
||||
canResetFontWeight = false,
|
||||
onThemeChange,
|
||||
onThemeReset,
|
||||
onFontFamilyChange,
|
||||
onFontFamilyReset,
|
||||
onFontSizeChange,
|
||||
onFontSizeReset,
|
||||
onFontWeightChange,
|
||||
onFontWeightReset,
|
||||
isVisible = true,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const availableFonts = useAvailableFonts();
|
||||
const customThemes = useCustomThemes();
|
||||
const { addTheme, updateTheme, deleteTheme } = useCustomThemeActions();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('theme');
|
||||
const [editingTheme, setEditingTheme] = useState<TerminalTheme | null>(null);
|
||||
const [isNewTheme, setIsNewTheme] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (followAppTerminalTheme && activeTab === 'custom') {
|
||||
setActiveTab('theme');
|
||||
setEditingTheme(null);
|
||||
}
|
||||
}, [activeTab, followAppTerminalTheme]);
|
||||
|
||||
const customThemeById = useMemo(
|
||||
() => new Map(customThemes.map((theme) => [theme.id, theme])),
|
||||
[customThemes],
|
||||
);
|
||||
const fontById = useMemo(
|
||||
() => new Map(availableFonts.map((font) => [font.id, font])),
|
||||
[availableFonts],
|
||||
);
|
||||
const getThemeById = useCallback((themeId: string): TerminalTheme | undefined =>
|
||||
getBuiltinTerminalThemeById(themeId) ?? customThemeById.get(themeId),
|
||||
[customThemeById]);
|
||||
const globalTheme = useMemo(
|
||||
() => getThemeById(globalThemeId) || TERMINAL_THEMES[0],
|
||||
[getThemeById, globalThemeId],
|
||||
);
|
||||
const hiddenSelectedTheme = useMemo(
|
||||
() => (isUiMatchTerminalThemeId(currentThemeId)
|
||||
? getBuiltinTerminalThemeById(currentThemeId) || null
|
||||
: null),
|
||||
[currentThemeId],
|
||||
);
|
||||
const globalFont = useMemo(
|
||||
() => fontById.get(globalFontFamilyId) || availableFonts[0],
|
||||
[availableFonts, fontById, globalFontFamilyId],
|
||||
);
|
||||
const builtinThemes = useMemo(
|
||||
() => (followAppTerminalTheme
|
||||
? TERMINAL_THEMES.filter((theme) => isFollowAppTerminalThemeId(theme.id))
|
||||
: USER_VISIBLE_TERMINAL_THEMES),
|
||||
[followAppTerminalTheme],
|
||||
);
|
||||
|
||||
const handleThemeSelect = useCallback((themeId: string) => {
|
||||
setEditingTheme(null);
|
||||
onThemeChange(themeId);
|
||||
}, [onThemeChange]);
|
||||
|
||||
const handleFontSelect = useCallback((fontId: string) => {
|
||||
onFontFamilyChange(fontId);
|
||||
}, [onFontFamilyChange]);
|
||||
|
||||
const handleFontSizeChange = useCallback((delta: number) => {
|
||||
const newSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, currentFontSize + delta));
|
||||
onFontSizeChange(newSize);
|
||||
}, [currentFontSize, onFontSizeChange]);
|
||||
|
||||
const handleNewTheme = useCallback(() => {
|
||||
const base = getThemeById(currentThemeId) || TERMINAL_THEMES[0];
|
||||
const newTheme: TerminalTheme = {
|
||||
...base,
|
||||
id: `custom-${Date.now()}`,
|
||||
name: `${base.name} (Custom)`,
|
||||
isCustom: true,
|
||||
colors: { ...base.colors },
|
||||
};
|
||||
setEditingTheme(newTheme);
|
||||
setIsNewTheme(true);
|
||||
}, [currentThemeId, getThemeById]);
|
||||
|
||||
const handleImportFile = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleFileSelected = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const name = file.name.replace(/\.(itermcolors|xml)$/i, '');
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const xml = reader.result as string;
|
||||
const parsed = parseItermcolors(xml, name);
|
||||
if (parsed) {
|
||||
addTheme(parsed);
|
||||
onThemeChange(parsed.id);
|
||||
setActiveTab('theme');
|
||||
} else {
|
||||
window.alert(t('terminal.customTheme.importError') || 'Failed to parse the selected file.');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
}, [addTheme, onThemeChange, t]);
|
||||
|
||||
const handleEditTheme = useCallback((themeId: string) => {
|
||||
const theme = customThemeById.get(themeId);
|
||||
if (theme) {
|
||||
setEditingTheme({ ...theme, colors: { ...theme.colors } });
|
||||
setIsNewTheme(false);
|
||||
}
|
||||
}, [customThemeById]);
|
||||
|
||||
const handleEditorDelete = useCallback((themeId: string) => {
|
||||
deleteTheme(themeId);
|
||||
if (currentThemeId === themeId) {
|
||||
onThemeChange(TERMINAL_THEMES[0].id);
|
||||
}
|
||||
setEditingTheme(null);
|
||||
setIsNewTheme(false);
|
||||
}, [deleteTheme, currentThemeId, onThemeChange]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const footerThemeName = getThemeById(currentThemeId)?.name ?? currentThemeId;
|
||||
const footerFontName = fontById.get(currentFontFamilyId)?.name ?? currentFontFamilyId;
|
||||
const footerLabel = `${footerThemeName} • ${footerFontName} • ${currentFontSize}px • ${currentFontWeight}`;
|
||||
const panelVars = terminalAppearanceThemePanelVars;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="h-full flex flex-col overflow-hidden"
|
||||
style={{
|
||||
...panelVars,
|
||||
backgroundColor: 'var(--terminal-panel-bg)',
|
||||
color: 'var(--terminal-panel-fg)',
|
||||
borderColor: 'var(--terminal-panel-border)',
|
||||
}}
|
||||
>
|
||||
{/* Tab Bar */}
|
||||
<div
|
||||
className={cn(TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS, 'flex items-center px-1.5 gap-0.5 border-b')}
|
||||
style={{ borderColor: 'var(--terminal-panel-border)' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => { setActiveTab('theme'); setEditingTheme(null); }}
|
||||
className="h-6 flex-1 flex items-center justify-center gap-1 px-1.5 rounded-md text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
backgroundColor: activeTab === 'theme' ? 'var(--terminal-panel-active)' : 'transparent',
|
||||
color: activeTab === 'theme' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
|
||||
}}
|
||||
>
|
||||
<Palette size={12} />
|
||||
{t('terminal.themeModal.tab.theme')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('font')}
|
||||
className="h-6 flex-1 flex items-center justify-center gap-1 px-1.5 rounded-md text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
backgroundColor: activeTab === 'font' ? 'var(--terminal-panel-active)' : 'transparent',
|
||||
color: activeTab === 'font' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
|
||||
}}
|
||||
>
|
||||
<Type size={12} />
|
||||
{t('terminal.themeModal.tab.font')}
|
||||
</button>
|
||||
{!followAppTerminalTheme && (
|
||||
<button
|
||||
onClick={() => setActiveTab('custom')}
|
||||
className="h-6 flex-1 flex items-center justify-center gap-1 px-1.5 rounded-md text-[11px] font-medium transition-all"
|
||||
style={{
|
||||
backgroundColor: activeTab === 'custom' ? 'var(--terminal-panel-active)' : 'transparent',
|
||||
color: activeTab === 'custom' ? 'var(--terminal-panel-fg)' : 'var(--terminal-panel-muted)',
|
||||
}}
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
{t('terminal.themeModal.tab.custom')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List Content */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="py-1">
|
||||
{activeTab === 'theme' && (
|
||||
<div>
|
||||
{!followAppTerminalTheme && hiddenSelectedTheme && (
|
||||
<ThemeItem
|
||||
theme={hiddenSelectedTheme}
|
||||
isSelected={currentThemeId === hiddenSelectedTheme.id && !editingTheme}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
)}
|
||||
{builtinThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={currentThemeId === theme.id && !editingTheme}
|
||||
onSelect={handleThemeSelect}
|
||||
/>
|
||||
))}
|
||||
{!followAppTerminalTheme && customThemes.length > 0 && (
|
||||
<>
|
||||
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{t('terminal.customTheme.section')}
|
||||
</div>
|
||||
{customThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={currentThemeId === theme.id && !editingTheme}
|
||||
onSelect={handleThemeSelect}
|
||||
onEdit={handleEditTheme}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{canResetTheme && (
|
||||
<>
|
||||
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{t('terminal.themeModal.globalTheme')}
|
||||
</div>
|
||||
<ThemeItem
|
||||
theme={globalTheme}
|
||||
isSelected={!canResetTheme}
|
||||
onSelect={() => onThemeReset?.()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'font' && (
|
||||
<div>
|
||||
{availableFonts.map(font => (
|
||||
<FontItem
|
||||
key={font.id}
|
||||
font={font}
|
||||
isSelected={currentFontFamilyId === font.id}
|
||||
onSelect={handleFontSelect}
|
||||
/>
|
||||
))}
|
||||
{canResetFontFamily && (
|
||||
<>
|
||||
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{t('terminal.themeModal.globalFont')}
|
||||
</div>
|
||||
<FontItem
|
||||
font={globalFont}
|
||||
isSelected={!canResetFontFamily}
|
||||
onSelect={() => onFontFamilyReset?.()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'custom' && !editingTheme && (
|
||||
<div>
|
||||
<button
|
||||
onClick={handleNewTheme}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors"
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center shrink-0"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--terminal-panel-fg) 10%, transparent)',
|
||||
color: 'var(--terminal-panel-fg)',
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium">{t('terminal.customTheme.new')}</div>
|
||||
<div className="text-[10px]" style={{ color: 'var(--terminal-panel-muted)' }}>{t('terminal.customTheme.newDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImportFile}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors"
|
||||
onMouseEnter={(e) => { e.currentTarget.style.backgroundColor = 'var(--terminal-panel-hover)'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.backgroundColor = 'transparent'; }}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-md flex items-center justify-center bg-blue-500/10 text-blue-500 shrink-0">
|
||||
<Download size={12} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium">{t('terminal.customTheme.import')}</div>
|
||||
<div className="text-[10px]" style={{ color: 'var(--terminal-panel-muted)' }}>{t('terminal.customTheme.importDesc')}</div>
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".itermcolors"
|
||||
onChange={handleFileSelected}
|
||||
className="hidden"
|
||||
/>
|
||||
{customThemes.length > 0 && (
|
||||
<>
|
||||
<div className="text-[9px] uppercase tracking-wider mt-2 mb-1 px-1 font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{t('terminal.customTheme.yourThemes')}
|
||||
</div>
|
||||
{customThemes.map(theme => (
|
||||
<ThemeItem
|
||||
key={theme.id}
|
||||
theme={theme}
|
||||
isSelected={currentThemeId === theme.id}
|
||||
onSelect={handleThemeSelect}
|
||||
onEdit={handleEditTheme}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Font Size Control (only in font tab) */}
|
||||
{activeTab === 'font' && (
|
||||
<div className="p-2.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<div className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{t('terminal.themeModal.fontSize')}
|
||||
</div>
|
||||
{canResetFontSize && (
|
||||
<button
|
||||
onClick={onFontSizeReset}
|
||||
className="text-[10px] font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ color: 'var(--terminal-panel-fg)' }}
|
||||
>
|
||||
{t('common.useGlobal')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg p-1.5" style={{ backgroundColor: 'var(--terminal-panel-hover)' }}>
|
||||
<button
|
||||
onClick={() => handleFontSizeChange(-1)}
|
||||
disabled={currentFontSize <= MIN_FONT_SIZE}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed transition-colors border"
|
||||
style={{
|
||||
backgroundColor: 'var(--terminal-panel-bg)',
|
||||
color: 'var(--terminal-panel-fg)',
|
||||
borderColor: 'var(--terminal-panel-border)',
|
||||
}}
|
||||
>
|
||||
<Minus size={12} />
|
||||
</button>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-lg font-bold tabular-nums">{currentFontSize}</span>
|
||||
<span className="text-[9px]" style={{ color: 'var(--terminal-panel-muted)' }}>px</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleFontSizeChange(1)}
|
||||
disabled={currentFontSize >= MAX_FONT_SIZE}
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed transition-colors border"
|
||||
style={{
|
||||
backgroundColor: 'var(--terminal-panel-bg)',
|
||||
color: 'var(--terminal-panel-fg)',
|
||||
borderColor: 'var(--terminal-panel-border)',
|
||||
}}
|
||||
>
|
||||
<Plus size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Font Weight Control (only in font tab) */}
|
||||
{activeTab === 'font' && (
|
||||
<div className="p-2.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<div className="text-[9px] uppercase tracking-wider font-semibold" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{t('terminal.themeModal.fontWeight')}
|
||||
</div>
|
||||
{canResetFontWeight && (
|
||||
<button
|
||||
onClick={onFontWeightReset}
|
||||
className="text-[10px] font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ color: 'var(--terminal-panel-fg)' }}
|
||||
>
|
||||
{t('common.useGlobal')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-lg p-1.5" style={{ backgroundColor: 'var(--terminal-panel-hover)' }}>
|
||||
<Select
|
||||
value={String(currentFontWeight)}
|
||||
onValueChange={(v) => onFontWeightChange(Number(v))}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="flex-1 h-7 text-xs"
|
||||
style={{
|
||||
backgroundColor: 'var(--terminal-panel-bg)',
|
||||
color: 'var(--terminal-panel-fg)',
|
||||
borderColor: 'var(--terminal-panel-border)',
|
||||
}}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="100">100 Thin</SelectItem>
|
||||
<SelectItem value="200">200 ExtraLight</SelectItem>
|
||||
<SelectItem value="300">300 Light</SelectItem>
|
||||
<SelectItem value="400">400 Normal</SelectItem>
|
||||
<SelectItem value="500">500 Medium</SelectItem>
|
||||
<SelectItem value="600">600 SemiBold</SelectItem>
|
||||
<SelectItem value="700">700 Bold</SelectItem>
|
||||
<SelectItem value="800">800 ExtraBold</SelectItem>
|
||||
<SelectItem value="900">900 Black</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current selection info */}
|
||||
<div className="px-2.5 py-1.5 border-t shrink-0" style={{ borderColor: 'var(--terminal-panel-border)' }}>
|
||||
<div className="text-[9px] truncate" style={{ color: 'var(--terminal-panel-muted)' }}>
|
||||
{footerLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Theme Editor Modal */}
|
||||
{editingTheme && (
|
||||
<CustomThemeModal
|
||||
open={!!editingTheme}
|
||||
theme={editingTheme}
|
||||
isNew={isNewTheme}
|
||||
onSave={(theme) => {
|
||||
if (isNewTheme) {
|
||||
addTheme(theme);
|
||||
onThemeChange(theme.id);
|
||||
} else {
|
||||
updateTheme(theme.id, theme);
|
||||
if (currentThemeId === theme.id) {
|
||||
onThemeChange(theme.id);
|
||||
}
|
||||
}
|
||||
setEditingTheme(null);
|
||||
setIsNewTheme(false);
|
||||
}}
|
||||
onDelete={isNewTheme ? undefined : handleEditorDelete}
|
||||
onCancel={() => { setEditingTheme(null); setIsNewTheme(false); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ThemeSidePanel = memo(ThemeSidePanelInner);
|
||||
ThemeSidePanel.displayName = 'ThemeSidePanel';
|
||||
33
components/terminal/ZmodemOverwriteDialog.tsx
Normal file
33
components/terminal/ZmodemOverwriteDialog.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, { useState } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "../ui/dialog";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface Props {
|
||||
filename: string;
|
||||
onRespond: (action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => void;
|
||||
}
|
||||
|
||||
export const ZmodemOverwriteDialog: React.FC<Props> = ({ filename, onRespond }) => {
|
||||
const { t } = useI18n();
|
||||
const [applyToRest, setApplyToRest] = useState(false);
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => { if (!o) onRespond("cancel", false); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("zmodem.overwrite.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground break-all">{filename}</p>
|
||||
<label className="flex items-center gap-2 text-sm mt-2">
|
||||
<input type="checkbox" checked={applyToRest} onChange={(e) => setApplyToRest(e.target.checked)} />
|
||||
{t("zmodem.overwrite.applyToRest")}
|
||||
</label>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onRespond("cancel", applyToRest)}>{t("zmodem.overwrite.cancel")}</Button>
|
||||
<Button variant="outline" onClick={() => onRespond("skip", applyToRest)}>{t("zmodem.overwrite.skip")}</Button>
|
||||
<Button onClick={() => onRespond("overwrite", applyToRest)}>{t("zmodem.overwrite.overwrite")}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
100
components/terminal/ZmodemProgressIndicator.tsx
Normal file
100
components/terminal/ZmodemProgressIndicator.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { ArrowDownToLine, ArrowUpFromLine, X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
interface ZmodemProgressIndicatorProps {
|
||||
transferType: 'upload' | 'download' | null;
|
||||
filename: string | null;
|
||||
transferred: number;
|
||||
total: number;
|
||||
fileIndex: number;
|
||||
fileCount: number;
|
||||
finalizing: boolean;
|
||||
bytesPerSecond: number | null;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes <= 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSecond: number | null): string | null {
|
||||
if (!bytesPerSecond || bytesPerSecond <= 0) return null;
|
||||
return `${formatBytes(bytesPerSecond)}/s`;
|
||||
}
|
||||
|
||||
export const ZmodemProgressIndicator: React.FC<ZmodemProgressIndicatorProps> = ({
|
||||
transferType,
|
||||
filename,
|
||||
transferred,
|
||||
total,
|
||||
fileIndex,
|
||||
fileCount,
|
||||
finalizing,
|
||||
bytesPerSecond,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const percent = total > 0 ? Math.min(100, Math.round((transferred / total) * 100)) : 0;
|
||||
const Icon = transferType === 'upload' ? ArrowUpFromLine : ArrowDownToLine;
|
||||
const label = finalizing
|
||||
? t('zmodem.waitingForRemote')
|
||||
: transferType === 'upload'
|
||||
? t('zmodem.uploading')
|
||||
: t('zmodem.downloading');
|
||||
const fileInfo = fileCount > 0 ? ` (${fileIndex + 1}/${fileCount})` : '';
|
||||
const speed = formatSpeed(bytesPerSecond);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-3 py-2 rounded-lg shadow-lg backdrop-blur-sm min-w-[240px] max-w-[360px]"
|
||||
style={{
|
||||
backgroundColor: 'color-mix(in srgb, var(--terminal-ui-bg, #000000) 90%, transparent)',
|
||||
border: '1px solid color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 15%, var(--terminal-ui-bg, #000000))',
|
||||
color: 'var(--terminal-ui-fg, #ffffff)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0 opacity-60" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-xs font-medium truncate">
|
||||
{filename || label}{fileInfo}
|
||||
</span>
|
||||
<span className="text-[10px] opacity-60 flex-shrink-0">{percent}%</span>
|
||||
</div>
|
||||
<div className="w-full h-1 rounded-full overflow-hidden" style={{ backgroundColor: 'color-mix(in srgb, var(--terminal-ui-fg, #ffffff) 10%, transparent)' }}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-150"
|
||||
style={{
|
||||
width: `${percent}%`,
|
||||
backgroundColor: transferType === 'upload' ? '#3b82f6' : '#22c55e',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-[10px] opacity-50 mt-0.5">
|
||||
{finalizing
|
||||
? label
|
||||
: `${formatBytes(transferred)} / ${formatBytes(total)}${speed ? ` · ${speed}` : ''}`}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-shrink-0 p-1 rounded transition-colors hover:bg-white/10"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 opacity-60" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('zmodem.cancelTransfer')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
100
components/terminal/alignTerminalViewportScroll.test.ts
Normal file
100
components/terminal/alignTerminalViewportScroll.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { alignTerminalViewportScroll } from "./terminalHelpers";
|
||||
|
||||
type ViewportSpy = {
|
||||
scrollToLine: (line: number, disableSmoothScroll?: boolean) => void;
|
||||
_sync?: () => void;
|
||||
calls: Array<{ line: number; disableSmoothScroll?: boolean }>;
|
||||
};
|
||||
|
||||
const createTerm = (
|
||||
viewportY: number,
|
||||
viewport?: ViewportSpy | null,
|
||||
): XTerm => ({
|
||||
buffer: { active: { viewportY } },
|
||||
_core: viewport === null ? undefined : { _viewport: viewport },
|
||||
}) as unknown as XTerm;
|
||||
|
||||
test("alignTerminalViewportScroll snaps the viewport back to the buffer row without smooth scrolling", () => {
|
||||
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
|
||||
const viewport: ViewportSpy = {
|
||||
scrollToLine: (line, disableSmoothScroll) => {
|
||||
calls.push({ line, disableSmoothScroll });
|
||||
},
|
||||
calls,
|
||||
};
|
||||
const term = createTerm(247, viewport);
|
||||
|
||||
alignTerminalViewportScroll(term);
|
||||
|
||||
assert.deepEqual(calls, [{ line: 247, disableSmoothScroll: true }]);
|
||||
});
|
||||
|
||||
test("alignTerminalViewportScroll syncs viewport dimensions before setting the position", () => {
|
||||
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
|
||||
const order: string[] = [];
|
||||
const viewport: ViewportSpy = {
|
||||
_sync: () => {
|
||||
order.push("sync");
|
||||
},
|
||||
scrollToLine: (line, disableSmoothScroll) => {
|
||||
order.push("scrollToLine");
|
||||
calls.push({ line, disableSmoothScroll });
|
||||
},
|
||||
calls,
|
||||
};
|
||||
const term = createTerm(247, viewport);
|
||||
|
||||
alignTerminalViewportScroll(term);
|
||||
|
||||
assert.deepEqual(order, ["sync", "scrollToLine"]);
|
||||
assert.deepEqual(calls, [{ line: 247, disableSmoothScroll: true }]);
|
||||
});
|
||||
|
||||
test("alignTerminalViewportScroll survives a failing dimension sync", () => {
|
||||
const calls: Array<{ line: number; disableSmoothScroll?: boolean }> = [];
|
||||
const viewport: ViewportSpy = {
|
||||
_sync: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
scrollToLine: (line, disableSmoothScroll) => {
|
||||
calls.push({ line, disableSmoothScroll });
|
||||
},
|
||||
calls,
|
||||
};
|
||||
|
||||
assert.doesNotThrow(() => alignTerminalViewportScroll(createTerm(5, viewport)));
|
||||
assert.deepEqual(calls, [{ line: 5, disableSmoothScroll: true }]);
|
||||
});
|
||||
|
||||
test("alignTerminalViewportScroll is a no-op when the private viewport is unavailable", () => {
|
||||
const term = createTerm(10, null);
|
||||
|
||||
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
|
||||
});
|
||||
|
||||
test("alignTerminalViewportScroll survives a viewport without scrollToLine", () => {
|
||||
const term = {
|
||||
buffer: { active: { viewportY: 5 } },
|
||||
_core: { _viewport: {} },
|
||||
} as unknown as XTerm;
|
||||
|
||||
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
|
||||
});
|
||||
|
||||
test("alignTerminalViewportScroll swallows viewport errors instead of breaking the fit", () => {
|
||||
const term = {
|
||||
buffer: { active: { viewportY: 5 } },
|
||||
_core: {
|
||||
_viewport: {
|
||||
scrollToLine: () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as XTerm;
|
||||
|
||||
assert.doesNotThrow(() => alignTerminalViewportScroll(term));
|
||||
});
|
||||
78
components/terminal/appResumeWebglRecovery.test.ts
Normal file
78
components/terminal/appResumeWebglRecovery.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const assertRecoverTerminalOnAppResumeOrder = (source: string): void => {
|
||||
const handlerIndex = source.indexOf("const recoverTerminalOnAppResume = () => {");
|
||||
assert.notEqual(handlerIndex, -1, "recoverTerminalOnAppResume must exist");
|
||||
|
||||
const bodyStart = source.indexOf("{", handlerIndex);
|
||||
assert.notEqual(bodyStart, -1, "recoverTerminalOnAppResume must have a body");
|
||||
|
||||
let depth = 0;
|
||||
let bodyEnd = -1;
|
||||
for (let index = bodyStart; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
if (char === "{") depth += 1;
|
||||
if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
bodyEnd = index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.notEqual(bodyEnd, -1, "recoverTerminalOnAppResume body must close");
|
||||
|
||||
const handlerSource = source.slice(handlerIndex, bodyEnd);
|
||||
const flushIndex = handlerSource.indexOf("flushPendingTerminalWritesOnResume(term)");
|
||||
const scrollIndex = handlerSource.indexOf("flushPendingOutputScroll()");
|
||||
const recoveryIndex = handlerSource.indexOf("recoverWebglRendererOnAppResume()");
|
||||
const refitIndex = handlerSource.indexOf("scheduleLayoutRecoveryRefit([0, 100, 300])");
|
||||
|
||||
assert.notEqual(flushIndex, -1, "recoverTerminalOnAppResume must flush pending writes");
|
||||
assert.notEqual(scrollIndex, -1, "recoverTerminalOnAppResume must flush pending scroll");
|
||||
assert.notEqual(recoveryIndex, -1, "recoverTerminalOnAppResume must recover WebGL");
|
||||
assert.notEqual(refitIndex, -1, "recoverTerminalOnAppResume must schedule layout recovery");
|
||||
assert.ok(flushIndex < scrollIndex, "flush pending writes before pending scroll");
|
||||
assert.ok(scrollIndex < recoveryIndex, "flush pending scroll before WebGL recovery");
|
||||
assert.ok(recoveryIndex < refitIndex, "recover WebGL before layout recovery");
|
||||
};
|
||||
|
||||
test("app resume handlers flush backlog and recover the terminal renderer before refit", () => {
|
||||
const source = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
|
||||
const resumeEffectIndex = source.indexOf("const recoverWebglRendererOnAppResume = () => {");
|
||||
const resumeEffectEnd = source.indexOf("// Only register the snippet executor", resumeEffectIndex);
|
||||
const resumeEffectSource = source.slice(resumeEffectIndex, resumeEffectEnd);
|
||||
|
||||
assert.ok(resumeEffectIndex >= 0);
|
||||
assert.ok(resumeEffectEnd > resumeEffectIndex);
|
||||
assertRecoverTerminalOnAppResumeOrder(source);
|
||||
assert.match(
|
||||
resumeEffectSource,
|
||||
/const handleVisibilityChange = \(\) => \{\s*if \(document\.visibilityState !== 'visible'\) \{\s*syncOutputPressureVisibility\(\);\s*return;\s*\}\s*recoverTerminalOnAppResume\(\);\s*\};/,
|
||||
);
|
||||
assert.match(
|
||||
resumeEffectSource,
|
||||
/const handleWindowFocus = \(\) => \{\s*recoverTerminalOnAppResume\(\);\s*\};/,
|
||||
);
|
||||
assert.match(
|
||||
resumeEffectSource,
|
||||
/const unsubscribeWindowShown = terminalBackend\.onWindowShown\?\.\(\(\) => \{\s*recoverTerminalOnAppResume\(\);\s*\}\);/,
|
||||
);
|
||||
assert.doesNotMatch(resumeEffectSource, /\binWorkspace\b/);
|
||||
assert.doesNotMatch(resumeEffectSource, /\bisFocusMode\b/);
|
||||
assert.doesNotMatch(resumeEffectSource, /\bisFocused\b/);
|
||||
assert.doesNotMatch(source, /shouldRecoverOnAppResume/);
|
||||
});
|
||||
|
||||
test("useTerminalBackend exposes onWindowShown so the resume hook actually fires", () => {
|
||||
const source = readFileSync(
|
||||
new URL("../../application/state/useTerminalBackend.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /const onWindowShown = useCallback\(\(cb: \(\) => void\) => \{\s*const bridge = netcattyBridge\.get\(\);\s*return bridge\?\.onWindowShown\?\.\(cb\);/);
|
||||
const returnIndex = source.indexOf("useMemo(");
|
||||
assert.notEqual(returnIndex, -1);
|
||||
assert.match(source.slice(returnIndex), /onWindowShown,/);
|
||||
});
|
||||
613
components/terminal/autocomplete/AutocompletePopup.tsx
Normal file
613
components/terminal/autocomplete/AutocompletePopup.tsx
Normal file
@@ -0,0 +1,613 @@
|
||||
/**
|
||||
* Popup autocomplete menu for terminal.
|
||||
* Renders a floating list of completion suggestions near the terminal cursor.
|
||||
* Shows a detail tooltip for the selected/hovered item with full description.
|
||||
* Colors are derived from the active terminal theme for visual consistency.
|
||||
*/
|
||||
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState, memo } from "react";
|
||||
import { Folder, File, Link } from "lucide-react";
|
||||
import type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
|
||||
import {
|
||||
clampAutocompletePopupGeometry,
|
||||
computeAutocompletePopupPlacement,
|
||||
resolveAutocompleteClampViewport,
|
||||
} from "./terminalAutocompleteLayout";
|
||||
|
||||
export interface AutocompleteThemeColors {
|
||||
background: string;
|
||||
foreground: string;
|
||||
selection: string;
|
||||
cursor: string;
|
||||
}
|
||||
|
||||
export interface SubDirEntry {
|
||||
name: string;
|
||||
type: "file" | "directory" | "symlink";
|
||||
}
|
||||
|
||||
export interface SubDirPanel {
|
||||
entries: SubDirEntry[];
|
||||
selectedIndex: number;
|
||||
dirPath: string;
|
||||
}
|
||||
|
||||
interface AutocompletePopupProps {
|
||||
suggestions: CompletionSuggestion[];
|
||||
selectedIndex: number;
|
||||
/** Cursor anchor in viewport coordinates */
|
||||
anchorViewport: { left: number; top: number; bottom: number };
|
||||
visible: boolean;
|
||||
expandUpward?: boolean;
|
||||
themeColors?: AutocompleteThemeColors;
|
||||
onSelect: (suggestion: CompletionSuggestion) => void;
|
||||
maxHeight?: number;
|
||||
subDirPanels?: SubDirPanel[];
|
||||
subDirFocusLevel?: number;
|
||||
/** Reference to the terminal container for calculating fixed position */
|
||||
containerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
/** Ask the autocomplete controller to recompute cursor-relative popup position */
|
||||
onRequestReposition?: () => void;
|
||||
/** Offset from top of container to terminal content area (toolbar + search bar) */
|
||||
searchBarOffset?: number;
|
||||
/** Called when user clicks outside the popup to dismiss it */
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
const SOURCE_LABELS: Record<SuggestionSource, { label: string; fullLabel: string; fallbackColor: string }> = {
|
||||
history: { label: "h", fullLabel: "History", fallbackColor: "#FBBF24" },
|
||||
command: { label: "c", fullLabel: "Command", fallbackColor: "#34D399" },
|
||||
subcommand: { label: "s", fullLabel: "Subcommand", fallbackColor: "#60A5FA" },
|
||||
option: { label: "o", fullLabel: "Option", fallbackColor: "#A78BFA" },
|
||||
arg: { label: "a", fullLabel: "Argument", fallbackColor: "#F87171" },
|
||||
path: { label: "p", fullLabel: "Path", fallbackColor: "#38BDF8" },
|
||||
snippet: { label: "{}", fullLabel: "Snippet", fallbackColor: "#C084FC" },
|
||||
plugin: { label: "P", fullLabel: "Plugin", fallbackColor: "#F472B6" },
|
||||
};
|
||||
|
||||
/** Lucide icon components for file types in path suggestions */
|
||||
const FILE_TYPE_CONFIG: Record<string, { Icon: React.FC<{ size?: number; color?: string }>; color: string }> = {
|
||||
directory: { Icon: Folder, color: "#38BDF8" },
|
||||
file: { Icon: File, color: "#94A3B8" },
|
||||
symlink: { Icon: Link, color: "#A78BFA" },
|
||||
};
|
||||
|
||||
const FileTypeIcon: React.FC<{ fileType: string }> = ({ fileType }) => {
|
||||
const cfg = FILE_TYPE_CONFIG[fileType] ?? FILE_TYPE_CONFIG.file;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<cfg.Icon size={14} color={cfg.color} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
/** Chevron indicator for expandable directory items */
|
||||
const DirExpandIndicator: React.FC<{ visible: boolean; color: string }> = ({ visible, color }) => (
|
||||
<span style={{ fontSize: "10px", color, opacity: visible ? 0.6 : 0, flexShrink: 0, marginLeft: "2px" }}>›</span>
|
||||
);
|
||||
|
||||
/** Small key-cap badge shown on the selected row to hint the actionable key. */
|
||||
const KeyCap: React.FC<{ label: string; color: string; bg: string }> = ({ label, color, bg }) => (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
boxSizing: "border-box",
|
||||
height: "16px",
|
||||
minWidth: "16px",
|
||||
padding: "0 4px",
|
||||
fontSize: "11px",
|
||||
lineHeight: 1,
|
||||
borderRadius: "4px",
|
||||
border: `1px solid color-mix(in srgb, ${color} 35%, transparent)`,
|
||||
color: `color-mix(in srgb, ${color} 80%, ${bg})`,
|
||||
backgroundColor: `color-mix(in srgb, ${color} 12%, ${bg})`,
|
||||
flexShrink: 0,
|
||||
fontFamily:
|
||||
'ui-sans-serif, -apple-system, "Segoe UI", system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
|
||||
const AutocompletePopup: React.FC<AutocompletePopupProps> = ({
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
anchorViewport,
|
||||
visible,
|
||||
expandUpward = false,
|
||||
themeColors,
|
||||
onSelect,
|
||||
maxHeight = 240,
|
||||
subDirPanels = [],
|
||||
subDirFocusLevel = -1,
|
||||
containerRef,
|
||||
onRequestReposition,
|
||||
searchBarOffset: _searchBarOffset = 30,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
const [hoveredIndex, setHoveredIndex] = useState(-1);
|
||||
const [measuredSize, setMeasuredSize] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRef.current && listRef.current) {
|
||||
selectedRef.current.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "instant" as ScrollBehavior,
|
||||
});
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Reset hover when suggestions change
|
||||
useEffect(() => {
|
||||
setHoveredIndex(-1);
|
||||
}, [suggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !onRequestReposition) return;
|
||||
|
||||
let frameId = 0;
|
||||
const requestReposition = () => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
frameId = requestAnimationFrame(() => {
|
||||
frameId = 0;
|
||||
onRequestReposition();
|
||||
});
|
||||
};
|
||||
|
||||
const container = containerRef?.current;
|
||||
const observer = container ? new ResizeObserver(requestReposition) : null;
|
||||
observer?.observe(container);
|
||||
window.addEventListener("resize", requestReposition);
|
||||
|
||||
return () => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
observer?.disconnect();
|
||||
window.removeEventListener("resize", requestReposition);
|
||||
};
|
||||
}, [containerRef, onRequestReposition, visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !onRequestReposition || suggestions.length === 0) return;
|
||||
|
||||
let firstFrame = 0;
|
||||
let secondFrame = 0;
|
||||
firstFrame = requestAnimationFrame(() => {
|
||||
onRequestReposition();
|
||||
secondFrame = requestAnimationFrame(onRequestReposition);
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (firstFrame) cancelAnimationFrame(firstFrame);
|
||||
if (secondFrame) cancelAnimationFrame(secondFrame);
|
||||
};
|
||||
}, [onRequestReposition, subDirPanels.length, suggestions, visible]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!visible || suggestions.length === 0) {
|
||||
setMeasuredSize((current) => (current === null ? current : null));
|
||||
return;
|
||||
}
|
||||
|
||||
let frameId = 0;
|
||||
const measure = () => {
|
||||
const rect = wrapperRef.current?.getBoundingClientRect();
|
||||
if (!rect || rect.width <= 0 || rect.height <= 0) return;
|
||||
setMeasuredSize((current) => {
|
||||
if (
|
||||
current &&
|
||||
Math.abs(current.width - rect.width) < 0.5 &&
|
||||
Math.abs(current.height - rect.height) < 0.5
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
return { width: rect.width, height: rect.height };
|
||||
});
|
||||
};
|
||||
|
||||
measure();
|
||||
const wrapper = wrapperRef.current;
|
||||
const observer = wrapper ? new ResizeObserver(measure) : null;
|
||||
observer?.observe(wrapper);
|
||||
frameId = requestAnimationFrame(measure);
|
||||
|
||||
return () => {
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [hoveredIndex, selectedIndex, subDirPanels, suggestions, visible]);
|
||||
|
||||
// Dismiss popup when clicking outside
|
||||
useEffect(() => {
|
||||
if (!visible || !onDismiss) return;
|
||||
const handlePointerDown = (e: PointerEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
onDismiss();
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown);
|
||||
}, [visible, onDismiss]);
|
||||
|
||||
if (!visible || suggestions.length === 0) return null;
|
||||
|
||||
const bg = themeColors?.background ?? "#1e1e2e";
|
||||
const fg = themeColors?.foreground ?? "#cdd6f4";
|
||||
// Accent comes from the active terminal theme's cursor/selection colors,
|
||||
// which already track the user's accent setting (custom accent rewrites them
|
||||
// in applyCustomAccentToTerminalTheme). Falling back to selection, then a
|
||||
// neutral fg-mix, keeps older/partial theme payloads working. This is what
|
||||
// makes the popup's highlight follow the accent instead of a hardcoded blue.
|
||||
const accent = themeColors?.cursor || themeColors?.selection || fg;
|
||||
const popupBg = `color-mix(in srgb, ${bg} 92%, ${fg} 8%)`;
|
||||
const popupBorder = `color-mix(in srgb, ${bg} 75%, ${fg} 25%)`;
|
||||
const selectedBg = `color-mix(in srgb, ${accent} 26%, ${bg} 74%)`;
|
||||
const selectedBorderAccent = `color-mix(in srgb, ${accent} 60%, ${bg} 40%)`;
|
||||
const hoverBg = `color-mix(in srgb, ${accent} 12%, ${bg} 88%)`;
|
||||
const textColor = fg;
|
||||
const dimTextColor = `color-mix(in srgb, ${fg} 50%, ${bg} 50%)`;
|
||||
|
||||
// Determine which item to show the detail tooltip for
|
||||
const detailIndex = hoveredIndex >= 0 ? hoveredIndex : selectedIndex;
|
||||
const detailItem = detailIndex >= 0 ? suggestions[detailIndex] : null;
|
||||
const showDetail = detailItem?.description && detailItem.description.length > 0;
|
||||
|
||||
// Whether ANY item in the current set can open the detail tooltip (non-path
|
||||
// row with a description). Placement reserves space from this set-level flag
|
||||
// rather than the hovered item, so moving the mouse between rows can't change
|
||||
// totalWidth/height and shift the popup out from under the pointer.
|
||||
const setMayShowDetailPanel = suggestions.some(
|
||||
(s) => s.source !== "path" && Boolean(s.description && s.description.length > 0),
|
||||
);
|
||||
|
||||
const fixedLeft = anchorViewport.left;
|
||||
const fixedLineTop = anchorViewport.top;
|
||||
const fixedLineBottom = anchorViewport.bottom;
|
||||
|
||||
const viewportPadding = 8;
|
||||
const anchorGap = 8;
|
||||
const clampViewport = resolveAutocompleteClampViewport(containerRef?.current ?? null);
|
||||
const estimatedPopupHeight = Math.min(maxHeight, suggestions.length * 28 + 8);
|
||||
// Reserve the detail height for the whole set (not the hovered row) so the
|
||||
// chosen direction/height stays stable while hovering.
|
||||
const estimatedDetailHeight = setMayShowDetailPanel ? 96 : 0;
|
||||
const desiredContentHeight = Math.max(estimatedPopupHeight, estimatedDetailHeight);
|
||||
|
||||
// Total horizontal extent so the WHOLE assembly is clamped inside the
|
||||
// viewport — not just the main list. Mirrors the rendered maxWidths:
|
||||
// main list (400) + each cascading sub-dir panel (240) + the detail
|
||||
// tooltip (280), separated by the flex gap (4). Without this, expanding a
|
||||
// directory near the right edge pushed the sub-panels off-screen (#1202).
|
||||
const FLEX_GAP = 4;
|
||||
const MAIN_LIST_MAX_WIDTH = 400;
|
||||
const SUBDIR_PANEL_MAX_WIDTH = 240;
|
||||
const DETAIL_PANEL_MAX_WIDTH = 280;
|
||||
const totalWidth =
|
||||
MAIN_LIST_MAX_WIDTH +
|
||||
subDirPanels.length * (FLEX_GAP + SUBDIR_PANEL_MAX_WIDTH) +
|
||||
(setMayShowDetailPanel ? FLEX_GAP + DETAIL_PANEL_MAX_WIDTH : 0);
|
||||
const clampWidth =
|
||||
MAIN_LIST_MAX_WIDTH +
|
||||
subDirPanels.length * (FLEX_GAP + SUBDIR_PANEL_MAX_WIDTH);
|
||||
|
||||
const placement = computeAutocompletePopupPlacement({
|
||||
anchorTop: fixedLineTop,
|
||||
anchorBottom: fixedLineBottom,
|
||||
anchorLeft: fixedLeft,
|
||||
viewportWidth: clampViewport.width,
|
||||
viewportHeight: clampViewport.height,
|
||||
clampViewport,
|
||||
desiredHeight: desiredContentHeight,
|
||||
totalWidth,
|
||||
clampWidth,
|
||||
maxHeight,
|
||||
anchorGap,
|
||||
viewportPadding,
|
||||
expandUpwardHint: expandUpward,
|
||||
forceExpandUpward: expandUpward,
|
||||
});
|
||||
const renderUpward = placement.renderUpward;
|
||||
const effectiveMaxHeight = placement.maxHeight;
|
||||
const anchoredTop = placement.top;
|
||||
const clampedLeft = placement.left;
|
||||
const finalGeometry = measuredSize
|
||||
? clampAutocompletePopupGeometry({
|
||||
left: clampedLeft,
|
||||
top: anchoredTop,
|
||||
width: measuredSize.width,
|
||||
height: measuredSize.height,
|
||||
clampViewport,
|
||||
viewportPadding,
|
||||
})
|
||||
: { left: clampedLeft, top: anchoredTop };
|
||||
|
||||
const sharedBoxStyle = {
|
||||
// border-box so each panel's maxWidth is its true outer width (padding +
|
||||
// border included). The horizontal clamp's totalWidth sums these maxWidths,
|
||||
// so this keeps the off-screen math exact even for the padded detail panel.
|
||||
boxSizing: "border-box" as const,
|
||||
backgroundColor: popupBg,
|
||||
border: `1px solid ${popupBorder}`,
|
||||
borderRadius: "6px",
|
||||
boxShadow: renderUpward
|
||||
? "0 -2px 6px rgba(0, 0, 0, 0.15)"
|
||||
: "0 2px 6px rgba(0, 0, 0, 0.15)",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "13px",
|
||||
color: textColor,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: `${finalGeometry.left}px`,
|
||||
top: `${finalGeometry.top}px`,
|
||||
zIndex: 10000,
|
||||
display: "flex",
|
||||
alignItems: renderUpward ? "flex-end" : "flex-start",
|
||||
gap: "4px",
|
||||
pointerEvents: "auto", // Re-enable on popup itself (parent is pointer-events-none)
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{/* Main suggestion list */}
|
||||
<div
|
||||
ref={listRef}
|
||||
className="xterm-autocomplete-popup"
|
||||
style={{
|
||||
...sharedBoxStyle,
|
||||
maxHeight: `${effectiveMaxHeight}px`,
|
||||
minWidth: "180px",
|
||||
maxWidth: "400px",
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: "4px 0",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{suggestions.map((suggestion, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const isHovered = index === hoveredIndex;
|
||||
const sourceInfo = SOURCE_LABELS[suggestion.source];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${suggestion.text}-${index}`}
|
||||
ref={isSelected ? selectedRef : undefined}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "5px 10px",
|
||||
cursor: "pointer",
|
||||
backgroundColor: isSelected ? selectedBg : isHovered ? hoverBg : "transparent",
|
||||
// Accent rail on the active row so the highlight reads as the
|
||||
// theme accent. Inset shadow avoids shifting row layout.
|
||||
boxShadow: isSelected ? `inset 2px 0 0 0 ${selectedBorderAccent}` : undefined,
|
||||
gap: "8px",
|
||||
lineHeight: "1.4",
|
||||
}}
|
||||
onMouseEnter={() => setHoveredIndex(index)}
|
||||
onMouseLeave={() => setHoveredIndex(-1)}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect(suggestion);
|
||||
}}
|
||||
>
|
||||
{/* Source / file type indicator */}
|
||||
{suggestion.source === "path" && suggestion.fileType ? (
|
||||
<FileTypeIcon fileType={suggestion.fileType} />
|
||||
) : (
|
||||
<span
|
||||
role="img"
|
||||
aria-label={sourceInfo.fullLabel}
|
||||
title={sourceInfo.fullLabel}
|
||||
style={{
|
||||
width: "18px",
|
||||
height: "18px",
|
||||
borderRadius: "3px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: "10px",
|
||||
fontWeight: 600,
|
||||
color: sourceInfo.fallbackColor,
|
||||
backgroundColor: `${sourceInfo.fallbackColor}15`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{sourceInfo.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Command text */}
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
color: textColor,
|
||||
fontWeight: isSelected ? 500 : 400,
|
||||
}}
|
||||
>
|
||||
{suggestion.displayText}
|
||||
</span>
|
||||
|
||||
{/* Inline description (truncated). Snippets show only their label
|
||||
in the row — the full command lives in the detail preview. */}
|
||||
{suggestion.source !== "snippet" && suggestion.description && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: dimTextColor,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
maxWidth: "160px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{suggestion.description}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Frequency badge for history */}
|
||||
{suggestion.frequency && suggestion.frequency > 1 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: "10px",
|
||||
color: dimTextColor,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
×{suggestion.frequency}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Expand indicator for directories */}
|
||||
{suggestion.source === "path" && suggestion.fileType === "directory" && (
|
||||
<DirExpandIndicator visible={isSelected || isHovered} color={dimTextColor} />
|
||||
)}
|
||||
|
||||
{/* Key hint on the selected row: → expands directories, ↵ runs. */}
|
||||
{isSelected && (
|
||||
<span style={{ display: "flex", gap: "3px", marginLeft: "4px", flexShrink: 0 }}>
|
||||
{suggestion.source === "path" && suggestion.fileType === "directory" && (
|
||||
<KeyCap label="→" color={dimTextColor} bg={popupBg} />
|
||||
)}
|
||||
<KeyCap label="⏎" color={dimTextColor} bg={popupBg} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Cascading sub-directory panels */}
|
||||
{subDirPanels.map((panel, level) => (
|
||||
<div
|
||||
key={panel.dirPath}
|
||||
style={{
|
||||
...sharedBoxStyle,
|
||||
maxHeight: `${effectiveMaxHeight}px`,
|
||||
minWidth: "150px",
|
||||
maxWidth: "240px",
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: "4px 0",
|
||||
userSelect: "none",
|
||||
alignSelf: "flex-start",
|
||||
}}
|
||||
>
|
||||
{panel.entries.map((entry, idx) => {
|
||||
const isFocused = level === subDirFocusLevel;
|
||||
const isSubSelected = isFocused && idx === panel.selectedIndex;
|
||||
return (
|
||||
<div
|
||||
key={entry.name}
|
||||
ref={isSubSelected ? (el) => { el?.scrollIntoView({ block: "nearest" }); } : undefined}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "4px 10px",
|
||||
cursor: "pointer",
|
||||
backgroundColor: isSubSelected ? selectedBg
|
||||
: (idx === panel.selectedIndex && level < subDirFocusLevel) ? hoverBg
|
||||
: "transparent",
|
||||
boxShadow: isSubSelected ? `inset 2px 0 0 0 ${selectedBorderAccent}` : undefined,
|
||||
gap: "8px",
|
||||
lineHeight: "1.4",
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<FileTypeIcon fileType={entry.type} />
|
||||
<span style={{
|
||||
flex: 1, overflow: "hidden", textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap", color: textColor,
|
||||
}}>
|
||||
{entry.name}{entry.type === "directory" ? "/" : ""}
|
||||
</span>
|
||||
{entry.type === "directory" && (
|
||||
<DirExpandIndicator visible={isSubSelected || (idx === panel.selectedIndex && level < subDirFocusLevel)} color={dimTextColor} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Detail tooltip panel — shows full description for non-path items */}
|
||||
{showDetail && detailItem && detailItem.source !== "path" && (
|
||||
<div
|
||||
style={{
|
||||
...sharedBoxStyle,
|
||||
padding: "10px 12px",
|
||||
maxWidth: "280px",
|
||||
minWidth: "160px",
|
||||
// Bound the tooltip too: a long multi-line snippet description must
|
||||
// scroll, not push the panel past the viewport edge (#1202).
|
||||
maxHeight: `${effectiveMaxHeight}px`,
|
||||
overflowY: "auto",
|
||||
alignSelf: renderUpward ? "flex-end" : "flex-start",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "6px" }}>
|
||||
<span style={{ fontWeight: 600, fontSize: "13px" }}>{detailItem.displayText}</span>
|
||||
<span style={{
|
||||
fontSize: "10px",
|
||||
color: SOURCE_LABELS[detailItem.source].fallbackColor,
|
||||
padding: "1px 5px",
|
||||
borderRadius: "3px",
|
||||
backgroundColor: `${SOURCE_LABELS[detailItem.source].fallbackColor}15`,
|
||||
}}>
|
||||
{SOURCE_LABELS[detailItem.source].fullLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", color: dimTextColor, lineHeight: "1.5", wordBreak: "break-word" }}>
|
||||
{detailItem.source === "snippet" ? (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
wordBreak: "break-word",
|
||||
fontFamily: "var(--terminal-font, monospace)",
|
||||
fontSize: "11px",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{detailItem.description}
|
||||
</pre>
|
||||
) : (
|
||||
detailItem.description
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(AutocompletePopup);
|
||||
537
components/terminal/autocomplete/GhostTextAddon.ts
Normal file
537
components/terminal/autocomplete/GhostTextAddon.ts
Normal file
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* Ghost Text addon for xterm.js.
|
||||
* Renders inline suggestion text after the cursor in a dimmed style,
|
||||
* similar to fish shell's autosuggestions.
|
||||
*
|
||||
* Uses a CSS overlay positioned relative to the terminal cursor,
|
||||
* avoiding modification of the terminal buffer.
|
||||
*/
|
||||
|
||||
import type { Terminal as XTerm, IDisposable } from "@xterm/xterm";
|
||||
import { getXTermCellDimensions, invalidateCellDimensionCache } from "./xtermUtils";
|
||||
import { lineHasUntrackedTrailingInput } from "./ghostTextConsistency";
|
||||
import { stringCellWidth } from "./terminalStringCellWidth";
|
||||
|
||||
function commonPrefixLength(a: string, b: string): number {
|
||||
const max = Math.min(a.length, b.length);
|
||||
let i = 0;
|
||||
while (i < max && a[i] === b[i]) i += 1;
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Longest prefix of `input` that is already a suffix of `beforeCursor`. */
|
||||
function echoedInputPrefixLength(beforeCursor: string, input: string): number {
|
||||
let n = Math.min(beforeCursor.length, input.length);
|
||||
while (n > 0 && !beforeCursor.endsWith(input.slice(0, n))) {
|
||||
n -= 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
function hasVisibleGhostPrefix(ghostText: string, afterCursor: string): boolean {
|
||||
if (!ghostText || !afterCursor) return false;
|
||||
const visibleAfterCursor = afterCursor.trimEnd();
|
||||
const overlap = commonPrefixLength(ghostText, visibleAfterCursor);
|
||||
if (overlap <= 0) return false;
|
||||
if (ghostText.slice(0, overlap).trim().length === 0) return false;
|
||||
return (
|
||||
overlap === ghostText.length ||
|
||||
overlap === visibleAfterCursor.length ||
|
||||
afterCursor[overlap] === " "
|
||||
);
|
||||
}
|
||||
|
||||
type BufferLineLike = {
|
||||
isWrapped?: boolean;
|
||||
translateToString?: (
|
||||
trimRight?: boolean,
|
||||
startColumn?: number,
|
||||
endColumn?: number,
|
||||
) => string;
|
||||
};
|
||||
|
||||
type ActiveBufferLike = {
|
||||
baseY: number;
|
||||
cursorY: number;
|
||||
cursorX: number;
|
||||
getLine?: (y: number) => BufferLineLike | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Text before the cursor across wrapped physical rows. `getLine` only returns
|
||||
* one row, so a wrapped command's current row cannot end with the full
|
||||
* `currentInput` — callers must reconstruct the logical line or they will
|
||||
* treat already-echoed text as unechoed.
|
||||
*/
|
||||
function readBeforeCursorAcrossWraps(
|
||||
buf: ActiveBufferLike,
|
||||
cols: number,
|
||||
): string | null {
|
||||
if (typeof buf.getLine !== "function") return null;
|
||||
const absY = buf.baseY + buf.cursorY;
|
||||
let line = buf.getLine(absY);
|
||||
if (!line || typeof line.translateToString !== "function") return null;
|
||||
|
||||
// cursorX is a cell column, not a UTF-16 offset — slice() breaks on
|
||||
// wide / multi-code-unit graphemes (emoji prompts, CJK).
|
||||
let beforeCursor = line.translateToString(false, 0, buf.cursorX);
|
||||
let y = absY;
|
||||
while (line.isWrapped && y > 0) {
|
||||
y -= 1;
|
||||
line = buf.getLine(y);
|
||||
if (!line || typeof line.translateToString !== "function") break;
|
||||
// Keep wrap seams aligned with the terminal width (do not trimRight).
|
||||
const rowCols = cols > 0 ? cols : undefined;
|
||||
const rowText = rowCols === undefined
|
||||
? line.translateToString(false)
|
||||
: line.translateToString(false, 0, rowCols);
|
||||
beforeCursor = rowText + beforeCursor;
|
||||
}
|
||||
return beforeCursor;
|
||||
}
|
||||
|
||||
export class GhostTextAddon implements IDisposable {
|
||||
private term: XTerm | null = null;
|
||||
private ghostElement: HTMLSpanElement | null = null;
|
||||
private hintElement: HTMLSpanElement | null = null;
|
||||
private hintActive = false;
|
||||
private containerElement: HTMLDivElement | null = null;
|
||||
private currentSuggestion: string = "";
|
||||
private currentInput: string = "";
|
||||
/** Cursor column captured at show() time — the anchor the ghost was painted from. */
|
||||
private anchorCursorX = 0;
|
||||
/** Cursor row captured at show() time. */
|
||||
private anchorCursorY = 0;
|
||||
/** Length of currentInput at show() time — lets adjustToInput shift left
|
||||
* by (newInput.length - anchorInputLength) cells without having to
|
||||
* re-read xterm's cursorX (which hasn't advanced yet at keystroke time). */
|
||||
private anchorInputLength = 0;
|
||||
private disposed = false;
|
||||
private disposables: IDisposable[] = [];
|
||||
private lastLeft = -1;
|
||||
private lastTop = -1;
|
||||
|
||||
activate(term: XTerm): void {
|
||||
this.term = term;
|
||||
|
||||
const termElement = term.element;
|
||||
if (!termElement) return;
|
||||
|
||||
this.containerElement = document.createElement("div");
|
||||
this.containerElement.className = "xterm-ghost-text-container";
|
||||
Object.assign(this.containerElement.style, {
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
pointerEvents: "none",
|
||||
overflow: "hidden",
|
||||
// Sit above xterm's canvas — xterm's default renderer paints its
|
||||
// theme.background across every cell including empty ones, so a
|
||||
// ghost placed beneath the canvas would be completely occluded.
|
||||
zIndex: "1",
|
||||
});
|
||||
|
||||
this.ghostElement = document.createElement("span");
|
||||
this.ghostElement.className = "xterm-ghost-text";
|
||||
Object.assign(this.ghostElement.style, {
|
||||
position: "absolute",
|
||||
opacity: "0.4",
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "pre",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "inherit",
|
||||
lineHeight: "inherit",
|
||||
color: "inherit",
|
||||
display: "none",
|
||||
});
|
||||
|
||||
this.containerElement.appendChild(this.ghostElement);
|
||||
|
||||
// Read-only inline hint (e.g. sudo "press Enter to paste password"). Shown
|
||||
// independently of autocomplete suggestions and never accepted as input.
|
||||
this.hintElement = document.createElement("span");
|
||||
this.hintElement.className = "xterm-inline-hint";
|
||||
Object.assign(this.hintElement.style, {
|
||||
position: "absolute",
|
||||
opacity: "0.4",
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "pre",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "inherit",
|
||||
lineHeight: "inherit",
|
||||
color: "inherit",
|
||||
display: "none",
|
||||
});
|
||||
this.containerElement.appendChild(this.hintElement);
|
||||
|
||||
const screenEl = termElement.querySelector(".xterm-screen");
|
||||
if (screenEl) {
|
||||
screenEl.appendChild(this.containerElement);
|
||||
} else {
|
||||
termElement.appendChild(this.containerElement);
|
||||
}
|
||||
|
||||
this.disposables.push(
|
||||
term.onRender(() => {
|
||||
if (this.hintActive) this.updateHintPosition();
|
||||
if (!this.isVisible()) return;
|
||||
// Fail-safe: if the device echoed input we didn't track (some bastion
|
||||
// hosts / network OS, #1013/#1060), hide rather than draw the ghost
|
||||
// over already-visible text. Done here (post-echo render) rather than
|
||||
// in show()/adjustToInput so it never fights the keystroke-time path.
|
||||
if (this.realLineHasUntrackedInput()) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
this.updatePosition();
|
||||
}),
|
||||
);
|
||||
|
||||
// Invalidate cell dimension cache on resize so measurements stay
|
||||
// accurate, and force a pixel-coord recompute on the next render —
|
||||
// otherwise the lastLeft/lastTop short-circuit in updatePosition
|
||||
// would keep the ghost at stale pixel coordinates until the user
|
||||
// typed again.
|
||||
this.disposables.push(
|
||||
term.onResize(() => {
|
||||
invalidateCellDimensionCache();
|
||||
this.lastLeft = -1;
|
||||
this.lastTop = -1;
|
||||
if (this.isVisible()) this.updatePosition();
|
||||
if (this.hintActive) this.updateHintPosition();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show ghost text suggestion.
|
||||
* @param fullSuggestion The complete suggested command
|
||||
* @param currentInput The text the user has typed so far
|
||||
*/
|
||||
show(fullSuggestion: string, currentInput: string): void {
|
||||
if (this.disposed || !this.ghostElement || !this.term) return;
|
||||
|
||||
const ghostText = fullSuggestion.startsWith(currentInput)
|
||||
? fullSuggestion.substring(currentInput.length)
|
||||
: "";
|
||||
|
||||
if (!ghostText) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentSuggestion = fullSuggestion;
|
||||
this.currentInput = currentInput;
|
||||
const buf = this.term.buffer.active;
|
||||
const liveX = buf.cursorX;
|
||||
// When show() runs before the shell echoes `currentInput` (CJK IME /
|
||||
// high-latency SSH), live cursorX is still at the prompt. Advance the
|
||||
// anchor by the pending input's cell width so the ghost sits after it
|
||||
// instead of painting over it. Skip the probe when getLine is unavailable
|
||||
// (unit fakes) so those tests keep the legacy "cursor already at end"
|
||||
// contract.
|
||||
let anchorX = liveX;
|
||||
if (
|
||||
currentInput.length > 0 &&
|
||||
typeof buf.getLine === "function"
|
||||
) {
|
||||
const beforeCursor = readBeforeCursorAcrossWraps(
|
||||
buf as ActiveBufferLike,
|
||||
this.term.cols,
|
||||
);
|
||||
if (beforeCursor !== null && !beforeCursor.endsWith(currentInput)) {
|
||||
// Shell may have echoed only a prefix (e.g. "$ doc" while
|
||||
// currentInput is "docker"). Advance by the unechoed suffix only —
|
||||
// adding the full input width on top of a partially-advanced liveX
|
||||
// overshoots and Math.max self-heal cannot move the ghost left.
|
||||
const unechoed = currentInput.slice(
|
||||
echoedInputPrefixLength(beforeCursor, currentInput),
|
||||
);
|
||||
anchorX = liveX + stringCellWidth(unechoed, this.term);
|
||||
}
|
||||
}
|
||||
this.anchorCursorX = anchorX;
|
||||
this.anchorCursorY = buf.cursorY;
|
||||
this.anchorInputLength = currentInput.length;
|
||||
// Force position recalc since the text also changed.
|
||||
this.lastLeft = -1;
|
||||
this.lastTop = -1;
|
||||
|
||||
this.updatePosition();
|
||||
this.ghostElement.textContent = ghostText;
|
||||
this.ghostElement.style.display = "block";
|
||||
// Set font properties once per show (not per frame in updatePosition)
|
||||
this.ghostElement.style.fontSize = `${this.term.options.fontSize}px`;
|
||||
this.ghostElement.style.fontFamily = this.term.options.fontFamily || "inherit";
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
if (this.ghostElement) {
|
||||
this.ghostElement.style.display = "none";
|
||||
this.ghostElement.textContent = "";
|
||||
}
|
||||
this.currentSuggestion = "";
|
||||
this.currentInput = "";
|
||||
this.anchorInputLength = 0;
|
||||
}
|
||||
|
||||
/** Show a read-only inline hint at the cursor (e.g. a sudo password prompt
|
||||
* hint). Independent of autocomplete suggestions; never accepted as input. */
|
||||
showHint(text: string): void {
|
||||
if (this.disposed || !this.hintElement || !this.term) return;
|
||||
this.hintActive = true;
|
||||
this.hintElement.textContent = text;
|
||||
this.hintElement.style.display = "block";
|
||||
this.hintElement.style.fontSize = `${this.term.options.fontSize}px`;
|
||||
this.hintElement.style.fontFamily = this.term.options.fontFamily || "inherit";
|
||||
this.updateHintPosition();
|
||||
}
|
||||
|
||||
hideHint(): void {
|
||||
this.hintActive = false;
|
||||
if (this.hintElement) {
|
||||
this.hintElement.style.display = "none";
|
||||
this.hintElement.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
isHintActive(): boolean {
|
||||
return this.hintActive;
|
||||
}
|
||||
|
||||
private updateHintPosition(): void {
|
||||
if (!this.term || !this.hintElement) return;
|
||||
const dims = getXTermCellDimensions(this.term);
|
||||
const buf = this.term.buffer.active;
|
||||
this.hintElement.style.left = `${buf.cursorX * dims.width}px`;
|
||||
this.hintElement.style.top = `${buf.cursorY * dims.height}px`;
|
||||
this.hintElement.style.lineHeight = `${dims.height}px`;
|
||||
this.hintElement.style.height = `${dims.height}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-align the ghost against a freshly-updated user input synchronously.
|
||||
* Called from handleInput on every keystroke that mutates the typed
|
||||
* buffer so ghost text never falls out of sync with what the user has
|
||||
* actually typed.
|
||||
*
|
||||
* Implementation relies on the predict-anchor-shift trick rather than
|
||||
* re-reading xterm's live cursorX: xterm hasn't echoed the triggering
|
||||
* keystroke yet at this point, so cursorX still points at the
|
||||
* pre-keystroke column. Instead we track the cursor column captured
|
||||
* at show() time and advance the ghost's left by the number of chars
|
||||
* typed since — so the tail aligns with where the real cursor *will*
|
||||
* land once the echo arrives, even across SSH round-trip latency.
|
||||
*/
|
||||
adjustToInput(newInput: string): void {
|
||||
if (this.disposed || !this.ghostElement || !this.currentSuggestion) return;
|
||||
if (!this.currentSuggestion.startsWith(newInput)) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
this.currentInput = newInput;
|
||||
const ghostText = this.currentSuggestion.substring(newInput.length);
|
||||
if (!ghostText) {
|
||||
this.hide();
|
||||
return;
|
||||
}
|
||||
// Force position recomputation — updatePosition skips DOM writes
|
||||
// when the left/top cache hasn't changed, but we also need the new
|
||||
// textContent to flush.
|
||||
this.lastLeft = -1;
|
||||
this.lastTop = -1;
|
||||
this.ghostElement.textContent = ghostText;
|
||||
this.updatePosition();
|
||||
this.ghostElement.style.display = "block";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single keystroke's effect to the ghost without consulting the
|
||||
* outer typed-input buffer. Used when that buffer's reliability flag is
|
||||
* off (post-Tab, history recall, cursor moves) — without this hook the
|
||||
* gate at handleInput's adjustToInput call would freeze the ghost at
|
||||
* the previous show()'s tail, and a subsequent → -accept would paste
|
||||
* that stale tail on top of the chars typed in the meantime
|
||||
* (sttop/dduplicate-glyph bug, issue #906).
|
||||
*
|
||||
* Only forwards events the ghost can locally re-derive: a printable
|
||||
* char appends, Backspace/DEL slices off one char, Ctrl-W performs
|
||||
* the same trailing-word erase as zsh/bash. Anything else (escape
|
||||
* sequences, other control codes) is treated as a no-op — those
|
||||
* paths already clearState() in handleInput, so by the time the user
|
||||
* could trigger an accept, the ghost is gone.
|
||||
*/
|
||||
applyKeystroke(data: string): void {
|
||||
if (this.disposed || !this.currentSuggestion || !data) return;
|
||||
let nextInput: string;
|
||||
if (data === "\x7f" || data === "\b") {
|
||||
if (this.currentInput.length === 0) return;
|
||||
nextInput = this.currentInput.slice(0, -1);
|
||||
} else if (data === "\x17") {
|
||||
const erased = this.currentInput.replace(/\s*\S+\s*$/, "");
|
||||
if (erased === this.currentInput) return;
|
||||
nextInput = erased;
|
||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
nextInput = this.currentInput + data;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
this.adjustToInput(nextInput);
|
||||
}
|
||||
|
||||
getSuggestion(): string {
|
||||
return this.currentSuggestion;
|
||||
}
|
||||
|
||||
isVisible(): boolean {
|
||||
return !!(this.ghostElement && this.ghostElement.style.display !== "none" &&
|
||||
this.currentSuggestion);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the ghost has a live suggestion even if it's momentarily
|
||||
* shown underneath the real text while the user keeps typing within
|
||||
* the prediction. Accept-path gates should use this instead of
|
||||
* isVisible() so the suggestion remains available even while its
|
||||
* leading characters are fully covered by real glyphs.
|
||||
*/
|
||||
isActive(): boolean {
|
||||
return !this.disposed && !!this.currentSuggestion;
|
||||
}
|
||||
|
||||
getGhostText(): string {
|
||||
if (!this.currentSuggestion) return "";
|
||||
return this.currentSuggestion.startsWith(this.currentInput)
|
||||
? this.currentSuggestion.substring(this.currentInput.length)
|
||||
: "";
|
||||
}
|
||||
|
||||
getNextWord(): string {
|
||||
const ghost = this.getGhostText();
|
||||
if (!ghost) return "";
|
||||
|
||||
const trimmed = ghost.replace(/^\s+/, "");
|
||||
const leadingSpace = ghost.length - trimmed.length;
|
||||
|
||||
if (trimmed.length === 0) return ghost; // Only whitespace
|
||||
|
||||
// Search for word boundary starting from index 1 (skip leading separator chars like /)
|
||||
const wordEnd = trimmed.substring(1).search(/[\s/\\-]/);
|
||||
if (wordEnd < 0) return ghost; // Single word, accept all
|
||||
|
||||
// Include leading whitespace + the word up to (and including) the separator
|
||||
return ghost.substring(0, leadingSpace + 1 + wordEnd + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the real terminal line has input we did not track, or already
|
||||
* visible text exactly matches the ghost we are about to paint. See
|
||||
* ./ghostTextConsistency and issues #1013 and #1060. Returns false on
|
||||
* hosts/inputs we can't judge (non-ASCII, echo still catching up), so the
|
||||
* ghost only gets suppressed when corruption is actually imminent.
|
||||
*/
|
||||
private realLineHasUntrackedInput(): boolean {
|
||||
if (!this.term) return false;
|
||||
const buf = this.term.buffer.active;
|
||||
if (typeof buf?.getLine !== "function") return false;
|
||||
const line = buf.getLine(buf.baseY + buf.cursorY);
|
||||
if (!line || typeof line.translateToString !== "function") return false;
|
||||
const lineText = line.translateToString(false);
|
||||
const beforeCursor = lineText.slice(0, buf.cursorX);
|
||||
const afterCursor = lineText.slice(buf.cursorX);
|
||||
const ghostText = this.getGhostText();
|
||||
if (hasVisibleGhostPrefix(ghostText, afterCursor)) return true;
|
||||
if (!this.currentInput) return false;
|
||||
return lineHasUntrackedTrailingInput(this.currentInput, beforeCursor);
|
||||
}
|
||||
|
||||
private updatePosition(): void {
|
||||
if (!this.term || !this.ghostElement) return;
|
||||
|
||||
// Self-heal a stale anchor: when show() fired during the SSH
|
||||
// keystroke→echo gap without a line probe, cursorX may still be the
|
||||
// pre-echo column. While no adjustToInput has moved us from the
|
||||
// show-time baseline, adopt a live cursor that has advanced (echo
|
||||
// caught up). Use max on the same row so a cell-width-predicted
|
||||
// pre-echo anchor is not collapsed back onto the prompt before echo
|
||||
// arrives. When the live row advances, the predicted X may already
|
||||
// encode a wrap (column >= cols); adopting the live X/Y pair avoids
|
||||
// counting that wrap again in the modulo math below.
|
||||
// When the predicted wrap happens on the bottom row, the echo scrolls
|
||||
// the buffer and Y stays put — adopt live X/Y once it matches the
|
||||
// normalized wrap column so Math.max cannot keep the unnormalized X.
|
||||
if (this.currentInput.length === this.anchorInputLength) {
|
||||
const liveX = this.term.buffer.active.cursorX;
|
||||
const liveY = this.term.buffer.active.cursorY;
|
||||
const cols = Math.max(1, this.term.cols);
|
||||
if (liveY !== this.anchorCursorY) {
|
||||
this.anchorCursorX = liveX;
|
||||
this.anchorCursorY = liveY;
|
||||
} else if (
|
||||
this.anchorCursorX >= cols &&
|
||||
liveX === this.anchorCursorX % cols
|
||||
) {
|
||||
this.anchorCursorX = liveX;
|
||||
this.anchorCursorY = liveY;
|
||||
} else {
|
||||
this.anchorCursorX = Math.max(this.anchorCursorX, liveX);
|
||||
}
|
||||
}
|
||||
|
||||
const dims = getXTermCellDimensions(this.term);
|
||||
|
||||
// Advance (or walk back) the anchor column by the cell width of
|
||||
// whatever the user has typed since show() was called. Using cell
|
||||
// width (not code-unit length) lets CJK / emoji / fullwidth glyphs
|
||||
// advance by 2 cells instead of 1. Backspace / Ctrl-W produces a
|
||||
// negative delta by shrinking currentInput below anchorInputLength.
|
||||
const cellDelta = this.currentInput.length >= this.anchorInputLength
|
||||
? stringCellWidth(this.currentInput.slice(this.anchorInputLength), this.term)
|
||||
: -stringCellWidth(
|
||||
// currentSuggestion[0..anchorInputLength] equals what was typed
|
||||
// when show() fired (prefix-match invariant), so its slice gives
|
||||
// the correct cell widths for the deleted glyphs.
|
||||
this.currentSuggestion.slice(this.currentInput.length, this.anchorInputLength),
|
||||
this.term,
|
||||
);
|
||||
const cols = Math.max(1, this.term.cols);
|
||||
const targetCol = this.anchorCursorX + cellDelta;
|
||||
// Wrap the predicted cursor position across line boundaries in both
|
||||
// directions — the real xterm cursor wraps to the next row once it
|
||||
// crosses cols forward, and to the previous row when a deletion
|
||||
// crosses back past column 0. JS `%` returns negative for negative
|
||||
// dividends, so normalize both col and rowOffset explicitly.
|
||||
let col = targetCol % cols;
|
||||
let rowOffset = Math.floor(targetCol / cols);
|
||||
if (col < 0) {
|
||||
col += cols;
|
||||
}
|
||||
// Clamp to the visible top row so a runaway negative delta (e.g.
|
||||
// deleted past the prompt) doesn't render above the terminal.
|
||||
const top = Math.max(0, this.anchorCursorY + rowOffset) * dims.height;
|
||||
const left = col * dims.width;
|
||||
|
||||
// Skip DOM writes if position hasn't changed (avoids unnecessary style recalc)
|
||||
if (left === this.lastLeft && top === this.lastTop) return;
|
||||
this.lastLeft = left;
|
||||
this.lastTop = top;
|
||||
|
||||
this.ghostElement.style.left = `${left}px`;
|
||||
this.ghostElement.style.top = `${top}px`;
|
||||
this.ghostElement.style.lineHeight = `${dims.height}px`;
|
||||
this.ghostElement.style.height = `${dims.height}px`;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
for (const d of this.disposables) d.dispose();
|
||||
this.disposables = [];
|
||||
this.containerElement?.remove();
|
||||
this.containerElement = null;
|
||||
this.ghostElement = null;
|
||||
this.hintElement = null;
|
||||
this.term = null;
|
||||
}
|
||||
}
|
||||
439
components/terminal/autocomplete/commandHistoryStore.ts
Normal file
439
components/terminal/autocomplete/commandHistoryStore.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Persistent command history store for terminal autocomplete.
|
||||
* Stores commands per host with frequency tracking and timestamp ordering.
|
||||
* Uses localStorageAdapter as the persistence layer (works in renderer process).
|
||||
*/
|
||||
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
|
||||
const STORAGE_KEY = "netcatty:commandHistory";
|
||||
const MAX_ENTRIES = 10000;
|
||||
const MAX_ENTRIES_PER_HOST = 5000;
|
||||
|
||||
export interface HistoryEntry {
|
||||
command: string;
|
||||
hostId: string;
|
||||
/** OS type for cross-host matching */
|
||||
os: "linux" | "windows" | "macos";
|
||||
/** Number of times this exact command was executed */
|
||||
frequency: number;
|
||||
/** Timestamp of last execution */
|
||||
lastUsedAt: number;
|
||||
/** Timestamp of first execution */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
interface HistoryStore {
|
||||
entries: HistoryEntry[];
|
||||
version: number;
|
||||
}
|
||||
|
||||
let cachedStore: HistoryStore | null = null;
|
||||
|
||||
function loadStore(): HistoryStore {
|
||||
if (cachedStore) return cachedStore;
|
||||
try {
|
||||
const parsed = localStorageAdapter.read<HistoryStore>(STORAGE_KEY);
|
||||
if (parsed) {
|
||||
cachedStore = parsed;
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Corrupted data, reset
|
||||
}
|
||||
cachedStore = { entries: [], version: 1 };
|
||||
return cachedStore;
|
||||
}
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function persistStoreNow(store: HistoryStore): boolean {
|
||||
const ok = localStorageAdapter.write(STORAGE_KEY, store);
|
||||
if (ok) return true;
|
||||
// Storage full — evict lowest scored entries (not just oldest by insertion)
|
||||
const now = Date.now();
|
||||
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
|
||||
store.entries = store.entries.slice(0, Math.floor(MAX_ENTRIES / 2));
|
||||
return localStorageAdapter.write(STORAGE_KEY, store);
|
||||
}
|
||||
|
||||
function saveStore(store: HistoryStore): void {
|
||||
cachedStore = store;
|
||||
// Debounce saves to avoid excessive writes
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
persistStoreNow(store);
|
||||
saveTimer = null;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush any pending debounced history write immediately.
|
||||
* Used after bulk imports (e.g. local histfile seeding) so a seed-complete
|
||||
* flag is not persisted before the imported commands land in storage.
|
||||
* Returns false when the write could not be persisted.
|
||||
*/
|
||||
export function flushCommandHistoryStore(): boolean {
|
||||
if (!cachedStore) return true;
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
return persistStoreNow(cachedStore);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a command execution. Updates frequency if the command already exists
|
||||
* for this host, otherwise creates a new entry.
|
||||
*/
|
||||
export function recordCommand(
|
||||
command: string,
|
||||
hostId: string,
|
||||
os: "linux" | "windows" | "macos" = "linux",
|
||||
): void {
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed || trimmed.length > 2000) return;
|
||||
|
||||
const store = loadStore();
|
||||
const now = Date.now();
|
||||
|
||||
// Find existing entry for same command + host
|
||||
const existingIdx = store.entries.findIndex(
|
||||
(e) => e.command === trimmed && e.hostId === hostId,
|
||||
);
|
||||
|
||||
if (existingIdx >= 0) {
|
||||
store.entries[existingIdx].frequency++;
|
||||
store.entries[existingIdx].lastUsedAt = now;
|
||||
} else {
|
||||
store.entries.push({
|
||||
command: trimmed,
|
||||
hostId,
|
||||
os,
|
||||
frequency: 1,
|
||||
lastUsedAt: now,
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
// Enforce per-host limit (evict by score, not insertion order)
|
||||
const hostEntries = store.entries.filter((e) => e.hostId === hostId);
|
||||
if (hostEntries.length > MAX_ENTRIES_PER_HOST) {
|
||||
hostEntries.sort((a, b) => scoreEntryAt(a, now) - scoreEntryAt(b, now));
|
||||
const toRemove = new Set(
|
||||
hostEntries.slice(0, hostEntries.length - MAX_ENTRIES_PER_HOST).map((e) => e.command),
|
||||
);
|
||||
store.entries = store.entries.filter(
|
||||
(e) => e.hostId !== hostId || !toRemove.has(e.command),
|
||||
);
|
||||
}
|
||||
|
||||
// Enforce global limit
|
||||
if (store.entries.length > MAX_ENTRIES) {
|
||||
store.entries.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
|
||||
store.entries = store.entries.slice(0, MAX_ENTRIES);
|
||||
}
|
||||
|
||||
saveStore(store);
|
||||
}
|
||||
|
||||
/** Remove one command from autocomplete history for a specific host. */
|
||||
export function removeCommandHistoryEntry(command: string, hostId: string): boolean {
|
||||
const trimmed = command.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
const store = loadStore();
|
||||
const nextEntries = store.entries.filter(
|
||||
(entry) => entry.command !== trimmed || entry.hostId !== hostId,
|
||||
);
|
||||
if (nextEntries.length === store.entries.length) return false;
|
||||
|
||||
store.entries = nextEntries;
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
return persistStoreNow(store);
|
||||
}
|
||||
|
||||
/**
|
||||
* Score an entry for ranking at a specific timestamp.
|
||||
* Caches Date.now() at query boundaries to avoid repeated syscalls during sort.
|
||||
*/
|
||||
function scoreEntryAt(entry: HistoryEntry, now: number): number {
|
||||
const ageMs = now - entry.lastUsedAt;
|
||||
const ageHours = ageMs / (1000 * 60 * 60);
|
||||
// Exponential decay: halve relevance every 24 hours
|
||||
const recencyScore = Math.pow(0.5, ageHours / 24);
|
||||
return entry.frequency * recencyScore;
|
||||
}
|
||||
|
||||
export interface HistoryQueryOptions {
|
||||
/** Filter by host ID (strict isolation — only this host's history) */
|
||||
hostId?: string;
|
||||
/** Maximum number of results */
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface RecentHistoryQueryOptions extends HistoryQueryOptions {
|
||||
/** Base command name, e.g. `cd` or `ls` */
|
||||
commandName: string;
|
||||
/** Exact command text to exclude from results */
|
||||
excludeCommand?: string;
|
||||
/** Optional path prefix to require on the current argument */
|
||||
argumentPrefix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query history entries matching a prefix.
|
||||
* Returns entries sorted by relevance (frequency * recency).
|
||||
*/
|
||||
export function queryHistory(
|
||||
prefix: string,
|
||||
options: HistoryQueryOptions = {},
|
||||
): HistoryEntry[] {
|
||||
const { hostId, limit = 20 } = options;
|
||||
if (limit <= 0) return [];
|
||||
const store = loadStore();
|
||||
const lowerPrefix = prefix.toLowerCase();
|
||||
const now = Date.now(); // Cache once per query
|
||||
|
||||
const filtered = store.entries.filter((entry) => {
|
||||
// Must match prefix
|
||||
if (!entry.command.toLowerCase().startsWith(lowerPrefix)) return false;
|
||||
// Must not be identical to prefix
|
||||
if (entry.command === prefix) return false;
|
||||
|
||||
// Host filtering: strict per-host isolation
|
||||
if (hostId) {
|
||||
return entry.hostId === hostId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort by score (frequency * recency)
|
||||
filtered.sort((a, b) => scoreEntryAt(b, now) - scoreEntryAt(a, now));
|
||||
|
||||
// Deduplicate by command text (keep highest scored)
|
||||
const seen = new Set<string>();
|
||||
const results: HistoryEntry[] = [];
|
||||
for (const entry of filtered) {
|
||||
if (seen.has(entry.command)) continue;
|
||||
seen.add(entry.command);
|
||||
results.push(entry);
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuzzy query: matches commands containing all characters of the query
|
||||
* in order (not necessarily contiguous). Used as a fallback when prefix
|
||||
* matching yields few results.
|
||||
*/
|
||||
export function fuzzyQueryHistory(
|
||||
query: string,
|
||||
options: HistoryQueryOptions = {},
|
||||
): HistoryEntry[] {
|
||||
const { hostId, limit = 10 } = options;
|
||||
if (limit <= 0) return [];
|
||||
const store = loadStore();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const now = Date.now(); // Cache once per query
|
||||
|
||||
const scored: { entry: HistoryEntry; matchScore: number }[] = [];
|
||||
|
||||
for (const entry of store.entries) {
|
||||
// Host filtering
|
||||
if (hostId) {
|
||||
if (entry.hostId !== hostId) continue;
|
||||
}
|
||||
|
||||
const matchScore = fuzzyScore(lowerQuery, entry.command.toLowerCase());
|
||||
if (matchScore > 0 && entry.command !== query) {
|
||||
scored.push({ entry, matchScore });
|
||||
}
|
||||
}
|
||||
|
||||
scored.sort((a, b) =>
|
||||
b.matchScore * scoreEntryAt(b.entry, now) - a.matchScore * scoreEntryAt(a.entry, now),
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const results: HistoryEntry[] = [];
|
||||
for (const { entry } of scored) {
|
||||
if (seen.has(entry.command)) continue;
|
||||
seen.add(entry.command);
|
||||
results.push(entry);
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the most recently used history entries for the same command name.
|
||||
* Useful when the user is currently completing a path argument and wants
|
||||
* a few recent command-line examples (e.g. recent `cd ...` commands).
|
||||
*/
|
||||
export function queryRecentHistoryByCommand(
|
||||
options: RecentHistoryQueryOptions,
|
||||
): HistoryEntry[] {
|
||||
const {
|
||||
commandName,
|
||||
excludeCommand,
|
||||
argumentPrefix,
|
||||
hostId,
|
||||
limit = 3,
|
||||
} = options;
|
||||
if (!commandName || limit <= 0) return [];
|
||||
|
||||
const store = loadStore();
|
||||
const trimmedCommandName = commandName.trim().toLowerCase();
|
||||
const commandPrefix = `${trimmedCommandName} `;
|
||||
const normalizedArgumentPrefix = normalizeArgumentToken(argumentPrefix ?? "");
|
||||
|
||||
const filtered = store.entries.filter((entry) => {
|
||||
const lowerCommand = entry.command.toLowerCase();
|
||||
if (lowerCommand !== trimmedCommandName && !lowerCommand.startsWith(commandPrefix)) {
|
||||
return false;
|
||||
}
|
||||
if (excludeCommand && entry.command === excludeCommand) return false;
|
||||
|
||||
if (normalizedArgumentPrefix) {
|
||||
const currentToken = normalizeArgumentToken(getCurrentCommandToken(entry.command));
|
||||
if (!currentToken.startsWith(normalizedArgumentPrefix)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (hostId) {
|
||||
return entry.hostId === hostId;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
filtered.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const results: HistoryEntry[] = [];
|
||||
for (const entry of filtered) {
|
||||
if (seen.has(entry.command)) continue;
|
||||
seen.add(entry.command);
|
||||
results.push(entry);
|
||||
if (results.length >= limit) break;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function getCurrentCommandToken(command: string): string {
|
||||
const tokens = tokenizeShellLike(command);
|
||||
return tokens.length > 0 ? (tokens[tokens.length - 1] || "") : "";
|
||||
}
|
||||
|
||||
function normalizeArgumentToken(token: string): string {
|
||||
return token
|
||||
.trim()
|
||||
.replace(/^['"]/, "")
|
||||
.replace(/['"]$/, "")
|
||||
.replace(/\\ /g, " ")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function tokenizeShellLike(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const ch = input[i];
|
||||
|
||||
if (escaped) {
|
||||
current += ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "\\") {
|
||||
escaped = true;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" && !inDoubleQuote) {
|
||||
inSingleQuote = !inSingleQuote;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' && !inSingleQuote) {
|
||||
inDoubleQuote = !inDoubleQuote;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === " " && !inSingleQuote && !inDoubleQuote) {
|
||||
if (current.length > 0) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
current += ch;
|
||||
}
|
||||
|
||||
tokens.push(current);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a fuzzy match score. Returns 0 for no match.
|
||||
* Higher score = better match quality.
|
||||
* Rewards: first-char match, consecutive matches, word-boundary matches.
|
||||
*/
|
||||
function fuzzyScore(query: string, target: string): number {
|
||||
if (query.length === 0) return 0;
|
||||
if (query.length > target.length) return 0;
|
||||
|
||||
let score = 0;
|
||||
let queryIdx = 0;
|
||||
let prevMatchIdx = -2;
|
||||
|
||||
for (let i = 0; i < target.length && queryIdx < query.length; i++) {
|
||||
if (target[i] === query[queryIdx]) {
|
||||
queryIdx++;
|
||||
// First character bonus
|
||||
if (i === 0) score += 10;
|
||||
// Consecutive match bonus
|
||||
if (i === prevMatchIdx + 1) score += 5;
|
||||
// Word boundary bonus
|
||||
if (i === 0 || target[i - 1] === " " || target[i - 1] === "/" ||
|
||||
target[i - 1] === "-" || target[i - 1] === "_") {
|
||||
score += 3;
|
||||
}
|
||||
score += 1;
|
||||
prevMatchIdx = i;
|
||||
}
|
||||
}
|
||||
|
||||
// All query characters must be matched
|
||||
return queryIdx === query.length ? score : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all history for a specific host, or all history if no hostId given.
|
||||
*/
|
||||
export function clearHistory(hostId?: string): void {
|
||||
const store = loadStore();
|
||||
if (hostId) {
|
||||
store.entries = store.entries.filter((e) => e.hostId !== hostId);
|
||||
} else {
|
||||
store.entries = [];
|
||||
}
|
||||
saveStore(store);
|
||||
}
|
||||
761
components/terminal/autocomplete/completionEngine.ts
Normal file
761
components/terminal/autocomplete/completionEngine.ts
Normal file
@@ -0,0 +1,761 @@
|
||||
/**
|
||||
* Context-aware completion engine.
|
||||
* Combines multiple data sources:
|
||||
* 1. Context-aware path completions and @withfig/autocomplete specs
|
||||
* 2. Command history
|
||||
* 3. Fuzzy history matching (fallback)
|
||||
*
|
||||
* Parses the current command line to determine context (command, subcommand,
|
||||
* option, or argument position) and provides appropriate suggestions.
|
||||
*/
|
||||
|
||||
import {
|
||||
queryHistory,
|
||||
queryRecentHistoryByCommand,
|
||||
fuzzyQueryHistory,
|
||||
type HistoryQueryOptions,
|
||||
} from "./commandHistoryStore";
|
||||
import {
|
||||
loadSpec,
|
||||
hasSpec,
|
||||
getAvailableSpecs,
|
||||
normalizeCommandName,
|
||||
resolveNames,
|
||||
type FigSpec,
|
||||
type FigSubcommand,
|
||||
type FigOption,
|
||||
} from "./figSpecLoader";
|
||||
import {
|
||||
shouldDoPathCompletion,
|
||||
getPathSuggestions,
|
||||
resolvePathComponents,
|
||||
} from "./remotePathCompleter";
|
||||
import { getSnippetSuggestions } from "./snippetCompleter";
|
||||
import type { AutocompleteHistoryScope, Snippet } from "../../../domain/models";
|
||||
import type { AutocompleteCwdSource } from "./terminalAutocompleteLayout";
|
||||
|
||||
/** Source indicator for where a suggestion came from */
|
||||
export type SuggestionSource = "history" | "command" | "subcommand" | "option" | "arg" | "path" | "snippet" | "plugin";
|
||||
|
||||
export interface CompletionSuggestion {
|
||||
/** The text to insert */
|
||||
text: string;
|
||||
/** Display text (may differ from insert text) */
|
||||
displayText: string;
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
/** Source of this suggestion */
|
||||
source: SuggestionSource;
|
||||
/** Relevance score (higher = more relevant) */
|
||||
score: number;
|
||||
/** For history entries: execution frequency */
|
||||
frequency?: number;
|
||||
/** Matching rule used by recent history surfaced during path completion. */
|
||||
historyMatch?: "path-argument";
|
||||
/** For path suggestions: file type */
|
||||
fileType?: "file" | "directory" | "symlink";
|
||||
/** For snippet suggestions: the source snippet (used by the accept path). */
|
||||
snippet?: Snippet;
|
||||
/** For plugin suggestions: the owning Provider contribution. */
|
||||
providerId?: string;
|
||||
}
|
||||
|
||||
export interface CompletionContext {
|
||||
/** Full command line text */
|
||||
commandLine: string;
|
||||
/** Current word being typed */
|
||||
currentWord: string;
|
||||
/** Index of the current word in the parsed tokens */
|
||||
wordIndex: number;
|
||||
/** Parsed command tokens */
|
||||
tokens: string[];
|
||||
/** The base command name (first token) */
|
||||
commandName: string;
|
||||
/** Whether the current position is after a recognized option that expects an argument */
|
||||
isOptionArg: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft wait for remote/local path listings. History, fig specs, and snippets are
|
||||
* local and should paint without waiting on high-latency SSH exec (#2830).
|
||||
* Timed-out listings still finish in the background: cacheable paths warm the
|
||||
* shared cache, and cache-bypassed relative SSH paths notify via onLateResult
|
||||
* so the UI can merge path suggestions when the listing finally resolves.
|
||||
*/
|
||||
export const PATH_COMPLETION_BUDGET_MS = 150;
|
||||
|
||||
type PathSuggestionEntry = { name: string; type: "file" | "directory" | "symlink" };
|
||||
|
||||
/** @internal Exported for unit tests covering the soft path-listing budget. */
|
||||
export async function getPathSuggestionsWithinBudget(
|
||||
pathPromise: Promise<PathSuggestionEntry[]>,
|
||||
budgetMs: number,
|
||||
onLateResult?: (entries: PathSuggestionEntry[]) => void,
|
||||
): Promise<PathSuggestionEntry[]> {
|
||||
if (!Number.isFinite(budgetMs) || budgetMs < 0) {
|
||||
return pathPromise;
|
||||
}
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
const raced = await Promise.race([
|
||||
// Settle rejections here so a late failure after timeout cannot surface
|
||||
// as an unhandled rejection from the losing Promise.race branch.
|
||||
pathPromise.then(
|
||||
(entries) => ({ kind: "entries" as const, entries }),
|
||||
() => ({ kind: "entries" as const, entries: [] as PathSuggestionEntry[] }),
|
||||
),
|
||||
new Promise<{ kind: "timeout" }>((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve({ kind: "timeout" }), budgetMs);
|
||||
}),
|
||||
]);
|
||||
if (raced.kind === "entries") return raced.entries;
|
||||
// Keep the listing in flight. Cacheable paths warm the shared cache for a
|
||||
// later keystroke; bypassed relative SSH paths have no cache, so surface
|
||||
// the late result to the caller instead of discarding it.
|
||||
void pathPromise.then(
|
||||
(entries) => {
|
||||
if (entries.length > 0) onLateResult?.(entries);
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
return [];
|
||||
} finally {
|
||||
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
function buildPathCompletionSuggestions(
|
||||
ctx: CompletionContext,
|
||||
pathEntries: PathSuggestionEntry[],
|
||||
cwd: string | undefined,
|
||||
): CompletionSuggestion[] {
|
||||
if (pathEntries.length === 0) return [];
|
||||
const { pathPrefix, quoteSuffix } = resolvePathComponents(ctx.currentWord, cwd);
|
||||
const isQuotedPath = ctx.currentWord.startsWith('"') || ctx.currentWord.startsWith("'");
|
||||
const suggestions: CompletionSuggestion[] = [];
|
||||
for (const entry of pathEntries) {
|
||||
const insertName = isQuotedPath || !/[\\$'"|!<>;#~` ]/.test(entry.name)
|
||||
? entry.name
|
||||
: shellEscape(entry.name);
|
||||
const suffix = entry.type === "directory" ? "/" : "";
|
||||
const fullPath = pathPrefix + insertName + suffix + quoteSuffix;
|
||||
suggestions.push({
|
||||
text: rebuildCommand(ctx.tokens, ctx.wordIndex, fullPath),
|
||||
displayText: entry.name + suffix,
|
||||
source: "path",
|
||||
score: 750,
|
||||
fileType: entry.type,
|
||||
});
|
||||
}
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
interface SpecSuggestionResult {
|
||||
suggestions: CompletionSuggestion[];
|
||||
pathArgs?: FigSubcommand["args"];
|
||||
}
|
||||
|
||||
export function shellEscape(name: string): string {
|
||||
if (!name) return name;
|
||||
if (/[\\$'"|!<>;#~` ]/.test(name)) {
|
||||
return `'${name.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a command line string into tokens, handling quoting.
|
||||
*/
|
||||
function tokenize(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let escaped = false;
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const ch = input[i];
|
||||
|
||||
if (escaped) {
|
||||
current += ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "\\") {
|
||||
escaped = true;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" && !inDoubleQuote) {
|
||||
inSingleQuote = !inSingleQuote;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"' && !inSingleQuote) {
|
||||
inDoubleQuote = !inDoubleQuote;
|
||||
current += ch;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === " " && !inSingleQuote && !inDoubleQuote) {
|
||||
if (current.length > 0) {
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
current += ch;
|
||||
}
|
||||
|
||||
// Always include the last token (even if empty, to indicate trailing space)
|
||||
tokens.push(current);
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the current command line into a CompletionContext.
|
||||
*/
|
||||
export function parseCommandLine(input: string): CompletionContext {
|
||||
const tokens = tokenize(input);
|
||||
const wordIndex = tokens.length - 1;
|
||||
const currentWord = tokens[wordIndex] || "";
|
||||
const commandName = tokens.length > 0 ? normalizeCommandName(tokens[0]) : "";
|
||||
|
||||
return {
|
||||
commandLine: input,
|
||||
currentWord,
|
||||
wordIndex,
|
||||
tokens,
|
||||
commandName,
|
||||
isOptionArg: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main completion function. Returns sorted suggestions from all sources.
|
||||
* Ghost text should use completions[0].text instead of a separate query.
|
||||
*/
|
||||
export async function getCompletions(
|
||||
input: string,
|
||||
options: {
|
||||
hostId?: string;
|
||||
hostGroup?: string;
|
||||
os?: "linux" | "windows" | "macos";
|
||||
maxResults?: number;
|
||||
/** Session ID for remote path completion */
|
||||
sessionId?: string;
|
||||
/** Connection protocol (ssh, local, telnet, serial) */
|
||||
protocol?: string;
|
||||
/** Current working directory (from OSC 7) */
|
||||
cwd?: string;
|
||||
cwdSource?: AutocompleteCwdSource;
|
||||
/** Custom snippets to surface at the command position */
|
||||
snippets?: Snippet[];
|
||||
/** Which history pool to query (default: current host only). */
|
||||
historyScope?: AutocompleteHistoryScope;
|
||||
/**
|
||||
* Soft budget for path listings (ms). Local suggestions return when this
|
||||
* elapses even if remote `find` is still running. Use `Infinity` in tests
|
||||
* that need the full remote listing.
|
||||
*/
|
||||
pathBudgetMs?: number;
|
||||
/**
|
||||
* Invoked when a path listing finishes after the soft budget elapsed.
|
||||
* Needed for cache-bypassed relative SSH cwd lookups, which cannot warm
|
||||
* the shared directory cache for a later keystroke.
|
||||
*/
|
||||
onLatePathSuggestions?: (suggestions: CompletionSuggestion[]) => void;
|
||||
} = {},
|
||||
): Promise<CompletionSuggestion[]> {
|
||||
const { hostId, maxResults = 15, historyScope = "host" } = options;
|
||||
const pathBudgetMs = options.pathBudgetMs ?? PATH_COMPLETION_BUDGET_MS;
|
||||
|
||||
if (!input || input.trim().length === 0) return [];
|
||||
|
||||
const ctx = parseCommandLine(input);
|
||||
const specResult: SpecSuggestionResult = ctx.commandName && ctx.wordIndex >= 0
|
||||
? await getSpecSuggestions(ctx)
|
||||
: { suggestions: [] };
|
||||
const suggestions: CompletionSuggestion[] = [];
|
||||
const seenSuggestionTexts = new Set<string>();
|
||||
const pathCheck = ctx.commandName && ctx.wordIndex >= 1
|
||||
? shouldDoPathCompletion(ctx, specResult.pathArgs)
|
||||
: { shouldComplete: false, foldersOnly: false };
|
||||
const preferPathSuggestions = pathCheck.shouldComplete;
|
||||
const resultLimit = preferPathSuggestions ? Math.max(maxResults, 24) : maxResults;
|
||||
|
||||
// History queries honor historyScope; snippets still stay host-scoped.
|
||||
const historyHostId = historyScope === "global" ? undefined : hostId;
|
||||
|
||||
// 1. History suggestions (full command line prefix match)
|
||||
// Cap history to leave room for spec suggestions in the popup
|
||||
const historyOpts: HistoryQueryOptions = {
|
||||
hostId: historyHostId,
|
||||
limit: preferPathSuggestions ? 0 : 5,
|
||||
};
|
||||
|
||||
const historyMatches = queryHistory(input, historyOpts);
|
||||
for (const entry of historyMatches) {
|
||||
const suggestion = {
|
||||
text: entry.command,
|
||||
displayText: entry.command,
|
||||
source: "history",
|
||||
score: 1000 + entry.frequency,
|
||||
frequency: entry.frequency,
|
||||
} satisfies CompletionSuggestion;
|
||||
suggestions.push(suggestion);
|
||||
seenSuggestionTexts.add(suggestion.text);
|
||||
}
|
||||
|
||||
if (preferPathSuggestions && ctx.commandName) {
|
||||
// When path completion is active (file-related commands like cat, vim, cd),
|
||||
// recent history is still useful but should rank below actual path matches
|
||||
// from the current directory.
|
||||
const recentHistory = queryRecentHistoryByCommand({
|
||||
commandName: ctx.commandName,
|
||||
excludeCommand: input,
|
||||
argumentPrefix: normalizeHistoryPathPrefix(ctx.currentWord),
|
||||
hostId: historyHostId,
|
||||
limit: 5,
|
||||
});
|
||||
for (let index = 0; index < recentHistory.length; index++) {
|
||||
const entry = recentHistory[index];
|
||||
if (seenSuggestionTexts.has(entry.command)) continue;
|
||||
const suggestion = {
|
||||
text: entry.command,
|
||||
displayText: entry.command,
|
||||
source: "history",
|
||||
score: 720 - index,
|
||||
frequency: entry.frequency,
|
||||
historyMatch: "path-argument",
|
||||
} satisfies CompletionSuggestion;
|
||||
suggestions.push(suggestion);
|
||||
seenSuggestionTexts.add(suggestion.text);
|
||||
}
|
||||
}
|
||||
|
||||
const canQueryPaths = options.protocol === "local" || options.sessionId !== undefined;
|
||||
|
||||
const pathEntries = canQueryPaths && pathCheck.shouldComplete
|
||||
? await getPathSuggestionsWithinBudget(
|
||||
getPathSuggestions(ctx, {
|
||||
sessionId: options.sessionId,
|
||||
protocol: options.protocol,
|
||||
os: options.os,
|
||||
cwd: options.cwd,
|
||||
cwdSource: options.cwdSource,
|
||||
foldersOnly: pathCheck.foldersOnly,
|
||||
}),
|
||||
pathBudgetMs,
|
||||
(lateEntries) => {
|
||||
if (!options.onLatePathSuggestions) return;
|
||||
const latePathSuggestions = buildPathCompletionSuggestions(
|
||||
ctx,
|
||||
lateEntries,
|
||||
options.cwd,
|
||||
);
|
||||
if (latePathSuggestions.length > 0) {
|
||||
options.onLatePathSuggestions(latePathSuggestions);
|
||||
}
|
||||
},
|
||||
)
|
||||
: [];
|
||||
|
||||
for (const suggestion of specResult.suggestions) {
|
||||
suggestions.push(suggestion);
|
||||
seenSuggestionTexts.add(suggestion.text);
|
||||
}
|
||||
|
||||
for (const suggestion of buildPathCompletionSuggestions(ctx, pathEntries, options.cwd)) {
|
||||
suggestions.push(suggestion);
|
||||
seenSuggestionTexts.add(suggestion.text);
|
||||
}
|
||||
|
||||
// 3. Fuzzy history fallback while typing the command name. Once arguments
|
||||
// are present, history completion is prefix-only: fuzzy matching the whole
|
||||
// line can borrow characters from later paths and keep an incompatible
|
||||
// middle argument visible (issue #3088).
|
||||
if (
|
||||
ctx.wordIndex === 0 &&
|
||||
!preferPathSuggestions &&
|
||||
suggestions.length < 3 &&
|
||||
input.length >= 2
|
||||
) {
|
||||
const fuzzyMatches = fuzzyQueryHistory(input, {
|
||||
...historyOpts,
|
||||
limit: 5,
|
||||
});
|
||||
for (const entry of fuzzyMatches) {
|
||||
if (seenSuggestionTexts.has(entry.command)) continue;
|
||||
const suggestion = {
|
||||
text: entry.command,
|
||||
displayText: entry.command,
|
||||
source: "history",
|
||||
score: 500 + entry.frequency,
|
||||
frequency: entry.frequency,
|
||||
} satisfies CompletionSuggestion;
|
||||
suggestions.push(suggestion);
|
||||
seenSuggestionTexts.add(suggestion.text);
|
||||
}
|
||||
}
|
||||
|
||||
// Snippets: only at the command position (typing the command name).
|
||||
// Push without the early seen-text skip: snippets score above history, so if
|
||||
// a snippet's label collides with an existing history entry's text, the
|
||||
// score-sort + final dedup below keeps the snippet (the higher-scored one).
|
||||
if (options.snippets && options.snippets.length > 0 && ctx.wordIndex === 0) {
|
||||
for (const snippetSuggestion of getSnippetSuggestions(input, options.snippets, {
|
||||
hostId,
|
||||
hostGroup: options.hostGroup,
|
||||
})) {
|
||||
suggestions.push(snippetSuggestion);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by score descending
|
||||
suggestions.sort((a, b) => b.score - a.score);
|
||||
|
||||
// Deduplicate
|
||||
const seen = new Set<string>();
|
||||
const unique: CompletionSuggestion[] = [];
|
||||
for (const s of suggestions) {
|
||||
if (seen.has(s.text)) continue;
|
||||
seen.add(s.text);
|
||||
unique.push(s);
|
||||
if (unique.length >= resultLimit) break;
|
||||
}
|
||||
|
||||
return unique;
|
||||
}
|
||||
|
||||
function normalizeHistoryPathPrefix(token: string): string {
|
||||
return token
|
||||
.trim()
|
||||
.replace(/^['"]/, "")
|
||||
.replace(/['"]$/, "")
|
||||
.replace(/\\ /g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggestions from Fig spec + return resolved args (for path detection reuse).
|
||||
*/
|
||||
async function getSpecSuggestions(ctx: CompletionContext): Promise<SpecSuggestionResult> {
|
||||
const suggestions: CompletionSuggestion[] = [];
|
||||
|
||||
const specAvailable = await hasSpec(ctx.commandName);
|
||||
if (!specAvailable) {
|
||||
if (ctx.wordIndex === 0 && ctx.currentWord.length >= 1) {
|
||||
return { suggestions: await getCommandNameSuggestions(ctx.currentWord) };
|
||||
}
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
const spec = await loadSpec(ctx.commandName);
|
||||
if (!spec) return { suggestions };
|
||||
|
||||
// If we're still typing the command name (partial match, not yet complete)
|
||||
if (ctx.wordIndex === 0) {
|
||||
const typedLower = ctx.currentWord.toLowerCase();
|
||||
const specNames = resolveNames(spec.name);
|
||||
const isExactMatch = specNames.some((n) => n.toLowerCase() === typedLower);
|
||||
if (!isExactMatch) return { suggestions };
|
||||
|
||||
// Show subcommands as preview (user typed full command but no space yet)
|
||||
if (spec.subcommands) {
|
||||
for (const sub of spec.subcommands) {
|
||||
const names = resolveNames(sub.name);
|
||||
suggestions.push({
|
||||
text: ctx.currentWord + " " + names[0],
|
||||
displayText: names[0],
|
||||
description: sub.description,
|
||||
source: "subcommand",
|
||||
score: 800,
|
||||
});
|
||||
if (suggestions.length >= 10) break;
|
||||
}
|
||||
}
|
||||
return { suggestions };
|
||||
}
|
||||
|
||||
// Navigate the spec tree based on typed tokens
|
||||
const resolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex));
|
||||
const currentToken = ctx.currentWord;
|
||||
|
||||
// Check if currentToken exactly matches a subcommand — if so, navigate into it
|
||||
// and show its children as preview (e.g., "git commit" shows commit's options)
|
||||
if (currentToken && resolved.subcommands) {
|
||||
const exactMatch = resolved.subcommands.find((s) => {
|
||||
const names = resolveNames(s.name);
|
||||
return names.includes(currentToken);
|
||||
});
|
||||
if (exactMatch) {
|
||||
// Navigate into the matched subcommand and show its children
|
||||
const childResolved = resolveSpecContext(spec, ctx.tokens.slice(1, ctx.wordIndex + 1));
|
||||
|
||||
// Show child subcommands
|
||||
if (childResolved.subcommands) {
|
||||
for (const sub of childResolved.subcommands) {
|
||||
const names = resolveNames(sub.name);
|
||||
suggestions.push({
|
||||
text: ctx.commandLine + " " + names[0],
|
||||
displayText: names[0],
|
||||
description: sub.description,
|
||||
source: "subcommand",
|
||||
score: 800,
|
||||
});
|
||||
if (suggestions.length >= 10) break;
|
||||
}
|
||||
}
|
||||
// Show child options
|
||||
appendOptionPreviewSuggestions(
|
||||
suggestions,
|
||||
ctx.commandLine,
|
||||
childResolved.options?.length ? childResolved.options : childResolved.fallbackOptions,
|
||||
15,
|
||||
);
|
||||
return { suggestions };
|
||||
}
|
||||
}
|
||||
|
||||
// Suggest subcommands (prefix match, excluding exact matches)
|
||||
if (resolved.subcommands) {
|
||||
for (const sub of resolved.subcommands) {
|
||||
const names = resolveNames(sub.name);
|
||||
for (const name of names) {
|
||||
if (name.startsWith(currentToken) && name !== currentToken) {
|
||||
suggestions.push({
|
||||
text: rebuildCommand(ctx.tokens, ctx.wordIndex, name),
|
||||
displayText: name,
|
||||
description: sub.description,
|
||||
source: "subcommand",
|
||||
score: 800,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Suggest options
|
||||
const hasDirectOptionSuggestions = appendOptionSuggestions(
|
||||
suggestions,
|
||||
ctx,
|
||||
currentToken,
|
||||
resolved.options,
|
||||
);
|
||||
if (!hasDirectOptionSuggestions) {
|
||||
appendOptionSuggestions(suggestions, ctx, currentToken, resolved.fallbackOptions);
|
||||
}
|
||||
|
||||
// Suggest argument values from suggestions in the spec
|
||||
if (resolved.args) {
|
||||
const args = Array.isArray(resolved.args) ? resolved.args : [resolved.args];
|
||||
for (const arg of args) {
|
||||
if (arg.suggestions) {
|
||||
for (const sug of arg.suggestions) {
|
||||
const sugName = typeof sug === "string" ? sug : (Array.isArray(sug.name) ? sug.name[0] : sug.name);
|
||||
const sugDesc = typeof sug === "string" ? undefined : sug.description;
|
||||
if (sugName.startsWith(currentToken) && sugName !== currentToken) {
|
||||
suggestions.push({
|
||||
text: rebuildCommand(ctx.tokens, ctx.wordIndex, sugName),
|
||||
displayText: sugName,
|
||||
description: sugDesc,
|
||||
source: "arg",
|
||||
score: 600,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
suggestions,
|
||||
pathArgs: resolved.args,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get command name suggestions by matching against available specs.
|
||||
* Uses the already-imported getAvailableSpecs directly (no dynamic self-import).
|
||||
*/
|
||||
async function getCommandNameSuggestions(prefix: string): Promise<CompletionSuggestion[]> {
|
||||
const specs = await getAvailableSpecs();
|
||||
const lower = prefix.toLowerCase();
|
||||
const suggestions: CompletionSuggestion[] = [];
|
||||
|
||||
for (const name of specs) {
|
||||
// Skip sub-path specs like "aws/s3", "dotnet/dotnet-build" — not direct shell commands
|
||||
if (name.includes("/")) continue;
|
||||
if (name.startsWith(lower) && name !== lower) {
|
||||
suggestions.push({
|
||||
text: name,
|
||||
displayText: name,
|
||||
source: "command",
|
||||
score: 600,
|
||||
});
|
||||
if (suggestions.length >= 10) break;
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
interface ResolvedContext {
|
||||
subcommands?: FigSubcommand[];
|
||||
options?: FigOption[];
|
||||
fallbackOptions?: FigOption[];
|
||||
args?: FigSubcommand["args"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the spec tree following the typed tokens to find the current context.
|
||||
* Handles options with arguments (e.g., --name value) by skipping the value token.
|
||||
*/
|
||||
function resolveSpecContext(spec: FigSpec, consumedTokens: string[]): ResolvedContext {
|
||||
let current: FigSubcommand = spec;
|
||||
let inheritedOptions: FigOption[] = [];
|
||||
let skipNext = false;
|
||||
let lastOptionArgs: FigSubcommand["args"] | undefined;
|
||||
|
||||
for (const token of consumedTokens) {
|
||||
// Skip this token if it's the argument value of a previous option
|
||||
if (skipNext) {
|
||||
skipNext = false;
|
||||
lastOptionArgs = undefined;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle option flags
|
||||
if (token.startsWith("-")) {
|
||||
// Check if this option expects an argument
|
||||
const opt = [...(current.options ?? []), ...inheritedOptions].find((candidate) => {
|
||||
const names = resolveNames(candidate.name);
|
||||
return names.includes(token);
|
||||
});
|
||||
if (opt?.args) {
|
||||
// This option expects an argument — the next token is its value
|
||||
const args = Array.isArray(opt.args) ? opt.args : [opt.args];
|
||||
if (args.length > 0 && !args[0].isOptional) {
|
||||
skipNext = true;
|
||||
lastOptionArgs = opt.args; // Track for the case where next token is currentWord
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try to find a matching subcommand
|
||||
if (current.subcommands) {
|
||||
const sub = current.subcommands.find((s) => {
|
||||
const names = resolveNames(s.name);
|
||||
return names.includes(token);
|
||||
});
|
||||
if (sub) {
|
||||
inheritedOptions = mergeOptionLists(inheritedOptions, current.options);
|
||||
current = sub;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// If no subcommand matched, we're at the args level
|
||||
break;
|
||||
}
|
||||
|
||||
// If skipNext is still true, the currentWord is an option's arg value
|
||||
// (e.g., "git archive --format |" — currentWord is the format value)
|
||||
// Return the option's args instead of the subcommand's args.
|
||||
if (skipNext && lastOptionArgs) {
|
||||
return {
|
||||
subcommands: undefined,
|
||||
options: undefined,
|
||||
fallbackOptions: inheritedOptions.length > 0 ? inheritedOptions : undefined,
|
||||
args: lastOptionArgs,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
subcommands: current.subcommands,
|
||||
options: current.options ? [...current.options] : undefined,
|
||||
fallbackOptions: inheritedOptions.length > 0 ? inheritedOptions : undefined,
|
||||
args: current.args,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeOptionLists(
|
||||
left: FigOption[] | undefined,
|
||||
right: FigOption[] | undefined,
|
||||
): FigOption[] {
|
||||
const merged: FigOption[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const option of [...(left ?? []), ...(right ?? [])]) {
|
||||
const key = resolveNames(option.name).sort().join("\0");
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
merged.push(option);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function appendOptionSuggestions(
|
||||
suggestions: CompletionSuggestion[],
|
||||
ctx: CompletionContext,
|
||||
currentToken: string,
|
||||
options: FigOption[] | undefined,
|
||||
): boolean {
|
||||
if (!options || options.length === 0) return false;
|
||||
|
||||
let added = false;
|
||||
for (const opt of options) {
|
||||
const names = resolveNames(opt.name);
|
||||
for (const name of names) {
|
||||
if (name.startsWith(currentToken) && name !== currentToken) {
|
||||
suggestions.push({
|
||||
text: rebuildCommand(ctx.tokens, ctx.wordIndex, name),
|
||||
displayText: name,
|
||||
description: opt.description,
|
||||
source: "option",
|
||||
score: 700,
|
||||
});
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
|
||||
function appendOptionPreviewSuggestions(
|
||||
suggestions: CompletionSuggestion[],
|
||||
commandLine: string,
|
||||
options: FigOption[] | undefined,
|
||||
limit: number,
|
||||
): void {
|
||||
if (!options || options.length === 0 || suggestions.length >= limit) return;
|
||||
|
||||
for (const opt of options) {
|
||||
const names = resolveNames(opt.name);
|
||||
suggestions.push({
|
||||
text: commandLine + " " + names[0],
|
||||
displayText: names[0],
|
||||
description: opt.description,
|
||||
source: "option",
|
||||
score: 700,
|
||||
});
|
||||
if (suggestions.length >= limit) break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the full command text with a replacement at a specific token index.
|
||||
*/
|
||||
function rebuildCommand(tokens: string[], replaceIndex: number, replacement: string): string {
|
||||
const rebuilt = [...tokens];
|
||||
rebuilt[replaceIndex] = replacement;
|
||||
return rebuilt.join(" ");
|
||||
}
|
||||
203
components/terminal/autocomplete/figSpecLoader.ts
Normal file
203
components/terminal/autocomplete/figSpecLoader.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Loader for @withfig/autocomplete command specifications.
|
||||
* Loads specs via Electron main process IPC (Node.js require),
|
||||
* which reliably accesses node_modules in both dev and production.
|
||||
*/
|
||||
|
||||
/** Minimal Fig spec types — mirrors @withfig/autocomplete-types */
|
||||
export interface FigOption {
|
||||
name: string | string[];
|
||||
description?: string;
|
||||
args?: FigArg | FigArg[];
|
||||
isRequired?: boolean;
|
||||
isPersistent?: boolean;
|
||||
exclusiveOn?: string[];
|
||||
}
|
||||
|
||||
export interface FigArg {
|
||||
name?: string;
|
||||
description?: string;
|
||||
suggestions?: (string | FigSuggestion)[];
|
||||
template?: string | string[];
|
||||
isOptional?: boolean;
|
||||
isVariadic?: boolean;
|
||||
generators?: unknown;
|
||||
}
|
||||
|
||||
export interface FigSuggestion {
|
||||
name: string | string[];
|
||||
description?: string;
|
||||
icon?: string;
|
||||
type?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface FigSubcommand {
|
||||
name: string | string[];
|
||||
description?: string;
|
||||
subcommands?: FigSubcommand[];
|
||||
options?: FigOption[];
|
||||
args?: FigArg | FigArg[];
|
||||
}
|
||||
|
||||
export interface FigSpec extends FigSubcommand {
|
||||
// Top-level spec may include additional metadata
|
||||
}
|
||||
|
||||
// Bridge type augmentation
|
||||
interface FigSpecBridge {
|
||||
listFigSpecs?: () => Promise<string[]>;
|
||||
loadFigSpec?: (commandName: string) => Promise<FigSpec | null>;
|
||||
}
|
||||
|
||||
function getBridge(): FigSpecBridge | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
return (window as Window & { netcatty?: FigSpecBridge }).netcatty;
|
||||
}
|
||||
|
||||
// Cache loaded specs
|
||||
const specCache = new Map<string, FigSpec | null>();
|
||||
|
||||
// In-flight loading promises to avoid duplicate loads
|
||||
const inFlightLoads = new Map<string, Promise<FigSpec | null>>();
|
||||
|
||||
// All available spec names
|
||||
let availableSpecs: string[] | null = null;
|
||||
let availableSpecsSet: Set<string> | null = null;
|
||||
|
||||
/**
|
||||
* Get the list of all available command specs via IPC.
|
||||
*/
|
||||
export async function getAvailableSpecs(): Promise<string[]> {
|
||||
// Only return cache if it has actual specs (not an empty failure)
|
||||
if (availableSpecs && availableSpecs.length > 0) return availableSpecs;
|
||||
|
||||
try {
|
||||
const bridge = getBridge();
|
||||
if (bridge?.listFigSpecs) {
|
||||
const specs = await bridge.listFigSpecs();
|
||||
if (Array.isArray(specs) && specs.length > 0) {
|
||||
availableSpecs = specs;
|
||||
availableSpecsSet = new Set(specs);
|
||||
return specs;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[Autocomplete] figspec bridge error:", err);
|
||||
}
|
||||
|
||||
// Don't cache empty — allow retry on next call
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a command specification by name via IPC.
|
||||
* Uses in-flight deduplication to avoid loading the same spec twice concurrently.
|
||||
*/
|
||||
export async function loadSpec(commandName: string): Promise<FigSpec | null> {
|
||||
if (specCache.has(commandName)) {
|
||||
return specCache.get(commandName) ?? null;
|
||||
}
|
||||
|
||||
const existing = inFlightLoads.get(commandName);
|
||||
if (existing) return existing;
|
||||
|
||||
const loadPromise = (async (): Promise<FigSpec | null> => {
|
||||
try {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.loadFigSpec) {
|
||||
// Don't cache — bridge may not be ready yet (dev reload, non-Electron preview)
|
||||
return null;
|
||||
}
|
||||
|
||||
const spec = await bridge.loadFigSpec(commandName);
|
||||
if (spec) {
|
||||
specCache.set(commandName, spec);
|
||||
}
|
||||
// Don't cache null — the load may have failed transiently (bridge not ready, etc.)
|
||||
// Only cache null when we're confident the spec doesn't exist (hasSpec returned false)
|
||||
return spec;
|
||||
} catch {
|
||||
// Don't cache failures — allow retry on next request
|
||||
return null;
|
||||
} finally {
|
||||
inFlightLoads.delete(commandName);
|
||||
}
|
||||
})();
|
||||
|
||||
inFlightLoads.set(commandName, loadPromise);
|
||||
return loadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a spec exists for a given command name (without loading it).
|
||||
*/
|
||||
export async function hasSpec(commandName: string): Promise<boolean> {
|
||||
// Only trust positive cache hits (spec loaded successfully).
|
||||
// Null entries may be stale failures from preload — ignore them.
|
||||
const cached = specCache.get(commandName);
|
||||
if (cached) return true;
|
||||
|
||||
await getAvailableSpecs();
|
||||
return availableSpecsSet?.has(commandName) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common shell commands preloaded when autocomplete is enabled.
|
||||
* Includes local overrides under electron/specs/ (e.g. yum, dnf, awk).
|
||||
*/
|
||||
export const COMMON_FIG_SPECS = [
|
||||
"git", "docker", "kubectl", "npm", "yarn", "pnpm",
|
||||
"ls", "cd", "cat", "grep", "find", "ssh", "scp",
|
||||
"curl", "wget", "tar", "zip", "unzip", "make",
|
||||
"python", "python3", "pip", "pip3", "node",
|
||||
"systemctl", "journalctl", "apt", "yum", "dnf", "brew",
|
||||
"vim", "nano", "less", "head", "tail", "sort",
|
||||
"awk", "sed", "chmod", "chown", "cp", "mv", "rm", "mkdir",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Preload commonly used specs in batches to avoid overwhelming IPC.
|
||||
* Only call this when autocomplete is enabled.
|
||||
*/
|
||||
export function preloadCommonSpecs(): void {
|
||||
const BATCH_SIZE = 8;
|
||||
let offset = 0;
|
||||
|
||||
const loadBatch = () => {
|
||||
const batch = COMMON_FIG_SPECS.slice(offset, offset + BATCH_SIZE);
|
||||
if (batch.length === 0) return;
|
||||
|
||||
for (const name of batch) {
|
||||
loadSpec(name).catch(() => {});
|
||||
}
|
||||
|
||||
offset += BATCH_SIZE;
|
||||
if (offset < COMMON_FIG_SPECS.length) {
|
||||
if (typeof requestIdleCallback === "function") {
|
||||
requestIdleCallback(() => loadBatch());
|
||||
} else {
|
||||
setTimeout(loadBatch, 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(loadBatch, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get normalized name variants (e.g., "git" from "/usr/bin/git").
|
||||
*/
|
||||
export function normalizeCommandName(rawCommand: string): string {
|
||||
const parts = rawCommand.split("/");
|
||||
let name = parts[parts.length - 1];
|
||||
name = name.replace(/\.(exe|cmd|bat|sh|bash|zsh|fish)$/i, "");
|
||||
return name.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve names from a Fig spec name field (which can be string or string[]).
|
||||
*/
|
||||
export function resolveNames(name: string | string[]): string[] {
|
||||
return Array.isArray(name) ? name : [name];
|
||||
}
|
||||
24
components/terminal/autocomplete/ghostSuggestionPolicy.ts
Normal file
24
components/terminal/autocomplete/ghostSuggestionPolicy.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export type GhostSuggestionDecision =
|
||||
| { type: "keep" }
|
||||
| { type: "show"; suggestion: string }
|
||||
| { type: "hide" };
|
||||
|
||||
/**
|
||||
* Prefer a stable ghost suggestion while the user's typed input still
|
||||
* falls within the currently shown prediction. This avoids a "jitter"
|
||||
* effect where freshly fetched suggestions keep replacing the same
|
||||
* visual prediction one character at a time.
|
||||
*/
|
||||
export function decideGhostSuggestion(
|
||||
activeSuggestion: string | null,
|
||||
input: string,
|
||||
nextSuggestion: string | null,
|
||||
): GhostSuggestionDecision {
|
||||
if (activeSuggestion && activeSuggestion.startsWith(input)) {
|
||||
return { type: "keep" };
|
||||
}
|
||||
if (nextSuggestion && nextSuggestion.startsWith(input)) {
|
||||
return { type: "show", suggestion: nextSuggestion };
|
||||
}
|
||||
return { type: "hide" };
|
||||
}
|
||||
42
components/terminal/autocomplete/ghostTextConsistency.ts
Normal file
42
components/terminal/autocomplete/ghostTextConsistency.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Fail-safe consistency check for inline (ghost-text) suggestions.
|
||||
*
|
||||
* Ghost text renders `suggestion.substring(trackedInput.length)` after the
|
||||
* cursor, where `trackedInput` is what the client thinks the user has typed.
|
||||
* On hosts with non-standard echo (hardware bastion hosts / network OS such as
|
||||
* `ecOS#`, issue #1013, previously #756 / #906) that tracked value drifts out
|
||||
* of sync with what is actually on the terminal line, and the ghost ends up
|
||||
* painted over characters the user already typed (`int` + ghost `terface` →
|
||||
* `intterface`).
|
||||
*
|
||||
* This detects the one direction that produces visible corruption: the real
|
||||
* line being AHEAD of the tracked input (it contains the tracked input
|
||||
* followed by more, untracked characters). SSH echo latency is the opposite
|
||||
* case — the line is a prefix-behind of the tracked input — and is
|
||||
* intentionally NOT flagged, so the ghost stays responsive on slow links.
|
||||
*
|
||||
* Returns true when the caller should hide the ghost.
|
||||
*/
|
||||
export function lineHasUntrackedTrailingInput(
|
||||
trackedInput: string,
|
||||
lineBeforeCursor: string,
|
||||
): boolean {
|
||||
// Single chars match too loosely to judge reliably; let them through.
|
||||
if (trackedInput.length < 2) return false;
|
||||
// Column↔string mapping is only unambiguous for narrow (ASCII) input, so the
|
||||
// existing wide-char (CJK / emoji) handling is left untouched.
|
||||
if (!/^[\x20-\x7e]+$/.test(trackedInput)) return false;
|
||||
|
||||
// Use the last occurrence so a prompt or command that repeats the same token
|
||||
// earlier on the line doesn't shadow the freshly-typed input.
|
||||
const idx = lineBeforeCursor.lastIndexOf(trackedInput);
|
||||
if (idx < 0) {
|
||||
// Tracked input isn't on screen yet — the echo is still catching up
|
||||
// (latency). Keep the ghost; reality being behind never corrupts.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Non-whitespace characters between the tracked input and the cursor mean the
|
||||
// device echoed input we never tracked → the ghost would overlap real text.
|
||||
return lineBeforeCursor.slice(idx + trackedInput.length).trimEnd().length > 0;
|
||||
}
|
||||
6
components/terminal/autocomplete/index.ts
Normal file
6
components/terminal/autocomplete/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { useTerminalAutocomplete, DEFAULT_AUTOCOMPLETE_SETTINGS } from "./useTerminalAutocomplete";
|
||||
export type { AutocompleteSettings, AutocompleteState, TerminalAutocompleteHandle } from "./useTerminalAutocomplete";
|
||||
export { default as AutocompletePopup } from "./AutocompletePopup";
|
||||
export type { CompletionSuggestion, SuggestionSource } from "./completionEngine";
|
||||
export { recordCommand, removeCommandHistoryEntry, clearHistory } from "./commandHistoryStore";
|
||||
export { shellEscape } from "./completionEngine";
|
||||
55
components/terminal/autocomplete/livePreviewSequence.ts
Normal file
55
components/terminal/autocomplete/livePreviewSequence.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Compute the keystrokes to send so the terminal input line becomes exactly
|
||||
* `candidate`, given what is currently on the line. Drives the popup
|
||||
* autocomplete live-preview (#1005): moving the selection renders the chosen
|
||||
* suggestion into the command line, and switching / reverting rewrites it.
|
||||
*
|
||||
* - Forward prefix (candidate continues the line): append only the new tail.
|
||||
* - Otherwise: clear the current input, then write the full candidate. POSIX
|
||||
* shells use Ctrl-U (kill-line); Windows (cmd/PowerShell) uses backspaces
|
||||
* sized to the current line length.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Live-preview rewrites inject Ctrl-U / backspaces into the PTY. Vendor
|
||||
* bastion and network-device CLIs treat those bytes as session-kill, so
|
||||
* network-device sessions keep the popup but skip the rewrite (#1193).
|
||||
*/
|
||||
export function shouldWriteAutocompleteLivePreview(
|
||||
livePreviewEnabled: boolean,
|
||||
isNetworkDevice = false,
|
||||
): boolean {
|
||||
return livePreviewEnabled && !isNetworkDevice;
|
||||
}
|
||||
|
||||
export function isWindowsShellLineInput(
|
||||
os: string,
|
||||
promptText?: string | null,
|
||||
): boolean {
|
||||
if (os === "windows") return true;
|
||||
// Hosts default to os:"linux" and the flag is easy to leave wrong. Windows
|
||||
// shells do not kill the line on Ctrl-U; PSReadLine renders the raw byte
|
||||
// literally (e.g. `tkn^Uuv run ...`), so every highlighted suggestion piles
|
||||
// onto the command line (#3184). The detected prompt is authoritative when
|
||||
// the flag disagrees: a drive-letter path with a backslash (`PS C:\Users>`,
|
||||
// `C:\Windows>`) only occurs in a Windows shell prompt.
|
||||
return typeof promptText === "string" && /(?:^|\s)[A-Za-z]:\\/.test(promptText);
|
||||
}
|
||||
|
||||
export function computeLivePreviewWrite(input: {
|
||||
currentLine: string;
|
||||
candidate: string;
|
||||
os: string;
|
||||
/** Detected prompt text; lets a mislabeled host OS flag still clear the line (#3184). */
|
||||
promptText?: string;
|
||||
}): string {
|
||||
const { currentLine, candidate, os } = input;
|
||||
if (candidate === currentLine) return "";
|
||||
if (candidate.startsWith(currentLine)) {
|
||||
return candidate.slice(currentLine.length);
|
||||
}
|
||||
const clear = isWindowsShellLineInput(os, input.promptText)
|
||||
? "\b".repeat(currentLine.length)
|
||||
: "\x15";
|
||||
return clear + candidate;
|
||||
}
|
||||
177
components/terminal/autocomplete/localShellHistorySeed.test.ts
Normal file
177
components/terminal/autocomplete/localShellHistorySeed.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
type LocalStorageMock = {
|
||||
clear(): void;
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
};
|
||||
|
||||
function installLocalStorage(): LocalStorageMock {
|
||||
const store = new Map<string, string>();
|
||||
const localStorage: LocalStorageMock = {
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
getItem(key: string) {
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: localStorage,
|
||||
configurable: true,
|
||||
});
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
const localStorage = installLocalStorage();
|
||||
|
||||
const files = new Map<string, string>();
|
||||
let bridgeEnabled = true;
|
||||
const bridge = {
|
||||
getHomeDir: async () => (bridgeEnabled ? "/Users/demo" : Promise.reject(new Error("no bridge"))),
|
||||
readLocalFile: async (path: string, options?: { maxBytes?: number }) => {
|
||||
if (!bridgeEnabled) throw new Error("no bridge");
|
||||
const text = files.get(path);
|
||||
if (text === undefined) throw new Error(`ENOENT: ${path}`);
|
||||
let bytes = new TextEncoder().encode(text);
|
||||
if (options?.maxBytes && bytes.byteLength > options.maxBytes) {
|
||||
bytes = bytes.subarray(bytes.byteLength - options.maxBytes);
|
||||
}
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: { electron: bridge, netcatty: bridge },
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const { clearHistory, queryHistory } = await import("./commandHistoryStore.ts");
|
||||
const { seedLocalShellHistoryFromHistfiles } = await import("./localShellHistorySeed.ts");
|
||||
const { getCompletions } = await import("./completionEngine.ts");
|
||||
|
||||
test.beforeEach(() => {
|
||||
localStorage.clear();
|
||||
clearHistory();
|
||||
files.clear();
|
||||
bridgeEnabled = true;
|
||||
(window as Window & { netcatty?: unknown }).netcatty = bridge;
|
||||
});
|
||||
|
||||
test("seedLocalShellHistoryFromHistfiles imports zsh history for autocomplete prefix match", async () => {
|
||||
const hostId = "local-terminal";
|
||||
files.set(
|
||||
"/Users/demo/.zsh_history",
|
||||
": 1700000000:0;sudo xattr -rd com.apple.quarantine /Applications/ClashX\\ Meta.app\n",
|
||||
);
|
||||
|
||||
const seeded = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||||
assert.ok(seeded > 0);
|
||||
|
||||
const matches = queryHistory("sudo xattr", { hostId, limit: 5 });
|
||||
assert.equal(matches.length, 1);
|
||||
assert.match(matches[0].command, /ClashX/);
|
||||
|
||||
const completions = await getCompletions("sudo xattr", {
|
||||
hostId,
|
||||
os: "macos",
|
||||
protocol: "local",
|
||||
});
|
||||
assert.ok(
|
||||
completions.some((c) => c.source === "history" && c.text.includes("ClashX")),
|
||||
`expected history completion, got ${JSON.stringify(completions.map((c) => ({ s: c.source, t: c.text })))}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("seedLocalShellHistoryFromHistfiles is idempotent for the same host after a successful import", async () => {
|
||||
const hostId = "local-terminal";
|
||||
files.set("/Users/demo/.zsh_history", ": 1700000000:0;pwd\n: 1700000001:0;ls\n");
|
||||
|
||||
const first = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||||
const second = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||||
assert.equal(first, 2);
|
||||
assert.equal(second, 0);
|
||||
});
|
||||
|
||||
test("seedLocalShellHistoryFromHistfiles retries when histfiles were empty", async () => {
|
||||
const hostId = "local-terminal";
|
||||
|
||||
const first = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||||
assert.equal(first, 0);
|
||||
|
||||
files.set("/Users/demo/.zsh_history", ": 1700000000:0;echo later\n");
|
||||
const second = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||||
assert.equal(second, 1);
|
||||
assert.equal(queryHistory("echo", { hostId, limit: 5 }).length, 1);
|
||||
});
|
||||
|
||||
test("seedLocalShellHistoryFromHistfiles no-ops without a bridge and stays retryable", async () => {
|
||||
const hostId = "local-terminal";
|
||||
(window as Window & { netcatty?: unknown }).netcatty = undefined;
|
||||
|
||||
const first = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||||
assert.equal(first, 0);
|
||||
|
||||
(window as Window & { netcatty?: unknown }).netcatty = bridge;
|
||||
files.set("/Users/demo/.zsh_history", ": 1700000000:0;pwd\n");
|
||||
const second = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||||
assert.equal(second, 1);
|
||||
});
|
||||
|
||||
test("seedLocalShellHistoryFromHistfiles dedupes concurrent calls for the same host", async () => {
|
||||
const hostId = "local-terminal";
|
||||
files.set("/Users/demo/.zsh_history", ": 1700000000:0;pwd\n");
|
||||
|
||||
// Start the first seed without awaiting so the second call overlaps in-flight.
|
||||
const firstPromise = seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||||
const secondPromise = seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||||
const [a, b] = await Promise.all([firstPromise, secondPromise]);
|
||||
assert.equal(a, 1);
|
||||
assert.equal(b, 1);
|
||||
assert.equal(queryHistory("pw", { hostId, limit: 5 }).length, 1);
|
||||
});
|
||||
|
||||
test("seedLocalShellHistoryFromHistfiles drops a partial first line from a full-budget histfile tail", async () => {
|
||||
const hostId = "local-terminal";
|
||||
// Simulate a main-process maxBytes tail: exactly 512KiB ending mid-command,
|
||||
// then a complete command on the next line.
|
||||
const maxBytes = 512 * 1024;
|
||||
const complete = ": 1700000001:0;echo complete\n";
|
||||
const partialPrefix = "PARTIAL_TRUNCATED_COMMAND_WITHOUT_NEWLINE";
|
||||
const overhead = Buffer.byteLength(`${partialPrefix}\n\n${complete}`, "utf8");
|
||||
const filler = "x".repeat(maxBytes - overhead);
|
||||
const tail = `${partialPrefix}\n${filler}\n${complete}`;
|
||||
assert.equal(Buffer.byteLength(tail, "utf8"), maxBytes);
|
||||
|
||||
files.set("/Users/demo/.zsh_history", tail);
|
||||
const seeded = await seedLocalShellHistoryFromHistfiles(hostId, "macos");
|
||||
assert.ok(seeded >= 1);
|
||||
assert.equal(queryHistory("echo", { hostId, limit: 5 })[0]?.command, "echo complete");
|
||||
assert.equal(queryHistory("PARTIAL", { hostId, limit: 5 }).length, 0);
|
||||
});
|
||||
|
||||
test("seedLocalShellHistoryFromHistfiles joins Windows home paths for fish history", async () => {
|
||||
const hostId = "local-terminal";
|
||||
const previousHome = bridge.getHomeDir;
|
||||
bridge.getHomeDir = async () => "C:\\Users\\demo";
|
||||
try {
|
||||
files.set(
|
||||
"C:\\Users\\demo\\.config\\fish\\fish_history",
|
||||
"- cmd: echo fish\n when: 1700000000\n",
|
||||
);
|
||||
|
||||
const seeded = await seedLocalShellHistoryFromHistfiles(hostId, "windows");
|
||||
assert.equal(seeded, 1);
|
||||
assert.equal(queryHistory("echo", { hostId, limit: 5 })[0]?.command, "echo fish");
|
||||
} finally {
|
||||
bridge.getHomeDir = previousHome;
|
||||
}
|
||||
});
|
||||
163
components/terminal/autocomplete/localShellHistorySeed.ts
Normal file
163
components/terminal/autocomplete/localShellHistorySeed.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Seed autocomplete command history from the local machine's shell histfiles.
|
||||
*
|
||||
* Local Terminal sessions previously used a per-session hostId (`local-${sessionId}`),
|
||||
* so Netcatty's autocomplete history never accumulated across opens. Even with a
|
||||
* stable hostId, a fresh install / new machine has an empty store until the user
|
||||
* types commands inside Netcatty — while Ghostty (and similar terminals) surface
|
||||
* suggestions from ~/.zsh_history / ~/.bash_history immediately.
|
||||
*
|
||||
* This module imports those histfiles once per hostId into commandHistoryStore
|
||||
* so prefix autocomplete can match them.
|
||||
*/
|
||||
|
||||
import {
|
||||
isNetcattyAiHistoryCommand,
|
||||
isNetcattyManagedStartupHistoryCommand,
|
||||
mergeRemoteHistory,
|
||||
parseBashHistory,
|
||||
parseFishHistory,
|
||||
parseZshHistory,
|
||||
} from "../../../domain/remoteHistory";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { flushCommandHistoryStore, recordCommand } from "./commandHistoryStore";
|
||||
|
||||
const SEED_FLAG_PREFIX = "netcatty:localHistSeeded:";
|
||||
const MAX_SEED_COMMANDS = 500;
|
||||
/** Cap histfile reads so a multi-MB history does not stall Local Terminal mount. */
|
||||
const MAX_HISTFILE_BYTES = 512 * 1024;
|
||||
|
||||
type LocalFsBridge = {
|
||||
getHomeDir?: () => Promise<string>;
|
||||
readLocalFile?: (
|
||||
path: string,
|
||||
options?: { maxBytes?: number },
|
||||
) => Promise<ArrayBuffer | Buffer | Uint8Array | string>;
|
||||
};
|
||||
|
||||
const inFlightSeeds = new Map<string, Promise<number>>();
|
||||
|
||||
function getBridge(): LocalFsBridge | undefined {
|
||||
return (window as Window & { netcatty?: LocalFsBridge }).netcatty;
|
||||
}
|
||||
|
||||
function joinHomePath(home: string, relativeUnix: string): string {
|
||||
const normalizedHome = home.replace(/[/\\]+$/, "");
|
||||
const sep = home.includes("\\") && !home.includes("/") ? "\\" : "/";
|
||||
const relative = sep === "\\" ? relativeUnix.replace(/\//g, "\\") : relativeUnix;
|
||||
return `${normalizedHome}${sep}${relative}`;
|
||||
}
|
||||
|
||||
function decodeHistfileBytes(bytes: Uint8Array): string {
|
||||
// Main-process reads already return at most MAX_HISTFILE_BYTES. A buffer that
|
||||
// fills the budget is treated as a truncated tail, so drop the first
|
||||
// (possibly partial) line before parsing.
|
||||
let text = new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||
if (bytes.byteLength >= MAX_HISTFILE_BYTES) {
|
||||
const firstNewline = text.indexOf("\n");
|
||||
if (firstNewline >= 0) text = text.slice(firstNewline + 1);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
async function readTextFile(bridge: LocalFsBridge, path: string): Promise<string | null> {
|
||||
if (!bridge.readLocalFile) return null;
|
||||
try {
|
||||
// Ask the main process to return only the trailing bytes so multi-MB
|
||||
// histfiles never cross the IPC boundary in full.
|
||||
const raw = await bridge.readLocalFile(path, { maxBytes: MAX_HISTFILE_BYTES });
|
||||
if (typeof raw === "string") {
|
||||
// Bridge returned a string (tests / alternate adapters). Cap by UTF-8
|
||||
// byte length so this path matches the binary branch.
|
||||
const encoded = new TextEncoder().encode(raw);
|
||||
return decodeHistfileBytes(encoded);
|
||||
}
|
||||
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw);
|
||||
return decodeHistfileBytes(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function alreadySeeded(hostId: string): boolean {
|
||||
return localStorageAdapter.readBoolean(`${SEED_FLAG_PREFIX}${hostId}`) === true;
|
||||
}
|
||||
|
||||
function markSeeded(hostId: string): void {
|
||||
localStorageAdapter.writeBoolean(`${SEED_FLAG_PREFIX}${hostId}`, true);
|
||||
}
|
||||
|
||||
async function seedLocalShellHistoryFromHistfilesOnce(
|
||||
hostId: string,
|
||||
os: "linux" | "windows" | "macos",
|
||||
): Promise<number> {
|
||||
if (!hostId || alreadySeeded(hostId)) return 0;
|
||||
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.getHomeDir || !bridge.readLocalFile) return 0;
|
||||
|
||||
let home: string;
|
||||
try {
|
||||
home = await bridge.getHomeDir();
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
if (!home) return 0;
|
||||
|
||||
const [zshText, bashText, fishText, fishAltText] = await Promise.all([
|
||||
readTextFile(bridge, joinHomePath(home, ".zsh_history")),
|
||||
readTextFile(bridge, joinHomePath(home, ".bash_history")),
|
||||
readTextFile(bridge, joinHomePath(home, ".local/share/fish/fish_history")),
|
||||
readTextFile(bridge, joinHomePath(home, ".config/fish/fish_history")),
|
||||
]);
|
||||
|
||||
const lists = [
|
||||
zshText ? parseZshHistory(zshText) : [],
|
||||
bashText ? parseBashHistory(bashText) : [],
|
||||
fishText ? parseFishHistory(fishText) : [],
|
||||
!fishText && fishAltText ? parseFishHistory(fishAltText) : [],
|
||||
];
|
||||
|
||||
const merged = mergeRemoteHistory(lists, MAX_SEED_COMMANDS);
|
||||
let recorded = 0;
|
||||
// mergeRemoteHistory returns newest-first; record oldest-first so frequency /
|
||||
// lastUsedAt ordering stays sensible if the same command appears later.
|
||||
for (const entry of [...merged].reverse()) {
|
||||
const command = entry.command.trim();
|
||||
if (!command) continue;
|
||||
if (isNetcattyAiHistoryCommand(command)) continue;
|
||||
if (isNetcattyManagedStartupHistoryCommand(command)) continue;
|
||||
recordCommand(command, hostId, os);
|
||||
recorded += 1;
|
||||
}
|
||||
|
||||
// Only persist the seeded flag after we actually imported commands and
|
||||
// flushed the store. An empty/missing histfile must remain retryable so a
|
||||
// later Local Terminal open can pick up history once it exists (#2037).
|
||||
if (recorded > 0 && flushCommandHistoryStore()) {
|
||||
markSeeded(hostId);
|
||||
}
|
||||
return recorded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import local shell histfiles into the autocomplete history store for `hostId`.
|
||||
* Returns the number of commands newly recorded. No-ops when already seeded for
|
||||
* this hostId, when the local FS bridge is unavailable, or when histfiles are
|
||||
* empty/missing (those cases stay retryable on the next Local Terminal open).
|
||||
*/
|
||||
export async function seedLocalShellHistoryFromHistfiles(
|
||||
hostId: string,
|
||||
os: "linux" | "windows" | "macos" = "macos",
|
||||
): Promise<number> {
|
||||
if (!hostId || alreadySeeded(hostId)) return 0;
|
||||
|
||||
const existing = inFlightSeeds.get(hostId);
|
||||
if (existing) return existing;
|
||||
|
||||
const pending = seedLocalShellHistoryFromHistfilesOnce(hostId, os).finally(() => {
|
||||
inFlightSeeds.delete(hostId);
|
||||
});
|
||||
inFlightSeeds.set(hostId, pending);
|
||||
return pending;
|
||||
}
|
||||
973
components/terminal/autocomplete/promptDetector.ts
Normal file
973
components/terminal/autocomplete/promptDetector.ts
Normal file
@@ -0,0 +1,973 @@
|
||||
/**
|
||||
* Prompt detector for terminal autocomplete.
|
||||
* Detects whether the user is currently at a shell prompt (vs. inside a running program).
|
||||
* Uses xterm.js buffer analysis to identify common prompt patterns.
|
||||
*
|
||||
* Strategy: scan prompt-looking boundaries ($ # % >, Powerline/Nerd Font glyphs,
|
||||
* etc.) and choose the most reliable split for prompt text vs. user input.
|
||||
*/
|
||||
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { isSensitiveTerminalChallenge } from "../../../domain/terminalPromptSecurity";
|
||||
import { sliceStringByCellColumns } from "./terminalStringCellWidth";
|
||||
import { COMMON_SHELL_COMMANDS, NON_PROMPT_PATTERNS, PROMPT_CHARS } from "./promptDetectorPatterns";
|
||||
|
||||
export interface PromptDetectionResult {
|
||||
/** Whether a prompt is detected on the current line */
|
||||
isAtPrompt: boolean;
|
||||
/** The detected prompt text (everything before user input) */
|
||||
promptText: string;
|
||||
/** The user's current input (after the prompt) */
|
||||
userInput: string;
|
||||
/** The cursor column position within the user input */
|
||||
cursorOffset: number;
|
||||
}
|
||||
|
||||
const NO_PROMPT: PromptDetectionResult = {
|
||||
isAtPrompt: false, promptText: "", userInput: "", cursorOffset: 0,
|
||||
};
|
||||
|
||||
export function isNonPromptLine(lineText: string): boolean {
|
||||
return NON_PROMPT_PATTERNS.some((pattern) => pattern.test(lineText));
|
||||
}
|
||||
|
||||
function isSpecificShellPromptCandidate(
|
||||
promptText: string,
|
||||
options: { allowGreaterThanTerminator?: boolean } = {},
|
||||
): boolean {
|
||||
const trimmed = promptText.trim();
|
||||
if (
|
||||
!options.allowGreaterThanTerminator &&
|
||||
(trimmed.endsWith(">") || trimmed.endsWith("›"))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return trimmed.length >= 6 && /[@:\\/~\])]/.test(trimmed);
|
||||
}
|
||||
|
||||
function isLikelyNoSpaceShellPromptText(promptText: string): boolean {
|
||||
const trimmed = promptText.trim();
|
||||
if (/^root[#%$]$/.test(trimmed)) return true;
|
||||
if (trimmed.length < 3) return false;
|
||||
|
||||
const marker = trimmed[trimmed.length - 1];
|
||||
if (!PROMPT_CHARS.has(marker) && !isPuaChar(marker)) return false;
|
||||
|
||||
const prev = trimmed[trimmed.length - 2] ?? "";
|
||||
return /[~:/\\\])]/.test(prev);
|
||||
}
|
||||
|
||||
export interface AlignedPromptResult {
|
||||
/** The prompt view every consumer should use for parsing / suggestion lookup / line rewrites. */
|
||||
prompt: PromptDetectionResult;
|
||||
/**
|
||||
* The keystroke buffer, but only when it's both marked reliable AND
|
||||
* can be validated against the live terminal line. Returns null
|
||||
* otherwise - the single signal downstream uses to decide whether
|
||||
* to record it as the executed command.
|
||||
*/
|
||||
alignedTyped: string | null;
|
||||
/**
|
||||
* When false, `prompt.userInput` was filled from the keystroke buffer
|
||||
* before any shell echo. Empty echo is also what echo-disabled password
|
||||
* prompts look like, so callers must not surface or accept completions
|
||||
* (built-in or external) and must not authorize history recording
|
||||
* (`alignedTyped`). Omitted/true means the live line validated input.
|
||||
*/
|
||||
allowExternalProviders?: boolean;
|
||||
}
|
||||
|
||||
function getCursorLinePrefix(term: XTerm): string | null {
|
||||
const buffer = term.buffer.active;
|
||||
const cursorY = buffer.cursorY + buffer.baseY;
|
||||
const line = buffer.getLine(cursorY);
|
||||
|
||||
if (!line) return null;
|
||||
|
||||
const lineText = line.translateToString(false);
|
||||
return sliceStringByCellColumns(lineText, 0, Math.max(0, buffer.cursorX), term);
|
||||
}
|
||||
|
||||
function getWrappedCursorPrefix(term: XTerm): string | null {
|
||||
const buffer = term.buffer.active;
|
||||
const cursorY = buffer.cursorY + buffer.baseY;
|
||||
const cursorX = buffer.cursorX;
|
||||
const line = buffer.getLine(cursorY);
|
||||
|
||||
if (!line?.isWrapped) return null;
|
||||
|
||||
let promptRow = cursorY - 1;
|
||||
while (promptRow >= 0) {
|
||||
const prevLine = buffer.getLine(promptRow);
|
||||
if (!prevLine) return null;
|
||||
if (!prevLine.isWrapped) break;
|
||||
promptRow--;
|
||||
}
|
||||
|
||||
const promptLine = buffer.getLine(promptRow);
|
||||
if (!promptLine) return null;
|
||||
|
||||
let prefix = promptLine.translateToString(false);
|
||||
for (let row = promptRow + 1; row < cursorY; row++) {
|
||||
const rowLine = buffer.getLine(row);
|
||||
if (!rowLine) return null;
|
||||
prefix += rowLine.translateToString(false);
|
||||
}
|
||||
|
||||
const cursorRowText = line.translateToString(false);
|
||||
return prefix + sliceStringByCellColumns(cursorRowText, 0, Math.max(0, cursorX), term);
|
||||
}
|
||||
|
||||
function inferPromptTextBeforeTypedInput(
|
||||
cursorPrefix: string,
|
||||
typedBuffer: string,
|
||||
allowPartialEcho: boolean,
|
||||
): string | null {
|
||||
if (cursorPrefix.endsWith(typedBuffer)) {
|
||||
const promptText = cursorPrefix.slice(0, cursorPrefix.length - typedBuffer.length);
|
||||
return promptText.length > 0 ? promptText : null;
|
||||
}
|
||||
|
||||
if (!allowPartialEcho) return null;
|
||||
|
||||
const maxEchoLength = Math.min(cursorPrefix.length, typedBuffer.length);
|
||||
const minPartialEchoLength = Math.max(6, typedBuffer.length - 2);
|
||||
for (let echoLength = maxEchoLength - 1; echoLength >= minPartialEchoLength; echoLength--) {
|
||||
const echoedInput = typedBuffer.slice(0, echoLength);
|
||||
if (!cursorPrefix.endsWith(echoedInput)) continue;
|
||||
|
||||
const promptText = cursorPrefix.slice(0, cursorPrefix.length - echoLength);
|
||||
if (promptText.length > 0) return promptText;
|
||||
}
|
||||
|
||||
const noSpacePromptMinEchoLength = typedBuffer.trim().length <= 2 ? 1 : 3;
|
||||
for (
|
||||
let echoLength = Math.min(maxEchoLength - 1, minPartialEchoLength - 1);
|
||||
echoLength >= noSpacePromptMinEchoLength;
|
||||
echoLength--
|
||||
) {
|
||||
const echoedInput = typedBuffer.slice(0, echoLength);
|
||||
if (!cursorPrefix.endsWith(echoedInput)) continue;
|
||||
const hasReliablePartialEcho =
|
||||
typedBuffer.trim().length <= 2 ||
|
||||
echoedInput.endsWith(" ") ||
|
||||
(echoedInput.includes(" ") && echoedInput.length >= 4);
|
||||
if (!hasReliablePartialEcho) continue;
|
||||
|
||||
const promptText = cursorPrefix.slice(0, cursorPrefix.length - echoLength);
|
||||
if (isLikelyNoSpaceShellPromptText(promptText)) return promptText;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasSwallowedCommandAfterPrompt(promptText: string, promptBoundary: number): boolean {
|
||||
const candidate = promptText.slice(0, promptBoundary).trimEnd();
|
||||
const finalIndex = candidate.length - 1;
|
||||
const finalChar = finalIndex >= 0 ? candidate[finalIndex] : "";
|
||||
|
||||
for (let i = 0; i < finalIndex; i++) {
|
||||
const ch = candidate[i];
|
||||
if (!PROMPT_CHARS.has(ch) && !isPuaChar(ch)) continue;
|
||||
|
||||
const nextChar = i + 1 < candidate.length ? candidate[i + 1] : null;
|
||||
if (nextChar === null || nextChar === " ") continue;
|
||||
|
||||
const earlierPrompt = candidate.slice(0, i + 1);
|
||||
if (isLikelyNoSpaceShellPromptText(earlierPrompt)) return true;
|
||||
if (isEmbeddedPromptMarkerAt(candidate, i)) continue;
|
||||
if (!isSpecificShellPromptCandidate(earlierPrompt)) continue;
|
||||
if (PROMPT_CHARS.has(nextChar) || isPuaChar(nextChar)) return true;
|
||||
if (startsWithCommonShellCommand(candidate.slice(i + 1))) return true;
|
||||
if (finalChar !== "$") return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function canUseInferredPromptText(promptText: string, rawIsAtPrompt: boolean): boolean {
|
||||
if (promptText.length === 0) return false;
|
||||
if (rawIsAtPrompt) return true;
|
||||
|
||||
const promptBoundary = findPromptBoundary(promptText);
|
||||
const promptEndsAtBoundary =
|
||||
promptBoundary >= 0 && promptText.slice(promptBoundary).trim().length === 0;
|
||||
return (
|
||||
promptEndsAtBoundary &&
|
||||
!hasSwallowedCommandAfterPrompt(promptText, promptBoundary) &&
|
||||
isSpecificShellPromptCandidate(promptText)
|
||||
);
|
||||
}
|
||||
|
||||
function isThemedPromptText(promptText: string): boolean {
|
||||
for (const ch of promptText) {
|
||||
if (isPuaChar(ch)) return true;
|
||||
}
|
||||
return /[❯❮→➜➤⟩»›]/.test(promptText);
|
||||
}
|
||||
|
||||
function isPromptPathDecoration(trimmed: string): boolean {
|
||||
return (
|
||||
trimmed === "~" ||
|
||||
trimmed.startsWith("~/") ||
|
||||
trimmed.startsWith("/") ||
|
||||
/^[A-Za-z]:[\\/]/.test(trimmed) ||
|
||||
trimmed.includes("\\")
|
||||
);
|
||||
}
|
||||
|
||||
function isPromptBareDirectoryText(trimmed: string): boolean {
|
||||
if (trimmed.startsWith("./") || trimmed.startsWith("../")) return false;
|
||||
return /^[\w.-]+$/.test(trimmed);
|
||||
}
|
||||
|
||||
function isPromptStatusToken(token: string): boolean {
|
||||
return (
|
||||
/^git:\([^)]*\)$/.test(token) ||
|
||||
/^[+$#%>!?*]$/.test(token) ||
|
||||
token === "✗" ||
|
||||
token === "✔"
|
||||
);
|
||||
}
|
||||
|
||||
function isPromptStatusText(trimmed: string): boolean {
|
||||
const [first = "", ...rest] = trimmed.split(/\s+/);
|
||||
if (rest.length === 0) return false;
|
||||
if (!isPromptBareDirectoryText(first) && !isPromptPathDecoration(first)) return false;
|
||||
return rest.every(isPromptStatusToken);
|
||||
}
|
||||
|
||||
function isPromptStatusDecoration(extra: string): boolean {
|
||||
if (!/^\s+/.test(extra) || !/\s+$/.test(extra)) return false;
|
||||
|
||||
return isPromptStatusText(extra.trim());
|
||||
}
|
||||
|
||||
function isPromptDecorationExtra(extra: string, promptText: string): boolean {
|
||||
const trimmed = extra.trim();
|
||||
if (trimmed.length === 0) return false;
|
||||
if (!isThemedPromptText(promptText)) return false;
|
||||
if (startsWithCommonShellCommand(extra)) return false;
|
||||
if (/^\s*\S+\s+$/.test(extra)) {
|
||||
return isPromptPathDecoration(trimmed) || (
|
||||
isPromptBareDirectoryText(trimmed) &&
|
||||
!startsWithCommonShellCommand(trimmed)
|
||||
);
|
||||
}
|
||||
if (isPromptStatusDecoration(extra)) return true;
|
||||
for (const ch of extra) {
|
||||
if (isPuaChar(ch)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getFinalPromptBoundary(promptText: string): number {
|
||||
const trimmedEnd = promptText.trimEnd().length;
|
||||
if (trimmedEnd === 0) return -1;
|
||||
|
||||
const markerIndex = trimmedEnd - 1;
|
||||
const marker = promptText[markerIndex];
|
||||
if (!PROMPT_CHARS.has(marker) && !isPuaChar(marker)) return -1;
|
||||
|
||||
const nextChar = markerIndex + 1 < promptText.length ? promptText[markerIndex + 1] : null;
|
||||
if (nextChar !== null && nextChar !== " ") return -1;
|
||||
return nextChar === " " ? markerIndex + 2 : markerIndex + 1;
|
||||
}
|
||||
|
||||
function endsAtFinalPromptBoundary(promptText: string): boolean {
|
||||
const promptBoundary = getFinalPromptBoundary(promptText);
|
||||
return promptBoundary >= 0 && promptText.slice(promptBoundary).trim().length === 0;
|
||||
}
|
||||
|
||||
function getLeadingShellCommandWord(text: string): string | null {
|
||||
return text.trimStart().match(/^[\w.-]+(?=\s|$)/)?.[0] ?? null;
|
||||
}
|
||||
|
||||
function startsWithCommonShellCommand(text: string): boolean {
|
||||
const command = getLeadingShellCommandWord(text);
|
||||
return command !== null && COMMON_SHELL_COMMANDS.has(command);
|
||||
}
|
||||
|
||||
function isCompleteSpecificPrompt(promptText: string): boolean {
|
||||
const promptBoundary = getFinalPromptBoundary(promptText);
|
||||
return (
|
||||
promptBoundary >= 0 &&
|
||||
promptText.slice(promptBoundary).trim().length === 0 &&
|
||||
isSpecificShellPromptCandidate(promptText) &&
|
||||
!isEmbeddedPromptMarker(promptText, promptBoundary)
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeCommandAfterCompletePrompt(promptText: string, extra: string): boolean {
|
||||
return isCompleteSpecificPrompt(promptText) && extra.trim().length > 0;
|
||||
}
|
||||
|
||||
function hasShellCommandAfterOptionalDecoration(text: string): boolean {
|
||||
const trimmedStart = text.trimStart();
|
||||
if (startsWithCommonShellCommand(trimmedStart)) return true;
|
||||
|
||||
const [, afterDecoration = ""] = trimmedStart.match(/^\S+\s+(.+)$/) ?? [];
|
||||
return startsWithCommonShellCommand(afterDecoration);
|
||||
}
|
||||
|
||||
function isSingleBareDirectoryExtra(extra: string): boolean {
|
||||
const trimmed = extra.trim();
|
||||
return /^\s*\S+\s+$/.test(extra) && isPromptBareDirectoryText(trimmed);
|
||||
}
|
||||
|
||||
function hasExplicitThemedDirectorySpacing(extra: string): boolean {
|
||||
return /^\s+\S+\s+$/.test(extra);
|
||||
}
|
||||
|
||||
type PromptDecorationReconcileOptions = {
|
||||
allowSingleWordCommandDirectory?: boolean;
|
||||
};
|
||||
|
||||
function canTreatCommonCommandNameAsThemedDirectory(
|
||||
extra: string,
|
||||
typedInput: string,
|
||||
options: PromptDecorationReconcileOptions = {},
|
||||
): boolean {
|
||||
const trimmedInput = typedInput.trim();
|
||||
return (
|
||||
isSingleBareDirectoryExtra(extra) &&
|
||||
(
|
||||
/\s/.test(trimmedInput) ||
|
||||
/^(?:ls|cd|pwd)$/.test(trimmedInput) ||
|
||||
(
|
||||
options.allowSingleWordCommandDirectory === true &&
|
||||
hasExplicitThemedDirectorySpacing(extra)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function canReconcilePromptDecoration(
|
||||
prompt: PromptDetectionResult,
|
||||
typedInput: string,
|
||||
options: PromptDecorationReconcileOptions = {},
|
||||
): boolean {
|
||||
if (
|
||||
!prompt.isAtPrompt ||
|
||||
!typedInput ||
|
||||
prompt.userInput.length <= typedInput.length ||
|
||||
!prompt.userInput.endsWith(typedInput)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const extra = prompt.userInput.slice(0, prompt.userInput.length - typedInput.length);
|
||||
if (looksLikeCommandAfterCompletePrompt(prompt.promptText, extra)) return false;
|
||||
if (
|
||||
isThemedPromptText(prompt.promptText) &&
|
||||
canTreatCommonCommandNameAsThemedDirectory(extra, typedInput, options)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (isThemedPromptText(prompt.promptText) && hasShellCommandAfterOptionalDecoration(extra)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const candidatePromptText = prompt.promptText + extra;
|
||||
const promptEndsAtBoundary =
|
||||
endsAtFinalPromptBoundary(candidatePromptText) &&
|
||||
isSpecificShellPromptCandidate(candidatePromptText);
|
||||
return promptEndsAtBoundary || isPromptDecorationExtra(extra, prompt.promptText);
|
||||
}
|
||||
|
||||
function alignTypedInputFromCursorPrefix(
|
||||
raw: PromptDetectionResult,
|
||||
cursorPrefix: string | null,
|
||||
typedBuffer: string,
|
||||
): AlignedPromptResult | null {
|
||||
if (!cursorPrefix) return null;
|
||||
if (!raw.isAtPrompt && isNonPromptLine(cursorPrefix)) return null;
|
||||
|
||||
const promptText = inferPromptTextBeforeTypedInput(cursorPrefix, typedBuffer, !raw.isAtPrompt);
|
||||
if (!promptText || !canUseInferredPromptText(promptText, raw.isAtPrompt)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: {
|
||||
isAtPrompt: true,
|
||||
promptText,
|
||||
userInput: typedBuffer,
|
||||
cursorOffset: typedBuffer.length,
|
||||
},
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
function canUseReliablePromptPrefix(
|
||||
raw: PromptDetectionResult,
|
||||
typedBuffer: string,
|
||||
): boolean {
|
||||
// Empty echo alone is not validation: echo-disabled prompts can look like
|
||||
// a normal shell PS1 (e.g. `read -s -p '$ '`), and treating the keystroke
|
||||
// buffer as alignedTyped would authorize history recording. Pre-echo
|
||||
// autocomplete uses a separate path that keeps alignedTyped null.
|
||||
if (!raw.isAtPrompt || typedBuffer.length === 0 || raw.userInput.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (typedBuffer.length <= raw.userInput.length) return false;
|
||||
return isReliableTypedPrefix(raw.userInput, typedBuffer, {
|
||||
allowShortEcho: allowsShortPromptEcho(raw.promptText),
|
||||
});
|
||||
}
|
||||
|
||||
function isLikelyBareMongoPromptName(promptName: string): boolean {
|
||||
return /^(?:test|admin|local|config)$/i.test(promptName);
|
||||
}
|
||||
|
||||
function endsWithHostStyleGreaterThanPrompt(promptText: string): boolean {
|
||||
const trimmed = promptText.trimEnd();
|
||||
if (!trimmed.endsWith(">")) return false;
|
||||
const promptName = trimmed.slice(0, -1).trim();
|
||||
return /^[\w.-]+$/.test(promptName) && !isLikelyBareMongoPromptName(promptName);
|
||||
}
|
||||
|
||||
function endsWithWindowsPathGreaterThanPrompt(promptText: string): boolean {
|
||||
const trimmed = promptText.trimEnd();
|
||||
if (!trimmed.endsWith(">")) return false;
|
||||
const before = trimmed.slice(0, -1).trimEnd();
|
||||
// cmd.exe: `C:\path>` / `C:\>`; PowerShell: `PS C:\path>`
|
||||
if (/^[A-Za-z]:[\\/]/.test(before)) return true;
|
||||
if (/^PS\s+[A-Za-z]:[\\/]/i.test(before)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function endsWithStandardShellPrompt(promptText: string): boolean {
|
||||
const finalChar = promptText.trimEnd().at(-1);
|
||||
return finalChar === "$" || finalChar === "#" || finalChar === "%";
|
||||
}
|
||||
|
||||
function allowsShortPromptEcho(promptText: string): boolean {
|
||||
return (
|
||||
endsWithStandardShellPrompt(promptText) ||
|
||||
endsWithHostStyleGreaterThanPrompt(promptText) ||
|
||||
endsWithWindowsPathGreaterThanPrompt(promptText)
|
||||
);
|
||||
}
|
||||
|
||||
function isReliableTypedPrefix(
|
||||
echoedInput: string,
|
||||
typedBuffer: string,
|
||||
options: { allowShortEcho?: boolean } = {},
|
||||
): boolean {
|
||||
if (!typedBuffer.startsWith(echoedInput)) return false;
|
||||
if (
|
||||
options.allowShortEcho &&
|
||||
typedBuffer.trim().length <= 2 &&
|
||||
echoedInput.trim().length >= 1
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
echoedInput.length >= Math.max(4, typedBuffer.length - 2) ||
|
||||
(echoedInput.endsWith(" ") && echoedInput.trim().length >= 2) ||
|
||||
(echoedInput.includes(" ") && echoedInput.length >= 4)
|
||||
);
|
||||
}
|
||||
|
||||
function withTypedUserInput(
|
||||
prompt: PromptDetectionResult,
|
||||
typedBuffer: string,
|
||||
): PromptDetectionResult {
|
||||
return {
|
||||
...prompt,
|
||||
userInput: typedBuffer,
|
||||
cursorOffset: typedBuffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
function alignThemedDecorationWithPartialEcho(
|
||||
raw: PromptDetectionResult,
|
||||
typedBuffer: string,
|
||||
): AlignedPromptResult | null {
|
||||
if (!raw.isAtPrompt || !isThemedPromptText(raw.promptText)) return null;
|
||||
|
||||
const maxEchoLength = Math.min(raw.userInput.length, typedBuffer.length);
|
||||
for (let echoLength = maxEchoLength; echoLength > 0; echoLength--) {
|
||||
const echoedInput = typedBuffer.slice(0, echoLength);
|
||||
if (!raw.userInput.endsWith(echoedInput)) continue;
|
||||
|
||||
const extra = raw.userInput.slice(0, raw.userInput.length - echoLength);
|
||||
if (extra.length === 0) continue;
|
||||
const hasReliableThemedDirectoryPrefix =
|
||||
isSingleBareDirectoryExtra(extra) &&
|
||||
hasExplicitThemedDirectorySpacing(extra) &&
|
||||
typedBuffer.trim().length <= 3 &&
|
||||
echoedInput.trim().length >= 1;
|
||||
|
||||
const syntheticPrompt = {
|
||||
...raw,
|
||||
userInput: extra + typedBuffer,
|
||||
cursorOffset: extra.length + typedBuffer.length,
|
||||
};
|
||||
if (
|
||||
!hasReliableThemedDirectoryPrefix &&
|
||||
!isReliableTypedPrefix(echoedInput, typedBuffer)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!canReconcilePromptDecoration(syntheticPrompt, typedBuffer, {
|
||||
allowSingleWordCommandDirectory: true,
|
||||
})) continue;
|
||||
|
||||
return {
|
||||
prompt: {
|
||||
isAtPrompt: true,
|
||||
promptText: raw.promptText + extra,
|
||||
userInput: typedBuffer,
|
||||
cursorOffset: typedBuffer.length,
|
||||
},
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the terminal cursor is at a shell prompt and extract the current user input.
|
||||
*/
|
||||
export function detectPrompt(term: XTerm): PromptDetectionResult {
|
||||
const buffer = term.buffer.active;
|
||||
const cursorY = buffer.cursorY + buffer.baseY;
|
||||
const cursorX = buffer.cursorX;
|
||||
const line = buffer.getLine(cursorY);
|
||||
|
||||
if (!line) return NO_PROMPT;
|
||||
|
||||
// translateToString(false) preserves trailing spaces — important for cursor-based
|
||||
// input extraction (trailing space triggers empty token for option suggestions)
|
||||
const lineText = line.translateToString(false);
|
||||
|
||||
// Check for non-prompt patterns (pagers, editors, etc.)
|
||||
if (isSensitiveTerminalChallenge(lineText) || isNonPromptLine(lineText)) return NO_PROMPT;
|
||||
if (line.isWrapped) {
|
||||
const wrappedPrefix = getWrappedCursorPrefix(term);
|
||||
if (wrappedPrefix && (isSensitiveTerminalChallenge(wrappedPrefix) || isNonPromptLine(wrappedPrefix))) {
|
||||
return NO_PROMPT;
|
||||
}
|
||||
}
|
||||
|
||||
// Empty line
|
||||
if (lineText.trim().length === 0) return NO_PROMPT;
|
||||
|
||||
// cursorX is a cell column; lineText is characters. Wide glyphs (CJK in a
|
||||
// Windows `C:\Users\用户>` prompt) make substring(cursorX) overshoot into
|
||||
// xterm's empty-cell padding and poison userInput with spaces (#2813 CMD).
|
||||
const cursorLinePrefix = sliceStringByCellColumns(lineText, 0, Math.max(0, cursorX), term);
|
||||
const afterCursor = sliceStringByCellColumns(lineText, Math.max(0, cursorX), undefined, term);
|
||||
// Try to find the prompt boundary on the current line. xterm buffer rows are
|
||||
// padded with blank cells; when the cursor is at the visible row end, scan
|
||||
// only up to the cursor so prompts like "root@host:~#" do not inherit a fake
|
||||
// trailing space. If there is command text to the right of the cursor, keep
|
||||
// the full line so "$" / ">" inside mid-line edits are validated against
|
||||
// their real following character.
|
||||
const promptScanText = afterCursor.trim().length > 0
|
||||
? lineText
|
||||
: cursorLinePrefix;
|
||||
const promptEnd = findPromptBoundary(promptScanText);
|
||||
if (promptEnd >= 0) {
|
||||
const promptText = lineText.substring(0, promptEnd);
|
||||
// Input is whatever sits between the prompt and the cursor on the cell-
|
||||
// accurate prefix — don't use cursorX as a character index.
|
||||
const userInput = cursorLinePrefix.length >= promptEnd
|
||||
? cursorLinePrefix.substring(promptEnd)
|
||||
: "";
|
||||
const cursorOffset = userInput.length;
|
||||
|
||||
return { isAtPrompt: true, promptText, userInput, cursorOffset };
|
||||
}
|
||||
|
||||
// Handle wrapped lines: if the prompt is on a previous row (e.g., long path or
|
||||
// long command wrapped onto multiple rows), look upward for the prompt line.
|
||||
// The current row's content is continuation of the command.
|
||||
if (line.isWrapped) {
|
||||
// Walk up to find the first non-wrapped line (the prompt line)
|
||||
let promptRow = cursorY - 1;
|
||||
while (promptRow >= 0) {
|
||||
const prevLine = buffer.getLine(promptRow);
|
||||
if (!prevLine) break;
|
||||
if (!prevLine.isWrapped) break;
|
||||
promptRow--;
|
||||
}
|
||||
|
||||
const promptLine = buffer.getLine(promptRow);
|
||||
if (promptLine) {
|
||||
const promptLineText = promptLine.translateToString(false);
|
||||
if (isSensitiveTerminalChallenge(promptLineText) || isNonPromptLine(promptLineText)) return NO_PROMPT;
|
||||
const pEnd = findPromptBoundary(promptLineText);
|
||||
if (pEnd >= 0) {
|
||||
const promptText = promptLineText.substring(0, pEnd);
|
||||
// Concatenate all rows from promptRow to cursorY to get full input
|
||||
let fullInput = promptLineText.substring(pEnd);
|
||||
for (let row = promptRow + 1; row <= cursorY; row++) {
|
||||
const rowLine = buffer.getLine(row);
|
||||
if (rowLine) fullInput += rowLine.translateToString(false);
|
||||
}
|
||||
// Trim to cursor position on the last row
|
||||
const totalCols = term.cols;
|
||||
const charsBeforeCursorRow = (cursorY - promptRow) * totalCols - pEnd;
|
||||
const userInput = fullInput.substring(0, charsBeforeCursorRow + cursorX);
|
||||
const cursorOffset = userInput.length;
|
||||
if (isSensitiveTerminalChallenge(promptText + userInput)
|
||||
|| isNonPromptLine(promptText + userInput)) return NO_PROMPT;
|
||||
|
||||
return { isAtPrompt: true, promptText, userInput, cursorOffset };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NO_PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a character lives in the Unicode Private Use Area (U+E000–U+F8FF).
|
||||
* Powerline separators (U+E0B0..) and Nerd Font icons (U+E200.., U+F000..) all
|
||||
* fall here. A PUA char followed by a space is common in themed prompt
|
||||
* terminators (oh-my-posh, starship, p10k, etc.), but commands can still echo
|
||||
* those glyphs, so PUA boundaries are kept lower priority than standard prompt
|
||||
* characters and reconciled with the typed buffer when available.
|
||||
*/
|
||||
function isPuaChar(ch: string): boolean {
|
||||
if (!ch) return false;
|
||||
const code = ch.charCodeAt(0);
|
||||
return code >= 0xE000 && code <= 0xF8FF;
|
||||
}
|
||||
|
||||
function getBoundaryMarkerIndex(lineText: string, boundary: number): number {
|
||||
if (boundary <= 0) return -1;
|
||||
return lineText[boundary - 1] === " " ? boundary - 2 : boundary - 1;
|
||||
}
|
||||
|
||||
function isEmbeddedPromptMarkerAt(lineText: string, markerIndex: number): boolean {
|
||||
if (markerIndex <= 0) return false;
|
||||
|
||||
const marker = lineText[markerIndex];
|
||||
if (marker !== "#" && marker !== "%" && marker !== ">" && marker !== "$") return false;
|
||||
|
||||
const prev = lineText[markerIndex - 1];
|
||||
return !/[\s~:\])}]/.test(prev);
|
||||
}
|
||||
|
||||
function isEmbeddedPromptMarker(lineText: string, boundary: number): boolean {
|
||||
return isEmbeddedPromptMarkerAt(lineText, getBoundaryMarkerIndex(lineText, boundary));
|
||||
}
|
||||
|
||||
function canSupersedeThemedPromptBoundary(
|
||||
lineText: string,
|
||||
previousBoundary: number,
|
||||
markerIndex: number,
|
||||
): boolean {
|
||||
if (!isThemedPromptText(lineText.slice(0, previousBoundary))) return false;
|
||||
|
||||
const rawBetween = lineText.slice(previousBoundary, markerIndex);
|
||||
const between = rawBetween.trim();
|
||||
return (
|
||||
between.length === 0 ||
|
||||
isPromptPathDecoration(between) ||
|
||||
isPromptStatusText(between) ||
|
||||
(
|
||||
/^\s/.test(rawBetween) &&
|
||||
isPromptBareDirectoryText(between)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function canPromptMarkerSupersedePreviousBoundary(ch: string): boolean {
|
||||
return ch === "$" || ch === "#" || ch === "%" || ch === ">" || ch === "›";
|
||||
}
|
||||
|
||||
function isSpacedPromptSegment(lineText: string, boundary: number): boolean {
|
||||
const markerIndex = getBoundaryMarkerIndex(lineText, boundary);
|
||||
if (markerIndex <= 0) return false;
|
||||
if (lineText[markerIndex - 1] !== " ") return false;
|
||||
return lineText[markerIndex + 1] === " ";
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the boundary between prompt and user input.
|
||||
* Scans left-to-right within the first 200 chars for a prompt character followed by space.
|
||||
* Avoids false positives: $VAR, $(...), ${...} are not prompt endings.
|
||||
* Returns the character index where user input begins, or -1 if no prompt detected.
|
||||
*/
|
||||
function findPromptBoundary(lineText: string): number {
|
||||
// Scan for prompt boundary. Take the LAST candidate.
|
||||
// For ambiguous chars like >, limit scan to first 60% to avoid matching redirections.
|
||||
// For unambiguous prompt chars ($, #), scan the full line since they're rarely
|
||||
// confused with shell syntax in a prompt position.
|
||||
const lineLen = lineText.trimEnd().length;
|
||||
const scanLimit = Math.min(lineLen, 200);
|
||||
let lastStandardBoundary = -1;
|
||||
let lastPuaBoundary = -1;
|
||||
|
||||
// Ambiguous chars (>) only scan first 60% to avoid matching redirections
|
||||
const ambiguousScanLimit = Math.min(scanLimit, Math.max(40, Math.floor(lineLen * 0.6)));
|
||||
|
||||
for (let i = 0; i < scanLimit; i++) {
|
||||
const ch = lineText[i];
|
||||
const isStandard = PROMPT_CHARS.has(ch);
|
||||
const isPua = !isStandard && isPuaChar(ch);
|
||||
|
||||
if (!isStandard && !isPua) continue;
|
||||
|
||||
// For ambiguous prompt chars like >, only accept in the first 60% of the line
|
||||
if ((ch === ">" || ch === "›") && i >= ambiguousScanLimit) continue;
|
||||
if (
|
||||
(ch === ">" || ch === "›") &&
|
||||
lastStandardBoundary >= 0 &&
|
||||
/\s/.test(lineText.slice(0, i).trim()) &&
|
||||
!isEmbeddedPromptMarker(lineText, lastStandardBoundary) &&
|
||||
!canSupersedeThemedPromptBoundary(lineText, lastStandardBoundary, i)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Must be followed by a space or end-of-line.
|
||||
const nextChar = i + 1 < lineText.length ? lineText[i + 1] : null;
|
||||
if (nextChar !== null && nextChar !== " ") {
|
||||
// Special case: cmd.exe prompt `C:\path>command` — allow > without space
|
||||
// only if preceded by a path-like pattern (drive letter or backslash)
|
||||
if (ch === ">" && i > 1 && (lineText[i - 1] === "\\" || lineText[i - 1] === "/" || /^[A-Za-z]:/.test(lineText))) {
|
||||
// Looks like a path ending — accept as prompt
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// For '$': exclude shell variable references ($HOME, $PATH, ${...}, $(...))
|
||||
if (ch === "$") {
|
||||
// Check what comes AFTER the space — but more importantly check what
|
||||
// comes BEFORE to see if this looks like a prompt ending vs mid-command $.
|
||||
// A prompt $ is typically preceded by: space, ), ], digit, username chars, or is at position 0.
|
||||
// A variable $ is typically inside a command: echo $HOME, export PATH=$PATH:...
|
||||
//
|
||||
// Heuristic: if the $ is preceded by a letter/digit/underscore without a space before it
|
||||
// (i.e., it's part of a token like "echo" or "=$PATH"), it's likely a variable.
|
||||
if (i > 0) {
|
||||
const prev = lineText[i - 1];
|
||||
// If preceded by = or / or another non-separator, it's a variable reference
|
||||
if (prev === "=" || prev === "/" || prev === ":") continue;
|
||||
// If preceded by a letter and there's no space between, it could be $HOME-style
|
||||
// But actually: "user@host:~$ " has letter before $. So check if there's
|
||||
// a valid prompt pattern before the $.
|
||||
}
|
||||
|
||||
// Check what follows: if after "$ " there's more content with $ in variable positions
|
||||
// Actually the simplest reliable check: if the character after the space is alphanumeric
|
||||
// or $ or (, this is likely the START of a command (i.e., this $ IS the prompt ending).
|
||||
// That's always true for a prompt. So the $ check is really about false positives mid-line.
|
||||
//
|
||||
// Better heuristic: if we haven't seen a space before this $ (meaning the $ is inside
|
||||
// the first token), it's likely a prompt. If we've already passed spaces (meaning
|
||||
// we're past the first "word"), a $ is more likely a variable.
|
||||
let seenSpaceBeforeDollar = false;
|
||||
for (let j = 0; j < i; j++) {
|
||||
if (lineText[j] === " ") { seenSpaceBeforeDollar = true; break; }
|
||||
}
|
||||
// If there was a space before this $, it might be mid-command (like "echo $HOME")
|
||||
// Only accept if the $ is reasonably close to common prompt patterns
|
||||
if (seenSpaceBeforeDollar) {
|
||||
// Check if this looks like a bracketed prompt ending: "]$ " or ")$ "
|
||||
if (i > 0 && (lineText[i - 1] === "]" || lineText[i - 1] === ")" ||
|
||||
lineText[i - 1] === " " || lineText[i - 1] === "~")) {
|
||||
// Likely a prompt ending like [user@host ~]$
|
||||
} else {
|
||||
continue; // Skip — likely a variable reference mid-command
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record this as a candidate boundary. A standard shell prompt terminator
|
||||
// is more reliable than a later Powerline/Nerd Font glyph in command text.
|
||||
const boundary = nextChar === " " ? i + 2 : i + 1;
|
||||
const candidatePromptText = lineText.slice(0, boundary);
|
||||
if (isStandard && hasSwallowedCommandAfterPrompt(candidatePromptText, boundary)) {
|
||||
continue;
|
||||
}
|
||||
if (isStandard && lastStandardBoundary >= 0) {
|
||||
const themedPromptCanSupersede = canSupersedeThemedPromptBoundary(
|
||||
lineText,
|
||||
lastStandardBoundary,
|
||||
getBoundaryMarkerIndex(lineText, boundary),
|
||||
);
|
||||
const canSupersedePreviousBoundary =
|
||||
canPromptMarkerSupersedePreviousBoundary(ch) &&
|
||||
(
|
||||
isEmbeddedPromptMarker(lineText, lastStandardBoundary) ||
|
||||
isSpacedPromptSegment(lineText, lastStandardBoundary) ||
|
||||
themedPromptCanSupersede
|
||||
) &&
|
||||
(
|
||||
themedPromptCanSupersede ||
|
||||
isSpecificShellPromptCandidate(candidatePromptText, {
|
||||
allowGreaterThanTerminator: ch === ">" || ch === "›",
|
||||
})
|
||||
);
|
||||
if (!canSupersedePreviousBoundary) continue;
|
||||
}
|
||||
if (isStandard) {
|
||||
lastStandardBoundary = boundary;
|
||||
} else {
|
||||
lastPuaBoundary = boundary;
|
||||
}
|
||||
}
|
||||
|
||||
return lastStandardBoundary >= 0 ? lastStandardBoundary : lastPuaBoundary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile a buffer-parsed prompt with the user's own keystroke history.
|
||||
*
|
||||
* findPromptBoundary stops at the first `PROMPT_CHAR + space` it sees, so
|
||||
* themes that render additional content after the prompt char — e.g.
|
||||
* oh-my-zsh's robbyrussell prints "➜ ~ " where `~` is the cwd — get
|
||||
* parsed as prompt="➜ " + userInput="~ lo". Every consumer downstream
|
||||
* (history recording, suggestion matching, insertion) then treats the
|
||||
* theme's cwd marker as part of the user's command, which pollutes
|
||||
* history with entries like "~ sudo id" and makes Tab insertions prepend
|
||||
* a phantom "~ " to the typed command (issue #806).
|
||||
*
|
||||
* Whenever we have an independent record of what the user actually typed
|
||||
* since the last Enter (keystroke buffer), we can detect this case: the
|
||||
* real input is always a suffix of the over-captured userInput. When it
|
||||
* is, reattribute the leading garbage back to promptText so the rest of
|
||||
* the pipeline sees the clean split.
|
||||
*/
|
||||
export function reconcilePromptWithTypedInput(
|
||||
prompt: PromptDetectionResult,
|
||||
typedInput: string,
|
||||
): PromptDetectionResult {
|
||||
if (!prompt.isAtPrompt) return prompt;
|
||||
if (!typedInput) return prompt;
|
||||
if (prompt.userInput === typedInput) return prompt;
|
||||
if (
|
||||
prompt.userInput.length > typedInput.length &&
|
||||
prompt.userInput.endsWith(typedInput)
|
||||
) {
|
||||
if (!canReconcilePromptDecoration(prompt, typedInput, {
|
||||
allowSingleWordCommandDirectory: true,
|
||||
})) {
|
||||
return prompt;
|
||||
}
|
||||
const extra = prompt.userInput.slice(0, prompt.userInput.length - typedInput.length);
|
||||
return {
|
||||
isAtPrompt: true,
|
||||
promptText: prompt.promptText + extra,
|
||||
userInput: typedInput,
|
||||
cursorOffset: typedInput.length,
|
||||
};
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
export function reconcilePromptWithExternalCommand(
|
||||
prompt: PromptDetectionResult,
|
||||
command: string,
|
||||
): PromptDetectionResult | null {
|
||||
const typedInput = command.trim();
|
||||
if (!prompt.isAtPrompt || typedInput.length === 0) return null;
|
||||
|
||||
const syntheticPrompt = {
|
||||
...prompt,
|
||||
userInput: `${prompt.userInput}${typedInput}`,
|
||||
cursorOffset: prompt.userInput.length + typedInput.length,
|
||||
};
|
||||
if (!canReconcilePromptDecoration(syntheticPrompt, typedInput, {
|
||||
allowSingleWordCommandDirectory: true,
|
||||
})) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const extra = syntheticPrompt.userInput.slice(
|
||||
0,
|
||||
syntheticPrompt.userInput.length - typedInput.length,
|
||||
);
|
||||
return {
|
||||
isAtPrompt: true,
|
||||
promptText: prompt.promptText + extra,
|
||||
userInput: typedInput,
|
||||
cursorOffset: typedInput.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified entry point for any autocomplete code path that needs a prompt
|
||||
* view. Every consumer (fetchSuggestions, insertSuggestion,
|
||||
* handleSubDirSelect, Enter-record) goes through this one helper so the
|
||||
* alignment policy lives in exactly one place — if another out-of-band
|
||||
* line-rewrite path gets added later and forgets to notify the keystroke
|
||||
* buffer, the worst that happens is reconcile no-ops and we degrade to
|
||||
* pre-#806 behavior, not a worse pollution.
|
||||
*
|
||||
* Alignment rule: the keystroke buffer is usable only when it's marked
|
||||
* reliable and it can be reconciled with the live line. Exact raw
|
||||
* matches are safe, over-captured prompt chrome can be moved back into
|
||||
* promptText, and no-space prompts can be inferred from the cursor line
|
||||
* when the inferred prompt still looks like a shell prompt. Otherwise
|
||||
* the buffer is ignored and the raw detector result passes through.
|
||||
*/
|
||||
export function getAlignedPrompt(
|
||||
term: XTerm | null,
|
||||
typedBuffer: string,
|
||||
typedReliable: boolean,
|
||||
): AlignedPromptResult {
|
||||
if (!term) return { prompt: NO_PROMPT, alignedTyped: null };
|
||||
const raw = detectPrompt(term);
|
||||
if (!typedReliable || typedBuffer.length === 0) {
|
||||
return { prompt: raw, alignedTyped: null };
|
||||
}
|
||||
|
||||
if (raw.isAtPrompt) {
|
||||
if (raw.userInput === typedBuffer) {
|
||||
return { prompt: raw, alignedTyped: typedBuffer };
|
||||
}
|
||||
if (raw.userInput.length > typedBuffer.length && raw.userInput.endsWith(typedBuffer)) {
|
||||
const prompt = reconcilePromptWithTypedInput(raw, typedBuffer);
|
||||
if (prompt === raw) return { prompt: raw, alignedTyped: null };
|
||||
return {
|
||||
prompt,
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
const themedDecorationAlignment = alignThemedDecorationWithPartialEcho(raw, typedBuffer);
|
||||
if (themedDecorationAlignment) return themedDecorationAlignment;
|
||||
if (canUseReliablePromptPrefix(raw, typedBuffer)) {
|
||||
return {
|
||||
prompt: withTypedUserInput(raw, typedBuffer),
|
||||
alignedTyped: typedBuffer,
|
||||
};
|
||||
}
|
||||
// No echo yet (CJK IME / high-latency SSH): surface the keystroke buffer
|
||||
// on prompts detectPrompt already recognizes (#2813), but do not set
|
||||
// alignedTyped. Empty / whitespace-only echo is also what echo-disabled
|
||||
// password prompts and padded themed PS1s look like, so this path must
|
||||
// not authorize history recording, built-in suggestion acceptance, or
|
||||
// third-party completion providers until echo validates the line.
|
||||
if (
|
||||
raw.userInput.trim().length === 0 &&
|
||||
(allowsShortPromptEcho(raw.promptText) || isThemedPromptText(raw.promptText))
|
||||
) {
|
||||
return {
|
||||
prompt: withTypedUserInput(raw, typedBuffer),
|
||||
alignedTyped: null,
|
||||
allowExternalProviders: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const cursorPrefixCandidates = [
|
||||
getWrappedCursorPrefix(term),
|
||||
getCursorLinePrefix(term),
|
||||
];
|
||||
for (const cursorPrefix of cursorPrefixCandidates) {
|
||||
const aligned = alignTypedInputFromCursorPrefix(raw, cursorPrefix, typedBuffer);
|
||||
if (aligned) return aligned;
|
||||
}
|
||||
|
||||
return { prompt: raw, alignedTyped: null };
|
||||
}
|
||||
109
components/terminal/autocomplete/promptDetectorPatterns.ts
Normal file
109
components/terminal/autocomplete/promptDetectorPatterns.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Patterns that indicate the user is NOT at a prompt
|
||||
* (e.g., inside vim, less, man, top, etc.)
|
||||
*/
|
||||
export const NON_PROMPT_PATTERNS = [
|
||||
/^~$/, // vim empty line marker
|
||||
/^\s*--\s*More\s*--/, // less/more pager
|
||||
/^\s*\(END\)/, // less end marker
|
||||
/^:\s*$/, // vim command mode
|
||||
/^\s*~\s*$/, // vim tilde lines
|
||||
/^>{1,3}\s/, // Bare > (bash PS2 continuation), >> or >>> (python REPL)
|
||||
/^\s{4}(?:->|['"`]>)\s/, // mysql / mariadb continuation prompts
|
||||
/^(?:mysql|sqlite(?:3)?|redis(?:-cli)?|psql|mariadb)>\s/i, // mysql> / sqlite> / redis-cli> prompts
|
||||
/^SQL>\s/i, // sqlplus SQL> prompts
|
||||
/^(?:sftp|ftp|lftp|ghci|node|mongo|mongosh|deno|irb|pry|julia|scala|gdb|lldb|cqlsh|hive|spark-sql|jshell|ksql|trino|presto|duckdb)>\s/i,
|
||||
/^irb\([^)]*\):\d+[:*]?\d*>\s/i,
|
||||
/^pry\([^)]*\)>\s/i,
|
||||
/^\[\d+\]\s+pry\([^)]*\)>\s/i,
|
||||
/^lftp\s+\S+>\s/i,
|
||||
/^\s{3}\.{3}>\s/,
|
||||
/^cqlsh(?::[\w.-]+)?>\s/i,
|
||||
/^(?:hive|spark-sql)\s+\([^)]+\)>\s/i,
|
||||
/^(?:\d+:\s*)?jdbc:hive2?:\/\/\S+>\s/i,
|
||||
/^(?:test|admin|local|config)>\s+(?:db(?:\.|\s*$)|rs\.|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*)/i,
|
||||
/^[\w.-]+:[A-Z]+>\s+(?:db\.|rs\.|exit\b|(?:const|let|var|await)\b|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
|
||||
/^(?:[\w.-]+\s+){0,5}\[[^\]]+\]\s+[\w.-]+>\s+(?:db\.|rs\.|exit\b|hel(?:p)?\b|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
|
||||
/^(?:[\w.-]+\s+){1,5}[\w.-]+>\s+(?:db\.|rs\.|exit\b|hel(?:p)?\b|print\s*\(|(?:const|let|var|await)\b|\d+\s*[-+*/]\s*\d*|show\s+(?:dbs?|collections|users|roles)|use\s+\w+|it\b)/i,
|
||||
/^(?:trino|presto)(?::[\w.-]+){1,2}>\s/i,
|
||||
/^[\w.-]+@(?:[\w.-]+|\d{1,3}(?:\.\d{1,3}){3}):\d+>\s/i,
|
||||
/^(?:[\w.-]+|\d{1,3}(?:\.\d{1,3}){3})(?::\d+)(?:\[\d+\])?>\s/, // redis host:port> prompts
|
||||
/^MariaDB\s+\[[^\]]+\]>\s/i, // MariaDB [(none)]> prompts
|
||||
/^[\w.-]+=[#>]\s/, // postgres=# / postgres=> REPL prompts
|
||||
/^[\w.-]+[-'"][#>]\s/, // postgres-# / postgres'# continuation prompts
|
||||
/^[\w.-]+(?:\([^)]*|\*|!|\^|\$[^$]*\$)[#>]\s/, // postgres multiline prompt states
|
||||
];
|
||||
|
||||
export const COMMON_SHELL_COMMANDS = new Set([
|
||||
"alias",
|
||||
"awk",
|
||||
"az",
|
||||
"brew",
|
||||
"bun",
|
||||
"bundle",
|
||||
"cargo",
|
||||
"cat",
|
||||
"cd",
|
||||
"chmod",
|
||||
"chown",
|
||||
"code",
|
||||
"composer",
|
||||
"cp",
|
||||
"curl",
|
||||
"docker",
|
||||
"echo",
|
||||
"emacs",
|
||||
"env",
|
||||
"export",
|
||||
"find",
|
||||
"gcloud",
|
||||
"gh",
|
||||
"git",
|
||||
"go",
|
||||
"gradle",
|
||||
"grep",
|
||||
"helm",
|
||||
"java",
|
||||
"javac",
|
||||
"kubectl",
|
||||
"less",
|
||||
"ls",
|
||||
"make",
|
||||
"mkdir",
|
||||
"mvn",
|
||||
"mv",
|
||||
"nano",
|
||||
"node",
|
||||
"npm",
|
||||
"npx",
|
||||
"nvim",
|
||||
"php",
|
||||
"pip",
|
||||
"pip3",
|
||||
"pnpm",
|
||||
"printf",
|
||||
"python",
|
||||
"python3",
|
||||
"rails",
|
||||
"rm",
|
||||
"rsync",
|
||||
"ruby",
|
||||
"rustc",
|
||||
"scp",
|
||||
"screen",
|
||||
"sed",
|
||||
"ssh",
|
||||
"sudo",
|
||||
"tail",
|
||||
"tar",
|
||||
"terraform",
|
||||
"tmux",
|
||||
"touch",
|
||||
"uv",
|
||||
"vi",
|
||||
"vim",
|
||||
"yarn",
|
||||
]);
|
||||
|
||||
/** Characters that commonly end a shell prompt */
|
||||
export const PROMPT_CHARS = new Set(["$", "#", "%", ">", "❯", "❮", "→", "➜", "➤", "⟩", "»", "›"]);
|
||||
506
components/terminal/autocomplete/remotePathCompleter.ts
Normal file
506
components/terminal/autocomplete/remotePathCompleter.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* Remote path completion for terminal autocomplete.
|
||||
* Lists files/directories on the remote (or local) machine
|
||||
* when the user types commands that expect path arguments.
|
||||
*/
|
||||
|
||||
import type { CompletionContext } from "./completionEngine";
|
||||
import type { FigArg } from "./figSpecLoader";
|
||||
import type { AutocompleteCwdSource } from "./terminalAutocompleteLayout";
|
||||
|
||||
/** Directory entry returned from IPC */
|
||||
export interface DirEntry {
|
||||
name: string;
|
||||
type: "file" | "directory" | "symlink";
|
||||
}
|
||||
|
||||
interface ResolvePathOptions {
|
||||
preferRelativeCwd?: boolean;
|
||||
}
|
||||
|
||||
/** Bridge interface for directory listing */
|
||||
interface PathBridge {
|
||||
listAutocompleteRemoteDir?: (
|
||||
sessionId: string,
|
||||
path: string,
|
||||
foldersOnly: boolean,
|
||||
filterPrefix?: string,
|
||||
limit?: number,
|
||||
) => Promise<{ success: boolean; entries: DirEntry[] }>;
|
||||
listAutocompleteLocalDir?: (
|
||||
path: string,
|
||||
foldersOnly: boolean,
|
||||
filterPrefix?: string,
|
||||
limit?: number,
|
||||
) => Promise<{ success: boolean; entries: DirEntry[] }>;
|
||||
}
|
||||
|
||||
function getBridge(): PathBridge | undefined {
|
||||
return (window as Window & { netcatty?: PathBridge }).netcatty;
|
||||
}
|
||||
|
||||
// Cache directory listings for 5 seconds. Full-directory cache is shared between
|
||||
// popup suggestions and cascading sub-directory panels; filtered cache avoids
|
||||
// repeated round-trips while the user keeps typing within the same directory.
|
||||
const fullDirCache = new Map<string, { entries: DirEntry[]; timestamp: number }>();
|
||||
const filteredDirCache = new Map<string, { entries: DirEntry[]; timestamp: number }>();
|
||||
const inFlightRequests = new Map<string, Promise<DirEntry[]>>();
|
||||
const CACHE_TTL_MS = 5000;
|
||||
const MAX_CACHE_SIZE = 30;
|
||||
const MAX_FILTERED_CACHE_SIZE = 60;
|
||||
|
||||
/** Commands that commonly accept file/directory path arguments.
|
||||
* Subcommand-first tools (docker, kubectl, go, cargo, make) are excluded —
|
||||
* their path arguments are better handled via Fig specs. */
|
||||
const PATH_COMMANDS = new Set([
|
||||
// Navigation & listing
|
||||
"cd", "pushd", "ls", "ll", "la", "dir", "tree", "exa", "eza", "lsd",
|
||||
// Viewing & editing
|
||||
"cat", "less", "more", "head", "tail", "bat", "tac", "nl", "tee",
|
||||
"vim", "vi", "nvim", "nano", "emacs", "code", "subl", "micro", "helix", "hx", "joe", "mcedit",
|
||||
// File operations
|
||||
"cp", "mv", "rm", "mkdir", "rmdir", "touch", "ln", "install", "shred",
|
||||
// Permissions & metadata
|
||||
"chmod", "chown", "chgrp", "stat", "file", "lsattr", "chattr",
|
||||
// Search & filter
|
||||
"find", "rg", "grep", "egrep", "fgrep", "ag", "fd", "locate",
|
||||
"wc", "sort", "uniq", "cut", "awk", "sed",
|
||||
// Archive & compression
|
||||
"tar", "zip", "unzip", "gzip", "gunzip", "bzip2", "bunzip2", "xz", "unxz", "zstd",
|
||||
"7z", "rar", "unrar",
|
||||
// Transfer & sync
|
||||
"scp", "rsync", "diff", "cmp", "patch",
|
||||
// Scripting & execution
|
||||
"source", ".", "bash", "sh", "zsh", "fish",
|
||||
"python", "python3", "node", "ruby", "perl", "php", "rustc", "gcc", "g++",
|
||||
"deno", "bun", "tsx", "ts-node",
|
||||
// Disk & filesystem
|
||||
"du", "df", "chroot",
|
||||
// Misc
|
||||
"realpath", "readlink", "basename", "dirname", "md5sum", "sha256sum", "xxd", "hexdump",
|
||||
"xdg-open", "open", "start",
|
||||
]);
|
||||
|
||||
/** Commands that only accept directories (not files) */
|
||||
const FOLDER_ONLY_COMMANDS = new Set(["cd", "mkdir", "rmdir", "pushd"]);
|
||||
|
||||
/**
|
||||
* Check if the current command context expects a path argument.
|
||||
*/
|
||||
export function shouldDoPathCompletion(
|
||||
ctx: CompletionContext,
|
||||
resolvedArgs?: FigArg | FigArg[],
|
||||
): { shouldComplete: boolean; foldersOnly: boolean } {
|
||||
const currentWord = stripWrappingQuotes(ctx.currentWord);
|
||||
|
||||
// 1. Typed path trigger: if current word starts with path-like prefix, always complete
|
||||
if (currentWord.startsWith("/") || currentWord.startsWith("./") ||
|
||||
currentWord.startsWith("../") || currentWord.startsWith("~/") ||
|
||||
currentWord === "." || currentWord === ".." || currentWord === "~") {
|
||||
const foldersOnly = FOLDER_ONLY_COMMANDS.has(ctx.commandName);
|
||||
return { shouldComplete: true, foldersOnly };
|
||||
}
|
||||
|
||||
// 2. Fig spec template check
|
||||
if (resolvedArgs) {
|
||||
const args = Array.isArray(resolvedArgs) ? resolvedArgs : [resolvedArgs];
|
||||
for (const arg of args) {
|
||||
const templates = Array.isArray(arg.template) ? arg.template : arg.template ? [arg.template] : [];
|
||||
if (templates.includes("filepaths") || templates.includes("folders")) {
|
||||
return {
|
||||
shouldComplete: true,
|
||||
foldersOnly: templates.includes("folders") && !templates.includes("filepaths"),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Hardcoded command list (for commands without fig specs)
|
||||
if (ctx.wordIndex >= 1 && PATH_COMMANDS.has(ctx.commandName)) {
|
||||
// Only if we're past the command name and not typing an option
|
||||
if (!currentWord.startsWith("-")) {
|
||||
return {
|
||||
shouldComplete: true,
|
||||
foldersOnly: FOLDER_ONLY_COMMANDS.has(ctx.commandName),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { shouldComplete: false, foldersOnly: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the current word into directory-to-list and filter prefix.
|
||||
*/
|
||||
export function resolvePathComponents(
|
||||
currentWord: string,
|
||||
cwd: string | undefined,
|
||||
options: ResolvePathOptions = {},
|
||||
): { dirToList: string; filterPrefix: string; pathPrefix: string; quoteSuffix: string } {
|
||||
const quotePrefix = getLeadingQuote(currentWord);
|
||||
const quoteSuffix = getTrailingMatchingQuote(currentWord, quotePrefix);
|
||||
const unquotedWord = stripWrappingQuotes(currentWord);
|
||||
const preferRelativeCwd = options.preferRelativeCwd === true;
|
||||
|
||||
// Handle empty input — list CWD
|
||||
if (!unquotedWord || unquotedWord === "." || unquotedWord === "~" || unquotedWord === "..") {
|
||||
const dir = unquotedWord === "~"
|
||||
? "~"
|
||||
: unquotedWord === ".."
|
||||
? resolveDirLookup("../", cwd, preferRelativeCwd)
|
||||
: resolveDirLookup("", cwd, preferRelativeCwd);
|
||||
const visiblePrefix = unquotedWord ? `${quotePrefix}${unquotedWord}/` : quotePrefix;
|
||||
return { dirToList: dir, filterPrefix: "", pathPrefix: visiblePrefix, quoteSuffix };
|
||||
}
|
||||
|
||||
// Find the last path separator
|
||||
const lastSlash = unquotedWord.lastIndexOf("/");
|
||||
|
||||
if (lastSlash >= 0) {
|
||||
const dirPart = unquotedWord.substring(0, lastSlash + 1); // includes trailing /
|
||||
const filterPart = unquotedWord.substring(lastSlash + 1);
|
||||
const decodedDirPart = decodeShellPathFragment(dirPart);
|
||||
const decodedFilterPart = decodeShellPathFragment(filterPart);
|
||||
|
||||
const dirToList = resolveDirLookup(decodedDirPart, cwd, preferRelativeCwd);
|
||||
|
||||
return { dirToList, filterPrefix: decodedFilterPart, pathPrefix: quotePrefix + dirPart, quoteSuffix };
|
||||
}
|
||||
|
||||
// No slash — filter CWD entries by the typed prefix
|
||||
return {
|
||||
dirToList: resolveDirLookup("", cwd, preferRelativeCwd),
|
||||
filterPrefix: decodeShellPathFragment(unquotedWord),
|
||||
pathPrefix: quotePrefix,
|
||||
quoteSuffix,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePathTokenForLookup(
|
||||
token: string,
|
||||
cwd?: string,
|
||||
options: ResolvePathOptions = {},
|
||||
): string {
|
||||
const { dirToList, filterPrefix } = resolvePathComponents(token, cwd, options);
|
||||
if (!filterPrefix) return dirToList;
|
||||
|
||||
if (!dirToList || dirToList === ".") {
|
||||
return filterPrefix;
|
||||
}
|
||||
|
||||
const needsSeparator = !dirToList.endsWith("/");
|
||||
return `${dirToList}${needsSeparator ? "/" : ""}${filterPrefix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path completion suggestions.
|
||||
*/
|
||||
export async function getPathSuggestions(
|
||||
ctx: CompletionContext,
|
||||
options: {
|
||||
sessionId?: string;
|
||||
protocol?: string;
|
||||
os?: "linux" | "windows" | "macos";
|
||||
cwd?: string;
|
||||
cwdSource?: AutocompleteCwdSource;
|
||||
foldersOnly: boolean;
|
||||
},
|
||||
): Promise<{ name: string; type: DirEntry["type"] }[]> {
|
||||
const { sessionId, protocol, os, cwd, cwdSource, foldersOnly } = options;
|
||||
const { dirToList, filterPrefix } = resolvePathComponents(ctx.currentWord, cwd, {
|
||||
preferRelativeCwd: shouldPreferRemoteShellCwd(protocol, sessionId, os, cwd, cwdSource),
|
||||
});
|
||||
|
||||
const entries = await listDirectoryEntries(dirToList, {
|
||||
sessionId,
|
||||
protocol,
|
||||
os,
|
||||
foldersOnly,
|
||||
filterPrefix,
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return sortPathEntries(entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* List directory contents via IPC, with shared caching and in-flight dedup.
|
||||
*/
|
||||
export async function listDirectoryEntries(
|
||||
dirPath: string,
|
||||
options: {
|
||||
sessionId?: string;
|
||||
protocol?: string;
|
||||
os?: "linux" | "windows" | "macos";
|
||||
foldersOnly: boolean;
|
||||
filterPrefix?: string;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<DirEntry[]> {
|
||||
const {
|
||||
sessionId,
|
||||
protocol,
|
||||
os,
|
||||
foldersOnly,
|
||||
filterPrefix = "",
|
||||
limit = 100,
|
||||
} = options;
|
||||
const normalizedPrefix = filterPrefix.toLowerCase();
|
||||
const maxEntries = clampLimit(limit);
|
||||
const baseKey = `${protocol || "auto"}:${sessionId || "local"}:${dirPath}:${foldersOnly}`;
|
||||
const fullCacheKey = `${baseKey}:all`;
|
||||
const filteredCacheKey = `${baseKey}:prefix:${normalizedPrefix}:${maxEntries}`;
|
||||
const bypassCache = shouldBypassCache(protocol, sessionId, os, dirPath);
|
||||
const requestKey = normalizedPrefix ? filteredCacheKey : fullCacheKey;
|
||||
|
||||
// Full directory cache can satisfy both full and filtered lookups.
|
||||
// Relative SSH cwd paths bypass durable cache and in-flight reuse: the shell
|
||||
// cwd can move, so a listing started for "." in directory A must not satisfy
|
||||
// a later lookup after cd into B. Soft-budget timeout + late refresh already
|
||||
// share one promise at the getCompletions call site.
|
||||
if (!bypassCache) {
|
||||
const fullCached = fullDirCache.get(fullCacheKey);
|
||||
if (isFresh(fullCached)) {
|
||||
return filterEntries(fullCached.entries, normalizedPrefix, maxEntries);
|
||||
}
|
||||
|
||||
if (normalizedPrefix) {
|
||||
const filteredCached = filteredDirCache.get(filteredCacheKey);
|
||||
if (isFresh(filteredCached)) {
|
||||
return filteredCached.entries;
|
||||
}
|
||||
}
|
||||
|
||||
const inFlightFull = inFlightRequests.get(fullCacheKey);
|
||||
if (inFlightFull) {
|
||||
return filterEntries(await inFlightFull, normalizedPrefix, maxEntries);
|
||||
}
|
||||
|
||||
const inFlight = inFlightRequests.get(requestKey);
|
||||
if (inFlight) return inFlight;
|
||||
}
|
||||
|
||||
// Make IPC call
|
||||
const promise = (async (): Promise<DirEntry[]> => {
|
||||
try {
|
||||
const bridge = getBridge();
|
||||
if (!bridge) return [];
|
||||
|
||||
let result: { success: boolean; entries: DirEntry[] };
|
||||
|
||||
if (protocol === "local" || !sessionId) {
|
||||
if (!bridge.listAutocompleteLocalDir) return [];
|
||||
result = await bridge.listAutocompleteLocalDir(
|
||||
dirPath,
|
||||
foldersOnly,
|
||||
normalizedPrefix || undefined,
|
||||
maxEntries,
|
||||
);
|
||||
} else {
|
||||
if (!bridge.listAutocompleteRemoteDir) return [];
|
||||
result = await bridge.listAutocompleteRemoteDir(
|
||||
sessionId,
|
||||
dirPath,
|
||||
foldersOnly,
|
||||
normalizedPrefix || undefined,
|
||||
maxEntries,
|
||||
);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
const timestamp = Date.now();
|
||||
if (bypassCache) {
|
||||
return result.entries;
|
||||
}
|
||||
if (normalizedPrefix) {
|
||||
filteredDirCache.set(requestKey, { entries: result.entries, timestamp });
|
||||
evictOldest(filteredDirCache, MAX_FILTERED_CACHE_SIZE);
|
||||
return result.entries;
|
||||
}
|
||||
|
||||
fullDirCache.set(requestKey, { entries: result.entries, timestamp });
|
||||
evictOldest(fullDirCache, MAX_CACHE_SIZE);
|
||||
return result.entries;
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
} finally {
|
||||
if (!bypassCache) {
|
||||
inFlightRequests.delete(requestKey);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
if (!bypassCache) {
|
||||
inFlightRequests.set(requestKey, promise);
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
|
||||
function clampLimit(limit: number): number {
|
||||
if (!Number.isFinite(limit)) return 100;
|
||||
return Math.max(1, Math.min(200, Math.floor(limit)));
|
||||
}
|
||||
|
||||
function resolveDirLookup(pathToken: string, cwd: string | undefined, preferRelativeCwd = false): string {
|
||||
if (!pathToken) return preferRelativeCwd ? "." : (cwd || ".");
|
||||
if (pathToken.startsWith("/")) return normalizePosixLikePath(pathToken);
|
||||
if (pathToken === "~" || pathToken.startsWith("~/")) return normalizePosixLikePath(pathToken);
|
||||
if (preferRelativeCwd) return normalizePosixLikePath(pathToken);
|
||||
if (cwd) return normalizePosixLikePath(`${cwd}/${pathToken}`);
|
||||
return normalizePosixLikePath(pathToken);
|
||||
}
|
||||
|
||||
export function shouldPreferRemoteShellCwd(
|
||||
protocol: string | undefined,
|
||||
sessionId: string | undefined,
|
||||
os?: "linux" | "windows" | "macos",
|
||||
cwd?: string,
|
||||
cwdSource?: AutocompleteCwdSource,
|
||||
): boolean {
|
||||
if (cwdSource === "prompt" && cwd?.startsWith("/")) return false;
|
||||
return Boolean(sessionId && protocol !== "local" && os === "linux");
|
||||
}
|
||||
|
||||
function shouldBypassCache(
|
||||
protocol: string | undefined,
|
||||
sessionId: string | undefined,
|
||||
os: "linux" | "windows" | "macos" | undefined,
|
||||
dirPath: string,
|
||||
): boolean {
|
||||
if (!shouldPreferRemoteShellCwd(protocol, sessionId, os)) return false;
|
||||
return !dirPath.startsWith("/") && dirPath !== "~" && !dirPath.startsWith("~/");
|
||||
}
|
||||
|
||||
function normalizePosixLikePath(input: string): string {
|
||||
if (!input) return ".";
|
||||
|
||||
const hasLeadingSlash = input.startsWith("/");
|
||||
const hasTildeRoot = input === "~" || input.startsWith("~/");
|
||||
const hasTrailingSlash = input.length > 1 && input.endsWith("/");
|
||||
const fixedRootSegments = hasTildeRoot ? 1 : 0;
|
||||
const raw = hasLeadingSlash
|
||||
? input.slice(1)
|
||||
: hasTildeRoot
|
||||
? input.slice(2)
|
||||
: input;
|
||||
const segments = hasTildeRoot ? ["~"] : [];
|
||||
|
||||
for (const segment of raw.split("/")) {
|
||||
if (!segment || segment === ".") continue;
|
||||
if (segment === "..") {
|
||||
if (
|
||||
segments.length > fixedRootSegments &&
|
||||
segments[segments.length - 1] !== ".."
|
||||
) {
|
||||
segments.pop();
|
||||
} else if (!hasLeadingSlash || hasTildeRoot) {
|
||||
segments.push(segment);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
segments.push(segment);
|
||||
}
|
||||
|
||||
let result: string;
|
||||
if (hasLeadingSlash) {
|
||||
result = "/" + segments.join("/");
|
||||
if (result === "/") return result;
|
||||
} else if (segments.length > 0) {
|
||||
result = segments.join("/");
|
||||
} else if (hasTildeRoot) {
|
||||
result = "~";
|
||||
} else {
|
||||
result = ".";
|
||||
}
|
||||
|
||||
if (hasTrailingSlash && result !== "/" && result !== "." && result !== "~") {
|
||||
result += "/";
|
||||
} else if (hasTrailingSlash && result === "~") {
|
||||
result = "~/";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function isFresh(
|
||||
cached: { entries: DirEntry[]; timestamp: number } | undefined,
|
||||
): cached is { entries: DirEntry[]; timestamp: number } {
|
||||
return Boolean(cached && Date.now() - cached.timestamp < CACHE_TTL_MS);
|
||||
}
|
||||
|
||||
function filterEntries(entries: DirEntry[], filterPrefix: string, limit: number): DirEntry[] {
|
||||
if (!filterPrefix) return entries.slice(0, limit);
|
||||
|
||||
const filtered: DirEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.name.toLowerCase().startsWith(filterPrefix)) {
|
||||
filtered.push(entry);
|
||||
if (filtered.length >= limit) break;
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function evictOldest(
|
||||
cache: Map<string, { entries: DirEntry[]; timestamp: number }>,
|
||||
maxSize: number,
|
||||
): void {
|
||||
while (cache.size > maxSize) {
|
||||
const oldestKey = cache.keys().next().value;
|
||||
if (!oldestKey) break;
|
||||
cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeShellPathFragment(value: string): string {
|
||||
let result = "";
|
||||
let escaped = false;
|
||||
|
||||
for (const ch of value) {
|
||||
if (escaped) {
|
||||
result += ch;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
result += ch;
|
||||
}
|
||||
|
||||
if (escaped) result += "\\";
|
||||
return result;
|
||||
}
|
||||
|
||||
function getLeadingQuote(value: string): string {
|
||||
return value.startsWith('"') || value.startsWith("'") ? value[0] : "";
|
||||
}
|
||||
|
||||
function getTrailingMatchingQuote(value: string, quotePrefix: string): string {
|
||||
return quotePrefix && value.endsWith(quotePrefix) ? quotePrefix : "";
|
||||
}
|
||||
|
||||
function stripWrappingQuotes(value: string): string {
|
||||
if (!value) return value;
|
||||
let result = value;
|
||||
if (result.startsWith('"') || result.startsWith("'")) {
|
||||
result = result.slice(1);
|
||||
}
|
||||
if (result.endsWith('"') || result.endsWith("'")) {
|
||||
result = result.slice(0, -1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sortPathEntries(entries: DirEntry[]): DirEntry[] {
|
||||
return [...entries].sort((left, right) => {
|
||||
const leftRank = left.type === "directory" ? 0 : left.type === "symlink" ? 1 : 2;
|
||||
const rightRank = right.type === "directory" ? 0 : right.type === "symlink" ? 1 : 2;
|
||||
if (leftRank !== rightRank) return leftRank - rightRank;
|
||||
return left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
|
||||
});
|
||||
}
|
||||
65
components/terminal/autocomplete/snippetCompleter.ts
Normal file
65
components/terminal/autocomplete/snippetCompleter.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Snippet completion source. Surfaces custom snippets in terminal autocomplete
|
||||
* when the user is typing the command name. Matches against the snippet label
|
||||
* and the first line of its command (case-insensitive; prefix matches rank
|
||||
* above substring matches). Chinese labels also match via pinyin / initials
|
||||
* through the shared search matcher (#2813). Each suggestion carries the full
|
||||
* Snippet so the accept path can run it through the canonical executeSnippetCommand.
|
||||
*/
|
||||
import type { Snippet } from "../../../domain/models";
|
||||
import { snippetAppliesToHost } from "../../../domain/snippetTargets";
|
||||
import { matchesSearchQuery } from "../../../lib/searchMatcher";
|
||||
import type { CompletionSuggestion } from "./completionEngine";
|
||||
|
||||
const SNIPPET_BASE_SCORE = 2000; // Above history (1000+freq) per "snippet > history".
|
||||
const SNIPPET_PREFIX_BONUS = 100;
|
||||
|
||||
function snippetAvailableForAutocomplete(
|
||||
snippet: Snippet,
|
||||
host: { hostId?: string; hostGroup?: string },
|
||||
): boolean {
|
||||
if (snippet.targetsAllHosts) return true;
|
||||
const hasScopedTargets = Boolean(
|
||||
snippet.targets?.length || snippet.targetGroups !== undefined,
|
||||
);
|
||||
if (!hasScopedTargets) return true;
|
||||
if (!host.hostId) return false;
|
||||
return snippetAppliesToHost(snippet, { id: host.hostId, group: host.hostGroup });
|
||||
}
|
||||
|
||||
export function getSnippetSuggestions(
|
||||
input: string,
|
||||
snippets: Snippet[],
|
||||
options: { hostId?: string; hostGroup?: string } = {},
|
||||
): CompletionSuggestion[] {
|
||||
const needle = input.trim().toLowerCase();
|
||||
if (!needle || !Array.isArray(snippets)) return [];
|
||||
|
||||
const out: CompletionSuggestion[] = [];
|
||||
for (const snippet of snippets) {
|
||||
if (!snippetAvailableForAutocomplete(snippet, options)) continue;
|
||||
const label = (snippet.label || "").toLowerCase();
|
||||
const firstLine = (snippet.command || "").split("\n")[0].trim().toLowerCase();
|
||||
|
||||
const labelPrefix = label.startsWith(needle);
|
||||
// Literal prefix/substring first (cheap); fall back to shared smart matcher
|
||||
// so Chinese titles surface for pinyin / initials the same way host search does.
|
||||
const matches = labelPrefix
|
||||
|| label.includes(needle)
|
||||
|| firstLine.startsWith(needle)
|
||||
|| matchesSearchQuery(needle, snippet.label, firstLine);
|
||||
if (!matches) continue;
|
||||
|
||||
out.push({
|
||||
text: snippet.label,
|
||||
displayText: snippet.label,
|
||||
description: snippet.command,
|
||||
source: "snippet",
|
||||
score: SNIPPET_BASE_SCORE + (labelPrefix ? SNIPPET_PREFIX_BONUS : 0),
|
||||
snippet,
|
||||
});
|
||||
}
|
||||
|
||||
out.sort((a, b) => b.score - a.score);
|
||||
return out;
|
||||
}
|
||||
278
components/terminal/autocomplete/terminalAutocompleteInput.ts
Normal file
278
components/terminal/autocomplete/terminalAutocompleteInput.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import type { MutableRefObject, RefObject } from "react";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { GhostTextAddon } from "./GhostTextAddon";
|
||||
import type { AutocompleteSettings } from "./useTerminalAutocomplete";
|
||||
import { getAlignedPrompt } from "./promptDetector";
|
||||
import { recordCommand } from "./commandHistoryStore";
|
||||
import { getCommandToRecordOnEnter } from "./terminalAutocompletePrompt";
|
||||
|
||||
interface TerminalAutocompleteInputContext {
|
||||
settingsRef: MutableRefObject<AutocompleteSettings>;
|
||||
lastKeystrokeRef: MutableRefObject<number>;
|
||||
suppressNextEnterRecordRef: MutableRefObject<boolean>;
|
||||
lastAcceptedCommandRef: MutableRefObject<string | null>;
|
||||
typedInputBufferRef: MutableRefObject<string>;
|
||||
typedBufferReliableRef: MutableRefObject<boolean>;
|
||||
previewBaselineRef: MutableRefObject<string>;
|
||||
previewActiveRef: MutableRefObject<boolean>;
|
||||
termRef: RefObject<XTerm | null>;
|
||||
hostIdRef: MutableRefObject<string>;
|
||||
hostOsRef: MutableRefObject<"linux" | "windows" | "macos">;
|
||||
ghostAddonRef: MutableRefObject<GhostTextAddon | null>;
|
||||
debounceTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>;
|
||||
clearState: () => void;
|
||||
syncPopupToInput: (input: string | null) => void;
|
||||
fetchSuggestions: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function handleTerminalAutocompleteInput(
|
||||
data: string,
|
||||
context: TerminalAutocompleteInputContext,
|
||||
): void {
|
||||
const {
|
||||
settingsRef,
|
||||
lastKeystrokeRef,
|
||||
suppressNextEnterRecordRef,
|
||||
lastAcceptedCommandRef,
|
||||
typedInputBufferRef,
|
||||
typedBufferReliableRef,
|
||||
previewBaselineRef,
|
||||
previewActiveRef,
|
||||
termRef,
|
||||
hostIdRef,
|
||||
hostOsRef,
|
||||
ghostAddonRef,
|
||||
debounceTimerRef,
|
||||
clearState,
|
||||
syncPopupToInput,
|
||||
fetchSuggestions,
|
||||
} = context;
|
||||
if (!settingsRef.current.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const timeSinceLastKeystroke = now - lastKeystrokeRef.current;
|
||||
lastKeystrokeRef.current = now;
|
||||
|
||||
// Command recording: Enter key
|
||||
if (data === "\r" || data === "\n") {
|
||||
// Skip recording if selectAndExecute already recorded this command
|
||||
if (suppressNextEnterRecordRef.current) {
|
||||
suppressNextEnterRecordRef.current = false;
|
||||
} else {
|
||||
// If user accepted a completion (Tab/→) and immediately pressed Enter,
|
||||
// the buffer may not reflect the accepted text yet. Use the tracked value.
|
||||
if (lastAcceptedCommandRef.current) {
|
||||
recordCommand(lastAcceptedCommandRef.current, hostIdRef.current, hostOsRef.current);
|
||||
} else {
|
||||
// Require a live prompt before trusting either keystroke buffer
|
||||
// or buffer-based detection — otherwise sudo password Enter
|
||||
// would record the typed password as a command.
|
||||
const typedBuffer = typedInputBufferRef.current;
|
||||
const typedBufferReliable = typedBufferReliableRef.current;
|
||||
const { prompt: livePrompt, alignedTyped } = getAlignedPrompt(
|
||||
termRef.current,
|
||||
typedBuffer,
|
||||
typedBufferReliable,
|
||||
);
|
||||
const commandToRecord = getCommandToRecordOnEnter(
|
||||
livePrompt,
|
||||
alignedTyped,
|
||||
typedBuffer,
|
||||
typedBufferReliable,
|
||||
);
|
||||
if (commandToRecord) {
|
||||
recordCommand(commandToRecord, hostIdRef.current, hostOsRef.current);
|
||||
}
|
||||
}
|
||||
lastAcceptedCommandRef.current = null;
|
||||
}
|
||||
typedInputBufferRef.current = "";
|
||||
typedBufferReliableRef.current = true;
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+C, Ctrl+U — clear. These kill the zle line entirely, so the
|
||||
// buffer is once again a true reflection of the (empty) line.
|
||||
if (data === "\x03" || data === "\x15") {
|
||||
typedInputBufferRef.current = "";
|
||||
typedBufferReliableRef.current = true;
|
||||
// Same rationale as the ctrl/escape early returns below: any
|
||||
// previously-accepted suggestion is gone from the line too, so
|
||||
// accept → Ctrl-C → type "foo" → Enter must not log the stale
|
||||
// accepted command via the Enter fast path.
|
||||
lastAcceptedCommandRef.current = null;
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Backspace / DEL: drop the last typed char so the buffer stays aligned
|
||||
// with what the shell actually holds.
|
||||
if (data === "\x7f" || data === "\b") {
|
||||
typedInputBufferRef.current = typedInputBufferRef.current.slice(0, -1);
|
||||
} else if (data === "\x17") {
|
||||
// Ctrl+W: word-erase — kill the trailing whitespace + word.
|
||||
typedInputBufferRef.current = typedInputBufferRef.current.replace(/\s*\S+\s*$/, "");
|
||||
} else if (data.startsWith("\x1b[200~")) {
|
||||
// Bracketed paste: "\x1b[200~...\x1b[201~". The inner bytes are
|
||||
// literal input, so newlines stay on the zle line instead of
|
||||
// executing each segment — meaning we must preserve the whole
|
||||
// content in the buffer, not just the post-final-newline tail
|
||||
// (Codex #814 P2).
|
||||
//
|
||||
// Reliability is *inherited*, not reset: if the buffer was
|
||||
// already aligned with the line (reliable=true), appending this
|
||||
// paste keeps it aligned; if the buffer was unreliable (e.g.
|
||||
// after ↑ recalled a history command so line ≠ buffer), the
|
||||
// paste only extends the tail but the head is still whatever
|
||||
// the shell had, so the buffer stays unreliable. Without this,
|
||||
// a paste-after-recall flow would flip reliability back on and
|
||||
// Enter would record just the pasted suffix as the command
|
||||
// (Codex #814 P1 follow-up).
|
||||
const endIdx = data.indexOf("\x1b[201~");
|
||||
const content = endIdx >= 0
|
||||
? data.slice("\x1b[200~".length, endIdx)
|
||||
: data.slice("\x1b[200~".length);
|
||||
typedInputBufferRef.current += content;
|
||||
// Paste extends the line past whatever was accepted, so the
|
||||
// Enter fast-path must not record the pre-paste accepted
|
||||
// command — mirrors the non-bracketed paste branch below.
|
||||
lastAcceptedCommandRef.current = null;
|
||||
clearState();
|
||||
return;
|
||||
} else if (data.startsWith("\x1b") && data !== "\x1b") {
|
||||
// Cursor-movement / function keys — we lose track of where the
|
||||
// cursor sits relative to our append-only buffer. Mark the
|
||||
// buffer unreliable and drop it; detectPrompt takes over until
|
||||
// the next Enter / Ctrl-C / Ctrl-U.
|
||||
typedInputBufferRef.current = "";
|
||||
typedBufferReliableRef.current = false;
|
||||
} else if (data.length === 1 && data.charCodeAt(0) >= 32) {
|
||||
typedInputBufferRef.current += data;
|
||||
} else if (data.length > 1 && !data.startsWith("\x1b")) {
|
||||
// Paste chunk. Any \r / \n inside executes the preceding text as
|
||||
// a command in the shell, so keeping the pre-newline portion in
|
||||
// our buffer would leave stale content that a later Enter could
|
||||
// record (Codex #814 P2). Drop everything up to and including
|
||||
// the last terminator and keep only the tail as new content.
|
||||
// Intermediate executed lines aren't synthesized back into
|
||||
// recordCommand here — the onCommandExecuted path in
|
||||
// createXTermRuntime still captures them independently.
|
||||
const lastCR = data.lastIndexOf("\r");
|
||||
const lastLF = data.lastIndexOf("\n");
|
||||
const nlIdx = Math.max(lastCR, lastLF);
|
||||
if (nlIdx >= 0) {
|
||||
typedInputBufferRef.current = data.slice(nlIdx + 1);
|
||||
typedBufferReliableRef.current = true;
|
||||
// The embedded newline flushed any previously-accepted
|
||||
// suggestion too — clearing the cache here prevents the next
|
||||
// Enter from falling into the lastAcceptedCommandRef fast path
|
||||
// and recording that stale command.
|
||||
lastAcceptedCommandRef.current = null;
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
typedInputBufferRef.current += data;
|
||||
} else if (data.length === 1 && data.charCodeAt(0) < 32) {
|
||||
// Any other single control char (Ctrl-A, Ctrl-E, Ctrl-B, Ctrl-F,
|
||||
// Ctrl-R, Ctrl-P, Ctrl-N, ...) moves the cursor or swaps the
|
||||
// line in ways this append-only buffer can't follow. Same story
|
||||
// as escape sequences above — and hide the ghost too, so the
|
||||
// unreliable-accept fallback doesn't pull a stale tail onto a
|
||||
// recalled line (Codex #815 follow-up).
|
||||
typedInputBufferRef.current = "";
|
||||
typedBufferReliableRef.current = false;
|
||||
// Null the fast-path accepted-command cache: accept-then-Ctrl-R
|
||||
// should not let an old accepted command sneak back in via the
|
||||
// Enter fast path after reverse-search picks a different one.
|
||||
lastAcceptedCommandRef.current = null;
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape sequences (arrow keys, Home, End, etc.): clear stale suggestions
|
||||
// since cursor position may have changed, making current suggestions invalid.
|
||||
// Up/Down/Right/Tab are handled by handleKeyEvent; other sequences land here.
|
||||
if (data.startsWith("\x1b") && data !== "\x1b") {
|
||||
// Same fast-path reset as the single-byte ctrl-char branch above —
|
||||
// accept-then-↑/↓ must not record the stale accepted command if
|
||||
// the user then presses Enter on a different recalled line.
|
||||
lastAcceptedCommandRef.current = null;
|
||||
clearState();
|
||||
return;
|
||||
}
|
||||
|
||||
// User is typing more — invalidate accepted command fallback since the
|
||||
// command is being edited further (e.g., accepted "git status" then added " --short")
|
||||
lastAcceptedCommandRef.current = null;
|
||||
// The previewed candidate is now edited, so the line is the user's own
|
||||
// text. Drop preview-active so Escape dismisses the popup without
|
||||
// reverting these edits back to the stale baseline (#1005).
|
||||
previewActiveRef.current = false;
|
||||
if (typedBufferReliableRef.current) {
|
||||
previewBaselineRef.current = typedInputBufferRef.current;
|
||||
}
|
||||
|
||||
// The popup must follow the edited line immediately, before the debounced
|
||||
// provider refresh runs. Reconcile stale history rows against the current
|
||||
// input; an unreliable append-only buffer cannot validate any old row.
|
||||
if (settingsRef.current.showPopupMenu) {
|
||||
const currentInput = typedBufferReliableRef.current
|
||||
? typedInputBufferRef.current
|
||||
: null;
|
||||
syncPopupToInput(
|
||||
currentInput !== null && currentInput.length >= settingsRef.current.minChars
|
||||
? currentInput
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
// Re-align any visible ghost text to the freshly-updated buffer
|
||||
// immediately. Without this the ghost keeps the tail it captured at
|
||||
// show() time; a fast "type + press →" sequence then pastes the
|
||||
// pre-update tail on top of the new input ("doc" + "cker ls" →
|
||||
// "doccker ls"). Skip when the user has turned showGhostText off
|
||||
// mid-session: otherwise a ghost that was active before the toggle
|
||||
// would keep moving around under a setting the user just said to
|
||||
// disable (Codex #815 P2).
|
||||
//
|
||||
// Reliable buffer: feed adjustToInput the full post-mutation buffer
|
||||
// so multi-char pastes refresh the ghost as one batch. Unreliable
|
||||
// buffer (post Tab / cursor-move / history recall): the buffer
|
||||
// is just the suffix typed since unreliability began, so feeding
|
||||
// it to adjustToInput would fail the prefix invariant and hide
|
||||
// the ghost. Instead let the addon evolve its own currentInput
|
||||
// off the keystroke directly (issue #906) — that input was seeded
|
||||
// by the last show() with the live xterm reading, which is the
|
||||
// only post-Tab source-of-truth we have.
|
||||
if (settingsRef.current.showGhostText) {
|
||||
if (typedBufferReliableRef.current) {
|
||||
ghostAddonRef.current?.adjustToInput(typedInputBufferRef.current);
|
||||
} else {
|
||||
ghostAddonRef.current?.applyKeystroke(data);
|
||||
}
|
||||
}
|
||||
|
||||
// Fast typing suppression: if typing faster than threshold, skip this debounce cycle
|
||||
const isFastTyping = timeSinceLastKeystroke < settingsRef.current.fastTypingThresholdMs;
|
||||
|
||||
// Debounced suggestion fetch
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
if (isFastTyping) {
|
||||
// Still debounce, but with a longer delay to wait for typing to pause
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
debounceTimerRef.current = null;
|
||||
void fetchSuggestions();
|
||||
}, settingsRef.current.debounceMs * 3);
|
||||
} else {
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
debounceTimerRef.current = null;
|
||||
void fetchSuggestions();
|
||||
}, settingsRef.current.debounceMs);
|
||||
}
|
||||
}
|
||||
387
components/terminal/autocomplete/terminalAutocompleteKeyEvent.ts
Normal file
387
components/terminal/autocomplete/terminalAutocompleteKeyEvent.ts
Normal file
@@ -0,0 +1,387 @@
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
|
||||
import type { GhostTextAddon } from "./GhostTextAddon";
|
||||
import type { AutocompleteSettings, AutocompleteState, SubDirEntry } from "./useTerminalAutocomplete";
|
||||
import type { Snippet } from "../../../domain/models";
|
||||
|
||||
interface TerminalAutocompleteKeyEventContext {
|
||||
settingsRef: MutableRefObject<AutocompleteSettings>;
|
||||
stateRef: MutableRefObject<AutocompleteState>;
|
||||
ghostAddonRef: MutableRefObject<GhostTextAddon | null>;
|
||||
typedInputBufferRef: MutableRefObject<string>;
|
||||
typedBufferReliableRef: MutableRefObject<boolean>;
|
||||
previewActiveRef: MutableRefObject<boolean>;
|
||||
lastAcceptedCommandRef: MutableRefObject<string | null>;
|
||||
setState: Dispatch<SetStateAction<AutocompleteState>>;
|
||||
expandSubDir: (level: number, entry: SubDirEntry, moveFocus?: boolean) => void;
|
||||
writeToTerminal: (text: string) => void;
|
||||
clearState: () => void;
|
||||
renderSubDirPath: (level: number, entry: SubDirEntry) => void;
|
||||
handleSubDirSelect: (level: number, entry: SubDirEntry) => void;
|
||||
fetchSubDirForIndex: (index: number) => void;
|
||||
renderPreviewSelection: (index: number) => void;
|
||||
acceptPreviewlessSelection: (index: number) => boolean;
|
||||
acceptSnippet: (snippet: Snippet) => boolean;
|
||||
/** Deadline (ms) until which `.` / `_` are treated as readline Meta follow-ups. */
|
||||
escMetaPrefixUntilRef: MutableRefObject<number>;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** Readline keyseq-timeout default; Esc then . within this window is M-. */
|
||||
export const AUTOCOMPLETE_ESC_META_TIMEOUT_MS = 500;
|
||||
|
||||
export function autocompleteEscMetaFollowUpSequence(e: {
|
||||
key: string;
|
||||
altKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
shiftKey: boolean;
|
||||
}): string | null {
|
||||
if (e.altKey || e.ctrlKey || e.metaKey) return null;
|
||||
// `_` is Shift+Minus on a standard keyboard; Shift+. is `>` and must not yank.
|
||||
if (e.key === "." && !e.shiftKey) return "\x1b.";
|
||||
if (e.key === "_") return "\x1b_";
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAutocompleteConfirmEnter = (
|
||||
e: KeyboardEvent,
|
||||
settings: AutocompleteSettings,
|
||||
): boolean => (
|
||||
e.key === "Enter" &&
|
||||
!e.ctrlKey &&
|
||||
!e.metaKey &&
|
||||
!e.altKey &&
|
||||
(!e.shiftKey || settings.shiftEnterNewlineEnabled === false)
|
||||
);
|
||||
|
||||
export function handleTerminalAutocompleteKeyEvent(
|
||||
e: KeyboardEvent,
|
||||
context: TerminalAutocompleteKeyEventContext,
|
||||
): boolean {
|
||||
const {
|
||||
settingsRef,
|
||||
stateRef,
|
||||
ghostAddonRef,
|
||||
typedInputBufferRef,
|
||||
typedBufferReliableRef,
|
||||
previewActiveRef,
|
||||
lastAcceptedCommandRef,
|
||||
setState,
|
||||
expandSubDir,
|
||||
writeToTerminal,
|
||||
clearState,
|
||||
renderSubDirPath,
|
||||
handleSubDirSelect,
|
||||
fetchSubDirForIndex,
|
||||
renderPreviewSelection,
|
||||
acceptPreviewlessSelection,
|
||||
acceptSnippet,
|
||||
escMetaPrefixUntilRef,
|
||||
now = Date.now,
|
||||
} = context;
|
||||
if (!settingsRef.current.enabled || e.type !== "keydown") return true;
|
||||
|
||||
const metaFollowUp = autocompleteEscMetaFollowUpSequence(e);
|
||||
if (metaFollowUp && now() < escMetaPrefixUntilRef.current) {
|
||||
escMetaPrefixUntilRef.current = 0;
|
||||
e.preventDefault();
|
||||
writeToTerminal(metaFollowUp);
|
||||
// Match handleTerminalAutocompleteInput's ESC-sequence path: yank-last-arg
|
||||
// rewrites the shell line, so the append-only typed buffer is stale.
|
||||
typedInputBufferRef.current = "";
|
||||
typedBufferReliableRef.current = false;
|
||||
lastAcceptedCommandRef.current = null;
|
||||
return false;
|
||||
}
|
||||
if (e.key !== "Escape" && e.key !== "Shift" && e.key !== "Control" && e.key !== "Alt" && e.key !== "Meta") {
|
||||
escMetaPrefixUntilRef.current = 0;
|
||||
}
|
||||
|
||||
const s = stateRef.current;
|
||||
const ghost = ghostAddonRef.current;
|
||||
|
||||
// Right arrow: if popup has selected directory with sub-dir panel, enter it
|
||||
// Skip this handler entirely when sub-dir panels are focused — let the
|
||||
// sub-panel navigation block handle → for deeper expansion.
|
||||
if (e.key === "ArrowRight" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && s.subDirFocusLevel < 0) {
|
||||
if (s.popupVisible && s.selectedIndex >= 0 && s.subDirPanels.length > 0) {
|
||||
const selected = s.suggestions[s.selectedIndex];
|
||||
if (selected?.fileType === "directory") {
|
||||
e.preventDefault();
|
||||
const firstEntry = s.subDirPanels[0]?.entries[0];
|
||||
setState((prev) => {
|
||||
const panels = [...prev.subDirPanels];
|
||||
if (panels[0]) panels[0] = { ...panels[0], selectedIndex: 0 };
|
||||
return { ...prev, subDirPanels: panels, subDirFocusLevel: 0 };
|
||||
});
|
||||
if (firstEntry?.type === "directory") {
|
||||
expandSubDir(0, firstEntry, false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Otherwise: accept ghost text. Use isActive(), not isVisible(),
|
||||
// so a fast "type + →" that lands in the hide-until-render gap
|
||||
// still hits this branch and accepts the pending ghost.
|
||||
if (ghost?.isActive()) {
|
||||
e.preventDefault();
|
||||
const fullSuggestion = ghost.getSuggestion();
|
||||
// When the keystroke buffer is reliable, recompute the tail
|
||||
// against the *live* buffer so a fast "type + →" in the
|
||||
// hide-until-render gap still writes the correct tail. When
|
||||
// it's not reliable (post history-recall / Ctrl-R), we can't
|
||||
// treat empty buffer as "nothing typed" — the line actually
|
||||
// has content we're not tracking — so fall back to the
|
||||
// ghost's own cached tail instead of writing the entire
|
||||
// suggestion onto an already-populated line.
|
||||
let ghostText: string;
|
||||
let newBuffer: string | null;
|
||||
if (typedBufferReliableRef.current) {
|
||||
const live = typedInputBufferRef.current;
|
||||
if (fullSuggestion && fullSuggestion.startsWith(live)) {
|
||||
ghostText = fullSuggestion.substring(live.length);
|
||||
newBuffer = fullSuggestion;
|
||||
} else {
|
||||
ghostText = "";
|
||||
newBuffer = null;
|
||||
}
|
||||
} else {
|
||||
ghostText = ghost.getGhostText();
|
||||
newBuffer = null; // buffer is unreliable; don't flip it back on
|
||||
}
|
||||
if (ghostText) {
|
||||
writeToTerminal(ghostText);
|
||||
lastAcceptedCommandRef.current = fullSuggestion;
|
||||
if (newBuffer !== null) {
|
||||
typedInputBufferRef.current = newBuffer;
|
||||
typedBufferReliableRef.current = true;
|
||||
}
|
||||
ghost.hide();
|
||||
clearState();
|
||||
} else {
|
||||
ghost.hide();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+Right / Alt+Right (Mac): accept next word
|
||||
if (e.key === "ArrowRight" && (e.ctrlKey || e.altKey) && !e.metaKey && !e.shiftKey) {
|
||||
if (ghost?.isActive()) {
|
||||
e.preventDefault();
|
||||
const fullSuggestion = ghost.getSuggestion();
|
||||
if (!fullSuggestion) {
|
||||
ghost.hide();
|
||||
return false;
|
||||
}
|
||||
// Determine the baseline the next word should extend. Reliable
|
||||
// buffer: resync the ghost to the live buffer so getNextWord
|
||||
// operates on the up-to-date tail. Unreliable buffer (post
|
||||
// history-recall / Ctrl-R): don't reanchor to "" — that would
|
||||
// make getNextWord hand back the very first word and the shell
|
||||
// would duplicate leading tokens on top of the recalled line.
|
||||
// Fall back to the ghost's existing cached input instead.
|
||||
if (typedBufferReliableRef.current) {
|
||||
const live = typedInputBufferRef.current;
|
||||
if (fullSuggestion.startsWith(live)) {
|
||||
ghost.show(fullSuggestion, live);
|
||||
} else {
|
||||
ghost.hide();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const base = ghost.getGhostText().length > 0
|
||||
? fullSuggestion.substring(0, fullSuggestion.length - ghost.getGhostText().length)
|
||||
: fullSuggestion;
|
||||
const nextWord = ghost.getNextWord();
|
||||
if (nextWord) {
|
||||
writeToTerminal(nextWord);
|
||||
// Only extend the buffer if it was already aligned with the
|
||||
// line — otherwise we'd end up with just the appended word,
|
||||
// which the next Enter would then record as the command.
|
||||
if (typedBufferReliableRef.current) {
|
||||
typedInputBufferRef.current += nextWord;
|
||||
}
|
||||
// Shrink the ghost to reflect what's left after the accept.
|
||||
const newInput = base + nextWord;
|
||||
if (fullSuggestion.startsWith(newInput) && fullSuggestion.length > newInput.length) {
|
||||
ghost.show(fullSuggestion, newInput);
|
||||
} else {
|
||||
ghost.hide();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Tab: accept selected popup suggestion. Ghost text is accepted via → only —
|
||||
// letting Tab pass through lets the shell's native completion (bash/zsh) run,
|
||||
// which is otherwise shadowed by our single-Tab ghost accept.
|
||||
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.altKey && s.subDirFocusLevel < 0) {
|
||||
if (s.popupVisible && s.suggestions.length > 0) {
|
||||
// #1005: don't intercept Tab. Keep whatever is currently rendered on
|
||||
// the line and let Tab reach the shell for native completion.
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return true;
|
||||
}
|
||||
// Hide stale ghost text before Tab reaches the shell — the shell's
|
||||
// completion will rewrite the line and the old ghost would mislead.
|
||||
if (ghost?.isActive()) {
|
||||
ghost.hide();
|
||||
}
|
||||
}
|
||||
|
||||
// Up/Down/Left/Right: navigate popup + sub-dir panel
|
||||
if (s.popupVisible && s.suggestions.length > 0) {
|
||||
|
||||
const focusLevel = s.subDirFocusLevel;
|
||||
const focusedPanel = focusLevel >= 0 ? s.subDirPanels[focusLevel] : null;
|
||||
|
||||
// Sub-dir panel focused: ↑↓ navigate, ← go back, → go deeper
|
||||
if (focusLevel >= 0 && focusedPanel) {
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const newIdx = e.key === "ArrowUp"
|
||||
? (focusedPanel.selectedIndex <= 0 ? focusedPanel.entries.length - 1 : focusedPanel.selectedIndex - 1)
|
||||
: (focusedPanel.selectedIndex >= focusedPanel.entries.length - 1 ? 0 : focusedPanel.selectedIndex + 1);
|
||||
setState((prev) => {
|
||||
const panels = [...prev.subDirPanels];
|
||||
const p = panels[focusLevel];
|
||||
if (!p) return prev;
|
||||
panels[focusLevel] = { ...p, selectedIndex: newIdx };
|
||||
return { ...prev, subDirPanels: panels.slice(0, focusLevel + 1) };
|
||||
});
|
||||
// Live-render the highlighted entry's full path into the line (#1005).
|
||||
const newEntry = focusedPanel.entries[newIdx];
|
||||
if (newEntry && settingsRef.current.livePreview) renderSubDirPath(focusLevel, newEntry);
|
||||
// Auto-expand next level if the newly selected item is a directory
|
||||
if (newEntry?.type === "directory") {
|
||||
expandSubDir(focusLevel, newEntry);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (e.key === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
subDirPanels: prev.subDirPanels.slice(0, focusLevel + 1),
|
||||
subDirFocusLevel: focusLevel - 1,
|
||||
}));
|
||||
return false;
|
||||
}
|
||||
if (e.key === "ArrowRight") {
|
||||
const entry = focusedPanel.entries[focusedPanel.selectedIndex];
|
||||
if (entry?.type === "directory") {
|
||||
e.preventDefault();
|
||||
expandSubDir(focusLevel, entry, true); // moveFocus = true
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (isAutocompleteConfirmEnter(e, settingsRef.current) || e.key === "Tab") {
|
||||
const entry = focusedPanel.entries[focusedPanel.selectedIndex];
|
||||
if (entry && focusedPanel.selectedIndex >= 0) {
|
||||
e.preventDefault();
|
||||
handleSubDirSelect(focusLevel, entry);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
if (focusLevel > 0) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
subDirPanels: prev.subDirPanels.slice(0, focusLevel),
|
||||
subDirFocusLevel: focusLevel - 1,
|
||||
}));
|
||||
} else {
|
||||
setState((prev) => ({ ...prev, subDirPanels: [], subDirFocusLevel: -1 }));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
e.key.length === 1 ||
|
||||
e.key === "Backspace" ||
|
||||
e.key === "Delete" ||
|
||||
e.key === "Home" ||
|
||||
e.key === "End"
|
||||
) {
|
||||
clearState();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Main panel navigation. The cycle includes a -1 "no selection" slot so
|
||||
// ↑ off the top / ↓ off the bottom reverts to the typed baseline. Moving
|
||||
// the selection live-renders the candidate into the command line (#1005).
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const n = s.suggestions.length;
|
||||
const cur = s.selectedIndex;
|
||||
const next =
|
||||
e.key === "ArrowDown"
|
||||
? (cur >= n - 1 ? -1 : cur + 1)
|
||||
: (cur <= -1 ? n - 1 : cur - 1);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
selectedIndex: next,
|
||||
subDirPanels: [], subDirFocusLevel: -1,
|
||||
}));
|
||||
if (settingsRef.current.livePreview) renderPreviewSelection(next);
|
||||
if (next >= 0) fetchSubDirForIndex(next);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enter on popup. The selected candidate is already rendered into the
|
||||
// line by live-preview, so let Enter reach the shell. Don't record here:
|
||||
// handleInput's Enter path records the *actual* line — it uses
|
||||
// lastAcceptedCommandRef (set on select) but falls back to the live
|
||||
// buffer when the user edited the previewed command (typing nulls that
|
||||
// ref), so recording stays accurate in both cases.
|
||||
if (isAutocompleteConfirmEnter(e, settingsRef.current)) {
|
||||
const selected = s.selectedIndex >= 0 ? s.suggestions[s.selectedIndex] : null;
|
||||
if (selected?.source === "snippet" && selected.snippet) {
|
||||
if (!acceptSnippet(selected.snippet)) {
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return true;
|
||||
}
|
||||
e.preventDefault();
|
||||
previewActiveRef.current = false;
|
||||
return false; // consume — run the snippet, not the typed text
|
||||
}
|
||||
if (!settingsRef.current.livePreview && selected) {
|
||||
if (acceptPreviewlessSelection(s.selectedIndex)) {
|
||||
e.preventDefault();
|
||||
previewActiveRef.current = false;
|
||||
return false;
|
||||
}
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return true;
|
||||
}
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Escape: close popup and hide ghost text.
|
||||
// Only consume Escape if popup is visible; don't block Escape for vi-mode shells
|
||||
// when only ghost text is showing (ghost text is passive/non-intrusive).
|
||||
// After dismissing the popup, arm a short Meta prefix so Esc+. / Esc+_ still
|
||||
// reach readline yank-last-arg (issue #2364) without entering vi-cmd mode.
|
||||
if (e.key === "Escape" && s.popupVisible) {
|
||||
e.preventDefault();
|
||||
if (previewActiveRef.current) {
|
||||
renderPreviewSelection(-1); // restore the typed baseline
|
||||
}
|
||||
ghost?.hide();
|
||||
clearState();
|
||||
previewActiveRef.current = false;
|
||||
escMetaPrefixUntilRef.current = now() + AUTOCOMPLETE_ESC_META_TIMEOUT_MS;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
610
components/terminal/autocomplete/terminalAutocompleteLayout.ts
Normal file
610
components/terminal/autocomplete/terminalAutocompleteLayout.ts
Normal file
@@ -0,0 +1,610 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { CompletionSuggestion } from "./completionEngine";
|
||||
import type { PromptDetectionResult } from "./promptDetector";
|
||||
import type { SubDirPanel } from "./useTerminalAutocomplete";
|
||||
import { stringCellWidth } from "./terminalStringCellWidth";
|
||||
import { getXTermCellDimensions } from "./xtermUtils";
|
||||
|
||||
export function resolveAutocompleteCwd(
|
||||
promptText: string,
|
||||
currentWord: string,
|
||||
fallbackCwd: string | undefined,
|
||||
os: "linux" | "windows" | "macos",
|
||||
): string | undefined {
|
||||
return resolveAutocompleteCwdWithSource(promptText, currentWord, fallbackCwd, os).cwd;
|
||||
}
|
||||
|
||||
export type AutocompleteCwdSource = "prompt" | "fallback" | "none";
|
||||
|
||||
export function resolveAutocompleteCwdWithSource(
|
||||
promptText: string,
|
||||
currentWord: string,
|
||||
fallbackCwd: string | undefined,
|
||||
os: "linux" | "windows" | "macos",
|
||||
): { cwd: string | undefined; source: AutocompleteCwdSource } {
|
||||
if (os === "windows") return { cwd: fallbackCwd, source: fallbackCwd ? "fallback" : "none" };
|
||||
|
||||
const normalizedWord = currentWord.trim().replace(/^['"]/, "");
|
||||
|
||||
// Absolute or home-relative paths don't depend on cwd
|
||||
if (normalizedWord.startsWith("/") || normalizedWord.startsWith("~/")) {
|
||||
return { cwd: fallbackCwd, source: fallbackCwd ? "fallback" : "none" };
|
||||
}
|
||||
|
||||
// For empty word (e.g. "cd ") and relative paths, try prompt-based cwd
|
||||
// extraction which reflects the current visible prompt — more up-to-date
|
||||
// than fallbackCwd when OSC 7 is not supported.
|
||||
const promptCwd = extractPosixCwdFromPrompt(promptText);
|
||||
return chooseAutocompleteCwdWithSource(promptCwd, fallbackCwd);
|
||||
}
|
||||
|
||||
function chooseAutocompleteCwdWithSource(
|
||||
promptCwd: string | undefined,
|
||||
fallbackCwd: string | undefined,
|
||||
): { cwd: string | undefined; source: AutocompleteCwdSource } {
|
||||
if (!promptCwd) return { cwd: fallbackCwd, source: fallbackCwd ? "fallback" : "none" };
|
||||
if (!fallbackCwd) return { cwd: promptCwd, source: "prompt" };
|
||||
|
||||
// Prompt cwd is extracted from the currently visible prompt, so it tracks
|
||||
// directory changes even when OSC 7 is not supported. Prefer it over
|
||||
// fallbackCwd (which may be stale from initial connection) whenever it
|
||||
// looks like a usable path.
|
||||
if (promptCwd.startsWith("/") || promptCwd === "~" || promptCwd.startsWith("~/")) {
|
||||
return { cwd: promptCwd, source: "prompt" };
|
||||
}
|
||||
|
||||
// Bare directory name (e.g. "xunlong") can't be used as a path — fallback
|
||||
return { cwd: fallbackCwd, source: fallbackCwd ? "fallback" : "none" };
|
||||
}
|
||||
|
||||
function extractPosixCwdFromPrompt(promptText: string): string | undefined {
|
||||
const trimmed = promptText.trimEnd().replace(/[#$%>]\s*$/, "");
|
||||
if (!trimmed) return undefined;
|
||||
|
||||
const patterns = [
|
||||
/:(\/[^\s\]]*|~(?:\/[^\s\]]*)?)$/,
|
||||
/\s(\/[^\s\]]*|~(?:\/[^\s\]]*)?)\]$/,
|
||||
/(^|[\s:])(\/[^\s\]]*|~(?:\/[^\s\]]*)?)$/,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = trimmed.match(pattern);
|
||||
if (!match) continue;
|
||||
const candidate = match[match.length - 1];
|
||||
if (candidate === "/" || candidate.startsWith("/") || candidate === "~" || candidate.startsWith("~/")) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackTokens = trimmed
|
||||
.split(/\s+/)
|
||||
.map((token) => token.replace(/^[([{:]+/, "").replace(/[\])}:]+$/, ""));
|
||||
|
||||
for (let index = fallbackTokens.length - 1; index >= 0; index--) {
|
||||
const candidate = fallbackTokens[index];
|
||||
if (candidate === "/" || candidate.startsWith("/") || candidate === "~" || candidate.startsWith("~/")) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function areSuggestionsEqual(
|
||||
left: CompletionSuggestion[],
|
||||
right: CompletionSuggestion[],
|
||||
): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
const a = left[i];
|
||||
const b = right[i];
|
||||
if (
|
||||
a.text !== b.text ||
|
||||
a.displayText !== b.displayText ||
|
||||
a.description !== b.description ||
|
||||
a.source !== b.source ||
|
||||
a.score !== b.score ||
|
||||
a.frequency !== b.frequency ||
|
||||
a.fileType !== b.fileType
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a popup highlight across a same-query list refresh (e.g. late path
|
||||
* suggestions). Match the previously selected row by stable identity; if a
|
||||
* late path replaces a same-text history/plugin entry, fall back to text.
|
||||
*/
|
||||
export function resolvePreservedSuggestionIndex(
|
||||
previousSuggestions: CompletionSuggestion[],
|
||||
previousSelectedIndex: number,
|
||||
nextSuggestions: CompletionSuggestion[],
|
||||
): number {
|
||||
if (previousSelectedIndex < 0 || previousSelectedIndex >= previousSuggestions.length) {
|
||||
return -1;
|
||||
}
|
||||
const selected = previousSuggestions[previousSelectedIndex];
|
||||
if (!selected) return -1;
|
||||
|
||||
const exactIndex = nextSuggestions.findIndex(
|
||||
(candidate) =>
|
||||
candidate.text === selected.text &&
|
||||
candidate.source === selected.source &&
|
||||
candidate.displayText === selected.displayText &&
|
||||
candidate.fileType === selected.fileType,
|
||||
);
|
||||
if (exactIndex >= 0) return exactIndex;
|
||||
|
||||
return nextSuggestions.findIndex((candidate) => candidate.text === selected.text);
|
||||
}
|
||||
|
||||
export function areSubDirPanelsEqual(left: SubDirPanel[], right: SubDirPanel[]): boolean {
|
||||
if (left.length !== right.length) return false;
|
||||
for (let i = 0; i < left.length; i++) {
|
||||
const a = left[i];
|
||||
const b = right[i];
|
||||
if (a.dirPath !== b.dirPath || a.selectedIndex !== b.selectedIndex) return false;
|
||||
if (a.entries.length !== b.entries.length) return false;
|
||||
for (let j = 0; j < a.entries.length; j++) {
|
||||
if (a.entries[j].name !== b.entries[j].name || a.entries[j].type !== b.entries[j].type) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface PopupClampViewport {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface PopupPlacementInput {
|
||||
/** Anchor (current input line) top edge, in viewport coordinates. */
|
||||
anchorTop: number;
|
||||
/** Anchor (current input line) bottom edge, in viewport coordinates. */
|
||||
anchorBottom: number;
|
||||
/** Desired left edge (cursor column), in viewport coordinates. */
|
||||
anchorLeft: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
/**
|
||||
* Optional clamp region in viewport coordinates. Defaults to the rectangle
|
||||
* `(0, 0, viewportWidth, viewportHeight)`.
|
||||
*/
|
||||
clampViewport?: PopupClampViewport;
|
||||
/** Natural height the popup wants if unconstrained (main list or detail). */
|
||||
desiredHeight: number;
|
||||
/**
|
||||
* Total horizontal extent of the popup including any cascading sub-directory
|
||||
* panels and the detail tooltip — used so the whole assembly is clamped
|
||||
* inside the viewport, not just the main list.
|
||||
*/
|
||||
totalWidth: number;
|
||||
/**
|
||||
* Width budget for horizontal clamping. Defaults to `totalWidth`. The detail
|
||||
* tooltip is rendered beside the list and can extend left on its own, so
|
||||
* callers may pass a smaller width to keep the primary list near the cursor.
|
||||
*/
|
||||
clampWidth?: number;
|
||||
/** Hard cap on rendered height (matches the list's maxHeight prop). */
|
||||
maxHeight: number;
|
||||
/** Gap between the anchor line and the popup. */
|
||||
anchorGap: number;
|
||||
/** Minimum distance to keep from the viewport edges. */
|
||||
viewportPadding: number;
|
||||
/**
|
||||
* Direction hint from the cursor-cell based calculation. Only used to break
|
||||
* ties when neither side can fully fit the desired height.
|
||||
*/
|
||||
expandUpwardHint: boolean;
|
||||
/**
|
||||
* When true, keep rendering above the supplied anchor even if there is a
|
||||
* full fit below. Used after a wrap pins the anchor to the command start
|
||||
* so placement cannot flip down over the continuation rows (#3061).
|
||||
*/
|
||||
forceExpandUpward?: boolean;
|
||||
}
|
||||
|
||||
export interface PopupPlacement {
|
||||
/** Whether the popup renders above the anchor line (flipped up). */
|
||||
renderUpward: boolean;
|
||||
/** Final top edge, in viewport coordinates (already clamped). */
|
||||
top: number;
|
||||
/** Final left edge, in viewport coordinates (already clamped). */
|
||||
left: number;
|
||||
/** Height budget for the rendered content (drives scrolling). */
|
||||
maxHeight: number;
|
||||
}
|
||||
|
||||
export interface PopupGeometryClampInput {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
clampViewport: PopupClampViewport;
|
||||
viewportPadding: number;
|
||||
}
|
||||
|
||||
export interface PopupGeometry {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
function clampCoordinate(value: number, min: number, max: number): number {
|
||||
if (max <= min) return min;
|
||||
return Math.max(min, Math.min(value, max));
|
||||
}
|
||||
|
||||
/**
|
||||
* Final guardrail using the rendered popup's actual DOM size. The placement
|
||||
* pass uses estimated list/detail/panel sizes so it can decide before render;
|
||||
* this pass prevents any estimate mismatch or delayed xterm cursor refresh
|
||||
* from letting the fixed-position portal escape the terminal/app bounds.
|
||||
*/
|
||||
export function clampAutocompletePopupGeometry(
|
||||
input: PopupGeometryClampInput,
|
||||
): PopupGeometry {
|
||||
const { left, top, width, height, clampViewport, viewportPadding } = input;
|
||||
const safeWidth = Number.isFinite(width) ? Math.max(0, width) : 0;
|
||||
const safeHeight = Number.isFinite(height) ? Math.max(0, height) : 0;
|
||||
const minLeft = clampViewport.left + viewportPadding;
|
||||
const minTop = clampViewport.top + viewportPadding;
|
||||
const maxLeft = clampViewport.left + clampViewport.width - viewportPadding - safeWidth;
|
||||
const maxTop = clampViewport.top + clampViewport.height - viewportPadding - safeHeight;
|
||||
|
||||
return {
|
||||
left: clampCoordinate(left, minLeft, Math.max(minLeft, maxLeft)),
|
||||
top: clampCoordinate(top, minTop, Math.max(minTop, maxTop)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide where to place the autocomplete popup so it never spills past the
|
||||
* viewport edges. Pure and deterministic so the boundary math is unit-tested
|
||||
* independently of React/DOM.
|
||||
*
|
||||
* Vertical: prefer downward, but flip upward when the space below the input
|
||||
* line can't fit the desired height and the space above is a better fit. The
|
||||
* height is then clamped to whatever the chosen side actually offers so the
|
||||
* list scrolls instead of overflowing.
|
||||
*
|
||||
* Horizontal: clamp the left edge using the popup's *total* width (main list +
|
||||
* cascading sub-dir panels + detail tooltip), not just the main list, so wide
|
||||
* assemblies near the right edge slide left instead of overflowing. When the
|
||||
* assembly is wider than the viewport it pins to the left padding so the
|
||||
* primary list stays visible.
|
||||
*/
|
||||
export function computeAutocompletePopupPlacement(
|
||||
input: PopupPlacementInput,
|
||||
): PopupPlacement {
|
||||
const {
|
||||
anchorTop,
|
||||
anchorBottom,
|
||||
anchorLeft,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
desiredHeight,
|
||||
totalWidth,
|
||||
maxHeight,
|
||||
anchorGap,
|
||||
viewportPadding,
|
||||
expandUpwardHint,
|
||||
forceExpandUpward = false,
|
||||
clampViewport,
|
||||
clampWidth,
|
||||
} = input;
|
||||
|
||||
const bounds: PopupClampViewport = clampViewport ?? {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: viewportWidth,
|
||||
height: viewportHeight,
|
||||
};
|
||||
const boundsRight = bounds.left + bounds.width;
|
||||
const boundsBottom = bounds.top + bounds.height;
|
||||
const horizontalClampWidth = clampWidth ?? totalWidth;
|
||||
|
||||
const cappedDesiredHeight = Math.min(maxHeight, Math.max(0, desiredHeight));
|
||||
const spaceAbove = Math.max(0, anchorTop - bounds.top - viewportPadding - anchorGap);
|
||||
const spaceBelow = Math.max(0, boundsBottom - anchorBottom - viewportPadding - anchorGap);
|
||||
const canFullyRenderAbove = spaceAbove >= cappedDesiredHeight;
|
||||
const canFullyRenderBelow = spaceBelow >= cappedDesiredHeight;
|
||||
const renderUpward = forceExpandUpward && spaceAbove > 0
|
||||
? true
|
||||
: canFullyRenderBelow
|
||||
? false
|
||||
: canFullyRenderAbove
|
||||
? true
|
||||
: expandUpwardHint
|
||||
? spaceAbove >= Math.min(spaceBelow, 80)
|
||||
: spaceAbove > spaceBelow;
|
||||
|
||||
const availableVerticalSpace = renderUpward ? spaceAbove : spaceBelow;
|
||||
const availableViewportHeight = Math.max(0, bounds.height - viewportPadding * 2);
|
||||
const effectiveMaxHeight = Math.max(
|
||||
0,
|
||||
Math.min(maxHeight, availableVerticalSpace, availableViewportHeight),
|
||||
);
|
||||
const contentHeightForPlacement = Math.min(effectiveMaxHeight, cappedDesiredHeight);
|
||||
const unclampedTop = renderUpward
|
||||
? Math.max(bounds.top + viewportPadding, anchorTop - anchorGap - contentHeightForPlacement)
|
||||
: Math.min(
|
||||
anchorBottom + anchorGap,
|
||||
boundsBottom - viewportPadding - contentHeightForPlacement,
|
||||
);
|
||||
const minTop = bounds.top + viewportPadding;
|
||||
const maxTop = Math.max(minTop, boundsBottom - viewportPadding - contentHeightForPlacement);
|
||||
const top = Math.max(minTop, Math.min(unclampedTop, maxTop));
|
||||
|
||||
// Right edge that keeps the clamped assembly inside the bounds. When the
|
||||
// assembly is wider than the available room this goes below the left padding,
|
||||
// so the final clamp pins the popup to the left padding (primary list wins).
|
||||
const maxLeft = boundsRight - viewportPadding - Math.max(0, horizontalClampWidth);
|
||||
const left = Math.max(bounds.left + viewportPadding, Math.min(anchorLeft, maxLeft));
|
||||
|
||||
return { renderUpward, top, left, maxHeight: effectiveMaxHeight };
|
||||
}
|
||||
|
||||
export interface AutocompleteViewportAnchor {
|
||||
anchorLeft: number;
|
||||
anchorTop: number;
|
||||
anchorBottom: number;
|
||||
expandUpward: boolean;
|
||||
}
|
||||
|
||||
const ESTIMATED_ROW_HEIGHT_PX = 28;
|
||||
const POPUP_CHROME_PADDING_PX = 8;
|
||||
|
||||
function estimatePopupHeight(itemCount: number): number {
|
||||
return itemCount * ESTIMATED_ROW_HEIGHT_PX + POPUP_CHROME_PADDING_PX;
|
||||
}
|
||||
|
||||
function shouldExpandAutocompleteUpward(
|
||||
cursorY: number,
|
||||
spaceBelowPx: number,
|
||||
spaceAbovePx: number,
|
||||
estimatedPopupHeight: number,
|
||||
): boolean {
|
||||
if (spaceBelowPx >= estimatedPopupHeight) return false;
|
||||
if (spaceAbovePx >= estimatedPopupHeight) return true;
|
||||
return cursorY > 2 && spaceAbovePx >= spaceBelowPx;
|
||||
}
|
||||
|
||||
/** Predicted cursor cell for popup anchoring (column within the row + row). */
|
||||
export type AutocompleteCursorCell = {
|
||||
column: number;
|
||||
row: number;
|
||||
};
|
||||
|
||||
function clampAutocompleteViewportRow(row: number, termRows: number): number {
|
||||
if (Number.isFinite(termRows) && termRows > 0) {
|
||||
return Math.max(0, Math.min(row, termRows - 1));
|
||||
}
|
||||
return Math.max(0, row);
|
||||
}
|
||||
|
||||
/** Absolute buffer index of the first physical row of the current wrapped line. */
|
||||
function resolveWrappedCommandStartAbsY(term: XTerm): number {
|
||||
const buffer = term.buffer.active;
|
||||
let startAbsY = buffer.cursorY + buffer.baseY;
|
||||
let startLine = buffer.getLine(startAbsY);
|
||||
while (startLine?.isWrapped && startAbsY > 0) {
|
||||
startAbsY -= 1;
|
||||
startLine = buffer.getLine(startAbsY);
|
||||
}
|
||||
return startAbsY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Viewport row of the command start (prompt / first physical line). After a
|
||||
* wrap at the bottom, xterm keeps the cursor on `term.rows - 1` and scrolls;
|
||||
* this row moves up with the wrapped command so the popup cannot cover it.
|
||||
*/
|
||||
export function resolveAutocompleteCommandStartRow(term: XTerm): number {
|
||||
const buffer = term.buffer.active;
|
||||
const startAbsY = resolveWrappedCommandStartAbsY(term);
|
||||
const viewportOrigin = Number.isFinite(buffer.viewportY) ? buffer.viewportY : buffer.baseY;
|
||||
return clampAutocompleteViewportRow(startAbsY - viewportOrigin, Number(term.rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort cursor cell for popup anchoring. xterm's helper textarea and
|
||||
* buffer.cursorX can lag behind the keystroke that triggered completion, so
|
||||
* derive the column from the aligned prompt and wrap onto following rows when
|
||||
* unechoed wide input crosses `term.cols`.
|
||||
*
|
||||
* When the live cursor already sits on a soft-wrapped continuation row,
|
||||
* measure from the logical line start so a still-unechoed `userInput` suffix
|
||||
* advances past the partial wrap instead of anchoring at the lagged cell.
|
||||
*
|
||||
* A wrap past the last visible row scrolls the buffer; xterm keeps the cursor
|
||||
* on `term.rows - 1`. Clamp the predicted row so a completion that resolves
|
||||
* before that scroll does not place the popup one cell below the grid.
|
||||
*/
|
||||
export function resolveAutocompleteCursorCell(
|
||||
term: XTerm,
|
||||
prompt: Pick<PromptDetectionResult, "promptText" | "userInput">,
|
||||
): AutocompleteCursorCell {
|
||||
const buffer = term.buffer.active;
|
||||
const cols = Math.max(1, Number(term.cols) || 80);
|
||||
const termRows = Number(term.rows);
|
||||
const absY = buffer.cursorY + buffer.baseY;
|
||||
|
||||
const startAbsY = resolveWrappedCommandStartAbsY(term);
|
||||
const startRowY = startAbsY - buffer.baseY;
|
||||
|
||||
let fromLine = (buffer.cursorY - startRowY) * cols + buffer.cursorX;
|
||||
const cursorLine = buffer.getLine(absY);
|
||||
if (cursorLine) {
|
||||
const lineText = cursorLine.translateToString(false);
|
||||
const tail = lineText.substring(buffer.cursorX).trimEnd();
|
||||
if (tail.length === 0) {
|
||||
const endCol = Math.max(buffer.cursorX, lineText.trimEnd().length);
|
||||
fromLine = (buffer.cursorY - startRowY) * cols + endCol;
|
||||
}
|
||||
}
|
||||
|
||||
// Use xterm's active Unicode width so CJK / emoji / fullwidth glyphs in
|
||||
// the synthetic pre-echo userInput advance the popup with the same cell
|
||||
// count as the real cursor (#2813).
|
||||
const fromPrompt =
|
||||
stringCellWidth(prompt.promptText, term) + stringCellWidth(prompt.userInput, term);
|
||||
const rawColumn = Math.max(fromLine, fromPrompt);
|
||||
const predictedRow = Math.max(0, startRowY + Math.floor(rawColumn / cols));
|
||||
// Only clamp when the terminal reports a real viewport height; missing
|
||||
// `rows` (tests/mocks) must not collapse every wrap onto row 0.
|
||||
return {
|
||||
column: rawColumn % cols,
|
||||
row: clampAutocompleteViewportRow(predictedRow, termRows),
|
||||
};
|
||||
}
|
||||
|
||||
/** Column-only helper for callers that do not need the predicted wrap row. */
|
||||
export function resolveAutocompleteCursorColumn(
|
||||
term: XTerm,
|
||||
prompt: Pick<PromptDetectionResult, "promptText" | "userInput">,
|
||||
): number {
|
||||
return resolveAutocompleteCursorCell(term, prompt).column;
|
||||
}
|
||||
|
||||
/** Clamp autocomplete popups to the active terminal screen in split workspaces.
|
||||
*
|
||||
* Uses the visible `.xterm-screen` rect as the clamp boundary so the popup
|
||||
* never overflows the *actual* rendered terminal grid. The `.xterm-container`
|
||||
* can be a few pixels taller than the screen (rounding/padding), so falling
|
||||
* back to its rect produced a false positive `spaceBelow` at the bottom row
|
||||
* and caused short suggestion lists to flip downward below the visible area
|
||||
* (see issue #1710).
|
||||
*/
|
||||
export function resolveAutocompleteClampViewport(container: HTMLElement | null): PopupClampViewport {
|
||||
const pane = container?.closest<HTMLElement>('[data-section="terminal-split-pane"]');
|
||||
const screen = container?.querySelector<HTMLElement>(".xterm-screen")
|
||||
?? null;
|
||||
// Clamp to the rendered screen so the popup cannot spill past the visible
|
||||
// terminal rows. If the screen is not mounted yet, fall back to the split
|
||||
// pane/container rect or the full viewport.
|
||||
const rect = screen?.getBoundingClientRect()
|
||||
?? pane?.getBoundingClientRect()
|
||||
?? container?.getBoundingClientRect();
|
||||
if (rect && rect.width > 0 && rect.height > 0) {
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: typeof window !== "undefined" ? window.innerWidth : 1200,
|
||||
height: typeof window !== "undefined" ? window.innerHeight : 800,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the autocomplete anchor in viewport coordinates so split panes and
|
||||
* padded xterm screens stay aligned with the real cursor.
|
||||
*
|
||||
* When `commandStartRow` is above `cursorRow` (a wrapped command), an
|
||||
* upward popup pins to the start row so it cannot cover the first physical
|
||||
* line after a wrap-induced scroll (#3061). Downward popups still pin to
|
||||
* the cursor row so they sit below the whole command.
|
||||
*/
|
||||
export function resolveAutocompleteAnchorInViewport(
|
||||
term: XTerm,
|
||||
container: HTMLElement | null,
|
||||
itemCount: number,
|
||||
cursorColumn = term.buffer.active.cursorX,
|
||||
cursorRow = term.buffer.active.cursorY,
|
||||
commandStartRow = cursorRow,
|
||||
): AutocompleteViewportAnchor {
|
||||
const empty: AutocompleteViewportAnchor = {
|
||||
anchorLeft: 0,
|
||||
anchorTop: 0,
|
||||
anchorBottom: 0,
|
||||
expandUpward: false,
|
||||
};
|
||||
if (!container || !term.element) return empty;
|
||||
|
||||
const rows = Math.max(1, term.rows);
|
||||
const estimatedPopupHeight = estimatePopupHeight(itemCount);
|
||||
const dims = getXTermCellDimensions(term);
|
||||
|
||||
const screen =
|
||||
container.querySelector<HTMLElement>(".xterm-screen")
|
||||
?? term.element.querySelector<HTMLElement>(".xterm-screen")
|
||||
?? container;
|
||||
const screenRect = screen.getBoundingClientRect();
|
||||
const upwardRow = Math.min(commandStartRow, cursorRow);
|
||||
const downwardRow = Math.max(commandStartRow, cursorRow);
|
||||
const spaceBelow = Math.max(0, (rows - downwardRow - 1) * dims.height);
|
||||
const spaceAbove = Math.max(0, upwardRow * dims.height);
|
||||
const expandUpward = shouldExpandAutocompleteUpward(
|
||||
downwardRow,
|
||||
spaceBelow,
|
||||
spaceAbove,
|
||||
estimatedPopupHeight,
|
||||
);
|
||||
const anchorRow = expandUpward ? upwardRow : downwardRow;
|
||||
const anchorLeft = screenRect.left + cursorColumn * dims.width;
|
||||
const anchorTop = screenRect.top + anchorRow * dims.height;
|
||||
const anchorBottom = screenRect.top + (anchorRow + 1) * dims.height;
|
||||
|
||||
return {
|
||||
anchorLeft,
|
||||
anchorTop,
|
||||
anchorBottom,
|
||||
expandUpward,
|
||||
};
|
||||
}
|
||||
|
||||
/** Popup viewport anchor using the live wrapped command-start row (#3061). */
|
||||
export function resolveAutocompletePopupAnchorInViewport(
|
||||
term: XTerm,
|
||||
container: HTMLElement | null,
|
||||
itemCount: number,
|
||||
cursorColumn: number,
|
||||
cursorRow: number,
|
||||
): AutocompleteViewportAnchor {
|
||||
return resolveAutocompleteAnchorInViewport(
|
||||
term,
|
||||
container,
|
||||
itemCount,
|
||||
cursorColumn,
|
||||
cursorRow,
|
||||
resolveAutocompleteCommandStartRow(term),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Next stored popup viewport when the command-start / cursor anchor moves.
|
||||
* Returns `prev` when nothing changed so callers can skip a React update.
|
||||
*/
|
||||
export function nextAutocompletePopupAnchorViewport(
|
||||
prev: { left: number; top: number; bottom: number },
|
||||
expandUpward: boolean,
|
||||
anchor: AutocompleteViewportAnchor,
|
||||
): { viewport: { left: number; top: number; bottom: number }; expandUpward: boolean } | null {
|
||||
const viewport = {
|
||||
left: anchor.anchorLeft,
|
||||
top: anchor.anchorTop,
|
||||
bottom: anchor.anchorBottom,
|
||||
};
|
||||
if (
|
||||
prev.left === viewport.left
|
||||
&& prev.top === viewport.top
|
||||
&& prev.bottom === viewport.bottom
|
||||
&& expandUpward === anchor.expandUpward
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { viewport, expandUpward: anchor.expandUpward };
|
||||
}
|
||||
211
components/terminal/autocomplete/terminalAutocompletePrompt.ts
Normal file
211
components/terminal/autocomplete/terminalAutocompletePrompt.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { isSensitiveTerminalChallenge } from "../../../domain/terminalPromptSecurity";
|
||||
import {
|
||||
isNonPromptLine,
|
||||
reconcilePromptWithExternalCommand,
|
||||
type PromptDetectionResult,
|
||||
} from "./promptDetector";
|
||||
import { computeLivePreviewWrite } from "./livePreviewSequence";
|
||||
|
||||
const THEMED_PROMPT_MARKERS = /[❯❮→➜➤⟩»›]/;
|
||||
|
||||
function hasStandardShellPromptTerminator(promptText: string): boolean {
|
||||
return /[$#%>]$/.test(promptText.trimEnd());
|
||||
}
|
||||
|
||||
function isSingleThemedPromptTerminator(promptText: string): boolean {
|
||||
const trimmed = promptText.trim();
|
||||
if (trimmed.length !== 1) return false;
|
||||
const code = trimmed.charCodeAt(0);
|
||||
return THEMED_PROMPT_MARKERS.test(trimmed) || (code >= 0xE000 && code <= 0xF8FF);
|
||||
}
|
||||
|
||||
function isThemedPromptPathToken(token: string): boolean {
|
||||
return (
|
||||
token === "~" ||
|
||||
token.startsWith("~/") ||
|
||||
token.startsWith("/") ||
|
||||
/^[A-Za-z]:[\\/]/.test(token) ||
|
||||
token.includes("\\")
|
||||
);
|
||||
}
|
||||
|
||||
function hasThemedPromptDecorationInInput(prompt: PromptDetectionResult): boolean {
|
||||
const hasThemedPromptMarker =
|
||||
THEMED_PROMPT_MARKERS.test(prompt.promptText) ||
|
||||
Array.from(prompt.promptText).some((ch) => {
|
||||
const code = ch.charCodeAt(0);
|
||||
return code >= 0xE000 && code <= 0xF8FF;
|
||||
});
|
||||
if (hasThemedPromptMarker && hasStandardShellPromptTerminator(prompt.promptText)) {
|
||||
return false;
|
||||
}
|
||||
if (hasThemedPromptMarker && isSingleThemedPromptTerminator(prompt.promptText)) {
|
||||
const firstToken = prompt.userInput.trimStart().match(/^\S+/)?.[0] ?? "";
|
||||
return (
|
||||
(prompt.userInput.startsWith(" ") || isThemedPromptPathToken(firstToken)) &&
|
||||
/\S+\s+\S/.test(prompt.userInput)
|
||||
);
|
||||
}
|
||||
return hasThemedPromptMarker && /\S+\s+\S/.test(prompt.userInput);
|
||||
}
|
||||
|
||||
/**
|
||||
* Command-line text used for autocomplete matching (popup / ghost).
|
||||
*
|
||||
* Enter recording keeps a stricter echo-alignment policy so short lagging
|
||||
* prefixes are not committed as history. Autocomplete can safely prefer the
|
||||
* reliable keystroke buffer when it is ahead of the remote shell echo —
|
||||
* otherwise high-latency SSH drops local history/fig matches until the user
|
||||
* pauses and the echo catches up (#2830).
|
||||
*/
|
||||
export function resolveAutocompleteQueryInput(
|
||||
prompt: PromptDetectionResult,
|
||||
typedBuffer: string,
|
||||
typedBufferReliable: boolean,
|
||||
): string | null {
|
||||
if (!prompt.isAtPrompt) return null;
|
||||
|
||||
// Prefer the keystroke buffer when it is reliably aligned with the remote
|
||||
// echo as a shared prefix in either direction:
|
||||
// - buffer ahead of echo (typing faster than SSH echo)
|
||||
// - echo ahead of buffer (partial/full backspace while echo still lags)
|
||||
// Without the second case, a lagging echo of deleted characters would keep
|
||||
// driving completions/accept (e.g. typed `gi` + echo `git` → accept
|
||||
// ` status` → remote `gi status`). An unreliable empty buffer is different:
|
||||
// history recall / cursor moves clear the buffer without meaning the line
|
||||
// is empty, so fall through to prompt.userInput there.
|
||||
if (
|
||||
typedBufferReliable &&
|
||||
(typedBuffer.startsWith(prompt.userInput) ||
|
||||
prompt.userInput.startsWith(typedBuffer))
|
||||
) {
|
||||
return typedBuffer;
|
||||
}
|
||||
|
||||
return prompt.userInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an in-flight completion result still belongs to the active query.
|
||||
*
|
||||
* Live preview rewrites the typed buffer to the highlighted candidate, so a
|
||||
* naive `currentInput === queryInput` check would drop late path listings
|
||||
* while a preview row remains selected.
|
||||
*/
|
||||
export function isSameAutocompleteQuery(options: {
|
||||
queryInput: string;
|
||||
currentInput: string | null;
|
||||
previewActive: boolean;
|
||||
previewBaseline: string;
|
||||
}): boolean {
|
||||
if (options.currentInput === null) return false;
|
||||
if (options.currentInput === options.queryInput) return true;
|
||||
return options.previewActive && options.previewBaseline === options.queryInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether fetchSuggestions must refuse to query/render for an already-known
|
||||
* sensitive line (host latch or auth-challenge prompt text).
|
||||
*
|
||||
* This is *not* a substitute for the empty-echo / `allowExternalProviders:
|
||||
* false` wait in useTerminalAutocomplete: `read -s -p '$ '` still looks like
|
||||
* a normal shell PS1 until echo validates, so that path stays fail-closed
|
||||
* separately (#2814).
|
||||
*/
|
||||
export function shouldBlockAutocompleteForSensitivePrompt(options: {
|
||||
sensitiveInputActive: boolean;
|
||||
promptText: string;
|
||||
}): boolean {
|
||||
if (options.sensitiveInputActive) return true;
|
||||
return isSensitiveTerminalChallenge(options.promptText);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keystrokes that rewrite the remote command line to `candidate`.
|
||||
*
|
||||
* Must use the same echo-lag-aware baseline as suggestion matching: the remote
|
||||
* shell already has the typed buffer, even when local echo still shows a short
|
||||
* prefix. Using lagging `prompt.userInput` here would append a duplicate tail
|
||||
* (e.g. typed `systemctl` + echo `s` + accept → send `ystemctl …`).
|
||||
*/
|
||||
export function computeAutocompleteAcceptWrite(options: {
|
||||
prompt: PromptDetectionResult;
|
||||
typedBuffer: string;
|
||||
typedBufferReliable: boolean;
|
||||
candidate: string;
|
||||
os: string;
|
||||
execute?: boolean;
|
||||
allowLineReplacement?: boolean;
|
||||
}): string | null {
|
||||
const currentLine = resolveAutocompleteQueryInput(
|
||||
options.prompt,
|
||||
options.typedBuffer,
|
||||
options.typedBufferReliable,
|
||||
);
|
||||
if (currentLine === null) return null;
|
||||
|
||||
const allowLineReplacement = options.allowLineReplacement !== false;
|
||||
if (
|
||||
!options.candidate.startsWith(currentLine) &&
|
||||
!allowLineReplacement
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = computeLivePreviewWrite({
|
||||
currentLine,
|
||||
candidate: options.candidate,
|
||||
os: options.os,
|
||||
promptText: options.prompt.promptText,
|
||||
});
|
||||
if (!options.execute) return body;
|
||||
return body ? `${body}\r` : "\r";
|
||||
}
|
||||
|
||||
export function getCommandToRecordOnEnter(
|
||||
livePrompt: PromptDetectionResult,
|
||||
alignedTyped: string | null,
|
||||
typedBuffer: string,
|
||||
typedBufferReliable: boolean,
|
||||
): string | null {
|
||||
if (!livePrompt.isAtPrompt) return null;
|
||||
const alignedCommand = alignedTyped?.trim();
|
||||
if (alignedCommand) return alignedCommand;
|
||||
|
||||
const reliableTypedCommand = typedBufferReliable ? typedBuffer.trim() : "";
|
||||
if (reliableTypedCommand) {
|
||||
const reconciledPrompt = reconcilePromptWithExternalCommand(
|
||||
livePrompt,
|
||||
reliableTypedCommand,
|
||||
);
|
||||
if (reconciledPrompt) return reliableTypedCommand;
|
||||
}
|
||||
|
||||
const liveCommand = livePrompt.userInput.trim();
|
||||
if (!liveCommand && reliableTypedCommand) {
|
||||
return isNonPromptLine(`${livePrompt.promptText}${reliableTypedCommand}`)
|
||||
? null
|
||||
: reliableTypedCommand;
|
||||
}
|
||||
if (!liveCommand) return null;
|
||||
if (!typedBufferReliable && hasThemedPromptDecorationInInput(livePrompt)) return null;
|
||||
|
||||
const liveInputMayIncludePromptDecoration =
|
||||
typedBufferReliable &&
|
||||
typedBuffer.trim().length > 0 &&
|
||||
liveCommand !== typedBuffer.trim() &&
|
||||
liveCommand.endsWith(typedBuffer.trim());
|
||||
if (liveInputMayIncludePromptDecoration) return null;
|
||||
|
||||
const liveInputMayBeLagging =
|
||||
typedBufferReliable &&
|
||||
typedBuffer.trim().length > 0 &&
|
||||
typedBuffer.length > livePrompt.userInput.length &&
|
||||
typedBuffer.startsWith(livePrompt.userInput);
|
||||
if (liveInputMayBeLagging) return null;
|
||||
|
||||
if (typedBufferReliable && hasThemedPromptDecorationInInput(livePrompt)) return null;
|
||||
|
||||
return liveCommand;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { AutocompleteSettings } from "./useTerminalAutocomplete";
|
||||
import type { AutocompleteHistoryScope } from "../../../domain/models";
|
||||
import { shouldWriteAutocompleteLivePreview } from "./livePreviewSequence";
|
||||
|
||||
type TerminalAutocompleteSettingFields = {
|
||||
autocompleteEnabled?: boolean;
|
||||
autocompleteGhostText?: boolean;
|
||||
autocompletePopupMenu?: boolean;
|
||||
autocompleteDebounceMs?: number;
|
||||
autocompleteMinChars?: number;
|
||||
autocompleteMaxSuggestions?: number;
|
||||
autocompleteHistoryScope?: AutocompleteHistoryScope;
|
||||
shiftEnterNewlineEnabled?: boolean;
|
||||
};
|
||||
|
||||
export function resolveTerminalAutocompleteSettings(input: {
|
||||
protocol?: string;
|
||||
terminalSettings?: TerminalAutocompleteSettingFields;
|
||||
/** Vendor CLI / network-device session: skip live-preview PTY rewrites (#1193). */
|
||||
isNetworkDevice?: boolean;
|
||||
systemUnknown?: boolean;
|
||||
}): Partial<AutocompleteSettings> | undefined {
|
||||
const { protocol, terminalSettings, isNetworkDevice, systemUnknown } = input;
|
||||
|
||||
if (protocol === "serial" || systemUnknown) {
|
||||
return {
|
||||
enabled: terminalSettings?.autocompleteEnabled ?? true,
|
||||
showGhostText: terminalSettings?.autocompleteGhostText ?? true,
|
||||
showPopupMenu: terminalSettings?.autocompletePopupMenu ?? true,
|
||||
livePreview: false,
|
||||
allowLineReplacement: false,
|
||||
debounceMs: terminalSettings?.autocompleteDebounceMs ?? 100,
|
||||
minChars: terminalSettings?.autocompleteMinChars ?? 1,
|
||||
maxSuggestions: terminalSettings?.autocompleteMaxSuggestions ?? 50,
|
||||
historyScope: terminalSettings?.autocompleteHistoryScope ?? "host",
|
||||
shiftEnterNewlineEnabled: terminalSettings?.shiftEnterNewlineEnabled ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
if (!terminalSettings) {
|
||||
return isNetworkDevice ? { livePreview: false } : undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: terminalSettings.autocompleteEnabled ?? true,
|
||||
showGhostText: terminalSettings.autocompleteGhostText ?? true,
|
||||
showPopupMenu: terminalSettings.autocompletePopupMenu ?? true,
|
||||
livePreview: shouldWriteAutocompleteLivePreview(true, isNetworkDevice),
|
||||
allowLineReplacement: true,
|
||||
debounceMs: terminalSettings.autocompleteDebounceMs ?? 100,
|
||||
minChars: terminalSettings.autocompleteMinChars ?? 1,
|
||||
maxSuggestions: terminalSettings.autocompleteMaxSuggestions ?? 50,
|
||||
historyScope: terminalSettings.autocompleteHistoryScope ?? "host",
|
||||
shiftEnterNewlineEnabled: terminalSettings.shiftEnterNewlineEnabled ?? true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { PluginTerminalProviderRegistry } from '../../../application/state/pluginTerminalProviderRegistry.ts';
|
||||
import { provideTerminalCompletions } from './terminalCompletionProviders.ts';
|
||||
|
||||
test('terminal completion adapter merges validated plugin results through the host Provider path', async () => {
|
||||
const calls: unknown[] = [];
|
||||
const registry = {
|
||||
async request(request: unknown) {
|
||||
calls.push(request);
|
||||
return {
|
||||
requestId: 'request-1',
|
||||
stale: false,
|
||||
results: [{
|
||||
pluginId: 'com.example',
|
||||
pluginVersion: '1.0.0',
|
||||
providerId: 'com.example.completion',
|
||||
kind: 'terminal.completion',
|
||||
requestId: 'provider-1',
|
||||
status: 'ok',
|
||||
result: {
|
||||
items: [
|
||||
{ text: 'zzzzunlikely-command', displayText: 'Plugin command', score: 50_000 },
|
||||
{ text: '', score: 100_000 },
|
||||
],
|
||||
},
|
||||
}],
|
||||
} as const;
|
||||
},
|
||||
} as unknown as PluginTerminalProviderRegistry;
|
||||
const results = await provideTerminalCompletions(registry, {
|
||||
input: 'zzzzunlikely',
|
||||
session: { sessionId: 'session-1', protocol: 'ssh', status: 'connected' },
|
||||
hostOs: 'linux',
|
||||
maximum: 8,
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(results[0].text, 'zzzzunlikely-command');
|
||||
assert.equal(results[0].source, 'plugin');
|
||||
assert.equal(results[0].providerId, 'com.example.completion');
|
||||
assert.equal(results.some((item) => item.text === ''), false);
|
||||
});
|
||||
|
||||
test('terminal completion adapter ignores stale plugin responses', async () => {
|
||||
const registry = {
|
||||
async request() { return { requestId: 'request-1', stale: true, results: [] }; },
|
||||
} as unknown as PluginTerminalProviderRegistry;
|
||||
const results = await provideTerminalCompletions(registry, {
|
||||
input: 'zzzzunlikely',
|
||||
session: { sessionId: 'session-1', protocol: 'ssh', status: 'connected' },
|
||||
hostOs: 'linux',
|
||||
maximum: 8,
|
||||
});
|
||||
assert.equal(results.some((item) => item.source === 'plugin'), false);
|
||||
});
|
||||
|
||||
test('terminal completion adapter preserves built-in results when the plugin bridge fails', async () => {
|
||||
const registry = {
|
||||
async request() { throw new Error('bridge unavailable'); },
|
||||
} as unknown as PluginTerminalProviderRegistry;
|
||||
const results = await provideTerminalCompletions(registry, {
|
||||
input: 'zzzzunlikely',
|
||||
session: { sessionId: 'session-1', protocol: 'ssh', status: 'connected' },
|
||||
hostOs: 'linux',
|
||||
maximum: 8,
|
||||
});
|
||||
assert.ok(Array.isArray(results));
|
||||
});
|
||||
|
||||
test('terminal completion adapter bounds plugin activation and authorization before returning built-ins', async () => {
|
||||
let signal: AbortSignal | undefined;
|
||||
const registry = {
|
||||
async request(_request: unknown, options?: { signal?: AbortSignal }) {
|
||||
signal = options?.signal;
|
||||
return new Promise(() => {});
|
||||
},
|
||||
} as unknown as PluginTerminalProviderRegistry;
|
||||
const result = await Promise.race([
|
||||
provideTerminalCompletions(registry, {
|
||||
input: 'git ',
|
||||
session: { sessionId: 'session-1', protocol: 'ssh', status: 'connected' },
|
||||
hostOs: 'linux',
|
||||
maximum: 8,
|
||||
pluginResponseTimeoutMs: 10,
|
||||
}),
|
||||
new Promise<'timed-out'>((resolve) => setTimeout(() => resolve('timed-out'), 250)),
|
||||
]);
|
||||
assert.notEqual(result, 'timed-out');
|
||||
assert.ok(Array.isArray(result));
|
||||
assert.equal(signal?.aborted, true);
|
||||
});
|
||||
|
||||
test('terminal completion adapter aborts and discards plugin results when the host security gate closes', async () => {
|
||||
let providerSignal: AbortSignal | undefined;
|
||||
let resolveProvider: ((value: {
|
||||
requestId: string;
|
||||
stale: false;
|
||||
results: readonly unknown[];
|
||||
}) => void) | undefined;
|
||||
const registry = {
|
||||
request(_request: unknown, options?: { signal?: AbortSignal }) {
|
||||
providerSignal = options?.signal;
|
||||
return new Promise((resolve) => { resolveProvider = resolve as typeof resolveProvider; });
|
||||
},
|
||||
} as unknown as PluginTerminalProviderRegistry;
|
||||
const securityController = new AbortController();
|
||||
const pending = provideTerminalCompletions(registry, {
|
||||
input: 'safe-command',
|
||||
session: { sessionId: 'session-1', protocol: 'ssh', status: 'connected' },
|
||||
hostOs: 'linux',
|
||||
maximum: 8,
|
||||
signal: securityController.signal,
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
securityController.abort();
|
||||
resolveProvider?.({
|
||||
requestId: 'request-1',
|
||||
stale: false,
|
||||
results: [{
|
||||
providerId: 'com.example.completion',
|
||||
status: 'ok',
|
||||
result: { items: [{ text: 'plugin-result', score: 50_000 }] },
|
||||
}],
|
||||
});
|
||||
const results = await pending;
|
||||
assert.equal(providerSignal?.aborted, true);
|
||||
assert.equal(results.some((item) => item.source === 'plugin'), false);
|
||||
});
|
||||
|
||||
test('terminal completion adapter preserves built-in snippet metadata on duplicate plugin text', async () => {
|
||||
const registry = {
|
||||
async request() {
|
||||
return {
|
||||
requestId: 'request-1',
|
||||
stale: false,
|
||||
results: [{
|
||||
pluginId: 'com.example',
|
||||
pluginVersion: '1.0.0',
|
||||
providerId: 'com.example.completion',
|
||||
kind: 'terminal.completion',
|
||||
requestId: 'provider-1',
|
||||
status: 'ok',
|
||||
result: { items: [{ text: 'deploy', score: 50_000 }] },
|
||||
}],
|
||||
} as const;
|
||||
},
|
||||
} as unknown as PluginTerminalProviderRegistry;
|
||||
const snippet = { id: 'deploy', label: 'deploy', command: 'kubectl apply -f .' };
|
||||
const results = await provideTerminalCompletions(registry, {
|
||||
input: 'dep',
|
||||
session: { sessionId: 'session-1', protocol: 'ssh', status: 'connected' },
|
||||
hostOs: 'linux',
|
||||
snippets: [snippet],
|
||||
maximum: 8,
|
||||
});
|
||||
const duplicate = results.find((item) => item.text === 'deploy');
|
||||
assert.equal(duplicate?.source, 'snippet');
|
||||
assert.equal(duplicate?.snippet, snippet);
|
||||
assert.equal(results.filter((item) => item.text === 'deploy').length, 1);
|
||||
});
|
||||
143
components/terminal/autocomplete/terminalCompletionProviders.ts
Normal file
143
components/terminal/autocomplete/terminalCompletionProviders.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
mergePluginCompletionItems,
|
||||
normalizePluginCompletionResult,
|
||||
} from '../../../domain/pluginTerminalProviders';
|
||||
import type { PluginTerminalProviderRegistry } from '../../../application/state/pluginTerminalProviderRegistry';
|
||||
import {
|
||||
getCompletions,
|
||||
type CompletionSuggestion,
|
||||
} from './completionEngine';
|
||||
import type { AutocompleteCwdSource } from './terminalAutocompleteLayout';
|
||||
import type { AutocompleteHistoryScope, Snippet } from '../../../domain/models';
|
||||
|
||||
export interface TerminalCompletionProviderRequest {
|
||||
input: string;
|
||||
session: NetcattyTerminalSessionSnapshot;
|
||||
hostGroup?: string;
|
||||
hostOs: 'linux' | 'windows' | 'macos';
|
||||
cwdSource?: AutocompleteCwdSource;
|
||||
snippets?: Snippet[];
|
||||
maximum: number;
|
||||
/** Which history pool built-in suggestions draw from. */
|
||||
historyScope?: AutocompleteHistoryScope;
|
||||
/** Internal end-to-end wait bound; tests may lower it deterministically. */
|
||||
pluginResponseTimeoutMs?: number;
|
||||
/** Host security/session cancellation propagated to the plugin bridge. */
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* Forwarded to built-in getCompletions when a path listing finishes after the
|
||||
* soft budget (cache-bypassed relative SSH cwd).
|
||||
*/
|
||||
onLatePathSuggestions?: (suggestions: CompletionSuggestion[]) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_PLUGIN_COMPLETION_RESPONSE_TIMEOUT_MS = 800;
|
||||
|
||||
type PluginCompletionResponse = Awaited<ReturnType<PluginTerminalProviderRegistry['request']>>;
|
||||
|
||||
function emptyPluginCompletionResponse(): PluginCompletionResponse {
|
||||
return { requestId: '', stale: false, results: Object.freeze([]) };
|
||||
}
|
||||
|
||||
async function waitForPluginCompletionResponse(
|
||||
response: Promise<PluginCompletionResponse>,
|
||||
timeoutMs: number,
|
||||
onTimeout?: () => void,
|
||||
): Promise<PluginCompletionResponse> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeout = new Promise<PluginCompletionResponse>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
onTimeout?.();
|
||||
resolve(emptyPluginCompletionResponse());
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([response, timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export async function provideTerminalCompletions(
|
||||
registry: PluginTerminalProviderRegistry | null,
|
||||
request: TerminalCompletionProviderRequest,
|
||||
): Promise<CompletionSuggestion[]> {
|
||||
const builtInPromise = getCompletions(request.input, {
|
||||
hostId: request.session.hostId,
|
||||
hostGroup: request.hostGroup,
|
||||
os: request.hostOs,
|
||||
maxResults: request.maximum,
|
||||
sessionId: request.session.sessionId,
|
||||
protocol: request.session.protocol,
|
||||
cwd: request.session.cwd,
|
||||
cwdSource: request.cwdSource,
|
||||
snippets: request.snippets,
|
||||
historyScope: request.historyScope,
|
||||
onLatePathSuggestions: request.onLatePathSuggestions,
|
||||
});
|
||||
const pluginRequestController = new AbortController();
|
||||
const abortPluginRequest = () => pluginRequestController.abort();
|
||||
request.signal?.addEventListener('abort', abortPluginRequest, { once: true });
|
||||
if (request.signal?.aborted) pluginRequestController.abort();
|
||||
const pluginPromise = registry?.request({
|
||||
kind: 'terminal.completion',
|
||||
operation: 'provideCompletions',
|
||||
session: request.session,
|
||||
payload: {
|
||||
input: request.input,
|
||||
cursor: request.input.length,
|
||||
hostOs: request.hostOs,
|
||||
cwdSource: request.cwdSource ?? null,
|
||||
maximum: request.maximum,
|
||||
},
|
||||
deadlineMs: 750,
|
||||
}, { signal: pluginRequestController.signal }).catch(() => emptyPluginCompletionResponse())
|
||||
?? Promise.resolve(emptyPluginCompletionResponse());
|
||||
const pluginResponseTimeoutMs = Number.isFinite(request.pluginResponseTimeoutMs)
|
||||
? Math.max(1, Math.min(5_000, Math.trunc(request.pluginResponseTimeoutMs ?? 0)))
|
||||
: DEFAULT_PLUGIN_COMPLETION_RESPONSE_TIMEOUT_MS;
|
||||
let builtIn: CompletionSuggestion[];
|
||||
let pluginResponse: PluginCompletionResponse;
|
||||
try {
|
||||
[builtIn, pluginResponse] = await Promise.all([
|
||||
builtInPromise,
|
||||
waitForPluginCompletionResponse(
|
||||
pluginPromise,
|
||||
pluginResponseTimeoutMs,
|
||||
() => pluginRequestController.abort(),
|
||||
),
|
||||
]);
|
||||
} finally {
|
||||
request.signal?.removeEventListener('abort', abortPluginRequest);
|
||||
}
|
||||
if (request.signal?.aborted || pluginRequestController.signal.aborted || pluginResponse.stale) {
|
||||
return builtIn;
|
||||
}
|
||||
const pluginGroups = pluginResponse.results.map((result) => result.status === 'ok'
|
||||
? normalizePluginCompletionResult(result.providerId, result.result)
|
||||
: Object.freeze([]));
|
||||
const pluginItems = mergePluginCompletionItems(pluginGroups, request.maximum);
|
||||
const combined: CompletionSuggestion[] = [
|
||||
...builtIn,
|
||||
...pluginItems.map((item) => ({
|
||||
text: item.text,
|
||||
displayText: item.displayText,
|
||||
...(item.description === undefined ? {} : { description: item.description }),
|
||||
source: 'plugin' as const,
|
||||
score: item.score,
|
||||
providerId: item.providerId,
|
||||
})),
|
||||
];
|
||||
const deduplicated = new Map<string, CompletionSuggestion>();
|
||||
for (const item of combined) {
|
||||
const existing = deduplicated.get(item.text);
|
||||
if (!existing
|
||||
|| (existing.source === 'plugin' && item.source !== 'plugin')
|
||||
|| (existing.source === item.source && item.score > existing.score)) {
|
||||
deduplicated.set(item.text, item);
|
||||
}
|
||||
}
|
||||
return [...deduplicated.values()]
|
||||
.sort((left, right) => right.score - left.score || left.text.localeCompare(right.text))
|
||||
.slice(0, request.maximum);
|
||||
}
|
||||
163
components/terminal/autocomplete/terminalStringCellWidth.ts
Normal file
163
components/terminal/autocomplete/terminalStringCellWidth.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Terminal cell-column width for autocomplete / ghost positioning.
|
||||
*
|
||||
* When an xterm instance is available, prefer its active Unicode provider
|
||||
* (`15-graphemes` via UnicodeGraphemesAddon) so emoji / VS-16 clusters match
|
||||
* the cursor advance. Fall back to a small East-Asian-Width-style classifier
|
||||
* for unit fakes that lack `_core.unicodeService`.
|
||||
*/
|
||||
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
type UnicodeServiceLike = {
|
||||
getStringCellWidth?: (s: string) => number;
|
||||
};
|
||||
|
||||
type TermWithUnicodeService = {
|
||||
_core?: {
|
||||
unicodeService?: UnicodeServiceLike;
|
||||
};
|
||||
};
|
||||
|
||||
const unicodeMarkPattern = /\p{Mark}/u;
|
||||
|
||||
function codePointCellWidth(cp: number): number {
|
||||
// Zero-width joiners / format / variation selectors / marks — xterm
|
||||
// folds these into the surrounding grapheme (wcwidth 0 or shouldJoin).
|
||||
if (
|
||||
cp === 0x00ad ||
|
||||
cp === 0x200d || // ZWJ
|
||||
(cp >= 0x200b && cp <= 0x200f) || // ZWSP..RLM
|
||||
(cp >= 0x202a && cp <= 0x202e) || // bidi overrides
|
||||
(cp >= 0x2060 && cp <= 0x206f) || // word joiner, invisible ops
|
||||
(cp >= 0xfe00 && cp <= 0xfe0f) || // Variation Selectors
|
||||
cp === 0xfeff ||
|
||||
(cp >= 0x1f3fb && cp <= 0x1f3ff) || // Emoji skin-tone modifiers
|
||||
(cp >= 0xe0100 && cp <= 0xe01ef) || // Variation Selectors Supplement
|
||||
unicodeMarkPattern.test(String.fromCodePoint(cp))
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
if (
|
||||
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
||||
(cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals, Kangxi
|
||||
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK Compat
|
||||
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Extension A
|
||||
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
|
||||
(cp >= 0xa000 && cp <= 0xa4cf) || // Yi
|
||||
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
|
||||
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compat Ideographs
|
||||
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compat Forms
|
||||
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth forms
|
||||
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
|
||||
(cp >= 0x1f300 && cp <= 0x1faff) || // Emoji blocks
|
||||
(cp >= 0x20000 && cp <= 0x3fffd) // CJK Extension B-F, G
|
||||
) {
|
||||
return 2;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function graphemeCellWidth(grapheme: string): number {
|
||||
let max = 0;
|
||||
for (const ch of grapheme) {
|
||||
const w = codePointCellWidth(ch.codePointAt(0) ?? 0);
|
||||
if (w > max) max = w;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
const graphemeSegmenter =
|
||||
typeof Intl !== "undefined" && "Segmenter" in Intl
|
||||
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
|
||||
: null;
|
||||
|
||||
function fallbackStringCellWidth(s: string): number {
|
||||
if (graphemeSegmenter) {
|
||||
let w = 0;
|
||||
for (const { segment } of graphemeSegmenter.segment(s)) {
|
||||
w += graphemeCellWidth(segment);
|
||||
}
|
||||
return w;
|
||||
}
|
||||
// Fallback without Segmenter: sum code-point widths (ZWJ/marks already 0).
|
||||
let w = 0;
|
||||
for (const ch of s) {
|
||||
w += codePointCellWidth(ch.codePointAt(0) ?? 0);
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
/** Terminal cell columns occupied by `s` (wide glyphs / grapheme clusters). */
|
||||
export function stringCellWidth(
|
||||
s: string,
|
||||
term?: XTerm | TermWithUnicodeService | null,
|
||||
): number {
|
||||
if (!s) return 0;
|
||||
const unicodeService = (term as TermWithUnicodeService | null | undefined)
|
||||
?._core?.unicodeService;
|
||||
const getWidth = unicodeService?.getStringCellWidth;
|
||||
if (typeof getWidth === "function") {
|
||||
return getWidth.call(unicodeService, s);
|
||||
}
|
||||
return fallbackStringCellWidth(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice a terminal line string by cell columns (xterm `cursorX` units).
|
||||
*
|
||||
* `translateToString()` returns characters, but `buffer.cursorX` is a cell
|
||||
* column. Mixing them with `String#substring(cursorX)` pulls padding spaces
|
||||
* into user input whenever the prompt contains wide glyphs (CJK paths in
|
||||
* Windows CMD / PowerShell), which breaks autocomplete matching (#2813).
|
||||
*/
|
||||
export function sliceStringByCellColumns(
|
||||
text: string,
|
||||
startCell: number,
|
||||
endCell?: number,
|
||||
term?: XTerm | TermWithUnicodeService | null,
|
||||
): string {
|
||||
if (!text) return "";
|
||||
const start = Math.max(0, startCell);
|
||||
const end = endCell === undefined ? Number.POSITIVE_INFINITY : Math.max(start, endCell);
|
||||
if (end === 0) return "";
|
||||
|
||||
let cell = 0;
|
||||
let startIndex = 0;
|
||||
let endIndex = text.length;
|
||||
let sawStart = false;
|
||||
|
||||
const advance = (segment: string, index: number, segmentLength: number): boolean => {
|
||||
const width = stringCellWidth(segment, term);
|
||||
const nextCell = cell + width;
|
||||
if (!sawStart && nextCell > start) {
|
||||
startIndex = index;
|
||||
sawStart = true;
|
||||
}
|
||||
if (nextCell >= end) {
|
||||
endIndex = nextCell === end ? index + segmentLength : index;
|
||||
if (!sawStart) {
|
||||
startIndex = index;
|
||||
sawStart = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
cell = nextCell;
|
||||
return false;
|
||||
};
|
||||
|
||||
if (graphemeSegmenter) {
|
||||
for (const { segment, index } of graphemeSegmenter.segment(text)) {
|
||||
if (advance(segment, index, segment.length)) break;
|
||||
}
|
||||
} else {
|
||||
let index = 0;
|
||||
for (const ch of text) {
|
||||
if (advance(ch, index, ch.length)) break;
|
||||
index += ch.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sawStart) return "";
|
||||
return text.slice(startIndex, endIndex);
|
||||
}
|
||||
1470
components/terminal/autocomplete/useTerminalAutocomplete.ts
Normal file
1470
components/terminal/autocomplete/useTerminalAutocomplete.ts
Normal file
File diff suppressed because it is too large
Load Diff
89
components/terminal/autocomplete/xtermUtils.ts
Normal file
89
components/terminal/autocomplete/xtermUtils.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Utility functions for xterm.js cell dimension access.
|
||||
* Centralizes access to xterm's internal renderer API to reduce upgrade risk.
|
||||
* Falls back to DOM measurement if the internal API is unavailable.
|
||||
*/
|
||||
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
export interface CellDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// Cache to avoid repeated DOM measurements (invalidated on resize)
|
||||
let cachedDims: CellDimensions | null = null;
|
||||
let cachedTermId: number = 0;
|
||||
let termIdCounter = 0;
|
||||
const termIdMap = new WeakMap<XTerm, number>();
|
||||
|
||||
function getTermId(term: XTerm): number {
|
||||
let id = termIdMap.get(term);
|
||||
if (id === undefined) {
|
||||
id = ++termIdCounter;
|
||||
termIdMap.set(term, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cell dimensions (width/height in CSS pixels) from an xterm instance.
|
||||
* Tries the internal renderer API first (fast path), falls back to DOM measurement.
|
||||
*/
|
||||
export function getXTermCellDimensions(term: XTerm): CellDimensions {
|
||||
// Try xterm core renderer API (fast path)
|
||||
const coreAccess = term as XTerm & {
|
||||
_core?: { _renderService?: { dimensions?: { css?: { cell?: CellDimensions } } } };
|
||||
};
|
||||
const coreDims = coreAccess._core?._renderService?.dimensions?.css?.cell;
|
||||
if (coreDims && coreDims.width > 0 && coreDims.height > 0) {
|
||||
// Update cache while we have a good value
|
||||
const id = getTermId(term);
|
||||
cachedDims = { width: coreDims.width, height: coreDims.height };
|
||||
cachedTermId = id;
|
||||
return cachedDims;
|
||||
}
|
||||
|
||||
// Check cache (same terminal instance)
|
||||
const id = getTermId(term);
|
||||
if (cachedDims && cachedTermId === id) {
|
||||
return cachedDims;
|
||||
}
|
||||
|
||||
// Fallback: measure from DOM (triggers single reflow)
|
||||
const dims = measureCellFromDOM(term);
|
||||
cachedDims = dims;
|
||||
cachedTermId = id;
|
||||
return dims;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure cell dimensions by inserting a temporary span into the terminal element.
|
||||
* Triggers a single reflow (reading offsetWidth + offsetHeight).
|
||||
*/
|
||||
function measureCellFromDOM(term: XTerm): CellDimensions {
|
||||
const element = term.element;
|
||||
if (!element) return { width: 8, height: 16 };
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.textContent = "W";
|
||||
Object.assign(span.style, {
|
||||
position: "absolute",
|
||||
visibility: "hidden",
|
||||
fontFamily: term.options.fontFamily || "monospace",
|
||||
fontSize: `${term.options.fontSize}px`,
|
||||
lineHeight: "normal",
|
||||
});
|
||||
element.appendChild(span);
|
||||
const width = span.offsetWidth || 8;
|
||||
const height = span.offsetHeight || 16;
|
||||
span.remove();
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cached cell dimensions (call on terminal resize).
|
||||
*/
|
||||
export function invalidateCellDimensionCache(): void {
|
||||
cachedDims = null;
|
||||
}
|
||||
526
components/terminal/clearTerminalViewport.test.ts
Normal file
526
components/terminal/clearTerminalViewport.test.ts
Normal file
@@ -0,0 +1,526 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import xterm from "@xterm/xterm";
|
||||
|
||||
import {
|
||||
appendEraseScrollbackAfterFullErases,
|
||||
clearTerminalViewport,
|
||||
clearTerminalViewportAndSyncPty,
|
||||
installEraseInDisplayHandlers,
|
||||
isEraseBelowSequence,
|
||||
preserveTerminalViewportInScrollback,
|
||||
shouldPreserveViewportBeforeEraseBelow,
|
||||
shouldPreserveViewportBeforeFullErase,
|
||||
shouldScrollOnEraseInDisplay,
|
||||
shouldWipeScrollbackAfterFullErase,
|
||||
} from "./clearTerminalViewport.ts";
|
||||
|
||||
const { Terminal } = xterm;
|
||||
|
||||
const createMockTerm = (
|
||||
bufferType: "normal" | "alternate",
|
||||
cursor: { cursorX?: number; cursorY?: number } = {},
|
||||
): { buffer: { active: { type: "normal" | "alternate"; cursorX: number; cursorY: number } } } => ({
|
||||
buffer: {
|
||||
active: {
|
||||
type: bufferType,
|
||||
cursorX: cursor.cursorX ?? 0,
|
||||
cursorY: cursor.cursorY ?? 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type RegisteredCsiHandler = {
|
||||
id: { prefix?: string; final: string };
|
||||
callback: (params: Array<number | number[]>) => boolean | Promise<boolean>;
|
||||
disposed: boolean;
|
||||
};
|
||||
|
||||
const createEraseHandlerHarness = (
|
||||
options: {
|
||||
bufferType?: "normal" | "alternate";
|
||||
clearWipesScrollback?: boolean;
|
||||
cursorX?: number;
|
||||
cursorY?: number;
|
||||
inDec2026SyncBlock?: boolean;
|
||||
scrollTop?: number;
|
||||
scrollBottom?: number;
|
||||
} = {},
|
||||
) => {
|
||||
const handlers: RegisteredCsiHandler[] = [];
|
||||
const microtasks: Array<() => void> = [];
|
||||
const trimStartCalls: number[] = [];
|
||||
const onScrollPositions: number[] = [];
|
||||
const scrollRegion = {
|
||||
lines: {
|
||||
length: options.clearWipesScrollback ? 9 : 5,
|
||||
trimStart: (count: number) => {
|
||||
trimStartCalls.push(count);
|
||||
scrollRegion.lines.length -= count;
|
||||
},
|
||||
},
|
||||
scrollTop: options.scrollTop ?? 0,
|
||||
scrollBottom: options.scrollBottom ?? 4,
|
||||
ybase: options.clearWipesScrollback ? 4 : 0,
|
||||
ydisp: options.clearWipesScrollback ? 4 : 0,
|
||||
};
|
||||
const observedScrollRegions: Array<[number, number]> = [];
|
||||
const term = {
|
||||
rows: 5,
|
||||
options: {
|
||||
scrollOnEraseInDisplay: false,
|
||||
},
|
||||
parser: {
|
||||
registerCsiHandler: (id: { prefix?: string; final: string }, callback: RegisteredCsiHandler["callback"]) => {
|
||||
const handler = {
|
||||
id,
|
||||
callback,
|
||||
disposed: false,
|
||||
};
|
||||
handlers.push(handler);
|
||||
return {
|
||||
dispose: () => {
|
||||
handler.disposed = true;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
buffer: {
|
||||
active: {
|
||||
type: options.bufferType ?? "normal",
|
||||
baseY: 0,
|
||||
cursorX: options.cursorX ?? 0,
|
||||
cursorY: options.cursorY ?? 0,
|
||||
getLine: (line: number) => {
|
||||
if (line < 0 || line >= 5) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
translateToString: () => `row-${line}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
_core: {
|
||||
buffer: scrollRegion,
|
||||
scroll: () => {
|
||||
observedScrollRegions.push([scrollRegion.scrollTop, scrollRegion.scrollBottom]);
|
||||
},
|
||||
_inputHandler: {
|
||||
_onScroll: {
|
||||
fire: (position: number) => {
|
||||
onScrollPositions.push(position);
|
||||
},
|
||||
},
|
||||
_eraseAttrData: () => ({}),
|
||||
},
|
||||
},
|
||||
};
|
||||
const disposable = installEraseInDisplayHandlers(term as never, {
|
||||
getClearWipesScrollback: () => options.clearWipesScrollback ?? false,
|
||||
isInDec2026SyncBlock: () => options.inDec2026SyncBlock ?? false,
|
||||
scheduleMicrotask: (callback) => {
|
||||
microtasks.push(callback);
|
||||
},
|
||||
});
|
||||
const erase = handlers.find((handler) => handler.id.final === "J" && handler.id.prefix === undefined);
|
||||
const selectiveErase = handlers.find((handler) => handler.id.final === "J" && handler.id.prefix === "?");
|
||||
if (!erase || !selectiveErase) {
|
||||
throw new Error("erase handlers were not registered");
|
||||
}
|
||||
|
||||
return {
|
||||
disposable,
|
||||
erase: erase.callback,
|
||||
flushMicrotasks: () => {
|
||||
while (microtasks.length > 0) {
|
||||
microtasks.shift()?.();
|
||||
}
|
||||
},
|
||||
handlers,
|
||||
observedScrollRegions,
|
||||
onScrollPositions,
|
||||
scrollRegion,
|
||||
selectiveErase: selectiveErase.callback,
|
||||
term,
|
||||
trimStartCalls,
|
||||
};
|
||||
};
|
||||
|
||||
const writeTerminal = (term: InstanceType<typeof Terminal>, data: string): Promise<void> =>
|
||||
new Promise((resolve) => term.write(data, resolve));
|
||||
|
||||
test("preserves viewport before full erase on the normal screen outside sync blocks", () => {
|
||||
const term = createMockTerm("normal");
|
||||
assert.equal(shouldPreserveViewportBeforeFullErase(term as never, false), true);
|
||||
});
|
||||
|
||||
test("skips viewport preservation inside DEC 2026 sync blocks", () => {
|
||||
const term = createMockTerm("normal");
|
||||
assert.equal(shouldPreserveViewportBeforeFullErase(term as never, true), false);
|
||||
});
|
||||
|
||||
test("skips viewport preservation on the alternate screen", () => {
|
||||
const term = createMockTerm("alternate");
|
||||
assert.equal(shouldPreserveViewportBeforeFullErase(term as never, false), false);
|
||||
});
|
||||
|
||||
test("skips viewport preservation when full erase should wipe scrollback", () => {
|
||||
const term = createMockTerm("normal");
|
||||
assert.equal(shouldPreserveViewportBeforeFullErase(term as never, false, true), false);
|
||||
});
|
||||
|
||||
test("wipes scrollback after full erase only on the normal screen outside sync blocks", () => {
|
||||
assert.equal(shouldWipeScrollbackAfterFullErase(createMockTerm("normal") as never, false, true), true);
|
||||
assert.equal(shouldWipeScrollbackAfterFullErase(createMockTerm("normal") as never, true, true), false);
|
||||
assert.equal(shouldWipeScrollbackAfterFullErase(createMockTerm("alternate") as never, false, true), false);
|
||||
assert.equal(shouldWipeScrollbackAfterFullErase(createMockTerm("normal") as never, false, false), false);
|
||||
});
|
||||
|
||||
test("native erase-in-display scrollback preservation follows the clear history setting", () => {
|
||||
assert.equal(shouldScrollOnEraseInDisplay(createMockTerm("normal") as never, false, false), true);
|
||||
assert.equal(shouldScrollOnEraseInDisplay(createMockTerm("normal") as never, false, true), false);
|
||||
assert.equal(shouldScrollOnEraseInDisplay(createMockTerm("normal") as never, true, false), false);
|
||||
assert.equal(shouldScrollOnEraseInDisplay(createMockTerm("alternate") as never, false, false), false);
|
||||
});
|
||||
|
||||
test("native erase-in-display scrollback preservation is skipped with active scroll margins", () => {
|
||||
const term = {
|
||||
rows: 5,
|
||||
buffer: {
|
||||
active: {
|
||||
type: "normal",
|
||||
cursorX: 0,
|
||||
cursorY: 0,
|
||||
},
|
||||
},
|
||||
_core: {
|
||||
buffer: {
|
||||
scrollTop: 1,
|
||||
scrollBottom: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(shouldScrollOnEraseInDisplay(term as never, false, false), false);
|
||||
assert.equal(shouldPreserveViewportBeforeFullErase(term as never, false, false), true);
|
||||
assert.equal(shouldPreserveViewportBeforeEraseBelow(term as never, false, false), true);
|
||||
});
|
||||
|
||||
test("erase-below is treated as viewport clear only from the home position", () => {
|
||||
assert.equal(isEraseBelowSequence([]), true);
|
||||
assert.equal(isEraseBelowSequence([0]), true);
|
||||
assert.equal(isEraseBelowSequence([2]), false);
|
||||
assert.equal(shouldPreserveViewportBeforeEraseBelow(createMockTerm("normal") as never, false, false), true);
|
||||
assert.equal(
|
||||
shouldPreserveViewportBeforeEraseBelow(createMockTerm("normal", { cursorX: 1 }) as never, false, false),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldPreserveViewportBeforeEraseBelow(createMockTerm("normal", { cursorY: 1 }) as never, false, false),
|
||||
false,
|
||||
);
|
||||
assert.equal(shouldPreserveViewportBeforeEraseBelow(createMockTerm("alternate") as never, false, false), false);
|
||||
});
|
||||
|
||||
test("viewport preservation temporarily uses the full scroll region", () => {
|
||||
const scrollRegion = {
|
||||
scrollTop: 1,
|
||||
scrollBottom: 3,
|
||||
};
|
||||
const observedScrollRegions: Array<[number, number]> = [];
|
||||
const term = {
|
||||
rows: 5,
|
||||
buffer: {
|
||||
active: {
|
||||
type: "normal",
|
||||
baseY: 0,
|
||||
getLine: (line: number) => {
|
||||
if (line < 0 || line >= 5) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
translateToString: () => `row-${line}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
_core: {
|
||||
buffer: scrollRegion,
|
||||
scroll: () => {
|
||||
observedScrollRegions.push([scrollRegion.scrollTop, scrollRegion.scrollBottom]);
|
||||
},
|
||||
_inputHandler: {
|
||||
_eraseAttrData: () => ({}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
preserveTerminalViewportInScrollback(term as never);
|
||||
|
||||
assert.equal(observedScrollRegions.length, 5);
|
||||
assert.deepEqual(observedScrollRegions, [
|
||||
[0, 4],
|
||||
[0, 4],
|
||||
[0, 4],
|
||||
[0, 4],
|
||||
[0, 4],
|
||||
]);
|
||||
assert.deepEqual(scrollRegion, {
|
||||
scrollTop: 1,
|
||||
scrollBottom: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("installed erase handlers preserve scrollback by behavior", () => {
|
||||
const fullClear = createEraseHandlerHarness();
|
||||
|
||||
assert.equal(fullClear.erase([2]), false);
|
||||
assert.equal(fullClear.term.options.scrollOnEraseInDisplay, true);
|
||||
assert.deepEqual(fullClear.observedScrollRegions, []);
|
||||
fullClear.flushMicrotasks();
|
||||
assert.equal(fullClear.term.options.scrollOnEraseInDisplay, false);
|
||||
|
||||
const marginFullClear = createEraseHandlerHarness({ scrollTop: 1, scrollBottom: 3 });
|
||||
assert.equal(marginFullClear.erase([2]), false);
|
||||
assert.equal(marginFullClear.term.options.scrollOnEraseInDisplay, false);
|
||||
assert.deepEqual(marginFullClear.observedScrollRegions, [
|
||||
[0, 4],
|
||||
[0, 4],
|
||||
[0, 4],
|
||||
[0, 4],
|
||||
[0, 4],
|
||||
]);
|
||||
assert.equal(marginFullClear.scrollRegion.scrollTop, 1);
|
||||
assert.equal(marginFullClear.scrollRegion.scrollBottom, 3);
|
||||
|
||||
const eraseBelow = createEraseHandlerHarness();
|
||||
assert.equal(eraseBelow.erase([]), false);
|
||||
assert.equal(eraseBelow.observedScrollRegions.length, 5);
|
||||
|
||||
const eraseBelowAwayFromHome = createEraseHandlerHarness({ cursorY: 1 });
|
||||
assert.equal(eraseBelowAwayFromHome.erase([]), false);
|
||||
assert.equal(eraseBelowAwayFromHome.observedScrollRegions.length, 0);
|
||||
|
||||
const wipeEraseBelow = createEraseHandlerHarness({ clearWipesScrollback: true });
|
||||
assert.equal(wipeEraseBelow.erase([]), false);
|
||||
assert.deepEqual(wipeEraseBelow.trimStartCalls, [4]);
|
||||
assert.equal(wipeEraseBelow.scrollRegion.ybase, 0);
|
||||
assert.equal(wipeEraseBelow.scrollRegion.ydisp, 0);
|
||||
assert.deepEqual(wipeEraseBelow.onScrollPositions, [0]);
|
||||
|
||||
const wipeEraseBelowAwayFromHome = createEraseHandlerHarness({
|
||||
clearWipesScrollback: true,
|
||||
cursorY: 1,
|
||||
});
|
||||
assert.equal(wipeEraseBelowAwayFromHome.erase([]), false);
|
||||
assert.deepEqual(wipeEraseBelowAwayFromHome.trimStartCalls, []);
|
||||
});
|
||||
|
||||
test("erase-below wipe preserves later output from the same write batch", async () => {
|
||||
const term = new Terminal({ cols: 20, rows: 5, scrollback: 100 });
|
||||
const disposable = installEraseInDisplayHandlers(term as never, {
|
||||
getClearWipesScrollback: () => true,
|
||||
isInDec2026SyncBlock: () => false,
|
||||
});
|
||||
|
||||
await writeTerminal(term, "old1\r\nold2\r\nold3\r\nold4\r\nold5\r\nold6\r\nold7\r\nold8");
|
||||
await writeTerminal(term, "\x1b[H\x1b[Jnew1\r\nnew2\r\nnew3\r\nnew4\r\nnew5\r\nnew6\r\nnew7\r\nnew8");
|
||||
|
||||
const scrollback = Array.from({ length: term.buffer.active.baseY }, (_, row) =>
|
||||
term.buffer.active.getLine(row)?.translateToString(true) ?? ""
|
||||
);
|
||||
|
||||
assert.equal(scrollback.some((line) => line.startsWith("old")), false);
|
||||
assert.equal(scrollback.some((line) => line.startsWith("new")), true);
|
||||
|
||||
disposable.dispose();
|
||||
term.dispose();
|
||||
});
|
||||
|
||||
test("installed erase handlers honor wipe, sync, alternate, and selective clears", () => {
|
||||
const preserveHistory = createEraseHandlerHarness({ clearWipesScrollback: false });
|
||||
assert.equal(preserveHistory.erase([3]), true);
|
||||
|
||||
const wipeHistory = createEraseHandlerHarness({ clearWipesScrollback: true });
|
||||
assert.equal(wipeHistory.erase([3]), false);
|
||||
|
||||
const syncBlock = createEraseHandlerHarness({ inDec2026SyncBlock: true });
|
||||
assert.equal(syncBlock.erase([2]), false);
|
||||
assert.equal(syncBlock.term.options.scrollOnEraseInDisplay, false);
|
||||
assert.deepEqual(syncBlock.observedScrollRegions, []);
|
||||
|
||||
const alternateScreen = createEraseHandlerHarness({ bufferType: "alternate" });
|
||||
assert.equal(alternateScreen.erase([2]), false);
|
||||
assert.equal(alternateScreen.term.options.scrollOnEraseInDisplay, false);
|
||||
assert.deepEqual(alternateScreen.observedScrollRegions, []);
|
||||
|
||||
const selectiveClear = createEraseHandlerHarness();
|
||||
selectiveClear.term.options.scrollOnEraseInDisplay = true;
|
||||
assert.equal(selectiveClear.selectiveErase([2]), false);
|
||||
assert.equal(selectiveClear.term.options.scrollOnEraseInDisplay, false);
|
||||
selectiveClear.disposable.dispose();
|
||||
assert.equal(selectiveClear.handlers.every((handler) => handler.disposed), true);
|
||||
});
|
||||
|
||||
test("local clear writes erase-scrollback when requested", () => {
|
||||
const writes: string[] = [];
|
||||
const term = {
|
||||
rows: 5,
|
||||
buffer: {
|
||||
active: {
|
||||
type: "normal",
|
||||
baseY: 0,
|
||||
cursorY: 2,
|
||||
cursorX: 4,
|
||||
},
|
||||
},
|
||||
_core: {
|
||||
scroll: () => {},
|
||||
_inputHandler: {
|
||||
_eraseAttrData: () => ({}),
|
||||
},
|
||||
},
|
||||
write: (payload: string, callback?: () => void) => {
|
||||
writes.push(payload);
|
||||
callback?.();
|
||||
},
|
||||
scrollToBottom: () => {},
|
||||
};
|
||||
|
||||
const didClear = clearTerminalViewport(term as never, { wipeScrollback: true });
|
||||
|
||||
assert.equal(didClear, true);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes[0].includes("\x1b[3J"), true);
|
||||
});
|
||||
|
||||
test("local clear preserves scrollback when erase-scrollback is not requested", () => {
|
||||
const writes: string[] = [];
|
||||
const term = {
|
||||
rows: 5,
|
||||
buffer: {
|
||||
active: {
|
||||
type: "normal",
|
||||
baseY: 0,
|
||||
cursorY: 2,
|
||||
cursorX: 4,
|
||||
},
|
||||
},
|
||||
_core: {
|
||||
scroll: () => {},
|
||||
_inputHandler: {
|
||||
_eraseAttrData: () => ({}),
|
||||
},
|
||||
},
|
||||
write: (payload: string, callback?: () => void) => {
|
||||
writes.push(payload);
|
||||
callback?.();
|
||||
},
|
||||
scrollToBottom: () => {},
|
||||
};
|
||||
|
||||
const didClear = clearTerminalViewport(term as never, { wipeScrollback: false });
|
||||
|
||||
assert.equal(didClear, true);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.equal(writes[0].includes("\x1b[3J"), false);
|
||||
});
|
||||
|
||||
test("local clear reports that alternate-screen content was left unchanged", () => {
|
||||
const writes: string[] = [];
|
||||
const term = {
|
||||
buffer: {
|
||||
active: {
|
||||
type: "alternate",
|
||||
baseY: 0,
|
||||
cursorY: 2,
|
||||
cursorX: 4,
|
||||
},
|
||||
},
|
||||
write: (payload: string) => writes.push(payload),
|
||||
};
|
||||
|
||||
const didClear = clearTerminalViewport(term as never, { wipeScrollback: true });
|
||||
|
||||
assert.equal(didClear, false);
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("PTY sync runs only when the local viewport was actually cleared", () => {
|
||||
const syncCalls: string[] = [];
|
||||
const makeTerm = (type: "normal" | "alternate", cursorY: number) => ({
|
||||
rows: 5,
|
||||
buffer: {
|
||||
active: {
|
||||
type,
|
||||
baseY: 0,
|
||||
cursorY,
|
||||
cursorX: 4,
|
||||
},
|
||||
},
|
||||
_core: {
|
||||
scroll: () => {},
|
||||
_inputHandler: {
|
||||
_eraseAttrData: () => ({}),
|
||||
},
|
||||
},
|
||||
write: (_payload: string, callback?: () => void) => callback?.(),
|
||||
scrollToBottom: () => {},
|
||||
});
|
||||
|
||||
assert.equal(clearTerminalViewportAndSyncPty(makeTerm("normal", 2) as never, {
|
||||
syncPty: () => syncCalls.push("normal"),
|
||||
}), true);
|
||||
assert.equal(clearTerminalViewportAndSyncPty(makeTerm("alternate", 2) as never, {
|
||||
syncPty: () => syncCalls.push("alternate"),
|
||||
}), false);
|
||||
assert.equal(clearTerminalViewportAndSyncPty(makeTerm("normal", 0) as never, {
|
||||
syncPty: () => syncCalls.push("empty"),
|
||||
}), false);
|
||||
|
||||
assert.deepEqual(syncCalls, ["normal"]);
|
||||
});
|
||||
|
||||
test("appendEraseScrollback adds 3J after a normal full clear", () => {
|
||||
assert.equal(
|
||||
appendEraseScrollbackAfterFullErases("\x1b[H\x1b[2Jframe", {
|
||||
wipeScrollback: true,
|
||||
normalScreen: true,
|
||||
}),
|
||||
"\x1b[H\x1b[2J\x1b[3Jframe",
|
||||
);
|
||||
});
|
||||
|
||||
test("appendEraseScrollback skips 3J inside an in-chunk DEC 2026 block", () => {
|
||||
assert.equal(
|
||||
appendEraseScrollbackAfterFullErases("\x1b[?2026h\x1b[H\x1b[2Jframe\x1b[?2026l", {
|
||||
wipeScrollback: true,
|
||||
normalScreen: true,
|
||||
}),
|
||||
"\x1b[?2026h\x1b[H\x1b[2Jframe\x1b[?2026l",
|
||||
);
|
||||
});
|
||||
|
||||
test("appendEraseScrollback skips 3J for a delayed clear when sync was already open", () => {
|
||||
// Prior chunk carried `?2026h`; this chunk is only home+clear+frame.
|
||||
assert.equal(
|
||||
appendEraseScrollbackAfterFullErases("\x1b[H\x1b[2Jframe\x1b[?2026l", {
|
||||
wipeScrollback: true,
|
||||
normalScreen: true,
|
||||
startInDec2026SyncBlock: true,
|
||||
}),
|
||||
"\x1b[H\x1b[2Jframe\x1b[?2026l",
|
||||
);
|
||||
});
|
||||
|
||||
test("appendEraseScrollback still wipes when delayed clear is outside a sync block", () => {
|
||||
assert.equal(
|
||||
appendEraseScrollbackAfterFullErases("\x1b[H\x1b[2Jframe", {
|
||||
wipeScrollback: true,
|
||||
normalScreen: true,
|
||||
startInDec2026SyncBlock: false,
|
||||
}),
|
||||
"\x1b[H\x1b[2J\x1b[3Jframe",
|
||||
);
|
||||
});
|
||||
405
components/terminal/clearTerminalViewport.ts
Normal file
405
components/terminal/clearTerminalViewport.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import type { IDisposable, IParser, Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
type CsiParam = number | number[];
|
||||
type EraseInDisplayTerminal = XTerm & {
|
||||
parser: Pick<IParser, "registerCsiHandler">;
|
||||
};
|
||||
type InternalTerminal = XTerm & {
|
||||
_core?: {
|
||||
buffer?: {
|
||||
lines?: {
|
||||
length: number;
|
||||
trimStart?: (count: number) => void;
|
||||
};
|
||||
scrollTop: number;
|
||||
scrollBottom: number;
|
||||
ybase?: number;
|
||||
ydisp?: number;
|
||||
};
|
||||
scroll?: (eraseAttr: unknown, isWrapped?: boolean) => void;
|
||||
_inputHandler?: {
|
||||
_onScroll?: {
|
||||
fire?: (position: number) => void;
|
||||
};
|
||||
_eraseAttrData?: () => unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type ClearTerminalViewportOptions = {
|
||||
wipeScrollback?: boolean;
|
||||
};
|
||||
|
||||
type ClearTerminalViewportAndSyncPtyOptions = ClearTerminalViewportOptions & {
|
||||
syncPty: () => void;
|
||||
};
|
||||
|
||||
type AppendEraseScrollbackOptions = {
|
||||
wipeScrollback: boolean;
|
||||
normalScreen: boolean;
|
||||
/**
|
||||
* Open DEC 2026 state carried from a prior PTY chunk (e.g. sync-block filter
|
||||
* saw `\x1b[?2026h` earlier). Chunk-local tracking alone cannot see that, so a
|
||||
* delayed full-redraw `\x1b[2J` would otherwise get a spurious `\x1b[3J`.
|
||||
*/
|
||||
startInDec2026SyncBlock?: boolean;
|
||||
};
|
||||
|
||||
type EraseInDisplayHandlerOptions = {
|
||||
getClearWipesScrollback: () => boolean;
|
||||
isInDec2026SyncBlock: () => boolean;
|
||||
scheduleMicrotask?: (callback: () => void) => void;
|
||||
};
|
||||
|
||||
const getVisibleContentRowCount = (term: XTerm): number => {
|
||||
const buffer = term.buffer.active;
|
||||
if (buffer.type !== "normal") {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const baseY = buffer.baseY;
|
||||
for (let row = term.rows - 1; row >= 0; row--) {
|
||||
const line = buffer.getLine(baseY + row);
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
if (line.translateToString(true).length > 0) {
|
||||
return row + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const getInternalScrollRegion = (term: XTerm): { scrollTop: number; scrollBottom: number } | undefined => {
|
||||
const internalBuffer = (term as InternalTerminal)._core?.buffer;
|
||||
if (
|
||||
typeof internalBuffer?.scrollTop !== "number"
|
||||
|| typeof internalBuffer.scrollBottom !== "number"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return internalBuffer;
|
||||
};
|
||||
|
||||
const hasDefaultScrollRegion = (term: XTerm): boolean => {
|
||||
const scrollRegion = getInternalScrollRegion(term);
|
||||
if (!scrollRegion) {
|
||||
return true;
|
||||
}
|
||||
return scrollRegion.scrollTop === 0 && scrollRegion.scrollBottom === term.rows - 1;
|
||||
};
|
||||
|
||||
export const preserveTerminalViewportInScrollback = (term: XTerm): void => {
|
||||
const rowsToPreserve = getVisibleContentRowCount(term);
|
||||
if (rowsToPreserve <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const internal = term as InternalTerminal;
|
||||
const scroll = internal._core?.scroll;
|
||||
const eraseAttr = internal._core?._inputHandler?._eraseAttrData?.();
|
||||
|
||||
if (typeof scroll !== "function" || eraseAttr === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollRegion = getInternalScrollRegion(term);
|
||||
const previousScrollTop = scrollRegion?.scrollTop;
|
||||
const previousScrollBottom = scrollRegion?.scrollBottom;
|
||||
|
||||
try {
|
||||
// xterm scrolls inside active DECSTBM margins; widen them while preserving.
|
||||
if (scrollRegion) {
|
||||
scrollRegion.scrollTop = 0;
|
||||
scrollRegion.scrollBottom = term.rows - 1;
|
||||
}
|
||||
for (let row = 0; row < rowsToPreserve; row++) {
|
||||
scroll.call(internal._core, eraseAttr, false);
|
||||
}
|
||||
} finally {
|
||||
if (
|
||||
scrollRegion
|
||||
&& previousScrollTop !== undefined
|
||||
&& previousScrollBottom !== undefined
|
||||
) {
|
||||
scrollRegion.scrollTop = previousScrollTop;
|
||||
scrollRegion.scrollBottom = previousScrollBottom;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const clearTerminalViewport = (
|
||||
term: XTerm,
|
||||
options: ClearTerminalViewportOptions = {},
|
||||
): boolean => {
|
||||
const buffer = term.buffer.active;
|
||||
if (buffer.type !== "normal") return false;
|
||||
|
||||
const cursorY = buffer.cursorY;
|
||||
const cursorX = buffer.cursorX;
|
||||
|
||||
if (cursorY === 0 && buffer.baseY === 0) return false;
|
||||
|
||||
const internal = term as InternalTerminal;
|
||||
const scroll = internal._core?.scroll;
|
||||
const eraseAttr = internal._core?._inputHandler?._eraseAttrData?.();
|
||||
|
||||
if (typeof scroll !== "function" || eraseAttr === undefined) return false;
|
||||
|
||||
// Push lines above cursor into scrollback so they are preserved.
|
||||
// After cursorY scrolls the prompt line shifts to active-screen row 0.
|
||||
for (let i = 0; i < cursorY; i++) {
|
||||
scroll.call(internal._core, eraseAttr, false);
|
||||
}
|
||||
|
||||
// Clear everything below the prompt and reposition the cursor on it.
|
||||
// CSI coordinates are 1-indexed.
|
||||
const col = cursorX + 1;
|
||||
const eraseScrollback = options.wipeScrollback ? "\x1b[3J" : "";
|
||||
term.write(`\x1b[2;1H\x1b[J${eraseScrollback}\x1b[1;${col}H`, () => {
|
||||
term.scrollToBottom();
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
export const clearTerminalViewportAndSyncPty = (
|
||||
term: XTerm,
|
||||
{ syncPty, ...options }: ClearTerminalViewportAndSyncPtyOptions,
|
||||
): boolean => {
|
||||
const didClearViewport = clearTerminalViewport(term, options);
|
||||
if (didClearViewport) {
|
||||
syncPty();
|
||||
}
|
||||
return didClearViewport;
|
||||
};
|
||||
|
||||
export const isEraseScrollbackSequence = (params: CsiParam[]): boolean =>
|
||||
params.length > 0 && params[0] === 3;
|
||||
|
||||
export const isEraseViewportSequence = (params: CsiParam[]): boolean =>
|
||||
params.length > 0 && params[0] === 2;
|
||||
|
||||
export const isEraseBelowSequence = (params: CsiParam[]): boolean =>
|
||||
params.length === 0 || params[0] === 0;
|
||||
|
||||
export const shouldScrollOnEraseInDisplay = (
|
||||
term: XTerm,
|
||||
inDec2026SyncBlock: boolean,
|
||||
clearWipesScrollback: boolean,
|
||||
): boolean => {
|
||||
if (clearWipesScrollback || inDec2026SyncBlock) {
|
||||
return false;
|
||||
}
|
||||
return term.buffer.active.type === "normal" && hasDefaultScrollRegion(term);
|
||||
};
|
||||
|
||||
/**
|
||||
* Netcatty preserves visible rows in scrollback before CSI 2 J so shell `clear`
|
||||
* does not discard history. TUIs inside DEC 2026 sync blocks or the alternate
|
||||
* screen expect an in-place erase instead.
|
||||
*/
|
||||
export const shouldPreserveViewportBeforeFullErase = (
|
||||
term: XTerm,
|
||||
inDec2026SyncBlock: boolean,
|
||||
clearWipesScrollback = false,
|
||||
): boolean => {
|
||||
if (inDec2026SyncBlock || clearWipesScrollback) {
|
||||
return false;
|
||||
}
|
||||
return term.buffer.active.type === "normal";
|
||||
};
|
||||
|
||||
export const shouldPreserveViewportBeforeEraseBelow = (
|
||||
term: XTerm,
|
||||
inDec2026SyncBlock: boolean,
|
||||
clearWipesScrollback = false,
|
||||
): boolean => {
|
||||
if (!shouldPreserveViewportBeforeFullErase(term, inDec2026SyncBlock, clearWipesScrollback)) {
|
||||
return false;
|
||||
}
|
||||
const buffer = term.buffer.active;
|
||||
return buffer.cursorX === 0 && buffer.cursorY === 0;
|
||||
};
|
||||
|
||||
export const shouldWipeScrollbackAfterEraseBelow = (
|
||||
term: XTerm,
|
||||
inDec2026SyncBlock: boolean,
|
||||
clearWipesScrollback: boolean,
|
||||
): boolean => {
|
||||
if (!shouldWipeScrollbackAfterFullErase(term, inDec2026SyncBlock, clearWipesScrollback)) {
|
||||
return false;
|
||||
}
|
||||
const buffer = term.buffer.active;
|
||||
return buffer.cursorX === 0 && buffer.cursorY === 0;
|
||||
};
|
||||
|
||||
const wipeTerminalScrollback = (term: XTerm): void => {
|
||||
const internal = term as InternalTerminal;
|
||||
const buffer = internal._core?.buffer;
|
||||
const lines = buffer?.lines;
|
||||
const scrollBackSize = (lines?.length ?? 0) - term.rows;
|
||||
if (!buffer || !lines || scrollBackSize <= 0 || typeof lines.trimStart !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
lines.trimStart(scrollBackSize);
|
||||
buffer.ybase = Math.max((buffer.ybase ?? 0) - scrollBackSize, 0);
|
||||
buffer.ydisp = Math.max((buffer.ydisp ?? 0) - scrollBackSize, 0);
|
||||
internal._core?._inputHandler?._onScroll?.fire?.(0);
|
||||
};
|
||||
|
||||
export const shouldWipeScrollbackAfterFullErase = (
|
||||
term: XTerm,
|
||||
inDec2026SyncBlock: boolean,
|
||||
clearWipesScrollback: boolean,
|
||||
): boolean => {
|
||||
if (!clearWipesScrollback || inDec2026SyncBlock) {
|
||||
return false;
|
||||
}
|
||||
return term.buffer.active.type === "normal";
|
||||
};
|
||||
|
||||
export const installEraseInDisplayHandlers = (
|
||||
term: EraseInDisplayTerminal,
|
||||
{
|
||||
getClearWipesScrollback,
|
||||
isInDec2026SyncBlock,
|
||||
scheduleMicrotask = queueMicrotask,
|
||||
}: EraseInDisplayHandlerOptions,
|
||||
): IDisposable => {
|
||||
const setScrollOnEraseInDisplayOnce = (enabled: boolean): void => {
|
||||
term.options.scrollOnEraseInDisplay = enabled;
|
||||
if (enabled) {
|
||||
scheduleMicrotask(() => {
|
||||
term.options.scrollOnEraseInDisplay = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const eraseDisposable = term.parser.registerCsiHandler({ final: "J" }, (params) => {
|
||||
const wipeAllowed = getClearWipesScrollback();
|
||||
const inDec2026SyncBlock = isInDec2026SyncBlock();
|
||||
// Scope xterm's native preservation to shell clears, not TUI redraws.
|
||||
if (isEraseViewportSequence(params)) {
|
||||
const useNativeScrollPreservation = shouldScrollOnEraseInDisplay(
|
||||
term,
|
||||
inDec2026SyncBlock,
|
||||
wipeAllowed,
|
||||
);
|
||||
setScrollOnEraseInDisplayOnce(useNativeScrollPreservation);
|
||||
if (
|
||||
!useNativeScrollPreservation
|
||||
&& shouldPreserveViewportBeforeFullErase(term, inDec2026SyncBlock, wipeAllowed)
|
||||
) {
|
||||
preserveTerminalViewportInScrollback(term);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
setScrollOnEraseInDisplayOnce(false);
|
||||
if (isEraseBelowSequence(params)) {
|
||||
if (shouldPreserveViewportBeforeEraseBelow(term, inDec2026SyncBlock, wipeAllowed)) {
|
||||
preserveTerminalViewportInScrollback(term);
|
||||
} else if (shouldWipeScrollbackAfterEraseBelow(term, inDec2026SyncBlock, wipeAllowed)) {
|
||||
wipeTerminalScrollback(term);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!isEraseScrollbackSequence(params)) {
|
||||
return false;
|
||||
}
|
||||
// CSI 3 J — POSIX/ncurses default `clear` emits this to wipe scrollback.
|
||||
// Honor it unless the user opts into the legacy "preserve history" behavior.
|
||||
return !wipeAllowed;
|
||||
});
|
||||
const selectiveEraseDisposable = term.parser.registerCsiHandler({ prefix: "?", final: "J" }, () => {
|
||||
setScrollOnEraseInDisplayOnce(false);
|
||||
return false;
|
||||
});
|
||||
|
||||
return {
|
||||
dispose: () => {
|
||||
eraseDisposable.dispose();
|
||||
selectiveEraseDisposable.dispose();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const appendEraseScrollbackAfterFullErases = (
|
||||
data: string,
|
||||
{
|
||||
wipeScrollback,
|
||||
normalScreen,
|
||||
startInDec2026SyncBlock = false,
|
||||
}: AppendEraseScrollbackOptions,
|
||||
): string => {
|
||||
if (!wipeScrollback || !normalScreen || data.length === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
// Hot path: all rewrites below only ever trigger on a literal \x1b[2J.
|
||||
// Sync open state may be carried from a prior chunk; without a 2J this chunk
|
||||
// still needs no rewrite.
|
||||
if (!data.includes("\x1b[2J")) {
|
||||
return data;
|
||||
}
|
||||
|
||||
let result = "";
|
||||
let index = 0;
|
||||
let inDec2026SyncBlock = startInDec2026SyncBlock;
|
||||
let inAlternateScreen = false;
|
||||
|
||||
while (index < data.length) {
|
||||
if (data.startsWith("\x1b[?2026h", index)) {
|
||||
inDec2026SyncBlock = true;
|
||||
result += "\x1b[?2026h";
|
||||
index += "\x1b[?2026h".length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.startsWith("\x1b[?2026l", index)) {
|
||||
inDec2026SyncBlock = false;
|
||||
result += "\x1b[?2026l";
|
||||
index += "\x1b[?2026l".length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const altEnter = ["\x1b[?47h", "\x1b[?1047h", "\x1b[?1049h"].find((sequence) =>
|
||||
data.startsWith(sequence, index)
|
||||
);
|
||||
if (altEnter) {
|
||||
inAlternateScreen = true;
|
||||
result += altEnter;
|
||||
index += altEnter.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const altLeave = ["\x1b[?47l", "\x1b[?1047l", "\x1b[?1049l"].find((sequence) =>
|
||||
data.startsWith(sequence, index)
|
||||
);
|
||||
if (altLeave) {
|
||||
inAlternateScreen = false;
|
||||
result += altLeave;
|
||||
index += altLeave.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data.startsWith("\x1b[2J", index)) {
|
||||
result += "\x1b[2J";
|
||||
index += "\x1b[2J".length;
|
||||
if (
|
||||
!inDec2026SyncBlock
|
||||
&& !inAlternateScreen
|
||||
&& !data.startsWith("\x1b[3J", index)
|
||||
) {
|
||||
result += "\x1b[3J";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
result += data[index];
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
286
components/terminal/clipboardImagePaste.test.ts
Normal file
286
components/terminal/clipboardImagePaste.test.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
buildRemoteClipboardImagePath,
|
||||
getRemoteClipboardImageUploadErrorMessageKey,
|
||||
handleRemoteClipboardImageUpload,
|
||||
quoteRemotePathForShell,
|
||||
} from "./clipboardImagePaste";
|
||||
|
||||
test("remote clipboard image path is placed under the current directory", () => {
|
||||
assert.equal(
|
||||
buildRemoteClipboardImagePath("/srv/app", "netcatty paste:1.png"),
|
||||
"/srv/app/.netcatty-paste-images/netcatty_paste_1.png",
|
||||
);
|
||||
});
|
||||
|
||||
test("remote clipboard image path is empty when cwd is unavailable", () => {
|
||||
assert.equal(
|
||||
buildRemoteClipboardImagePath(undefined, "shot.png"),
|
||||
"",
|
||||
);
|
||||
});
|
||||
|
||||
test("remote paths are quoted for shell-safe insertion", () => {
|
||||
assert.equal(
|
||||
quoteRemotePathForShell("/srv/app/.netcatty-paste-images/a b's.png"),
|
||||
"'/srv/app/.netcatty-paste-images/a b'\\''s.png'",
|
||||
);
|
||||
});
|
||||
|
||||
test("remote clipboard image upload inserts the remote image path without broadcasting", async () => {
|
||||
const writes: Array<{ sessionId: string; data: string; sensitive?: boolean }> = [];
|
||||
const scrolled: string[] = [];
|
||||
let focused = false;
|
||||
let closedSftpId: string | undefined;
|
||||
let deletedTempFile: string | undefined;
|
||||
const transferPayloads: unknown[] = [];
|
||||
const broadcastData: string[] = [];
|
||||
|
||||
const result = await handleRemoteClipboardImageUpload({
|
||||
bridge: {
|
||||
readClipboardImage: async () => ({
|
||||
path: "/tmp/netcatty/shot.png",
|
||||
name: "shot 1.png",
|
||||
mediaType: "image/png",
|
||||
size: 12,
|
||||
}),
|
||||
openSftpForSession: async (sessionId) => {
|
||||
assert.equal(sessionId, "session-1");
|
||||
return "sftp-1";
|
||||
},
|
||||
startStreamTransfer: async (options) => {
|
||||
transferPayloads.push(options);
|
||||
return { transferId: options.transferId, totalBytes: 12 };
|
||||
},
|
||||
closeSftp: async (sftpId) => {
|
||||
closedSftpId = sftpId;
|
||||
},
|
||||
deleteTempFile: async (filePath) => {
|
||||
deletedTempFile = filePath;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
createTransferId: () => "transfer-1",
|
||||
getRemoteCwd: async () => "/home/alice/project",
|
||||
isSensitiveInput: () => true,
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: (sessionId, data, options) => writes.push({
|
||||
sessionId,
|
||||
data,
|
||||
sensitive: options?.sensitive,
|
||||
}),
|
||||
},
|
||||
term: {
|
||||
focus: () => {
|
||||
focused = true;
|
||||
},
|
||||
},
|
||||
scrollToBottomAfterProgrammaticInput: (data) => scrolled.push(data),
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
ok: true,
|
||||
remotePath: "/home/alice/project/.netcatty-paste-images/shot_1.png",
|
||||
pastedPath: "/home/alice/project/.netcatty-paste-images/shot_1.png",
|
||||
});
|
||||
assert.deepEqual(transferPayloads, [
|
||||
{
|
||||
transferId: "transfer-1",
|
||||
sourcePath: "/tmp/netcatty/shot.png",
|
||||
targetPath: "/home/alice/project/.netcatty-paste-images/shot_1.png",
|
||||
sourceType: "local",
|
||||
targetType: "sftp",
|
||||
targetSftpId: "sftp-1",
|
||||
totalBytes: 12,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(writes, [
|
||||
{
|
||||
sessionId: "session-1",
|
||||
data: "/home/alice/project/.netcatty-paste-images/shot_1.png",
|
||||
sensitive: true,
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(scrolled, ["/home/alice/project/.netcatty-paste-images/shot_1.png"]);
|
||||
assert.deepEqual(broadcastData, []);
|
||||
assert.equal(focused, true);
|
||||
assert.equal(closedSftpId, "sftp-1");
|
||||
assert.equal(deletedTempFile, "/tmp/netcatty/shot.png");
|
||||
});
|
||||
|
||||
test("remote clipboard image upload reports no image when no image exists", async () => {
|
||||
const result = await handleRemoteClipboardImageUpload({
|
||||
bridge: {
|
||||
readClipboardImage: async () => null,
|
||||
openSftpForSession: async () => "sftp-1",
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("should not paste without an image"),
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { ok: false, reason: "no-image" });
|
||||
});
|
||||
|
||||
test("remote clipboard image upload reports no image without inserting a path", async () => {
|
||||
const result = await handleRemoteClipboardImageUpload({
|
||||
bridge: {
|
||||
readClipboardImage: async () => null,
|
||||
openSftpForSession: async () => {
|
||||
assert.fail("should not open SFTP without an image");
|
||||
},
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("should not paste without an image"),
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { ok: false, reason: "no-image" });
|
||||
});
|
||||
|
||||
test("remote clipboard image upload skips upload without a reliable cwd", async () => {
|
||||
const transferPayloads: unknown[] = [];
|
||||
let deletedTempFile: string | undefined;
|
||||
|
||||
const result = await handleRemoteClipboardImageUpload({
|
||||
bridge: {
|
||||
readClipboardImage: async () => ({
|
||||
path: "/tmp/netcatty/shot.png",
|
||||
name: "shot.png",
|
||||
mediaType: "image/png",
|
||||
size: 12,
|
||||
}),
|
||||
openSftpForSession: async () => {
|
||||
assert.fail("should not open SFTP without cwd");
|
||||
},
|
||||
startStreamTransfer: async (options) => {
|
||||
transferPayloads.push(options);
|
||||
return { transferId: options.transferId };
|
||||
},
|
||||
deleteTempFile: async (filePath) => {
|
||||
deletedTempFile = filePath;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
getRemoteCwd: async () => undefined,
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("should not paste without upload"),
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { ok: false, reason: "no-cwd" });
|
||||
assert.deepEqual(transferPayloads, []);
|
||||
assert.equal(deletedTempFile, "/tmp/netcatty/shot.png");
|
||||
});
|
||||
|
||||
test("remote clipboard image upload does not insert a path when upload returns an error", async () => {
|
||||
let closedSftpId: string | undefined;
|
||||
let deletedTempFile: string | undefined;
|
||||
|
||||
const result = await handleRemoteClipboardImageUpload({
|
||||
bridge: {
|
||||
readClipboardImage: async () => ({
|
||||
path: "/tmp/netcatty/shot.png",
|
||||
name: "shot.png",
|
||||
mediaType: "image/png",
|
||||
size: 12,
|
||||
}),
|
||||
openSftpForSession: async () => "sftp-1",
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId, error: "disk full" }),
|
||||
closeSftp: async (sftpId) => {
|
||||
closedSftpId = sftpId;
|
||||
},
|
||||
deleteTempFile: async (filePath) => {
|
||||
deletedTempFile = filePath;
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("should not paste failed upload path"),
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { ok: false, reason: "upload-failed" });
|
||||
assert.equal(closedSftpId, "sftp-1");
|
||||
assert.equal(deletedTempFile, "/tmp/netcatty/shot.png");
|
||||
});
|
||||
|
||||
test("remote clipboard image upload reports transfer failures without inserting a path", async () => {
|
||||
const result = await handleRemoteClipboardImageUpload({
|
||||
bridge: {
|
||||
readClipboardImage: async () => ({
|
||||
path: "/tmp/netcatty/shot.png",
|
||||
name: "shot.png",
|
||||
mediaType: "image/png",
|
||||
size: 12,
|
||||
}),
|
||||
openSftpForSession: async () => "sftp-1",
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId, error: "disk full" }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("should not paste failed upload path"),
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { ok: false, reason: "upload-failed" });
|
||||
});
|
||||
|
||||
test("remote clipboard image upload treats clipboard read failures as no image", async () => {
|
||||
const result = await handleRemoteClipboardImageUpload({
|
||||
bridge: {
|
||||
readClipboardImage: async () => {
|
||||
throw new Error("clipboard unavailable");
|
||||
},
|
||||
openSftpForSession: async () => {
|
||||
assert.fail("should not open SFTP without an image");
|
||||
},
|
||||
startStreamTransfer: async (options) => ({ transferId: options.transferId }),
|
||||
},
|
||||
getRemoteCwd: async () => "/home/alice",
|
||||
sessionId: "session-1",
|
||||
terminalBackend: {
|
||||
writeToSession: () => assert.fail("should not paste without an image"),
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { ok: false, reason: "no-image" });
|
||||
});
|
||||
|
||||
test("remote clipboard image upload result maps to user-facing message keys", () => {
|
||||
assert.equal(
|
||||
getRemoteClipboardImageUploadErrorMessageKey({
|
||||
ok: false,
|
||||
reason: "no-image",
|
||||
}),
|
||||
"terminal.clipboardImageUpload.noImage",
|
||||
);
|
||||
assert.equal(
|
||||
getRemoteClipboardImageUploadErrorMessageKey({
|
||||
ok: false,
|
||||
reason: "upload-failed",
|
||||
}),
|
||||
"terminal.clipboardImageUpload.failed",
|
||||
);
|
||||
assert.equal(
|
||||
getRemoteClipboardImageUploadErrorMessageKey({
|
||||
ok: true,
|
||||
remotePath: "/tmp/image.png",
|
||||
pastedPath: "/tmp/image.png",
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
141
components/terminal/clipboardImagePaste.ts
Normal file
141
components/terminal/clipboardImagePaste.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
const REMOTE_CLIPBOARD_IMAGE_DIR = ".netcatty-paste-images";
|
||||
|
||||
type ClipboardImageFile = {
|
||||
path: string;
|
||||
name: string;
|
||||
mediaType: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export type RemoteClipboardImageBridge = Pick<
|
||||
NetcattyBridge,
|
||||
"readClipboardImage" | "openSftpForSession" | "startStreamTransfer"
|
||||
> & Pick<Partial<NetcattyBridge>, "closeSftp" | "deleteTempFile">;
|
||||
|
||||
type TerminalLike = {
|
||||
focus?: () => void;
|
||||
};
|
||||
|
||||
type HandleRemoteClipboardImagePasteOptions = {
|
||||
bridge?: RemoteClipboardImageBridge;
|
||||
createTransferId?: () => string;
|
||||
getRemoteCwd: () => Promise<string | null | undefined>;
|
||||
isSensitiveInput?: () => boolean;
|
||||
scrollToBottomAfterProgrammaticInput?: (data: string) => void;
|
||||
sessionId: string | null | undefined;
|
||||
terminalBackend: {
|
||||
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean; sensitive?: boolean }) => void;
|
||||
};
|
||||
term?: TerminalLike | null;
|
||||
};
|
||||
|
||||
export type RemoteClipboardImageUploadResult =
|
||||
| { ok: true; remotePath: string; pastedPath: string }
|
||||
| { ok: false; reason: "unsupported" | "no-session" | "no-image" | "no-cwd" | "upload-failed" };
|
||||
|
||||
export function getRemoteClipboardImageUploadErrorMessageKey(
|
||||
result: RemoteClipboardImageUploadResult,
|
||||
): "terminal.clipboardImageUpload.noImage" | "terminal.clipboardImageUpload.failed" | null {
|
||||
if (result.ok === true) return null;
|
||||
return result.reason === "no-image"
|
||||
? "terminal.clipboardImageUpload.noImage"
|
||||
: "terminal.clipboardImageUpload.failed";
|
||||
}
|
||||
|
||||
const shellSafePathPattern = /^[A-Za-z0-9_./~:@%+=,-]+$/;
|
||||
|
||||
export function sanitizeRemoteClipboardImageName(name: string): string {
|
||||
const fallback = "netcatty-paste.png";
|
||||
const trimmed = name.trim() || fallback;
|
||||
const sanitized = trimmed
|
||||
.replace(/[\0/\\]/g, "_")
|
||||
.replace(/[^A-Za-z0-9._-]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
|
||||
return sanitized || fallback;
|
||||
}
|
||||
|
||||
export function buildRemoteClipboardImagePath(cwd: string | null | undefined, fileName: string): string {
|
||||
const safeFileName = sanitizeRemoteClipboardImageName(fileName);
|
||||
const normalizedCwd = typeof cwd === "string" ? cwd.trim() : "";
|
||||
if (!normalizedCwd) return "";
|
||||
const base = normalizedCwd.replace(/\/+$/g, "") || "/";
|
||||
|
||||
if (base === "/") {
|
||||
return `/${REMOTE_CLIPBOARD_IMAGE_DIR}/${safeFileName}`;
|
||||
}
|
||||
|
||||
return `${base}/${REMOTE_CLIPBOARD_IMAGE_DIR}/${safeFileName}`;
|
||||
}
|
||||
|
||||
export function quoteRemotePathForShell(remotePath: string): string {
|
||||
if (shellSafePathPattern.test(remotePath)) return remotePath;
|
||||
return `'${remotePath.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
function defaultTransferId(): string {
|
||||
const uuid = globalThis.crypto?.randomUUID?.();
|
||||
return uuid ? `clipboard-image-${uuid}` : `clipboard-image-${Date.now()}`;
|
||||
}
|
||||
|
||||
export async function handleRemoteClipboardImageUpload({
|
||||
bridge,
|
||||
createTransferId = defaultTransferId,
|
||||
getRemoteCwd,
|
||||
isSensitiveInput,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
sessionId,
|
||||
terminalBackend,
|
||||
term,
|
||||
}: HandleRemoteClipboardImagePasteOptions): Promise<RemoteClipboardImageUploadResult> {
|
||||
if (!sessionId) return { ok: false, reason: "no-session" };
|
||||
if (!bridge?.readClipboardImage || !bridge.openSftpForSession || !bridge.startStreamTransfer) {
|
||||
return { ok: false, reason: "unsupported" };
|
||||
}
|
||||
|
||||
let image: ClipboardImageFile | null;
|
||||
try {
|
||||
image = await bridge.readClipboardImage();
|
||||
} catch {
|
||||
// A clipboard read failure is indistinguishable from an empty clipboard —
|
||||
// treat it as "no image" so callers can fall back to a normal paste.
|
||||
return { ok: false, reason: "no-image" };
|
||||
}
|
||||
if (!image?.path || !image.name) return { ok: false, reason: "no-image" };
|
||||
|
||||
let sftpId: string | undefined;
|
||||
try {
|
||||
const remoteCwd = await getRemoteCwd();
|
||||
const targetPath = buildRemoteClipboardImagePath(remoteCwd, image.name);
|
||||
if (!targetPath) return { ok: false, reason: "no-cwd" };
|
||||
const transferId = createTransferId();
|
||||
|
||||
sftpId = await bridge.openSftpForSession(sessionId);
|
||||
const transferResult = await bridge.startStreamTransfer({
|
||||
transferId,
|
||||
sourcePath: image.path,
|
||||
targetPath,
|
||||
sourceType: "local",
|
||||
targetType: "sftp",
|
||||
targetSftpId: sftpId,
|
||||
totalBytes: image.size,
|
||||
});
|
||||
if (!transferResult || transferResult.error) return { ok: false, reason: "upload-failed" };
|
||||
|
||||
const pastedPath = quoteRemotePathForShell(targetPath);
|
||||
terminalBackend.writeToSession(sessionId, pastedPath, {
|
||||
sensitive: isSensitiveInput?.() === true,
|
||||
});
|
||||
scrollToBottomAfterProgrammaticInput?.(pastedPath);
|
||||
term?.focus?.();
|
||||
return { ok: true, remotePath: targetPath, pastedPath };
|
||||
} finally {
|
||||
if (sftpId && bridge.closeSftp) {
|
||||
await bridge.closeSftp(sftpId).catch(() => undefined);
|
||||
}
|
||||
if (bridge.deleteTempFile) {
|
||||
await bridge.deleteTempFile(image.path).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
506
components/terminal/completionEngine.test.ts
Normal file
506
components/terminal/completionEngine.test.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { FigSpec } from "./autocomplete/figSpecLoader.ts";
|
||||
|
||||
type LocalStorageMock = {
|
||||
clear(): void;
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
};
|
||||
|
||||
type MockDirEntry = {
|
||||
name: string;
|
||||
type: "file" | "directory" | "symlink";
|
||||
};
|
||||
|
||||
function installLocalStorage(): LocalStorageMock {
|
||||
const store = new Map<string, string>();
|
||||
const localStorage: LocalStorageMock = {
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
getItem(key: string) {
|
||||
return store.has(key) ? store.get(key)! : null;
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
store.set(key, String(value));
|
||||
},
|
||||
removeItem(key: string) {
|
||||
store.delete(key);
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
value: localStorage,
|
||||
configurable: true,
|
||||
});
|
||||
return localStorage;
|
||||
}
|
||||
|
||||
const localStorage = installLocalStorage();
|
||||
const storySpec: FigSpec = {
|
||||
name: "story",
|
||||
subcommands: [
|
||||
{
|
||||
name: "open",
|
||||
args: { template: "filepaths" },
|
||||
},
|
||||
{
|
||||
name: "pick",
|
||||
args: { name: "item", generators: {} },
|
||||
},
|
||||
],
|
||||
};
|
||||
const bridgeState: {
|
||||
localEntries: MockDirEntry[];
|
||||
remoteEntriesByPath: Map<string, MockDirEntry[]>;
|
||||
remoteCalls: string[];
|
||||
remoteDelayMs: number;
|
||||
} = {
|
||||
localEntries: [],
|
||||
remoteEntriesByPath: new Map(),
|
||||
remoteCalls: [],
|
||||
remoteDelayMs: 0,
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
netcatty: {
|
||||
listFigSpecs: async () => ["story"],
|
||||
loadFigSpec: async (commandName: string) => commandName === "story" ? storySpec : null,
|
||||
listAutocompleteLocalDir: async (
|
||||
_path: string,
|
||||
foldersOnly: boolean,
|
||||
filterPrefix?: string,
|
||||
limit?: number,
|
||||
) => {
|
||||
const prefix = (filterPrefix ?? "").toLowerCase();
|
||||
const entries = bridgeState.localEntries
|
||||
.filter((entry) => !foldersOnly || entry.type === "directory")
|
||||
.filter((entry) => !prefix || entry.name.toLowerCase().startsWith(prefix))
|
||||
.slice(0, limit ?? bridgeState.localEntries.length);
|
||||
return { success: true, entries };
|
||||
},
|
||||
listAutocompleteRemoteDir: async (
|
||||
_sessionId: string,
|
||||
path: string,
|
||||
foldersOnly: boolean,
|
||||
filterPrefix?: string,
|
||||
limit?: number,
|
||||
) => {
|
||||
bridgeState.remoteCalls.push(path);
|
||||
if (bridgeState.remoteDelayMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, bridgeState.remoteDelayMs));
|
||||
}
|
||||
const prefix = (filterPrefix ?? "").toLowerCase();
|
||||
const remoteEntries = bridgeState.remoteEntriesByPath.get(path) ?? [];
|
||||
const entries = remoteEntries
|
||||
.filter((entry) => !foldersOnly || entry.type === "directory")
|
||||
.filter((entry) => !prefix || entry.name.toLowerCase().startsWith(prefix))
|
||||
.slice(0, limit ?? remoteEntries.length);
|
||||
return { success: true, entries };
|
||||
},
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const {
|
||||
getCompletions,
|
||||
getPathSuggestionsWithinBudget,
|
||||
} = await import("./autocomplete/completionEngine.ts");
|
||||
const {
|
||||
clearHistory,
|
||||
recordCommand,
|
||||
removeCommandHistoryEntry,
|
||||
} = await import("./autocomplete/commandHistoryStore.ts");
|
||||
const {
|
||||
normalizePathTokenForLookup,
|
||||
shouldPreferRemoteShellCwd,
|
||||
} = await import("./autocomplete/remotePathCompleter.ts");
|
||||
|
||||
test.beforeEach(() => {
|
||||
localStorage.clear();
|
||||
clearHistory();
|
||||
bridgeState.localEntries = [{ name: "package.json", type: "file" }];
|
||||
bridgeState.remoteEntriesByPath = new Map();
|
||||
bridgeState.remoteCalls = [];
|
||||
bridgeState.remoteDelayMs = 0;
|
||||
});
|
||||
|
||||
test("getCompletions prioritizes spec-driven path suggestions over history", async () => {
|
||||
recordCommand("story open package-lock.json", "host-1");
|
||||
|
||||
const completions = await getCompletions("story open pa", {
|
||||
hostId: "host-1",
|
||||
protocol: "local",
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
assert.ok(completions.length > 0);
|
||||
assert.equal(completions[0]?.source, "path");
|
||||
assert.equal(completions[0]?.text, "story open package.json");
|
||||
|
||||
const historyIndex = completions.findIndex((entry) =>
|
||||
entry.source === "history" && entry.text === "story open package-lock.json"
|
||||
);
|
||||
assert.ok(historyIndex > 0);
|
||||
assert.equal(completions[historyIndex]?.historyMatch, "path-argument");
|
||||
});
|
||||
|
||||
test("path completion marks a matching history replacement even when its full line is shorter", async () => {
|
||||
const historyCommand = "story package.json";
|
||||
const input = "story open --number p";
|
||||
recordCommand(historyCommand, "host-1");
|
||||
|
||||
const completions = await getCompletions(input, {
|
||||
hostId: "host-1",
|
||||
protocol: "local",
|
||||
cwd: "/repo",
|
||||
});
|
||||
const history = completions.find((entry) => entry.text === historyCommand);
|
||||
|
||||
assert.ok(history);
|
||||
assert.ok(history.text.length < input.length);
|
||||
assert.equal(history.historyMatch, "path-argument");
|
||||
});
|
||||
|
||||
test("getCompletions does not treat generator-only spec args as path contexts", async () => {
|
||||
recordCommand("story pick package-choice", "host-1");
|
||||
|
||||
const completions = await getCompletions("story pick pa", {
|
||||
hostId: "host-1",
|
||||
protocol: "local",
|
||||
cwd: "/repo",
|
||||
});
|
||||
|
||||
assert.ok(completions.length > 0);
|
||||
assert.equal(completions[0]?.source, "history");
|
||||
assert.equal(completions[0]?.text, "story pick package-choice");
|
||||
assert.equal(completions.some((entry) => entry.source === "path"), false);
|
||||
});
|
||||
|
||||
test("history suggestions stop when an edited argument no longer matches the command prefix", async () => {
|
||||
const historyCommand = "python3.14 -m robot -d /home/wx0043/Desktop/suite9";
|
||||
recordCommand(historyCommand, "host-1");
|
||||
|
||||
const matching = await getCompletions("python3.14 -m r", {
|
||||
hostId: "host-1",
|
||||
historyScope: "host",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
assert.equal(
|
||||
matching.some((entry) => entry.source === "history" && entry.text === historyCommand),
|
||||
true,
|
||||
);
|
||||
|
||||
const changedArgument = await getCompletions("python3.14 -m p", {
|
||||
hostId: "host-1",
|
||||
historyScope: "host",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
assert.equal(
|
||||
changedArgument.some((entry) => entry.source === "history" && entry.text === historyCommand),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("single-token history queries retain fuzzy command-name matching", async () => {
|
||||
const historyCommand = "docker compose up";
|
||||
recordCommand(historyCommand, "host-1");
|
||||
|
||||
const completions = await getCompletions("dcu", {
|
||||
hostId: "host-1",
|
||||
historyScope: "host",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
completions.some((entry) => entry.source === "history" && entry.text === historyCommand),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("removeCommandHistoryEntry removes only the matching host's autocomplete record", async () => {
|
||||
recordCommand("bad-command --flag", "host-1");
|
||||
recordCommand("bad-command --flag", "host-2");
|
||||
|
||||
removeCommandHistoryEntry("bad-command --flag", "host-1");
|
||||
|
||||
const completions = await getCompletions("bad-command", {
|
||||
hostId: "host-1",
|
||||
historyScope: "global",
|
||||
protocol: "local",
|
||||
cwd: "/repo",
|
||||
});
|
||||
assert.equal(
|
||||
completions.some((entry) => entry.source === "history" && entry.text === "bad-command --flag"),
|
||||
true,
|
||||
);
|
||||
|
||||
const hostOnlyCompletions = await getCompletions("bad-command", {
|
||||
hostId: "host-1",
|
||||
historyScope: "host",
|
||||
protocol: "local",
|
||||
cwd: "/repo",
|
||||
});
|
||||
assert.equal(
|
||||
hostOnlyCompletions.some((entry) => entry.source === "history" && entry.text === "bad-command --flag"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("removeCommandHistoryEntry trims command text like recordCommand", async () => {
|
||||
recordCommand("padded-cmd", "host-trim");
|
||||
assert.equal(removeCommandHistoryEntry(" padded-cmd ", "host-trim"), true);
|
||||
|
||||
const hostOnlyCompletions = await getCompletions("padded", {
|
||||
hostId: "host-trim",
|
||||
historyScope: "host",
|
||||
protocol: "local",
|
||||
cwd: "/repo",
|
||||
});
|
||||
assert.equal(
|
||||
hostOnlyCompletions.some((entry) => entry.source === "history" && entry.text === "padded-cmd"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("getCompletions uses the remote shell cwd for relative path arguments instead of stale home", async () => {
|
||||
bridgeState.remoteEntriesByPath.set("~", [{ name: "home-only.txt", type: "file" }]);
|
||||
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
|
||||
|
||||
const completions = await getCompletions("cat wo", {
|
||||
hostId: "host-1",
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
cwd: "~",
|
||||
});
|
||||
|
||||
assert.deepEqual(bridgeState.remoteCalls, ["."]);
|
||||
assert.equal(completions[0]?.source, "path");
|
||||
assert.equal(completions[0]?.text, "cat worktree.txt");
|
||||
assert.equal(completions.some((entry) => entry.text.includes("~")), false);
|
||||
});
|
||||
|
||||
test("getCompletions uses absolute prompt cwd for remote relative path arguments", async () => {
|
||||
bridgeState.remoteEntriesByPath.set(".", [{ name: "old-user-file.txt", type: "file" }]);
|
||||
bridgeState.remoteEntriesByPath.set("/etc", [{ name: "passwd", type: "file" }]);
|
||||
|
||||
const completions = await getCompletions("cat pa", {
|
||||
hostId: "host-1",
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
cwd: "/etc",
|
||||
cwdSource: "prompt",
|
||||
});
|
||||
|
||||
assert.deepEqual(bridgeState.remoteCalls, ["/etc"]);
|
||||
assert.equal(completions[0]?.source, "path");
|
||||
assert.equal(completions[0]?.text, "cat passwd");
|
||||
assert.equal(completions.some((entry) => entry.text === "cat old-user-file.txt"), false);
|
||||
});
|
||||
|
||||
test("remote subdirectory lookups keep absolute prompt cwd", () => {
|
||||
const preferRelativeCwd = shouldPreferRemoteShellCwd("ssh", "session-1", "linux", "/etc", "prompt");
|
||||
|
||||
assert.equal(preferRelativeCwd, false);
|
||||
assert.equal(
|
||||
normalizePathTokenForLookup("pam.d/", "/etc", { preferRelativeCwd }),
|
||||
"/etc/pam.d/",
|
||||
);
|
||||
});
|
||||
|
||||
test("getCompletions keeps remote shell cwd when absolute cwd is only a fallback", async () => {
|
||||
bridgeState.remoteEntriesByPath.set("/old", [{ name: "old-user-file.txt", type: "file" }]);
|
||||
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
|
||||
|
||||
const completions = await getCompletions("cat wo", {
|
||||
hostId: "host-1",
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
cwd: "/old",
|
||||
cwdSource: "fallback",
|
||||
});
|
||||
|
||||
assert.deepEqual(bridgeState.remoteCalls, ["."]);
|
||||
assert.equal(completions[0]?.source, "path");
|
||||
assert.equal(completions[0]?.text, "cat worktree.txt");
|
||||
assert.equal(completions.some((entry) => entry.text === "cat old-user-file.txt"), false);
|
||||
});
|
||||
|
||||
test("getCompletions does not reuse cached remote relative listings after cwd changes", async () => {
|
||||
bridgeState.remoteEntriesByPath.set(".", [{ name: "home-only.txt", type: "file" }]);
|
||||
|
||||
await getCompletions("cat ", {
|
||||
hostId: "host-1",
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
|
||||
|
||||
const completions = await getCompletions("cat wo", {
|
||||
hostId: "host-1",
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
assert.equal(bridgeState.remoteCalls.length, 2);
|
||||
assert.equal(completions[0]?.text, "cat worktree.txt");
|
||||
});
|
||||
|
||||
test("getCompletions does not reuse in-flight remote relative listings after cwd changes", async () => {
|
||||
bridgeState.remoteDelayMs = 150;
|
||||
bridgeState.remoteEntriesByPath.set(".", [{ name: "home-only.txt", type: "file" }]);
|
||||
|
||||
const first = getCompletions("cat ", {
|
||||
hostId: "host-1",
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-inflight-cwd",
|
||||
pathBudgetMs: Infinity,
|
||||
});
|
||||
|
||||
// First listing must be in flight before the shell cwd listing changes.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
|
||||
|
||||
const second = await getCompletions("cat wo", {
|
||||
hostId: "host-1",
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-inflight-cwd",
|
||||
pathBudgetMs: Infinity,
|
||||
});
|
||||
await first;
|
||||
|
||||
assert.equal(bridgeState.remoteCalls.length, 2);
|
||||
assert.equal(second[0]?.text, "cat worktree.txt");
|
||||
assert.equal(second.some((entry) => entry.text === "cat home-only.txt"), false);
|
||||
});
|
||||
|
||||
test("getCompletions returns local history before a slow remote path listing finishes", async () => {
|
||||
recordCommand("cat worktree.txt", "host-1");
|
||||
bridgeState.remoteDelayMs = 250;
|
||||
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
|
||||
|
||||
const started = Date.now();
|
||||
const completions = await getCompletions("cat wo", {
|
||||
hostId: "host-1",
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-slow-path",
|
||||
cwd: "~",
|
||||
pathBudgetMs: 40,
|
||||
});
|
||||
const elapsed = Date.now() - started;
|
||||
|
||||
assert.ok(elapsed < 200, `expected local suggestions within budget, took ${elapsed}ms`);
|
||||
assert.ok(
|
||||
completions.some((entry) => entry.source === "history" && entry.text === "cat worktree.txt"),
|
||||
);
|
||||
assert.equal(completions.some((entry) => entry.source === "path"), false);
|
||||
});
|
||||
|
||||
test("getCompletions surfaces late path suggestions for cache-bypassed relative SSH cwd", async () => {
|
||||
recordCommand("cat worktree.txt", "host-1");
|
||||
bridgeState.remoteDelayMs = 250;
|
||||
bridgeState.remoteEntriesByPath.set(".", [{ name: "worktree.txt", type: "file" }]);
|
||||
|
||||
let latePathSuggestions: Awaited<ReturnType<typeof getCompletions>> | null = null;
|
||||
const latePathPromise = new Promise<void>((resolve) => {
|
||||
void getCompletions("cat wo", {
|
||||
hostId: "host-1",
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
sessionId: "session-late-path",
|
||||
cwd: "/stale-fallback",
|
||||
cwdSource: "fallback",
|
||||
pathBudgetMs: 40,
|
||||
onLatePathSuggestions: (suggestions) => {
|
||||
latePathSuggestions = suggestions;
|
||||
resolve();
|
||||
},
|
||||
}).then((completions) => {
|
||||
assert.equal(completions.some((entry) => entry.source === "path"), false);
|
||||
assert.ok(
|
||||
completions.some((entry) => entry.source === "history" && entry.text === "cat worktree.txt"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
await latePathPromise;
|
||||
assert.ok(latePathSuggestions);
|
||||
assert.equal(latePathSuggestions![0]?.source, "path");
|
||||
assert.equal(latePathSuggestions![0]?.text, "cat worktree.txt");
|
||||
});
|
||||
|
||||
test("getPathSuggestionsWithinBudget ignores late rejections after the soft timeout", async () => {
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (reason: unknown) => {
|
||||
unhandled.push(reason);
|
||||
};
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
try {
|
||||
const pathPromise = new Promise<{ name: string; type: "file" }[]>((_resolve, reject) => {
|
||||
setTimeout(() => reject(new Error("late path failure")), 80);
|
||||
});
|
||||
const entries = await getPathSuggestionsWithinBudget(pathPromise, 20);
|
||||
assert.deepEqual(entries, []);
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
assert.equal(unhandled.length, 0);
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
}
|
||||
});
|
||||
|
||||
test("getPathSuggestionsWithinBudget delivers late entries after the soft timeout", async () => {
|
||||
const pathPromise = new Promise<{ name: string; type: "file" }[]>((resolve) => {
|
||||
setTimeout(() => resolve([{ name: "late.txt", type: "file" }]), 80);
|
||||
});
|
||||
let lateEntries: { name: string; type: "file" }[] | null = null;
|
||||
const entries = await getPathSuggestionsWithinBudget(pathPromise, 20, (late) => {
|
||||
lateEntries = late as { name: string; type: "file" }[];
|
||||
});
|
||||
assert.deepEqual(entries, []);
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
assert.deepEqual(lateEntries, [{ name: "late.txt", type: "file" }]);
|
||||
});
|
||||
|
||||
test("getCompletions includes other hosts' history when historyScope is global", async () => {
|
||||
recordCommand("systemctl restart nginx", "host-a");
|
||||
recordCommand("systemctl status nginx", "host-b");
|
||||
|
||||
const hostScoped = await getCompletions("systemctl", {
|
||||
hostId: "host-a",
|
||||
historyScope: "host",
|
||||
});
|
||||
assert.ok(
|
||||
hostScoped.some((entry) => entry.source === "history" && entry.text === "systemctl restart nginx"),
|
||||
);
|
||||
assert.equal(
|
||||
hostScoped.some((entry) => entry.source === "history" && entry.text === "systemctl status nginx"),
|
||||
false,
|
||||
);
|
||||
|
||||
const globalScoped = await getCompletions("systemctl", {
|
||||
hostId: "host-a",
|
||||
historyScope: "global",
|
||||
});
|
||||
assert.ok(
|
||||
globalScoped.some((entry) => entry.source === "history" && entry.text === "systemctl restart nginx"),
|
||||
);
|
||||
assert.ok(
|
||||
globalScoped.some((entry) => entry.source === "history" && entry.text === "systemctl status nginx"),
|
||||
);
|
||||
});
|
||||
65
components/terminal/completionEngineSnippets.test.ts
Normal file
65
components/terminal/completionEngineSnippets.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getCompletions } from "./autocomplete/completionEngine";
|
||||
import { DEFAULT_AUTOCOMPLETE_SETTINGS } from "./autocomplete/useTerminalAutocomplete";
|
||||
import type { Snippet } from "../../domain/models";
|
||||
|
||||
const deploySnippet: Snippet = { id: "d", label: "deploy", command: "kubectl apply -f ." };
|
||||
|
||||
test("getCompletions includes snippet suggestions at the command position", async () => {
|
||||
const out = await getCompletions("dep", { snippets: [deploySnippet] });
|
||||
const snip = out.find((s) => s.source === "snippet");
|
||||
assert.ok(snip, "expected a snippet suggestion");
|
||||
assert.equal(snip?.displayText, "deploy");
|
||||
});
|
||||
|
||||
test("getCompletions does not surface snippets past the command position", async () => {
|
||||
const out = await getCompletions("git dep", { snippets: [deploySnippet] });
|
||||
assert.equal(out.find((s) => s.source === "snippet"), undefined);
|
||||
});
|
||||
|
||||
test("getCompletions applies dynamic group targets to the current host", async () => {
|
||||
const groupedSnippet: Snippet = {
|
||||
...deploySnippet,
|
||||
targetGroups: ["Production"],
|
||||
};
|
||||
|
||||
const matching = await getCompletions("dep", {
|
||||
hostId: "host-prod",
|
||||
hostGroup: "Production/Web",
|
||||
snippets: [groupedSnippet],
|
||||
});
|
||||
const outside = await getCompletions("dep", {
|
||||
hostId: "host-dev",
|
||||
hostGroup: "Development",
|
||||
snippets: [groupedSnippet],
|
||||
});
|
||||
|
||||
assert.ok(matching.some((suggestion) => suggestion.source === "snippet"));
|
||||
assert.equal(outside.some((suggestion) => suggestion.source === "snippet"), false);
|
||||
});
|
||||
|
||||
test("getCompletions returns more than 8 snippet matches when default maxSuggestions allows it", async () => {
|
||||
const snippets: Snippet[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: `s${i}`,
|
||||
label: `deploy-${String(i).padStart(2, "0")}`,
|
||||
command: `echo deploy-${i}`,
|
||||
}));
|
||||
|
||||
assert.ok(
|
||||
DEFAULT_AUTOCOMPLETE_SETTINGS.maxSuggestions > 8,
|
||||
`expected raised default maxSuggestions (>8), got ${DEFAULT_AUTOCOMPLETE_SETTINGS.maxSuggestions}`,
|
||||
);
|
||||
|
||||
const out = await getCompletions("dep", {
|
||||
snippets,
|
||||
maxResults: DEFAULT_AUTOCOMPLETE_SETTINGS.maxSuggestions,
|
||||
});
|
||||
const snippetMatches = out.filter((s) => s.source === "snippet");
|
||||
assert.ok(
|
||||
snippetMatches.length > 8,
|
||||
`expected more than 8 snippet matches for scrolling popup, got ${snippetMatches.length}`,
|
||||
);
|
||||
assert.equal(snippetMatches.length, 20);
|
||||
});
|
||||
25
components/terminal/completionGate.test.ts
Normal file
25
components/terminal/completionGate.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { shouldQueryCompletions } from "./autocomplete/useTerminalAutocomplete.ts";
|
||||
|
||||
test("queries completions when the popup menu is enabled", () => {
|
||||
assert.equal(
|
||||
shouldQueryCompletions({ showPopupMenu: true, showGhostText: false }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("queries completions when ghost text is enabled", () => {
|
||||
assert.equal(
|
||||
shouldQueryCompletions({ showPopupMenu: false, showGhostText: true }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("skips completion work when both popup and ghost text are off", () => {
|
||||
assert.equal(
|
||||
shouldQueryCompletions({ showPopupMenu: false, showGhostText: false }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
66
components/terminal/composeBarHelpers.test.ts
Normal file
66
components/terminal/composeBarHelpers.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import type { Snippet } from '../../types';
|
||||
import {
|
||||
buildSnippetIdKey,
|
||||
clampComposeBarHeight,
|
||||
COMPOSE_BAR_BUILTIN_SNIPPET_IDS,
|
||||
COMPOSE_BAR_MAX_HEIGHT,
|
||||
COMPOSE_BAR_MIN_HEIGHT,
|
||||
filterComposeBarSnippets,
|
||||
resolveComposeBarDefaultSeedIds,
|
||||
} from './composeBarHelpers';
|
||||
|
||||
const sampleSnippets: Snippet[] = [
|
||||
{ id: 'a', label: 'List files', command: 'ls -la', package: 'utils' },
|
||||
{ id: 'b', label: 'Disk usage', command: 'df -h', package: 'monitor' },
|
||||
{ id: 'c', label: 'Restart nginx', command: 'systemctl restart nginx' },
|
||||
];
|
||||
|
||||
test('clampComposeBarHeight clamps below minimum', () => {
|
||||
assert.equal(clampComposeBarHeight(10), COMPOSE_BAR_MIN_HEIGHT);
|
||||
});
|
||||
|
||||
test('clampComposeBarHeight clamps above maximum', () => {
|
||||
assert.equal(clampComposeBarHeight(999), COMPOSE_BAR_MAX_HEIGHT);
|
||||
});
|
||||
|
||||
test('clampComposeBarHeight passes through valid values', () => {
|
||||
assert.equal(clampComposeBarHeight(150), 150);
|
||||
});
|
||||
|
||||
test('filterComposeBarSnippets returns all snippets sorted when query is empty', () => {
|
||||
assert.deepEqual(
|
||||
filterComposeBarSnippets(sampleSnippets, '').map((s) => s.id),
|
||||
['b', 'a', 'c'],
|
||||
);
|
||||
});
|
||||
|
||||
test('filterComposeBarSnippets filters by label, command, and package', () => {
|
||||
assert.deepEqual(filterComposeBarSnippets(sampleSnippets, 'nginx').map((s) => s.id), ['c']);
|
||||
assert.deepEqual(filterComposeBarSnippets(sampleSnippets, 'df').map((s) => s.id), ['b']);
|
||||
assert.deepEqual(filterComposeBarSnippets(sampleSnippets, 'utils').map((s) => s.id), ['a']);
|
||||
});
|
||||
|
||||
test('filterComposeBarSnippets returns empty list when nothing matches', () => {
|
||||
assert.deepEqual(filterComposeBarSnippets(sampleSnippets, 'missing'), []);
|
||||
});
|
||||
|
||||
test('buildSnippetIdKey joins ids with a null delimiter', () => {
|
||||
assert.equal(buildSnippetIdKey(['a', 'b']), 'a\0b');
|
||||
});
|
||||
|
||||
test('resolveComposeBarDefaultSeedIds uses vault snippets when available', () => {
|
||||
assert.deepEqual(
|
||||
resolveComposeBarDefaultSeedIds(sampleSnippets).map((id) => id),
|
||||
['b', 'a', 'c'],
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveComposeBarDefaultSeedIds falls back to built-ins when vault is empty', () => {
|
||||
assert.deepEqual(
|
||||
resolveComposeBarDefaultSeedIds([]),
|
||||
COMPOSE_BAR_BUILTIN_SNIPPET_IDS.slice(0, 4),
|
||||
);
|
||||
});
|
||||
56
components/terminal/composeBarHelpers.ts
Normal file
56
components/terminal/composeBarHelpers.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Snippet } from '../../types';
|
||||
|
||||
export const COMPOSE_BAR_MIN_HEIGHT = 72;
|
||||
export const COMPOSE_BAR_MAX_HEIGHT = 360;
|
||||
|
||||
export function clampComposeBarHeight(height: number): number {
|
||||
return Math.max(COMPOSE_BAR_MIN_HEIGHT, Math.min(COMPOSE_BAR_MAX_HEIGHT, height));
|
||||
}
|
||||
|
||||
export function filterComposeBarSnippets(snippets: Snippet[], query: string): Snippet[] {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
const filtered = normalized
|
||||
? snippets.filter((snippet) => (
|
||||
snippet.label.toLowerCase().includes(normalized)
|
||||
|| snippet.command.toLowerCase().includes(normalized)
|
||||
|| (snippet.package?.toLowerCase().includes(normalized) ?? false)
|
||||
))
|
||||
: snippets;
|
||||
return filtered.slice().sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
export function buildSnippetIdKey(snippetIds: readonly string[]): string {
|
||||
return snippetIds.join('\0');
|
||||
}
|
||||
|
||||
/** Built-in quick commands shown until the user customizes the strip. */
|
||||
export const COMPOSE_BAR_BUILTIN_SNIPPETS: Snippet[] = [
|
||||
{ id: '__compose_builtin_ls', label: 'ls -la', command: 'ls -la' },
|
||||
{ id: '__compose_builtin_df', label: 'df -h', command: 'df -h' },
|
||||
{ id: '__compose_builtin_free', label: 'free -h', command: 'free -h' },
|
||||
{ id: '__compose_builtin_pwd', label: 'pwd', command: 'pwd' },
|
||||
];
|
||||
|
||||
export const COMPOSE_BAR_BUILTIN_SNIPPET_IDS = COMPOSE_BAR_BUILTIN_SNIPPETS.map((s) => s.id);
|
||||
|
||||
export const COMPOSE_BAR_DEFAULT_SEED_COUNT = 4;
|
||||
|
||||
export function resolveComposeBarDefaultSeedIds(snippets: Snippet[]): string[] {
|
||||
if (snippets.length > 0) {
|
||||
return filterComposeBarSnippets(snippets, '')
|
||||
.slice(0, COMPOSE_BAR_DEFAULT_SEED_COUNT)
|
||||
.map((snippet) => snippet.id);
|
||||
}
|
||||
return COMPOSE_BAR_BUILTIN_SNIPPET_IDS.slice(0, COMPOSE_BAR_DEFAULT_SEED_COUNT);
|
||||
}
|
||||
|
||||
export function mergeComposeBarSnippetMap(snippets: Snippet[]): Map<string, Snippet> {
|
||||
const map = new Map<string, Snippet>();
|
||||
for (const builtin of COMPOSE_BAR_BUILTIN_SNIPPETS) {
|
||||
map.set(builtin.id, builtin);
|
||||
}
|
||||
for (const snippet of snippets) {
|
||||
map.set(snippet.id, snippet);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
57
components/terminal/connectAutomationBatch.test.ts
Normal file
57
components/terminal/connectAutomationBatch.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
cancelConnectAutomationBatch,
|
||||
createConnectAutomationBatch,
|
||||
trackConnectAutomationStop,
|
||||
} from "./connectAutomationBatch.ts";
|
||||
|
||||
test("cancel waits for the stop operation registered by the abort listener", async () => {
|
||||
const batch = createConnectAutomationBatch();
|
||||
let releaseStop: (() => void) | undefined;
|
||||
let stopFinished = false;
|
||||
|
||||
batch.controller.signal.addEventListener("abort", () => {
|
||||
trackConnectAutomationStop(batch, () => new Promise<void>((resolve) => {
|
||||
releaseStop = () => {
|
||||
stopFinished = true;
|
||||
resolve();
|
||||
};
|
||||
}));
|
||||
}, { once: true });
|
||||
|
||||
const cancelling = cancelConnectAutomationBatch(batch);
|
||||
await Promise.resolve();
|
||||
assert.equal(stopFinished, false);
|
||||
releaseStop?.();
|
||||
await cancelling;
|
||||
assert.equal(stopFinished, true);
|
||||
});
|
||||
|
||||
test("cancel surfaces a backend stop failure", async () => {
|
||||
const batch = createConnectAutomationBatch();
|
||||
batch.controller.signal.addEventListener("abort", () => {
|
||||
trackConnectAutomationStop(batch, async () => {
|
||||
throw new Error("stop failed");
|
||||
});
|
||||
}, { once: true });
|
||||
|
||||
await assert.rejects(() => cancelConnectAutomationBatch(batch), /stop failed/);
|
||||
});
|
||||
|
||||
test("cancel can retry a stop operation after a transient failure", async () => {
|
||||
const batch = createConnectAutomationBatch();
|
||||
let attempts = 0;
|
||||
trackConnectAutomationStop(batch, async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) throw new Error("temporary stop failure");
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => cancelConnectAutomationBatch(batch),
|
||||
/temporary stop failure/,
|
||||
);
|
||||
await cancelConnectAutomationBatch(batch);
|
||||
assert.equal(attempts, 2);
|
||||
});
|
||||
25
components/terminal/connectAutomationBatch.ts
Normal file
25
components/terminal/connectAutomationBatch.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export interface ConnectAutomationBatch {
|
||||
controller: AbortController;
|
||||
stopCurrentRun: (() => Promise<void>) | null;
|
||||
}
|
||||
|
||||
export const createConnectAutomationBatch = (): ConnectAutomationBatch => ({
|
||||
controller: new AbortController(),
|
||||
stopCurrentRun: null,
|
||||
});
|
||||
|
||||
export const trackConnectAutomationStop = (
|
||||
batch: ConnectAutomationBatch,
|
||||
stopCurrentRun: (() => Promise<void>) | null,
|
||||
): void => {
|
||||
batch.stopCurrentRun = stopCurrentRun;
|
||||
};
|
||||
|
||||
export const cancelConnectAutomationBatch = async (
|
||||
batch: ConnectAutomationBatch,
|
||||
): Promise<void> => {
|
||||
batch.controller.abort();
|
||||
if (batch.stopCurrentRun) {
|
||||
await batch.stopCurrentRun();
|
||||
}
|
||||
};
|
||||
98
components/terminal/connectionLogBuffer.test.ts
Normal file
98
components/terminal/connectionLogBuffer.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createConnectionLogBuffer } from "./connectionLogBuffer.ts";
|
||||
|
||||
test("concatenates appended chunks while under the cap", () => {
|
||||
const buf = createConnectionLogBuffer(100);
|
||||
buf.append("foo");
|
||||
buf.append("bar");
|
||||
buf.append("baz");
|
||||
assert.equal(buf.toString(), "foobarbaz");
|
||||
});
|
||||
|
||||
test("keeps only the last maxChars, matching slice(-max) semantics", () => {
|
||||
const max = 10;
|
||||
const buf = createConnectionLogBuffer(max);
|
||||
const chunks = ["abcd", "efgh", "ijkl", "mnop"]; // 16 chars total
|
||||
let naive = "";
|
||||
for (const c of chunks) {
|
||||
buf.append(c);
|
||||
naive += c;
|
||||
}
|
||||
assert.equal(buf.toString(), naive.slice(-max));
|
||||
assert.equal(buf.toString().length, max);
|
||||
});
|
||||
|
||||
test("trims a single chunk larger than the cap to its last maxChars", () => {
|
||||
const buf = createConnectionLogBuffer(5);
|
||||
buf.append("0123456789");
|
||||
assert.equal(buf.toString(), "56789");
|
||||
});
|
||||
|
||||
test("partial-trims the boundary chunk to keep exactly maxChars", () => {
|
||||
const buf = createConnectionLogBuffer(6);
|
||||
buf.append("abcde"); // 5
|
||||
buf.append("fghij"); // total 10 -> keep last 6 => "efghij"
|
||||
assert.equal(buf.toString(), "efghij");
|
||||
});
|
||||
|
||||
test("stays correct across many small appends (ring semantics)", () => {
|
||||
const max = 50;
|
||||
const buf = createConnectionLogBuffer(max);
|
||||
let naive = "";
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const chunk = `x${i}-`;
|
||||
buf.append(chunk);
|
||||
naive += chunk;
|
||||
}
|
||||
assert.equal(buf.toString(), naive.slice(-max));
|
||||
});
|
||||
|
||||
test("reset clears the buffer", () => {
|
||||
const buf = createConnectionLogBuffer(100);
|
||||
buf.append("hello");
|
||||
buf.reset();
|
||||
assert.equal(buf.toString(), "");
|
||||
buf.append("world");
|
||||
assert.equal(buf.toString(), "world");
|
||||
});
|
||||
|
||||
test("ignores empty appends", () => {
|
||||
const buf = createConnectionLogBuffer(100);
|
||||
buf.append("a");
|
||||
buf.append("");
|
||||
buf.append("b");
|
||||
assert.equal(buf.toString(), "ab");
|
||||
});
|
||||
|
||||
test("keeps the segment count bounded across many tiny appends", () => {
|
||||
// The whole point of the rewrite: trimming must not walk one array entry
|
||||
// per append. With a blockSize of 10 and a 100-char cap, the buffer should
|
||||
// never hold more than ~ceil(cap/blockSize)+1 segments no matter how many
|
||||
// single-char appends arrive once it's at capacity.
|
||||
const maxChars = 100;
|
||||
const blockSize = 10;
|
||||
const buf = createConnectionLogBuffer(maxChars, blockSize);
|
||||
let naive = "";
|
||||
for (let i = 0; i < 10000; i++) {
|
||||
buf.append("x");
|
||||
naive += "x";
|
||||
}
|
||||
assert.ok(
|
||||
buf.segmentCount() <= Math.ceil(maxChars / blockSize) + 1,
|
||||
`segmentCount ${buf.segmentCount()} exceeded the bound`,
|
||||
);
|
||||
assert.equal(buf.toString(), naive.slice(-maxChars));
|
||||
});
|
||||
|
||||
test("seals and trims whole blocks with a small blockSize", () => {
|
||||
const buf = createConnectionLogBuffer(10, 4);
|
||||
const chunks = ["abcd", "efgh", "ijkl"]; // 12 chars total
|
||||
let naive = "";
|
||||
for (const c of chunks) {
|
||||
buf.append(c);
|
||||
naive += c;
|
||||
}
|
||||
assert.equal(buf.toString(), naive.slice(-10)); // "cdefghijkl"
|
||||
});
|
||||
94
components/terminal/connectionLogBuffer.ts
Normal file
94
components/terminal/connectionLogBuffer.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* A bounded, append-only text buffer that retains only the last `maxChars`
|
||||
* characters — the connection log used for diagnostics/replay.
|
||||
*
|
||||
* The naive implementation (`log += chunk; if (log.length > max) log =
|
||||
* log.slice(-max)`) flattens a ~max-length string on *every* append once the
|
||||
* cap is reached — on the render thread, for every output chunk including each
|
||||
* echoed keystroke.
|
||||
*
|
||||
* Instead, data is coalesced into a small, bounded number of fixed-size blocks
|
||||
* (~`maxChars / blockSize`, e.g. ~16 for the 1 MB cap). New data accumulates in
|
||||
* an open `tail`; once it reaches `blockSize` it is sealed into a block. Trimming
|
||||
* the oldest data therefore only ever drops/slices a handful of blocks — never
|
||||
* one array element per append, which would make trim O(number of appends) and
|
||||
* defeat the purpose. Append is amortized O(chunk); the full string is
|
||||
* materialized only on `toString()` (called rarely, on finalize).
|
||||
*/
|
||||
export interface ConnectionLogBuffer {
|
||||
append(chunk: string): void;
|
||||
toString(): string;
|
||||
reset(): void;
|
||||
/**
|
||||
* Number of internal string segments currently retained. Exposed for tests
|
||||
* to assert the bounded-memory / bounded-trim property.
|
||||
*/
|
||||
segmentCount(): number;
|
||||
}
|
||||
|
||||
const DEFAULT_BLOCK_SIZE = 64 * 1024;
|
||||
|
||||
export function createConnectionLogBuffer(
|
||||
maxChars: number,
|
||||
blockSize: number = DEFAULT_BLOCK_SIZE,
|
||||
): ConnectionLogBuffer {
|
||||
let blocks: string[] = []; // sealed blocks, oldest first, each up to ~blockSize
|
||||
let tail = ""; // open block currently being filled (newest data)
|
||||
let total = 0; // total retained length across blocks + tail
|
||||
|
||||
const trim = () => {
|
||||
let overflow = total - maxChars;
|
||||
if (overflow <= 0) return;
|
||||
// Drop/slice whole blocks from the front. `blocks.length` is bounded by
|
||||
// ~maxChars/blockSize, so this shift is O(small constant), not O(appends).
|
||||
while (overflow > 0 && blocks.length > 0) {
|
||||
const head = blocks[0];
|
||||
if (head.length <= overflow) {
|
||||
blocks.shift();
|
||||
total -= head.length;
|
||||
overflow -= head.length;
|
||||
} else {
|
||||
blocks[0] = head.slice(overflow);
|
||||
total -= overflow;
|
||||
overflow = 0;
|
||||
}
|
||||
}
|
||||
// Only reachable when the tail alone exceeds the cap (e.g. blockSize >=
|
||||
// maxChars); keep its last `maxChars` characters.
|
||||
if (overflow > 0) {
|
||||
tail = tail.slice(overflow);
|
||||
total -= overflow;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
append(chunk: string): void {
|
||||
if (!chunk) return;
|
||||
// A single chunk at/over the cap can only contribute its own tail.
|
||||
if (chunk.length >= maxChars) {
|
||||
blocks = [];
|
||||
tail = chunk.slice(chunk.length - maxChars);
|
||||
total = tail.length;
|
||||
return;
|
||||
}
|
||||
tail += chunk;
|
||||
total += chunk.length;
|
||||
if (tail.length >= blockSize) {
|
||||
blocks.push(tail);
|
||||
tail = "";
|
||||
}
|
||||
if (total > maxChars) trim();
|
||||
},
|
||||
toString(): string {
|
||||
return blocks.length > 0 ? blocks.join("") + tail : tail;
|
||||
},
|
||||
reset(): void {
|
||||
blocks = [];
|
||||
tail = "";
|
||||
total = 0;
|
||||
},
|
||||
segmentCount(): number {
|
||||
return blocks.length + (tail.length > 0 ? 1 : 0);
|
||||
},
|
||||
};
|
||||
}
|
||||
51
components/terminal/connectionProgress.test.ts
Normal file
51
components/terminal/connectionProgress.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
CONNECTION_PROGRESS_CAP,
|
||||
CONNECTION_PROGRESS_START,
|
||||
advanceIndeterminateConnectionProgress,
|
||||
advanceMonotonicConnectionProgress,
|
||||
resolveHopConnectionProgress,
|
||||
} from "./connectionProgress";
|
||||
|
||||
test("indeterminate connection progress only moves forward toward the cap", () => {
|
||||
assert.equal(advanceIndeterminateConnectionProgress(CONNECTION_PROGRESS_CAP), CONNECTION_PROGRESS_CAP);
|
||||
assert.equal(advanceIndeterminateConnectionProgress(94), CONNECTION_PROGRESS_CAP);
|
||||
const first = advanceIndeterminateConnectionProgress(CONNECTION_PROGRESS_START);
|
||||
assert.ok(first > CONNECTION_PROGRESS_START);
|
||||
assert.ok(first < CONNECTION_PROGRESS_CAP);
|
||||
const second = advanceIndeterminateConnectionProgress(first);
|
||||
assert.ok(second > first);
|
||||
});
|
||||
|
||||
test("hop connection progress stays within the visual cap", () => {
|
||||
assert.equal(resolveHopConnectionProgress(1, 1), 90);
|
||||
assert.equal(resolveHopConnectionProgress(1, 2), 50);
|
||||
assert.equal(resolveHopConnectionProgress(2, 2), 90);
|
||||
assert.equal(resolveHopConnectionProgress(0, 1), 10);
|
||||
});
|
||||
|
||||
test("connection progress updates never rewind", () => {
|
||||
assert.equal(advanceMonotonicConnectionProgress(40, 10), 40);
|
||||
assert.equal(advanceMonotonicConnectionProgress(40, 90), 90);
|
||||
assert.equal(advanceMonotonicConnectionProgress(5, 5), 5);
|
||||
});
|
||||
|
||||
test("connecting seeds progress once and hop/phase changes do not snap it back", () => {
|
||||
const effectsSource = readFileSync(new URL("./useTerminalEffects.ts", import.meta.url), "utf8");
|
||||
assert.match(
|
||||
effectsSource,
|
||||
/if \(status === "connecting"\) \{\s*setIsDisconnectedDialogDismissed\(false\);\s*setProgressValue\(CONNECTION_PROGRESS_START\);/,
|
||||
);
|
||||
assert.match(effectsSource, /setProgressValue\(advanceIndeterminateConnectionProgress\)/);
|
||||
assert.doesNotMatch(effectsSource, /setProgressValue\(5\)/);
|
||||
|
||||
const starterSource = readFileSync(
|
||||
new URL("./runtime/createTerminalSessionStarters.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(starterSource, /advanceMonotonicConnectionProgress/);
|
||||
assert.match(starterSource, /resolveHopConnectionProgress/);
|
||||
});
|
||||
18
components/terminal/connectionProgress.ts
Normal file
18
components/terminal/connectionProgress.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export const CONNECTION_PROGRESS_START = 5;
|
||||
export const CONNECTION_PROGRESS_CAP = 95;
|
||||
|
||||
export const advanceIndeterminateConnectionProgress = (prev: number): number => {
|
||||
if (prev >= CONNECTION_PROGRESS_CAP) return prev;
|
||||
const remaining = CONNECTION_PROGRESS_CAP - prev;
|
||||
const increment = Math.max(1, remaining * 0.15);
|
||||
return Math.min(CONNECTION_PROGRESS_CAP, prev + increment);
|
||||
};
|
||||
|
||||
export const resolveHopConnectionProgress = (hop: number, total: number): number => {
|
||||
const safeTotal = Math.max(1, total);
|
||||
const safeHop = Math.max(0, hop);
|
||||
return Math.min(CONNECTION_PROGRESS_CAP, (safeHop / safeTotal) * 80 + 10);
|
||||
};
|
||||
|
||||
export const advanceMonotonicConnectionProgress = (prev: number, next: number): number =>
|
||||
Math.max(prev, next);
|
||||
99
components/terminal/connectionTimeouts.test.ts
Normal file
99
components/terminal/connectionTimeouts.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
SSH_AUTH_READY_TIMEOUT_MS,
|
||||
SSH_TCP_CONNECT_TIMEOUT_MS,
|
||||
getConnectionTimeoutMs,
|
||||
hasConnectionPassedTcpDial,
|
||||
resolveActiveConnectionTimeoutHost,
|
||||
shouldRunConnectionTimeout,
|
||||
} from "./connectionTimeouts";
|
||||
|
||||
test("SSH connection timeout constants separate TCP dial from auth wait", () => {
|
||||
assert.equal(SSH_TCP_CONNECT_TIMEOUT_MS, 20_000);
|
||||
assert.equal(SSH_AUTH_READY_TIMEOUT_MS, 120_000);
|
||||
});
|
||||
|
||||
const baseTimeoutState = {
|
||||
status: "connecting",
|
||||
needsAuth: false,
|
||||
isLocalConnection: false,
|
||||
isSerialConnection: false,
|
||||
hasSshTcpConnectProgress: true,
|
||||
needsHostKeyVerification: false,
|
||||
isConnectionAwaitingUserInput: false,
|
||||
isConnectionPastTcpDial: false,
|
||||
};
|
||||
|
||||
test("connection timeout runs for an ordinary remote connect attempt", () => {
|
||||
assert.equal(shouldRunConnectionTimeout(baseTimeoutState), true);
|
||||
});
|
||||
|
||||
test("connection timeout pauses while SSH waits for user confirmation", () => {
|
||||
assert.equal(shouldRunConnectionTimeout({
|
||||
...baseTimeoutState,
|
||||
needsHostKeyVerification: true,
|
||||
}), false);
|
||||
assert.equal(shouldRunConnectionTimeout({
|
||||
...baseTimeoutState,
|
||||
isConnectionAwaitingUserInput: true,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("connection timeout switches from TCP dial to auth readiness after TCP connects", () => {
|
||||
assert.equal(getConnectionTimeoutMs(baseTimeoutState), SSH_TCP_CONNECT_TIMEOUT_MS);
|
||||
assert.equal(getConnectionTimeoutMs({
|
||||
...baseTimeoutState,
|
||||
isConnectionPastTcpDial: true,
|
||||
}), SSH_AUTH_READY_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
test("connection timeout uses configured timeout values", () => {
|
||||
assert.equal(getConnectionTimeoutMs(baseTimeoutState, {
|
||||
tcpConnectTimeoutMs: 45_000,
|
||||
authReadyTimeoutMs: 300_000,
|
||||
}), 45_000);
|
||||
assert.equal(getConnectionTimeoutMs({
|
||||
...baseTimeoutState,
|
||||
isConnectionPastTcpDial: true,
|
||||
}, {
|
||||
tcpConnectTimeoutMs: 45_000,
|
||||
authReadyTimeoutMs: 300_000,
|
||||
}), 300_000);
|
||||
});
|
||||
|
||||
test("connection timeout follows the current jump host before the target", () => {
|
||||
const target = { sshTcpConnectTimeoutSeconds: 20, sshAuthReadyTimeoutSeconds: 120 };
|
||||
const jumps = [
|
||||
{ sshTcpConnectTimeoutSeconds: 75, sshAuthReadyTimeoutSeconds: 360 },
|
||||
{ sshTcpConnectTimeoutSeconds: 90, sshAuthReadyTimeoutSeconds: 420 },
|
||||
];
|
||||
|
||||
assert.equal(resolveActiveConnectionTimeoutHost(target, jumps, 1), jumps[0]);
|
||||
assert.equal(resolveActiveConnectionTimeoutHost(target, jumps, 2), jumps[1]);
|
||||
assert.equal(resolveActiveConnectionTimeoutHost(target, jumps, 3), target);
|
||||
assert.equal(resolveActiveConnectionTimeoutHost(target, jumps), target);
|
||||
assert.equal(resolveActiveConnectionTimeoutHost(target, jumps, 1, "forwarding"), jumps[1]);
|
||||
assert.equal(resolveActiveConnectionTimeoutHost(target, jumps, 2, "forwarding"), target);
|
||||
});
|
||||
|
||||
test("connection timeout keeps the auth-ready window for protocols without SSH TCP progress", () => {
|
||||
assert.equal(getConnectionTimeoutMs({
|
||||
...baseTimeoutState,
|
||||
hasSshTcpConnectProgress: false,
|
||||
}, {
|
||||
tcpConnectTimeoutMs: 5_000,
|
||||
authReadyTimeoutMs: 5_000,
|
||||
}), SSH_AUTH_READY_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
test("TCP dial is only considered passed after an actual transport connection", () => {
|
||||
assert.equal(hasConnectionPassedTcpDial("connecting"), false);
|
||||
assert.equal(hasConnectionPassedTcpDial("auth-attempt"), false);
|
||||
assert.equal(hasConnectionPassedTcpDial("error"), false);
|
||||
assert.equal(hasConnectionPassedTcpDial("forwarding"), false);
|
||||
assert.equal(hasConnectionPassedTcpDial("tcp-connected"), true);
|
||||
assert.equal(hasConnectionPassedTcpDial("authenticating"), true);
|
||||
assert.equal(hasConnectionPassedTcpDial("connected"), true);
|
||||
});
|
||||
71
components/terminal/connectionTimeouts.ts
Normal file
71
components/terminal/connectionTimeouts.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
DEFAULT_SSH_AUTH_READY_TIMEOUT_SECONDS,
|
||||
DEFAULT_SSH_TCP_CONNECT_TIMEOUT_SECONDS,
|
||||
} from '../../domain/sshConnectionTimeouts';
|
||||
import type { Host } from '../../domain/models';
|
||||
|
||||
export const SSH_TCP_CONNECT_TIMEOUT_MS = DEFAULT_SSH_TCP_CONNECT_TIMEOUT_SECONDS * 1000;
|
||||
export const SSH_AUTH_READY_TIMEOUT_MS = DEFAULT_SSH_AUTH_READY_TIMEOUT_SECONDS * 1000;
|
||||
|
||||
type ConnectionTimeouts = {
|
||||
tcpConnectTimeoutMs?: number;
|
||||
authReadyTimeoutMs?: number;
|
||||
};
|
||||
|
||||
type HostConnectionTimeouts = Pick<
|
||||
Host,
|
||||
'sshTcpConnectTimeoutSeconds' | 'sshAuthReadyTimeoutSeconds'
|
||||
>;
|
||||
|
||||
export function resolveActiveConnectionTimeoutHost(
|
||||
targetHost: HostConnectionTimeouts,
|
||||
chainHosts: HostConnectionTimeouts[],
|
||||
currentHop?: number,
|
||||
connectionPhase?: string,
|
||||
): HostConnectionTimeouts {
|
||||
const activeHop = connectionPhase === 'forwarding' && currentHop
|
||||
? currentHop + 1
|
||||
: currentHop;
|
||||
if (!activeHop || activeHop > chainHosts.length) return targetHost;
|
||||
return chainHosts[activeHop - 1] ?? targetHost;
|
||||
}
|
||||
|
||||
type ConnectionTimeoutState = {
|
||||
status: string;
|
||||
needsAuth: boolean;
|
||||
isLocalConnection: boolean;
|
||||
isSerialConnection: boolean;
|
||||
hasSshTcpConnectProgress: boolean;
|
||||
needsHostKeyVerification: boolean;
|
||||
isConnectionAwaitingUserInput: boolean;
|
||||
isConnectionPastTcpDial: boolean;
|
||||
};
|
||||
|
||||
export function getConnectionTimeoutMs(
|
||||
state: ConnectionTimeoutState,
|
||||
timeouts: ConnectionTimeouts = {},
|
||||
): number {
|
||||
const tcpConnectTimeoutMs = timeouts.tcpConnectTimeoutMs ?? SSH_TCP_CONNECT_TIMEOUT_MS;
|
||||
const authReadyTimeoutMs = timeouts.authReadyTimeoutMs ?? SSH_AUTH_READY_TIMEOUT_MS;
|
||||
if (!state.hasSshTcpConnectProgress) return SSH_AUTH_READY_TIMEOUT_MS;
|
||||
return state.isConnectionPastTcpDial
|
||||
? authReadyTimeoutMs
|
||||
: tcpConnectTimeoutMs;
|
||||
}
|
||||
|
||||
export function hasConnectionPassedTcpDial(status: string): boolean {
|
||||
return status === "tcp-connected"
|
||||
|| status === "authenticating"
|
||||
|| status === "authenticated"
|
||||
|| status === "connected"
|
||||
|| status === "shell";
|
||||
}
|
||||
|
||||
export function shouldRunConnectionTimeout(state: ConnectionTimeoutState): boolean {
|
||||
return state.status === "connecting"
|
||||
&& !state.needsAuth
|
||||
&& !state.isLocalConnection
|
||||
&& !state.isSerialConnection
|
||||
&& !state.needsHostKeyVerification
|
||||
&& !state.isConnectionAwaitingUserInput;
|
||||
}
|
||||
304
components/terminal/copyOnSelect.test.ts
Normal file
304
components/terminal/copyOnSelect.test.ts
Normal file
@@ -0,0 +1,304 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
COPY_ON_SELECT_USER_GESTURE_RELEASE_MS,
|
||||
createCopyOnSelectUserGestureTracker,
|
||||
pulseCopyOnSelectUserCommand,
|
||||
shouldWriteCopyOnSelect,
|
||||
subscribeCopyOnSelectUserCommand,
|
||||
subscribeCopyOnSelectUserGesture,
|
||||
} from "./copyOnSelect.ts";
|
||||
|
||||
test("copy-on-select writes only after a user selection gesture", () => {
|
||||
assert.equal(shouldWriteCopyOnSelect({
|
||||
hasText: true,
|
||||
copyOnSelect: true,
|
||||
isRestoringSelection: false,
|
||||
isUserSelection: true,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("copy-on-select skips SearchAddon and other programmatic selections", () => {
|
||||
assert.equal(shouldWriteCopyOnSelect({
|
||||
hasText: true,
|
||||
copyOnSelect: true,
|
||||
isRestoringSelection: false,
|
||||
isUserSelection: false,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("copy-on-select still skips restore and attach snapshots", () => {
|
||||
assert.equal(shouldWriteCopyOnSelect({
|
||||
allowCopy: false,
|
||||
hasText: true,
|
||||
copyOnSelect: true,
|
||||
isRestoringSelection: false,
|
||||
isUserSelection: true,
|
||||
}), false);
|
||||
assert.equal(shouldWriteCopyOnSelect({
|
||||
hasText: true,
|
||||
copyOnSelect: true,
|
||||
isRestoringSelection: true,
|
||||
isUserSelection: true,
|
||||
}), false);
|
||||
assert.equal(shouldWriteCopyOnSelect({
|
||||
hasText: false,
|
||||
copyOnSelect: true,
|
||||
isRestoringSelection: false,
|
||||
isUserSelection: true,
|
||||
}), false);
|
||||
assert.equal(shouldWriteCopyOnSelect({
|
||||
hasText: true,
|
||||
copyOnSelect: false,
|
||||
isRestoringSelection: false,
|
||||
isUserSelection: true,
|
||||
}), false);
|
||||
});
|
||||
|
||||
test("user gesture stays armed until shortly after pointer-up", () => {
|
||||
assert.ok(COPY_ON_SELECT_USER_GESTURE_RELEASE_MS < 200);
|
||||
|
||||
const scheduled: Array<{ cb: () => void; ms: number }> = [];
|
||||
const tracker = createCopyOnSelectUserGestureTracker({
|
||||
setTimeoutFn: ((cb: () => void, ms?: number) => {
|
||||
scheduled.push({ cb, ms: ms ?? 0 });
|
||||
return scheduled.length as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout,
|
||||
clearTimeoutFn: (() => {}) as typeof clearTimeout,
|
||||
});
|
||||
|
||||
assert.equal(tracker.isActive(), false);
|
||||
tracker.mark();
|
||||
assert.equal(tracker.isActive(), true);
|
||||
|
||||
tracker.release();
|
||||
assert.equal(tracker.isActive(), true);
|
||||
assert.equal(scheduled.at(-1)?.ms, COPY_ON_SELECT_USER_GESTURE_RELEASE_MS);
|
||||
|
||||
// A later SearchAddon revival (200ms) must not still look like a drag.
|
||||
scheduled.at(-1)?.cb();
|
||||
assert.equal(tracker.isActive(), false);
|
||||
|
||||
tracker.dispose();
|
||||
});
|
||||
|
||||
test("marking again cancels a pending release so a new drag can copy", () => {
|
||||
const cleared: number[] = [];
|
||||
let nextId = 1;
|
||||
const tracker = createCopyOnSelectUserGestureTracker({
|
||||
setTimeoutFn: ((cb: () => void) => {
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
void cb;
|
||||
return id as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout,
|
||||
clearTimeoutFn: ((id: ReturnType<typeof setTimeout>) => {
|
||||
cleared.push(id as unknown as number);
|
||||
}) as typeof clearTimeout,
|
||||
});
|
||||
|
||||
tracker.mark();
|
||||
tracker.release();
|
||||
tracker.mark();
|
||||
|
||||
assert.deepEqual(cleared, [1]);
|
||||
assert.equal(tracker.isActive(), true);
|
||||
tracker.dispose();
|
||||
});
|
||||
|
||||
const listenerKey = (
|
||||
type: string,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
): string => {
|
||||
const capture = options === true || (
|
||||
typeof options === "object" && options?.capture === true
|
||||
);
|
||||
return `${type}:${capture ? "capture" : "bubble"}`;
|
||||
};
|
||||
|
||||
const createEventTargetStub = () => {
|
||||
const listeners = new Map<string, Set<EventListener>>();
|
||||
return {
|
||||
addEventListener(
|
||||
type: string,
|
||||
listener: EventListener,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
) {
|
||||
const key = listenerKey(type, options);
|
||||
const set = listeners.get(key) ?? new Set();
|
||||
set.add(listener);
|
||||
listeners.set(key, set);
|
||||
},
|
||||
removeEventListener(
|
||||
type: string,
|
||||
listener: EventListener,
|
||||
options?: boolean | AddEventListenerOptions,
|
||||
) {
|
||||
listeners.get(listenerKey(type, options))?.delete(listener);
|
||||
},
|
||||
dispatch(type: string, event: Event, phase: "capture" | "bubble") {
|
||||
for (const listener of listeners.get(`${type}:${phase}`) ?? []) {
|
||||
listener(event);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const eventOn = (type: string, target: EventTarget): Event => {
|
||||
const event = new Event(type);
|
||||
Object.defineProperty(event, "target", { value: target });
|
||||
return event;
|
||||
};
|
||||
|
||||
test("pointer listeners mark on capture down and release on document up", () => {
|
||||
const el = createEventTargetStub();
|
||||
const root = createEventTargetStub();
|
||||
const view = createEventTargetStub();
|
||||
let marked = 0;
|
||||
let pulsed = 0;
|
||||
let released = 0;
|
||||
const unsubscribe = subscribeCopyOnSelectUserGesture(
|
||||
{ element: el },
|
||||
{
|
||||
mark: () => {
|
||||
marked += 1;
|
||||
},
|
||||
release: () => {
|
||||
released += 1;
|
||||
},
|
||||
pulse: () => {
|
||||
pulsed += 1;
|
||||
},
|
||||
},
|
||||
root,
|
||||
view,
|
||||
);
|
||||
|
||||
root.dispatch("mousedown", eventOn("mousedown", el), "capture");
|
||||
root.dispatch("mouseup", eventOn("mouseup", el), "bubble");
|
||||
root.dispatch("contextmenu", eventOn("contextmenu", el), "capture");
|
||||
root.dispatch("touchcancel", eventOn("touchcancel", el), "bubble");
|
||||
view.dispatch("blur", eventOn("blur", el), "bubble");
|
||||
assert.equal(marked, 1);
|
||||
assert.equal(released, 3);
|
||||
assert.equal(pulsed, 1);
|
||||
|
||||
unsubscribe();
|
||||
root.dispatch("mousedown", eventOn("mousedown", el), "capture");
|
||||
root.dispatch("mouseup", eventOn("mouseup", el), "bubble");
|
||||
root.dispatch("touchcancel", eventOn("touchcancel", el), "bubble");
|
||||
view.dispatch("blur", eventOn("blur", el), "bubble");
|
||||
assert.equal(marked, 1);
|
||||
assert.equal(released, 3);
|
||||
assert.equal(pulsed, 1);
|
||||
});
|
||||
|
||||
test("late contextmenu after mouseup is a one-shot and then releases", () => {
|
||||
const scheduled: Array<() => void> = [];
|
||||
const tracker = createCopyOnSelectUserGestureTracker({
|
||||
setTimeoutFn: ((cb: () => void) => {
|
||||
scheduled.push(cb);
|
||||
return scheduled.length as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout,
|
||||
clearTimeoutFn: (() => {}) as typeof clearTimeout,
|
||||
});
|
||||
|
||||
tracker.mark();
|
||||
tracker.release();
|
||||
// Windows: contextmenu after mouseup used to cancel this timer and stick.
|
||||
tracker.pulse();
|
||||
assert.equal(tracker.isActive(), true);
|
||||
scheduled.at(-1)?.();
|
||||
assert.equal(tracker.isActive(), false);
|
||||
|
||||
tracker.dispose();
|
||||
});
|
||||
|
||||
test("document-capture contextmenu still counts when the terminal never sees the bubble", () => {
|
||||
const el = createEventTargetStub();
|
||||
const outside = createEventTargetStub();
|
||||
const root = createEventTargetStub();
|
||||
let pulsed = 0;
|
||||
subscribeCopyOnSelectUserGesture(
|
||||
{ element: el },
|
||||
{
|
||||
mark: () => {},
|
||||
release: () => {},
|
||||
pulse: () => {
|
||||
pulsed += 1;
|
||||
},
|
||||
},
|
||||
root,
|
||||
);
|
||||
|
||||
// tmux/vim capture handler on the container stops the event before
|
||||
// term.element bubble listeners would run.
|
||||
root.dispatch("contextmenu", eventOn("contextmenu", el), "capture");
|
||||
root.dispatch("contextmenu", eventOn("contextmenu", outside), "capture");
|
||||
assert.equal(pulsed, 1);
|
||||
});
|
||||
|
||||
test("user-invoked Select All pulses only the selected terminal", () => {
|
||||
let pulsedA = 0;
|
||||
let pulsedB = 0;
|
||||
const terminalA = { id: "a" };
|
||||
const terminalB = { id: "b" };
|
||||
const unsubscribeA = subscribeCopyOnSelectUserCommand(terminalA, () => {
|
||||
pulsedA += 1;
|
||||
});
|
||||
const unsubscribeB = subscribeCopyOnSelectUserCommand(terminalB, () => {
|
||||
pulsedB += 1;
|
||||
});
|
||||
|
||||
pulseCopyOnSelectUserCommand(terminalA);
|
||||
assert.equal(pulsedA, 1);
|
||||
assert.equal(pulsedB, 0);
|
||||
|
||||
unsubscribeA();
|
||||
unsubscribeB();
|
||||
pulseCopyOnSelectUserCommand(terminalA);
|
||||
assert.equal(pulsedA, 1);
|
||||
assert.equal(pulsedB, 0);
|
||||
});
|
||||
|
||||
test("issue 3007: search match then later revival does not copy", () => {
|
||||
const scheduled: Array<() => void> = [];
|
||||
const tracker = createCopyOnSelectUserGestureTracker({
|
||||
setTimeoutFn: ((cb: () => void) => {
|
||||
scheduled.push(cb);
|
||||
return scheduled.length as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout,
|
||||
clearTimeoutFn: (() => {}) as typeof clearTimeout,
|
||||
});
|
||||
|
||||
// Typing in the search bar selects the match with no terminal pointer.
|
||||
assert.equal(shouldWriteCopyOnSelect({
|
||||
hasText: true,
|
||||
copyOnSelect: true,
|
||||
isRestoringSelection: false,
|
||||
isUserSelection: tracker.isActive(),
|
||||
}), false);
|
||||
|
||||
// User drag-selects a docker image id in the buffer.
|
||||
tracker.mark();
|
||||
assert.equal(shouldWriteCopyOnSelect({
|
||||
hasText: true,
|
||||
copyOnSelect: true,
|
||||
isRestoringSelection: false,
|
||||
isUserSelection: tracker.isActive(),
|
||||
}), true);
|
||||
tracker.release();
|
||||
scheduled.at(-1)?.();
|
||||
|
||||
// Opening the snippet dialog resizes the terminal; SearchAddon re-selects
|
||||
// the search term. Clipboard must stay on the image id.
|
||||
assert.equal(shouldWriteCopyOnSelect({
|
||||
hasText: true,
|
||||
copyOnSelect: true,
|
||||
isRestoringSelection: false,
|
||||
isUserSelection: tracker.isActive(),
|
||||
}), false);
|
||||
|
||||
tracker.dispose();
|
||||
});
|
||||
168
components/terminal/copyOnSelect.ts
Normal file
168
components/terminal/copyOnSelect.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Copy-on-select policy for the xterm selection overlay.
|
||||
*
|
||||
* SearchAddon (and other programmatic paths) call terminal.select() to mark
|
||||
* the active match. Those selection-change events must not write the
|
||||
* clipboard — otherwise a later user selection is overwritten by the search
|
||||
* term after a resize/write revival (issue #3007).
|
||||
*/
|
||||
|
||||
export const COPY_ON_SELECT_USER_GESTURE_RELEASE_MS = 80;
|
||||
|
||||
export type CopyOnSelectUserGestureTracker = {
|
||||
mark: () => void;
|
||||
release: () => void;
|
||||
/** Mark then release — one-shot gestures such as a late contextmenu. */
|
||||
pulse: () => void;
|
||||
isActive: () => boolean;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
export const createCopyOnSelectUserGestureTracker = ({
|
||||
releaseDelayMs = COPY_ON_SELECT_USER_GESTURE_RELEASE_MS,
|
||||
setTimeoutFn = setTimeout,
|
||||
clearTimeoutFn = clearTimeout,
|
||||
}: {
|
||||
releaseDelayMs?: number;
|
||||
setTimeoutFn?: typeof setTimeout;
|
||||
clearTimeoutFn?: typeof clearTimeout;
|
||||
} = {}): CopyOnSelectUserGestureTracker => {
|
||||
let active = false;
|
||||
let releaseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearReleaseTimer = () => {
|
||||
if (releaseTimer === null) return;
|
||||
clearTimeoutFn(releaseTimer);
|
||||
releaseTimer = null;
|
||||
};
|
||||
|
||||
const mark = () => {
|
||||
clearReleaseTimer();
|
||||
active = true;
|
||||
};
|
||||
|
||||
const release = () => {
|
||||
clearReleaseTimer();
|
||||
releaseTimer = setTimeoutFn(() => {
|
||||
releaseTimer = null;
|
||||
active = false;
|
||||
}, releaseDelayMs);
|
||||
};
|
||||
|
||||
const pulse = () => {
|
||||
mark();
|
||||
release();
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
clearReleaseTimer();
|
||||
active = false;
|
||||
};
|
||||
|
||||
return {
|
||||
mark,
|
||||
release,
|
||||
pulse,
|
||||
isActive: () => active,
|
||||
dispose,
|
||||
};
|
||||
};
|
||||
|
||||
const CAPTURE = { capture: true } as const;
|
||||
|
||||
const eventIsInsideTerminal = (
|
||||
event: Event,
|
||||
el: EventTarget,
|
||||
): boolean => {
|
||||
const target = event.target;
|
||||
if (!target) return false;
|
||||
if (target === el) return true;
|
||||
const host = el as { contains?: (node: EventTarget) => boolean };
|
||||
return typeof host.contains === "function" && host.contains(target);
|
||||
};
|
||||
|
||||
export const subscribeCopyOnSelectUserGesture = (
|
||||
term: { element?: EventTarget | null } | null | undefined,
|
||||
tracker: Pick<CopyOnSelectUserGestureTracker, "mark" | "release" | "pulse">,
|
||||
root: Pick<EventTarget, "addEventListener" | "removeEventListener"> | null = (
|
||||
typeof document === "undefined" ? null : document
|
||||
),
|
||||
view: Pick<EventTarget, "addEventListener" | "removeEventListener"> | null = (
|
||||
typeof window === "undefined" ? null : window
|
||||
),
|
||||
): (() => void) => {
|
||||
const el = term?.element;
|
||||
if (!el || !root) return () => {};
|
||||
|
||||
const onPointerDown = (event: Event) => {
|
||||
if (!eventIsInsideTerminal(event, el)) return;
|
||||
tracker.mark();
|
||||
};
|
||||
const onContextMenu = (event: Event) => {
|
||||
if (!eventIsInsideTerminal(event, el)) return;
|
||||
// Capture on the document so we still see right-clicks that
|
||||
// useTerminalEffects intercepts with stopImmediatePropagation
|
||||
// (tmux/vim mouse tracking + select-word). Pulse so a late
|
||||
// contextmenu after mouseup cannot leave the tracker armed.
|
||||
tracker.pulse();
|
||||
};
|
||||
const onPointerUp = () => tracker.release();
|
||||
|
||||
root.addEventListener("mousedown", onPointerDown, CAPTURE);
|
||||
root.addEventListener("touchstart", onPointerDown, CAPTURE);
|
||||
root.addEventListener("contextmenu", onContextMenu, CAPTURE);
|
||||
root.addEventListener("mouseup", onPointerUp);
|
||||
root.addEventListener("touchend", onPointerUp);
|
||||
root.addEventListener("touchcancel", onPointerUp);
|
||||
// Alt-tab / window blur drops the matching mouseup in Electron.
|
||||
view?.addEventListener("blur", onPointerUp);
|
||||
|
||||
return () => {
|
||||
root.removeEventListener("mousedown", onPointerDown, CAPTURE);
|
||||
root.removeEventListener("touchstart", onPointerDown, CAPTURE);
|
||||
root.removeEventListener("contextmenu", onContextMenu, CAPTURE);
|
||||
root.removeEventListener("mouseup", onPointerUp);
|
||||
root.removeEventListener("touchend", onPointerUp);
|
||||
root.removeEventListener("touchcancel", onPointerUp);
|
||||
view?.removeEventListener("blur", onPointerUp);
|
||||
};
|
||||
};
|
||||
|
||||
const userCommandPulses = new Map<unknown, () => void>();
|
||||
|
||||
/** Select All / Select Word from a shortcut or menu — not SearchAddon. */
|
||||
export const subscribeCopyOnSelectUserCommand = (
|
||||
key: unknown,
|
||||
pulse: () => void,
|
||||
): (() => void) => {
|
||||
userCommandPulses.set(key, pulse);
|
||||
return () => {
|
||||
if (userCommandPulses.get(key) === pulse) {
|
||||
userCommandPulses.delete(key);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const pulseCopyOnSelectUserCommand = (key: unknown): void => {
|
||||
userCommandPulses.get(key)?.();
|
||||
};
|
||||
|
||||
export const shouldWriteCopyOnSelect = ({
|
||||
allowCopy = true,
|
||||
hasText,
|
||||
copyOnSelect,
|
||||
isRestoringSelection,
|
||||
isUserSelection,
|
||||
}: {
|
||||
allowCopy?: boolean;
|
||||
hasText: boolean;
|
||||
copyOnSelect: boolean;
|
||||
isRestoringSelection: boolean;
|
||||
isUserSelection: boolean;
|
||||
}): boolean => (
|
||||
allowCopy
|
||||
&& hasText
|
||||
&& copyOnSelect
|
||||
&& !isRestoringSelection
|
||||
&& isUserSelection
|
||||
);
|
||||
126
components/terminal/extractRootPaths.test.ts
Normal file
126
components/terminal/extractRootPaths.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { extractRootPathsFromDropEntries } from './terminalHelpers.ts';
|
||||
|
||||
// Inline copy of extractRootPathsFromClipboardFiles for standalone test
|
||||
function extractRootPathsFromClipboardFiles(
|
||||
files: Array<{ path: string; name: string; isDirectory: boolean; size?: number }>,
|
||||
): string[] {
|
||||
const paths: string[] = [];
|
||||
const seenPaths = new Set<string>();
|
||||
for (const file of files) {
|
||||
const fullPath = file.path;
|
||||
if (!fullPath || seenPaths.has(fullPath)) continue;
|
||||
paths.push(fullPath.includes(' ') ? `"${fullPath}"` : fullPath);
|
||||
seenPaths.add(fullPath);
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
describe('extractRootPathsFromClipboardFiles', () => {
|
||||
it('single file path', () => {
|
||||
assert.deepEqual(
|
||||
extractRootPathsFromClipboardFiles([{
|
||||
path: '/home/user/file.txt', name: 'file.txt', isDirectory: false, size: 100,
|
||||
}]),
|
||||
['/home/user/file.txt'],
|
||||
);
|
||||
});
|
||||
|
||||
it('multiple files', () => {
|
||||
assert.deepEqual(
|
||||
extractRootPathsFromClipboardFiles([
|
||||
{ path: '/home/a.txt', name: 'a.txt', isDirectory: false, size: 10 },
|
||||
{ path: '/home/b.txt', name: 'b.txt', isDirectory: false, size: 20 },
|
||||
]),
|
||||
['/home/a.txt', '/home/b.txt'],
|
||||
);
|
||||
});
|
||||
|
||||
it('quotes paths with spaces', () => {
|
||||
assert.deepEqual(
|
||||
extractRootPathsFromClipboardFiles([{
|
||||
path: '/home/user/my file.txt', name: 'my file.txt', isDirectory: false, size: 100,
|
||||
}]),
|
||||
['"/home/user/my file.txt"'],
|
||||
);
|
||||
});
|
||||
|
||||
it('deduplicates', () => {
|
||||
assert.deepEqual(
|
||||
extractRootPathsFromClipboardFiles([
|
||||
{ path: '/home/file.txt', name: 'file.txt', isDirectory: false, size: 10 },
|
||||
{ path: '/home/file.txt', name: 'file.txt', isDirectory: false, size: 10 },
|
||||
]),
|
||||
['/home/file.txt'],
|
||||
);
|
||||
});
|
||||
|
||||
it('handles directory entries', () => {
|
||||
assert.deepEqual(
|
||||
extractRootPathsFromClipboardFiles([{
|
||||
path: '/home/myfolder', name: 'myfolder', isDirectory: true, size: 0,
|
||||
}]),
|
||||
['/home/myfolder'],
|
||||
);
|
||||
});
|
||||
|
||||
it('filters out empty paths', () => {
|
||||
assert.deepEqual(
|
||||
extractRootPathsFromClipboardFiles([{
|
||||
path: '', name: 'empty', isDirectory: false,
|
||||
}]),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('Windows-style paths', () => {
|
||||
assert.deepEqual(
|
||||
extractRootPathsFromClipboardFiles([{
|
||||
path: 'C:\\Users\\test\\file.txt', name: 'file.txt', isDirectory: false, size: 100,
|
||||
}]),
|
||||
['C:\\Users\\test\\file.txt'],
|
||||
);
|
||||
});
|
||||
|
||||
it('empty list', () => {
|
||||
assert.deepEqual(extractRootPathsFromClipboardFiles([]), []);
|
||||
});
|
||||
|
||||
it('multiple spaced paths', () => {
|
||||
assert.deepEqual(
|
||||
extractRootPathsFromClipboardFiles([
|
||||
{ path: '/home/user/a b.txt', name: 'a b.txt', isDirectory: false, size: 10 },
|
||||
{ path: '/home/user/c d.txt', name: 'c d.txt', isDirectory: false, size: 20 },
|
||||
]),
|
||||
['"/home/user/a b.txt"', '"/home/user/c d.txt"'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractRootPathsFromDropEntries', () => {
|
||||
it('uses a reconstructed DropEntry local path when File.path is unavailable', () => {
|
||||
const fileWithoutPath = { name: 'child.txt' } as File;
|
||||
assert.deepEqual(
|
||||
extractRootPathsFromDropEntries([{
|
||||
file: fileWithoutPath,
|
||||
localPath: '/home/user/folder/child.txt',
|
||||
relativePath: 'folder/child.txt',
|
||||
isDirectory: false,
|
||||
}]),
|
||||
['/home/user/folder'],
|
||||
);
|
||||
});
|
||||
|
||||
it('uses native path-only entries from a folder scan', () => {
|
||||
assert.deepEqual(
|
||||
extractRootPathsFromDropEntries([{
|
||||
file: null,
|
||||
localPath: '/home/user/folder/src/main.ts',
|
||||
relativePath: 'folder/src/main.ts',
|
||||
isDirectory: false,
|
||||
}]),
|
||||
['/home/user/folder'],
|
||||
);
|
||||
});
|
||||
});
|
||||
14
components/terminal/figSpecLoader.test.ts
Normal file
14
components/terminal/figSpecLoader.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { COMMON_FIG_SPECS, normalizeCommandName } from "./autocomplete/figSpecLoader";
|
||||
|
||||
test("preload common specs includes dnf alongside yum and apt", () => {
|
||||
assert.ok(COMMON_FIG_SPECS.includes("apt"));
|
||||
assert.ok(COMMON_FIG_SPECS.includes("yum"));
|
||||
assert.ok(COMMON_FIG_SPECS.includes("dnf"));
|
||||
});
|
||||
|
||||
test("normalizeCommandName strips path and extension", () => {
|
||||
assert.equal(normalizeCommandName("/usr/bin/dnf"), "dnf");
|
||||
assert.equal(normalizeCommandName("DNF"), "dnf");
|
||||
});
|
||||
172
components/terminal/focusTerminalSession.test.ts
Normal file
172
components/terminal/focusTerminalSession.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { focusTerminalSessionInput, hasOpenAppDialog } from "./focusTerminalSession";
|
||||
|
||||
test("focusTerminalSessionInput focuses the xterm helper textarea immediately and after scheduled retries", () => {
|
||||
const focusCalls: string[] = [];
|
||||
const textarea = {
|
||||
focus: () => focusCalls.push("focus"),
|
||||
};
|
||||
const pane = {
|
||||
querySelector: (selector: string) => {
|
||||
assert.equal(selector, "textarea.xterm-helper-textarea");
|
||||
return textarea;
|
||||
},
|
||||
};
|
||||
const queriedSelectors: string[] = [];
|
||||
const doc = {
|
||||
querySelector: (selector: string) => {
|
||||
queriedSelectors.push(selector);
|
||||
if (selector === '[role="dialog"][data-state="open"]') return null;
|
||||
return pane;
|
||||
},
|
||||
};
|
||||
const timeouts: number[] = [];
|
||||
|
||||
focusTerminalSessionInput("session-1", {
|
||||
document: doc,
|
||||
requestAnimationFrame: (callback) => {
|
||||
callback();
|
||||
return 1;
|
||||
},
|
||||
setTimeout: (callback, delay) => {
|
||||
timeouts.push(delay);
|
||||
callback();
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(queriedSelectors, [
|
||||
'[role="dialog"][data-state="open"]',
|
||||
'[data-session-id="session-1"]',
|
||||
'[role="dialog"][data-state="open"]',
|
||||
'[data-session-id="session-1"]',
|
||||
]);
|
||||
assert.deepEqual(timeouts, [50]);
|
||||
assert.deepEqual(focusCalls, ["focus", "focus"]);
|
||||
});
|
||||
|
||||
test("hasOpenAppDialog detects open Radix dialogs", () => {
|
||||
assert.equal(hasOpenAppDialog(null), false);
|
||||
assert.equal(hasOpenAppDialog({ querySelector: () => null }), false);
|
||||
assert.equal(
|
||||
hasOpenAppDialog({
|
||||
querySelector: (selector) => (
|
||||
selector === '[role="dialog"][data-state="open"]' ? {} : null
|
||||
),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("focusTerminalSessionInput skips textarea focus while an app dialog is open", () => {
|
||||
const focusCalls: string[] = [];
|
||||
const events: string[] = [];
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
dispatchEvent: (event: Event) => {
|
||||
events.push((event as CustomEvent<{ sessionId: string }>).detail.sessionId);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
focusTerminalSessionInput("session-1", {
|
||||
document: {
|
||||
querySelector: (selector) => {
|
||||
if (selector === '[role="dialog"][data-state="open"]') return {};
|
||||
if (selector === '[data-session-id="session-1"]') {
|
||||
return {
|
||||
querySelector: () => ({
|
||||
focus: () => focusCalls.push("focus"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
requestAnimationFrame: (callback) => {
|
||||
callback();
|
||||
return 1;
|
||||
},
|
||||
setTimeout: (callback) => {
|
||||
callback();
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(focusCalls, []);
|
||||
assert.deepEqual(events, ["session-1", "session-1"]);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("focusTerminalSessionInput dispatches a terminal restore focus event", () => {
|
||||
const events: string[] = [];
|
||||
const handler = (event: Event) => {
|
||||
events.push((event as CustomEvent<{ sessionId: string }>).detail.sessionId);
|
||||
};
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: {
|
||||
dispatchEvent: (event: Event) => {
|
||||
handler(event);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
focusTerminalSessionInput("session-1", {
|
||||
document: {
|
||||
querySelector: (selector) => {
|
||||
if (selector === '[role="dialog"][data-state="open"]') return null;
|
||||
return {
|
||||
querySelector: () => ({ focus: () => undefined }),
|
||||
};
|
||||
},
|
||||
},
|
||||
requestAnimationFrame: (callback) => {
|
||||
callback();
|
||||
return 1;
|
||||
},
|
||||
setTimeout: (callback) => {
|
||||
callback();
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(events, ["session-1", "session-1"]);
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: originalWindow,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("focusTerminalSessionInput ignores empty or unavailable targets", () => {
|
||||
assert.doesNotThrow(() => {
|
||||
focusTerminalSessionInput(null, {
|
||||
document: undefined,
|
||||
requestAnimationFrame: (callback) => {
|
||||
callback();
|
||||
return 1;
|
||||
},
|
||||
setTimeout: (callback, delay) => {
|
||||
callback();
|
||||
return delay;
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
89
components/terminal/focusTerminalSession.ts
Normal file
89
components/terminal/focusTerminalSession.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
type QueryRoot = {
|
||||
querySelector: (selector: string) => unknown | null;
|
||||
};
|
||||
|
||||
type QueryTarget = QueryRoot & {
|
||||
querySelector: (selector: string) => QueryTarget | FocusableTarget | null;
|
||||
};
|
||||
|
||||
type FocusableTarget = {
|
||||
focus?: () => void;
|
||||
};
|
||||
|
||||
/** Skip terminal refocus while a Radix dialog is open so deferred tmux actions do not steal modal focus. */
|
||||
export const hasOpenAppDialog = (
|
||||
doc: QueryRoot | null = typeof document !== "undefined" ? document : null,
|
||||
): boolean => {
|
||||
if (!doc) return false;
|
||||
return doc.querySelector('[role="dialog"][data-state="open"]') !== null;
|
||||
};
|
||||
|
||||
interface FocusTerminalSessionInputOptions {
|
||||
document?: QueryTarget | null;
|
||||
requestAnimationFrame?: (callback: () => void) => unknown;
|
||||
setTimeout?: (callback: () => void, delay: number) => unknown;
|
||||
retryDelays?: readonly number[];
|
||||
}
|
||||
|
||||
const escapeAttributeValue = (value: string): string =>
|
||||
value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
|
||||
export const TERMINAL_SESSION_RESTORE_FOCUS_EVENT = "netcatty:terminal-session-restore-focus";
|
||||
|
||||
export type TerminalSessionRestoreFocusDetail = {
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
const dispatchTerminalSessionRestoreFocus = (sessionId: string): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(new CustomEvent<TerminalSessionRestoreFocusDetail>(
|
||||
TERMINAL_SESSION_RESTORE_FOCUS_EVENT,
|
||||
{ detail: { sessionId } },
|
||||
));
|
||||
};
|
||||
|
||||
export const focusTerminalSessionInput = (
|
||||
sessionId: string | null | undefined,
|
||||
options: FocusTerminalSessionInputOptions = {},
|
||||
): void => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const doc = options.document ?? (typeof document !== "undefined" ? document : null);
|
||||
if (!doc) return;
|
||||
|
||||
const raf = options.requestAnimationFrame
|
||||
?? (typeof requestAnimationFrame !== "undefined"
|
||||
? requestAnimationFrame
|
||||
: (callback: () => void) => {
|
||||
callback();
|
||||
return undefined;
|
||||
});
|
||||
const scheduleTimeout = options.setTimeout
|
||||
?? (typeof setTimeout !== "undefined"
|
||||
? setTimeout
|
||||
: (callback: () => void) => {
|
||||
callback();
|
||||
return undefined;
|
||||
});
|
||||
const retryDelays = options.retryDelays ?? [50];
|
||||
const paneSelector = `[data-session-id="${escapeAttributeValue(sessionId)}"]`;
|
||||
|
||||
const focusTarget = () => {
|
||||
if (hasOpenAppDialog(doc)) {
|
||||
dispatchTerminalSessionRestoreFocus(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const pane = doc.querySelector(paneSelector) as QueryTarget | null;
|
||||
const textarea = pane?.querySelector("textarea.xterm-helper-textarea") as FocusableTarget | null;
|
||||
textarea?.focus?.();
|
||||
dispatchTerminalSessionRestoreFocus(sessionId);
|
||||
};
|
||||
|
||||
raf(() => {
|
||||
focusTarget();
|
||||
retryDelays.forEach((delay) => {
|
||||
scheduleTimeout(focusTarget, delay);
|
||||
});
|
||||
});
|
||||
};
|
||||
45
components/terminal/ghostTextConsistency.test.ts
Normal file
45
components/terminal/ghostTextConsistency.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { lineHasUntrackedTrailingInput } from "./autocomplete/ghostTextConsistency.ts";
|
||||
|
||||
test("keeps the ghost when the line matches the tracked input (in sync)", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("network int", "ecOS# network int"), false);
|
||||
});
|
||||
|
||||
test("hides the ghost when the device echoed untracked trailing input (#1013)", () => {
|
||||
// Tracked is one char behind what the device actually shows.
|
||||
assert.equal(lineHasUntrackedTrailingInput("network in", "ecOS# network int"), true);
|
||||
});
|
||||
|
||||
test("keeps the ghost during echo latency (line is behind the tracked input)", () => {
|
||||
// The tracked input hasn't been fully echoed yet — reality being behind
|
||||
// never corrupts, so the ghost must stay.
|
||||
assert.equal(lineHasUntrackedTrailingInput("network int", "ecOS# network in"), false);
|
||||
});
|
||||
|
||||
test("ignores trailing whitespace after the tracked input", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("git", "$ git "), false);
|
||||
});
|
||||
|
||||
test("hides when untracked non-space input follows the tracked input", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("git", "$ git push"), true);
|
||||
});
|
||||
|
||||
test("uses the last occurrence so a repeated token earlier on the line is ignored", () => {
|
||||
// Prompt contains 'int'; the real typed 'int' is the one at the end.
|
||||
assert.equal(lineHasUntrackedTrailingInput("int", "user@int-host:~$ int"), false);
|
||||
assert.equal(lineHasUntrackedTrailingInput("int", "user@int-host:~$ intf"), true);
|
||||
});
|
||||
|
||||
test("skips non-ASCII input (wide-char column mapping is ambiguous)", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("网络", "$ 网络口"), false);
|
||||
});
|
||||
|
||||
test("skips single-character input", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("l", "$ lx"), false);
|
||||
});
|
||||
|
||||
test("returns false when the tracked input isn't on the line yet (latency)", () => {
|
||||
assert.equal(lineHasUntrackedTrailingInput("systemctl", "$ sys"), false);
|
||||
});
|
||||
369
components/terminal/hibernateWakeChunking.test.ts
Normal file
369
components/terminal/hibernateWakeChunking.test.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import xterm from "@xterm/xterm";
|
||||
import serializeMod from "@xterm/addon-serialize";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
|
||||
import {
|
||||
applyHibernateWakeToTerminal,
|
||||
resolveHibernateWakeHistory,
|
||||
} from "./terminalHibernateRuntime.ts";
|
||||
import { writeTerminalPayloadChunked } from "./terminalReplay.ts";
|
||||
|
||||
const { Terminal } = xterm;
|
||||
const { SerializeAddon } = serializeMod;
|
||||
|
||||
const readActiveBufferText = (term: XTerm): string => {
|
||||
const buffer = term.buffer.active;
|
||||
const lines: string[] = [];
|
||||
for (let index = 0; index < buffer.length; index += 1) {
|
||||
lines.push(buffer.getLine(index)?.translateToString(true) ?? "");
|
||||
}
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const writeAndWait = (term: XTerm, data: string): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
term.write(data, () => resolve());
|
||||
});
|
||||
|
||||
test("hibernate wake pauses flow before replay and resumes only after reattach", () => {
|
||||
// #2762 / Codex: full-history wake must not race the capped pending buffer or
|
||||
// the 64 KiB preload backlog. Pause+wait drains into pending, then stop the
|
||||
// data listener, take pending once, replay, reattach, and only then resume.
|
||||
const mountSource = readFileSync(new URL("./terminalRuntimeMount.ts", import.meta.url), "utf8");
|
||||
const terminalSource = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(mountSource, /prepareWakeFlow\?:\s*\(\) => Promise<boolean>/);
|
||||
assert.match(mountSource, /restoreAfterFailedWake\?:\s*\(takenPending: string\) => void/);
|
||||
assert.match(mountSource, /resumeAfterReattach\?:\s*\(\) => void/);
|
||||
assert.match(mountSource, /takePendingBuffer: \(\) => string/);
|
||||
assert.match(mountSource, /stopHibernateDataListener: \(\) => void/);
|
||||
assert.match(mountSource, /const drainOk = \(await prepareWakeFlow\?\.\(\)\) \?\? true;/);
|
||||
assert.match(mountSource, /const takeAndTrackPending = \(\): string =>/);
|
||||
assert.match(mountSource, /const pendingAtApplyStart = takeAndTrackPending\(\);/);
|
||||
assert.match(mountSource, /const pendingTail = takeAndTrackPending\(\);/);
|
||||
assert.match(mountSource, /if \(!pendingTail\) break;/);
|
||||
assert.match(mountSource, /restoreAfterFailedWake\?\.\(takenPendingForRestore\)/);
|
||||
assert.doesNotMatch(mountSource, /pending\.slice\(replayedPendingLength\)/);
|
||||
assert.doesNotMatch(mountSource, /for \(let drainPass = 0;/);
|
||||
assert.doesNotMatch(mountSource, /finalPendingDelta/);
|
||||
assert.doesNotMatch(mountSource, /preTeardownTail/);
|
||||
|
||||
const prepareIndex = mountSource.indexOf("const drainOk = (await prepareWakeFlow?.()) ?? true;");
|
||||
const disableCapIndex = mountSource.indexOf("setHibernatePendingCapDisabled?.(true);");
|
||||
const stopDataBeforeReplay = mountSource.indexOf("if (drainOk) {\n stopHibernateDataListener();");
|
||||
const pendingIndex = mountSource.indexOf("const pendingAtApplyStart = takeAndTrackPending();");
|
||||
const applyIndex = mountSource.indexOf("await applyHibernateWakeToTerminal(");
|
||||
const stopDataBeforeTail = mountSource.indexOf(
|
||||
"stopHibernateDataListener();",
|
||||
applyIndex,
|
||||
);
|
||||
const pendingTailIndex = mountSource.indexOf("const pendingTail = takeAndTrackPending();");
|
||||
const emptyBreakIndex = mountSource.indexOf("if (!pendingTail) break;");
|
||||
const shouldReattachIndex = mountSource.indexOf(
|
||||
"const shouldReattach = sessionConnected && (getSessionConnected?.() ?? true);",
|
||||
);
|
||||
const stopAllIndex = mountSource.indexOf("stopHibernateListeners();", shouldReattachIndex);
|
||||
const reattachIndex = mountSource.indexOf("reattachSession(term);", shouldReattachIndex);
|
||||
const failedRestoreIndex = mountSource.indexOf("restoreAfterFailedWake?.(takenPendingForRestore);");
|
||||
const resumeIndex = mountSource.indexOf("resumeAfterReattach?.();");
|
||||
|
||||
assert.ok(prepareIndex >= 0, "wake must pause backend flow before history replay");
|
||||
assert.ok(
|
||||
disableCapIndex > prepareIndex,
|
||||
"drain failure must disable the pending cap before keeping the live listener",
|
||||
);
|
||||
assert.ok(stopDataBeforeReplay > prepareIndex, "successful drain stops the data listener before replay");
|
||||
assert.ok(pendingIndex > stopDataBeforeReplay, "pending take must run after drain handling");
|
||||
assert.ok(applyIndex > pendingIndex, "history replay follows the pending capture");
|
||||
assert.ok(
|
||||
stopDataBeforeTail > applyIndex,
|
||||
"data listener must stop again before residual pending drain",
|
||||
);
|
||||
assert.ok(
|
||||
pendingTailIndex > stopDataBeforeTail,
|
||||
"residual pending drain must run after history replay",
|
||||
);
|
||||
assert.ok(
|
||||
emptyBreakIndex > pendingTailIndex,
|
||||
"residual drain must end with an empty take before teardown",
|
||||
);
|
||||
assert.ok(
|
||||
shouldReattachIndex > emptyBreakIndex,
|
||||
"reattach decision must run after until-empty pending drain",
|
||||
);
|
||||
assert.ok(
|
||||
stopAllIndex > shouldReattachIndex,
|
||||
"full hibernate listener teardown must wait until after the reattach decision",
|
||||
);
|
||||
assert.ok(reattachIndex > stopAllIndex, "reattach runs after hibernate listeners are cleared");
|
||||
assert.ok(failedRestoreIndex >= 0, "failed wakes must restore take-and-cleared pending");
|
||||
assert.ok(resumeIndex > reattachIndex, "flow resume must wait until after reattach");
|
||||
assert.match(
|
||||
mountSource,
|
||||
/if \(!wakeSucceeded\) \{[\s\S]*?restoreAfterFailedWake\?\.\(takenPendingForRestore\);[\s\S]*?\} else if \(didReattach\) \{[\s\S]*?resumeAfterReattach\?\.\(\);/,
|
||||
);
|
||||
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/takePendingBuffer:\s*\(\)\s*=>\s*\{\s*const pending = hibernatePendingBufferRef\.current \+ oscNotificationScannerRef\.current\.flush\(\);\s*hibernatePendingBufferRef\.current = "";\s*return pending;\s*\}/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/setSessionFlowPausedAndWait\(backendId,\s*true\)/,
|
||||
);
|
||||
// Reconnect wakes (sessionConnected=false) must still pause when a backend
|
||||
// session exists; otherwise stopping the hibernate listener drops live output.
|
||||
assert.doesNotMatch(
|
||||
terminalSource,
|
||||
/prepareWakeFlow: async \(\) => \{\s*if \(!options\.sessionConnected\) return true;/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/stopHibernateListeners\(\{\s*keepPaused:\s*true\s*\}\)/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/restoreAfterFailedWake:\s*\(takenPending\)\s*=>\s*\{[\s\S]*?disposeRuntimeOnly\(\);[\s\S]*?beginHibernatedSessionListeners\(backendId\)/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/appendHibernatePendingBuffer\(\s*takenPending \|\| "",\s*pendingStillInRef,\s*\)/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/resumeAfterReattach:\s*\(\)\s*=>\s*\{[\s\S]*?setSessionFlowPaused\?\.\(backendId,\s*false\)/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/hibernatePendingCapDisabledRef\.current\s*\?\s*hibernatePendingBufferRef\.current \+ scanned\.remainder/,
|
||||
);
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/result\?\.success === true/,
|
||||
);
|
||||
});
|
||||
|
||||
test("writeTerminalPayloadChunked splits large buffers (shipped wake helper)", async () => {
|
||||
const writes: string[] = [];
|
||||
const term = {
|
||||
write: (data: string, cb: () => void) => {
|
||||
writes.push(data);
|
||||
cb();
|
||||
},
|
||||
} as unknown as XTerm;
|
||||
|
||||
const payload = "y".repeat(50_000);
|
||||
await writeTerminalPayloadChunked(term, payload, { chunkBytes: 8_192 });
|
||||
assert.ok(writes.length >= 2, `expected multiple chunks, got ${writes.length}`);
|
||||
assert.equal(writes.join(""), payload);
|
||||
});
|
||||
|
||||
test("resolveHibernateWakeHistory prefers the coherent full snapshot", () => {
|
||||
assert.equal(
|
||||
resolveHibernateWakeHistory({
|
||||
snapshot: "FULL",
|
||||
viewportSnapshot: "VIEW",
|
||||
scrollbackSnapshot: "SCROLL",
|
||||
pendingBuffer: "",
|
||||
alternateScreen: false,
|
||||
}),
|
||||
"FULL",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHibernateWakeHistory falls back to scrollback before viewport with a seam newline", () => {
|
||||
assert.equal(
|
||||
resolveHibernateWakeHistory({
|
||||
snapshot: "",
|
||||
viewportSnapshot: "VIEWPORT_END\r\n",
|
||||
scrollbackSnapshot: "SCROLLBACK_START",
|
||||
pendingBuffer: "",
|
||||
alternateScreen: false,
|
||||
}),
|
||||
"SCROLLBACK_START\r\nVIEWPORT_END\r\n",
|
||||
);
|
||||
assert.equal(
|
||||
resolveHibernateWakeHistory({
|
||||
snapshot: "",
|
||||
viewportSnapshot: "VIEWPORT_END\r\n",
|
||||
scrollbackSnapshot: "SCROLLBACK_START\r\n",
|
||||
pendingBuffer: "",
|
||||
alternateScreen: false,
|
||||
}),
|
||||
"SCROLLBACK_START\r\nVIEWPORT_END\r\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("applyHibernateWakeToTerminal replays snapshot then pending without idle append", async () => {
|
||||
const writes: string[] = [];
|
||||
const term = {
|
||||
rows: 24,
|
||||
write: (data: string, cb?: () => void) => {
|
||||
writes.push(data);
|
||||
cb?.();
|
||||
},
|
||||
refresh: () => {},
|
||||
} as unknown as XTerm;
|
||||
|
||||
const runtime = {
|
||||
ensureWebglRenderer: () => {},
|
||||
clearTextureAtlas: () => {},
|
||||
};
|
||||
|
||||
let idleScheduled = false;
|
||||
const originalRic = globalThis.requestIdleCallback;
|
||||
// @ts-expect-error test override
|
||||
globalThis.requestIdleCallback = (cb: () => void) => {
|
||||
idleScheduled = true;
|
||||
setTimeout(cb, 0);
|
||||
return 1;
|
||||
};
|
||||
|
||||
try {
|
||||
const snapshot = "SCROLLBACK_START\r\nVIEWPORT_END\r\n";
|
||||
const pending = "PENDING_TAIL\r\n";
|
||||
await applyHibernateWakeToTerminal(
|
||||
term,
|
||||
runtime as never,
|
||||
{
|
||||
snapshot,
|
||||
viewportSnapshot: "VIEWPORT_END\r\n",
|
||||
scrollbackSnapshot: "SCROLLBACK_START\r\n",
|
||||
pendingBuffer: pending,
|
||||
alternateScreen: false,
|
||||
},
|
||||
{ replayOptions: { chunkBytes: 8_192 } },
|
||||
);
|
||||
|
||||
assert.equal(writes.join(""), `${snapshot}${pending}`);
|
||||
assert.equal(
|
||||
idleScheduled,
|
||||
false,
|
||||
"scrollback must not be deferred to idle after viewport (append would evict the end)",
|
||||
);
|
||||
} finally {
|
||||
if (originalRic) {
|
||||
globalThis.requestIdleCallback = originalRic;
|
||||
} else {
|
||||
// @ts-expect-error cleanup
|
||||
delete globalThis.requestIdleCallback;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("hibernate wake keeps newest rows from a SerializeAddon snapshot under a finite scrollback cap", async () => {
|
||||
// #2762: idle-appending older scrollback after viewport under scrollback=N
|
||||
// evicts the newest rows. Prefer the full SerializeAddon snapshot.
|
||||
const rows = 5;
|
||||
const scrollbackCap = 8;
|
||||
const source = new Terminal({
|
||||
cols: 40,
|
||||
rows,
|
||||
scrollback: 50,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
const serializeAddon = new SerializeAddon();
|
||||
source.loadAddon(serializeAddon);
|
||||
|
||||
const olderLines = Array.from({ length: 20 }, (_, index) => `old-${index}`);
|
||||
const newestLines = Array.from({ length: rows }, (_, index) => `new-${index}`);
|
||||
await writeAndWait(source, `${olderLines.join("\r\n")}\r\n${newestLines.join("\r\n")}\r\n`);
|
||||
|
||||
const snapshot = serializeAddon.serialize();
|
||||
const bufferLength = source.buffer.active.length;
|
||||
const viewportStart = Math.max(0, bufferLength - rows);
|
||||
const scrollbackSnapshot = serializeAddon.serialize({
|
||||
range: { start: Math.max(0, viewportStart - 20), end: viewportStart - 1 },
|
||||
});
|
||||
const viewportSnapshot = serializeAddon.serialize({
|
||||
range: { start: viewportStart, end: bufferLength - 1 },
|
||||
});
|
||||
source.dispose();
|
||||
|
||||
// Range concat is not byte-identical to the full snapshot (missing seam newline).
|
||||
assert.notEqual(scrollbackSnapshot + viewportSnapshot, snapshot);
|
||||
|
||||
const term = new Terminal({
|
||||
cols: 40,
|
||||
rows,
|
||||
scrollback: scrollbackCap,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
const runtime = {
|
||||
ensureWebglRenderer: () => {},
|
||||
clearTextureAtlas: () => {},
|
||||
};
|
||||
|
||||
try {
|
||||
await applyHibernateWakeToTerminal(
|
||||
term,
|
||||
runtime as never,
|
||||
{
|
||||
snapshot,
|
||||
viewportSnapshot,
|
||||
scrollbackSnapshot,
|
||||
pendingBuffer: "",
|
||||
alternateScreen: false,
|
||||
},
|
||||
{ replayOptions: { chunkBytes: 1024 } },
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
|
||||
const text = readActiveBufferText(term);
|
||||
for (const line of newestLines) {
|
||||
assert.match(text, new RegExp(line), `newest viewport line missing after wake: ${line}`);
|
||||
}
|
||||
assert.doesNotMatch(text, /new-0new-1/, "seam must not merge adjacent snapshot lines");
|
||||
} finally {
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("wrong wake order (viewport then scrollback append) drops newest rows under scrollback cap", async () => {
|
||||
// Guardrail: documents why idle-append-after-viewport is unsafe.
|
||||
const rows = 5;
|
||||
const scrollbackCap = 10;
|
||||
const term = new Terminal({
|
||||
cols: 40,
|
||||
rows,
|
||||
scrollback: scrollbackCap,
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const olderLines = Array.from({ length: scrollbackCap + rows }, (_, index) => `old-${index}`);
|
||||
const newestLines = Array.from({ length: rows }, (_, index) => `new-${index}`);
|
||||
await writeAndWait(term, `${newestLines.join("\r\n")}\r\n`);
|
||||
await writeAndWait(term, `${olderLines.join("\r\n")}\r\n`);
|
||||
|
||||
const text = readActiveBufferText(term);
|
||||
for (const line of newestLines) {
|
||||
assert.equal(
|
||||
text.includes(line),
|
||||
false,
|
||||
`viewport-first append must evict newest line under the cap: ${line}`,
|
||||
);
|
||||
}
|
||||
assert.match(text, /old-14/);
|
||||
} finally {
|
||||
term.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test("hibernate runtime source prefers resolveHibernateWakeHistory on the wake path", () => {
|
||||
const source = readFileSync(new URL("./terminalHibernateRuntime.ts", import.meta.url), "utf8");
|
||||
assert.match(source, /export function resolveHibernateWakeHistory/);
|
||||
assert.match(
|
||||
source,
|
||||
/writeTerminalReplaySequence\(\s*term,\s*\[\s*history,\s*payload\.pendingBuffer\s*\]/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/scheduleIdle\(\(\)\s*=>\s*\{\s*void writeTerminalPayloadChunked\(term, scrollback/,
|
||||
);
|
||||
});
|
||||
8
components/terminal/hooks/useServerStats.ts
Normal file
8
components/terminal/hooks/useServerStats.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @deprecated Import from `@/application/state/useServerStats` instead. */
|
||||
export {
|
||||
useServerStats,
|
||||
type DiskInfo,
|
||||
type NetInterfaceInfo,
|
||||
type ProcessInfo,
|
||||
type ServerStats,
|
||||
} from "../../../application/state/useServerStats";
|
||||
186
components/terminal/hooks/useTerminalAuthState.ts
Normal file
186
components/terminal/hooks/useTerminalAuthState.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import type { Host, TerminalSession } from "../../../types";
|
||||
import type { PendingAuth } from "../runtime/createTerminalSessionStarters";
|
||||
import type { TerminalAuthMethod } from "../TerminalAuthDialog";
|
||||
import { logger } from "../../../lib/logger";
|
||||
|
||||
/**
|
||||
* Password auth is valid when the user typed something — including a single
|
||||
* space. SSH passwords may be whitespace-only; do not trim before this check
|
||||
* (issue #2036).
|
||||
*/
|
||||
export const isAuthPasswordProvided = (password: string): boolean =>
|
||||
password.length > 0;
|
||||
|
||||
export const buildSavedAuthHostUpdate = (
|
||||
host: Host,
|
||||
auth: {
|
||||
authMethod: TerminalAuthMethod;
|
||||
username: string;
|
||||
password: string;
|
||||
keyId: string | null;
|
||||
},
|
||||
): Host => ({
|
||||
...host,
|
||||
username: auth.username,
|
||||
authMethod: auth.authMethod,
|
||||
password: auth.authMethod === "password" ? auth.password : undefined,
|
||||
savePassword: auth.authMethod === "password" ? true : host.savePassword,
|
||||
identityFileId:
|
||||
auth.authMethod === "key" || auth.authMethod === "certificate"
|
||||
? (auth.keyId ?? undefined)
|
||||
: undefined,
|
||||
// Detach stale Keychain identity on explicit credential save (#1956):
|
||||
// resolveHostAuth prefers identity credentials over host fields.
|
||||
// Empty string (not undefined) so applyGroupDefaults treats this as an explicit
|
||||
// host-level override and does not re-inherit a group-level identity; consumers
|
||||
// check host.identityId truthiness so "" behaves as "no identity".
|
||||
identityId: "",
|
||||
});
|
||||
|
||||
export const useTerminalAuthState = ({
|
||||
host,
|
||||
pendingAuthRef,
|
||||
termRef,
|
||||
onUpdateHost,
|
||||
onStartSession,
|
||||
setStatus,
|
||||
setProgressLogs,
|
||||
}: {
|
||||
host: Host;
|
||||
pendingAuthRef: RefObject<PendingAuth>;
|
||||
termRef: RefObject<XTerm | null>;
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
onStartSession: (term: XTerm) => void;
|
||||
setStatus: (status: TerminalSession["status"]) => void;
|
||||
setProgressLogs: (next: string[] | ((prev: string[]) => string[])) => void;
|
||||
}) => {
|
||||
const [needsAuth, setNeedsAuth] = useState(false);
|
||||
const [authRetryMessage, setAuthRetryMessage] = useState<string | null>(null);
|
||||
const [authUsername, setAuthUsername] = useState(host.username || "root");
|
||||
const [authMethod, setAuthMethod] = useState<TerminalAuthMethod>("password");
|
||||
const [authPassword, setAuthPassword] = useState("");
|
||||
const [authKeyId, setAuthKeyId] = useState<string | null>(null);
|
||||
const [authPassphrase, setAuthPassphrase] = useState("");
|
||||
const [showAuthPassword, setShowAuthPassword] = useState(false);
|
||||
const [showAuthPassphrase, setShowAuthPassphrase] = useState(false);
|
||||
const [saveCredentials, setSaveCredentials] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setNeedsAuth(false);
|
||||
setAuthRetryMessage(null);
|
||||
setAuthUsername(host.username || "root");
|
||||
setAuthPassword("");
|
||||
setAuthKeyId(null);
|
||||
setAuthPassphrase("");
|
||||
setShowAuthPassword(false);
|
||||
setShowAuthPassphrase(false);
|
||||
setSaveCredentials(true);
|
||||
}, [host.id, host.username]);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
if (!authUsername.trim()) return false;
|
||||
if (authMethod === "password") return isAuthPasswordProvided(authPassword);
|
||||
if (authMethod === "key" || authMethod === "certificate") return !!authKeyId;
|
||||
return false;
|
||||
}, [authKeyId, authMethod, authPassword, authUsername]);
|
||||
|
||||
const resetForRetry = useCallback(() => {
|
||||
setNeedsAuth(false);
|
||||
setAuthRetryMessage(null);
|
||||
pendingAuthRef.current = null;
|
||||
}, [pendingAuthRef]);
|
||||
|
||||
const submit = useCallback(
|
||||
(opts?: { saveToHost?: boolean }) => {
|
||||
if (!isValid) return;
|
||||
|
||||
const shouldSave = opts?.saveToHost ?? saveCredentials;
|
||||
pendingAuthRef.current = {
|
||||
authMethod,
|
||||
username: authUsername,
|
||||
password: authMethod === "password" ? authPassword : undefined,
|
||||
keyId:
|
||||
authMethod === "key" || authMethod === "certificate"
|
||||
? (authKeyId ?? undefined)
|
||||
: undefined,
|
||||
passphrase:
|
||||
authMethod === "key" || authMethod === "certificate"
|
||||
? authPassphrase || undefined
|
||||
: undefined,
|
||||
savedToHost: shouldSave && Boolean(onUpdateHost),
|
||||
};
|
||||
|
||||
if (shouldSave && onUpdateHost) {
|
||||
onUpdateHost(
|
||||
buildSavedAuthHostUpdate(host, {
|
||||
authMethod,
|
||||
username: authUsername,
|
||||
password: authPassword,
|
||||
keyId: authKeyId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setNeedsAuth(false);
|
||||
setAuthRetryMessage(null);
|
||||
setStatus("connecting");
|
||||
setProgressLogs(["Authenticating with provided credentials..."]);
|
||||
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
|
||||
try {
|
||||
term.clear?.();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to clear terminal", err);
|
||||
}
|
||||
|
||||
onStartSession(term);
|
||||
},
|
||||
[
|
||||
authKeyId,
|
||||
authMethod,
|
||||
authPassphrase,
|
||||
authPassword,
|
||||
authUsername,
|
||||
host,
|
||||
isValid,
|
||||
onStartSession,
|
||||
onUpdateHost,
|
||||
pendingAuthRef,
|
||||
saveCredentials,
|
||||
setProgressLogs,
|
||||
setStatus,
|
||||
termRef,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
needsAuth,
|
||||
setNeedsAuth,
|
||||
authRetryMessage,
|
||||
setAuthRetryMessage,
|
||||
authUsername,
|
||||
setAuthUsername,
|
||||
authMethod,
|
||||
setAuthMethod,
|
||||
authPassword,
|
||||
setAuthPassword,
|
||||
authKeyId,
|
||||
setAuthKeyId,
|
||||
authPassphrase,
|
||||
setAuthPassphrase,
|
||||
showAuthPassword,
|
||||
setShowAuthPassword,
|
||||
showAuthPassphrase,
|
||||
setShowAuthPassphrase,
|
||||
saveCredentials,
|
||||
setSaveCredentials,
|
||||
isValid,
|
||||
resetForRetry,
|
||||
submit,
|
||||
};
|
||||
};
|
||||
250
components/terminal/hooks/useTerminalContextActions.ts
Normal file
250
components/terminal/hooks/useTerminalContextActions.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { useCallback } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { pasteTextIntoTerminal } from "../runtime/terminalUserPaste";
|
||||
import { clearTerminalViewportAndSyncPty } from "../clearTerminalViewport";
|
||||
import {
|
||||
handleRemoteClipboardImageUpload,
|
||||
type RemoteClipboardImageUploadResult,
|
||||
} from "../clipboardImagePaste";
|
||||
import { handleTerminalClipboardPaste } from "../terminalClipboardPaste";
|
||||
import { pulseCopyOnSelectUserCommand } from "../copyOnSelect";
|
||||
import { getTerminalSelectionForClipboard } from "../normalizeTerminalSelection";
|
||||
import {
|
||||
getHistoryPreviewSelectionFromRoot,
|
||||
requestHistoryPreviewHide,
|
||||
selectHistoryPreviewAll,
|
||||
findHistoryPreviewOverlay,
|
||||
} from "../runtime/terminalHistoryScrollOverride";
|
||||
|
||||
type BroadcastPasteRefs = {
|
||||
sourceSessionId: string;
|
||||
sessionRef: RefObject<string | null>;
|
||||
isBroadcastEnabledRef?: RefObject<boolean | undefined>;
|
||||
onBroadcastInputRef?: RefObject<((data: string, sourceSessionId: string) => void) | undefined>;
|
||||
passwordPromptActiveRef?: RefObject<boolean | undefined>;
|
||||
};
|
||||
|
||||
export const broadcastTerminalPasteData = (
|
||||
data: string,
|
||||
{
|
||||
sourceSessionId,
|
||||
sessionRef,
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
passwordPromptActiveRef,
|
||||
}: BroadcastPasteRefs,
|
||||
): boolean => {
|
||||
if (
|
||||
passwordPromptActiveRef?.current !== true
|
||||
&& sessionRef.current
|
||||
&& isBroadcastEnabledRef?.current
|
||||
&& onBroadcastInputRef?.current
|
||||
) {
|
||||
onBroadcastInputRef.current(data, sourceSessionId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const useTerminalContextActions = ({
|
||||
termRef,
|
||||
sourceSessionId,
|
||||
sessionRef,
|
||||
onHasSelectionChange,
|
||||
scrollOnPasteRef,
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
passwordPromptActiveRef,
|
||||
isLocalConnection,
|
||||
supportsRemoteImagePaste,
|
||||
autoUploadClipboardImageOnPasteRef,
|
||||
clearWipesScrollbackRef,
|
||||
normalizeTextOnCopyRef,
|
||||
terminalBackend,
|
||||
getRemoteCwd,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
onClipboardImageUploadResult,
|
||||
}: {
|
||||
termRef: RefObject<XTerm | null>;
|
||||
sourceSessionId: string;
|
||||
sessionRef: RefObject<string | null>;
|
||||
onHasSelectionChange?: (hasSelection: boolean) => void;
|
||||
scrollOnPasteRef?: RefObject<boolean>;
|
||||
isBroadcastEnabledRef?: RefObject<boolean | undefined>;
|
||||
onBroadcastInputRef?: RefObject<((data: string, sourceSessionId: string) => void) | undefined>;
|
||||
passwordPromptActiveRef?: RefObject<boolean | undefined>;
|
||||
isLocalConnection: boolean;
|
||||
supportsRemoteImagePaste: boolean;
|
||||
/** When true, paste auto-uploads a clipboard image (remote sessions only). */
|
||||
autoUploadClipboardImageOnPasteRef?: RefObject<boolean | undefined>;
|
||||
clearWipesScrollbackRef?: RefObject<boolean | undefined>;
|
||||
/** When false, copy uses raw getSelection(). Default true when unset. */
|
||||
normalizeTextOnCopyRef?: RefObject<boolean | undefined>;
|
||||
terminalBackend: {
|
||||
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean }) => void;
|
||||
clearSessionPtyBuffer?: (sessionId: string) => void;
|
||||
};
|
||||
getRemoteCwd?: () => Promise<string | null | undefined>;
|
||||
scrollToBottomAfterProgrammaticInput?: (data: string) => void;
|
||||
onClipboardImageUploadResult?: (result: RemoteClipboardImageUploadResult) => void;
|
||||
}) => {
|
||||
const broadcastUserPasteData = useCallback((data: string) => {
|
||||
return broadcastTerminalPasteData(data, {
|
||||
sourceSessionId,
|
||||
sessionRef,
|
||||
isBroadcastEnabledRef,
|
||||
onBroadcastInputRef,
|
||||
passwordPromptActiveRef,
|
||||
});
|
||||
}, [isBroadcastEnabledRef, onBroadcastInputRef, passwordPromptActiveRef, sessionRef, sourceSessionId]);
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const selection = getHistoryPreviewSelectionFromRoot(term.element?.parentElement)
|
||||
|| getTerminalSelectionForClipboard(
|
||||
term,
|
||||
normalizeTextOnCopyRef?.current ?? true,
|
||||
);
|
||||
if (selection) {
|
||||
navigator.clipboard.writeText(selection);
|
||||
}
|
||||
}, [normalizeTextOnCopyRef, termRef]);
|
||||
|
||||
const onPaste = useCallback(async () => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
requestHistoryPreviewHide(term.element?.parentElement);
|
||||
term.focus();
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge,
|
||||
autoUploadClipboardImage:
|
||||
supportsRemoteImagePaste && autoUploadClipboardImageOnPasteRef?.current === true,
|
||||
clipboardImageBridge: bridge ?? undefined,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
isSensitiveInput: () => passwordPromptActiveRef?.current === true,
|
||||
onClipboardImageUploadResult,
|
||||
readClipboardText: () => navigator.clipboard.readText(),
|
||||
scrollOnPaste: scrollOnPasteRef?.current ?? false,
|
||||
onPasteData: broadcastUserPasteData,
|
||||
sessionId: sessionRef.current,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
terminalBackend,
|
||||
term,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("Failed to paste from clipboard", err);
|
||||
}
|
||||
}, [
|
||||
autoUploadClipboardImageOnPasteRef,
|
||||
broadcastUserPasteData,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
onClipboardImageUploadResult,
|
||||
passwordPromptActiveRef,
|
||||
sessionRef,
|
||||
supportsRemoteImagePaste,
|
||||
termRef,
|
||||
scrollOnPasteRef,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
terminalBackend,
|
||||
]);
|
||||
|
||||
const onUploadClipboardImage = useCallback(async () => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
const result = await handleRemoteClipboardImageUpload({
|
||||
bridge,
|
||||
getRemoteCwd: getRemoteCwd ?? (async () => undefined),
|
||||
isSensitiveInput: () => passwordPromptActiveRef?.current === true,
|
||||
sessionId: supportsRemoteImagePaste ? sessionRef.current : null,
|
||||
terminalBackend,
|
||||
term,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
});
|
||||
onClipboardImageUploadResult?.(result);
|
||||
} catch (err) {
|
||||
logger.warn("Failed to upload clipboard image", err);
|
||||
onClipboardImageUploadResult?.({ ok: false, reason: "upload-failed" });
|
||||
}
|
||||
}, [
|
||||
getRemoteCwd,
|
||||
passwordPromptActiveRef,
|
||||
onClipboardImageUploadResult,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
sessionRef,
|
||||
supportsRemoteImagePaste,
|
||||
termRef,
|
||||
terminalBackend,
|
||||
]);
|
||||
|
||||
const onPasteSelection = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const selection = getHistoryPreviewSelectionFromRoot(term.element?.parentElement)
|
||||
|| getTerminalSelectionForClipboard(
|
||||
term,
|
||||
normalizeTextOnCopyRef?.current ?? true,
|
||||
);
|
||||
if (!selection || !sessionRef.current) return;
|
||||
requestHistoryPreviewHide(term.element?.parentElement);
|
||||
term.focus();
|
||||
pasteTextIntoTerminal(term, selection, {
|
||||
scrollOnPaste: scrollOnPasteRef?.current ?? false,
|
||||
onPasteData: broadcastUserPasteData,
|
||||
});
|
||||
}, [broadcastUserPasteData, normalizeTextOnCopyRef, sessionRef, termRef, scrollOnPasteRef]);
|
||||
|
||||
const onSelectAll = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
pulseCopyOnSelectUserCommand(term);
|
||||
const previewOverlay = findHistoryPreviewOverlay(term.element?.parentElement);
|
||||
if (previewOverlay && selectHistoryPreviewAll(previewOverlay)) {
|
||||
onHasSelectionChange?.(true);
|
||||
return;
|
||||
}
|
||||
term.selectAll();
|
||||
onHasSelectionChange?.(true);
|
||||
}, [onHasSelectionChange, termRef]);
|
||||
|
||||
const onClear = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
clearTerminalViewportAndSyncPty(term, {
|
||||
wipeScrollback: clearWipesScrollbackRef?.current ?? true,
|
||||
syncPty: () => {
|
||||
const id = sessionRef.current;
|
||||
if (id) {
|
||||
terminalBackend.clearSessionPtyBuffer?.(id);
|
||||
}
|
||||
},
|
||||
});
|
||||
}, [clearWipesScrollbackRef, sessionRef, termRef, terminalBackend]);
|
||||
|
||||
const onSelectWord = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
pulseCopyOnSelectUserCommand(term);
|
||||
term.selectAll();
|
||||
onHasSelectionChange?.(true);
|
||||
}, [onHasSelectionChange, termRef]);
|
||||
|
||||
return {
|
||||
onCopy,
|
||||
onPaste,
|
||||
onUploadClipboardImage: supportsRemoteImagePaste ? onUploadClipboardImage : undefined,
|
||||
onPasteSelection,
|
||||
onSelectAll,
|
||||
onClear,
|
||||
onSelectWord,
|
||||
};
|
||||
};
|
||||
430
components/terminal/hooks/useTerminalDragDrop.ts
Normal file
430
components/terminal/hooks/useTerminalDragDrop.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type React from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { logger } from "../../../lib/logger";
|
||||
import {
|
||||
buildZmodemDragDropFiles,
|
||||
buildZmodemDragDropUploadCommand,
|
||||
containsZmodemRzMissingMarker,
|
||||
createZmodemRzMissingToken,
|
||||
supportsZmodemDragDropSftpFallback,
|
||||
supportsZmodemTerminalDragDrop,
|
||||
type ZmodemDragDropFile,
|
||||
} from "../../../lib/zmodemDragDrop";
|
||||
import { extractDropEntries, type DropEntry } from "../../../lib/sftpFileUtils";
|
||||
import type { Host, TerminalSession } from "../../../types";
|
||||
import { resolveSftpReuseSourceSessionId } from "../../../application/state/terminalConnectionReuse";
|
||||
import {
|
||||
resolveTerminalDropSftpHost,
|
||||
TerminalDropNeedsSudoError,
|
||||
} from "../../../domain/sftpDropElevation";
|
||||
import { toast } from "../../ui/toast";
|
||||
import {
|
||||
extractRootPathsFromDropEntries,
|
||||
type TerminalProps,
|
||||
} from "../terminalHelpers";
|
||||
|
||||
interface UseTerminalDragDropOptions {
|
||||
host: Host;
|
||||
/** Password already resolved through host auth (host or Keychain identity). */
|
||||
resolvedSudoPassword?: string;
|
||||
/** Login username already resolved through host auth (host or Keychain identity). */
|
||||
resolvedLoginUsername?: string;
|
||||
isLocalConnection: boolean;
|
||||
isNetworkDevice?: boolean;
|
||||
onOpenSftp?: TerminalProps["onOpenSftp"];
|
||||
resolveSftpInitialPath: (options?: {
|
||||
preferFreshBackend?: boolean;
|
||||
requireActiveShellCwd?: boolean;
|
||||
}) => Promise<string | undefined>;
|
||||
scrollToBottomAfterProgrammaticInput: (data: string) => void;
|
||||
sessionId: string;
|
||||
sessionRef: React.MutableRefObject<string | null>;
|
||||
status: TerminalSession["status"];
|
||||
t: (key: string) => string;
|
||||
terminalBackend: {
|
||||
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean; sensitive?: boolean }) => void;
|
||||
cancelZmodem?: (sessionId: string, options?: { interrupt?: boolean }) => void;
|
||||
onSessionData?: (sessionId: string, cb: (chunk: string) => void) => () => void;
|
||||
onZmodemEvent?: (
|
||||
sessionId: string,
|
||||
cb: (event: { type: string; transferType?: string }) => void,
|
||||
) => () => void;
|
||||
startZmodemDragDropUpload?: (
|
||||
sessionId: string,
|
||||
files: ZmodemDragDropFile[],
|
||||
uploadCommand?: string,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
};
|
||||
isSensitiveInput?: () => boolean;
|
||||
rzMissingFallbackTimeoutMs?: number;
|
||||
termRef: React.MutableRefObject<XTerm | null>;
|
||||
}
|
||||
|
||||
// Keep this aligned with the main-process drag-drop start watchdog. Falling
|
||||
// back sooner interrupts valid rz handshakes on slow shells and jump routes.
|
||||
export const DEFAULT_RZ_MISSING_FALLBACK_TIMEOUT_MS = 15_000;
|
||||
|
||||
export class ActiveTerminalCwdUnavailableError extends Error {
|
||||
constructor() {
|
||||
super("Could not determine the active terminal directory");
|
||||
this.name = "ActiveTerminalCwdUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveTerminalDropErrorMessage(
|
||||
error: unknown,
|
||||
t: UseTerminalDragDropOptions["t"],
|
||||
): string {
|
||||
if (error instanceof ActiveTerminalCwdUnavailableError) {
|
||||
return t("terminal.dragDrop.destinationUnknown");
|
||||
}
|
||||
if (error instanceof TerminalDropNeedsSudoError) {
|
||||
return t("terminal.dragDrop.needsSudoElevation");
|
||||
}
|
||||
if (error instanceof Error && error.message === "No files to upload") {
|
||||
return t("terminal.dragDrop.noFiles");
|
||||
}
|
||||
return t("terminal.dragDrop.errorMessage");
|
||||
}
|
||||
|
||||
async function openSftpForTerminalDrop({
|
||||
dropEntries,
|
||||
host,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
sessionId,
|
||||
}: {
|
||||
dropEntries: DropEntry[];
|
||||
host: Host;
|
||||
onOpenSftp: NonNullable<UseTerminalDragDropOptions["onOpenSftp"]>;
|
||||
resolveSftpInitialPath: UseTerminalDragDropOptions["resolveSftpInitialPath"];
|
||||
resolvedLoginUsername?: string;
|
||||
resolvedSudoPassword?: string;
|
||||
sessionId: string;
|
||||
}): Promise<void> {
|
||||
const initialPath = await resolveTerminalDropUploadInitialPath(resolveSftpInitialPath);
|
||||
const uploadHost = resolveTerminalDropSftpHost(host, initialPath, {
|
||||
password: resolvedSudoPassword ?? host.password,
|
||||
username: resolvedLoginUsername ?? host.username,
|
||||
});
|
||||
onOpenSftp(
|
||||
uploadHost,
|
||||
initialPath,
|
||||
dropEntries,
|
||||
sessionId,
|
||||
resolveSftpReuseSourceSessionId(host, sessionId),
|
||||
);
|
||||
}
|
||||
|
||||
export async function resolveTerminalDropUploadInitialPath(
|
||||
resolveSftpInitialPath: UseTerminalDragDropOptions["resolveSftpInitialPath"],
|
||||
): Promise<string | undefined> {
|
||||
const initialPath = await resolveSftpInitialPath({
|
||||
preferFreshBackend: true,
|
||||
requireActiveShellCwd: true,
|
||||
});
|
||||
if (!initialPath) {
|
||||
throw new ActiveTerminalCwdUnavailableError();
|
||||
}
|
||||
return initialPath;
|
||||
}
|
||||
|
||||
function createRzMissingWatcher({
|
||||
sessionId,
|
||||
terminalBackend,
|
||||
token,
|
||||
timeoutMs = DEFAULT_RZ_MISSING_FALLBACK_TIMEOUT_MS,
|
||||
}: {
|
||||
sessionId: string;
|
||||
terminalBackend: Pick<UseTerminalDragDropOptions["terminalBackend"], "onSessionData" | "onZmodemEvent">;
|
||||
token: string;
|
||||
timeoutMs?: number;
|
||||
}): { promise: Promise<"missing" | "detected" | "timeout">; stop: () => void } {
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
let buffer = "";
|
||||
let unsubscribeData: (() => void) | undefined;
|
||||
let unsubscribeZmodem: (() => void) | undefined;
|
||||
let settle: (result: "missing" | "detected" | "timeout") => void = () => {};
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
timeout = undefined;
|
||||
unsubscribeData?.();
|
||||
unsubscribeData = undefined;
|
||||
unsubscribeZmodem?.();
|
||||
unsubscribeZmodem = undefined;
|
||||
};
|
||||
|
||||
const promise = new Promise<"missing" | "detected" | "timeout">((resolve) => {
|
||||
settle = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
unsubscribeData = terminalBackend.onSessionData?.(sessionId, (chunk) => {
|
||||
buffer = `${buffer}${chunk}`.slice(-512);
|
||||
if (containsZmodemRzMissingMarker(buffer, token)) {
|
||||
settle("missing");
|
||||
}
|
||||
});
|
||||
|
||||
unsubscribeZmodem = terminalBackend.onZmodemEvent?.(sessionId, (event) => {
|
||||
if (event.type === "detect" && event.transferType === "upload") {
|
||||
settle("detected");
|
||||
}
|
||||
});
|
||||
|
||||
timeout = setTimeout(() => settle("timeout"), timeoutMs);
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
stop: () => settle("detected"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleTerminalDropEntries({
|
||||
dropEntries,
|
||||
host,
|
||||
isLocalConnection,
|
||||
isNetworkDevice = false,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
sessionId,
|
||||
sessionRef,
|
||||
terminalBackend,
|
||||
isSensitiveInput,
|
||||
rzMissingFallbackTimeoutMs,
|
||||
termRef,
|
||||
}: Pick<
|
||||
UseTerminalDragDropOptions,
|
||||
| "host"
|
||||
| "resolvedLoginUsername"
|
||||
| "resolvedSudoPassword"
|
||||
| "isLocalConnection"
|
||||
| "isNetworkDevice"
|
||||
| "onOpenSftp"
|
||||
| "resolveSftpInitialPath"
|
||||
| "scrollToBottomAfterProgrammaticInput"
|
||||
| "sessionId"
|
||||
| "sessionRef"
|
||||
| "terminalBackend"
|
||||
| "isSensitiveInput"
|
||||
| "rzMissingFallbackTimeoutMs"
|
||||
| "termRef"
|
||||
> & {
|
||||
dropEntries: DropEntry[];
|
||||
}): Promise<void> {
|
||||
if (dropEntries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isLocalConnection) {
|
||||
const paths = extractRootPathsFromDropEntries(dropEntries);
|
||||
|
||||
if (paths.length > 0 && termRef.current && sessionRef.current) {
|
||||
const pathsText = paths.join(" ");
|
||||
terminalBackend.writeToSession(sessionRef.current, pathsText, {
|
||||
sensitive: isSensitiveInput?.() === true,
|
||||
});
|
||||
scrollToBottomAfterProgrammaticInput(pathsText);
|
||||
termRef.current.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const requiresSftpForDirectoryDrop = dropEntries.some((entry) => (
|
||||
entry.isDirectory || /[\\/]/.test(entry.relativePath)
|
||||
));
|
||||
|
||||
if (
|
||||
requiresSftpForDirectoryDrop
|
||||
&& onOpenSftp
|
||||
&& supportsZmodemDragDropSftpFallback(host)
|
||||
) {
|
||||
await openSftpForTerminalDrop({
|
||||
dropEntries,
|
||||
host,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
sessionId,
|
||||
});
|
||||
} else if (supportsZmodemTerminalDragDrop(host, isNetworkDevice)) {
|
||||
const files = await buildZmodemDragDropFiles(dropEntries);
|
||||
if (files.length === 0) {
|
||||
throw new Error("No files to upload");
|
||||
}
|
||||
|
||||
if (!terminalBackend.startZmodemDragDropUpload) {
|
||||
throw new Error("ZMODEM drag-drop upload is unavailable");
|
||||
}
|
||||
|
||||
const shouldFallbackToSftpWhenRzMissing = Boolean(
|
||||
onOpenSftp
|
||||
&& supportsZmodemDragDropSftpFallback(host)
|
||||
&& terminalBackend.onSessionData
|
||||
&& terminalBackend.cancelZmodem,
|
||||
);
|
||||
const rzMissingToken = shouldFallbackToSftpWhenRzMissing
|
||||
? createZmodemRzMissingToken()
|
||||
: undefined;
|
||||
const rzMissingWatcher = rzMissingToken
|
||||
? createRzMissingWatcher({
|
||||
sessionId,
|
||||
terminalBackend,
|
||||
token: rzMissingToken,
|
||||
timeoutMs: rzMissingFallbackTimeoutMs,
|
||||
})
|
||||
: undefined;
|
||||
const uploadCommand = rzMissingToken
|
||||
? buildZmodemDragDropUploadCommand(rzMissingToken)
|
||||
: undefined;
|
||||
|
||||
let result: { success: boolean; error?: string };
|
||||
try {
|
||||
result = await terminalBackend.startZmodemDragDropUpload(sessionId, files, uploadCommand);
|
||||
} catch (error) {
|
||||
rzMissingWatcher?.stop();
|
||||
throw error;
|
||||
}
|
||||
if (!result.success) {
|
||||
rzMissingWatcher?.stop();
|
||||
throw new Error(result.error || "ZMODEM upload failed");
|
||||
}
|
||||
|
||||
const fallbackResult = rzMissingWatcher ? await rzMissingWatcher.promise : "detected";
|
||||
if (fallbackResult === "missing" || fallbackResult === "timeout") {
|
||||
terminalBackend.cancelZmodem?.(sessionId, { interrupt: fallbackResult === "timeout" });
|
||||
if (onOpenSftp) {
|
||||
await openSftpForTerminalDrop({
|
||||
dropEntries,
|
||||
host,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (onOpenSftp) {
|
||||
await openSftpForTerminalDrop({
|
||||
dropEntries,
|
||||
host,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function useTerminalDragDrop({
|
||||
host,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
isLocalConnection,
|
||||
isNetworkDevice = false,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
sessionId,
|
||||
sessionRef,
|
||||
status,
|
||||
t,
|
||||
terminalBackend,
|
||||
isSensitiveInput,
|
||||
rzMissingFallbackTimeoutMs,
|
||||
termRef,
|
||||
}: UseTerminalDragDropOptions) {
|
||||
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
||||
const dragCounterRef = useRef(0);
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current++;
|
||||
if (e.dataTransfer.types.includes("Files")) {
|
||||
setIsDraggingOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.dataTransfer.types.includes("Files")) {
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current--;
|
||||
if (dragCounterRef.current === 0) {
|
||||
setIsDraggingOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounterRef.current = 0;
|
||||
setIsDraggingOver(false);
|
||||
|
||||
if (!e.dataTransfer.types.includes("Files")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status !== "connected") {
|
||||
toast.error(t("terminal.dragDrop.notConnected"), t("terminal.dragDrop.errorTitle"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const dropEntries = await extractDropEntries(e.dataTransfer);
|
||||
await handleTerminalDropEntries({
|
||||
dropEntries,
|
||||
host,
|
||||
resolvedLoginUsername,
|
||||
resolvedSudoPassword,
|
||||
isLocalConnection,
|
||||
isNetworkDevice,
|
||||
onOpenSftp,
|
||||
resolveSftpInitialPath,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
sessionId,
|
||||
sessionRef,
|
||||
terminalBackend,
|
||||
isSensitiveInput,
|
||||
rzMissingFallbackTimeoutMs,
|
||||
termRef,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to handle file drop", error);
|
||||
const message = resolveTerminalDropErrorMessage(error, t);
|
||||
toast.error(message, t("terminal.dragDrop.errorTitle"));
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
handleDragEnter,
|
||||
handleDragLeave,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
isDraggingOver,
|
||||
};
|
||||
}
|
||||
111
components/terminal/hooks/useTerminalFilePaste.ts
Normal file
111
components/terminal/hooks/useTerminalFilePaste.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type React from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import type { TerminalSession } from "../../../types";
|
||||
import type { RemoteClipboardImageUploadResult } from "../clipboardImagePaste";
|
||||
import { handleTerminalClipboardPaste } from "../terminalClipboardPaste";
|
||||
|
||||
interface UseTerminalFilePasteOptions {
|
||||
isLocalConnection: boolean;
|
||||
status: TerminalSession["status"];
|
||||
termRef: React.MutableRefObject<XTerm | null>;
|
||||
sessionRef: React.MutableRefObject<string | null>;
|
||||
terminalBackend: {
|
||||
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean; sensitive?: boolean }) => void;
|
||||
};
|
||||
isSensitiveInput?: () => boolean;
|
||||
scrollOnPasteRef?: React.RefObject<boolean>;
|
||||
onPasteData?: (data: string) => boolean | void;
|
||||
scrollToBottomAfterProgrammaticInput: (data: string) => void;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** Remote sessions only: auto-upload a clipboard image on paste. */
|
||||
autoUploadClipboardImage?: boolean;
|
||||
getRemoteCwd?: () => Promise<string | null | undefined>;
|
||||
onClipboardImageUploadResult?: (result: RemoteClipboardImageUploadResult) => void;
|
||||
}
|
||||
|
||||
export function useTerminalFilePaste({
|
||||
isLocalConnection,
|
||||
status,
|
||||
termRef,
|
||||
sessionRef,
|
||||
terminalBackend,
|
||||
isSensitiveInput,
|
||||
scrollOnPasteRef,
|
||||
onPasteData,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
containerRef,
|
||||
autoUploadClipboardImage = false,
|
||||
getRemoteCwd,
|
||||
onClipboardImageUploadResult,
|
||||
}: UseTerminalFilePasteOptions) {
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handlePaste = (event: ClipboardEvent) => {
|
||||
if (status !== "connected") return;
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
const wantsImageUpload =
|
||||
autoUploadClipboardImage && !isLocalConnection && !!bridge?.readClipboardImage;
|
||||
const canHandleLocalPaste =
|
||||
isLocalConnection && !!(bridge?.readClipboardFiles || bridge?.hasClipboardImage);
|
||||
if (!wantsImageUpload && !canHandleLocalPaste) return;
|
||||
|
||||
// ⚡ Must call preventDefault SYNCHRONOUSLY — the event lifecycle
|
||||
// is synchronous; calling it after an await is too late and the
|
||||
// browser will have already performed the default paste action.
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
await handleTerminalClipboardPaste({
|
||||
bridge,
|
||||
autoUploadClipboardImage: wantsImageUpload,
|
||||
clipboardImageBridge: bridge ?? undefined,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
isSensitiveInput,
|
||||
onClipboardImageUploadResult,
|
||||
readClipboardText: () => navigator.clipboard.readText(),
|
||||
scrollOnPaste: scrollOnPasteRef?.current ?? false,
|
||||
onPasteData,
|
||||
sessionId: sessionRef.current,
|
||||
terminalBackend,
|
||||
term,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("Failed to handle file paste", error);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
container.addEventListener("paste", handlePaste, true);
|
||||
return () => {
|
||||
container.removeEventListener("paste", handlePaste, true);
|
||||
};
|
||||
}, [
|
||||
autoUploadClipboardImage,
|
||||
containerRef,
|
||||
getRemoteCwd,
|
||||
isLocalConnection,
|
||||
isSensitiveInput,
|
||||
onClipboardImageUploadResult,
|
||||
onPasteData,
|
||||
scrollOnPasteRef,
|
||||
scrollToBottomAfterProgrammaticInput,
|
||||
sessionRef,
|
||||
status,
|
||||
terminalBackend,
|
||||
termRef,
|
||||
]);
|
||||
}
|
||||
580
components/terminal/hooks/useTerminalSearch.ts
Normal file
580
components/terminal/hooks/useTerminalSearch.ts
Normal file
@@ -0,0 +1,580 @@
|
||||
import type { SearchAddon } from "@xterm/addon-search";
|
||||
import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { useStoredBoolean } from "../../../application/state/useStoredBoolean";
|
||||
import { STORAGE_KEY_TERMINAL_SEARCH_OPEN } from "../../../infrastructure/config/storageKeys";
|
||||
|
||||
type SearchMatchCount = { current: number; total: number } | null;
|
||||
|
||||
type SearchAddonResetTarget = Pick<SearchAddon, "findNext" | "clearDecorations"> | null;
|
||||
type TerminalSearchVisualElement = {
|
||||
querySelectorAll: (selector: string) => ArrayLike<{ remove: () => void }>;
|
||||
};
|
||||
type TerminalSearchResetTarget = Pick<XTerm, "refresh" | "rows" | "clearSelection"> & {
|
||||
element?: TerminalSearchVisualElement | null;
|
||||
clearTextureAtlas?: () => void;
|
||||
} | null;
|
||||
type TerminalSearchGuardTarget = TerminalSearchResetTarget;
|
||||
|
||||
const SEARCH_DECORATIONS = {
|
||||
matchBackground: "#FFFF0044",
|
||||
matchBorder: "#FFFF00",
|
||||
matchOverviewRuler: "#FFFF00",
|
||||
activeMatchBackground: "#FF880088",
|
||||
activeMatchBorder: "#FF8800",
|
||||
activeMatchColorOverviewRuler: "#FF8800",
|
||||
} as const;
|
||||
|
||||
const SEARCH_DECORATION_BACKGROUNDS = new Set<string>([
|
||||
SEARCH_DECORATIONS.matchBackground.toLowerCase(),
|
||||
SEARCH_DECORATIONS.activeMatchBackground.toLowerCase(),
|
||||
]);
|
||||
|
||||
type StaleSearchDecoration = {
|
||||
dispose: () => void;
|
||||
options?: { backgroundColor?: string };
|
||||
element?: {
|
||||
classList?: { contains: (name: string) => boolean };
|
||||
style?: { backgroundColor?: string };
|
||||
};
|
||||
};
|
||||
|
||||
type CellDecorationService = {
|
||||
decorations?: Iterable<StaleSearchDecoration>;
|
||||
forEachDecorationAtCell?: (
|
||||
x: number,
|
||||
y: number,
|
||||
layer: "bottom" | "top" | undefined,
|
||||
callback: (decoration: StaleSearchDecoration) => void,
|
||||
) => void;
|
||||
};
|
||||
|
||||
type SearchAddonInternals = {
|
||||
clearDecorations?: () => void;
|
||||
clearActiveDecoration?: () => void;
|
||||
_highlightTimeout?: { clear?: () => void };
|
||||
_state?: { reset?: () => void };
|
||||
};
|
||||
|
||||
type TerminalDecorationHost = {
|
||||
_core?: { _decorationService?: CellDecorationService };
|
||||
_decorationService?: CellDecorationService;
|
||||
};
|
||||
|
||||
export const SEARCH_DECORATION_TRACKER_KEY = "__netcattySearchDecorationTracker";
|
||||
|
||||
export type SearchDecorationTracker = {
|
||||
disposeAll: () => number;
|
||||
size: () => number;
|
||||
markSearched: () => void;
|
||||
hasSearched: () => boolean;
|
||||
consumeSearched: () => boolean;
|
||||
noteEmptyQueryReset: () => void;
|
||||
consumeCloseSweep: () => boolean;
|
||||
};
|
||||
|
||||
type TrackableTerminal = Pick<XTerm, "registerDecoration"> & {
|
||||
[SEARCH_DECORATION_TRACKER_KEY]?: SearchDecorationTracker;
|
||||
};
|
||||
|
||||
const SEARCH_OPTIONS = {
|
||||
regex: false,
|
||||
caseSensitive: false,
|
||||
wholeWord: false,
|
||||
decorations: SEARCH_DECORATIONS,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* SearchAddon schedules `_updateMatches` 200ms after writes/resizes and does
|
||||
* not cancel that timer from `clearDecorations()`. A timeout that already
|
||||
* captured the prior term can revive yellow match decorations after reset —
|
||||
* re-clear once past that window (issue #2980).
|
||||
*/
|
||||
export const SEARCH_HIGHLIGHT_REVIVAL_GUARD_MS = 250;
|
||||
|
||||
/**
|
||||
* SearchAddon paints matches as HTML overlays (`.xterm-find-result-decoration`).
|
||||
* Disposing the addon decoration does not always detach that node — after Esc
|
||||
* closes the search bar and the terminal refits, the first two cells of the
|
||||
* last hit can stay outlined on top of the buffer.
|
||||
*/
|
||||
export const SEARCH_DECORATION_NODE_SELECTOR =
|
||||
".xterm-find-result-decoration, .xterm-find-active-result-decoration";
|
||||
|
||||
export const stripStaleSearchDecorationNodes = (
|
||||
term?: { element?: TerminalSearchVisualElement | null } | null,
|
||||
): void => {
|
||||
const nodes = term?.element?.querySelectorAll(SEARCH_DECORATION_NODE_SELECTOR);
|
||||
if (!nodes) return;
|
||||
for (let i = 0; i < nodes.length; i += 1) {
|
||||
nodes[i]?.remove();
|
||||
}
|
||||
};
|
||||
|
||||
export const isSearchDecorationBackground = (color?: string): boolean => (
|
||||
Boolean(color) && SEARCH_DECORATION_BACKGROUNDS.has(color.trim().toLowerCase())
|
||||
);
|
||||
|
||||
export const installSearchDecorationTracker = (
|
||||
term: TrackableTerminal,
|
||||
): SearchDecorationTracker => {
|
||||
const existing = term[SEARCH_DECORATION_TRACKER_KEY];
|
||||
if (existing) return existing;
|
||||
|
||||
const tracked = new Set<{ dispose: () => void }>();
|
||||
let searched = false;
|
||||
let pendingCloseSweep = false;
|
||||
const originalRegister = term.registerDecoration.bind(term);
|
||||
term.registerDecoration = (options) => {
|
||||
// Keep search fill on the HTML overlay only. Passing backgroundColor into
|
||||
// xterm lets WebGL bake the yellow into the glyph atlas, and those cells
|
||||
// stay stained after Esc even when the decoration handle is gone.
|
||||
const searchBackground = isSearchDecorationBackground(options.backgroundColor)
|
||||
? options.backgroundColor
|
||||
: undefined;
|
||||
const decoration = originalRegister(
|
||||
searchBackground ? { ...options, backgroundColor: undefined } : options,
|
||||
);
|
||||
if (!decoration) return decoration;
|
||||
if (!searchBackground) return decoration;
|
||||
tracked.add(decoration);
|
||||
decoration.onRender((element) => {
|
||||
element.style.backgroundColor = searchBackground;
|
||||
});
|
||||
decoration.onDispose(() => {
|
||||
tracked.delete(decoration);
|
||||
});
|
||||
return decoration;
|
||||
};
|
||||
|
||||
const tracker: SearchDecorationTracker = {
|
||||
disposeAll: () => {
|
||||
const leftover = [...tracked];
|
||||
tracked.clear();
|
||||
for (const decoration of leftover) decoration.dispose();
|
||||
return leftover.length;
|
||||
},
|
||||
size: () => tracked.size,
|
||||
markSearched: () => {
|
||||
searched = true;
|
||||
pendingCloseSweep = true;
|
||||
},
|
||||
hasSearched: () => searched,
|
||||
consumeSearched: () => {
|
||||
const value = searched;
|
||||
searched = false;
|
||||
return value;
|
||||
},
|
||||
noteEmptyQueryReset: () => {
|
||||
searched = false;
|
||||
},
|
||||
consumeCloseSweep: () => {
|
||||
const value = pendingCloseSweep;
|
||||
pendingCloseSweep = false;
|
||||
return value;
|
||||
},
|
||||
};
|
||||
term[SEARCH_DECORATION_TRACKER_KEY] = tracker;
|
||||
return tracker;
|
||||
};
|
||||
|
||||
export const getSearchDecorationTracker = (term?: unknown): SearchDecorationTracker | null => {
|
||||
if (!term || typeof term !== "object") return null;
|
||||
const tracker = (term as TrackableTerminal)[SEARCH_DECORATION_TRACKER_KEY];
|
||||
return tracker && typeof tracker.disposeAll === "function" ? tracker : null;
|
||||
};
|
||||
|
||||
const isStaleSearchDecoration = (decoration: StaleSearchDecoration): boolean => (
|
||||
isSearchDecorationBackground(decoration.options?.backgroundColor)
|
||||
|| isSearchDecorationBackground(decoration.element?.style?.backgroundColor)
|
||||
|| decoration.element?.classList?.contains("xterm-find-result-decoration") === true
|
||||
|| decoration.element?.classList?.contains("xterm-find-active-result-decoration") === true
|
||||
);
|
||||
|
||||
const readDecorationService = (term?: unknown): CellDecorationService | null => {
|
||||
const host = term as TerminalDecorationHost & {
|
||||
_core?: Record<string, unknown>;
|
||||
} | null | undefined;
|
||||
const direct = host?._core?._decorationService ?? host?._decorationService;
|
||||
if (direct && (direct.decorations || direct.forEachDecorationAtCell)) {
|
||||
return direct;
|
||||
}
|
||||
const core = host?._core;
|
||||
if (!core || typeof core !== "object") return null;
|
||||
for (const value of Object.values(core)) {
|
||||
const candidate = value as CellDecorationService | undefined;
|
||||
if (candidate && typeof candidate.forEachDecorationAtCell === "function") {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const readDecorationIterable = (
|
||||
term?: unknown,
|
||||
): Iterable<StaleSearchDecoration> | null => (
|
||||
readDecorationService(term)?.decorations ?? null
|
||||
);
|
||||
|
||||
export const disposeSearchDecorationsInViewport = (term?: unknown): number => {
|
||||
const service = readDecorationService(term);
|
||||
const view = term as {
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
buffer?: { active?: { viewportY?: number } };
|
||||
} | null | undefined;
|
||||
if (!service?.forEachDecorationAtCell || !view?.cols || !view.rows) return 0;
|
||||
const viewportY = view.buffer?.active?.viewportY ?? 0;
|
||||
const stale = new Set<StaleSearchDecoration>();
|
||||
for (let y = viewportY; y < viewportY + view.rows; y += 1) {
|
||||
for (let x = 0; x < view.cols; x += 1) {
|
||||
service.forEachDecorationAtCell(x, y, undefined, (decoration) => {
|
||||
if (isStaleSearchDecoration(decoration)) stale.add(decoration);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const decoration of stale) decoration.dispose();
|
||||
return stale.size;
|
||||
};
|
||||
|
||||
/**
|
||||
* WebGL paints decoration backgroundColor into the cell. SearchAddon can lose
|
||||
* a couple of those handles on Esc+refit, so walk the terminal decoration
|
||||
* service and dispose anything still using the search yellow/orange.
|
||||
*/
|
||||
export const disposeStaleSearchDecorations = (term?: unknown): number => {
|
||||
if (term && typeof term === "object" && "registerDecoration" in term) {
|
||||
installSearchDecorationTracker(term as TrackableTerminal);
|
||||
}
|
||||
const trackedCount = getSearchDecorationTracker(term)?.disposeAll() ?? 0;
|
||||
const viewportCount = disposeSearchDecorationsInViewport(term);
|
||||
const decorations = readDecorationIterable(term);
|
||||
if (!decorations) return trackedCount + viewportCount;
|
||||
const stale: StaleSearchDecoration[] = [];
|
||||
for (const decoration of decorations) {
|
||||
if (isStaleSearchDecoration(decoration)) stale.push(decoration);
|
||||
}
|
||||
for (const decoration of stale) decoration.dispose();
|
||||
return trackedCount + viewportCount + stale.length;
|
||||
};
|
||||
|
||||
/** Cancel SearchAddon's 200ms _updateMatches timer and drop cached term/options. */
|
||||
export const disarmSearchAddonRevival = (searchAddon?: unknown): void => {
|
||||
const addon = searchAddon as SearchAddonInternals | null | undefined;
|
||||
if (!addon) return;
|
||||
addon._highlightTimeout?.clear?.();
|
||||
addon._state?.reset?.();
|
||||
addon.clearActiveDecoration?.();
|
||||
addon.clearDecorations?.();
|
||||
};
|
||||
|
||||
/**
|
||||
* Delayed re-clear for addon decoration revival only. Do not clearSelection
|
||||
* here: reset already cleared the search selection, and a user may have made
|
||||
* a new manual selection during the guard window.
|
||||
*/
|
||||
export const clearTerminalSearchHighlights = (
|
||||
searchAddon: Pick<SearchAddon, "clearDecorations"> | null,
|
||||
term?: Pick<XTerm, "refresh" | "rows"> & {
|
||||
element?: TerminalSearchVisualElement | null;
|
||||
clearTextureAtlas?: () => void;
|
||||
} | null,
|
||||
): void => {
|
||||
disarmSearchAddonRevival(searchAddon);
|
||||
disposeStaleSearchDecorations(term);
|
||||
stripStaleSearchDecorationNodes(term);
|
||||
if (term && term.rows > 0) {
|
||||
term.refresh(0, term.rows - 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* After the search bar unmounts the terminal grows and is force-fitted.
|
||||
* Re-sweep leftover overlays and the addon selection that a resize can revive
|
||||
* as a 2-cell sliver of the last match.
|
||||
*/
|
||||
export const settleTerminalSearchAfterLayout = (
|
||||
searchAddon: Pick<SearchAddon, "clearDecorations"> | null,
|
||||
term?: TerminalSearchResetTarget,
|
||||
onRepaint?: () => void,
|
||||
): void => {
|
||||
// Search-open state is shared across terminals. A sibling that never
|
||||
// searched still sees the bar close and would otherwise lose a manual
|
||||
// selection via clearSelection(). Emptying the query resets highlights
|
||||
// while the bar stays open; keep the close-time repaint, but do not
|
||||
// treat that stale search as a reason to wipe a later manual selection.
|
||||
const tracker = getSearchDecorationTracker(term);
|
||||
const shouldClearSelection = tracker ? tracker.consumeSearched() : true;
|
||||
const shouldSweepLeftovers = tracker ? tracker.consumeCloseSweep() : true;
|
||||
const shouldSweep = !tracker || shouldClearSelection || shouldSweepLeftovers;
|
||||
if (!shouldSweep) return;
|
||||
clearTerminalSearchHighlights(searchAddon, term);
|
||||
if (shouldClearSelection) term?.clearSelection();
|
||||
term?.clearTextureAtlas?.();
|
||||
onRepaint?.();
|
||||
};
|
||||
|
||||
export const resetTerminalSearch = (
|
||||
searchAddon: SearchAddonResetTarget,
|
||||
searchTermRef: { current: string },
|
||||
term?: TerminalSearchResetTarget,
|
||||
): void => {
|
||||
searchTermRef.current = "";
|
||||
// Drop decorations and cachedSearchTerm first so any not-yet-running addon
|
||||
// `_updateMatches` timeout observes an empty cache and does not revive.
|
||||
disarmSearchAddonRevival(searchAddon);
|
||||
// clearDecorations() leaves the active-match selection; clear it explicitly.
|
||||
term?.clearSelection();
|
||||
// Empty find clears selection via the addon path. Do NOT pass SEARCH_OPTIONS:
|
||||
// findNext always assigns lastSearchOptions, and decoration options would
|
||||
// keep that latch armed for later write/resize updates.
|
||||
try {
|
||||
searchAddon?.findNext("");
|
||||
} catch {
|
||||
// Addon not activated yet.
|
||||
}
|
||||
// findNext("") assigns cachedSearchTerm back to "". Clear again so the cache
|
||||
// is undefined rather than an empty string.
|
||||
searchAddon?.clearDecorations();
|
||||
// SearchAddon can drop a couple of decoration handles on Esc+refit. Those
|
||||
// leftover yellow cells are still in xterm's decoration service and WebGL
|
||||
// keeps painting them until we dispose them directly.
|
||||
disposeStaleSearchDecorations(term);
|
||||
// Disposing search decorations does not always detach the overlay nodes
|
||||
// (Esc close leaves the first two cells of the last hit). Sweep them
|
||||
// before refresh so WebGL/DOM cannot keep the yellow outline.
|
||||
stripStaleSearchDecorationNodes(term);
|
||||
// Disposing search decorations does not always repaint cells (observed on
|
||||
// Windows after clearing or closing search). Keyword highlighting already
|
||||
// forces a refresh after dispose; do the same here so yellow match
|
||||
// backgrounds cannot linger.
|
||||
if (term && term.rows > 0) {
|
||||
term.refresh(0, term.rows - 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Pointer listeners used to tell a user-created selection apart from the
|
||||
* addon's delayed findPrevious re-select. Keyboard selections in this 250ms
|
||||
* window are rare enough that treating them as addon revival is acceptable.
|
||||
*/
|
||||
export const subscribeTerminalUserSelection = (
|
||||
term: Pick<XTerm, "element"> | null | undefined,
|
||||
mark: () => void,
|
||||
): (() => void) => {
|
||||
const el = term?.element;
|
||||
if (!el) return () => {};
|
||||
const onPointer = () => mark();
|
||||
el.addEventListener("mousedown", onPointer);
|
||||
el.addEventListener("touchstart", onPointer);
|
||||
return () => {
|
||||
el.removeEventListener("mousedown", onPointer);
|
||||
el.removeEventListener("touchstart", onPointer);
|
||||
};
|
||||
};
|
||||
|
||||
export const armSearchHighlightRevivalGuard = ({
|
||||
getSearchAddon,
|
||||
getTerm,
|
||||
subscribeUserSelection,
|
||||
delayMs = SEARCH_HIGHLIGHT_REVIVAL_GUARD_MS,
|
||||
setTimeoutFn = setTimeout,
|
||||
clearTimeoutFn = clearTimeout,
|
||||
}: {
|
||||
getSearchAddon: () => Pick<SearchAddon, "clearDecorations"> | null;
|
||||
getTerm: () => TerminalSearchGuardTarget;
|
||||
subscribeUserSelection?: (mark: () => void) => () => void;
|
||||
delayMs?: number;
|
||||
setTimeoutFn?: typeof setTimeout;
|
||||
clearTimeoutFn?: typeof clearTimeout;
|
||||
}): { arm: () => void; dispose: () => void; markUserSelection: () => void } => {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let userTouchedSelection = false;
|
||||
let unsubscribeUserSelection: (() => void) | null = null;
|
||||
|
||||
const markUserSelection = () => {
|
||||
userTouchedSelection = true;
|
||||
};
|
||||
|
||||
const dispose = () => {
|
||||
if (timer !== null) {
|
||||
clearTimeoutFn(timer);
|
||||
timer = null;
|
||||
}
|
||||
unsubscribeUserSelection?.();
|
||||
unsubscribeUserSelection = null;
|
||||
};
|
||||
|
||||
const arm = () => {
|
||||
dispose();
|
||||
userTouchedSelection = false;
|
||||
if (subscribeUserSelection) {
|
||||
unsubscribeUserSelection = subscribeUserSelection(markUserSelection);
|
||||
}
|
||||
timer = setTimeoutFn(() => {
|
||||
timer = null;
|
||||
unsubscribeUserSelection?.();
|
||||
unsubscribeUserSelection = null;
|
||||
const term = getTerm();
|
||||
clearTerminalSearchHighlights(getSearchAddon(), term);
|
||||
// Addon findPrevious re-selects the prior active match. Clear that
|
||||
// revived selection unless the user started a new one in this window.
|
||||
if (!userTouchedSelection) {
|
||||
term?.clearSelection();
|
||||
}
|
||||
}, delayMs);
|
||||
};
|
||||
|
||||
return { arm, dispose, markUserSelection };
|
||||
};
|
||||
|
||||
/** True when this terminal has a local query that shared search-close must clear. */
|
||||
export const shouldResetOnSharedSearchClose = (localSearchTerm: string): boolean =>
|
||||
localSearchTerm !== "";
|
||||
|
||||
export const useTerminalSearch = ({
|
||||
searchAddonRef,
|
||||
termRef,
|
||||
}: {
|
||||
searchAddonRef: RefObject<SearchAddon | null>;
|
||||
termRef: RefObject<XTerm | null>;
|
||||
}) => {
|
||||
const [isSearchOpen, setIsSearchOpen] = useStoredBoolean(
|
||||
STORAGE_KEY_TERMINAL_SEARCH_OPEN,
|
||||
false,
|
||||
);
|
||||
const [searchMatchCount, setSearchMatchCount] = useState<SearchMatchCount>(null);
|
||||
// Bumped each time the search hotkey fires. The SearchBar watches this token
|
||||
// to refocus its input — without it, calling setIsSearchOpen(true) when
|
||||
// already open is a no-op (React bails on the unchanged boolean) and focus
|
||||
// never returns to the input. See issue #1789.
|
||||
const [searchFocusToken, setSearchFocusToken] = useState(0);
|
||||
const searchTermRef = useRef<string>("");
|
||||
const revivalGuardRef = useRef<ReturnType<typeof armSearchHighlightRevivalGuard> | null>(null);
|
||||
|
||||
// Existing sessions (and Vite HMR) never go back through createXTermRuntime.
|
||||
// Install on the live term so Esc can still find leaked decorations.
|
||||
if (termRef.current) {
|
||||
installSearchDecorationTracker(termRef.current);
|
||||
}
|
||||
|
||||
if (revivalGuardRef.current === null) {
|
||||
revivalGuardRef.current = armSearchHighlightRevivalGuard({
|
||||
getSearchAddon: () => searchAddonRef.current,
|
||||
getTerm: () => termRef.current,
|
||||
subscribeUserSelection: (mark) => subscribeTerminalUserSelection(termRef.current, mark),
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => () => {
|
||||
revivalGuardRef.current?.dispose();
|
||||
}, []);
|
||||
|
||||
const runReset = useCallback(() => {
|
||||
resetTerminalSearch(searchAddonRef.current, searchTermRef, termRef.current);
|
||||
revivalGuardRef.current?.arm();
|
||||
}, [searchAddonRef, termRef]);
|
||||
|
||||
// Search open state is shared via localStorage across terminal sessions. When
|
||||
// another session closes search, this session's bar unmounts without going
|
||||
// through handleCloseSearch — clear leftover decorations only when this
|
||||
// terminal actually searched (otherwise shared false would wipe unrelated
|
||||
// manual selections in other splits).
|
||||
useEffect(() => {
|
||||
if (isSearchOpen) return;
|
||||
setSearchMatchCount(null);
|
||||
if (!shouldResetOnSharedSearchClose(searchTermRef.current)) return;
|
||||
runReset();
|
||||
}, [isSearchOpen, runReset]);
|
||||
|
||||
// Invoked by the searchTerminal hotkey (Cmd/Ctrl+F). Always opens the bar
|
||||
// and bumps the focus token: when closed, setIsSearchOpen(true) mounts the
|
||||
// SearchBar (whose isOpen effect focuses the input); when open, the token
|
||||
// bump makes the SearchBar re-run its focus effect and refocus. Doing both
|
||||
// unconditionally avoids reading `isSearchOpen` here — the xterm runtime
|
||||
// captures this callback once at creation (it only re-runs on host.id /
|
||||
// sessionId change), so a stale `isSearchOpen` closure would otherwise pick
|
||||
// the wrong branch.
|
||||
const requestSearchFocus = useCallback(() => {
|
||||
setIsSearchOpen(true);
|
||||
setSearchFocusToken((n) => n + 1);
|
||||
}, [setIsSearchOpen]);
|
||||
|
||||
const handleToggleSearch = useCallback(() => {
|
||||
const next = !isSearchOpen;
|
||||
setIsSearchOpen(next);
|
||||
if (!next) {
|
||||
setSearchMatchCount(null);
|
||||
runReset();
|
||||
}
|
||||
}, [isSearchOpen, runReset, setIsSearchOpen]);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(term: string): boolean => {
|
||||
const searchAddon = searchAddonRef.current;
|
||||
if (!searchAddon || !term) {
|
||||
runReset();
|
||||
if (termRef.current) installSearchDecorationTracker(termRef.current);
|
||||
getSearchDecorationTracker(termRef.current)?.noteEmptyQueryReset();
|
||||
setSearchMatchCount(null);
|
||||
return false;
|
||||
}
|
||||
|
||||
searchTermRef.current = term;
|
||||
revivalGuardRef.current?.dispose();
|
||||
// Incremental typing (ro -> root) can leave the previous term's
|
||||
// decorations in xterm even after clearDecorations(). Drop our tracked
|
||||
// leftovers before painting the new matches.
|
||||
if (termRef.current) installSearchDecorationTracker(termRef.current);
|
||||
getSearchDecorationTracker(termRef.current)?.markSearched();
|
||||
disposeStaleSearchDecorations(termRef.current);
|
||||
searchAddon.clearDecorations();
|
||||
|
||||
const found = searchAddon.findNext(term, SEARCH_OPTIONS);
|
||||
|
||||
if (found) {
|
||||
setSearchMatchCount({ current: 1, total: 1 });
|
||||
} else {
|
||||
setSearchMatchCount({ current: 0, total: 0 });
|
||||
}
|
||||
|
||||
return found;
|
||||
},
|
||||
[runReset, searchAddonRef, termRef],
|
||||
);
|
||||
|
||||
const handleFindNext = useCallback((): boolean => {
|
||||
const searchAddon = searchAddonRef.current;
|
||||
const term = searchTermRef.current;
|
||||
if (!searchAddon || !term) return false;
|
||||
return searchAddon.findNext(term, SEARCH_OPTIONS);
|
||||
}, [searchAddonRef]);
|
||||
|
||||
const handleFindPrevious = useCallback((): boolean => {
|
||||
const searchAddon = searchAddonRef.current;
|
||||
const term = searchTermRef.current;
|
||||
if (!searchAddon || !term) return false;
|
||||
return searchAddon.findPrevious(term, SEARCH_OPTIONS);
|
||||
}, [searchAddonRef]);
|
||||
|
||||
const handleCloseSearch = useCallback(() => {
|
||||
setIsSearchOpen(false);
|
||||
setSearchMatchCount(null);
|
||||
runReset();
|
||||
termRef.current?.focus();
|
||||
}, [runReset, setIsSearchOpen, termRef]);
|
||||
|
||||
return {
|
||||
isSearchOpen,
|
||||
setIsSearchOpen,
|
||||
searchMatchCount,
|
||||
searchFocusToken,
|
||||
requestSearchFocus,
|
||||
handleToggleSearch,
|
||||
handleSearch,
|
||||
handleFindNext,
|
||||
handleFindPrevious,
|
||||
handleCloseSearch,
|
||||
};
|
||||
};
|
||||
170
components/terminal/hooks/useZmodemTransfer.ts
Normal file
170
components/terminal/hooks/useZmodemTransfer.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export interface ZmodemTransferEvent {
|
||||
type: 'detect' | 'progress' | 'complete' | 'error';
|
||||
sessionId: string;
|
||||
transferType?: 'upload' | 'download';
|
||||
filename?: string;
|
||||
transferred?: number;
|
||||
total?: number;
|
||||
fileIndex?: number;
|
||||
fileCount?: number;
|
||||
finalizing?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ZmodemTransferState {
|
||||
active: boolean;
|
||||
transferType: 'upload' | 'download' | null;
|
||||
filename: string | null;
|
||||
transferred: number;
|
||||
total: number;
|
||||
fileIndex: number;
|
||||
fileCount: number;
|
||||
finalizing: boolean;
|
||||
completed: boolean;
|
||||
startedAt: number | null;
|
||||
updatedAt: number | null;
|
||||
bytesPerSecond: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: ZmodemTransferState = {
|
||||
active: false,
|
||||
transferType: null,
|
||||
filename: null,
|
||||
transferred: 0,
|
||||
total: 0,
|
||||
fileIndex: 0,
|
||||
fileCount: 0,
|
||||
finalizing: false,
|
||||
completed: false,
|
||||
startedAt: null,
|
||||
updatedAt: null,
|
||||
bytesPerSecond: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
export function reduceZmodemTransferState(
|
||||
prev: ZmodemTransferState,
|
||||
event: ZmodemTransferEvent,
|
||||
now: number = Date.now(),
|
||||
): ZmodemTransferState {
|
||||
switch (event.type) {
|
||||
case 'detect':
|
||||
return {
|
||||
...initialState,
|
||||
active: true,
|
||||
transferType: event.transferType ?? null,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
case 'progress': {
|
||||
const transferred = event.transferred ?? prev.transferred;
|
||||
const fileChanged = (
|
||||
prev.filename !== null
|
||||
&& (
|
||||
(typeof event.fileIndex === 'number' && event.fileIndex !== prev.fileIndex)
|
||||
|| (typeof event.filename === 'string' && event.filename !== prev.filename)
|
||||
)
|
||||
);
|
||||
const previousUpdatedAt = fileChanged ? now : (prev.updatedAt ?? now);
|
||||
const elapsedSeconds = Math.max((now - previousUpdatedAt) / 1000, 0);
|
||||
const deltaBytes = Math.max(transferred - prev.transferred, 0);
|
||||
const bytesPerSecond = elapsedSeconds > 0 && deltaBytes > 0
|
||||
? deltaBytes / elapsedSeconds
|
||||
: fileChanged
|
||||
? null
|
||||
: prev.bytesPerSecond;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
active: true,
|
||||
transferType: event.transferType ?? prev.transferType,
|
||||
filename: event.filename ?? prev.filename,
|
||||
transferred,
|
||||
total: event.total ?? prev.total,
|
||||
fileIndex: event.fileIndex ?? prev.fileIndex,
|
||||
fileCount: event.fileCount ?? prev.fileCount,
|
||||
finalizing: !!event.finalizing,
|
||||
completed: false,
|
||||
startedAt: prev.startedAt ?? now,
|
||||
updatedAt: now,
|
||||
bytesPerSecond,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
case 'complete':
|
||||
return {
|
||||
...prev,
|
||||
active: false,
|
||||
finalizing: false,
|
||||
completed: true,
|
||||
updatedAt: now,
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
...prev,
|
||||
active: false,
|
||||
finalizing: false,
|
||||
completed: false,
|
||||
updatedAt: now,
|
||||
error: event.error ?? 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function useZmodemTransfer(sessionId: string | null) {
|
||||
const [state, setState] = useState<ZmodemTransferState>(initialState);
|
||||
const [overwriteRequest, setOverwriteRequest] = useState<{ requestId: string; filename: string } | null>(null);
|
||||
const disposeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
const disposeExitRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onZmodemEvent) return;
|
||||
|
||||
disposeRef.current = bridge.onZmodemEvent(sessionId, (event) => {
|
||||
setState((prev) => reduceZmodemTransferState(prev, event));
|
||||
});
|
||||
|
||||
const disposeOverwrite = bridge.onZmodemOverwriteRequest?.(sessionId, (payload) => {
|
||||
setOverwriteRequest({ requestId: payload.requestId, filename: payload.filename });
|
||||
});
|
||||
|
||||
// If the session exits mid-transfer (disconnect, shell exit, etc.),
|
||||
// reset state so the progress indicator doesn't stay stuck.
|
||||
disposeExitRef.current = bridge.onSessionExit(sessionId, () => {
|
||||
setState(initialState);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposeRef.current?.();
|
||||
disposeRef.current = null;
|
||||
disposeOverwrite?.();
|
||||
disposeExitRef.current?.();
|
||||
disposeExitRef.current = null;
|
||||
setState(initialState);
|
||||
setOverwriteRequest(null);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (!sessionId) return;
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.cancelZmodem?.(sessionId);
|
||||
}, [sessionId]);
|
||||
|
||||
const respondOverwrite = useCallback((action: "overwrite" | "skip" | "cancel", applyToRest: boolean) => {
|
||||
setOverwriteRequest((req) => {
|
||||
if (req) netcattyBridge.get()?.respondZmodemOverwrite?.({ requestId: req.requestId, action, applyToRest });
|
||||
return null;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { ...state, cancel, overwriteRequest, respondOverwrite };
|
||||
}
|
||||
34
components/terminal/hostKeyVerification.test.ts
Normal file
34
components/terminal/hostKeyVerification.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createKnownHostFromHostKeyInfo, toHostKeyInfo } from "./hostKeyVerification";
|
||||
|
||||
test("host key verification keeps the existing known host id when saving", () => {
|
||||
const hostKeyInfo = toHostKeyInfo({
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "unknown",
|
||||
fingerprint: "new-fingerprint",
|
||||
status: "changed",
|
||||
knownHostId: "kh-existing",
|
||||
knownFingerprint: "old-fingerprint",
|
||||
});
|
||||
|
||||
const knownHost = createKnownHostFromHostKeyInfo(
|
||||
hostKeyInfo,
|
||||
{ port: 2200 },
|
||||
200,
|
||||
"generated",
|
||||
);
|
||||
|
||||
assert.equal(hostKeyInfo.knownHostId, "kh-existing");
|
||||
assert.deepEqual(knownHost, {
|
||||
id: "kh-existing",
|
||||
hostname: "switch.local",
|
||||
port: 22,
|
||||
keyType: "unknown",
|
||||
publicKey: "SHA256:new-fingerprint",
|
||||
fingerprint: "new-fingerprint",
|
||||
discoveredAt: 200,
|
||||
});
|
||||
});
|
||||
17
components/terminal/hostKeyVerification.ts
Normal file
17
components/terminal/hostKeyVerification.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Host, KnownHost } from "../../types";
|
||||
import type { HostKeyInfo } from "../../domain/hostKey";
|
||||
import { createKnownHostFromHostKeyInfo as createKnownHostFromHostKeyInfoDomain } from "../../domain/knownHosts";
|
||||
|
||||
export type { HostKeyInfo, HostKeyVerificationRequest } from "../../domain/hostKey";
|
||||
export { toHostKeyInfo } from "../../domain/hostKey";
|
||||
|
||||
export const createKnownHostFromHostKeyInfo = (
|
||||
hostKeyInfo: HostKeyInfo,
|
||||
host: Pick<Host, "port">,
|
||||
now = Date.now(),
|
||||
idSuffix = Math.random().toString(36).slice(2, 11),
|
||||
): KnownHost => createKnownHostFromHostKeyInfoDomain(hostKeyInfo, {
|
||||
defaultPort: host.port,
|
||||
now,
|
||||
idSuffix,
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user