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
@@ -1,21 +1,17 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { TabProps } from "@termix/plugin-sdk/frontend";
|
||||
import { TerminalTabContent } from "@/features/terminal/TerminalTabContent";
|
||||
import type { TabRenderProps } from "@/shell/tab-registry";
|
||||
import type { TerminalHandle } from "@/features/terminal/terminal-types";
|
||||
import { TerminalTabContent } from "./terminal/TerminalTabContent";
|
||||
import type { TerminalHandle } from "./terminal/terminal-types";
|
||||
import { registerSession, setActiveSession } from "./session-registry";
|
||||
|
||||
/**
|
||||
* Wraps the core terminal tab component to register its ref in the session
|
||||
* registry, so terminal.sendToActive/sendToSession (used by the snippets
|
||||
* plugin, among others) can reach a live session without core exposing
|
||||
* terminalRef through the public tab props.
|
||||
* Registers the tab's terminal handle in the session registry, so
|
||||
* terminal.sendToActive/sendToSession (used by the snippets plugin, among
|
||||
* others) can reach a live session.
|
||||
*/
|
||||
export function TerminalTabWithRegistry(props: TabProps) {
|
||||
const tabRecord = props.tab as unknown as {
|
||||
id: string;
|
||||
terminalRef?: { current: TerminalHandle | null };
|
||||
};
|
||||
const tabRecord = props.tab as { id: string };
|
||||
const handle = props.handleRef as { current: TerminalHandle | null } | null;
|
||||
const host = props.host as
|
||||
| {
|
||||
id: string;
|
||||
@@ -33,7 +29,7 @@ export function TerminalTabWithRegistry(props: TabProps) {
|
||||
|
||||
const tryRegister = () => {
|
||||
if (cancelled || registeredRef.current) return;
|
||||
const ref = tabRecord.terminalRef?.current;
|
||||
const ref = handle?.current;
|
||||
if (!ref) {
|
||||
if (attempts++ < 40) setTimeout(tryRegister, 250);
|
||||
return;
|
||||
@@ -64,5 +60,5 @@ export function TerminalTabWithRegistry(props: TabProps) {
|
||||
if (props.isFocusedPane) setActiveSession(tabRecord.id);
|
||||
}, [props.isFocusedPane, tabRecord.id]);
|
||||
|
||||
return <TerminalTabContent {...(props as unknown as TabRenderProps)} />;
|
||||
return <TerminalTabContent {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Suspense, lazy, type Ref } from "react";
|
||||
import type { TerminalHandle } from "./terminal/terminal-types";
|
||||
|
||||
const CommandHistoryProvider = lazy(() =>
|
||||
import("./terminal/command-history/CommandHistoryContext").then((m) => ({
|
||||
default: m.CommandHistoryProvider,
|
||||
})),
|
||||
);
|
||||
const Terminal = lazy(() =>
|
||||
import("./terminal/Terminal").then((m) => ({ default: m.Terminal })),
|
||||
);
|
||||
|
||||
/**
|
||||
* The terminal as other code embeds it, registered as "terminal.view".
|
||||
*
|
||||
* Props are the terminal's own (hostConfig, isVisible, title, splitScreen,
|
||||
* executeCommand and the rest), plus `handleRef` for the TerminalHandle
|
||||
* (connect, disconnect, sendInput, fit), since a registered component cannot
|
||||
* take a React ref across the plugin boundary.
|
||||
*/
|
||||
export function TerminalView({
|
||||
handleRef,
|
||||
...props
|
||||
}: {
|
||||
handleRef?: Ref<TerminalHandle>;
|
||||
[prop: string]: unknown;
|
||||
}) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<CommandHistoryProvider>
|
||||
<Terminal
|
||||
ref={handleRef}
|
||||
{...(props as unknown as React.ComponentProps<typeof Terminal>)}
|
||||
/>
|
||||
</CommandHistoryProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Copy, Search, Terminal, Trash2 } from "lucide-react";
|
||||
import { copyToClipboard, Button, Input } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
usePluginApi,
|
||||
useTranslation,
|
||||
type PanelProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
clearCommandHistory,
|
||||
deleteCommandFromHistory,
|
||||
getCommandHistory,
|
||||
hostSetting,
|
||||
} from "../terminal-api";
|
||||
|
||||
/** Command history for the terminal the user is working in. */
|
||||
export function HistoryPanel({ targetTab }: PanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const api = usePluginApi();
|
||||
const [search, setSearch] = useState("");
|
||||
const [commands, setCommands] = useState<string[]>([]);
|
||||
|
||||
const activeTab = targetTab;
|
||||
const activeIsTerminal = !!activeTab;
|
||||
const hostId = activeTab?.host?.id ? parseInt(activeTab.host.id, 10) : null;
|
||||
const trackingEnabled = hostSetting(
|
||||
activeTab?.host,
|
||||
"enableCommandHistory",
|
||||
true,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostId || !trackingEnabled) {
|
||||
setCommands([]);
|
||||
return;
|
||||
}
|
||||
getCommandHistory(api, hostId)
|
||||
.then(setCommands)
|
||||
.catch(() => setCommands([]));
|
||||
}, [api, hostId, trackingEnabled]);
|
||||
|
||||
if (activeIsTerminal && !trackingEnabled) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
|
||||
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
|
||||
<Terminal className="size-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-muted-foreground/60">
|
||||
{t("history.trackingDisabled")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/40">
|
||||
{t("history.trackingDisabledHint")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeIsTerminal) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
|
||||
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
|
||||
<Terminal className="size-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-muted-foreground/60">
|
||||
{t("history.noTerminalSelected")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/40">
|
||||
{t("history.noTerminalSelectedHint")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filtered = search
|
||||
? commands.filter((c) => c.toLowerCase().includes(search.toLowerCase()))
|
||||
: commands;
|
||||
|
||||
async function handleDelete(cmd: string) {
|
||||
if (!hostId) return;
|
||||
try {
|
||||
await deleteCommandFromHistory(api, hostId, cmd);
|
||||
setCommands((prev) => prev.filter((c) => c !== cmd));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-muted/30 border border-border/60">
|
||||
<Terminal className="size-3 shrink-0 text-accent-brand" />
|
||||
<span className="text-xs font-medium truncate text-foreground">
|
||||
{activeTab.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("history.searchPlaceholder")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("history.commandCount", { count: filtered.length })}
|
||||
</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!hostId) return;
|
||||
try {
|
||||
await clearCommandHistory(api, hostId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setCommands([]);
|
||||
}}
|
||||
className="text-xs text-accent-brand hover:text-accent-brand/70"
|
||||
>
|
||||
{t("history.clearAll")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{filtered.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground/60 text-center py-8">
|
||||
{t("history.noHistoryEntries")}
|
||||
</span>
|
||||
)}
|
||||
{filtered.map((cmd, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="group flex flex-col gap-1 px-2.5 py-2 border border-border bg-background hover:border-muted-foreground/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-xs font-mono text-foreground break-all leading-relaxed">
|
||||
{cmd}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 shrink-0 md:opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => copyToClipboard(cmd)}
|
||||
>
|
||||
<Copy className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => handleDelete(cmd)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,41 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { Terminal } from "lucide-react";
|
||||
import { lazy, Suspense, type ComponentType } from "react";
|
||||
import { History, Laptop, Terminal } from "lucide-react";
|
||||
import type {
|
||||
PanelProps,
|
||||
StandaloneViewProps,
|
||||
TabProps,
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { loadTerminal } from "@/features/terminal/TerminalTabContent";
|
||||
import TerminalApp from "@/features/terminal/TerminalApp";
|
||||
import { isElectron } from "@termix/plugin-sdk/ui";
|
||||
import { loadTerminal } from "./terminal/TerminalTabContent";
|
||||
import TerminalApp from "./terminal/TerminalApp";
|
||||
import {
|
||||
TERMINAL_DOCK_SLOT,
|
||||
TERMINAL_OVERLAY_SLOT,
|
||||
TERMINAL_SIDE_PANEL_SLOT,
|
||||
TERMINAL_TOOLBAR_SLOT,
|
||||
} from "@/features/terminal/terminal-slots";
|
||||
TERMINAL_TOOLBAR_STATUS_SLOT,
|
||||
} from "./terminal/terminal-slots";
|
||||
import { TerminalTabWithRegistry } from "./TerminalTabWithRegistry";
|
||||
import { listSessions, sendToActive, sendToSession } from "./session-registry";
|
||||
import { TerminalView } from "./TerminalView";
|
||||
import { HistoryPanel } from "./history/HistoryPanel";
|
||||
import { TouchInputSettings } from "./settings/TouchInputSettings";
|
||||
import { ImageStorageTest } from "./settings/ImageStorageTest";
|
||||
import { resetTouchInputSettingsCache } from "./terminal/touch-input-settings-store";
|
||||
import { hostSetting } from "./terminal-api";
|
||||
|
||||
const LocalTerminal = lazy(() =>
|
||||
import("./local-terminal/LocalTerminal").then((m) => ({
|
||||
default: m.LocalTerminal,
|
||||
})),
|
||||
);
|
||||
|
||||
interface TerminalOpenOptions {
|
||||
path?: string;
|
||||
joinSharedSessionId?: string;
|
||||
joinShareId?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** `?view=terminal` full-screen links. */
|
||||
function TerminalStandalone({ hostId, params }: StandaloneViewProps) {
|
||||
@@ -25,9 +47,21 @@ function TerminalStandalone({ hostId, params }: StandaloneViewProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** The desktop app's own shell, one tab per open. */
|
||||
function LocalTerminalTab({ tab, isVisible }: TabProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LocalTerminal
|
||||
instanceId={(tab.instanceId as string | undefined) ?? tab.id}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The SSH terminal. Its component still lives in core until the terminal's
|
||||
* own Phase B step; this plugin decides whether the shell offers it.
|
||||
* The SSH terminal: its tab, the desktop app's local terminal, the command
|
||||
* history panel and the places other plugins can plug into a session.
|
||||
*/
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerTab(
|
||||
@@ -38,6 +72,8 @@ export function activate(app: TermixApp): void {
|
||||
titleKey: "nav.terminal",
|
||||
persistent: true,
|
||||
session: true,
|
||||
commandTarget: true,
|
||||
ownBackground: true,
|
||||
restore: (host) => !!host.enableSsh,
|
||||
activityTypes: ["terminal"],
|
||||
standalone: TerminalStandalone,
|
||||
@@ -54,17 +90,75 @@ export function activate(app: TermixApp): void {
|
||||
order: 10,
|
||||
tabType: "terminal",
|
||||
copyUrlView: "terminal",
|
||||
when: (host) => !!host.enableSsh && host.enableTerminal !== false,
|
||||
when: (host) =>
|
||||
!!host.enableSsh && hostSetting(host, "enableTerminal", true),
|
||||
});
|
||||
|
||||
// Places other plugins can fill: toolbar buttons, a docked side panel and
|
||||
// overlays that follow the session's connection flow.
|
||||
if (isElectron()) {
|
||||
app.registerTab("local-terminal", LocalTerminalTab, {
|
||||
icon: Laptop,
|
||||
titleKey: "nav.localTerminal",
|
||||
hostless: true,
|
||||
session: true,
|
||||
ownBackground: true,
|
||||
multiInstance: true,
|
||||
inLayouts: false,
|
||||
});
|
||||
app.registerRailItem({
|
||||
id: "local-terminal",
|
||||
icon: Laptop,
|
||||
titleKey: "nav.localTerminal",
|
||||
kind: "tab",
|
||||
electronOnly: true,
|
||||
hideable: true,
|
||||
separatorAfter: true,
|
||||
});
|
||||
app.registerPaletteEntry({
|
||||
id: "local-terminal",
|
||||
titleKey: "palette.localTerminal",
|
||||
icon: Laptop,
|
||||
keywords: ["local", "shell", "terminal"],
|
||||
scope: "global",
|
||||
run: (shell) => shell.openSingletonTab("local-terminal"),
|
||||
});
|
||||
}
|
||||
|
||||
app.registerPanel(
|
||||
"history",
|
||||
HistoryPanel as unknown as ComponentType<PanelProps>,
|
||||
);
|
||||
app.registerRailItem({
|
||||
id: "history",
|
||||
icon: History,
|
||||
titleKey: "nav.history",
|
||||
hideable: true,
|
||||
promotable: true,
|
||||
rightDockable: true,
|
||||
separatorAfter: true,
|
||||
});
|
||||
|
||||
// Places other plugins can fill: toolbar buttons and readouts, a side
|
||||
// panel and overlays that follow the session's connection flow.
|
||||
app.declareActionSlot({ id: TERMINAL_TOOLBAR_SLOT, accepts: ["button"] });
|
||||
app.declareActionSlot({ id: TERMINAL_DOCK_SLOT, accepts: ["component"] });
|
||||
app.declareActionSlot({
|
||||
id: TERMINAL_TOOLBAR_STATUS_SLOT,
|
||||
accepts: ["component"],
|
||||
});
|
||||
app.declareActionSlot({
|
||||
id: TERMINAL_SIDE_PANEL_SLOT,
|
||||
accepts: ["component"],
|
||||
});
|
||||
app.declareActionSlot({ id: TERMINAL_OVERLAY_SLOT, accepts: ["component"] });
|
||||
|
||||
// A terminal other code can embed (collab rooms, the homepage widget, the
|
||||
// tmux monitor, the file manager's window) without importing this plugin.
|
||||
app.registerComponent(
|
||||
"terminal.view",
|
||||
TerminalView as unknown as ComponentType<Record<string, unknown>>,
|
||||
);
|
||||
|
||||
// Lets another plugin (snippets) push text into a live terminal session
|
||||
// without reaching into core's tab state itself.
|
||||
// without reaching into the shell's tab state itself.
|
||||
app.registerAction("terminal.listSessions", () => listSessions());
|
||||
app.registerAction("terminal.sendToActive", ((
|
||||
text: string,
|
||||
@@ -75,4 +169,28 @@ export function activate(app: TermixApp): void {
|
||||
text: string,
|
||||
opts?: { run?: boolean },
|
||||
) => sendToSession(sessionId, text, opts)) as never);
|
||||
// Opens a new terminal tab on a host: at a path (the file manager), or
|
||||
// joining someone else's shared session (the connections panel).
|
||||
app.registerAction("terminal.open", ((
|
||||
host: Parameters<TermixApp["tabs"]["openTab"]>[0],
|
||||
options: TerminalOpenOptions = {},
|
||||
) =>
|
||||
app.tabs.openTab(host, "terminal", {
|
||||
forceNewTab: true,
|
||||
label: options.label,
|
||||
data: {
|
||||
...(options.path ? { initialPath: options.path } : {}),
|
||||
...(options.joinSharedSessionId
|
||||
? {
|
||||
joinSharedSessionId: options.joinSharedSessionId,
|
||||
joinShareId: options.joinShareId ?? null,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})) as never);
|
||||
|
||||
app.registerSettingsComponent("touchInput", TouchInputSettings);
|
||||
app.registerSettingsComponent("imageStorageTest", ImageStorageTest);
|
||||
|
||||
app.onDispose(resetTouchInputSettingsCache);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import { type KeybindingAction } from "@termix/plugin-sdk/ui";
|
||||
import { invokeAction } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export interface SnippetHostContext {
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number | string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface KeybindingDispatchContext {
|
||||
terminal: Terminal;
|
||||
webSocketRef: React.MutableRefObject<WebSocket | null>;
|
||||
writeTextToClipboard: (text: string) => Promise<boolean>;
|
||||
readTextFromClipboard: () => Promise<string>;
|
||||
/** Host context used to resolve $HOST/$USER/$PORT/$NAME in runSnippet actions. */
|
||||
hostContext?: SnippetHostContext | null;
|
||||
/**
|
||||
* Called instead of sending the snippet directly when its content still has
|
||||
* unresolved $INPUT_n placeholders after host-variable substitution -- the
|
||||
* caller is expected to collect values (e.g. via a dialog) and send itself.
|
||||
*/
|
||||
onSnippetNeedsInputs?: (snippet: { id: string; content: string }) => void;
|
||||
}
|
||||
|
||||
export function sendRawToSocket(
|
||||
webSocketRef: React.MutableRefObject<WebSocket | null>,
|
||||
data: string,
|
||||
): void {
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchKeybindingAction(
|
||||
action: KeybindingAction,
|
||||
ctx: KeybindingDispatchContext,
|
||||
): void {
|
||||
const sendRaw = (data: string) => sendRawToSocket(ctx.webSocketRef, data);
|
||||
|
||||
switch (action.type) {
|
||||
case "copy": {
|
||||
const selection = ctx.terminal.getSelection();
|
||||
if (selection) {
|
||||
ctx.writeTextToClipboard(selection);
|
||||
ctx.terminal.clearSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "paste": {
|
||||
ctx.readTextFromClipboard().then((text) => {
|
||||
if (text) ctx.terminal.paste(text);
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "sendControlCode": {
|
||||
if (!action.controlCode) return;
|
||||
const code = action.controlCode.toLowerCase().charCodeAt(0) - 96;
|
||||
if (code >= 1 && code <= 26) sendRaw(String.fromCharCode(code));
|
||||
return;
|
||||
}
|
||||
case "sendText": {
|
||||
sendRaw((action.text ?? "") + (action.appendEnter ? "\r" : ""));
|
||||
return;
|
||||
}
|
||||
case "runSnippet": {
|
||||
if (!action.snippetId) return;
|
||||
const snippetId = Number(action.snippetId);
|
||||
if (!Number.isFinite(snippetId)) return;
|
||||
// Resolving with no inputValues first tells us whether the snippet
|
||||
// still needs them; the plugin decides and reports back.
|
||||
void invokeAction(
|
||||
"snippets.resolveForTerminal",
|
||||
snippetId,
|
||||
ctx.hostContext ?? null,
|
||||
).then((result) => {
|
||||
const resolved = result as
|
||||
{ needsInputs: boolean; content: string } | null | undefined;
|
||||
if (!resolved) return;
|
||||
if (resolved.needsInputs) {
|
||||
ctx.onSnippetNeedsInputs?.({
|
||||
id: action.snippetId!,
|
||||
content: resolved.content,
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendRaw(resolved.content + (action.appendEnter !== false ? "\r" : ""));
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "nextTab":
|
||||
case "previousTab":
|
||||
case "openCommandPalette": {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:global-keybinding", {
|
||||
detail: { type: action.type },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { isMacPlatform } from "@termix/plugin-sdk/ui";
|
||||
|
||||
export function getMacLineNavigationSequence(e: KeyboardEvent): string | null {
|
||||
if (e.type !== "keydown" || !isMacPlatform()) return null;
|
||||
if (!e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return null;
|
||||
|
||||
switch (e.code) {
|
||||
case "ArrowLeft":
|
||||
return "\x01";
|
||||
case "ArrowRight":
|
||||
return "\x05";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
export type LocalEchoMode = "off" | "auto" | "on";
|
||||
|
||||
const PASSWORD_PROMPT =
|
||||
/(?:password|passphrase|verification code|one[- ]time|otp|token)(?:[^\r\n::]{0,160})?[::]\s*$/i;
|
||||
const SAFE_INPUT = /^[\x20-\x7e]$/;
|
||||
|
||||
type PendingCharacter = { value: string; sentAt: number; predicted: boolean };
|
||||
|
||||
export class TerminalLocalEcho {
|
||||
private pending: PendingCharacter[] = [];
|
||||
private slowSamples = 0;
|
||||
private secretInput = false;
|
||||
private outputTail = "";
|
||||
|
||||
constructor(
|
||||
private readonly mode: LocalEchoMode,
|
||||
private readonly now: () => number = () => performance.now(),
|
||||
private readonly latencyThreshold = 120,
|
||||
) {}
|
||||
|
||||
handleInput(data: string): string {
|
||||
if (data === "\r" || data === "\n") {
|
||||
return "";
|
||||
}
|
||||
if (this.secretInput || !SAFE_INPUT.test(data)) return "";
|
||||
|
||||
const predicted =
|
||||
this.mode === "on" || (this.mode === "auto" && this.slowSamples >= 2);
|
||||
this.pending.push({ value: data, sentAt: this.now(), predicted });
|
||||
return predicted ? data : "";
|
||||
}
|
||||
|
||||
handleOutput(data: string): string {
|
||||
const plainOutput = this.stripAnsi(data);
|
||||
this.outputTail = (this.outputTail + plainOutput).slice(-128);
|
||||
if (PASSWORD_PROMPT.test(this.outputTail)) {
|
||||
const rollback = this.rollback();
|
||||
this.secretInput = true;
|
||||
return rollback + data;
|
||||
}
|
||||
|
||||
if (this.secretInput) {
|
||||
if (data.includes("\n") || data.includes("\r")) this.secretInput = false;
|
||||
return data;
|
||||
}
|
||||
|
||||
if (this.pending.length === 0) return data;
|
||||
|
||||
let consumed = 0;
|
||||
while (
|
||||
consumed < data.length &&
|
||||
consumed < this.pending.length &&
|
||||
data[consumed] === this.pending[consumed].value
|
||||
) {
|
||||
const item = this.pending[consumed];
|
||||
if (
|
||||
!item.predicted &&
|
||||
this.now() - item.sentAt >= this.latencyThreshold
|
||||
) {
|
||||
this.slowSamples += 1;
|
||||
}
|
||||
consumed += 1;
|
||||
}
|
||||
|
||||
if (consumed > 0) {
|
||||
const matched = this.pending.splice(0, consumed);
|
||||
const predictedCount = matched.filter((item) => item.predicted).length;
|
||||
return data.slice(predictedCount);
|
||||
}
|
||||
|
||||
return this.rollback() + data;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.pending = [];
|
||||
this.slowSamples = 0;
|
||||
this.secretInput = false;
|
||||
this.outputTail = "";
|
||||
}
|
||||
|
||||
private rollback() {
|
||||
const count = this.pending.filter((item) => item.predicted).length;
|
||||
this.pending = [];
|
||||
return count > 0 ? `\x1b[${count}D\x1b[${count}X` : "";
|
||||
}
|
||||
|
||||
private stripAnsi(value: string) {
|
||||
return value.replace(/\x1b(?:[@-Z\\-_]|\[[0-9:;<=>?!]*[@-~])/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveLocalEchoMode(
|
||||
hostMode: "default" | LocalEchoMode | undefined,
|
||||
storedMode: string | null,
|
||||
): LocalEchoMode {
|
||||
if (hostMode && hostMode !== "default") return hostMode;
|
||||
return storedMode === "on" || storedMode === "off" || storedMode === "auto"
|
||||
? storedMode
|
||||
: "auto";
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
const ANSI = {
|
||||
reset: "\x1b[0m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
brightBlack: "\x1b[90m",
|
||||
brightRed: "\x1b[91m",
|
||||
brightGreen: "\x1b[92m",
|
||||
brightYellow: "\x1b[93m",
|
||||
brightBlue: "\x1b[94m",
|
||||
brightCyan: "\x1b[96m",
|
||||
bold: "\x1b[1m",
|
||||
underline: "\x1b[4m",
|
||||
} as const;
|
||||
|
||||
export interface SyntaxHighlightOptions {
|
||||
logLevels?: boolean;
|
||||
paths?: boolean;
|
||||
timestamps?: boolean;
|
||||
ipAddresses?: boolean;
|
||||
urls?: boolean;
|
||||
numbers?: boolean;
|
||||
}
|
||||
|
||||
interface HighlightPattern {
|
||||
name: string;
|
||||
regex: RegExp;
|
||||
ansiCode: string;
|
||||
priority: number;
|
||||
category: keyof SyntaxHighlightOptions;
|
||||
}
|
||||
|
||||
interface MatchResult {
|
||||
start: number;
|
||||
end: number;
|
||||
ansiCode: string;
|
||||
priority: number;
|
||||
// capture group offset: some patterns capture a sub-group rather than whole match
|
||||
captureStart?: number;
|
||||
captureEnd?: number;
|
||||
}
|
||||
|
||||
interface TextSegment {
|
||||
isAnsi: boolean;
|
||||
content: string;
|
||||
// the last active SGR code before this segment (for state restoration)
|
||||
activeSgr?: string;
|
||||
}
|
||||
|
||||
interface ProtectedRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const MAX_LINE_LENGTH = 2000;
|
||||
|
||||
// Cursor-positioning, erase, and private mode sequences used by TUI apps
|
||||
// (mc, nano, vim, htop). If a chunk contains these, highlighting can inject
|
||||
// extra SGR bytes into a full-screen redraw and corrupt xterm's cursor state.
|
||||
const TUI_SEQUENCE = /\x1b\[(?:[\d;]*[ABCDEFGHJKST]|\?[\d;]*[hl])/;
|
||||
const TUI_FRAME_SEQUENCE = /[\u2500-\u257f]|\x1b[()][0B]|\x1b\[[0-9;]*[~`]/;
|
||||
const CONTROL_STRING_SEQUENCE = /\x1b[\]P^_]/;
|
||||
|
||||
// A bare \r (not immediately followed by \n) means the terminal is overwriting
|
||||
// the current line (shell prompts, progress bars). Highlighting mid-rewrite
|
||||
// chunks corrupts the cursor state, so we skip the whole chunk.
|
||||
const MID_LINE_CR = /\r(?!\n)/;
|
||||
|
||||
// Detects shell prompt lines (user@host:/path$ or similar) after stripping ANSI.
|
||||
// These should not be highlighted — the server already colored them, and injecting
|
||||
// additional ANSI codes into the prompt fragments causes display corruption.
|
||||
//
|
||||
// The parameter-byte class (0-9;?>=!) intentionally also includes `<` and `:` —
|
||||
// SGR mouse-tracking reports (`ESC[<Cb;Cx;CyM`) use `<` as their private-mode
|
||||
// marker. Without it, mouse reports from TUI apps (especially through a
|
||||
// multiplexer like screen) aren't recognized as escape sequences and leak
|
||||
// through as literal "35;191;1M" text on screen.
|
||||
const STRIP_ANSI_RE = /\x1b(?:[@-Z\\-_]|\[[0-9:;<=>?!]*[@-~])/g;
|
||||
const SSH_BRACKET_HEADING_RE =
|
||||
/(?:(?<=^)|(?<=\s))\[[\w.-]+@[\w.-]+(?:[^\]\r\n]*)?\]/g;
|
||||
|
||||
function isShellPromptLine(bare: string): boolean {
|
||||
const plain = bare.replace(STRIP_ANSI_RE, "");
|
||||
// Matches a trailing prompt: "user@host:~$ ", "root@pi:/home/pi# ", "[user@host dir]$ "
|
||||
if (/(?:[\w.-]+@[\w.-]+|[\w.-]+).*?[$#%>]\s*$/.test(plain)) return true;
|
||||
// Matches a leading prompt followed by a command: "user@host:/path$ cmd arg"
|
||||
// This is the echoed command line — the shell colors the prompt prefix itself,
|
||||
// so injecting extra ANSI codes into it causes visual corruption / doubled paths.
|
||||
if (/^(?:\[?[\w.-]+@[\w.-]+[\w./ ~-]*\]?|[\w.-]+).*?[$#%>]\s+\S/.test(plain))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Matches any complete ANSI escape sequence
|
||||
const ANSI_REGEX = /\x1b(?:[@-Z\\-_]|\[[0-9:;<=>?!]*[@-~])/g;
|
||||
|
||||
// Matches SGR sequences (color/style setters) specifically — used to track active color state
|
||||
const SGR_REGEX = /\x1b\[[0-9;]*m/;
|
||||
|
||||
// All patterns, ordered roughly by specificity. Priority determines which wins on overlap.
|
||||
const ALL_PATTERNS: HighlightPattern[] = [
|
||||
// IPv4 with optional :port — well-bounded, very specific
|
||||
{
|
||||
name: "ipv4",
|
||||
regex:
|
||||
/(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?::\d{1,5})?/g,
|
||||
ansiCode: ANSI.magenta,
|
||||
priority: 10,
|
||||
category: "ipAddresses",
|
||||
},
|
||||
|
||||
// Bracket timestamps [HH:MM] or [HH:MM:SS] — tight range checks prevent false positives
|
||||
{
|
||||
name: "timestamp-bracket",
|
||||
regex: /\[(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?\]/g,
|
||||
ansiCode: ANSI.brightBlack,
|
||||
priority: 9,
|
||||
category: "timestamps",
|
||||
},
|
||||
|
||||
// ISO 8601 date with optional time. Require non-digit before to avoid matching
|
||||
// inside version strings like "1.2024.3". The \b on the right ensures we don't
|
||||
// match partial tokens.
|
||||
{
|
||||
name: "timestamp-iso",
|
||||
regex:
|
||||
/(?<![0-9])\b\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b/g,
|
||||
ansiCode: ANSI.brightBlack,
|
||||
priority: 9,
|
||||
category: "timestamps",
|
||||
},
|
||||
|
||||
// Error-level keywords — case-insensitive, catches "Error", "error", "FAILED", etc.
|
||||
{
|
||||
name: "log-error",
|
||||
regex:
|
||||
/\b(?:error|fatal|critical|fail(?:ed)?|denied|exception)\b|\[(?:error|fatal|critical)\]/gi,
|
||||
ansiCode: ANSI.brightRed,
|
||||
priority: 9,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Warning-level keywords — case-insensitive
|
||||
{
|
||||
name: "log-warn",
|
||||
regex: /\b(?:warn(?:ing)?|alert|caution)\b|\[warn(?:ing)?\]/gi,
|
||||
ansiCode: ANSI.brightYellow,
|
||||
priority: 9,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Success keywords — kept conservative to avoid noise.
|
||||
// Negative lookahead prevents matching when directly followed by a path separator
|
||||
// (e.g. shell `cd` output that prints "success~/new/dir").
|
||||
{
|
||||
name: "log-success",
|
||||
regex:
|
||||
/\b(?:success(?:ful(?:ly)?)?|pass(?:ed)?|complete(?:d)?|ok\b)\b(?![\\/~])/gi,
|
||||
ansiCode: ANSI.brightGreen,
|
||||
priority: 8,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// URLs — blue + underline
|
||||
{
|
||||
name: "url",
|
||||
regex: /https?:\/\/[^\s\])}>"']+/g,
|
||||
ansiCode: `${ANSI.blue}${ANSI.underline}`,
|
||||
priority: 8,
|
||||
category: "urls",
|
||||
},
|
||||
|
||||
// Absolute paths — permissive character class that allows *, ?, [], globs, etc.
|
||||
// Requires at least one slash after the root segment so we don't match bare /dev or /tmp.
|
||||
// Boundary: must start at beginning of text or after whitespace/colon/comma/paren.
|
||||
{
|
||||
name: "path-absolute",
|
||||
regex:
|
||||
/(?:(?<=^)|(?<=[\s:,;('"]))\/[^\s"'`|<>&;\\]+(?:\/[^\s"'`|<>&;\\]+)+/g,
|
||||
ansiCode: ANSI.cyan,
|
||||
priority: 7,
|
||||
category: "paths",
|
||||
},
|
||||
|
||||
// Home-relative paths
|
||||
{
|
||||
name: "path-home",
|
||||
regex: /~\/[^\s"'`|<>&;(){}[\]\\]+/g,
|
||||
ansiCode: ANSI.cyan,
|
||||
priority: 7,
|
||||
category: "paths",
|
||||
},
|
||||
|
||||
// Info keyword
|
||||
{
|
||||
name: "log-info",
|
||||
regex: /\binfo\b|\[info\]/gi,
|
||||
ansiCode: ANSI.blue,
|
||||
priority: 6,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Debug/trace
|
||||
{
|
||||
name: "log-debug",
|
||||
regex: /\b(?:debug|trace|verbose)\b|\[(?:debug|trace)\]/gi,
|
||||
ansiCode: ANSI.brightBlack,
|
||||
priority: 6,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Labeled numbers: port 8080, exit 1, code 127, status 404, signal 9, returned 0
|
||||
// Captures just the number portion so only the digit is highlighted.
|
||||
{
|
||||
name: "number-labeled",
|
||||
regex: /\b(?:port|exit|code|status|signal|returned?)\s+(\d+)\b/gi,
|
||||
ansiCode: ANSI.brightCyan,
|
||||
priority: 5,
|
||||
category: "numbers",
|
||||
},
|
||||
];
|
||||
|
||||
function hasIncompleteAnsiSequence(text: string): boolean {
|
||||
return /\x1b\[[0-9:;<=>?!]*$/.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks whether the stream is inside a control string (OSC/DCS/APC/PM) across
|
||||
* chunk boundaries.
|
||||
*
|
||||
* A control string carries text that must never reach the screen — an OSC 0
|
||||
* title, for instance, contains the user, host and path. Its opening `ESC ]`
|
||||
* and its terminator often land in different websocket frames, and the
|
||||
* continuation frame contains no escape byte at all, so every single-chunk
|
||||
* guard here misses it. Highlighting that continuation injects an SGR sequence
|
||||
* into the middle of the string, which aborts it early in xterm.js and dumps
|
||||
* the rest of the payload on screen as ordinary text.
|
||||
*
|
||||
* A trailing lone ESC counts as active for the same reason: its intent is only
|
||||
* knowable from the next chunk.
|
||||
*/
|
||||
export function updateControlStringMode(
|
||||
output: string,
|
||||
currentMode: boolean,
|
||||
): { isActive: boolean; wasActive: boolean } {
|
||||
const wasActive = currentMode;
|
||||
let isActive = currentMode;
|
||||
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
const char = output[i];
|
||||
|
||||
if (isActive) {
|
||||
if (char === "\x07") {
|
||||
isActive = false;
|
||||
} else if (char === "\x1b") {
|
||||
// ST (ESC \) closes it; any other ESC aborts it.
|
||||
isActive = false;
|
||||
if (output[i + 1] === "\\") i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char !== "\x1b") continue;
|
||||
|
||||
const next = output[i + 1];
|
||||
if (next === undefined) return { isActive: true, wasActive };
|
||||
if (next === "]" || next === "P" || next === "^" || next === "_") {
|
||||
isActive = true;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return { isActive, wasActive };
|
||||
}
|
||||
|
||||
function parseAnsiSegments(text: string): TextSegment[] {
|
||||
const segments: TextSegment[] = [];
|
||||
ANSI_REGEX.lastIndex = 0;
|
||||
let lastIndex = 0;
|
||||
let activeSgr = "";
|
||||
let match;
|
||||
|
||||
while ((match = ANSI_REGEX.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
isAnsi: false,
|
||||
content: text.slice(lastIndex, match.index),
|
||||
activeSgr,
|
||||
});
|
||||
}
|
||||
const seq = match[0];
|
||||
segments.push({ isAnsi: true, content: seq });
|
||||
// Track the active SGR state so we can restore it after a highlight reset
|
||||
if (SGR_REGEX.test(seq)) {
|
||||
activeSgr = seq === ANSI.reset ? "" : seq;
|
||||
}
|
||||
lastIndex = ANSI_REGEX.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({ isAnsi: false, content: text.slice(lastIndex), activeSgr });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function highlightPlainText(
|
||||
text: string,
|
||||
activePatterns: HighlightPattern[],
|
||||
activeSgr: string,
|
||||
protectedRanges: ProtectedRange[],
|
||||
): string {
|
||||
if (text.length > MAX_LINE_LENGTH || !text.trim()) return text;
|
||||
|
||||
const matches: MatchResult[] = [];
|
||||
|
||||
for (const pattern of activePatterns) {
|
||||
pattern.regex.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = pattern.regex.exec(text)) !== null) {
|
||||
// For patterns with a capture group (labeled numbers), highlight only the capture
|
||||
if (m[1] !== undefined) {
|
||||
const captureOffset = m[0].indexOf(m[1], m[0].search(/\d/));
|
||||
const captureStart = m.index + captureOffset;
|
||||
const captureEnd = captureStart + m[1].length;
|
||||
if (isProtectedRange(captureStart, captureEnd, protectedRanges)) {
|
||||
continue;
|
||||
}
|
||||
matches.push({
|
||||
start: m.index,
|
||||
end: m.index + m[0].length,
|
||||
ansiCode: pattern.ansiCode,
|
||||
priority: pattern.priority,
|
||||
captureStart,
|
||||
captureEnd,
|
||||
});
|
||||
} else {
|
||||
if (isProtectedRange(m.index, m.index + m[0].length, protectedRanges)) {
|
||||
continue;
|
||||
}
|
||||
matches.push({
|
||||
start: m.index,
|
||||
end: m.index + m[0].length,
|
||||
ansiCode: pattern.ansiCode,
|
||||
priority: pattern.priority,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) return text;
|
||||
|
||||
// Highest priority wins; ties broken by earlier start position
|
||||
matches.sort((a, b) =>
|
||||
a.priority !== b.priority ? b.priority - a.priority : a.start - b.start,
|
||||
);
|
||||
|
||||
const used: Array<{ start: number; end: number }> = [];
|
||||
const final = matches.filter((m) => {
|
||||
const overlaps = used.some(
|
||||
(r) =>
|
||||
(m.start >= r.start && m.start < r.end) ||
|
||||
(m.end > r.start && m.end <= r.end) ||
|
||||
(m.start <= r.start && m.end >= r.end),
|
||||
);
|
||||
if (!overlaps) {
|
||||
used.push({ start: m.start, end: m.end });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Apply in reverse order so indices stay valid as we insert escape codes
|
||||
final.sort((a, b) => a.start - b.start).reverse();
|
||||
|
||||
let result = text;
|
||||
for (const m of final) {
|
||||
const hs = m.captureStart ?? m.start;
|
||||
const he = m.captureEnd ?? m.end;
|
||||
|
||||
// After reset, re-emit the SGR that was active before this plain-text segment
|
||||
// so we don't clobber colors set by the server earlier in the line.
|
||||
const restore = activeSgr || "";
|
||||
result =
|
||||
result.slice(0, hs) +
|
||||
m.ansiCode +
|
||||
result.slice(hs, he) +
|
||||
ANSI.reset +
|
||||
restore +
|
||||
result.slice(he);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getProtectedRanges(text: string): ProtectedRange[] {
|
||||
const ranges: ProtectedRange[] = [];
|
||||
SSH_BRACKET_HEADING_RE.lastIndex = 0;
|
||||
|
||||
let match;
|
||||
while ((match = SSH_BRACKET_HEADING_RE.exec(text)) !== null) {
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function isProtectedRange(
|
||||
start: number,
|
||||
end: number,
|
||||
ranges: ProtectedRange[],
|
||||
): boolean {
|
||||
return ranges.some((range) => start < range.end && end > range.start);
|
||||
}
|
||||
|
||||
function buildActivePatterns(
|
||||
options: SyntaxHighlightOptions,
|
||||
): HighlightPattern[] {
|
||||
return ALL_PATTERNS.filter((p) => options[p.category] !== false);
|
||||
}
|
||||
|
||||
function highlightLine(
|
||||
line: string,
|
||||
activePatterns: HighlightPattern[],
|
||||
): string {
|
||||
const cr = line.endsWith("\r");
|
||||
const bare = cr ? line.slice(0, -1) : line;
|
||||
|
||||
if (!bare.trim()) return line;
|
||||
if (bare.length > MAX_LINE_LENGTH) return line;
|
||||
if (isShellPromptLine(bare)) return line;
|
||||
|
||||
// Compute protected ranges (e.g. SSH bracket headings) against the fully
|
||||
// stripped line rather than per-ANSI-segment text. A colored prompt theme
|
||||
// (e.g. "[<color>user<reset>@<color>host<reset>]") splits the heading across
|
||||
// multiple plain-text segments, so matching per-segment would miss it and
|
||||
// let a username like "warning" get wrongly highlighted as a log level.
|
||||
const plainLine = bare.replace(STRIP_ANSI_RE, "");
|
||||
const lineProtectedRanges = getProtectedRanges(plainLine);
|
||||
|
||||
const segments = parseAnsiSegments(bare);
|
||||
let plainOffset = 0;
|
||||
const result = segments
|
||||
.map((s) => {
|
||||
if (s.isAnsi) return s.content;
|
||||
const segmentStart = plainOffset;
|
||||
plainOffset += s.content.length;
|
||||
const localRanges = lineProtectedRanges
|
||||
.map((r) => ({
|
||||
start: r.start - segmentStart,
|
||||
end: r.end - segmentStart,
|
||||
}))
|
||||
.filter((r) => r.start < s.content.length && r.end > 0);
|
||||
return highlightPlainText(
|
||||
s.content,
|
||||
activePatterns,
|
||||
s.activeSgr ?? "",
|
||||
localRanges,
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
|
||||
return cr ? result + "\r" : result;
|
||||
}
|
||||
|
||||
export function highlightTerminalOutput(
|
||||
text: string,
|
||||
options: SyntaxHighlightOptions = {},
|
||||
): string {
|
||||
if (!text || !text.trim()) return text;
|
||||
if (hasIncompleteAnsiSequence(text)) return text;
|
||||
|
||||
if (TUI_SEQUENCE.test(text)) return text;
|
||||
if (TUI_FRAME_SEQUENCE.test(text)) return text;
|
||||
if (CONTROL_STRING_SEQUENCE.test(text)) return text;
|
||||
if (MID_LINE_CR.test(text)) return text;
|
||||
|
||||
const activePatterns = buildActivePatterns(options);
|
||||
if (activePatterns.length === 0) return text;
|
||||
|
||||
const lines = text.split("\n");
|
||||
const endsWithNewline = text.endsWith("\n");
|
||||
const hasMultipleLines = lines.length > 1;
|
||||
|
||||
// When a multi-line chunk has a trailing fragment without \n, that fragment
|
||||
// could be a live readline input line that bash will redraw with \r +
|
||||
// cursor-positioning sequences. Injecting ANSI bytes into it adds invisible
|
||||
// chars that bash doesn't count, so bash's cursor arithmetic diverges from
|
||||
// xterm's actual column position — causing the cursor to jump and text to
|
||||
// shift when the user types or navigates with arrow keys.
|
||||
// Only skip the last fragment when the chunk already has complete lines
|
||||
// before it (single-line chunks with no \n are plain output, not input).
|
||||
const highlightCount =
|
||||
hasMultipleLines && !endsWithNewline ? lines.length - 1 : lines.length;
|
||||
|
||||
return lines
|
||||
.map((line, i) =>
|
||||
i < highlightCount ? highlightLine(line, activePatterns) : line,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { toast } from "sonner";
|
||||
import { getMacLineNavigationSequence } from "../lib/mac-line-navigation";
|
||||
import {
|
||||
handleTerminalClipboardKeyEvent,
|
||||
createTerminalContextMenuHandler,
|
||||
} from "../terminal/terminal-clipboard";
|
||||
import {
|
||||
useAppTheme as useTheme,
|
||||
resolveTermixThemeColors,
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
TERMINAL_FONTS,
|
||||
ensureTerminalFontsLoaded,
|
||||
RobustClipboardProvider,
|
||||
copyToClipboard,
|
||||
readFromClipboard,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export function LocalTerminal({
|
||||
instanceId,
|
||||
isVisible,
|
||||
}: {
|
||||
instanceId: string;
|
||||
isVisible: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Read via a ref inside the session-lifecycle effect below so a language
|
||||
// change (which gives react-i18next a new `t` identity) doesn't tear down
|
||||
// the running local shell and spawn a new session.
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
const { theme: appTheme } = useTheme();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const [isWindows, setIsWindows] = useState(false);
|
||||
const [shell, setShell] = useState<"default" | "wsl">("default");
|
||||
|
||||
useEffect(() => {
|
||||
window.electronAPI?.getPlatform().then((platform) => {
|
||||
setIsWindows(platform === "win32");
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fit = useCallback(() => {
|
||||
const fitAddon = fitAddonRef.current;
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (!terminal || !fitAddon) return;
|
||||
fitAddon.fit();
|
||||
if (sessionId) {
|
||||
window.electronAPI.resizeLocalTerminal(
|
||||
sessionId,
|
||||
terminal.cols,
|
||||
terminal.rows,
|
||||
);
|
||||
}
|
||||
}, [terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal) return;
|
||||
const colors = resolveTermixThemeColors("termix", appTheme);
|
||||
const font = TERMINAL_FONTS.find(
|
||||
(item) => item.value === DEFAULT_TERMINAL_CONFIG.fontFamily,
|
||||
);
|
||||
ensureTerminalFontsLoaded(font?.value ?? TERMINAL_FONTS[0].value);
|
||||
terminal.options.theme = colors;
|
||||
terminal.options.fontFamily = font?.fallback ?? TERMINAL_FONTS[0].fallback;
|
||||
terminal.options.fontSize = DEFAULT_TERMINAL_CONFIG.fontSize;
|
||||
}, [appTheme, terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !window.electronAPI?.isElectron) return;
|
||||
const fitAddon = new FitAddon();
|
||||
const clipboardProvider = new RobustClipboardProvider();
|
||||
const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider);
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(clipboardAddon);
|
||||
fitAddon.fit();
|
||||
|
||||
async function writeTextToClipboard(text: string): Promise<boolean> {
|
||||
const ok = await copyToClipboard(text);
|
||||
if (!ok) toast.error(tRef.current("terminal.clipboardWriteFailed"));
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function readTextFromClipboard(): Promise<string> {
|
||||
const text = await readFromClipboard();
|
||||
if (!text) toast.error(tRef.current("terminal.clipboardReadFailed"));
|
||||
return text;
|
||||
}
|
||||
|
||||
const clipboardActions = { writeTextToClipboard, readTextFromClipboard };
|
||||
|
||||
terminal.attachCustomKeyEventHandler((e: KeyboardEvent): boolean => {
|
||||
if (e.type !== "keydown") return true;
|
||||
const sequence = getMacLineNavigationSequence(e);
|
||||
if (sequence) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
terminal.input(sequence, true);
|
||||
return false;
|
||||
}
|
||||
// No native "paste" event listener here (unlike the SSH terminal), so
|
||||
// plain Ctrl/Cmd+V reads the clipboard explicitly rather than relying
|
||||
// on the browser's own paste event.
|
||||
return handleTerminalClipboardKeyEvent(e, terminal, clipboardActions, {
|
||||
plainPasteMode: "explicit",
|
||||
});
|
||||
});
|
||||
|
||||
const handleContextMenu = createTerminalContextMenuHandler(
|
||||
terminal,
|
||||
clipboardActions,
|
||||
);
|
||||
const element = xtermRef.current;
|
||||
element?.addEventListener("contextmenu", handleContextMenu);
|
||||
|
||||
let disposed = false;
|
||||
let removeData = () => {};
|
||||
let removeExit = () => {};
|
||||
const input = terminal.onData((data) => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (sessionId) window.electronAPI.writeLocalTerminal(sessionId, data);
|
||||
});
|
||||
|
||||
window.electronAPI
|
||||
.startLocalTerminal({ cols: terminal.cols, rows: terminal.rows, shell })
|
||||
.then(({ sessionId }) => {
|
||||
if (disposed) {
|
||||
window.electronAPI.closeLocalTerminal(sessionId);
|
||||
return;
|
||||
}
|
||||
sessionIdRef.current = sessionId;
|
||||
removeData = window.electronAPI.onLocalTerminalData(sessionId, (data) =>
|
||||
terminal.write(data),
|
||||
);
|
||||
removeExit = window.electronAPI.onLocalTerminalExit(
|
||||
sessionId,
|
||||
(exitCode) => {
|
||||
sessionIdRef.current = null;
|
||||
terminal.write(
|
||||
`\r\n\x1b[33mProcess exited (${exitCode})\x1b[0m\r\n`,
|
||||
);
|
||||
},
|
||||
);
|
||||
return window.electronAPI.readyLocalTerminal(sessionId);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
terminal.write(`\r\n\x1b[31m${message}\x1b[0m\r\n`);
|
||||
});
|
||||
|
||||
const observer = new ResizeObserver(() => fit());
|
||||
if (xtermRef.current) observer.observe(xtermRef.current);
|
||||
return () => {
|
||||
disposed = true;
|
||||
terminal.attachCustomKeyEventHandler(() => true);
|
||||
observer.disconnect();
|
||||
input.dispose();
|
||||
removeData();
|
||||
removeExit();
|
||||
element?.removeEventListener("contextmenu", handleContextMenu);
|
||||
clipboardProvider.dispose();
|
||||
const sessionId = sessionIdRef.current;
|
||||
sessionIdRef.current = null;
|
||||
if (sessionId) window.electronAPI.closeLocalTerminal(sessionId);
|
||||
fitAddonRef.current = null;
|
||||
fitAddon.dispose();
|
||||
};
|
||||
}, [fit, instanceId, shell, terminal, xtermRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) requestAnimationFrame(fit);
|
||||
}, [fit, isVisible]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col bg-background">
|
||||
{isWindows && (
|
||||
<div className="flex justify-end border-b border-border px-2 py-1">
|
||||
<select
|
||||
aria-label="Local terminal shell"
|
||||
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground"
|
||||
value={shell}
|
||||
onChange={(event) =>
|
||||
setShell(event.target.value === "wsl" ? "wsl" : "default")
|
||||
}
|
||||
>
|
||||
<option value="default">PowerShell</option>
|
||||
<option value="wsl">WSL</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div ref={xtermRef} className="min-h-0 flex-1 p-2" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TerminalHandle } from "@/features/terminal/terminal-types";
|
||||
import type { TerminalHandle } from "./terminal/terminal-types";
|
||||
|
||||
export interface TerminalSessionInfo {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button, Input } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
usePluginApi,
|
||||
useTranslation,
|
||||
type SettingsComponentProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
interface TestResult {
|
||||
mode: string;
|
||||
connected: boolean;
|
||||
remoteSftpAvailable: boolean;
|
||||
localHostVisible: boolean | null;
|
||||
selectedMode: "local" | "remote-sftp" | "unavailable";
|
||||
localMappingConfigured: boolean;
|
||||
}
|
||||
|
||||
const SELECTED_MODE_LABEL_KEYS: Record<TestResult["selectedMode"], string> = {
|
||||
local: "settings.admin.imageStorageMode.local",
|
||||
"remote-sftp": "settings.admin.imageStorageMode.remoteSftp",
|
||||
unavailable: "imageStorage.selectedModeUnavailable",
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks which storage an image upload would use for one of the admin's own
|
||||
* connected terminals, against the saved settings.
|
||||
*/
|
||||
export function ImageStorageTest(_props: SettingsComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const api = usePluginApi();
|
||||
const [instanceId, setInstanceId] = useState("");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [result, setResult] = useState<TestResult | null>(null);
|
||||
|
||||
const runTest = async () => {
|
||||
setTesting(true);
|
||||
setResult(null);
|
||||
try {
|
||||
const { data } = await api.post<TestResult>("/image-storage/test", {
|
||||
instanceId: instanceId.trim(),
|
||||
});
|
||||
setResult(data);
|
||||
} catch {
|
||||
toast.error(t("imageStorage.testFailed"));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 py-3">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-sm font-medium leading-snug">
|
||||
{t("imageStorage.instanceId")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground leading-snug">
|
||||
{t("imageStorage.instanceIdDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<Input
|
||||
aria-label={t("imageStorage.instanceId")}
|
||||
value={instanceId}
|
||||
onChange={(event) => setInstanceId(event.target.value)}
|
||||
className="h-8 w-full @md:w-64 text-xs"
|
||||
/>
|
||||
{result && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<div>
|
||||
{result.connected
|
||||
? t("imageStorage.testConnected")
|
||||
: t("imageStorage.testNotConnected")}
|
||||
</div>
|
||||
<div>
|
||||
{t("imageStorage.testSelectedMode", {
|
||||
mode: t(SELECTED_MODE_LABEL_KEYS[result.selectedMode]),
|
||||
})}
|
||||
</div>
|
||||
{result.localHostVisible !== null && (
|
||||
<div>
|
||||
{result.localHostVisible
|
||||
? t("imageStorage.testLocalVisible")
|
||||
: t("imageStorage.testLocalNotVisible")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={testing || !instanceId.trim()}
|
||||
onClick={() => void runTest()}
|
||||
>
|
||||
{testing ? t("imageStorage.testing") : t("imageStorage.test")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import {
|
||||
normalizeTouchInputSettings,
|
||||
TOUCH_INPUT_DEFAULTS,
|
||||
TOUCH_INPUT_NUMERIC_BOUNDS,
|
||||
type TouchInputNumericKey,
|
||||
type TouchInputSettings as TouchInputValues,
|
||||
} from "../../shared/touch-input-settings";
|
||||
import { Button, Input, SettingRow, Switch } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useTranslation,
|
||||
type SettingsComponentProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { cacheTouchInputSettings } from "../terminal/touch-input-settings-store";
|
||||
|
||||
const fields: Array<{
|
||||
key: TouchInputNumericKey;
|
||||
label: string;
|
||||
unit: string;
|
||||
}> = [
|
||||
{ key: "dragThresholdPx", label: "dragThreshold", unit: "px" },
|
||||
{ key: "maxWheelDeltaPx", label: "maxWheelDelta", unit: "px" },
|
||||
{
|
||||
key: "momentumSampleWindowMs",
|
||||
label: "momentumSampleWindow",
|
||||
unit: "ms",
|
||||
},
|
||||
{
|
||||
key: "releaseGracePeriodMs",
|
||||
label: "releaseGracePeriod",
|
||||
unit: "ms",
|
||||
},
|
||||
{
|
||||
key: "minimumVelocityPxPerMs",
|
||||
label: "minimumVelocity",
|
||||
unit: "px/ms",
|
||||
},
|
||||
{
|
||||
key: "maximumVelocityPxPerMs",
|
||||
label: "maximumVelocity",
|
||||
unit: "px/ms",
|
||||
},
|
||||
{ key: "maximumDurationMs", label: "maximumDuration", unit: "ms" },
|
||||
{ key: "maximumTravelPx", label: "maximumTravel", unit: "px" },
|
||||
{ key: "decayTimeMs", label: "decayTime", unit: "ms" },
|
||||
{ key: "pixelsPerTick", label: "pixelsPerTick", unit: "px" },
|
||||
{
|
||||
key: "maximumFrameIntervalMs",
|
||||
label: "maximumFrameInterval",
|
||||
unit: "ms",
|
||||
},
|
||||
{
|
||||
key: "maximumTicksPerFrame",
|
||||
label: "maximumTicksPerFrame",
|
||||
unit: "",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Touch scrolling tuning for terminals on touch screens, stored as one
|
||||
* object in the "touchInput" admin setting.
|
||||
*/
|
||||
export function TouchInputSettings({
|
||||
values,
|
||||
setValue,
|
||||
}: SettingsComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const settings = normalizeTouchInputSettings(values.touchInput);
|
||||
const setSettings = (next: TouchInputValues) => {
|
||||
const normalized = normalizeTouchInputSettings(next);
|
||||
cacheTouchInputSettings(normalized);
|
||||
setValue("touchInput", normalized);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0">
|
||||
<SettingRow
|
||||
label={t("touchInput.enabled")}
|
||||
description={t("touchInput.enabledDesc")}
|
||||
>
|
||||
<Switch
|
||||
checked={settings.enabled}
|
||||
onCheckedChange={(enabled) => setSettings({ ...settings, enabled })}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("touchInput.momentumEnabled")}
|
||||
description={t("touchInput.momentumEnabledDesc")}
|
||||
>
|
||||
<Switch
|
||||
checked={settings.momentumEnabled}
|
||||
disabled={!settings.enabled}
|
||||
onCheckedChange={(momentumEnabled) =>
|
||||
setSettings({ ...settings, momentumEnabled })
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 py-3 text-left text-xs font-semibold text-foreground"
|
||||
onClick={() => setAdvancedOpen((value) => !value)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-3.5 transition-transform ${advancedOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
{t("touchInput.advanced")}
|
||||
</button>
|
||||
{advancedOpen && (
|
||||
<div className="grid grid-cols-1 gap-3 pb-3 sm:grid-cols-2">
|
||||
{fields.map(({ key, label, unit }) => {
|
||||
const bounds = TOUCH_INPUT_NUMERIC_BOUNDS[key];
|
||||
return (
|
||||
<label key={key} className="flex flex-col gap-1 text-xs">
|
||||
<span className="font-medium text-foreground">
|
||||
{t(`touchInput.${label}`)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
aria-label={t(`touchInput.${label}`)}
|
||||
type="number"
|
||||
min={bounds.min}
|
||||
max={bounds.max}
|
||||
step={bounds.step}
|
||||
value={settings[key]}
|
||||
onChange={(event) =>
|
||||
setSettings({
|
||||
...settings,
|
||||
[key]: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
{unit && (
|
||||
<span className="w-10 text-muted-foreground">{unit}</span>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSettings({ ...TOUCH_INPUT_DEFAULTS })}
|
||||
>
|
||||
{t("touchInput.resetDefaults")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
normalizeTouchInputSettings,
|
||||
type TouchInputSettings,
|
||||
} from "../shared/touch-input-settings";
|
||||
|
||||
/** Calls to this plugin's own routes under /plugin-api/ssh-terminal/. */
|
||||
|
||||
export interface TerminalClientSettings {
|
||||
sessionTimeoutMinutes: number;
|
||||
sessionPersistence: boolean;
|
||||
commandHistoryEnabled: boolean;
|
||||
touchInput: TouchInputSettings;
|
||||
}
|
||||
|
||||
export async function getClientSettings(
|
||||
api: PluginApiClient,
|
||||
): Promise<TerminalClientSettings> {
|
||||
const { data } = await api.get<TerminalClientSettings>("/client-settings");
|
||||
return { ...data, touchInput: normalizeTouchInputSettings(data?.touchInput) };
|
||||
}
|
||||
|
||||
export async function saveCommandToHistory(
|
||||
api: PluginApiClient,
|
||||
hostId: number,
|
||||
command: string,
|
||||
): Promise<void> {
|
||||
await api.post("/command-history", { hostId, command });
|
||||
}
|
||||
|
||||
export async function getCommandHistory(
|
||||
api: PluginApiClient,
|
||||
hostId: number,
|
||||
): Promise<string[]> {
|
||||
const { data } = await api.get<string[]>(`/command-history/${hostId}`);
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
|
||||
export async function deleteCommandFromHistory(
|
||||
api: PluginApiClient,
|
||||
hostId: number,
|
||||
command: string,
|
||||
): Promise<void> {
|
||||
await api.post("/command-history/delete", { hostId, command });
|
||||
}
|
||||
|
||||
export async function clearCommandHistory(
|
||||
api: PluginApiClient,
|
||||
hostId: number,
|
||||
): Promise<void> {
|
||||
await api.delete(`/command-history/${hostId}`);
|
||||
}
|
||||
|
||||
/** A host-scope setting of this plugin, as the host payload carries it. */
|
||||
export function hostSetting(
|
||||
host: object | null | undefined,
|
||||
key: string,
|
||||
fallback: boolean,
|
||||
): boolean {
|
||||
const settings = (
|
||||
host as { pluginSettings?: Record<string, Record<string, unknown>> } | null
|
||||
)?.pluginSettings;
|
||||
const value = settings?.["ssh-terminal"]?.[key];
|
||||
return typeof value === "boolean" ? value : fallback;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Host and terminal shapes as the terminal reads them. The plugin keeps its
|
||||
* own copy instead of importing core's types; only the fields it uses matter,
|
||||
* and the index signatures let the rest of a host record through.
|
||||
*/
|
||||
|
||||
export interface TerminalConfig {
|
||||
localEcho?: "default" | "off" | "auto" | "on";
|
||||
cursorBlink: boolean;
|
||||
cursorStyle: "block" | "underline" | "bar";
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
letterSpacing: number;
|
||||
lineHeight: number;
|
||||
theme: string;
|
||||
|
||||
scrollback: number;
|
||||
bellStyle: "none" | "sound" | "visual" | "both";
|
||||
rightClickSelectsWord: boolean;
|
||||
macOptionIsMeta: boolean;
|
||||
fastScrollModifier: "alt" | "ctrl" | "shift";
|
||||
fastScrollSensitivity: number;
|
||||
minimumContrastRatio: number;
|
||||
|
||||
backspaceMode: "normal" | "control-h";
|
||||
agentForwarding: boolean;
|
||||
environmentVariables: Array<{ key: string; value: string }>;
|
||||
startupSnippetId: number | null;
|
||||
autoMosh: boolean;
|
||||
moshCommand: string;
|
||||
sudoPasswordAutoFill: boolean;
|
||||
sudoPassword?: string | null;
|
||||
keepaliveInterval?: number;
|
||||
keepaliveCountMax?: number;
|
||||
autoTmux: boolean;
|
||||
syntaxHighlighting: boolean;
|
||||
syntaxHighlightingOptions?: {
|
||||
logLevels: boolean;
|
||||
paths: boolean;
|
||||
timestamps: boolean;
|
||||
ipAddresses: boolean;
|
||||
urls: boolean;
|
||||
numbers: boolean;
|
||||
};
|
||||
backgroundImage?: string;
|
||||
backgroundImageOpacity?: number;
|
||||
allowLegacyAlgorithms?: boolean;
|
||||
linkClickBehavior?: "confirm" | "direct";
|
||||
useSSHTitle?: boolean;
|
||||
agentSocketPath?: string;
|
||||
agentIdentity?: string;
|
||||
customThemeColors?: {
|
||||
background: string;
|
||||
foreground: string;
|
||||
cursor?: string;
|
||||
cursorAccent?: string;
|
||||
selectionBackground?: string;
|
||||
selectionForeground?: string;
|
||||
black: string;
|
||||
red: string;
|
||||
green: string;
|
||||
yellow: string;
|
||||
blue: string;
|
||||
magenta: string;
|
||||
cyan: string;
|
||||
white: string;
|
||||
brightBlack: string;
|
||||
brightRed: string;
|
||||
brightGreen: string;
|
||||
brightYellow: string;
|
||||
brightBlue: string;
|
||||
brightMagenta: string;
|
||||
brightCyan: string;
|
||||
brightWhite: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type HostBackspaceMode = TerminalConfig["backspaceMode"];
|
||||
|
||||
/** A host as the shell hands it to a tab. */
|
||||
export interface Host {
|
||||
id: number | string;
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
terminalConfig?: Partial<TerminalConfig> | null;
|
||||
pluginSettings?: Record<string, Record<string, unknown>>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** Enough of a snippet for the variables dialog. */
|
||||
export interface Snippet {
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export type TabType = string;
|
||||
Reference in new issue
Block a user