mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55:24 +00:00
feat: move the terminal fully into the ssh-terminal plugin (B9)
This commit is contained in:
1 parent
535126fd1b
commit
79d77678c9
309 files changed
+41789
-14958
No files matched your search
@@ -0,0 +1,525 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clipboard,
|
||||
Pencil,
|
||||
X,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { TerminalHandle } from "./terminal-types";
|
||||
import {
|
||||
cn,
|
||||
Button,
|
||||
Input,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
interface MobileTerminalKeyboardProps {
|
||||
terminalRef: React.RefObject<TerminalHandle | null>;
|
||||
}
|
||||
|
||||
const CTRL_MAP: Record<string, string> = {
|
||||
a: "\x01",
|
||||
c: "\x03",
|
||||
d: "\x04",
|
||||
k: "\x0B",
|
||||
l: "\x0C",
|
||||
r: "\x12",
|
||||
u: "\x15",
|
||||
w: "\x17",
|
||||
z: "\x1A",
|
||||
};
|
||||
|
||||
const DEFAULT_QUICK_KEYS = [
|
||||
"/",
|
||||
"|",
|
||||
"~",
|
||||
"-",
|
||||
"_",
|
||||
"#",
|
||||
"\\",
|
||||
'"',
|
||||
"'",
|
||||
";",
|
||||
":",
|
||||
"!",
|
||||
"&",
|
||||
];
|
||||
|
||||
const LS_KEY = "termix:mobileQuickKeys";
|
||||
|
||||
function loadQuickKeys(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.every((v) => typeof v === "string"))
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return DEFAULT_QUICK_KEYS;
|
||||
}
|
||||
|
||||
function saveQuickKeys(keys: string[]) {
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(keys));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// Shared button styles
|
||||
const KEY_BASE =
|
||||
"flex items-center justify-center rounded border transition-colors select-none touch-none active:scale-95 shrink-0";
|
||||
const KEY_NORMAL = "border-border bg-muted/50 text-foreground hover:bg-muted";
|
||||
const KEY_ACTIVE =
|
||||
"border-accent-brand bg-accent-brand/20 text-accent-brand shadow-[0_0_0_1px_color-mix(in_oklab,var(--accent-brand)_30%,transparent)]";
|
||||
const KEY_MD = "h-9 px-3 min-w-[2.75rem] text-xs font-medium";
|
||||
const KEY_SM = "h-9 w-9";
|
||||
const SEP = "w-px h-5 bg-border mx-0.5 shrink-0";
|
||||
|
||||
// --- QuickKeysSheet ---
|
||||
|
||||
interface QuickKeysSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
quickKeys: string[];
|
||||
onUpdateKeys: (keys: string[]) => void;
|
||||
}
|
||||
|
||||
function QuickKeysSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
quickKeys,
|
||||
onUpdateKeys,
|
||||
}: QuickKeysSheetProps) {
|
||||
const { t } = useTranslation();
|
||||
const [newSymbol, setNewSymbol] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setNewSymbol("");
|
||||
}, [open]);
|
||||
|
||||
function addKey() {
|
||||
const sym = newSymbol.trim();
|
||||
if (!sym || quickKeys.includes(sym) || sym.length > 8) {
|
||||
setNewSymbol("");
|
||||
return;
|
||||
}
|
||||
onUpdateKeys([...quickKeys, sym]);
|
||||
setNewSymbol("");
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
|
||||
function removeKey(index: number) {
|
||||
onUpdateKeys(quickKeys.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
function resetDefaults() {
|
||||
onUpdateKeys(DEFAULT_QUICK_KEYS);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className="max-h-[70vh] flex flex-col border-border"
|
||||
>
|
||||
<SheetHeader className="flex-row items-start justify-between pr-8">
|
||||
<div>
|
||||
<SheetTitle>{t("mobileKeyboard.quickKeysTitle")}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t("mobileKeyboard.quickKeysDesc")}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={resetDefaults}
|
||||
>
|
||||
<RotateCcw className="size-3 mr-1" />
|
||||
{t("mobileKeyboard.resetDefaults")}
|
||||
</Button>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{quickKeys.map((sym, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-1 h-8 pl-3 pr-1 rounded border border-border bg-muted/50 text-xs font-medium text-foreground"
|
||||
>
|
||||
<span className="font-mono">{sym}</span>
|
||||
<button
|
||||
className="size-5 flex items-center justify-center rounded hover:bg-destructive hover:text-destructive-foreground transition-colors"
|
||||
onClick={() => removeKey(i)}
|
||||
title={t("mobileKeyboard.removeQuickKey")}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 px-4 py-2 border-t border-border">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={newSymbol}
|
||||
onChange={(e) => setNewSymbol(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") addKey();
|
||||
}}
|
||||
maxLength={8}
|
||||
placeholder={t("mobileKeyboard.quickKeyPlaceholder")}
|
||||
className="h-9 text-xs font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 shrink-0"
|
||||
onClick={addKey}
|
||||
disabled={!newSymbol.trim() || quickKeys.includes(newSymbol.trim())}
|
||||
>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
{t("mobileKeyboard.addQuickKey")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="pt-0">
|
||||
<Button className="w-full" onClick={() => onOpenChange(false)}>
|
||||
{t("mobileKeyboard.done")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
// --- CtrlPanel ---
|
||||
|
||||
interface CtrlPanelProps {
|
||||
onSend: (letter: string) => void;
|
||||
}
|
||||
|
||||
function CtrlPanel({ onSend }: CtrlPanelProps) {
|
||||
const CTRL_KEYS = ["c", "d", "l", "u", "z", "a", "r", "w", "k"];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-muted/30 border-t border-border overflow-x-auto">
|
||||
{CTRL_KEYS.map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD, "font-mono")}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
onSend(k);
|
||||
}}
|
||||
title={`Ctrl+${k.toUpperCase()}`}
|
||||
>
|
||||
^{k.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- MobileTerminalKeyboard ---
|
||||
|
||||
export function MobileTerminalKeyboard({
|
||||
terminalRef,
|
||||
}: MobileTerminalKeyboardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [ctrlActive, setCtrlActive] = useState(false);
|
||||
const [shiftActive, setShiftActive] = useState(false);
|
||||
const [quickKeys, setQuickKeys] = useState<string[]>(loadQuickKeys);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
|
||||
function send(seq: string) {
|
||||
terminalRef.current?.sendInput?.(seq);
|
||||
}
|
||||
|
||||
async function handlePaste() {
|
||||
try {
|
||||
const text = window.electronClipboard
|
||||
? await window.electronClipboard.readText()
|
||||
: ((await navigator.clipboard?.readText?.()) ?? "");
|
||||
if (text) terminalRef.current?.paste?.(text);
|
||||
else toast.error(t("terminal.clipboardReadFailed"));
|
||||
} catch {
|
||||
toast.error(t("terminal.clipboardReadFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCtrl() {
|
||||
setCtrlActive((v) => !v);
|
||||
setShiftActive(false);
|
||||
}
|
||||
|
||||
function toggleShift() {
|
||||
setShiftActive((v) => !v);
|
||||
setCtrlActive(false);
|
||||
}
|
||||
|
||||
function sendArrow(normalSeq: string, appSeq: string, shiftSeq: string) {
|
||||
if (shiftActive) {
|
||||
send(shiftSeq);
|
||||
setShiftActive(false);
|
||||
return;
|
||||
}
|
||||
const appMode =
|
||||
terminalRef.current?.getApplicationCursorKeysMode?.() ?? false;
|
||||
send(appMode ? appSeq : normalSeq);
|
||||
}
|
||||
|
||||
function handleTab() {
|
||||
send(shiftActive ? "\x1b[Z" : "\t");
|
||||
setShiftActive(false);
|
||||
}
|
||||
|
||||
function handleCtrlKey(letter: string) {
|
||||
const seq = CTRL_MAP[letter];
|
||||
if (seq) send(seq);
|
||||
setCtrlActive(false);
|
||||
}
|
||||
|
||||
function updateQuickKeys(next: string[]) {
|
||||
setQuickKeys(next);
|
||||
saveQuickKeys(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="md:hidden flex flex-col bg-sidebar border-t border-border shrink-0">
|
||||
{/* Row 1 — special keys */}
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 overflow-x-auto">
|
||||
{/* ESC */}
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send("\x1b");
|
||||
}}
|
||||
title={t("mobileKeyboard.esc")}
|
||||
>
|
||||
{t("mobileKeyboard.esc")}
|
||||
</button>
|
||||
|
||||
{/* Tab / back-tab */}
|
||||
<button
|
||||
className={cn(
|
||||
KEY_BASE,
|
||||
KEY_NORMAL,
|
||||
KEY_MD,
|
||||
shiftActive && "ring-1 ring-accent-brand/50",
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleTab();
|
||||
}}
|
||||
title={shiftActive ? "Shift+Tab" : t("mobileKeyboard.tab")}
|
||||
>
|
||||
{shiftActive ? t("mobileKeyboard.backTab") : t("mobileKeyboard.tab")}
|
||||
</button>
|
||||
|
||||
{/* Paste */}
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
handlePaste();
|
||||
}}
|
||||
title={t("mobileKeyboard.paste")}
|
||||
>
|
||||
<Clipboard className="size-4" />
|
||||
</button>
|
||||
|
||||
<div className={SEP} />
|
||||
|
||||
{/* Ctrl */}
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_MD, ctrlActive ? KEY_ACTIVE : KEY_NORMAL)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
toggleCtrl();
|
||||
}}
|
||||
title={t("mobileKeyboard.ctrl")}
|
||||
>
|
||||
{t("mobileKeyboard.ctrl")}
|
||||
</button>
|
||||
|
||||
{/* Shift */}
|
||||
<button
|
||||
className={cn(
|
||||
KEY_BASE,
|
||||
KEY_MD,
|
||||
shiftActive ? KEY_ACTIVE : KEY_NORMAL,
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
toggleShift();
|
||||
}}
|
||||
title={t("mobileKeyboard.shift")}
|
||||
>
|
||||
{t("mobileKeyboard.shift")}
|
||||
</button>
|
||||
|
||||
<div className={SEP} />
|
||||
|
||||
{/* Arrow keys */}
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendArrow("\x1b[A", "\x1bOA", "\x1b[1;2A");
|
||||
}}
|
||||
title={t("mobileKeyboard.arrowUp")}
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendArrow("\x1b[B", "\x1bOB", "\x1b[1;2B");
|
||||
}}
|
||||
title={t("mobileKeyboard.arrowDown")}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendArrow("\x1b[D", "\x1bOD", "\x1b[1;2D");
|
||||
}}
|
||||
title={t("mobileKeyboard.arrowLeft")}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
sendArrow("\x1b[C", "\x1bOC", "\x1b[1;2C");
|
||||
}}
|
||||
title={t("mobileKeyboard.arrowRight")}
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
|
||||
<div className={SEP} />
|
||||
|
||||
{/* Home / End */}
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send("\x1b[H");
|
||||
}}
|
||||
title={t("mobileKeyboard.home")}
|
||||
>
|
||||
{t("mobileKeyboard.home")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send("\x1b[F");
|
||||
}}
|
||||
title={t("mobileKeyboard.end")}
|
||||
>
|
||||
{t("mobileKeyboard.end")}
|
||||
</button>
|
||||
|
||||
<div className={SEP} />
|
||||
|
||||
{/* PgUp / PgDn / Del */}
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send("\x1b[5~");
|
||||
}}
|
||||
title={t("mobileKeyboard.pageUp")}
|
||||
>
|
||||
{t("mobileKeyboard.pageUp")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send("\x1b[6~");
|
||||
}}
|
||||
title={t("mobileKeyboard.pageDown")}
|
||||
>
|
||||
{t("mobileKeyboard.pageDown")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send("\x1b[3~");
|
||||
}}
|
||||
title={t("mobileKeyboard.delete")}
|
||||
>
|
||||
{t("mobileKeyboard.delete")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Ctrl combos panel */}
|
||||
{ctrlActive && <CtrlPanel onSend={handleCtrlKey} />}
|
||||
|
||||
{/* Row 2 — quick keys */}
|
||||
<div className="flex items-center gap-1 px-2 pb-1.5 overflow-x-auto">
|
||||
{quickKeys.map((sym, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_MD, "font-mono")}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
send(sym);
|
||||
}}
|
||||
title={sym}
|
||||
>
|
||||
{sym}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<div className="ml-auto shrink-0">
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
setSheetOpen(true);
|
||||
}}
|
||||
title={t("mobileKeyboard.editQuickKeys")}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<QuickKeysSheet
|
||||
open={sheetOpen}
|
||||
onOpenChange={setSheetOpen}
|
||||
quickKeys={quickKeys}
|
||||
onUpdateKeys={updateQuickKeys}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,63 @@
|
||||
import React from "react";
|
||||
import { Terminal } from "./Terminal";
|
||||
import { FullScreenAppWrapper, ConnectionScreen } from "@termix/plugin-sdk/ui";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
interface TerminalAppProps {
|
||||
hostId?: string;
|
||||
/** tmux session to attach to once the shell is ready (tmux monitor "Attach"). */
|
||||
tmuxSession?: string;
|
||||
}
|
||||
|
||||
// Only the session name travels in the URL (never a raw command), so a crafted
|
||||
// link cannot execute arbitrary input. `=` forces exact-name matching in tmux.
|
||||
function tmuxAttachCommand(session: string): string {
|
||||
return `tmux attach-session -t '=${session.replace(/'/g, "'\\''")}'`;
|
||||
}
|
||||
|
||||
const TerminalApp: React.FC<TerminalAppProps> = ({ hostId, tmuxSession }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, phase) => {
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="connecting"
|
||||
message={t("hosts.loadingHost")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("hosts.hostNotFound")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Terminal
|
||||
hostConfig={hostConfig}
|
||||
isVisible={true}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
showTitle={false}
|
||||
splitScreen={false}
|
||||
onClose={() => {}}
|
||||
executeCommand={
|
||||
tmuxSession ? tmuxAttachCommand(tmuxSession) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default TerminalApp;
|
||||
@@ -0,0 +1,135 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { TerminalSquare } from "lucide-react";
|
||||
import type { TerminalHandle, TerminalHostConfig } from "./Terminal";
|
||||
import { useIsMobile, useTabsSafe } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useTranslation,
|
||||
invokeAction,
|
||||
type TabProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type { Host } from "../types";
|
||||
|
||||
/** The session fields the shell keeps on a terminal tab. */
|
||||
interface TerminalTabRecord {
|
||||
id: string;
|
||||
instanceId?: string;
|
||||
restoredSessionId?: string | null;
|
||||
joinSharedSessionId?: string | null;
|
||||
joinShareId?: string | null;
|
||||
initialFilePath?: string;
|
||||
data?: {
|
||||
initialPath?: string;
|
||||
joinSharedSessionId?: string | null;
|
||||
joinShareId?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const CommandHistoryProvider = lazy(() =>
|
||||
import("./command-history/CommandHistoryContext").then((m) => ({
|
||||
default: m.CommandHistoryProvider,
|
||||
})),
|
||||
);
|
||||
export const loadTerminal = () =>
|
||||
import("./Terminal").then((m) => ({ default: m.Terminal }));
|
||||
const TerminalFeature = lazy(loadTerminal);
|
||||
const MobileTerminalKeyboard = lazy(() =>
|
||||
import("./MobileTerminalKeyboard").then((m) => ({
|
||||
default: m.MobileTerminalKeyboard,
|
||||
})),
|
||||
);
|
||||
|
||||
function TabChunkFallback() {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-background">
|
||||
<div className="size-5 rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground/70 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The SSH terminal as a tab. */
|
||||
export function TerminalTabContent({
|
||||
tab: tabRecord,
|
||||
host: hostRecord,
|
||||
sshHost,
|
||||
label,
|
||||
isVisible,
|
||||
isFocusedPane,
|
||||
handleRef,
|
||||
shell,
|
||||
}: TabProps) {
|
||||
const tab = tabRecord as unknown as TerminalTabRecord;
|
||||
const host = hostRecord as unknown as Host | undefined;
|
||||
const { t } = useTranslation();
|
||||
const { previewTerminalTheme } = useTabsSafe();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
|
||||
<TerminalSquare className="size-5 text-muted-foreground/30" />
|
||||
<span className="text-sm font-semibold text-muted-foreground/60">
|
||||
{t("terminal.noHostSelected")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<TabChunkFallback />}>
|
||||
<CommandHistoryProvider>
|
||||
<div className="flex flex-col h-full w-full">
|
||||
<div className="flex-1 min-h-0">
|
||||
<TerminalFeature
|
||||
ref={handleRef as React.Ref<TerminalHandle>}
|
||||
hostConfig={
|
||||
{
|
||||
...sshHost,
|
||||
sshPort: host.sshPort ?? host.port,
|
||||
instanceId: tab.instanceId ?? tab.id,
|
||||
restoredSessionId: tab.restoredSessionId ?? null,
|
||||
joinSharedSessionId:
|
||||
tab.data?.joinSharedSessionId ??
|
||||
tab.joinSharedSessionId ??
|
||||
null,
|
||||
joinShareId: tab.data?.joinShareId ?? tab.joinShareId ?? null,
|
||||
} as unknown as TerminalHostConfig
|
||||
}
|
||||
isVisible={isVisible}
|
||||
initialPath={tab.data?.initialPath ?? tab.initialFilePath}
|
||||
title={label}
|
||||
showTitle={false}
|
||||
splitScreen={false}
|
||||
onClose={() => shell.closeTab(tab.id)}
|
||||
onTitleChange={
|
||||
host.terminalConfig?.useSSHTitle
|
||||
? (title) => shell.renameTab(tab.id, title)
|
||||
: undefined
|
||||
}
|
||||
previewTheme={previewTerminalTheme}
|
||||
onOpenFileInEditor={(filePath) =>
|
||||
void invokeAction("files.openEditor", host, filePath)
|
||||
}
|
||||
onOpenFileManager={(path) =>
|
||||
void invokeAction("files.openHost", host, path)
|
||||
}
|
||||
isQuickConnect={String(host.id).startsWith("quick-connect-")}
|
||||
onSaveQuickConnect={
|
||||
shell.saveQuickConnect
|
||||
? () => shell.saveQuickConnect!(tabRecord, hostRecord!)
|
||||
: undefined
|
||||
}
|
||||
host={host}
|
||||
onOpenTab={(type) => shell.openTab(hostRecord!, type)}
|
||||
isFocusedPane={isFocusedPane}
|
||||
/>
|
||||
</div>
|
||||
{isMobile && (
|
||||
<MobileTerminalKeyboard
|
||||
terminalRef={handleRef as React.RefObject<TerminalHandle | null>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CommandHistoryProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,714 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ClipboardPaste,
|
||||
GripVertical,
|
||||
ImagePlus,
|
||||
LayoutGrid,
|
||||
LogOut,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
TERMINAL_TOOLBAR_SLOT,
|
||||
TERMINAL_TOOLBAR_STATUS_SLOT,
|
||||
type TerminalSlotApi,
|
||||
} from "./terminal-slots";
|
||||
import type { Host } from "../types";
|
||||
import {
|
||||
clampToolbarPosition,
|
||||
getResponsiveToolbarDensity,
|
||||
persistToolbarPosition,
|
||||
readStoredToolbarPosition,
|
||||
type ToolbarPosition,
|
||||
type ToolbarDensity,
|
||||
} from "./toolbar-geometry";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
cn,
|
||||
ActionSlot,
|
||||
ComponentSlot,
|
||||
useIsMobile,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useTranslation,
|
||||
useSlotContributions,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
type SelectedToolbarDensity = ToolbarDensity;
|
||||
|
||||
const DENSITY_STORAGE_KEY = "termix-terminal-toolbar-density";
|
||||
|
||||
/** Plugins contribute toolbar buttons here. Declared by the ssh-terminal plugin. */
|
||||
const TOOLBAR_SLOT_ID = TERMINAL_TOOLBAR_SLOT;
|
||||
|
||||
const DENSITY_OPTIONS: { value: ToolbarDensity }[] = [
|
||||
{ value: "icon" },
|
||||
{ value: "labeled" },
|
||||
{ value: "expanded" },
|
||||
];
|
||||
const DENSITIES = DENSITY_OPTIONS.map((option) => option.value);
|
||||
|
||||
function readStoredDensity(): ToolbarDensity {
|
||||
if (typeof window === "undefined") return "labeled";
|
||||
try {
|
||||
const stored = window.localStorage.getItem(DENSITY_STORAGE_KEY);
|
||||
return DENSITIES.includes(stored as ToolbarDensity)
|
||||
? (stored as ToolbarDensity)
|
||||
: "labeled";
|
||||
} catch {
|
||||
return "labeled";
|
||||
}
|
||||
}
|
||||
|
||||
const CONTROL =
|
||||
"inline-flex min-h-8 min-w-8 items-center justify-center gap-1.5 rounded-sm px-2 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground active:translate-y-px active:bg-muted/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-40 data-[state=open]:bg-muted data-[state=open]:text-foreground";
|
||||
const SEPARATOR = "mx-0.5 h-5 w-px shrink-0 bg-border";
|
||||
|
||||
interface TerminalToolbarProps {
|
||||
host: Host;
|
||||
terminalIdentity?: string;
|
||||
isConnected: boolean;
|
||||
isTmuxAttached: boolean;
|
||||
onTmuxDetach: () => void;
|
||||
isImageUploading: boolean;
|
||||
onUploadImage: (file: File) => void | Promise<void>;
|
||||
onPasteImage: () => void | Promise<void>;
|
||||
isFocused: boolean;
|
||||
/** Hides every contributed action, e.g. while the toolbar is measuring. */
|
||||
actionsEnabled?: boolean;
|
||||
/** Handed to contributed actions when they are invoked. */
|
||||
slotApi?: TerminalSlotApi;
|
||||
}
|
||||
|
||||
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
host,
|
||||
terminalIdentity,
|
||||
isConnected,
|
||||
isTmuxAttached,
|
||||
onTmuxDetach,
|
||||
isImageUploading,
|
||||
onUploadImage,
|
||||
onPasteImage,
|
||||
isFocused,
|
||||
actionsEnabled = true,
|
||||
slotApi,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Drives both the measurement copy and the separator, so the toolbar sizes
|
||||
// itself correctly whether or not anything contributed.
|
||||
const allSlotContributions = useSlotContributions(TOOLBAR_SLOT_ID, { host });
|
||||
const statusContributions = useSlotContributions(
|
||||
TERMINAL_TOOLBAR_STATUS_SLOT,
|
||||
{ host },
|
||||
);
|
||||
const slotContributions = useMemo(
|
||||
() =>
|
||||
allSlotContributions.filter(
|
||||
(contribution) => contribution.kind !== "component",
|
||||
),
|
||||
[allSlotContributions],
|
||||
);
|
||||
const [density, setDensity] =
|
||||
useState<SelectedToolbarDensity>(readStoredDensity);
|
||||
const [responsiveDensity, setResponsiveDensity] =
|
||||
useState<SelectedToolbarDensity>(readStoredDensity);
|
||||
const [position, setPosition] = useState<ToolbarPosition>(
|
||||
readStoredToolbarPosition,
|
||||
);
|
||||
const positionRef = useRef(position);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [densityOpen, setDensityOpen] = useState(false);
|
||||
const [imageError, setImageError] = useState<string | null>(null);
|
||||
const [retryImageAction, setRetryImageAction] = useState<(() => void) | null>(
|
||||
null,
|
||||
);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const hideToolbarRef = useRef<HTMLButtonElement>(null);
|
||||
const pendingExpansionFocusRef = useRef(false);
|
||||
const pendingRightEdgeRef = useRef<number | null>(null);
|
||||
const mountedRef = useRef(false);
|
||||
const imageGenerationRef = useRef(0);
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const measurementRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startY: number;
|
||||
basePosition: ToolbarPosition;
|
||||
} | null>(null);
|
||||
const capturedPointerIdRef = useRef<number | null>(null);
|
||||
const dragMovedRef = useRef(false);
|
||||
const densityId = useId();
|
||||
const [desktopViewportReady, setDesktopViewportReady] = useState<boolean>();
|
||||
const hostIdentity = terminalIdentity ?? `${host.id ?? "unknown"}`;
|
||||
positionRef.current = position;
|
||||
const effectiveDensity: ToolbarDensity = isFocused
|
||||
? responsiveDensity
|
||||
: "icon";
|
||||
const showStats =
|
||||
effectiveDensity === "expanded" && statusContributions.length > 0;
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setDesktopViewportReady(
|
||||
typeof window !== "undefined" && window.innerWidth >= 768,
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile === false) setDesktopViewportReady(true);
|
||||
}, [isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
if (desktopViewportReady !== true || isMobile !== false) return;
|
||||
try {
|
||||
window.localStorage.setItem(DENSITY_STORAGE_KEY, density);
|
||||
} catch {
|
||||
// Storage can be unavailable in hardened browser contexts.
|
||||
}
|
||||
}, [density, desktopViewportReady, isMobile]);
|
||||
|
||||
// Picking an interface preset seeds this key, so pick the new value up
|
||||
// without needing the tab to remount.
|
||||
useEffect(() => {
|
||||
if (desktopViewportReady !== true || isMobile !== false) return;
|
||||
const handler = () => setDensity(readStoredDensity());
|
||||
window.addEventListener("terminalToolbarDensityChanged", handler);
|
||||
return () =>
|
||||
window.removeEventListener("terminalToolbarDensityChanged", handler);
|
||||
}, [desktopViewportReady, isMobile]);
|
||||
useEffect(() => {
|
||||
if (desktopViewportReady !== true || isMobile !== false) return;
|
||||
const measurement = measurementRef.current;
|
||||
const host = hostRef.current;
|
||||
if (!measurement || !host) return;
|
||||
let frameId: number | null = null;
|
||||
let remeasurements = 0;
|
||||
const measure = () => {
|
||||
frameId = null;
|
||||
const available = host.getBoundingClientRect().width;
|
||||
const required = measurement.getBoundingClientRect().width;
|
||||
if (available > 0 && required > 0) {
|
||||
setResponsiveDensity((current) =>
|
||||
getResponsiveToolbarDensity(density, current, available, required),
|
||||
);
|
||||
remeasurements = 0;
|
||||
} else if (remeasurements < 2) {
|
||||
remeasurements += 1;
|
||||
frameId = window.requestAnimationFrame(measure);
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
if (frameId == null) frameId = window.requestAnimationFrame(measure);
|
||||
};
|
||||
schedule();
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined"
|
||||
? null
|
||||
: new ResizeObserver(schedule);
|
||||
observer?.observe(host);
|
||||
observer?.observe(measurement);
|
||||
return () => {
|
||||
if (frameId != null) window.cancelAnimationFrame(frameId);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [
|
||||
density,
|
||||
desktopViewportReady,
|
||||
isConnected,
|
||||
isMobile,
|
||||
isTmuxAttached,
|
||||
actionsEnabled,
|
||||
slotContributions,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
imageGenerationRef.current += 1;
|
||||
dragRef.current = null;
|
||||
capturedPointerIdRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (desktopViewportReady !== true || isMobile !== false) return;
|
||||
const toolbar = toolbarRef.current;
|
||||
const host = hostRef.current;
|
||||
if (!toolbar || !host || dragRef.current) return;
|
||||
const hostRect = host.getBoundingClientRect();
|
||||
const toolbarRect = toolbar.getBoundingClientRect();
|
||||
const extremeOffset =
|
||||
hostRect.width > 0 &&
|
||||
hostRect.height > 0 &&
|
||||
Math.max(
|
||||
Math.abs(positionRef.current.x),
|
||||
Math.abs(positionRef.current.y),
|
||||
) >
|
||||
Math.max(hostRect.width, hostRect.height) * 4;
|
||||
if (extremeOffset) {
|
||||
positionRef.current = { x: 0, y: 0 };
|
||||
setPosition({ x: 0, y: 0 });
|
||||
persistToolbarPosition({ x: 0, y: 0 });
|
||||
return;
|
||||
}
|
||||
if (toolbarRect.width > 0 && toolbarRect.height > 0) {
|
||||
const pendingRightEdge = pendingRightEdgeRef.current;
|
||||
pendingRightEdgeRef.current = null;
|
||||
const baseRight = toolbarRect.right - positionRef.current.x;
|
||||
const next = clampToolbarPosition(
|
||||
{
|
||||
x:
|
||||
pendingRightEdge == null
|
||||
? positionRef.current.x
|
||||
: pendingRightEdge - baseRight,
|
||||
y: positionRef.current.y,
|
||||
},
|
||||
toolbarRect,
|
||||
hostRect,
|
||||
positionRef.current,
|
||||
);
|
||||
if (
|
||||
next.x !== positionRef.current.x ||
|
||||
next.y !== positionRef.current.y
|
||||
) {
|
||||
positionRef.current = next;
|
||||
setPosition(next);
|
||||
persistToolbarPosition(next);
|
||||
}
|
||||
}
|
||||
if (!collapsed && pendingExpansionFocusRef.current) {
|
||||
pendingExpansionFocusRef.current = false;
|
||||
hideToolbarRef.current?.focus();
|
||||
}
|
||||
}, [
|
||||
collapsed,
|
||||
density,
|
||||
desktopViewportReady,
|
||||
effectiveDensity,
|
||||
isConnected,
|
||||
isMobile,
|
||||
isTmuxAttached,
|
||||
responsiveDensity,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
imageGenerationRef.current += 1;
|
||||
setImageError(null);
|
||||
setRetryImageAction(null);
|
||||
}, [isConnected, hostIdentity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (collapsed || !pendingExpansionFocusRef.current) return;
|
||||
pendingExpansionFocusRef.current = false;
|
||||
hideToolbarRef.current?.focus();
|
||||
}, [collapsed]);
|
||||
|
||||
if (!isConnected || desktopViewportReady !== true || isMobile !== false)
|
||||
return null;
|
||||
|
||||
const setAndStoreDensity = (next: ToolbarDensity) => {
|
||||
setDensity(next);
|
||||
};
|
||||
const reportImageResult = (
|
||||
action: () => void | Promise<void>,
|
||||
retry: () => void,
|
||||
) => {
|
||||
const generation = ++imageGenerationRef.current;
|
||||
let result: void | Promise<void>;
|
||||
try {
|
||||
result = action();
|
||||
} catch (error) {
|
||||
result = Promise.reject(error);
|
||||
}
|
||||
void Promise.resolve(result).then(
|
||||
() => {
|
||||
if (!mountedRef.current || generation !== imageGenerationRef.current)
|
||||
return;
|
||||
setImageError(null);
|
||||
setRetryImageAction(null);
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!mountedRef.current || generation !== imageGenerationRef.current)
|
||||
return;
|
||||
setImageError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("terminalToolbar.imageActionFailed"),
|
||||
);
|
||||
setRetryImageAction(() => retry);
|
||||
},
|
||||
);
|
||||
};
|
||||
const uploadFile = (file: File) => {
|
||||
setImageError(null);
|
||||
reportImageResult(
|
||||
() => onUploadImage(file),
|
||||
() => uploadFile(file),
|
||||
);
|
||||
};
|
||||
const pasteImage = () => {
|
||||
setImageError(null);
|
||||
// Invoke clipboard access in this click's user-activation call chain.
|
||||
reportImageResult(onPasteImage, pasteImage);
|
||||
};
|
||||
const chooseFile = () => fileInputRef.current?.click();
|
||||
const handleGrabPointerDown = (
|
||||
event: React.PointerEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
if (event.button !== 0 || event.pointerType === "touch") return;
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
capturedPointerIdRef.current = event.pointerId;
|
||||
dragMovedRef.current = false;
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
basePosition: positionRef.current,
|
||||
};
|
||||
};
|
||||
const handleGrabPointerMove = (
|
||||
event: React.PointerEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
const deltaX = event.clientX - drag.startX;
|
||||
const deltaY = event.clientY - drag.startY;
|
||||
const toolbar = toolbarRef.current;
|
||||
const host = hostRef.current;
|
||||
if (!toolbar || !host) return;
|
||||
const next = clampToolbarPosition(
|
||||
{
|
||||
x: drag.basePosition.x + deltaX,
|
||||
y: drag.basePosition.y + deltaY,
|
||||
},
|
||||
toolbar.getBoundingClientRect(),
|
||||
host.getBoundingClientRect(),
|
||||
positionRef.current,
|
||||
);
|
||||
if (event.clientX !== drag.startX || event.clientY !== drag.startY) {
|
||||
dragMovedRef.current = true;
|
||||
}
|
||||
positionRef.current = next;
|
||||
setPosition(next);
|
||||
};
|
||||
const handleGrabPointerEnd = (
|
||||
event: React.PointerEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
persistToolbarPosition(positionRef.current);
|
||||
dragRef.current = null;
|
||||
if (capturedPointerIdRef.current === event.pointerId) {
|
||||
capturedPointerIdRef.current = null;
|
||||
if (event.type !== "lostpointercapture") {
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
} catch {
|
||||
// The browser may have already released capture for this pointer.
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
const densityLabels: Record<ToolbarDensity, string> = {
|
||||
icon: t("terminalToolbar.layoutIcon"),
|
||||
labeled: t("terminalToolbar.layoutLabeled"),
|
||||
expanded: t("terminalToolbar.layoutExpanded"),
|
||||
};
|
||||
|
||||
const densitySelect = (
|
||||
<Select
|
||||
value={density}
|
||||
open={densityOpen}
|
||||
onOpenChange={setDensityOpen}
|
||||
onValueChange={(value) => setAndStoreDensity(value as ToolbarDensity)}
|
||||
>
|
||||
<SelectTrigger
|
||||
id={densityId}
|
||||
size="sm"
|
||||
aria-label={t("terminalToolbar.layout")}
|
||||
title={t("terminalToolbar.layout")}
|
||||
className={cn(
|
||||
CONTROL,
|
||||
"h-8 border-0 bg-transparent px-2 shadow-none dark:bg-transparent dark:hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<LayoutGrid className="size-4 shrink-0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DENSITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{densityLabels[option.value]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
const imageButtons = (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={CONTROL}
|
||||
aria-label={t("terminalToolbar.uploadImage")}
|
||||
title={t("terminalToolbar.uploadImage")}
|
||||
disabled={isImageUploading}
|
||||
onClick={chooseFile}
|
||||
>
|
||||
<ImagePlus className="size-4 shrink-0" />
|
||||
{effectiveDensity !== "icon" && t("terminalToolbar.upload")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={CONTROL}
|
||||
aria-label={t("terminalToolbar.pasteImage")}
|
||||
title={t("terminalToolbar.pasteImage")}
|
||||
disabled={isImageUploading}
|
||||
onClick={pasteImage}
|
||||
>
|
||||
<ClipboardPaste className="size-4 shrink-0" />
|
||||
{effectiveDensity !== "icon" && t("terminalToolbar.paste")}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
const expandButton = (
|
||||
<button
|
||||
type="button"
|
||||
className={CONTROL}
|
||||
aria-label={t("terminalToolbar.showToolbar")}
|
||||
title={t("terminalToolbar.showToolbar")}
|
||||
onClick={() => {
|
||||
pendingRightEdgeRef.current =
|
||||
toolbarRef.current?.getBoundingClientRect().right ?? null;
|
||||
pendingExpansionFocusRef.current = true;
|
||||
setCollapsed(false);
|
||||
}}
|
||||
>
|
||||
<Maximize2 className="size-4" />
|
||||
<span className="sr-only">{t("terminalToolbar.showToolbar")}</span>
|
||||
</button>
|
||||
);
|
||||
const grabButton = () => (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(CONTROL, "cursor-grab active:cursor-grabbing")}
|
||||
aria-label={t("terminalToolbar.moveToolbar")}
|
||||
title={t("terminalToolbar.moveToolbarHint")}
|
||||
onPointerDown={handleGrabPointerDown}
|
||||
onPointerMove={handleGrabPointerMove}
|
||||
onPointerUp={handleGrabPointerEnd}
|
||||
onPointerCancel={handleGrabPointerEnd}
|
||||
onLostPointerCapture={handleGrabPointerEnd}
|
||||
>
|
||||
<GripVertical className="size-4" />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-terminal-toolbar-host
|
||||
className={cn(
|
||||
"pointer-events-none @container absolute inset-0 z-[110] flex items-end justify-center pb-2",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
disabled={isImageUploading}
|
||||
aria-hidden="true"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (file) uploadFile(file);
|
||||
}}
|
||||
/>
|
||||
<span role="status" aria-live="polite" className="sr-only">
|
||||
{isImageUploading ? t("terminalToolbar.uploadingImage") : ""}
|
||||
</span>
|
||||
{imageError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="pointer-events-auto absolute bottom-full mb-2 flex min-h-11 max-w-sm items-center gap-2 rounded-sm border border-destructive bg-background p-2 text-xs text-destructive shadow-lg"
|
||||
>
|
||||
<span>{imageError}</span>
|
||||
{retryImageAction && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(CONTROL, "text-foreground")}
|
||||
title={t("terminalToolbar.retry")}
|
||||
disabled={isImageUploading}
|
||||
onClick={retryImageAction}
|
||||
>
|
||||
{t("terminalToolbar.retry")}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(CONTROL, "px-2")}
|
||||
aria-label={t("terminalToolbar.dismissError")}
|
||||
title={t("terminalToolbar.dismissError")}
|
||||
onClick={() => {
|
||||
setImageError(null);
|
||||
setRetryImageAction(null);
|
||||
}}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={measurementRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex whitespace-nowrap"
|
||||
>
|
||||
{isTmuxAttached && (
|
||||
<span className={CONTROL}>
|
||||
<LogOut className="size-4" />
|
||||
{density !== "icon" && t("terminalToolbar.detachTmux")}
|
||||
</span>
|
||||
)}
|
||||
<span className={CONTROL}>
|
||||
<ImagePlus className="size-4" />
|
||||
{density !== "icon" && t("terminalToolbar.upload")}
|
||||
</span>
|
||||
<span className={CONTROL}>
|
||||
<ClipboardPaste className="size-4" />
|
||||
{density !== "icon" && t("terminalToolbar.paste")}
|
||||
</span>
|
||||
{actionsEnabled &&
|
||||
slotContributions.map((contribution) => (
|
||||
<span key={contribution.actionId} className={CONTROL}>
|
||||
{contribution.icon && <contribution.icon className="size-4" />}
|
||||
{density !== "icon" && t(contribution.titleKey)}
|
||||
</span>
|
||||
))}
|
||||
<span className={cn(CONTROL, "h-8 px-2")}>
|
||||
<LayoutGrid className="size-4" />
|
||||
<ChevronDown className="size-4" />
|
||||
</span>
|
||||
<span className={CONTROL}>
|
||||
<Minimize2 className="size-4" />
|
||||
</span>
|
||||
<span className={CONTROL}>
|
||||
<GripVertical className="size-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
data-terminal-toolbar-wide
|
||||
className={cn(
|
||||
"pointer-events-auto transition-opacity duration-300 hover:duration-0 focus-within:duration-0 hover:opacity-100 focus-within:opacity-100",
|
||||
collapsed || densityOpen ? "opacity-100" : "opacity-30",
|
||||
)}
|
||||
style={{
|
||||
transform: `translate(${position.x}px, ${position.y}px)`,
|
||||
}}
|
||||
>
|
||||
{collapsed ? (
|
||||
<div className="terminal-toolbar-collapsed-shell flex rounded-sm border border-border bg-background/90 p-0.5 shadow-lg backdrop-blur-sm">
|
||||
{expandButton}
|
||||
{grabButton()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col overflow-hidden rounded-sm border border-border bg-background/90 shadow-lg backdrop-blur-sm">
|
||||
{showStats && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-1 border-b border-border px-1.5 py-0.5">
|
||||
<ComponentSlot
|
||||
slotId={TERMINAL_TOOLBAR_STATUS_SLOT}
|
||||
when={{ host }}
|
||||
props={{
|
||||
host,
|
||||
isConnected,
|
||||
active:
|
||||
isConnected &&
|
||||
desktopViewportReady === true &&
|
||||
isMobile === false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn("flex gap-0.5 p-0.5", "items-center")}>
|
||||
{isTmuxAttached && (
|
||||
<button
|
||||
type="button"
|
||||
className={CONTROL}
|
||||
aria-label={t("terminalToolbar.detachTmuxDescription")}
|
||||
title={t("terminalToolbar.detachTmuxDescription")}
|
||||
onClick={onTmuxDetach}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
{effectiveDensity !== "icon" &&
|
||||
t("terminalToolbar.detachTmux")}
|
||||
</button>
|
||||
)}
|
||||
{isTmuxAttached && <div className={SEPARATOR} />}
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("terminalToolbar.image")}
|
||||
className="flex items-center gap-0.5"
|
||||
>
|
||||
{imageButtons}
|
||||
</div>
|
||||
{actionsEnabled && slotContributions.length > 0 && (
|
||||
<>
|
||||
<div className={SEPARATOR} />
|
||||
<ActionSlot
|
||||
slotId={TOOLBAR_SLOT_ID}
|
||||
className={CONTROL}
|
||||
enabled={actionsEnabled}
|
||||
hideLabels={effectiveDensity === "icon"}
|
||||
when={{ host }}
|
||||
context={() => [slotApi]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className={SEPARATOR} />
|
||||
{densitySelect}
|
||||
<button
|
||||
ref={hideToolbarRef}
|
||||
type="button"
|
||||
className={CONTROL}
|
||||
aria-label={t("terminalToolbar.hideToolbar")}
|
||||
title={t("terminalToolbar.hideToolbar")}
|
||||
onClick={() => {
|
||||
pendingRightEdgeRef.current =
|
||||
toolbarRef.current?.getBoundingClientRect().right ?? null;
|
||||
setCollapsed(true);
|
||||
}}
|
||||
>
|
||||
<Minimize2 className="size-4" />
|
||||
<span className="sr-only">
|
||||
{t("terminalToolbar.hideToolbar")}
|
||||
</span>
|
||||
</button>
|
||||
<div className={SEPARATOR} />
|
||||
{grabButton()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from "react";
|
||||
import { Terminal, Monitor, Users, Clock } from "lucide-react";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
interface TmuxSessionInfo {
|
||||
name: string;
|
||||
created: number;
|
||||
lastActivity: number;
|
||||
windows: number;
|
||||
attachedClients: number;
|
||||
}
|
||||
|
||||
interface TmuxSessionPickerProps {
|
||||
isOpen: boolean;
|
||||
sessions: TmuxSessionInfo[];
|
||||
onSelect: (sessionName: string) => void;
|
||||
onCreateNew: () => void;
|
||||
onCancel: () => void;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
function formatTimestamp(
|
||||
unix: number,
|
||||
t: (key: string, opts?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
if (!unix) return "---";
|
||||
const date = new Date(unix * 1000);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDays = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffMin < 1) return t("terminal.tmuxTimeJustNow");
|
||||
if (diffMin < 60) return t("terminal.tmuxTimeMinutes", { count: diffMin });
|
||||
if (diffHr < 24) return t("terminal.tmuxTimeHours", { count: diffHr });
|
||||
if (diffDays < 7) return t("terminal.tmuxTimeDays", { count: diffDays });
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
|
||||
export function TmuxSessionPicker({
|
||||
isOpen,
|
||||
sessions,
|
||||
onSelect,
|
||||
onCreateNew,
|
||||
onCancel,
|
||||
backgroundColor,
|
||||
}: TmuxSessionPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
|
||||
<div
|
||||
className="absolute inset-0 bg-canvas rounded-md"
|
||||
style={{ backgroundColor: backgroundColor || undefined }}
|
||||
/>
|
||||
<div className="bg-card border border-border w-full max-w-sm mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="p-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="size-4 text-accent-brand" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("terminal.tmuxSessionPickerTitle")}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground mt-1">
|
||||
{t("terminal.tmuxSessionPickerDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col max-h-60 overflow-y-auto">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.name}
|
||||
onClick={() => onSelect(session.name)}
|
||||
className="w-full text-left px-4 py-3 border-b border-border hover:bg-muted/50 transition-colors last:border-b-0"
|
||||
>
|
||||
<div className="font-mono text-xs font-bold">{session.name}</div>
|
||||
<div className="flex gap-3 mt-1 text-[10px] text-muted-foreground">
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t("terminal.tmuxWindows")}
|
||||
>
|
||||
<Monitor className="size-3" />
|
||||
{t("terminal.tmuxWindowCount", { count: session.windows })}
|
||||
</span>
|
||||
{session.attachedClients > 0 && (
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t("terminal.tmuxAttached")}
|
||||
>
|
||||
<Users className="size-3" />
|
||||
{t("terminal.tmuxAttachedCount", {
|
||||
count: session.attachedClients,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="flex items-center gap-1"
|
||||
title={t("terminal.tmuxLastActivity")}
|
||||
>
|
||||
<Clock className="size-3" />
|
||||
{formatTimestamp(session.lastActivity, t)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="p-4 border-t border-border flex justify-end gap-2">
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="ghost"
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCreateNew}
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("terminal.tmuxCreateNew")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { HostBackspaceMode } from "../types";
|
||||
|
||||
const CURSOR_SEQUENCES: Record<string, [normal: string, application: string]> =
|
||||
{
|
||||
ArrowUp: ["\x1b[A", "\x1bOA"],
|
||||
ArrowDown: ["\x1b[B", "\x1bOB"],
|
||||
ArrowRight: ["\x1b[C", "\x1bOC"],
|
||||
ArrowLeft: ["\x1b[D", "\x1bOD"],
|
||||
};
|
||||
|
||||
export function getAndroidHardwareKeySequence(
|
||||
event: Pick<
|
||||
KeyboardEvent,
|
||||
"key" | "ctrlKey" | "altKey" | "metaKey" | "shiftKey"
|
||||
>,
|
||||
applicationCursorKeys: boolean,
|
||||
backspaceMode: HostBackspaceMode | undefined,
|
||||
): string | null {
|
||||
if (event.ctrlKey || event.altKey || event.metaKey || event.shiftKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cursor = CURSOR_SEQUENCES[event.key];
|
||||
if (cursor) return cursor[applicationCursorKeys ? 1 : 0];
|
||||
if (event.key === "Delete") return "\x1b[3~";
|
||||
if (event.key === "Backspace" && backspaceMode !== "control-h") {
|
||||
return "\x7f";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { cn } from "@termix/plugin-sdk/ui";
|
||||
|
||||
interface CommandAutocompleteProps {
|
||||
suggestions: string[];
|
||||
selectedIndex: number;
|
||||
onSelect: (command: string) => void;
|
||||
position: { top: number; left: number };
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface CommandAutosuggestionProps {
|
||||
suggestion: string;
|
||||
position: { top: number; left: number };
|
||||
style?: React.CSSProperties;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export function CommandAutosuggestion({
|
||||
suggestion,
|
||||
position,
|
||||
style,
|
||||
visible,
|
||||
}: CommandAutosuggestionProps) {
|
||||
if (!visible || !suggestion) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none fixed z-[9998] whitespace-pre text-muted-foreground/45"
|
||||
style={{
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
fontVariantLigatures: "none",
|
||||
fontFeatureSettings: '"liga" 0, "calt" 0',
|
||||
textRendering: "optimizeLegibility",
|
||||
WebkitFontSmoothing: "antialiased",
|
||||
transition: "opacity 80ms ease-out",
|
||||
willChange: "contents",
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{suggestion}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandAutocomplete({
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
position,
|
||||
visible,
|
||||
}: CommandAutocompleteProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRef.current && containerRef.current) {
|
||||
selectedRef.current.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!visible || suggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const footerHeight = 32;
|
||||
const maxSuggestionsHeight = 240 - footerHeight;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed z-[9999] bg-popover border border-border shadow-lg min-w-[200px] max-w-[600px] flex flex-col"
|
||||
style={{
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
maxHeight: "240px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="overflow-y-auto thin-scrollbar"
|
||||
style={{ maxHeight: `${maxSuggestionsHeight}px` }}
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={index}
|
||||
ref={index === selectedIndex ? selectedRef : null}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors text-foreground",
|
||||
"hover:bg-muted",
|
||||
index === selectedIndex && "bg-accent text-accent-foreground",
|
||||
)}
|
||||
onClick={() => onSelect(suggestion)}
|
||||
onMouseEnter={() => {}}
|
||||
>
|
||||
{suggestion}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-border bg-muted/50 shrink-0">
|
||||
Tab/Enter to complete • ↑↓ to navigate • Esc to close
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
interface CommandHistoryContextType {
|
||||
commandHistory: string[];
|
||||
isLoading: boolean;
|
||||
setCommandHistory: (history: string[]) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
onSelectCommand?: (command: string) => void;
|
||||
setOnSelectCommand: (callback: (command: string) => void) => void;
|
||||
onDeleteCommand?: (command: string) => void;
|
||||
setOnDeleteCommand: (callback: (command: string) => void) => void;
|
||||
openCommandHistory: () => void;
|
||||
setOpenCommandHistory: (callback: () => void) => void;
|
||||
}
|
||||
|
||||
const CommandHistoryContext = createContext<
|
||||
CommandHistoryContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function CommandHistoryProvider({ children }: { children: ReactNode }) {
|
||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [onSelectCommand, setOnSelectCommand] = useState<
|
||||
((command: string) => void) | undefined
|
||||
>(undefined);
|
||||
const [onDeleteCommand, setOnDeleteCommand] = useState<
|
||||
((command: string) => void) | undefined
|
||||
>(undefined);
|
||||
const [openCommandHistory, setOpenCommandHistory] = useState<
|
||||
(() => void) | undefined
|
||||
>(() => () => {});
|
||||
|
||||
const handleSetOnSelectCommand = useCallback(
|
||||
(callback: (command: string) => void) => {
|
||||
setOnSelectCommand(() => callback);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSetOnDeleteCommand = useCallback(
|
||||
(callback: (command: string) => void) => {
|
||||
setOnDeleteCommand(() => callback);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSetOpenCommandHistory = useCallback((callback: () => void) => {
|
||||
setOpenCommandHistory(() => callback);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CommandHistoryContext.Provider
|
||||
value={{
|
||||
commandHistory,
|
||||
isLoading,
|
||||
setCommandHistory,
|
||||
setIsLoading,
|
||||
onSelectCommand,
|
||||
setOnSelectCommand: handleSetOnSelectCommand,
|
||||
onDeleteCommand,
|
||||
setOnDeleteCommand: handleSetOnDeleteCommand,
|
||||
openCommandHistory: openCommandHistory || (() => {}),
|
||||
setOpenCommandHistory: handleSetOpenCommandHistory,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CommandHistoryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCommandHistory() {
|
||||
const context = useContext(CommandHistoryContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useCommandHistory must be used within a CommandHistoryProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useRef, useCallback } from "react";
|
||||
import { usePluginApi } from "@termix/plugin-sdk/frontend";
|
||||
import { saveCommandToHistory } from "../../terminal-api";
|
||||
|
||||
const SENSITIVE_PATTERNS = [
|
||||
/\bpassw(or)?d\b/i,
|
||||
/\bsecret\b/i,
|
||||
/\btoken\b/i,
|
||||
/\bapi.?key\b/i,
|
||||
/\bPASS(WORD)?=/i,
|
||||
/\bAWS_SECRET/i,
|
||||
/\bmysql\b.*-p/i,
|
||||
/\bsudo\s+-S\b/,
|
||||
/\bhtpasswd\b/i,
|
||||
/\bsshpass\b/i,
|
||||
/\bcurl\b.*-u\s/i,
|
||||
/\bexport\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
|
||||
];
|
||||
|
||||
interface UseCommandTrackerOptions {
|
||||
hostId?: number;
|
||||
enabled?: boolean;
|
||||
onCommandExecuted?: (command: string) => void;
|
||||
}
|
||||
|
||||
interface CommandTrackerResult {
|
||||
trackInput: (data: string) => void;
|
||||
getCurrentCommand: () => string;
|
||||
clearCurrentCommand: () => void;
|
||||
updateCurrentCommand: (command: string) => void;
|
||||
}
|
||||
|
||||
export function useCommandTracker({
|
||||
hostId,
|
||||
enabled = true,
|
||||
onCommandExecuted,
|
||||
}: UseCommandTrackerOptions): CommandTrackerResult {
|
||||
const api = usePluginApi();
|
||||
const currentCommandRef = useRef<string>("");
|
||||
const isInEscapeSequenceRef = useRef<boolean>(false);
|
||||
|
||||
const trackInput = useCallback(
|
||||
(data: string) => {
|
||||
if (!enabled || !hostId) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const char = data[i];
|
||||
const charCode = char.charCodeAt(0);
|
||||
|
||||
if (charCode === 27) {
|
||||
isInEscapeSequenceRef.current = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isInEscapeSequenceRef.current) {
|
||||
if (
|
||||
(charCode >= 65 && charCode <= 90) ||
|
||||
(charCode >= 97 && charCode <= 122) ||
|
||||
charCode === 126
|
||||
) {
|
||||
isInEscapeSequenceRef.current = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode === 13 || charCode === 10) {
|
||||
const command = currentCommandRef.current.trim();
|
||||
|
||||
if (command.length > 0) {
|
||||
const isSensitive = SENSITIVE_PATTERNS.some((p) => p.test(command));
|
||||
|
||||
if (!isSensitive) {
|
||||
saveCommandToHistory(api, hostId, command).catch((error) => {
|
||||
console.error("Failed to save command to history:", error);
|
||||
});
|
||||
}
|
||||
|
||||
if (onCommandExecuted) {
|
||||
onCommandExecuted(command);
|
||||
}
|
||||
}
|
||||
|
||||
currentCommandRef.current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode === 8 || charCode === 127) {
|
||||
if (currentCommandRef.current.length > 0) {
|
||||
currentCommandRef.current = currentCommandRef.current.slice(0, -1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode === 3 || charCode === 4) {
|
||||
currentCommandRef.current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode === 21) {
|
||||
currentCommandRef.current = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charCode >= 32 && charCode <= 126) {
|
||||
currentCommandRef.current += char;
|
||||
}
|
||||
}
|
||||
},
|
||||
[enabled, hostId, onCommandExecuted],
|
||||
);
|
||||
|
||||
const getCurrentCommand = useCallback(() => {
|
||||
return currentCommandRef.current;
|
||||
}, []);
|
||||
|
||||
const clearCurrentCommand = useCallback(() => {
|
||||
currentCommandRef.current = "";
|
||||
}, []);
|
||||
|
||||
const updateCurrentCommand = useCallback((command: string) => {
|
||||
currentCommandRef.current = command;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
trackInput,
|
||||
getCurrentCommand,
|
||||
clearCurrentCommand,
|
||||
updateCurrentCommand,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export function fontSizeStorageKey(identity: unknown): string | null {
|
||||
return typeof identity === "string" || typeof identity === "number"
|
||||
? `terminal_fontsize_host_${identity}`
|
||||
: null;
|
||||
}
|
||||
|
||||
export function readFontSize(
|
||||
storage: Storage,
|
||||
key: string | null,
|
||||
configured: number,
|
||||
): number | null {
|
||||
if (!key) return null;
|
||||
try {
|
||||
const value = JSON.parse(storage.getItem(key) || "null");
|
||||
if (
|
||||
value?.configured === configured &&
|
||||
Number.isFinite(value.size) &&
|
||||
value.size > 0
|
||||
)
|
||||
return value.size;
|
||||
storage.removeItem(key);
|
||||
} catch {
|
||||
/* Storage may be unavailable. */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function saveFontSize(
|
||||
storage: Storage,
|
||||
key: string | null,
|
||||
configured: number,
|
||||
size: number,
|
||||
): void {
|
||||
if (!key) return;
|
||||
try {
|
||||
storage.setItem(key, JSON.stringify({ configured, size }));
|
||||
} catch {
|
||||
/* Storage may be unavailable. */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import React from "react";
|
||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
Separator,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
cn,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
interface TerminalSearchBarProps {
|
||||
visible: boolean;
|
||||
query: string;
|
||||
onQueryChange: (query: string) => void;
|
||||
onFindNext: () => void;
|
||||
onFindPrevious: () => void;
|
||||
onClose: () => void;
|
||||
caseSensitive: boolean;
|
||||
onToggleCaseSensitive: () => void;
|
||||
wholeWord: boolean;
|
||||
onToggleWholeWord: () => void;
|
||||
regex: boolean;
|
||||
onToggleRegex: () => void;
|
||||
resultIndex: number;
|
||||
resultCount: number;
|
||||
inputRef: React.RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
interface SearchToggleProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function SearchToggle({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
}: SearchToggleProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"font-mono text-[11px] leading-none text-muted-foreground",
|
||||
active &&
|
||||
"border-border bg-muted text-foreground hover:bg-muted dark:bg-muted/60",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function TerminalSearchBar({
|
||||
visible,
|
||||
query,
|
||||
onQueryChange,
|
||||
onFindNext,
|
||||
onFindPrevious,
|
||||
onClose,
|
||||
caseSensitive,
|
||||
onToggleCaseSensitive,
|
||||
wholeWord,
|
||||
onToggleWholeWord,
|
||||
regex,
|
||||
onToggleRegex,
|
||||
resultIndex,
|
||||
resultCount,
|
||||
inputRef,
|
||||
}: TerminalSearchBarProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) {
|
||||
onFindPrevious();
|
||||
} else {
|
||||
onFindNext();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop keys like Ctrl+C/Ctrl+V from bubbling up to xterm's document-level
|
||||
// paste/clipboard handling while the search input is focused.
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const hasQuery = query.length > 0;
|
||||
const noResults = hasQuery && resultCount === 0;
|
||||
const resultLabel = !hasQuery
|
||||
? ""
|
||||
: resultCount === 0
|
||||
? t("terminal.searchNoResults")
|
||||
: t("terminal.searchResultCount", {
|
||||
index: resultIndex + 1,
|
||||
count: resultCount,
|
||||
});
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={500}>
|
||||
<div
|
||||
className="absolute top-2 right-2 z-[130] flex items-center gap-1 border border-border bg-background/95 p-1 shadow-lg backdrop-blur-sm"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="relative flex items-center">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => onQueryChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t("terminal.searchPlaceholder")}
|
||||
aria-label={t("terminal.searchPlaceholder")}
|
||||
className={cn(
|
||||
"h-7 w-48 pr-16 font-mono",
|
||||
noResults &&
|
||||
"border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20",
|
||||
)}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none absolute right-2 font-mono text-[11px] tabular-nums select-none",
|
||||
noResults ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{resultLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
<SearchToggle
|
||||
label={t("terminal.searchCaseSensitive")}
|
||||
active={caseSensitive}
|
||||
onClick={onToggleCaseSensitive}
|
||||
>
|
||||
Aa
|
||||
</SearchToggle>
|
||||
<SearchToggle
|
||||
label={t("terminal.searchWholeWord")}
|
||||
active={wholeWord}
|
||||
onClick={onToggleWholeWord}
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
ab
|
||||
</SearchToggle>
|
||||
<SearchToggle
|
||||
label={t("terminal.searchRegex")}
|
||||
active={regex}
|
||||
onClick={onToggleRegex}
|
||||
>
|
||||
.*
|
||||
</SearchToggle>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 !h-5" />
|
||||
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={t("terminal.searchPrevious")}
|
||||
disabled={!hasQuery || resultCount === 0}
|
||||
onClick={onFindPrevious}
|
||||
>
|
||||
<ChevronUp />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("terminal.searchPrevious")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={t("terminal.searchNext")}
|
||||
disabled={!hasQuery || resultCount === 0}
|
||||
onClick={onFindNext}
|
||||
>
|
||||
<ChevronDown />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("terminal.searchNext")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
aria-label={t("terminal.searchClose")}
|
||||
onClick={onClose}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{t("terminal.searchClose")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
|
||||
/**
|
||||
* How much scrollback to hand out by default. Bounded on purpose: a
|
||||
* long-running session holds megabytes, and nothing consuming this wants all
|
||||
* of it.
|
||||
*/
|
||||
export const DEFAULT_BUFFER_CONTEXT_LINES = 200;
|
||||
|
||||
/**
|
||||
* Serializes the tail of a terminal's buffer to plain text.
|
||||
*
|
||||
* Wrapped rows are joined back onto the line they continue. xterm splits one
|
||||
* logical line across several buffer rows when it wraps, so emitting a newline
|
||||
* per row would chop long commands in half.
|
||||
*/
|
||||
export function getTerminalBufferText(
|
||||
terminal: Terminal | null | undefined,
|
||||
maxLines: number = DEFAULT_BUFFER_CONTEXT_LINES,
|
||||
): string {
|
||||
if (!terminal || maxLines <= 0) return "";
|
||||
|
||||
try {
|
||||
const buffer = terminal.buffer.active;
|
||||
const end = buffer.baseY + terminal.rows;
|
||||
const start = Math.max(0, end - maxLines);
|
||||
|
||||
const lines: string[] = [];
|
||||
for (let i = start; i < end; i++) {
|
||||
const line = buffer.getLine(i);
|
||||
if (!line) continue;
|
||||
const text = line.translateToString(true);
|
||||
if (line.isWrapped && lines.length > 0) {
|
||||
lines[lines.length - 1] += text;
|
||||
} else {
|
||||
lines.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
|
||||
return lines.join("\n");
|
||||
} catch {
|
||||
// Called from a click handler. A disposed terminal returns nothing
|
||||
// rather than breaking the click.
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type TerminalContextMenuAction = "native" | "copy" | "paste";
|
||||
|
||||
export function resolveTerminalContextMenuAction({
|
||||
rightClickCopyPaste,
|
||||
copyOnSelect,
|
||||
hasSelection,
|
||||
}: {
|
||||
rightClickCopyPaste: boolean;
|
||||
copyOnSelect: boolean;
|
||||
hasSelection: boolean;
|
||||
}): TerminalContextMenuAction {
|
||||
if (!rightClickCopyPaste) return "native";
|
||||
return hasSelection && !copyOnSelect ? "copy" : "paste";
|
||||
}
|
||||
|
||||
export function selectedTextToCopy({
|
||||
copyOnSelect,
|
||||
button,
|
||||
selection,
|
||||
}: {
|
||||
copyOnSelect: boolean;
|
||||
button: number;
|
||||
selection: string;
|
||||
}): string | null {
|
||||
return copyOnSelect && button === 0 && selection ? selection : null;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import { getCookie } from "@termix/plugin-sdk/ui";
|
||||
import { isPhysicalShortcutKey } from "./terminal-key-event";
|
||||
|
||||
export function getUseRightClickCopyPaste(): boolean {
|
||||
return getCookie("rightClickCopyPaste") !== "false";
|
||||
}
|
||||
|
||||
export interface TerminalClipboardActions {
|
||||
writeTextToClipboard: (text: string) => Promise<unknown>;
|
||||
readTextFromClipboard: () => Promise<string>;
|
||||
}
|
||||
|
||||
export interface TerminalClipboardKeyOptions {
|
||||
/**
|
||||
* How a plain Ctrl/Cmd+V with no other modifiers is handled.
|
||||
* "native" (default, matches the SSH terminal) leaves the key alone so the
|
||||
* browser's own paste event fires - this avoids re-triggering the OS
|
||||
* clipboard permission prompt on every keystroke, but requires the caller
|
||||
* to also listen for the DOM "paste" event.
|
||||
* "explicit" reads the clipboard directly instead, for terminals with no
|
||||
* such paste event listener of their own.
|
||||
*/
|
||||
plainPasteMode?: "native" | "explicit";
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared copy/paste keyboard shortcuts for xterm-based terminals:
|
||||
* Ctrl/Cmd+C copies the current selection (Ctrl+C also clears it, matching
|
||||
* its dual use as SIGINT when nothing is selected), Ctrl+Insert copies
|
||||
* without clearing, and Ctrl+Shift+C / Ctrl+Shift+V always copy/paste
|
||||
* regardless of selection state. Returns false (having called
|
||||
* preventDefault/stopPropagation) once it has handled the event, true
|
||||
* otherwise so callers can keep processing the keydown.
|
||||
*/
|
||||
export function handleTerminalClipboardKeyEvent(
|
||||
e: KeyboardEvent,
|
||||
terminal: Terminal,
|
||||
{ writeTextToClipboard, readTextFromClipboard }: TerminalClipboardActions,
|
||||
{ plainPasteMode = "native" }: TerminalClipboardKeyOptions = {},
|
||||
): boolean {
|
||||
const isCopyKey = isPhysicalShortcutKey(e, "KeyC", "c");
|
||||
const isPasteKey = isPhysicalShortcutKey(e, "KeyV", "v");
|
||||
|
||||
if (
|
||||
e.ctrlKey &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
isCopyKey &&
|
||||
terminal.hasSelection()
|
||||
) {
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
writeTextToClipboard(selection);
|
||||
terminal.clearSelection();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(e.metaKey && !e.shiftKey && !e.ctrlKey && !e.altKey && isCopyKey) ||
|
||||
(e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey && e.key === "Insert")
|
||||
) {
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
writeTextToClipboard(selection);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey && isCopyKey) {
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
writeTextToClipboard(selection);
|
||||
terminal.clearSelection();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey && isPasteKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
readTextFromClipboard().then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const plainModPaste =
|
||||
((e.ctrlKey && !e.metaKey) || (e.metaKey && !e.ctrlKey)) &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
isPasteKey;
|
||||
if (plainModPaste) {
|
||||
if (plainPasteMode === "explicit") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
readTextFromClipboard().then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
});
|
||||
}
|
||||
// "native": leave the event alone so the browser's own paste event fires.
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface TerminalContextMenuOptions {
|
||||
/** Optional Ctrl+right-click passthrough, e.g. to open a file manager. */
|
||||
onCtrlClick?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a "contextmenu" listener implementing right-click copy/paste:
|
||||
* copies the selection if one exists, otherwise pastes. Gated behind the
|
||||
* user's "rightClickCopyPaste" preference cookie.
|
||||
*/
|
||||
export function createTerminalContextMenuHandler(
|
||||
terminal: Terminal,
|
||||
{ writeTextToClipboard, readTextFromClipboard }: TerminalClipboardActions,
|
||||
{ onCtrlClick }: TerminalContextMenuOptions = {},
|
||||
): (e: MouseEvent) => void {
|
||||
return (e: MouseEvent) => {
|
||||
if (e.ctrlKey && onCtrlClick) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCtrlClick();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!getUseRightClickCopyPaste()) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (terminal.hasSelection()) {
|
||||
const text = terminal.getSelection();
|
||||
writeTextToClipboard(text).then(() => terminal.clearSelection());
|
||||
} else {
|
||||
readTextFromClipboard().then((text) => {
|
||||
if (text) terminal.paste(text);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function getErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "Unknown error",
|
||||
): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Return an absolute POSIX path as one safe shell argument for terminal input.
|
||||
*
|
||||
* The backend already constrains this to an absolute agent-visible path. This
|
||||
* helper handles spaces and shell metacharacters at the final terminal-input
|
||||
* boundary so configured paths cannot become shell syntax.
|
||||
*/
|
||||
export function quoteTerminalImagePath(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export type TerminalImageUploadSource = "file" | "clipboard";
|
||||
|
||||
export function buildImageUploadFormData(
|
||||
file: File,
|
||||
instanceId: string,
|
||||
source: TerminalImageUploadSource,
|
||||
clientUploadTimestamp = new Date().toISOString(),
|
||||
): FormData {
|
||||
const form = new FormData();
|
||||
form.append("image", file);
|
||||
form.append("instanceId", instanceId);
|
||||
form.append("source", source);
|
||||
form.append("clientUploadTimestamp", clientUploadTimestamp);
|
||||
return form;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function isTabKeyEvent(event: KeyboardEvent): boolean {
|
||||
return event.key === "Tab" || event.code === "Tab" || event.keyCode === 9;
|
||||
}
|
||||
|
||||
export function isPhysicalShortcutKey(
|
||||
event: Pick<KeyboardEvent, "code" | "key">,
|
||||
code: string,
|
||||
fallbackKey: string,
|
||||
): boolean {
|
||||
return event.code
|
||||
? event.code === code
|
||||
: event.key.toLowerCase() === fallbackKey;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { Host } from "../types";
|
||||
|
||||
/**
|
||||
* The slots the terminal offers plugins, and what it hands them.
|
||||
*
|
||||
* - "terminal.toolbar" (buttons): actions are invoked with a TerminalSlotApi.
|
||||
* - "terminal.toolbarStatus" (components): small live readouts at the end
|
||||
* of the toolbar (host metrics), rendered with TerminalToolbarStatusProps.
|
||||
* - "terminal.sidePanel" (components): a side panel an action opens with
|
||||
* `openSidePanel(id)`, rendered with TerminalSidePanelProps.
|
||||
* - "terminal.overlay" (components): always mounted over the terminal and
|
||||
* given the session's server messages, for connection flows a plugin owns
|
||||
* (a Tailscale check, say).
|
||||
*
|
||||
* Every contribution's `when` sees `{ host }`.
|
||||
*/
|
||||
export const TERMINAL_TOOLBAR_SLOT = "terminal.toolbar";
|
||||
export const TERMINAL_TOOLBAR_STATUS_SLOT = "terminal.toolbarStatus";
|
||||
export const TERMINAL_SIDE_PANEL_SLOT = "terminal.sidePanel";
|
||||
export const TERMINAL_OVERLAY_SLOT = "terminal.overlay";
|
||||
|
||||
export interface TerminalSlotApi {
|
||||
host: Host | undefined;
|
||||
/** The visible scrollback, read at call time. */
|
||||
getBufferText: () => string;
|
||||
/** The live session's id, once the server created it. */
|
||||
sessionId: () => string | null;
|
||||
/** Opens a "terminal.sidePanel" contribution by its actionId. */
|
||||
openSidePanel: (panelId: string, props?: Record<string, unknown>) => void;
|
||||
/** Types a command into the session and runs it. */
|
||||
runCommand: (command: string) => void;
|
||||
}
|
||||
|
||||
export interface TerminalToolbarStatusProps {
|
||||
host: Host;
|
||||
isConnected: boolean;
|
||||
/** Visible on a desktop viewport and connected: poll only while true. */
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface TerminalSidePanelProps {
|
||||
host: Host | undefined;
|
||||
hostId: number | undefined;
|
||||
hostLabel: string;
|
||||
/** Whatever the opening action passed to openSidePanel. */
|
||||
panelProps: Record<string, unknown>;
|
||||
onClose: () => void;
|
||||
onRunInTerminal: (command: string) => void;
|
||||
}
|
||||
|
||||
export interface TerminalSessionMessage {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TerminalOverlayProps {
|
||||
host: Host | undefined;
|
||||
backgroundColor?: string;
|
||||
/** Server messages for this session, plus a local "session_closed". */
|
||||
subscribe: (
|
||||
listener: (message: TerminalSessionMessage) => void,
|
||||
) => () => void;
|
||||
/**
|
||||
* Holds the connect timeout while the overlay waits on the user, e.g. an
|
||||
* approval in another window. Release it when done.
|
||||
*/
|
||||
holdConnectTimeout: (held: boolean) => void;
|
||||
/** Ends the attempt with an error shown in the terminal. */
|
||||
fail: (message: string) => void;
|
||||
/** Closes the session's connection. */
|
||||
disconnect: () => void;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { TerminalConfig } from "../types";
|
||||
|
||||
export interface TerminalHostConfig {
|
||||
id?: number;
|
||||
instanceId?: string;
|
||||
restoredSessionId?: string | null;
|
||||
/** Set when this tab joins someone else's live shared SSH session instead of connecting/attaching. */
|
||||
joinSharedSessionId?: string | null;
|
||||
joinShareId?: string | null;
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
credentialId?: number;
|
||||
terminalConfig?: Partial<TerminalConfig>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TerminalHandle {
|
||||
disconnect: () => void;
|
||||
reconnect: () => void;
|
||||
isConnected: () => boolean;
|
||||
fit: () => void;
|
||||
focus: () => void;
|
||||
sendInput: (data: string) => void;
|
||||
subscribeOutput: (listener: (data: string) => void) => () => void;
|
||||
paste: (text: string) => void;
|
||||
notifyResize: () => void;
|
||||
refresh: () => void;
|
||||
getApplicationCursorKeysMode: () => boolean;
|
||||
/** What the shell's share action needs, or null while there is nothing to share. */
|
||||
getShareTarget: () => {
|
||||
hostId: number;
|
||||
sessionId: string;
|
||||
protocol: "ssh";
|
||||
tabInstanceId?: string;
|
||||
} | null;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
export interface ToolbarPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface RectLike {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type ToolbarDensity = "icon" | "labeled" | "expanded";
|
||||
|
||||
export const TOOLBAR_POSITION_STORAGE_KEY =
|
||||
"termix-terminal-toolbar-position-v2";
|
||||
const TOOLBAR_MARGIN = 8;
|
||||
const RECOVERY_SIZE = 44;
|
||||
|
||||
export function getDefaultToolbarPosition(): ToolbarPosition {
|
||||
return { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
export function sanitizeToolbarPosition(value: unknown): ToolbarPosition {
|
||||
if (!value || typeof value !== "object") return getDefaultToolbarPosition();
|
||||
const candidate = value as Partial<ToolbarPosition>;
|
||||
return Number.isFinite(candidate.x) && Number.isFinite(candidate.y)
|
||||
? { x: Number(candidate.x), y: Number(candidate.y) }
|
||||
: getDefaultToolbarPosition();
|
||||
}
|
||||
|
||||
function readStoredPosition(storageKey: string): ToolbarPosition {
|
||||
if (typeof window === "undefined") return getDefaultToolbarPosition();
|
||||
try {
|
||||
return sanitizeToolbarPosition(
|
||||
JSON.parse(window.localStorage.getItem(storageKey) ?? "null"),
|
||||
);
|
||||
} catch {
|
||||
return getDefaultToolbarPosition();
|
||||
}
|
||||
}
|
||||
|
||||
function persistPosition(storageKey: string, position: ToolbarPosition): void {
|
||||
try {
|
||||
window.localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify(sanitizeToolbarPosition(position)),
|
||||
);
|
||||
} catch {
|
||||
// Storage may be unavailable in hardened browser contexts.
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredToolbarPosition(): ToolbarPosition {
|
||||
return readStoredPosition(TOOLBAR_POSITION_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function persistToolbarPosition(position: ToolbarPosition): void {
|
||||
persistPosition(TOOLBAR_POSITION_STORAGE_KEY, position);
|
||||
}
|
||||
|
||||
export function clampToolbarPosition(
|
||||
position: ToolbarPosition,
|
||||
toolbarRect: RectLike,
|
||||
hostRect: RectLike,
|
||||
renderedPosition: ToolbarPosition = position,
|
||||
): ToolbarPosition {
|
||||
if (
|
||||
!toolbarRect.width ||
|
||||
!toolbarRect.height ||
|
||||
!hostRect.width ||
|
||||
!hostRect.height
|
||||
)
|
||||
return sanitizeToolbarPosition(position);
|
||||
const left = hostRect.left + TOOLBAR_MARGIN;
|
||||
const right = hostRect.right - TOOLBAR_MARGIN;
|
||||
const top = hostRect.top + TOOLBAR_MARGIN;
|
||||
const bottom = hostRect.bottom - TOOLBAR_MARGIN;
|
||||
const baseLeft = toolbarRect.left - renderedPosition.x;
|
||||
const baseRight = toolbarRect.right - renderedPosition.x;
|
||||
const baseTop = toolbarRect.top - renderedPosition.y;
|
||||
const baseBottom = toolbarRect.bottom - renderedPosition.y;
|
||||
const clamp = (min: number, max: number, value: number) =>
|
||||
Math.min(max, Math.max(min, value));
|
||||
const xMin =
|
||||
toolbarRect.width <= right - left
|
||||
? left - baseLeft
|
||||
: left + RECOVERY_SIZE - baseRight;
|
||||
const xMax = right - baseRight;
|
||||
const yMin =
|
||||
toolbarRect.height <= bottom - top
|
||||
? top - baseTop
|
||||
: top + RECOVERY_SIZE - baseBottom;
|
||||
const yMax = bottom - baseBottom;
|
||||
return { x: clamp(xMin, xMax, position.x), y: clamp(yMin, yMax, position.y) };
|
||||
}
|
||||
|
||||
export function getResponsiveToolbarDensity(
|
||||
selectedDensity: ToolbarDensity,
|
||||
currentDensity: ToolbarDensity,
|
||||
availableInlineSize: number,
|
||||
requiredInlineSize: number,
|
||||
): ToolbarDensity {
|
||||
if (selectedDensity === "icon") return "icon";
|
||||
if (!Number.isFinite(availableInlineSize) || availableInlineSize <= 0)
|
||||
return currentDensity;
|
||||
if (!Number.isFinite(requiredInlineSize) || requiredInlineSize <= 0)
|
||||
return currentDensity;
|
||||
if (currentDensity === "icon")
|
||||
return availableInlineSize >= requiredInlineSize * 1.15
|
||||
? selectedDensity
|
||||
: currentDensity;
|
||||
return availableInlineSize < requiredInlineSize * 1.1
|
||||
? "icon"
|
||||
: selectedDensity;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import { getClientSettings } from "../terminal-api";
|
||||
import {
|
||||
TOUCH_INPUT_DEFAULTS,
|
||||
type TouchInputSettings,
|
||||
} from "../../shared/touch-input-settings";
|
||||
|
||||
let cached: TouchInputSettings | undefined;
|
||||
let pending: Promise<TouchInputSettings> | undefined;
|
||||
|
||||
export function loadTouchInputSettings(
|
||||
api: PluginApiClient,
|
||||
): Promise<TouchInputSettings> {
|
||||
if (cached) return Promise.resolve(cached);
|
||||
pending ??= getClientSettings(api)
|
||||
.then((settings) => (cached = settings.touchInput))
|
||||
.catch(() => (cached = { ...TOUCH_INPUT_DEFAULTS }))
|
||||
.finally(() => {
|
||||
pending = undefined;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function cacheTouchInputSettings(settings: TouchInputSettings): void {
|
||||
cached = settings;
|
||||
}
|
||||
|
||||
export function resetTouchInputSettingsCache(): void {
|
||||
cached = undefined;
|
||||
pending = undefined;
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
TOUCH_INPUT_DEFAULTS,
|
||||
type TouchInputSettings,
|
||||
} from "../../shared/touch-input-settings";
|
||||
|
||||
export interface TouchWheelPoint {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
}
|
||||
|
||||
export interface SyntheticWheelInput extends TouchWheelPoint {
|
||||
deltaY: number;
|
||||
deltaMode: number;
|
||||
}
|
||||
|
||||
interface TouchWheelTiming {
|
||||
now: () => number;
|
||||
requestFrame: (callback: FrameRequestCallback) => number;
|
||||
cancelFrame: (handle: number) => void;
|
||||
momentumAllowed: () => boolean;
|
||||
}
|
||||
|
||||
const defaultTiming: TouchWheelTiming = {
|
||||
now: () => performance.now(),
|
||||
requestFrame: (callback) => requestAnimationFrame(callback),
|
||||
cancelFrame: (handle) => cancelAnimationFrame(handle),
|
||||
momentumAllowed: () => true,
|
||||
};
|
||||
|
||||
interface VelocitySample {
|
||||
deltaY: number;
|
||||
durationMs: number;
|
||||
endedAt: number;
|
||||
}
|
||||
|
||||
export class TouchWheelCoordinator {
|
||||
private startY: number | undefined;
|
||||
private lastPoint: TouchWheelPoint | undefined;
|
||||
private pendingDeltaY = 0;
|
||||
private scrolling = false;
|
||||
private cancelled = false;
|
||||
private velocitySamples: VelocitySample[] = [];
|
||||
private lastMoveTime: number | undefined;
|
||||
private momentumFrame: number | undefined;
|
||||
private momentumRemainderY = 0;
|
||||
|
||||
constructor(
|
||||
private readonly emitWheel: (input: SyntheticWheelInput) => void,
|
||||
private readonly dragThresholdPx: number = TOUCH_INPUT_DEFAULTS.dragThresholdPx,
|
||||
private readonly maxWheelDeltaPx: number = TOUCH_INPUT_DEFAULTS.maxWheelDeltaPx,
|
||||
private readonly timing: TouchWheelTiming = defaultTiming,
|
||||
private readonly settings: TouchInputSettings = TOUCH_INPUT_DEFAULTS,
|
||||
) {}
|
||||
|
||||
start(point: TouchWheelPoint | undefined, time = this.timing.now()): void {
|
||||
this.reset();
|
||||
if (!point) {
|
||||
this.cancelled = true;
|
||||
return;
|
||||
}
|
||||
this.startY = point.clientY;
|
||||
this.lastPoint = point;
|
||||
this.lastMoveTime = time;
|
||||
}
|
||||
|
||||
move(point: TouchWheelPoint | undefined, time = this.timing.now()): boolean {
|
||||
if (this.cancelled || !point || !this.lastPoint) {
|
||||
if (!point) this.cancel();
|
||||
return false;
|
||||
}
|
||||
|
||||
const deltaY = this.lastPoint.clientY - point.clientY;
|
||||
this.pendingDeltaY += deltaY;
|
||||
this.lastPoint = point;
|
||||
if (this.lastMoveTime !== undefined && time > this.lastMoveTime) {
|
||||
this.velocitySamples.push({
|
||||
deltaY,
|
||||
durationMs: time - this.lastMoveTime,
|
||||
endedAt: time,
|
||||
});
|
||||
this.velocitySamples = this.velocitySamples.filter(
|
||||
(sample) =>
|
||||
time - sample.endedAt <= this.settings.momentumSampleWindowMs,
|
||||
);
|
||||
}
|
||||
this.lastMoveTime = time;
|
||||
|
||||
if (!this.scrolling) {
|
||||
if (Math.abs(point.clientY - this.startY!) < this.dragThresholdPx) {
|
||||
return true;
|
||||
}
|
||||
this.scrolling = true;
|
||||
}
|
||||
|
||||
this.flush(point);
|
||||
return true;
|
||||
}
|
||||
|
||||
end(time = this.timing.now()): void {
|
||||
const point = this.lastPoint;
|
||||
const velocity = this.scrolling ? this.recentVelocity(time) : 0;
|
||||
this.clearGesture();
|
||||
if (
|
||||
point &&
|
||||
this.timing.momentumAllowed() &&
|
||||
Math.abs(velocity) >= this.settings.minimumVelocityPxPerMs
|
||||
) {
|
||||
this.startMomentum(point, velocity, time);
|
||||
}
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.reset();
|
||||
this.cancelled = true;
|
||||
}
|
||||
|
||||
private flush(point: TouchWheelPoint): void {
|
||||
while (Math.abs(this.pendingDeltaY) > this.maxWheelDeltaPx) {
|
||||
const deltaY = Math.sign(this.pendingDeltaY) * this.maxWheelDeltaPx;
|
||||
this.emitWheel({
|
||||
...point,
|
||||
deltaY,
|
||||
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
||||
});
|
||||
this.pendingDeltaY -= deltaY;
|
||||
}
|
||||
if (this.pendingDeltaY !== 0) {
|
||||
this.emitWheel({
|
||||
...point,
|
||||
deltaY: this.pendingDeltaY,
|
||||
deltaMode: WheelEvent.DOM_DELTA_PIXEL,
|
||||
});
|
||||
this.pendingDeltaY = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.stopMomentum();
|
||||
this.clearGesture();
|
||||
this.cancelled = false;
|
||||
}
|
||||
|
||||
private clearGesture(): void {
|
||||
this.startY = undefined;
|
||||
this.lastPoint = undefined;
|
||||
this.pendingDeltaY = 0;
|
||||
this.scrolling = false;
|
||||
this.velocitySamples = [];
|
||||
this.lastMoveTime = undefined;
|
||||
}
|
||||
|
||||
private recentVelocity(time: number): number {
|
||||
const newestSampleTime = this.velocitySamples.at(-1)?.endedAt;
|
||||
if (
|
||||
newestSampleTime === undefined ||
|
||||
time - newestSampleTime > this.settings.releaseGracePeriodMs
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
const samples = this.velocitySamples.filter(
|
||||
(sample) =>
|
||||
newestSampleTime - sample.endedAt <=
|
||||
this.settings.momentumSampleWindowMs,
|
||||
);
|
||||
const duration = samples.reduce(
|
||||
(total, sample) => total + sample.durationMs,
|
||||
0,
|
||||
);
|
||||
if (duration === 0) return 0;
|
||||
const distance = samples.reduce(
|
||||
(total, sample) => total + sample.deltaY,
|
||||
0,
|
||||
);
|
||||
return Math.max(
|
||||
-this.settings.maximumVelocityPxPerMs,
|
||||
Math.min(this.settings.maximumVelocityPxPerMs, distance / duration),
|
||||
);
|
||||
}
|
||||
|
||||
private startMomentum(
|
||||
point: TouchWheelPoint,
|
||||
initialVelocity: number,
|
||||
startedAt: number,
|
||||
): void {
|
||||
let lastTime = startedAt;
|
||||
let travelled = 0;
|
||||
|
||||
const step = (time: number) => {
|
||||
if (!this.timing.momentumAllowed()) {
|
||||
this.momentumFrame = undefined;
|
||||
this.momentumRemainderY = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = Math.max(0, time - startedAt);
|
||||
const remainingTravel = this.settings.maximumTravelPx - travelled;
|
||||
if (elapsed > this.settings.maximumDurationMs || remainingTravel <= 0) {
|
||||
this.momentumFrame = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const duration = Math.min(
|
||||
Math.max(0, time - lastTime),
|
||||
this.settings.maximumFrameIntervalMs,
|
||||
);
|
||||
if (duration > 0) {
|
||||
const frameStart = Math.max(0, elapsed - duration);
|
||||
const distance =
|
||||
Math.abs(initialVelocity) *
|
||||
this.settings.decayTimeMs *
|
||||
(Math.exp(-frameStart / this.settings.decayTimeMs) -
|
||||
Math.exp(-elapsed / this.settings.decayTimeMs));
|
||||
const deltaY =
|
||||
Math.sign(initialVelocity) * Math.min(distance, remainingTravel);
|
||||
this.flushMomentum(point, deltaY);
|
||||
travelled += Math.abs(deltaY);
|
||||
}
|
||||
|
||||
lastTime = time;
|
||||
if (
|
||||
elapsed < this.settings.maximumDurationMs &&
|
||||
travelled < this.settings.maximumTravelPx
|
||||
) {
|
||||
this.momentumFrame = this.timing.requestFrame(step);
|
||||
} else {
|
||||
this.momentumFrame = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
this.momentumFrame = this.timing.requestFrame(step);
|
||||
}
|
||||
|
||||
private flushMomentum(point: TouchWheelPoint, deltaY: number): void {
|
||||
this.momentumRemainderY += deltaY;
|
||||
const availableTicks = Math.trunc(
|
||||
Math.abs(this.momentumRemainderY) / this.settings.pixelsPerTick,
|
||||
);
|
||||
const tickCount = Math.min(
|
||||
availableTicks,
|
||||
this.settings.maximumTicksPerFrame,
|
||||
);
|
||||
const direction = Math.sign(this.momentumRemainderY);
|
||||
for (let index = 0; index < tickCount; index++) {
|
||||
this.emitWheel({
|
||||
...point,
|
||||
deltaY: direction,
|
||||
deltaMode: WheelEvent.DOM_DELTA_LINE,
|
||||
});
|
||||
}
|
||||
this.momentumRemainderY -=
|
||||
direction * tickCount * this.settings.pixelsPerTick;
|
||||
}
|
||||
|
||||
private stopMomentum(): void {
|
||||
if (this.momentumFrame !== undefined) {
|
||||
this.timing.cancelFrame(this.momentumFrame);
|
||||
this.momentumFrame = undefined;
|
||||
}
|
||||
this.momentumRemainderY = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function installTouchWheelCoordinator(
|
||||
terminalElement: HTMLElement,
|
||||
momentumAllowed: () => boolean = () => true,
|
||||
settings: TouchInputSettings = TOUCH_INPUT_DEFAULTS,
|
||||
): () => void {
|
||||
if (!settings.enabled) return () => {};
|
||||
const wheelElement = terminalElement.querySelector<HTMLElement>(
|
||||
".xterm-scrollable-element",
|
||||
);
|
||||
if (!wheelElement) return () => {};
|
||||
|
||||
const coordinator = new TouchWheelCoordinator(
|
||||
({ deltaY, deltaMode, clientX, clientY }) => {
|
||||
wheelElement.dispatchEvent(
|
||||
new WheelEvent("wheel", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
clientX,
|
||||
clientY,
|
||||
deltaMode,
|
||||
deltaY,
|
||||
}),
|
||||
);
|
||||
},
|
||||
settings.dragThresholdPx,
|
||||
settings.maxWheelDeltaPx,
|
||||
{
|
||||
...defaultTiming,
|
||||
momentumAllowed: () =>
|
||||
settings.momentumEnabled &&
|
||||
momentumAllowed() &&
|
||||
!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches,
|
||||
},
|
||||
settings,
|
||||
);
|
||||
|
||||
const getSingleTouch = (event: TouchEvent): Touch | undefined =>
|
||||
event.touches.length === 1
|
||||
? (event.touches.item(0) ?? undefined)
|
||||
: undefined;
|
||||
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
const touch = getSingleTouch(event);
|
||||
coordinator.start(touch);
|
||||
};
|
||||
const handleTouchMove = (event: TouchEvent) => {
|
||||
if (coordinator.move(getSingleTouch(event))) event.preventDefault();
|
||||
};
|
||||
const handleTouchEnd = () => coordinator.end();
|
||||
const handleTouchCancel = () => coordinator.cancel();
|
||||
|
||||
terminalElement.addEventListener("touchstart", handleTouchStart, {
|
||||
passive: true,
|
||||
});
|
||||
terminalElement.addEventListener("touchmove", handleTouchMove, {
|
||||
passive: false,
|
||||
});
|
||||
terminalElement.addEventListener("touchend", handleTouchEnd, {
|
||||
passive: true,
|
||||
});
|
||||
terminalElement.addEventListener("touchcancel", handleTouchCancel, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
coordinator.cancel();
|
||||
terminalElement.removeEventListener("touchstart", handleTouchStart);
|
||||
terminalElement.removeEventListener("touchmove", handleTouchMove);
|
||||
terminalElement.removeEventListener("touchend", handleTouchEnd);
|
||||
terminalElement.removeEventListener("touchcancel", handleTouchCancel);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user