mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: move terminal settings, look and ssh tools into ssh-terminal plugin
This commit is contained in:
1 parent
ffa872ebd3
commit
d9bba10612
242 files changed
+25071
-7207
No files matched your search
@@ -1,20 +1,50 @@
|
||||
import {
|
||||
hostSettingsFromTerminalConfig,
|
||||
terminalConfigFromHostSettings,
|
||||
} from "../shared/terminal-settings.js";
|
||||
|
||||
const SWITCH_KEYS = [
|
||||
"enableTerminal",
|
||||
"enableTerminalToolbar",
|
||||
"enableCommandHistory",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Registered as ctx.registry.provide("ssh-terminal.hostImportNormalizer", ...)
|
||||
* so a Termix-JSON host import keeps its terminal switches, which used to be
|
||||
* host columns and are this plugin's host settings now.
|
||||
* host columns, and its terminal look and behavior, which a 2.8 or early 2.9
|
||||
* export carried in terminalConfig. An export that already carries this
|
||||
* plugin's pluginSettings has had them written by core, so terminalConfig is
|
||||
* only read without them.
|
||||
*/
|
||||
export function hostImportNormalizer(
|
||||
raw: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const keys = [
|
||||
"enableTerminal",
|
||||
"enableTerminalToolbar",
|
||||
"enableCommandHistory",
|
||||
] as const;
|
||||
if (keys.every((key) => raw[key] === undefined)) return null;
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const key of keys) {
|
||||
for (const key of SWITCH_KEYS) {
|
||||
if (raw[key] !== undefined) values[key] = raw[key] !== false;
|
||||
}
|
||||
return values;
|
||||
|
||||
const carried = (raw.pluginSettings as Record<string, unknown> | undefined)?.[
|
||||
"ssh-terminal"
|
||||
];
|
||||
const hasCarried =
|
||||
!!carried && typeof carried === "object" && Object.keys(carried).length > 0;
|
||||
if (!hasCarried) {
|
||||
Object.assign(values, hostSettingsFromTerminalConfig(raw.terminalConfig));
|
||||
}
|
||||
|
||||
return Object.keys(values).length > 0 ? values : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered as "ssh-terminal.hostPayloadLegacy": the terminal keys of the
|
||||
* 2.8 terminalConfig, for clients that still read it (Termix-Mobile). Core
|
||||
* merges them into the terminalConfig it sends, never over a key it set.
|
||||
* Remove once the mobile app reads pluginSettings["ssh-terminal"].
|
||||
*/
|
||||
export function hostPayloadLegacy(
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return { terminalConfig: terminalConfigFromHostSettings(values) };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { createTerminalLogger } from "./helpers.js";
|
||||
import { hostImportNormalizer } from "./host-import.js";
|
||||
import { hostImportNormalizer, hostPayloadLegacy } from "./host-import.js";
|
||||
import { createHistoryRepository } from "./history-repository.js";
|
||||
import { registerTerminalRoutes } from "./routes.js";
|
||||
import {
|
||||
@@ -171,6 +171,7 @@ export async function activate(ctx: PluginContext) {
|
||||
"ssh-terminal.hostImportNormalizer",
|
||||
hostImportNormalizer,
|
||||
);
|
||||
ctx.registry.provide("ssh-terminal.hostPayloadLegacy", hostPayloadLegacy);
|
||||
|
||||
ctx.log.info("SSH terminal mounted at /plugin-ws/ssh-terminal/terminal");
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { TerminalSessionManager } from "./session-manager.js";
|
||||
import {
|
||||
ADMIN_KEYS,
|
||||
HOST_KEYS,
|
||||
MOVABLE_USER_KEYS,
|
||||
readClientSettings,
|
||||
readImageStorageSettings,
|
||||
} from "./settings.js";
|
||||
@@ -524,6 +525,99 @@ export function registerTerminalRoutes(
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/user-settings:
|
||||
* put:
|
||||
* summary: Save some of the caller's terminal settings
|
||||
* description: Writes the caller's local echo, link click and command autocomplete settings. The terminal uses it once, to move values a browser kept before 2.9.0.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* localEcho:
|
||||
* type: string
|
||||
* linkClickBehavior:
|
||||
* type: string
|
||||
* commandAutocomplete:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Saved.
|
||||
* 400:
|
||||
* description: A value the setting does not accept.
|
||||
*/
|
||||
router.put("/user-settings", json, async (req, res) => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) return res.status(401).json({ error: "Sign in first" });
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
try {
|
||||
for (const key of MOVABLE_USER_KEYS) {
|
||||
if (body[key] !== undefined) {
|
||||
await ctx.settings.setUser(userId, key, body[key]);
|
||||
}
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(400).json({ error: getErrorMessage(err, "Invalid setting") });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/hosts/{hostId}/auto-tmux:
|
||||
* put:
|
||||
* summary: Turn auto tmux on or off for a host
|
||||
* description: Sets the host's autoTmux terminal setting. Used by the "enable Auto-Tmux" action shown when a persisted session expires. Needs edit access to the host.
|
||||
* tags:
|
||||
* - Terminal
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* enabled:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Saved.
|
||||
* 400:
|
||||
* description: Invalid host id or value.
|
||||
* 403:
|
||||
* description: No edit access to the host.
|
||||
*/
|
||||
router.put("/hosts/:hostId/auto-tmux", json, async (req, res) => {
|
||||
const hostId = Number(req.params.hostId);
|
||||
const enabled = (req.body ?? {}).enabled;
|
||||
if (!Number.isInteger(hostId) || typeof enabled !== "boolean") {
|
||||
return res.status(400).json({ error: "Invalid host or value" });
|
||||
}
|
||||
try {
|
||||
const access = await ctx.hosts.checkAccess(hostId, "edit");
|
||||
if (!access.hasAccess) {
|
||||
return res.status(403).json({ error: "Access denied to host" });
|
||||
}
|
||||
await ctx.settings.setHost(hostId, HOST_KEYS.autoTmux, enabled);
|
||||
res.json({ success: true, autoTmux: enabled });
|
||||
} catch (err) {
|
||||
log.error("Failed to update auto tmux", err);
|
||||
res.status(500).json({ error: "Failed to update auto tmux" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/ssh-terminal/command-history:
|
||||
|
||||
@@ -9,6 +9,14 @@ import {
|
||||
type TerminalImageStorageSettings,
|
||||
} from "./images/image-storage-settings.js";
|
||||
import { DEFAULT_TIMEOUT_MINUTES } from "./session-manager.js";
|
||||
import {
|
||||
DEFAULT_APPEARANCE,
|
||||
NEW_HOST_ADMIN_KEYS,
|
||||
pickTerminalValues,
|
||||
readUserSettings,
|
||||
type NewHostTerminalDefaults,
|
||||
type TerminalUserSettings,
|
||||
} from "../shared/terminal-settings.js";
|
||||
|
||||
/**
|
||||
* Admin setting keys, as declared in manifest.json. The image storage fields
|
||||
@@ -37,8 +45,16 @@ export const HOST_KEYS = {
|
||||
terminalToolbarShowStatus: "terminalToolbarShowStatus",
|
||||
terminalToolbarFade: "terminalToolbarFade",
|
||||
enableCommandHistory: "enableCommandHistory",
|
||||
autoTmux: "autoTmux",
|
||||
} as const;
|
||||
|
||||
/** User settings a browser kept in localStorage before 2.9.0. */
|
||||
export const MOVABLE_USER_KEYS = [
|
||||
"localEcho",
|
||||
"linkClickBehavior",
|
||||
"commandAutocomplete",
|
||||
] as const;
|
||||
|
||||
const IMAGE_FIELD_BY_LEGACY_KEY: Record<string, string> = {
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.mode]: ADMIN_KEYS.imageStorageMode,
|
||||
[TERMINAL_IMAGE_STORAGE_KEYS.localDir]: ADMIN_KEYS.imageLocalDir,
|
||||
@@ -68,6 +84,10 @@ export interface ClientSettings {
|
||||
sessionPersistence: boolean;
|
||||
commandHistoryEnabled: boolean;
|
||||
touchInput: TouchInputSettings;
|
||||
/** What a new host's terminal starts with. */
|
||||
newHostDefaults: NewHostTerminalDefaults;
|
||||
/** The caller's own terminal settings. */
|
||||
user: TerminalUserSettings;
|
||||
}
|
||||
|
||||
/** What every signed-in user's terminal needs, whether or not they are an admin. */
|
||||
@@ -84,6 +104,8 @@ export async function readClientSettings(
|
||||
touch = null;
|
||||
}
|
||||
}
|
||||
const actor = ctx.currentActor();
|
||||
const user = actor ? await ctx.settings.getAll("user", actor) : {};
|
||||
return {
|
||||
sessionTimeoutMinutes:
|
||||
Number.isFinite(timeout) && timeout > 0
|
||||
@@ -92,5 +114,26 @@ export async function readClientSettings(
|
||||
sessionPersistence: all[ADMIN_KEYS.sessionPersistence] !== false,
|
||||
commandHistoryEnabled: all[ADMIN_KEYS.commandHistoryEnabled] !== false,
|
||||
touchInput: normalizeTouchInputSettings(touch),
|
||||
newHostDefaults: readNewHostDefaults(all),
|
||||
user: readUserSettings(user),
|
||||
};
|
||||
}
|
||||
|
||||
/** The admin's new-host terminal defaults, typed, with the built-in ones under them. */
|
||||
export function readNewHostDefaults(
|
||||
admin: Record<string, unknown>,
|
||||
): NewHostTerminalDefaults {
|
||||
const byHostKey: Record<string, unknown> = {};
|
||||
for (const [adminKey, hostKey] of Object.entries(NEW_HOST_ADMIN_KEYS)) {
|
||||
byHostKey[hostKey] = admin[adminKey];
|
||||
}
|
||||
return {
|
||||
fontSize: DEFAULT_APPEARANCE.fontSize,
|
||||
fontFamily: DEFAULT_APPEARANCE.fontFamily,
|
||||
theme: DEFAULT_APPEARANCE.theme,
|
||||
cursorStyle: DEFAULT_APPEARANCE.cursorStyle,
|
||||
cursorBlink: DEFAULT_APPEARANCE.cursorBlink,
|
||||
autoTmux: false,
|
||||
...pickTerminalValues(byHostKey, Object.values(NEW_HOST_ADMIN_KEYS)),
|
||||
} as NewHostTerminalDefaults;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import ssh2Pkg, {
|
||||
} from "ssh2";
|
||||
import {
|
||||
PluginSshInteractionError,
|
||||
type HostSshOptions,
|
||||
type PluginContext,
|
||||
type PluginSshHost,
|
||||
type PluginSshPrepared,
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
type TerminalLogger,
|
||||
} from "./helpers.js";
|
||||
import { SSHAuthManager } from "./keyboard-prompt.js";
|
||||
import { HOST_KEYS } from "./settings.js";
|
||||
import {
|
||||
isMessageAllowedForParticipant,
|
||||
type TerminalSessionManager,
|
||||
@@ -76,11 +78,8 @@ interface ConnectToHostData {
|
||||
protocol?: "tcp" | "udp";
|
||||
delay?: number;
|
||||
}>;
|
||||
terminalConfig?: {
|
||||
keepaliveInterval?: number;
|
||||
keepaliveCountMax?: number;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/** Keepalive, legacy algorithms, agent and environment options. */
|
||||
sshOptions?: HostSshOptions | null;
|
||||
/** When true, ignore key material and force password auth (fallback path). */
|
||||
passwordFallbackOnly?: boolean;
|
||||
};
|
||||
@@ -1434,7 +1433,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: unknown;
|
||||
portKnockSequence?: ConnectToHostData["hostConfig"]["portKnockSequence"];
|
||||
terminalConfig?: ConnectToHostData["hostConfig"]["terminalConfig"];
|
||||
sshOptions?: HostSshOptions | null;
|
||||
})
|
||||
| null = null;
|
||||
|
||||
@@ -1520,8 +1519,8 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
|
||||
hostConfig.socks5ProxyChain = resolvedHostData.socks5ProxyChain;
|
||||
}
|
||||
|
||||
if (!hostConfig.terminalConfig && resolvedHostData.terminalConfig) {
|
||||
hostConfig.terminalConfig = resolvedHostData.terminalConfig;
|
||||
if (!hostConfig.sshOptions && resolvedHostData.sshOptions) {
|
||||
hostConfig.sshOptions = resolvedHostData.sshOptions;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1549,6 +1548,17 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
|
||||
|
||||
const serverHostId = resolveServerHostId(id, resolvedHostData);
|
||||
|
||||
let autoTmuxEnabled = false;
|
||||
if (serverHostId != null) {
|
||||
try {
|
||||
autoTmuxEnabled =
|
||||
(await ctx.settings.getHost(serverHostId, HOST_KEYS.autoTmux)) ===
|
||||
true;
|
||||
} catch {
|
||||
autoTmuxEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve credentials server-side when frontend doesn't provide them
|
||||
let resolvedCredentials = {
|
||||
username,
|
||||
@@ -2115,7 +2125,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
|
||||
}
|
||||
});
|
||||
|
||||
const autoTmux = hostConfig.terminalConfig?.autoTmux === true;
|
||||
const autoTmux = autoTmuxEnabled;
|
||||
|
||||
// Helper to run initialPath/executeCommand after the shell
|
||||
// (or tmux session) is ready
|
||||
@@ -2692,7 +2702,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
|
||||
keyType: resolvedCredentials.keyType,
|
||||
certPublicKey: resolvedCredentials.certPublicKey,
|
||||
forceKeyboardInteractive: hostConfig.forceKeyboardInteractive,
|
||||
terminalConfig: hostConfig.terminalConfig,
|
||||
sshOptions: hostConfig.sshOptions,
|
||||
jumpHosts: hostConfig.jumpHosts,
|
||||
useSocks5: hostConfig.useSocks5,
|
||||
socks5Host: hostConfig.socks5Host,
|
||||
@@ -2758,7 +2768,7 @@ export function createTerminalSocket(deps: TerminalSocketDeps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hostConfig.terminalConfig?.agentForwarding) {
|
||||
if (hostConfig.sshOptions?.agentForwarding) {
|
||||
if (connectConfig.privateKey) {
|
||||
try {
|
||||
const parsed = ssh2Utils.parseKey(
|
||||
|
||||
@@ -39,6 +39,7 @@ export function TerminalTabWithRegistry(props: TabProps) {
|
||||
id: tabRecord.id,
|
||||
hostId: host ? Number(host.id) || null : null,
|
||||
hostName: host?.name,
|
||||
label: props.label,
|
||||
ip: host?.ip,
|
||||
username: host?.username,
|
||||
port: host?.port,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { lazy, Suspense, type ComponentType } from "react";
|
||||
import { History, Laptop, SquareTerminal, Terminal } from "lucide-react";
|
||||
import {
|
||||
Hammer,
|
||||
History,
|
||||
Laptop,
|
||||
SquareTerminal,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { listHosts } from "@termix/plugin-sdk/frontend";
|
||||
import type {
|
||||
PanelProps,
|
||||
PluginHostRecord,
|
||||
StandaloneViewProps,
|
||||
TabProps,
|
||||
TermixApp,
|
||||
@@ -24,6 +32,25 @@ import { ImageStorageTest } from "./settings/ImageStorageTest";
|
||||
import { HostTerminalSection } from "./settings/HostTerminalSection";
|
||||
import { resetTouchInputSettingsCache } from "./terminal/touch-input-settings-store";
|
||||
import { hostSetting } from "./terminal-api";
|
||||
import { SshToolsPanel } from "./ssh-tools/SshToolsPanel";
|
||||
import { TerminalDefaultsSettings } from "./settings/TerminalDefaultsSettings";
|
||||
import {
|
||||
NewHostFontSetting,
|
||||
NewHostThemeSetting,
|
||||
} from "./settings/NewHostLookSettings";
|
||||
import { TerminalPreview } from "./look/TerminalPreview";
|
||||
import { installTerminalGlobalStyles } from "./look/terminal-global-styles";
|
||||
import {
|
||||
listTerminalThemes,
|
||||
resolveTerminalLook,
|
||||
type ResolveLookRequest,
|
||||
} from "./look/look-actions";
|
||||
import {
|
||||
invalidateTerminalClientSettings,
|
||||
loadTerminalClientSettings,
|
||||
resetTerminalClientSettings,
|
||||
} from "./terminal-settings";
|
||||
import { moveLocalTerminalPreferences } from "./settings/local-preferences-migration";
|
||||
|
||||
const LocalTerminal = lazy(() =>
|
||||
import("./local-terminal/LocalTerminal").then((m) => ({
|
||||
@@ -65,6 +92,7 @@ function LocalTerminalTab({ tab, isVisible }: TabProps) {
|
||||
* history panel and the places other plugins can plug into a session.
|
||||
*/
|
||||
export function activate(app: TermixApp): void {
|
||||
app.onDispose(installTerminalGlobalStyles());
|
||||
app.registerTab(
|
||||
"terminal",
|
||||
TerminalTabWithRegistry as unknown as ComponentType<TabProps>,
|
||||
@@ -147,6 +175,22 @@ export function activate(app: TermixApp): void {
|
||||
separatorAfter: true,
|
||||
});
|
||||
|
||||
app.registerPanel(
|
||||
"ssh-tools",
|
||||
SshToolsPanel as unknown as ComponentType<PanelProps>,
|
||||
);
|
||||
app.registerRailItem({
|
||||
id: "ssh-tools",
|
||||
icon: Hammer,
|
||||
titleKey: "nav.sshTools",
|
||||
after: "quick-connect",
|
||||
hideable: true,
|
||||
mobilePrimary: 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"] });
|
||||
@@ -199,8 +243,38 @@ export function activate(app: TermixApp): void {
|
||||
},
|
||||
})) as never);
|
||||
|
||||
// The terminal look for other terminal-like surfaces (docker, serial,
|
||||
// proxmox), which must not import this plugin. Each answers undefined
|
||||
// while this plugin is off, and the caller falls back to plain colors.
|
||||
app.registerComponent(
|
||||
"terminal.preview",
|
||||
TerminalPreview as unknown as ComponentType<Record<string, unknown>>,
|
||||
);
|
||||
app.registerAction("terminal.resolveTheme", (async (
|
||||
request: ResolveLookRequest & { hostId?: number | string } = {},
|
||||
) => {
|
||||
const settings = await loadTerminalClientSettings(app.api).catch(
|
||||
() => null,
|
||||
);
|
||||
let host: PluginHostRecord | null | undefined = request.host;
|
||||
if (!host && request.hostId !== undefined) {
|
||||
host = (await listHosts().catch(() => [])).find(
|
||||
(entry) => String(entry.id) === String(request.hostId),
|
||||
);
|
||||
}
|
||||
return resolveTerminalLook({ ...request, host }, settings?.user);
|
||||
}) as never);
|
||||
app.registerAction("terminal.themes", () => listTerminalThemes());
|
||||
|
||||
app.registerSettingsComponent("touchInput", TouchInputSettings);
|
||||
app.registerSettingsComponent("imageStorageTest", ImageStorageTest);
|
||||
app.registerSettingsComponent("terminalDefaults", TerminalDefaultsSettings);
|
||||
app.registerSettingsComponent("newHostTheme", NewHostThemeSetting);
|
||||
app.registerSettingsComponent("newHostFontFamily", NewHostFontSetting);
|
||||
|
||||
app.onSettingsChanged(() => invalidateTerminalClientSettings());
|
||||
if (!app.guest) void moveLocalTerminalPreferences(app);
|
||||
|
||||
app.onDispose(resetTouchInputSettingsCache);
|
||||
app.onDispose(resetTerminalClientSettings);
|
||||
}
|
||||
@@ -10,14 +10,16 @@ import {
|
||||
} from "../terminal/terminal-clipboard";
|
||||
import {
|
||||
useAppTheme as useTheme,
|
||||
resolveTermixThemeColors,
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
TERMINAL_FONTS,
|
||||
ensureTerminalFontsLoaded,
|
||||
RobustClipboardProvider,
|
||||
copyToClipboard,
|
||||
readFromClipboard,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { resolveTermixThemeColors } from "../look/terminal-theme";
|
||||
import {
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
TERMINAL_FONTS,
|
||||
} from "../look/terminal-themes";
|
||||
import { ensureTerminalFontsLoaded } from "../look/terminal-global-styles";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export function LocalTerminal({
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useAppTheme } from "@termix/plugin-sdk/ui";
|
||||
import { TERMINAL_THEMES, resolveTerminalFontFamily } from "./terminal-themes";
|
||||
import type { TerminalThemeColors } from "../../shared/terminal-settings";
|
||||
|
||||
/**
|
||||
* A few lines of sample output in a terminal look. Also registered as the
|
||||
* "terminal.preview" component for other plugins' pickers.
|
||||
*/
|
||||
export interface TerminalPreviewProps {
|
||||
theme: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
cursorStyle?: "block" | "underline" | "bar";
|
||||
cursorBlink?: boolean;
|
||||
letterSpacing?: number;
|
||||
lineHeight?: number;
|
||||
customThemeColors?: TerminalThemeColors | null;
|
||||
}
|
||||
|
||||
export function TerminalPreview({
|
||||
theme = "termix",
|
||||
fontSize = 14,
|
||||
fontFamily = "Caskaydia Cove Nerd Font Mono",
|
||||
cursorStyle = "bar",
|
||||
cursorBlink = true,
|
||||
letterSpacing = 0,
|
||||
lineHeight = 1.0,
|
||||
customThemeColors,
|
||||
}: TerminalPreviewProps) {
|
||||
const { theme: appTheme } = useAppTheme();
|
||||
|
||||
const resolvedTheme =
|
||||
theme === "termix"
|
||||
? appTheme === "dark" ||
|
||||
(appTheme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
? "termixDark"
|
||||
: "termixLight"
|
||||
: theme;
|
||||
|
||||
const colors =
|
||||
theme === "custom" && customThemeColors
|
||||
? customThemeColors
|
||||
: TERMINAL_THEMES[resolvedTheme]?.colors;
|
||||
const fontFallback = resolveTerminalFontFamily(fontFamily);
|
||||
|
||||
return (
|
||||
<div className="border border-input overflow-hidden">
|
||||
<div
|
||||
className="p-3"
|
||||
style={{
|
||||
fontSize: `${fontSize}px`,
|
||||
fontFamily: fontFallback,
|
||||
letterSpacing: `${letterSpacing}px`,
|
||||
lineHeight,
|
||||
background: colors?.background || "var(--bg-base)",
|
||||
color: colors?.foreground || "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span style={{ color: colors?.green }}>deploy@web-01</span>
|
||||
<span style={{ color: colors?.brightBlack }}>:</span>
|
||||
<span style={{ color: colors?.blue }}>~</span>
|
||||
<span style={{ color: colors?.brightBlack }}>$</span>
|
||||
<span> ls -la</span>
|
||||
</div>
|
||||
<div style={{ color: colors?.brightBlack }}>total 48</div>
|
||||
<div>
|
||||
<span style={{ color: colors?.cyan }}>drwxr-xr-x</span>
|
||||
<span style={{ color: colors?.brightBlack }}>
|
||||
{" "}
|
||||
5 deploy deploy 4096 May 1 09:12{" "}
|
||||
</span>
|
||||
<span style={{ color: colors?.blue }}>.</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: colors?.cyan }}>drwxr-xr-x</span>
|
||||
<span style={{ color: colors?.brightBlack }}>
|
||||
{" "}
|
||||
3 root root 4096 Apr 15 18:44{" "}
|
||||
</span>
|
||||
<span style={{ color: colors?.blue }}>..</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: colors?.cyan }}>-rw-r--r--</span>
|
||||
<span style={{ color: colors?.brightBlack }}>
|
||||
{" "}
|
||||
1 deploy deploy 220 Apr 15 18:44{" "}
|
||||
</span>
|
||||
<span>.bash_logout</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: colors?.cyan }}>-rwxr-xr-x</span>
|
||||
<span style={{ color: colors?.brightBlack }}>
|
||||
{" "}
|
||||
1 deploy deploy 8192 May 1 08:55{" "}
|
||||
</span>
|
||||
<span style={{ color: colors?.green }}>deploy.sh</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 mt-0.5">
|
||||
<span style={{ color: colors?.green }}>deploy@web-01</span>
|
||||
<span style={{ color: colors?.brightBlack }}>:</span>
|
||||
<span style={{ color: colors?.blue }}>~</span>
|
||||
<span style={{ color: colors?.brightBlack }}>$</span>
|
||||
<span> </span>
|
||||
<span
|
||||
className="inline-block"
|
||||
style={{
|
||||
width: cursorStyle === "block" ? "0.6em" : "0.12em",
|
||||
height: cursorStyle === "underline" ? "0.12em" : `${fontSize}px`,
|
||||
background: colors?.cursor || colors?.foreground || "#f7f7f7",
|
||||
animation: cursorBlink
|
||||
? "termPreviewBlink 1s step-end infinite"
|
||||
: "none",
|
||||
verticalAlign:
|
||||
cursorStyle === "underline" ? "bottom" : "text-bottom",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes termPreviewBlink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PluginHostRecord } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
resolveTerminalFontFamily,
|
||||
type TerminalTheme,
|
||||
} from "./terminal-themes";
|
||||
import { resolveTermixThemeColors } from "./terminal-theme";
|
||||
import { ensureTerminalFontsLoaded } from "./terminal-global-styles";
|
||||
import { resolveForHost } from "../terminal-settings";
|
||||
import type {
|
||||
CursorStyle,
|
||||
TerminalThemeColors,
|
||||
TerminalUserSettings,
|
||||
} from "../../shared/terminal-settings";
|
||||
|
||||
/**
|
||||
* What `terminal.resolveTheme` answers: an xterm theme and the font options
|
||||
* a terminal-like surface (a docker console, a serial console) should use to
|
||||
* look like the SSH terminal on the same host.
|
||||
*/
|
||||
export interface ResolvedTerminalLook {
|
||||
themeId: string;
|
||||
colors: TerminalThemeColors;
|
||||
/** A CSS font stack, ready for xterm's fontFamily option. */
|
||||
fontFamily: string;
|
||||
fontSize: number;
|
||||
cursorStyle: CursorStyle;
|
||||
cursorBlink: boolean;
|
||||
letterSpacing: number;
|
||||
lineHeight: number;
|
||||
scrollback: number;
|
||||
minimumContrastRatio: number;
|
||||
backgroundImage: string;
|
||||
backgroundImageOpacity: number;
|
||||
}
|
||||
|
||||
export interface ResolveLookRequest {
|
||||
/** A host record from the shell; its terminal settings decide the look. */
|
||||
host?: PluginHostRecord | null;
|
||||
/** Force a theme id instead of the host's. */
|
||||
theme?: string;
|
||||
/** The app theme ("dark", "light", "system" or a named theme). */
|
||||
appTheme?: string;
|
||||
}
|
||||
|
||||
export function resolveTerminalLook(
|
||||
request: ResolveLookRequest,
|
||||
user: TerminalUserSettings | undefined,
|
||||
): ResolvedTerminalLook {
|
||||
const config = resolveForHost(request.host ?? null, user);
|
||||
const themeId = request.theme || config.theme;
|
||||
ensureTerminalFontsLoaded(config.fontFamily);
|
||||
return {
|
||||
themeId,
|
||||
colors: resolveTermixThemeColors(
|
||||
themeId,
|
||||
request.appTheme ?? "dark",
|
||||
config.customThemeColors,
|
||||
),
|
||||
fontFamily: resolveTerminalFontFamily(config.fontFamily),
|
||||
fontSize: config.fontSize,
|
||||
cursorStyle: config.cursorStyle,
|
||||
cursorBlink: config.cursorBlink,
|
||||
letterSpacing: config.letterSpacing,
|
||||
lineHeight: config.lineHeight,
|
||||
scrollback: config.scrollback,
|
||||
minimumContrastRatio: config.minimumContrastRatio,
|
||||
backgroundImage: config.backgroundImage,
|
||||
backgroundImageOpacity: config.backgroundImageOpacity,
|
||||
};
|
||||
}
|
||||
|
||||
/** What `terminal.themes` answers: every built-in theme, for a picker. */
|
||||
export function listTerminalThemes(): Array<
|
||||
{ id: string } & Pick<TerminalTheme, "name" | "category" | "colors">
|
||||
> {
|
||||
return Object.entries(TERMINAL_THEMES)
|
||||
.filter(([id]) => id !== "termixDark" && id !== "termixLight")
|
||||
.map(([id, theme]) => ({
|
||||
id,
|
||||
name: theme.name,
|
||||
category: theme.category,
|
||||
colors: theme.colors,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export const TERMINAL_FONT_ZOOM_MIN = 8;
|
||||
export const TERMINAL_FONT_ZOOM_MAX = 36;
|
||||
|
||||
type ZoomKeyEvent = Pick<
|
||||
KeyboardEvent,
|
||||
"altKey" | "code" | "ctrlKey" | "key" | "metaKey"
|
||||
>;
|
||||
|
||||
export function getTerminalFontZoomDirection(event: ZoomKeyEvent): -1 | 0 | 1 {
|
||||
if ((!event.ctrlKey && !event.metaKey) || event.altKey) return 0;
|
||||
|
||||
if (
|
||||
event.key === "+" ||
|
||||
event.code === "Equal" ||
|
||||
event.code === "NumpadAdd"
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
if (
|
||||
event.key === "-" ||
|
||||
event.code === "Minus" ||
|
||||
event.code === "NumpadSubtract"
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getNextTerminalFontSize(
|
||||
currentFontSize: number,
|
||||
direction: -1 | 1,
|
||||
): number {
|
||||
return Math.min(
|
||||
TERMINAL_FONT_ZOOM_MAX,
|
||||
Math.max(TERMINAL_FONT_ZOOM_MIN, currentFontSize + direction),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
const TERMINAL_STYLES = `
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0,0,0,0.5);
|
||||
}
|
||||
.xterm .xterm-viewport {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0,0,0,0.3) transparent;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
.dark .xterm .xterm-viewport {
|
||||
scrollbar-color: rgba(255,255,255,0.3) transparent;
|
||||
}
|
||||
|
||||
.xterm {
|
||||
font-feature-settings: "liga" 0, "calt" 0;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen .xterm-char {
|
||||
font-feature-settings: "liga" 0, "calt" 0;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* The terminal font faces and xterm scrollbar rules, added to the page while
|
||||
* this plugin is active. Returns the disposer.
|
||||
*/
|
||||
export function installTerminalGlobalStyles(): () => void {
|
||||
if (typeof document === "undefined") return () => {};
|
||||
const style = document.createElement("style");
|
||||
style.dataset.termixTerminalStyles = "";
|
||||
style.innerHTML = TERMINAL_STYLES;
|
||||
document.head.appendChild(style);
|
||||
return () => style.remove();
|
||||
}
|
||||
|
||||
// Canvas fillText() does not reliably trigger @font-face fetches on every
|
||||
// browser engine (notably Android WebView) the way rendering real DOM text
|
||||
// does. xterm.js draws glyphs to a <canvas>, so without an explicit load the
|
||||
// terminal can keep painting the fallback font's tofu boxes even after
|
||||
// document.fonts.ready resolves. Forcing the load here ensures the glyph
|
||||
// data is actually fetched before the terminal renders with it.
|
||||
export function ensureTerminalFontsLoaded(fontFamily: string): void {
|
||||
if (typeof document === "undefined" || !document.fonts) return;
|
||||
const specs = [
|
||||
`400 16px "${fontFamily}"`,
|
||||
`700 16px "${fontFamily}"`,
|
||||
`italic 400 16px "${fontFamily}"`,
|
||||
`italic 700 16px "${fontFamily}"`,
|
||||
];
|
||||
for (const spec of specs) {
|
||||
document.fonts.load(spec).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { TERMINAL_THEMES, type TerminalTheme } from "./terminal-themes";
|
||||
import type { TerminalThemeColors } from "../../shared/terminal-settings";
|
||||
|
||||
// Background/foreground per UI theme for "Termix Default" - must match index.css
|
||||
const TERMIX_DEFAULT_COLORS: Record<
|
||||
string,
|
||||
{ background: string; foreground: string }
|
||||
> = {
|
||||
dark: { background: "#0c0d0b", foreground: "#fafafa" },
|
||||
light: { background: "#ffffff", foreground: "#111210" },
|
||||
dracula: { background: "#282a36", foreground: "#f8f8f2" },
|
||||
catppuccin: { background: "#1e1e2e", foreground: "#cdd6f4" },
|
||||
nord: { background: "#2e3440", foreground: "#eceff4" },
|
||||
solarized: { background: "#002b36", foreground: "#839496" },
|
||||
"tokyo-night": { background: "#1a1b26", foreground: "#a9b1d6" },
|
||||
"one-dark": { background: "#282c34", foreground: "#abb2bf" },
|
||||
gruvbox: { background: "#282828", foreground: "#ebdbb2" },
|
||||
};
|
||||
|
||||
export function resolveTermixThemeColors(
|
||||
activeTheme: string,
|
||||
appTheme: string,
|
||||
customThemeColors?: TerminalThemeColors | null,
|
||||
): TerminalTheme["colors"] {
|
||||
if (activeTheme === "custom") {
|
||||
if (customThemeColors) {
|
||||
return customThemeColors;
|
||||
}
|
||||
return TERMINAL_THEMES.termixDark.colors;
|
||||
}
|
||||
if (activeTheme !== "termix") {
|
||||
return (
|
||||
TERMINAL_THEMES[activeTheme]?.colors || TERMINAL_THEMES.termixDark.colors
|
||||
);
|
||||
}
|
||||
let resolvedUiTheme = appTheme;
|
||||
if (appTheme === "system") {
|
||||
resolvedUiTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
const uiColors =
|
||||
TERMIX_DEFAULT_COLORS[resolvedUiTheme] ?? TERMIX_DEFAULT_COLORS.dark;
|
||||
const base = TERMINAL_THEMES.termixDark.colors;
|
||||
return {
|
||||
...base,
|
||||
background: uiColors.background,
|
||||
foreground: uiColors.foreground,
|
||||
cursor: uiColors.foreground,
|
||||
cursorAccent: uiColors.background,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
import {
|
||||
DEFAULT_APPEARANCE,
|
||||
DEFAULT_BEHAVIOR,
|
||||
DEFAULT_MOSH_COMMAND,
|
||||
type TerminalThemeColors,
|
||||
} from "../../shared/terminal-settings";
|
||||
|
||||
export interface TerminalTheme {
|
||||
name: string;
|
||||
category: "dark" | "light" | "colorful";
|
||||
colors: TerminalThemeColors;
|
||||
}
|
||||
|
||||
export const TERMINAL_THEMES: Record<string, TerminalTheme> = {
|
||||
termix: {
|
||||
name: "Termix Default",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#0c0d0b",
|
||||
foreground: "#f7f7f7",
|
||||
cursor: "#f7f7f7",
|
||||
cursorAccent: "#0c0d0b",
|
||||
selectionBackground: "#3a3a3d",
|
||||
black: "#2e3436",
|
||||
red: "#cc0000",
|
||||
green: "#4e9a06",
|
||||
yellow: "#c4a000",
|
||||
blue: "#3465a4",
|
||||
magenta: "#75507b",
|
||||
cyan: "#06989a",
|
||||
white: "#d3d7cf",
|
||||
brightBlack: "#555753",
|
||||
brightRed: "#ef2929",
|
||||
brightGreen: "#8ae234",
|
||||
brightYellow: "#fce94f",
|
||||
brightBlue: "#729fcf",
|
||||
brightMagenta: "#ad7fa8",
|
||||
brightCyan: "#34e2e2",
|
||||
brightWhite: "#eeeeec",
|
||||
},
|
||||
},
|
||||
|
||||
termixDark: {
|
||||
name: "Termix Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#0c0d0b",
|
||||
foreground: "#f7f7f7",
|
||||
cursor: "#f7f7f7",
|
||||
cursorAccent: "#0c0d0b",
|
||||
selectionBackground: "#3a3a3d",
|
||||
black: "#2e3436",
|
||||
red: "#cc0000",
|
||||
green: "#4e9a06",
|
||||
yellow: "#c4a000",
|
||||
blue: "#3465a4",
|
||||
magenta: "#75507b",
|
||||
cyan: "#06989a",
|
||||
white: "#d3d7cf",
|
||||
brightBlack: "#555753",
|
||||
brightRed: "#ef2929",
|
||||
brightGreen: "#8ae234",
|
||||
brightYellow: "#fce94f",
|
||||
brightBlue: "#729fcf",
|
||||
brightMagenta: "#ad7fa8",
|
||||
brightCyan: "#34e2e2",
|
||||
brightWhite: "#eeeeec",
|
||||
},
|
||||
},
|
||||
|
||||
termixLight: {
|
||||
name: "Termix Light",
|
||||
category: "light",
|
||||
colors: {
|
||||
background: "#ffffff",
|
||||
foreground: "#18181b",
|
||||
cursor: "#18181b",
|
||||
cursorAccent: "#ffffff",
|
||||
selectionBackground: "#d1d5db",
|
||||
black: "#18181b",
|
||||
red: "#dc2626",
|
||||
green: "#16a34a",
|
||||
yellow: "#ca8a04",
|
||||
blue: "#2563eb",
|
||||
magenta: "#9333ea",
|
||||
cyan: "#0891b2",
|
||||
white: "#f4f4f5",
|
||||
brightBlack: "#71717a",
|
||||
brightRed: "#ef4444",
|
||||
brightGreen: "#22c55e",
|
||||
brightYellow: "#eab308",
|
||||
brightBlue: "#3b82f6",
|
||||
brightMagenta: "#a855f7",
|
||||
brightCyan: "#06b6d4",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
dracula: {
|
||||
name: "Dracula",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#282a36",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f2",
|
||||
cursorAccent: "#282a36",
|
||||
selectionBackground: "#44475a",
|
||||
black: "#21222c",
|
||||
red: "#ff5555",
|
||||
green: "#50fa7b",
|
||||
yellow: "#f1fa8c",
|
||||
blue: "#bd93f9",
|
||||
magenta: "#ff79c6",
|
||||
cyan: "#8be9fd",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#6272a4",
|
||||
brightRed: "#ff6e6e",
|
||||
brightGreen: "#69ff94",
|
||||
brightYellow: "#ffffa5",
|
||||
brightBlue: "#d6acff",
|
||||
brightMagenta: "#ff92df",
|
||||
brightCyan: "#a4ffff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
monokai: {
|
||||
name: "Monokai",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#272822",
|
||||
foreground: "#f8f8f2",
|
||||
cursor: "#f8f8f0",
|
||||
cursorAccent: "#272822",
|
||||
selectionBackground: "#49483e",
|
||||
black: "#272822",
|
||||
red: "#f92672",
|
||||
green: "#a6e22e",
|
||||
yellow: "#f4bf75",
|
||||
blue: "#66d9ef",
|
||||
magenta: "#ae81ff",
|
||||
cyan: "#a1efe4",
|
||||
white: "#f8f8f2",
|
||||
brightBlack: "#75715e",
|
||||
brightRed: "#f92672",
|
||||
brightGreen: "#a6e22e",
|
||||
brightYellow: "#f4bf75",
|
||||
brightBlue: "#66d9ef",
|
||||
brightMagenta: "#ae81ff",
|
||||
brightCyan: "#a1efe4",
|
||||
brightWhite: "#f9f8f5",
|
||||
},
|
||||
},
|
||||
|
||||
nord: {
|
||||
name: "Nord",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#2e3440",
|
||||
foreground: "#d8dee9",
|
||||
cursor: "#d8dee9",
|
||||
cursorAccent: "#2e3440",
|
||||
selectionBackground: "#434c5e",
|
||||
black: "#3b4252",
|
||||
red: "#bf616a",
|
||||
green: "#a3be8c",
|
||||
yellow: "#ebcb8b",
|
||||
blue: "#81a1c1",
|
||||
magenta: "#b48ead",
|
||||
cyan: "#88c0d0",
|
||||
white: "#e5e9f0",
|
||||
brightBlack: "#4c566a",
|
||||
brightRed: "#bf616a",
|
||||
brightGreen: "#a3be8c",
|
||||
brightYellow: "#ebcb8b",
|
||||
brightBlue: "#81a1c1",
|
||||
brightMagenta: "#b48ead",
|
||||
brightCyan: "#8fbcbb",
|
||||
brightWhite: "#eceff4",
|
||||
},
|
||||
},
|
||||
|
||||
gruvboxDark: {
|
||||
name: "Gruvbox Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#282828",
|
||||
foreground: "#ebdbb2",
|
||||
cursor: "#ebdbb2",
|
||||
cursorAccent: "#282828",
|
||||
selectionBackground: "#504945",
|
||||
black: "#282828",
|
||||
red: "#cc241d",
|
||||
green: "#98971a",
|
||||
yellow: "#d79921",
|
||||
blue: "#458588",
|
||||
magenta: "#b16286",
|
||||
cyan: "#689d6a",
|
||||
white: "#a89984",
|
||||
brightBlack: "#928374",
|
||||
brightRed: "#fb4934",
|
||||
brightGreen: "#b8bb26",
|
||||
brightYellow: "#fabd2f",
|
||||
brightBlue: "#83a598",
|
||||
brightMagenta: "#d3869b",
|
||||
brightCyan: "#8ec07c",
|
||||
brightWhite: "#ebdbb2",
|
||||
},
|
||||
},
|
||||
|
||||
gruvboxLight: {
|
||||
name: "Gruvbox Light",
|
||||
category: "light",
|
||||
colors: {
|
||||
background: "#fbf1c7",
|
||||
foreground: "#3c3836",
|
||||
cursor: "#3c3836",
|
||||
cursorAccent: "#fbf1c7",
|
||||
selectionBackground: "#d5c4a1",
|
||||
black: "#fbf1c7",
|
||||
red: "#cc241d",
|
||||
green: "#98971a",
|
||||
yellow: "#d79921",
|
||||
blue: "#458588",
|
||||
magenta: "#b16286",
|
||||
cyan: "#689d6a",
|
||||
white: "#7c6f64",
|
||||
brightBlack: "#928374",
|
||||
brightRed: "#9d0006",
|
||||
brightGreen: "#79740e",
|
||||
brightYellow: "#b57614",
|
||||
brightBlue: "#076678",
|
||||
brightMagenta: "#8f3f71",
|
||||
brightCyan: "#427b58",
|
||||
brightWhite: "#3c3836",
|
||||
},
|
||||
},
|
||||
|
||||
solarizedDark: {
|
||||
name: "Solarized Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#002b36",
|
||||
foreground: "#839496",
|
||||
cursor: "#839496",
|
||||
cursorAccent: "#002b36",
|
||||
selectionBackground: "#073642",
|
||||
black: "#073642",
|
||||
red: "#dc322f",
|
||||
green: "#859900",
|
||||
yellow: "#b58900",
|
||||
blue: "#268bd2",
|
||||
magenta: "#d33682",
|
||||
cyan: "#2aa198",
|
||||
white: "#eee8d5",
|
||||
brightBlack: "#002b36",
|
||||
brightRed: "#cb4b16",
|
||||
brightGreen: "#586e75",
|
||||
brightYellow: "#657b83",
|
||||
brightBlue: "#839496",
|
||||
brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1",
|
||||
brightWhite: "#fdf6e3",
|
||||
},
|
||||
},
|
||||
|
||||
solarizedLight: {
|
||||
name: "Solarized Light",
|
||||
category: "light",
|
||||
colors: {
|
||||
background: "#fdf6e3",
|
||||
foreground: "#657b83",
|
||||
cursor: "#657b83",
|
||||
cursorAccent: "#fdf6e3",
|
||||
selectionBackground: "#eee8d5",
|
||||
black: "#073642",
|
||||
red: "#dc322f",
|
||||
green: "#859900",
|
||||
yellow: "#b58900",
|
||||
blue: "#268bd2",
|
||||
magenta: "#d33682",
|
||||
cyan: "#2aa198",
|
||||
white: "#eee8d5",
|
||||
brightBlack: "#002b36",
|
||||
brightRed: "#cb4b16",
|
||||
brightGreen: "#586e75",
|
||||
brightYellow: "#657b83",
|
||||
brightBlue: "#839496",
|
||||
brightMagenta: "#6c71c4",
|
||||
brightCyan: "#93a1a1",
|
||||
brightWhite: "#fdf6e3",
|
||||
},
|
||||
},
|
||||
|
||||
oneDark: {
|
||||
name: "One Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#282c34",
|
||||
foreground: "#abb2bf",
|
||||
cursor: "#528bff",
|
||||
cursorAccent: "#282c34",
|
||||
selectionBackground: "#3e4451",
|
||||
black: "#282c34",
|
||||
red: "#e06c75",
|
||||
green: "#98c379",
|
||||
yellow: "#e5c07b",
|
||||
blue: "#61afef",
|
||||
magenta: "#c678dd",
|
||||
cyan: "#56b6c2",
|
||||
white: "#abb2bf",
|
||||
brightBlack: "#5c6370",
|
||||
brightRed: "#e06c75",
|
||||
brightGreen: "#98c379",
|
||||
brightYellow: "#e5c07b",
|
||||
brightBlue: "#61afef",
|
||||
brightMagenta: "#c678dd",
|
||||
brightCyan: "#56b6c2",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
tokyoNight: {
|
||||
name: "Tokyo Night",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1a1b26",
|
||||
foreground: "#a9b1d6",
|
||||
cursor: "#a9b1d6",
|
||||
cursorAccent: "#1a1b26",
|
||||
selectionBackground: "#283457",
|
||||
black: "#15161e",
|
||||
red: "#f7768e",
|
||||
green: "#9ece6a",
|
||||
yellow: "#e0af68",
|
||||
blue: "#7aa2f7",
|
||||
magenta: "#bb9af7",
|
||||
cyan: "#7dcfff",
|
||||
white: "#a9b1d6",
|
||||
brightBlack: "#414868",
|
||||
brightRed: "#f7768e",
|
||||
brightGreen: "#9ece6a",
|
||||
brightYellow: "#e0af68",
|
||||
brightBlue: "#7aa2f7",
|
||||
brightMagenta: "#bb9af7",
|
||||
brightCyan: "#7dcfff",
|
||||
brightWhite: "#c0caf5",
|
||||
},
|
||||
},
|
||||
|
||||
ayuDark: {
|
||||
name: "Ayu Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#0a0e14",
|
||||
foreground: "#b3b1ad",
|
||||
cursor: "#e6b450",
|
||||
cursorAccent: "#0a0e14",
|
||||
selectionBackground: "#253340",
|
||||
black: "#01060e",
|
||||
red: "#ea6c73",
|
||||
green: "#91b362",
|
||||
yellow: "#f9af4f",
|
||||
blue: "#53bdfa",
|
||||
magenta: "#fae994",
|
||||
cyan: "#90e1c6",
|
||||
white: "#c7c7c7",
|
||||
brightBlack: "#686868",
|
||||
brightRed: "#f07178",
|
||||
brightGreen: "#c2d94c",
|
||||
brightYellow: "#ffb454",
|
||||
brightBlue: "#59c2ff",
|
||||
brightMagenta: "#ffee99",
|
||||
brightCyan: "#95e6cb",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
ayuLight: {
|
||||
name: "Ayu Light",
|
||||
category: "light",
|
||||
colors: {
|
||||
background: "#fafafa",
|
||||
foreground: "#5c6166",
|
||||
cursor: "#ff9940",
|
||||
cursorAccent: "#fafafa",
|
||||
selectionBackground: "#d1e4f4",
|
||||
black: "#000000",
|
||||
red: "#f51818",
|
||||
green: "#86b300",
|
||||
yellow: "#f2ae49",
|
||||
blue: "#399ee6",
|
||||
magenta: "#a37acc",
|
||||
cyan: "#4cbf99",
|
||||
white: "#c7c7c7",
|
||||
brightBlack: "#686868",
|
||||
brightRed: "#ff3333",
|
||||
brightGreen: "#b8e532",
|
||||
brightYellow: "#ffc849",
|
||||
brightBlue: "#59c2ff",
|
||||
brightMagenta: "#bf7ce0",
|
||||
brightCyan: "#5cf7a0",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
materialTheme: {
|
||||
name: "Material Theme",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#263238",
|
||||
foreground: "#eeffff",
|
||||
cursor: "#ffcc00",
|
||||
cursorAccent: "#263238",
|
||||
selectionBackground: "#546e7a",
|
||||
black: "#000000",
|
||||
red: "#e53935",
|
||||
green: "#91b859",
|
||||
yellow: "#ffb62c",
|
||||
blue: "#6182b8",
|
||||
magenta: "#7c4dff",
|
||||
cyan: "#39adb5",
|
||||
white: "#ffffff",
|
||||
brightBlack: "#546e7a",
|
||||
brightRed: "#ff5370",
|
||||
brightGreen: "#c3e88d",
|
||||
brightYellow: "#ffcb6b",
|
||||
brightBlue: "#82aaff",
|
||||
brightMagenta: "#c792ea",
|
||||
brightCyan: "#89ddff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
palenight: {
|
||||
name: "Palenight",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#292d3e",
|
||||
foreground: "#a6accd",
|
||||
cursor: "#ffcc00",
|
||||
cursorAccent: "#292d3e",
|
||||
selectionBackground: "#676e95",
|
||||
black: "#292d3e",
|
||||
red: "#f07178",
|
||||
green: "#c3e88d",
|
||||
yellow: "#ffcb6b",
|
||||
blue: "#82aaff",
|
||||
magenta: "#c792ea",
|
||||
cyan: "#89ddff",
|
||||
white: "#d0d0d0",
|
||||
brightBlack: "#434758",
|
||||
brightRed: "#ff8b92",
|
||||
brightGreen: "#ddffa7",
|
||||
brightYellow: "#ffe585",
|
||||
brightBlue: "#9cc4ff",
|
||||
brightMagenta: "#e1acff",
|
||||
brightCyan: "#a3f7ff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
oceanicNext: {
|
||||
name: "Oceanic Next",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1b2b34",
|
||||
foreground: "#cdd3de",
|
||||
cursor: "#c0c5ce",
|
||||
cursorAccent: "#1b2b34",
|
||||
selectionBackground: "#343d46",
|
||||
black: "#343d46",
|
||||
red: "#ec5f67",
|
||||
green: "#99c794",
|
||||
yellow: "#fac863",
|
||||
blue: "#6699cc",
|
||||
magenta: "#c594c5",
|
||||
cyan: "#5fb3b3",
|
||||
white: "#cdd3de",
|
||||
brightBlack: "#65737e",
|
||||
brightRed: "#ec5f67",
|
||||
brightGreen: "#99c794",
|
||||
brightYellow: "#fac863",
|
||||
brightBlue: "#6699cc",
|
||||
brightMagenta: "#c594c5",
|
||||
brightCyan: "#5fb3b3",
|
||||
brightWhite: "#d8dee9",
|
||||
},
|
||||
},
|
||||
|
||||
nightOwl: {
|
||||
name: "Night Owl",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#011627",
|
||||
foreground: "#d6deeb",
|
||||
cursor: "#80a4c2",
|
||||
cursorAccent: "#011627",
|
||||
selectionBackground: "#1d3b53",
|
||||
black: "#011627",
|
||||
red: "#ef5350",
|
||||
green: "#22da6e",
|
||||
yellow: "#c5e478",
|
||||
blue: "#82aaff",
|
||||
magenta: "#c792ea",
|
||||
cyan: "#21c7a8",
|
||||
white: "#ffffff",
|
||||
brightBlack: "#575656",
|
||||
brightRed: "#ef5350",
|
||||
brightGreen: "#22da6e",
|
||||
brightYellow: "#ffeb95",
|
||||
brightBlue: "#82aaff",
|
||||
brightMagenta: "#c792ea",
|
||||
brightCyan: "#7fdbca",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
synthwave84: {
|
||||
name: "Synthwave '84",
|
||||
category: "colorful",
|
||||
colors: {
|
||||
background: "#241b2f",
|
||||
foreground: "#f92aad",
|
||||
cursor: "#f92aad",
|
||||
cursorAccent: "#241b2f",
|
||||
selectionBackground: "#495495",
|
||||
black: "#000000",
|
||||
red: "#f6188f",
|
||||
green: "#1eff8e",
|
||||
yellow: "#ffe261",
|
||||
blue: "#03edf9",
|
||||
magenta: "#f10596",
|
||||
cyan: "#03edf9",
|
||||
white: "#ffffff",
|
||||
brightBlack: "#5a5a5a",
|
||||
brightRed: "#ff1a8e",
|
||||
brightGreen: "#1eff8e",
|
||||
brightYellow: "#ffff00",
|
||||
brightBlue: "#00d8ff",
|
||||
brightMagenta: "#ff00d4",
|
||||
brightCyan: "#00ffff",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
cobalt2: {
|
||||
name: "Cobalt2",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#193549",
|
||||
foreground: "#ffffff",
|
||||
cursor: "#f0cc09",
|
||||
cursorAccent: "#193549",
|
||||
selectionBackground: "#0050a4",
|
||||
black: "#000000",
|
||||
red: "#ff0000",
|
||||
green: "#38de21",
|
||||
yellow: "#ffe50a",
|
||||
blue: "#1460d2",
|
||||
magenta: "#ff005d",
|
||||
cyan: "#00bbbb",
|
||||
white: "#bbbbbb",
|
||||
brightBlack: "#555555",
|
||||
brightRed: "#f40e17",
|
||||
brightGreen: "#3bd01d",
|
||||
brightYellow: "#edc809",
|
||||
brightBlue: "#5555ff",
|
||||
brightMagenta: "#ff55ff",
|
||||
brightCyan: "#6ae3fa",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
snazzy: {
|
||||
name: "Snazzy",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#282a36",
|
||||
foreground: "#eff0eb",
|
||||
cursor: "#97979b",
|
||||
cursorAccent: "#282a36",
|
||||
selectionBackground: "#97979b",
|
||||
black: "#282a36",
|
||||
red: "#ff5c57",
|
||||
green: "#5af78e",
|
||||
yellow: "#f3f99d",
|
||||
blue: "#57c7ff",
|
||||
magenta: "#ff6ac1",
|
||||
cyan: "#9aedfe",
|
||||
white: "#f1f1f0",
|
||||
brightBlack: "#686868",
|
||||
brightRed: "#ff5c57",
|
||||
brightGreen: "#5af78e",
|
||||
brightYellow: "#f3f99d",
|
||||
brightBlue: "#57c7ff",
|
||||
brightMagenta: "#ff6ac1",
|
||||
brightCyan: "#9aedfe",
|
||||
brightWhite: "#eff0eb",
|
||||
},
|
||||
},
|
||||
|
||||
atomOneDark: {
|
||||
name: "Atom One Dark",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1e2127",
|
||||
foreground: "#abb2bf",
|
||||
cursor: "#528bff",
|
||||
cursorAccent: "#1e2127",
|
||||
selectionBackground: "#3e4451",
|
||||
black: "#000000",
|
||||
red: "#e06c75",
|
||||
green: "#98c379",
|
||||
yellow: "#d19a66",
|
||||
blue: "#61afef",
|
||||
magenta: "#c678dd",
|
||||
cyan: "#56b6c2",
|
||||
white: "#abb2bf",
|
||||
brightBlack: "#5c6370",
|
||||
brightRed: "#e06c75",
|
||||
brightGreen: "#98c379",
|
||||
brightYellow: "#d19a66",
|
||||
brightBlue: "#61afef",
|
||||
brightMagenta: "#c678dd",
|
||||
brightCyan: "#56b6c2",
|
||||
brightWhite: "#ffffff",
|
||||
},
|
||||
},
|
||||
|
||||
catppuccinMocha: {
|
||||
name: "Catppuccin Mocha",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1e1e2e",
|
||||
foreground: "#cdd6f4",
|
||||
cursor: "#f5e0dc",
|
||||
cursorAccent: "#1e1e2e",
|
||||
selectionBackground: "#585b70",
|
||||
black: "#45475a",
|
||||
red: "#f38ba8",
|
||||
green: "#a6e3a1",
|
||||
yellow: "#f9e2af",
|
||||
blue: "#89b4fa",
|
||||
magenta: "#f5c2e7",
|
||||
cyan: "#94e2d5",
|
||||
white: "#bac2de",
|
||||
brightBlack: "#585b70",
|
||||
brightRed: "#f38ba8",
|
||||
brightGreen: "#a6e3a1",
|
||||
brightYellow: "#f9e2af",
|
||||
brightBlue: "#89b4fa",
|
||||
brightMagenta: "#f5c2e7",
|
||||
brightCyan: "#94e2d5",
|
||||
brightWhite: "#a6adc8",
|
||||
},
|
||||
},
|
||||
|
||||
gentlemansChoice: {
|
||||
name: "Gentleman's Choice",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#1a1c1a",
|
||||
foreground: "#d1c7a3",
|
||||
cursor: "#d1c7a3",
|
||||
cursorAccent: "#1a1c1a",
|
||||
selectionBackground: "#3e4437",
|
||||
black: "#1a1c1a",
|
||||
red: "#9d3a3a",
|
||||
green: "#5a7a3a",
|
||||
yellow: "#b39a3a",
|
||||
blue: "#3a5a7a",
|
||||
magenta: "#7a3a5a",
|
||||
cyan: "#3a7a7a",
|
||||
white: "#d1c7a3",
|
||||
brightBlack: "#3e4437",
|
||||
brightRed: "#bf4a4a",
|
||||
brightGreen: "#7a9a4a",
|
||||
brightYellow: "#d1b34a",
|
||||
brightBlue: "#4a7abf",
|
||||
brightMagenta: "#9a4abf",
|
||||
brightCyan: "#4abfbf",
|
||||
brightWhite: "#e3dbc3",
|
||||
},
|
||||
},
|
||||
|
||||
midnightEspresso: {
|
||||
name: "Midnight Espresso",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#120f0d",
|
||||
foreground: "#ceb195",
|
||||
cursor: "#ceb195",
|
||||
cursorAccent: "#120f0d",
|
||||
selectionBackground: "#3d2b1f",
|
||||
black: "#120f0d",
|
||||
red: "#a05a4a",
|
||||
green: "#7a8a5a",
|
||||
yellow: "#b08a4a",
|
||||
blue: "#5a7a9a",
|
||||
magenta: "#8a5a7a",
|
||||
cyan: "#5a8a8a",
|
||||
white: "#ceb195",
|
||||
brightBlack: "#3d2b1f",
|
||||
brightRed: "#c07a6a",
|
||||
brightGreen: "#9aaa7a",
|
||||
brightYellow: "#d0aa6a",
|
||||
brightBlue: "#7a9aba",
|
||||
brightMagenta: "#aa7aba",
|
||||
brightCyan: "#7ababa",
|
||||
brightWhite: "#e0cbb5",
|
||||
},
|
||||
},
|
||||
|
||||
custom: {
|
||||
name: "Custom",
|
||||
category: "dark",
|
||||
colors: {
|
||||
background: "#0c0d0b",
|
||||
foreground: "#f7f7f7",
|
||||
cursor: "#f7f7f7",
|
||||
cursorAccent: "#0c0d0b",
|
||||
selectionBackground: "#3a3a3d",
|
||||
black: "#2e3436",
|
||||
red: "#cc0000",
|
||||
green: "#4e9a06",
|
||||
yellow: "#c4a000",
|
||||
blue: "#3465a4",
|
||||
magenta: "#75507b",
|
||||
cyan: "#06989a",
|
||||
white: "#d3d7cf",
|
||||
brightBlack: "#555753",
|
||||
brightRed: "#ef2929",
|
||||
brightGreen: "#8ae234",
|
||||
brightYellow: "#fce94f",
|
||||
brightBlue: "#729fcf",
|
||||
brightMagenta: "#ad7fa8",
|
||||
brightCyan: "#34e2e2",
|
||||
brightWhite: "#eeeeec",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const TERMINAL_FONTS = [
|
||||
{
|
||||
value: "Caskaydia Cove Nerd Font Mono",
|
||||
label: "Caskaydia Cove Nerd Font Mono",
|
||||
fallback:
|
||||
'"Caskaydia Cove Nerd Font Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "JetBrains Mono",
|
||||
label: "JetBrains Mono",
|
||||
fallback:
|
||||
'"JetBrains Mono", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Fira Code",
|
||||
label: "Fira Code",
|
||||
fallback: '"Fira Code", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Cascadia Code",
|
||||
label: "Cascadia Code",
|
||||
fallback:
|
||||
'"Cascadia Code", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Source Code Pro",
|
||||
label: "Source Code Pro",
|
||||
fallback:
|
||||
'"Source Code Pro", "SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "SF Mono",
|
||||
label: "SF Mono",
|
||||
fallback: '"SF Mono", Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Consolas",
|
||||
label: "Consolas",
|
||||
fallback: 'Consolas, "Liberation Mono", monospace',
|
||||
},
|
||||
{
|
||||
value: "Monaco",
|
||||
label: "Monaco",
|
||||
fallback: 'Monaco, "Liberation Mono", monospace',
|
||||
},
|
||||
];
|
||||
|
||||
// Unknown values are treated as a custom font installed on the user's system.
|
||||
export function resolveTerminalFontFamily(fontFamily?: string): string {
|
||||
const known = TERMINAL_FONTS.find((f) => f.value === fontFamily);
|
||||
if (known) return known.fallback;
|
||||
if (fontFamily && fontFamily.trim()) {
|
||||
return `"${fontFamily.trim()}", "SF Mono", Consolas, "Liberation Mono", monospace`;
|
||||
}
|
||||
return TERMINAL_FONTS[0].fallback;
|
||||
}
|
||||
|
||||
export const CURSOR_STYLES = [
|
||||
{ value: "block", label: "Block", labelKey: "hosts.cursorStyleBlock" },
|
||||
{
|
||||
value: "underline",
|
||||
label: "Underline",
|
||||
labelKey: "hosts.cursorStyleUnderline",
|
||||
},
|
||||
{ value: "bar", label: "Bar", labelKey: "hosts.cursorStyleBar" },
|
||||
] as const;
|
||||
|
||||
export const BELL_STYLES = [
|
||||
{ value: "none", label: "None", labelKey: "hosts.bellStyleNone" },
|
||||
{ value: "sound", label: "Sound", labelKey: "hosts.bellStyleSound" },
|
||||
{ value: "visual", label: "Visual", labelKey: "hosts.bellStyleVisual" },
|
||||
{ value: "both", label: "Both", labelKey: "hosts.bellStyleBoth" },
|
||||
] as const;
|
||||
|
||||
export const FAST_SCROLL_MODIFIERS = [
|
||||
{ value: "alt", label: "Alt", labelKey: "hosts.fastScrollModifierAlt" },
|
||||
{ value: "ctrl", label: "Ctrl", labelKey: "hosts.fastScrollModifierCtrl" },
|
||||
{ value: "shift", label: "Shift", labelKey: "hosts.fastScrollModifierShift" },
|
||||
] as const;
|
||||
|
||||
/** A terminal's full configuration before anything is overridden. */
|
||||
export const DEFAULT_TERMINAL_CONFIG = {
|
||||
...DEFAULT_APPEARANCE,
|
||||
...DEFAULT_BEHAVIOR,
|
||||
moshCommand: DEFAULT_MOSH_COMMAND,
|
||||
};
|
||||
|
||||
export type TerminalConfigType = typeof DEFAULT_TERMINAL_CONFIG;
|
||||
@@ -4,6 +4,8 @@ export interface TerminalSessionInfo {
|
||||
id: string;
|
||||
hostId: number | null;
|
||||
hostName?: string;
|
||||
/** The tab's title. */
|
||||
label?: string;
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
@@ -15,6 +17,18 @@ interface Session extends TerminalSessionInfo {
|
||||
|
||||
const sessions = new Map<string, Session>();
|
||||
let activeId: string | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: TerminalSessionInfo[] = [];
|
||||
|
||||
function notify(): void {
|
||||
snapshot = listSessions();
|
||||
for (const listener of [...listeners]) listener();
|
||||
}
|
||||
|
||||
/** The open sessions, the same array until one opens or closes. */
|
||||
export function sessionsSnapshot(): TerminalSessionInfo[] {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/** Called by the terminal tab wrapper on mount/unmount and focus change. */
|
||||
export function registerSession(
|
||||
@@ -22,12 +36,25 @@ export function registerSession(
|
||||
ref: TerminalHandle,
|
||||
): () => void {
|
||||
sessions.set(info.id, { ...info, ref });
|
||||
notify();
|
||||
return () => {
|
||||
sessions.delete(info.id);
|
||||
if (activeId === info.id) activeId = null;
|
||||
notify();
|
||||
};
|
||||
}
|
||||
|
||||
/** Called whenever a session opens or closes. Returns the unsubscribe. */
|
||||
export function subscribeSessions(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** The live terminal of a session, for tools that type into several at once. */
|
||||
export function getSessionHandle(sessionId: string): TerminalHandle | null {
|
||||
return sessions.get(sessionId)?.ref ?? null;
|
||||
}
|
||||
|
||||
export function setActiveSession(id: string | null): void {
|
||||
activeId = id;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { HostEditorSectionProps } from "@termix/plugin-sdk/frontend";
|
||||
import { HostFeatureFields, HostTerminalSettings } from "@termix/plugin-sdk/ui";
|
||||
import { HostFeatureFields } from "@termix/plugin-sdk/ui";
|
||||
import { HostTerminalSettings } from "./HostTerminalSettings";
|
||||
|
||||
/** The host editor's Terminal tab: this plugin's switches, then the look. */
|
||||
export function HostTerminalSection({
|
||||
form,
|
||||
setField,
|
||||
updateForm,
|
||||
host,
|
||||
snippets,
|
||||
}: HostEditorSectionProps) {
|
||||
return (
|
||||
@@ -14,6 +16,8 @@ export function HostTerminalSection({
|
||||
<HostTerminalSettings
|
||||
form={form}
|
||||
setField={setField}
|
||||
updateForm={updateForm}
|
||||
host={host}
|
||||
snippets={snippets}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,914 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Info, Palette, X, Zap } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Button,
|
||||
FakeSwitch,
|
||||
Input,
|
||||
SectionCard,
|
||||
Select2,
|
||||
SettingRow,
|
||||
Slider,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
useTabsSafe,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useSettings,
|
||||
useTranslation,
|
||||
type HostEditorSectionProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { TerminalPreview } from "../look/TerminalPreview";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
TERMINAL_FONTS,
|
||||
BELL_STYLES,
|
||||
FAST_SCROLL_MODIFIERS,
|
||||
CURSOR_STYLES,
|
||||
} from "../look/terminal-themes";
|
||||
import {
|
||||
TERMINAL_FONT_ZOOM_MIN,
|
||||
TERMINAL_FONT_ZOOM_MAX,
|
||||
} from "../look/terminal-font-zoom";
|
||||
import {
|
||||
APPEARANCE_KEYS,
|
||||
INHERIT_APPEARANCE_KEY,
|
||||
pickTerminalValues,
|
||||
readHostTerminalSettings,
|
||||
readUserSettings,
|
||||
type BackspaceMode,
|
||||
type BellStyle,
|
||||
type CursorStyle,
|
||||
type FastScrollModifier,
|
||||
type HostTerminalSettings as HostTerminalValues,
|
||||
type SavedCustomTheme,
|
||||
} from "../../shared/terminal-settings";
|
||||
import {
|
||||
invalidateTerminalClientSettings,
|
||||
useTerminalClientSettings,
|
||||
} from "../terminal-settings";
|
||||
|
||||
const PLUGIN_ID = "ssh-terminal";
|
||||
const CUSTOM_FONT_OPTION = "__custom__";
|
||||
const APPEARANCE = new Set<string>(APPEARANCE_KEYS);
|
||||
|
||||
type HostPluginSettings = Record<string, Record<string, unknown>>;
|
||||
|
||||
/**
|
||||
* The host editor's terminal appearance and behavior cards. The values are
|
||||
* this plugin's host settings, kept on the editor form under pluginSettings
|
||||
* and saved with the host; the startup snippet is still a core host field.
|
||||
*/
|
||||
export function HostTerminalSettings({
|
||||
form: editorForm,
|
||||
setField: setCoreField,
|
||||
updateForm,
|
||||
host,
|
||||
snippets: rawSnippets,
|
||||
}: Pick<
|
||||
HostEditorSectionProps,
|
||||
"form" | "setField" | "updateForm" | "host" | "snippets"
|
||||
>) {
|
||||
const { t } = useTranslation();
|
||||
const client = useTerminalClientSettings();
|
||||
const userSettings = useSettings("user");
|
||||
const snippets = (rawSnippets ?? []) as { id: number; name: string }[];
|
||||
const { setPreviewTerminalTheme } = useTabsSafe();
|
||||
|
||||
const stored = ((editorForm?.pluginSettings as HostPluginSettings)?.[
|
||||
PLUGIN_ID
|
||||
] ?? {}) as Record<string, unknown>;
|
||||
// A new host starts from the admin's new-host defaults.
|
||||
const seed = host ? {} : (client?.newHostDefaults ?? {});
|
||||
const own = readHostTerminalSettings({ ...seed, ...stored });
|
||||
const userDefaults = pickTerminalValues(
|
||||
client?.user.terminalDefaults ?? {},
|
||||
APPEARANCE_KEYS,
|
||||
);
|
||||
// While the host follows the user, show the look it will actually get.
|
||||
const form = {
|
||||
...own,
|
||||
...(own.inheritAppearance ? userDefaults : {}),
|
||||
startupSnippetId: (editorForm?.startupSnippetId ?? null) as number | null,
|
||||
} as HostTerminalValues & { startupSnippetId: number | null };
|
||||
|
||||
const writeValues = (values: Record<string, unknown>) =>
|
||||
updateForm((current) => {
|
||||
const all = (current.pluginSettings ?? {}) as HostPluginSettings;
|
||||
return {
|
||||
...current,
|
||||
pluginSettings: {
|
||||
...all,
|
||||
[PLUGIN_ID]: { ...(all[PLUGIN_ID] ?? {}), ...values },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const setField = <K extends keyof HostTerminalValues>(
|
||||
key: K,
|
||||
value: HostTerminalValues[K],
|
||||
) =>
|
||||
writeValues({
|
||||
[key]: value,
|
||||
// Touching the look takes the host off the user's defaults.
|
||||
...(APPEARANCE.has(key) ? { [INHERIT_APPEARANCE_KEY]: false } : {}),
|
||||
});
|
||||
|
||||
const setInheritAppearance = (inherit: boolean) => {
|
||||
if (inherit) {
|
||||
writeValues({ [INHERIT_APPEARANCE_KEY]: true });
|
||||
return;
|
||||
}
|
||||
// Keep what was shown, so switching off does not change the look.
|
||||
const shown: Record<string, unknown> = {};
|
||||
for (const key of APPEARANCE_KEYS) shown[key] = form[key];
|
||||
writeValues({ ...shown, [INHERIT_APPEARANCE_KEY]: false });
|
||||
};
|
||||
|
||||
const [isCustomFont, setIsCustomFont] = useState(
|
||||
() => !TERMINAL_FONTS.some((f) => f.value === form.fontFamily),
|
||||
);
|
||||
const savedThemes: SavedCustomTheme[] = useMemo(
|
||||
() => readUserSettings(userSettings.values).customThemes,
|
||||
[userSettings.values],
|
||||
);
|
||||
const [savingTheme, setSavingTheme] = useState(false);
|
||||
|
||||
const saveThemes = async (updated: SavedCustomTheme[]) => {
|
||||
await userSettings.save({ customThemes: updated });
|
||||
invalidateTerminalClientSettings();
|
||||
};
|
||||
|
||||
const handleSaveAsGlobalTheme = async () => {
|
||||
const colors = form.customThemeColors;
|
||||
if (!colors) return;
|
||||
const name = window.prompt(t("hosts.saveGlobalThemeNamePrompt"));
|
||||
if (!name || !name.trim()) return;
|
||||
setSavingTheme(true);
|
||||
try {
|
||||
await saveThemes([
|
||||
...savedThemes,
|
||||
{
|
||||
id:
|
||||
"theme-" +
|
||||
Date.now() +
|
||||
"-" +
|
||||
Math.random().toString(36).slice(2, 8),
|
||||
name: name.trim(),
|
||||
colors,
|
||||
},
|
||||
]);
|
||||
toast.success(t("hosts.saveGlobalThemeSuccess"));
|
||||
} catch {
|
||||
toast.error(t("hosts.saveGlobalThemeError"));
|
||||
} finally {
|
||||
setSavingTheme(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteGlobalTheme = async (id: string) => {
|
||||
try {
|
||||
await saveThemes(savedThemes.filter((theme) => theme.id !== id));
|
||||
} catch {
|
||||
toast.error(t("hosts.saveGlobalThemeError"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyGlobalTheme = (id: string) => {
|
||||
const theme = savedThemes.find((entry) => entry.id === id);
|
||||
if (!theme) return;
|
||||
setField("customThemeColors", { ...theme.colors });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionCard
|
||||
title={t("hosts.terminalAppearance")}
|
||||
icon={<Palette className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<SettingRow
|
||||
label={t("hosts.useUserDefaults")}
|
||||
description={t("hosts.useUserTerminalDefaultsDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.inheritAppearance}
|
||||
onChange={setInheritAppearance}
|
||||
/>
|
||||
</SettingRow>
|
||||
<fieldset
|
||||
disabled={form.inheritAppearance}
|
||||
className={
|
||||
form.inheritAppearance ? "contents opacity-60" : "contents"
|
||||
}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.themePreview")}
|
||||
</label>
|
||||
<TerminalPreview
|
||||
theme={form.theme}
|
||||
fontSize={form.fontSize}
|
||||
fontFamily={form.fontFamily}
|
||||
cursorStyle={form.cursorStyle}
|
||||
cursorBlink={form.cursorBlink}
|
||||
letterSpacing={form.letterSpacing}
|
||||
lineHeight={form.lineHeight}
|
||||
customThemeColors={form.customThemeColors ?? undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.colorTheme")}
|
||||
</label>
|
||||
<Select2
|
||||
value={form.theme}
|
||||
onChange={(e) => {
|
||||
const newTheme = e.target.value;
|
||||
setField("theme", newTheme);
|
||||
setPreviewTerminalTheme(newTheme);
|
||||
if (newTheme === "custom" && !form.customThemeColors) {
|
||||
setField("customThemeColors", {
|
||||
...TERMINAL_THEMES.termixDark.colors,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{Object.entries(TERMINAL_THEMES)
|
||||
.filter(
|
||||
([key]) => key !== "termixDark" && key !== "termixLight",
|
||||
)
|
||||
.map(([key, theme]) => (
|
||||
<option key={key} value={key}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.fontFamilyLabel")}
|
||||
</label>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="size-3 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
{t("hosts.fontFamilyCustomHint")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<Select2
|
||||
value={isCustomFont ? CUSTOM_FONT_OPTION : form.fontFamily}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === CUSTOM_FONT_OPTION) {
|
||||
setIsCustomFont(true);
|
||||
setField("fontFamily", "");
|
||||
} else {
|
||||
setIsCustomFont(false);
|
||||
setField("fontFamily", e.target.value);
|
||||
}
|
||||
}}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
>
|
||||
{TERMINAL_FONTS.map((f) => (
|
||||
<option key={f.value} value={f.value}>
|
||||
{f.label}
|
||||
</option>
|
||||
))}
|
||||
<option value={CUSTOM_FONT_OPTION}>
|
||||
{t("hosts.fontFamilyCustomOption")}
|
||||
</option>
|
||||
</Select2>
|
||||
{isCustomFont && (
|
||||
<Input
|
||||
value={form.fontFamily}
|
||||
onChange={(e) => setField("fontFamily", e.target.value)}
|
||||
placeholder={t("hosts.fontFamilyCustomPlaceholder")}
|
||||
className="h-9 text-xs font-mono"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.fontSizeLabel")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.fontSize}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={TERMINAL_FONT_ZOOM_MIN}
|
||||
max={TERMINAL_FONT_ZOOM_MAX}
|
||||
step={1}
|
||||
value={[form.fontSize]}
|
||||
onValueChange={([v]) => setField("fontSize", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.cursorStyleLabel")}
|
||||
</label>
|
||||
<Select2
|
||||
value={form.cursorStyle}
|
||||
onChange={(e) =>
|
||||
setField("cursorStyle", e.target.value as CursorStyle)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{CURSOR_STYLES.map((s) => (
|
||||
<option key={s.value} value={s.value}>
|
||||
{t(s.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.letterSpacingPx")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.letterSpacing}px
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={-2}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={[form.letterSpacing]}
|
||||
onValueChange={([v]) => setField("letterSpacing", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.lineHeightLabel")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.lineHeight.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1.0}
|
||||
max={2.0}
|
||||
step={0.1}
|
||||
value={[form.lineHeight]}
|
||||
onValueChange={([v]) => setField("lineHeight", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.bellStyleLabel")}
|
||||
</label>
|
||||
<Select2
|
||||
value={form.bellStyle}
|
||||
onChange={(e) =>
|
||||
setField("bellStyle", e.target.value as BellStyle)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{BELL_STYLES.map((b) => (
|
||||
<option key={b.value} value={b.value}>
|
||||
{t(b.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.backspaceModeLabel")}
|
||||
</label>
|
||||
<Select2
|
||||
value={form.backspaceMode}
|
||||
onChange={(e) =>
|
||||
setField("backspaceMode", e.target.value as BackspaceMode)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="normal">
|
||||
{t("hosts.backspaceModeNormal")}
|
||||
</option>
|
||||
<option value="control-h">
|
||||
{t("hosts.backspaceModeControlH")}
|
||||
</option>
|
||||
</Select2>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
{form.theme === "custom" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.savedThemesLabel")}
|
||||
</label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px]"
|
||||
disabled={savingTheme || !form.customThemeColors}
|
||||
onClick={handleSaveAsGlobalTheme}
|
||||
>
|
||||
{t("hosts.saveAsGlobalTheme")}
|
||||
</Button>
|
||||
</div>
|
||||
{savedThemes.length === 0 ? (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.noSavedThemes")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{savedThemes.map((theme) => (
|
||||
<div
|
||||
key={theme.id}
|
||||
className="flex items-center justify-between gap-2 border border-border px-2 py-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title={t("hosts.applyGlobalThemeTooltip")}
|
||||
onClick={() => handleApplyGlobalTheme(theme.id)}
|
||||
className="flex items-center gap-2 text-xs text-left flex-1 min-w-0 hover:text-foreground transition-colors"
|
||||
>
|
||||
<span
|
||||
className="size-3.5 shrink-0 border border-border"
|
||||
style={{
|
||||
background: theme.colors.background,
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{theme.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
title={t("hosts.deleteGlobalThemeTooltip")}
|
||||
onClick={() => handleDeleteGlobalTheme(theme.id)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.customThemeColors")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
title={t("hosts.customThemeResetTooltip")}
|
||||
onClick={() =>
|
||||
setField("customThemeColors", {
|
||||
...TERMINAL_THEMES.termixDark.colors,
|
||||
})
|
||||
}
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t("hosts.customThemeResetTooltip")}
|
||||
</button>
|
||||
</div>
|
||||
{(
|
||||
[
|
||||
["background", "customThemeBackground"],
|
||||
["foreground", "customThemeForeground"],
|
||||
["cursor", "customThemeCursor"],
|
||||
["cursorAccent", "customThemeCursorAccent"],
|
||||
["selectionBackground", "customThemeSelection"],
|
||||
] as const
|
||||
).map(([key, labelKey]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<label className="text-xs text-muted-foreground min-w-0 flex-1">
|
||||
{t(`hosts.${labelKey}`)}
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={
|
||||
form.customThemeColors?.[key] ??
|
||||
TERMINAL_THEMES.termixDark.colors[key]
|
||||
}
|
||||
onChange={(e) =>
|
||||
setField("customThemeColors", {
|
||||
...(form.customThemeColors ??
|
||||
TERMINAL_THEMES.termixDark.colors),
|
||||
[key]: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-7 w-10 cursor-pointer border border-border bg-background p-0.5"
|
||||
/>
|
||||
<span className="text-[10px] font-mono text-muted-foreground w-16 tabular-nums">
|
||||
{form.customThemeColors?.[key] ??
|
||||
TERMINAL_THEMES.termixDark.colors[key]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mt-1">
|
||||
{t("hosts.customThemeAnsiColors")}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
{(
|
||||
[
|
||||
["black", "customThemeBlack"],
|
||||
["brightBlack", "customThemeBrightBlack"],
|
||||
["red", "customThemeRed"],
|
||||
["brightRed", "customThemeBrightRed"],
|
||||
["green", "customThemeGreen"],
|
||||
["brightGreen", "customThemeBrightGreen"],
|
||||
["yellow", "customThemeYellow"],
|
||||
["brightYellow", "customThemeBrightYellow"],
|
||||
["blue", "customThemeBlue"],
|
||||
["brightBlue", "customThemeBrightBlue"],
|
||||
["magenta", "customThemeMagenta"],
|
||||
["brightMagenta", "customThemeBrightMagenta"],
|
||||
["cyan", "customThemeCyan"],
|
||||
["brightCyan", "customThemeBrightCyan"],
|
||||
["white", "customThemeWhite"],
|
||||
["brightWhite", "customThemeBrightWhite"],
|
||||
] as const
|
||||
).map(([key, labelKey]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<label className="text-xs text-muted-foreground min-w-0 flex-1 truncate">
|
||||
{t(`hosts.${labelKey}`)}
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="color"
|
||||
value={
|
||||
form.customThemeColors?.[key] ??
|
||||
TERMINAL_THEMES.termixDark.colors[key]
|
||||
}
|
||||
onChange={(e) =>
|
||||
setField("customThemeColors", {
|
||||
...(form.customThemeColors ??
|
||||
TERMINAL_THEMES.termixDark.colors),
|
||||
[key]: e.target.value,
|
||||
})
|
||||
}
|
||||
className="h-7 w-10 cursor-pointer border border-border bg-background p-0.5"
|
||||
/>
|
||||
<span className="text-[10px] font-mono text-muted-foreground w-16 tabular-nums">
|
||||
{form.customThemeColors?.[key] ??
|
||||
TERMINAL_THEMES.termixDark.colors[key]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<SettingRow
|
||||
label={t("hosts.cursorBlinking")}
|
||||
description={t("hosts.cursorBlinkingDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.cursorBlink}
|
||||
onChange={(v) => setField("cursorBlink", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.rightClickSelectsWordLabel")}
|
||||
description={t("hosts.rightClickSelectsWordShortDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.rightClickSelectsWord}
|
||||
onChange={(v) => setField("rightClickSelectsWord", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.macOptionIsMetaLabel")}
|
||||
description={t("hosts.macOptionIsMetaShortDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.macOptionIsMeta}
|
||||
onChange={(v) => setField("macOptionIsMeta", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.syntaxHighlightingLabel")}
|
||||
description={t("hosts.syntaxHighlightingDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.syntaxHighlighting}
|
||||
onChange={(v) => setField("syntaxHighlighting", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.syntaxHighlighting && (
|
||||
<div className="flex flex-col ml-4">
|
||||
{(
|
||||
[
|
||||
[
|
||||
"logLevels",
|
||||
"syntaxCategoryLogLevels",
|
||||
"syntaxCategoryLogLevelsDesc",
|
||||
],
|
||||
["paths", "syntaxCategoryPaths", "syntaxCategoryPathsDesc"],
|
||||
[
|
||||
"timestamps",
|
||||
"syntaxCategoryTimestamps",
|
||||
"syntaxCategoryTimestampsDesc",
|
||||
],
|
||||
[
|
||||
"ipAddresses",
|
||||
"syntaxCategoryIpAddresses",
|
||||
"syntaxCategoryIpAddressesDesc",
|
||||
],
|
||||
["urls", "syntaxCategoryUrls", "syntaxCategoryUrlsDesc"],
|
||||
[
|
||||
"numbers",
|
||||
"syntaxCategoryNumbers",
|
||||
"syntaxCategoryNumbersDesc",
|
||||
],
|
||||
] as const
|
||||
).map(([key, labelKey, descKey]) => (
|
||||
<SettingRow
|
||||
key={key}
|
||||
label={t(`hosts.${labelKey}`)}
|
||||
description={t(`hosts.${descKey}`)}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.syntaxHighlightingOptions?.[key] ?? true}
|
||||
onChange={(v) =>
|
||||
setField("syntaxHighlightingOptions", {
|
||||
...form.syntaxHighlightingOptions,
|
||||
[key]: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.backgroundImageLabel")}
|
||||
</label>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.backgroundImageDesc")}
|
||||
</p>
|
||||
<input
|
||||
type="url"
|
||||
value={form.backgroundImage}
|
||||
onChange={(e) => setField("backgroundImage", e.target.value)}
|
||||
placeholder="https://example.com/image.jpg"
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</div>
|
||||
{form.backgroundImage && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.backgroundImageOpacityLabel")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{Math.round(form.backgroundImageOpacity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={0.05}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={[form.backgroundImageOpacity]}
|
||||
onValueChange={([v]) => setField("backgroundImageOpacity", v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard
|
||||
title={t("hosts.behaviorAndAdvanced")}
|
||||
icon={<Zap className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.scrollbackBufferLabel")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.scrollback.toLocaleString()}{" "}
|
||||
{t("hosts.scrollbackMaxLines")}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1000}
|
||||
max={100000}
|
||||
step={1000}
|
||||
value={[form.scrollback]}
|
||||
onValueChange={([v]) => setField("scrollback", v)}
|
||||
/>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("hosts.enableAutoTmux")}
|
||||
description={
|
||||
<>
|
||||
{t("hosts.enableAutoTmuxDesc")}{" "}
|
||||
<a
|
||||
href="https://docs.termix.site/features/terminal/tmux"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.autoTmux}
|
||||
onChange={(v) => setField("autoTmux", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.useSSHTitleLabel")}
|
||||
description={t("hosts.useSSHTitleDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.useSSHTitle}
|
||||
onChange={(v) => setField("useSSHTitle", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.enableAutoMosh")}
|
||||
description={t("hosts.enableAutoMoshDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.autoMosh}
|
||||
onChange={(v) => setField("autoMosh", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.passwordPromptAutoFillLabel")}
|
||||
description={t("hosts.passwordPromptAutoFillDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.passwordPromptAutoFill}
|
||||
onChange={(v) => setField("passwordPromptAutoFill", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.sudoPasswordAutoFillLabel")}
|
||||
description={t("hosts.sudoPasswordAutoFillDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.sudoPasswordAutoFill}
|
||||
onChange={(v) => setField("sudoPasswordAutoFill", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.localEchoLabel")}
|
||||
</label>
|
||||
<Select2
|
||||
value={form.localEcho}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"localEcho",
|
||||
e.target.value as "default" | "off" | "auto" | "on",
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="default">{t("hosts.localEchoDefault")}</option>
|
||||
<option value="off">{t("hosts.localEchoOff")}</option>
|
||||
<option value="auto">{t("hosts.localEchoAuto")}</option>
|
||||
<option value="on">{t("hosts.localEchoOn")}</option>
|
||||
</Select2>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.localEchoDesc")}{" "}
|
||||
<a
|
||||
href="https://docs.termix.site/features/terminal/appearance"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.linkClickBehaviorLabel")}
|
||||
</label>
|
||||
<Select2
|
||||
value={form.linkClickBehavior}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"linkClickBehavior",
|
||||
e.target.value as "default" | "confirm" | "direct",
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="default">
|
||||
{t("hosts.linkClickBehaviorDefault")}
|
||||
</option>
|
||||
<option value="confirm">
|
||||
{t("hosts.linkClickBehaviorConfirm")}
|
||||
</option>
|
||||
<option value="direct">
|
||||
{t("hosts.linkClickBehaviorDirect")}
|
||||
</option>
|
||||
</Select2>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.linkClickBehaviorDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.fastScrollModifierLabel")}
|
||||
</label>
|
||||
<Select2
|
||||
value={form.fastScrollModifier}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"fastScrollModifier",
|
||||
e.target.value as FastScrollModifier,
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{FAST_SCROLL_MODIFIERS.map((m) => (
|
||||
<option key={m.value} value={m.value}>
|
||||
{t(m.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.fastScrollSensitivityLabel")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground tabular-nums">
|
||||
{form.fastScrollSensitivity}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
value={[form.fastScrollSensitivity]}
|
||||
onValueChange={([v]) => setField("fastScrollSensitivity", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{form.autoMosh && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.moshCommandLabel")}
|
||||
</label>
|
||||
<Input
|
||||
placeholder="mosh"
|
||||
value={form.moshCommand}
|
||||
onChange={(e) => setField("moshCommand", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.startupSnippetLabel")}
|
||||
</label>
|
||||
<Select2
|
||||
value={form.startupSnippetId ?? ""}
|
||||
onChange={(e) =>
|
||||
setCoreField(
|
||||
"startupSnippetId",
|
||||
e.target.value ? Number(e.target.value) : null,
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("hosts.none")}</option>
|
||||
{snippets.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { SettingRow, Select2 } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useTranslation,
|
||||
type SettingsComponentProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { TERMINAL_FONTS, TERMINAL_THEMES } from "../look/terminal-themes";
|
||||
import { DEFAULT_APPEARANCE } from "../../shared/terminal-settings";
|
||||
|
||||
const selectClass =
|
||||
"flex h-8 w-48 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring";
|
||||
|
||||
/** Admin setting `newHostTheme`: the theme a new host starts with. */
|
||||
export function NewHostThemeSetting({
|
||||
values,
|
||||
setValue,
|
||||
running,
|
||||
}: SettingsComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const value =
|
||||
typeof values.newHostTheme === "string"
|
||||
? values.newHostTheme
|
||||
: DEFAULT_APPEARANCE.theme;
|
||||
return (
|
||||
<SettingRow
|
||||
label={t("settings.admin.newHostTheme.label")}
|
||||
description={t("settings.admin.newHostTheme.description")}
|
||||
>
|
||||
<Select2
|
||||
value={value}
|
||||
disabled={!running}
|
||||
onChange={(e) => setValue("newHostTheme", e.target.value)}
|
||||
className={selectClass}
|
||||
>
|
||||
{Object.entries(TERMINAL_THEMES)
|
||||
.filter(([key]) => key !== "termixDark" && key !== "termixLight")
|
||||
.map(([key, theme]) => (
|
||||
<option key={key} value={key}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
|
||||
/** Admin setting `newHostFontFamily`: the font a new host starts with. */
|
||||
export function NewHostFontSetting({
|
||||
values,
|
||||
setValue,
|
||||
running,
|
||||
}: SettingsComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const value =
|
||||
typeof values.newHostFontFamily === "string"
|
||||
? values.newHostFontFamily
|
||||
: DEFAULT_APPEARANCE.fontFamily;
|
||||
const known = TERMINAL_FONTS.some((font) => font.value === value);
|
||||
return (
|
||||
<SettingRow
|
||||
label={t("settings.admin.newHostFontFamily.label")}
|
||||
description={t("settings.admin.newHostFontFamily.description")}
|
||||
>
|
||||
<Select2
|
||||
value={value}
|
||||
disabled={!running}
|
||||
onChange={(e) => setValue("newHostFontFamily", e.target.value)}
|
||||
className={`${selectClass} font-mono`}
|
||||
>
|
||||
{!known && <option value={value}>{value}</option>}
|
||||
{TERMINAL_FONTS.map((font) => (
|
||||
<option key={font.value} value={font.value}>
|
||||
{font.label}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</SettingRow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type React from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
SettingRow,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useTranslation,
|
||||
type SettingsComponentProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
CURSOR_STYLES,
|
||||
TERMINAL_FONTS,
|
||||
TERMINAL_THEMES,
|
||||
} from "../look/terminal-themes";
|
||||
import { fromTriState, toTriState, type TriState } from "./tri-state";
|
||||
import {
|
||||
readUserSettings,
|
||||
type TerminalDefaults,
|
||||
} from "../../shared/terminal-settings";
|
||||
|
||||
const inputClass =
|
||||
"h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring";
|
||||
const labelClass =
|
||||
"text-[10px] font-bold uppercase tracking-widest text-muted-foreground";
|
||||
|
||||
function Field({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className={labelClass}>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TriStateSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value?: boolean;
|
||||
onChange: (value: boolean | undefined) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<select
|
||||
className="h-7 w-28 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
value={toTriState(value)}
|
||||
onChange={(e) => onChange(fromTriState(e.target.value as TriState))}
|
||||
>
|
||||
<option value="inherit">{t("terminalDefaults.inherit")}</option>
|
||||
<option value="on">{t("terminalDefaults.on")}</option>
|
||||
<option value="off">{t("terminalDefaults.off")}</option>
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's terminal defaults (the `terminalDefaults` user setting): the
|
||||
* look every host that follows the user starts from. Applied to the form,
|
||||
* saved with the rest of the section.
|
||||
*/
|
||||
export function TerminalDefaultsSettings({
|
||||
values,
|
||||
setValue,
|
||||
running,
|
||||
}: SettingsComponentProps) {
|
||||
const { t } = useTranslation();
|
||||
const saved = useMemo(() => readUserSettings(values), [values]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [terminal, setTerminal] = useState<TerminalDefaults>({});
|
||||
|
||||
const configuredCount = Object.values(saved.terminalDefaults).filter(
|
||||
(value) => value !== undefined,
|
||||
).length;
|
||||
|
||||
const openDialog = () => {
|
||||
setTerminal(saved.terminalDefaults);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const updateTerminal = <K extends keyof TerminalDefaults>(
|
||||
key: K,
|
||||
value: TerminalDefaults[K],
|
||||
) => setTerminal((current) => ({ ...current, [key]: value }));
|
||||
|
||||
const applySavedTheme = (id: string) => {
|
||||
const theme = saved.customThemes.find((entry) => entry.id === id);
|
||||
if (!theme) return;
|
||||
setTerminal((current) => ({
|
||||
...current,
|
||||
theme: "custom",
|
||||
customThemeColors: { ...theme.colors },
|
||||
}));
|
||||
};
|
||||
|
||||
const apply = () => {
|
||||
const cleaned = Object.fromEntries(
|
||||
Object.entries(terminal).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
setValue("terminalDefaults", cleaned);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const inheritLabel = t("terminalDefaults.inherit");
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow
|
||||
label={t("terminalDefaults.title")}
|
||||
description={t("terminalDefaults.description")}
|
||||
badge={configuredCount > 0 ? String(configuredCount) : undefined}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!running}
|
||||
onClick={openDialog}
|
||||
>
|
||||
{t("terminalDefaults.manage")}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base font-bold">
|
||||
{t("terminalDefaults.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs text-muted-foreground">
|
||||
{t("terminalDefaults.dialogDescription")}{" "}
|
||||
<a
|
||||
href="https://docs.termix.site/features/files-and-hosts/connection-defaults"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="max-h-[55vh] overflow-y-auto pr-1">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Field label={t("hosts.fontFamilyLabel")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={terminal.fontFamily ?? ""}
|
||||
onChange={(e) =>
|
||||
updateTerminal("fontFamily", e.target.value || undefined)
|
||||
}
|
||||
>
|
||||
<option value="">{inheritLabel}</option>
|
||||
{TERMINAL_FONTS.map((font) => (
|
||||
<option key={font.value} value={font.value}>
|
||||
{font.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label={t("hosts.colorTheme")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={terminal.theme ?? ""}
|
||||
onChange={(e) =>
|
||||
updateTerminal("theme", e.target.value || undefined)
|
||||
}
|
||||
>
|
||||
<option value="">{inheritLabel}</option>
|
||||
{Object.entries(TERMINAL_THEMES)
|
||||
.filter(
|
||||
([key]) => key !== "termixDark" && key !== "termixLight",
|
||||
)
|
||||
.map(([key, theme]) => (
|
||||
<option key={key} value={key}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
{saved.customThemes.length > 0 && (
|
||||
<Field label={t("terminalDefaults.savedThemeLabel")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value=""
|
||||
onChange={(e) => applySavedTheme(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{t("terminalDefaults.savedThemePlaceholder")}
|
||||
</option>
|
||||
{saved.customThemes.map((theme) => (
|
||||
<option key={theme.id} value={theme.id}>
|
||||
{theme.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<Field label={t("hosts.fontSizeLabel")}>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="number"
|
||||
min={8}
|
||||
max={32}
|
||||
value={terminal.fontSize ?? ""}
|
||||
placeholder={inheritLabel}
|
||||
onChange={(e) =>
|
||||
updateTerminal(
|
||||
"fontSize",
|
||||
e.target.value ? Number(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t("hosts.lineHeightLabel")}>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="number"
|
||||
min={0.8}
|
||||
max={2}
|
||||
step={0.05}
|
||||
value={terminal.lineHeight ?? ""}
|
||||
placeholder={inheritLabel}
|
||||
onChange={(e) =>
|
||||
updateTerminal(
|
||||
"lineHeight",
|
||||
e.target.value ? Number(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t("hosts.cursorStyleLabel")}>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={terminal.cursorStyle ?? ""}
|
||||
onChange={(e) =>
|
||||
updateTerminal(
|
||||
"cursorStyle",
|
||||
(e.target.value || undefined) as
|
||||
TerminalDefaults["cursorStyle"] | undefined,
|
||||
)
|
||||
}
|
||||
>
|
||||
<option value="">{inheritLabel}</option>
|
||||
{CURSOR_STYLES.map((style) => (
|
||||
<option key={style.value} value={style.value}>
|
||||
{t(style.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label={t("hosts.scrollbackBufferLabel")}>
|
||||
<input
|
||||
className={inputClass}
|
||||
type="number"
|
||||
min={1000}
|
||||
max={100000}
|
||||
step={1000}
|
||||
value={terminal.scrollback ?? ""}
|
||||
placeholder={inheritLabel}
|
||||
onChange={(e) =>
|
||||
updateTerminal(
|
||||
"scrollback",
|
||||
e.target.value ? Number(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<SettingRow
|
||||
label={t("hosts.cursorBlinking")}
|
||||
description={t("hosts.cursorBlinkingDesc")}
|
||||
>
|
||||
<TriStateSelect
|
||||
value={terminal.cursorBlink}
|
||||
onChange={(value) => updateTerminal("cursorBlink", value)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2 pt-3 border-t border-border">
|
||||
<Button variant="ghost" size="sm" onClick={() => setTerminal({})}>
|
||||
{t("terminalDefaults.clearAll")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button size="sm" onClick={apply}>
|
||||
{t("terminalDefaults.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
invalidateTerminalClientSettings,
|
||||
loadTerminalClientSettings,
|
||||
} from "../terminal-settings";
|
||||
import { DEFAULT_USER_SETTINGS } from "../../shared/terminal-settings";
|
||||
|
||||
/**
|
||||
* Browser-only terminal preferences from before 2.9.0, and the user setting
|
||||
* each became. Read once: the key is removed after it is saved.
|
||||
*/
|
||||
const LOCAL_KEYS: Array<{
|
||||
storageKey: string;
|
||||
setting: string;
|
||||
parse: (raw: string) => unknown;
|
||||
}> = [
|
||||
{
|
||||
storageKey: "terminalLocalEchoMode",
|
||||
setting: "localEcho",
|
||||
parse: (raw) => (["off", "auto", "on"].includes(raw) ? raw : undefined),
|
||||
},
|
||||
{
|
||||
storageKey: "terminalLinkClickBehavior",
|
||||
setting: "linkClickBehavior",
|
||||
parse: (raw) => (["confirm", "direct"].includes(raw) ? raw : undefined),
|
||||
},
|
||||
{
|
||||
storageKey: "commandAutocomplete",
|
||||
setting: "commandAutocomplete",
|
||||
parse: (raw) =>
|
||||
raw === "true" ? true : raw === "false" ? false : undefined,
|
||||
},
|
||||
];
|
||||
|
||||
function readStorage(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the old localStorage values into this plugin's user settings, the
|
||||
* first time this browser runs the plugin. A value the user already changed
|
||||
* on the server wins: the browser's value only replaces a default.
|
||||
*/
|
||||
export async function moveLocalTerminalPreferences(
|
||||
app: Pick<TermixApp, "api">,
|
||||
): Promise<void> {
|
||||
const found = LOCAL_KEYS.map((entry) => ({
|
||||
...entry,
|
||||
raw: readStorage(entry.storageKey),
|
||||
})).filter((entry) => entry.raw !== null);
|
||||
if (found.length === 0) return;
|
||||
|
||||
try {
|
||||
const { user } = await loadTerminalClientSettings(app.api);
|
||||
const current = user as unknown as Record<string, unknown>;
|
||||
const defaults = DEFAULT_USER_SETTINGS as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const entry of found) {
|
||||
const value = entry.parse(entry.raw as string);
|
||||
if (
|
||||
value !== undefined &&
|
||||
value !== current[entry.setting] &&
|
||||
current[entry.setting] === defaults[entry.setting]
|
||||
) {
|
||||
values[entry.setting] = value;
|
||||
}
|
||||
}
|
||||
if (Object.keys(values).length > 0) {
|
||||
await app.api.put("/user-settings", values);
|
||||
invalidateTerminalClientSettings();
|
||||
}
|
||||
for (const entry of found) {
|
||||
try {
|
||||
localStorage.removeItem(entry.storageKey);
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Try again next time the plugin starts.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Booleans have three states in the defaults UI: unset means the host decides.
|
||||
export type TriState = "inherit" | "on" | "off";
|
||||
|
||||
export function toTriState(value?: boolean): TriState {
|
||||
if (value === undefined) return "inherit";
|
||||
return value ? "on" : "off";
|
||||
}
|
||||
|
||||
export function fromTriState(value: TriState): boolean | undefined {
|
||||
if (value === "inherit") return undefined;
|
||||
return value === "on";
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import { useState, useRef, useEffect, useSyncExternalStore } from "react";
|
||||
import { KeyRound, Terminal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button, Separator } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
getClientPreference,
|
||||
getHostPassword,
|
||||
setClientPreference,
|
||||
useTranslation,
|
||||
type PanelProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
getSessionHandle,
|
||||
sessionsSnapshot,
|
||||
subscribeSessions,
|
||||
} from "../session-registry";
|
||||
|
||||
/**
|
||||
* SSH Tools: type into several open terminals at once, fill their saved
|
||||
* passwords, and the terminal's clipboard preferences.
|
||||
*/
|
||||
export function SshToolsPanel({ targetTab }: PanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const terminalTabs = useSyncExternalStore(
|
||||
subscribeSessions,
|
||||
sessionsSnapshot,
|
||||
);
|
||||
const activeTabId = targetTab?.id ?? "";
|
||||
const [keyRecording, setKeyRecording] = useState(false);
|
||||
const [rightClickPaste, setRightClickPaste] = useState(
|
||||
() => getClientPreference("rightClickCopyPaste") !== "false",
|
||||
);
|
||||
const [copyOnSelect, setCopyOnSelect] = useState(
|
||||
() => getClientPreference("copyOnSelect") === "true",
|
||||
);
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
|
||||
() =>
|
||||
new Set(
|
||||
activeTabId && terminalTabs.some((tab) => tab.id === activeTabId)
|
||||
? [activeTabId]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (keyRecording) inputRef.current?.focus();
|
||||
}, [keyRecording]);
|
||||
|
||||
function toggleTab(id: string) {
|
||||
setSelectedTabIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
setSelectedTabIds(new Set(terminalTabs.map((tab) => tab.id)));
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
setSelectedTabIds(new Set());
|
||||
}
|
||||
|
||||
function broadcast(data: string) {
|
||||
for (const tabId of selectedTabIds) {
|
||||
getSessionHandle(tabId)?.sendInput(data);
|
||||
}
|
||||
}
|
||||
|
||||
async function fillPassword() {
|
||||
let filled = 0;
|
||||
let missing = 0;
|
||||
|
||||
for (const tabId of selectedTabIds) {
|
||||
const tab = terminalTabs.find((session) => session.id === tabId);
|
||||
const hostId = tab?.hostId ?? null;
|
||||
const ref = getSessionHandle(tabId);
|
||||
if (!hostId || !ref) {
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const password = await getHostPassword(hostId, "password");
|
||||
if (!password) {
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
ref.sendInput(password + "\r");
|
||||
filled++;
|
||||
}
|
||||
|
||||
if (filled > 0) {
|
||||
toast.success(t("sshTools.fillPasswordSuccess", { count: filled }));
|
||||
}
|
||||
if (missing > 0) {
|
||||
toast.error(t("sshTools.fillPasswordMissing", { count: missing }));
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastArrow(normalSeq: string, appSeq: string) {
|
||||
for (const tabId of selectedTabIds) {
|
||||
const ref = getSessionHandle(tabId);
|
||||
if (!ref) continue;
|
||||
const appMode = ref.getApplicationCursorKeysMode?.() ?? false;
|
||||
ref.sendInput(appMode ? appSeq : normalSeq);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const text = e.clipboardData.getData("text");
|
||||
if (text) broadcast(text);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
const ctrl = e.ctrlKey;
|
||||
const { key } = e;
|
||||
|
||||
// Let Ctrl/Cmd+V fall through so the browser fires a paste event
|
||||
// instead of being swallowed by the preventDefault() below.
|
||||
if ((ctrl || e.metaKey) && key.toLowerCase() === "v") {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (ctrl) {
|
||||
const ctrlMap: Record<string, string> = {
|
||||
c: "\x03",
|
||||
d: "\x04",
|
||||
l: "\x0C",
|
||||
u: "\x15",
|
||||
k: "\x0B",
|
||||
a: "\x01",
|
||||
e: "\x05",
|
||||
w: "\x17",
|
||||
z: "\x1A",
|
||||
r: "\x12",
|
||||
};
|
||||
const seq = ctrlMap[key.toLowerCase()];
|
||||
if (seq) {
|
||||
broadcast(seq);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (key === "ArrowUp") {
|
||||
broadcastArrow("\x1B[A", "\x1BOA");
|
||||
return;
|
||||
}
|
||||
if (key === "ArrowDown") {
|
||||
broadcastArrow("\x1B[B", "\x1BOB");
|
||||
return;
|
||||
}
|
||||
if (key === "ArrowRight") {
|
||||
broadcastArrow("\x1B[C", "\x1BOC");
|
||||
return;
|
||||
}
|
||||
if (key === "ArrowLeft") {
|
||||
broadcastArrow("\x1B[D", "\x1BOD");
|
||||
return;
|
||||
}
|
||||
|
||||
const specialMap: Record<string, string> = {
|
||||
Enter: "\r",
|
||||
Backspace: "\x7F",
|
||||
Delete: "\x1B[3~",
|
||||
Tab: "\t",
|
||||
Escape: "\x1B",
|
||||
Home: "\x1B[H",
|
||||
End: "\x1B[F",
|
||||
PageUp: "\x1B[5~",
|
||||
PageDown: "\x1B[6~",
|
||||
Insert: "\x1B[2~",
|
||||
F1: "\x1BOP",
|
||||
F2: "\x1BOQ",
|
||||
F3: "\x1BOR",
|
||||
F4: "\x1BOS",
|
||||
F5: "\x1B[15~",
|
||||
F6: "\x1B[17~",
|
||||
F7: "\x1B[18~",
|
||||
F8: "\x1B[19~",
|
||||
F9: "\x1B[20~",
|
||||
F10: "\x1B[21~",
|
||||
F11: "\x1B[23~",
|
||||
F12: "\x1B[24~",
|
||||
};
|
||||
|
||||
const seq = specialMap[key];
|
||||
if (seq) {
|
||||
broadcast(seq);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctrl && !e.altKey && !e.metaKey && key.length === 1) {
|
||||
broadcast(key);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRecording() {
|
||||
const next = !keyRecording;
|
||||
if (!next) {
|
||||
// clear the phantom text when stopping
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
setKeyRecording(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("sshTools.keyRecordingTitle")}
|
||||
</span>
|
||||
|
||||
{/* Terminal selector */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("sshTools.recordToTerminals")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="text-[10px] text-accent-brand hover:text-accent-brand/70"
|
||||
>
|
||||
{t("sshTools.selectAll")}
|
||||
</button>
|
||||
<button
|
||||
onClick={deselectAll}
|
||||
className="text-[10px] text-accent-brand hover:text-accent-brand/70"
|
||||
>
|
||||
{t("sshTools.selectNone")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{terminalTabs.length === 0 ? (
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-2 border border-dashed border-border/60 text-muted-foreground/40">
|
||||
<Terminal className="size-3 shrink-0" />
|
||||
<span className="text-xs">
|
||||
{t("sshTools.noTerminalTabsOpen")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{terminalTabs.map((tab) => {
|
||||
const selected = selectedTabIds.has(tab.id);
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => toggleTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-2.5 py-1.5 border text-left transition-colors ${
|
||||
selected
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground hover:border-muted-foreground/40"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`size-3 border-2 flex items-center justify-center shrink-0 transition-colors ${
|
||||
selected
|
||||
? "border-accent-brand bg-accent-brand"
|
||||
: "border-border/60"
|
||||
}`}
|
||||
>
|
||||
{selected && <div className="size-1.5 bg-background" />}
|
||||
</div>
|
||||
<Terminal className="size-3 shrink-0 opacity-60" />
|
||||
<span className="text-xs font-medium truncate flex-1">
|
||||
{tab.label || tab.hostName || tab.ip}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={selectedTabIds.size === 0}
|
||||
className={`w-full ${keyRecording ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10 hover:bg-accent-brand/20 hover:text-accent-brand" : ""}`}
|
||||
onClick={toggleRecording}
|
||||
>
|
||||
{keyRecording
|
||||
? `${t("sshTools.stopRecording")} (${selectedTabIds.size})`
|
||||
: selectedTabIds.size === 0
|
||||
? t("sshTools.selectTerminalsAbove")
|
||||
: `${t("sshTools.startRecording")} (${selectedTabIds.size})`}
|
||||
</Button>
|
||||
|
||||
{keyRecording && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
onChange={(e) => {
|
||||
// Keystrokes are broadcast directly via handleKeyDown; the
|
||||
// field itself must stay empty. This also catches paste
|
||||
// insertion on browsers that fire "input" before we can
|
||||
// intercept it in onPaste.
|
||||
e.target.value = "";
|
||||
}}
|
||||
placeholder={t("sshTools.broadcastInputPlaceholder")}
|
||||
className="w-full px-2.5 py-2 text-xs bg-background border border-accent-brand/40 text-foreground placeholder:text-muted-foreground/40 outline-none focus:border-accent-brand/70 caret-transparent"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={selectedTabIds.size === 0}
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
void fillPassword();
|
||||
}}
|
||||
>
|
||||
<KeyRound className="size-3.5 mr-2" />
|
||||
{selectedTabIds.size === 0
|
||||
? t("sshTools.selectTerminalsAbove")
|
||||
: `${t("sshTools.fillPassword")} (${selectedTabIds.size})`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-bold uppercase tracking-widest">
|
||||
{t("sshTools.settingsTitle")}
|
||||
</span>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("sshTools.enableRightClickCopyPaste")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = !rightClickPaste;
|
||||
setRightClickPaste(next);
|
||||
setClientPreference(
|
||||
"rightClickCopyPaste",
|
||||
next ? "true" : "false",
|
||||
);
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${
|
||||
rightClickPaste
|
||||
? "bg-accent-brand border-accent-brand"
|
||||
: "bg-muted border-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${rightClickPaste ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("sshTools.copyOnSelect")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = !copyOnSelect;
|
||||
setCopyOnSelect(next);
|
||||
setClientPreference("copyOnSelect", next ? "true" : "false");
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${
|
||||
copyOnSelect
|
||||
? "bg-accent-brand border-accent-brand"
|
||||
: "bg-muted border-border"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${copyOnSelect ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,11 @@ import {
|
||||
normalizeTouchInputSettings,
|
||||
type TouchInputSettings,
|
||||
} from "../shared/touch-input-settings";
|
||||
import {
|
||||
readUserSettings,
|
||||
type NewHostTerminalDefaults,
|
||||
type TerminalUserSettings,
|
||||
} from "../shared/terminal-settings";
|
||||
|
||||
/** Calls to this plugin's own routes under /plugin-api/ssh-terminal/. */
|
||||
|
||||
@@ -11,13 +16,27 @@ export interface TerminalClientSettings {
|
||||
sessionPersistence: boolean;
|
||||
commandHistoryEnabled: boolean;
|
||||
touchInput: TouchInputSettings;
|
||||
newHostDefaults?: NewHostTerminalDefaults;
|
||||
user: TerminalUserSettings;
|
||||
}
|
||||
|
||||
export async function getClientSettings(
|
||||
api: PluginApiClient,
|
||||
): Promise<TerminalClientSettings> {
|
||||
const { data } = await api.get<TerminalClientSettings>("/client-settings");
|
||||
return { ...data, touchInput: normalizeTouchInputSettings(data?.touchInput) };
|
||||
return {
|
||||
...data,
|
||||
touchInput: normalizeTouchInputSettings(data?.touchInput),
|
||||
user: readUserSettings(data?.user as unknown as Record<string, unknown>),
|
||||
};
|
||||
}
|
||||
|
||||
/** Turns auto tmux on for a host (its terminal host setting). */
|
||||
export async function enableHostAutoTmux(
|
||||
api: PluginApiClient,
|
||||
hostId: number,
|
||||
): Promise<void> {
|
||||
await api.put(`/hosts/${hostId}/auto-tmux`, { enabled: true });
|
||||
}
|
||||
|
||||
export async function saveCommandToHistory(
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
usePluginApi,
|
||||
type PluginApiClient,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { getClientSettings, type TerminalClientSettings } from "./terminal-api";
|
||||
import {
|
||||
DEFAULT_MOSH_COMMAND,
|
||||
DEFAULT_USER_SETTINGS,
|
||||
readHostTerminalSettings,
|
||||
resolveTerminalSettings,
|
||||
type TerminalAppearance,
|
||||
type TerminalBehavior,
|
||||
type TerminalUserSettings,
|
||||
} from "../shared/terminal-settings";
|
||||
|
||||
/**
|
||||
* The signed-in user's terminal settings, fetched once and shared by every
|
||||
* terminal. Invalidated when one of this plugin's settings is saved.
|
||||
*/
|
||||
|
||||
let cached: TerminalClientSettings | null = null;
|
||||
let pending: Promise<TerminalClientSettings> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function loadTerminalClientSettings(
|
||||
api: PluginApiClient,
|
||||
): Promise<TerminalClientSettings> {
|
||||
if (cached) return Promise.resolve(cached);
|
||||
if (!pending) {
|
||||
const request = getClientSettings(api)
|
||||
.then((settings) => {
|
||||
if (pending === request) cached = settings;
|
||||
return settings;
|
||||
})
|
||||
.finally(() => {
|
||||
if (pending === request) pending = null;
|
||||
});
|
||||
pending = request;
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function peekTerminalClientSettings(): TerminalClientSettings | null {
|
||||
return cached;
|
||||
}
|
||||
|
||||
/** Drops the cache and has every mounted terminal read the settings again. */
|
||||
export function invalidateTerminalClientSettings(): void {
|
||||
cached = null;
|
||||
pending = null;
|
||||
for (const listener of [...listeners]) listener();
|
||||
}
|
||||
|
||||
export function resetTerminalClientSettings(): void {
|
||||
cached = null;
|
||||
pending = null;
|
||||
listeners.clear();
|
||||
}
|
||||
|
||||
export function useTerminalClientSettings(): TerminalClientSettings | null {
|
||||
const api = usePluginApi();
|
||||
const apiRef = useRef(api);
|
||||
apiRef.current = api;
|
||||
const [value, setValue] = useState<TerminalClientSettings | null>(cached);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const load = () => {
|
||||
loadTerminalClientSettings(apiRef.current)
|
||||
.then((settings) => {
|
||||
if (active) setValue(settings);
|
||||
})
|
||||
.catch(() => {
|
||||
// A guest on a share link has no account to read settings for.
|
||||
});
|
||||
};
|
||||
load();
|
||||
listeners.add(load);
|
||||
return () => {
|
||||
active = false;
|
||||
listeners.delete(load);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export type ResolvedTerminalConfig = TerminalAppearance & TerminalBehavior;
|
||||
|
||||
export interface TerminalSettingsForHost {
|
||||
config: ResolvedTerminalConfig;
|
||||
user: TerminalUserSettings;
|
||||
}
|
||||
|
||||
/** This plugin's host values off a host record, as the host payload carries them. */
|
||||
export function hostTerminalValues(
|
||||
host: object | null | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
const settings = (
|
||||
host as { pluginSettings?: Record<string, Record<string, unknown>> } | null
|
||||
)?.pluginSettings;
|
||||
return settings?.["ssh-terminal"];
|
||||
}
|
||||
|
||||
export function resolveForHost(
|
||||
host: object | null | undefined,
|
||||
user: TerminalUserSettings = DEFAULT_USER_SETTINGS,
|
||||
): ResolvedTerminalConfig {
|
||||
const resolved = resolveTerminalSettings(
|
||||
readHostTerminalSettings(hostTerminalValues(host)),
|
||||
user.terminalDefaults,
|
||||
);
|
||||
return {
|
||||
...resolved,
|
||||
moshCommand: resolved.moshCommand || DEFAULT_MOSH_COMMAND,
|
||||
};
|
||||
}
|
||||
|
||||
/** What a terminal on this host runs with, kept current as settings change. */
|
||||
export function useTerminalSettings(
|
||||
host: object | null | undefined,
|
||||
): TerminalSettingsForHost {
|
||||
const client = useTerminalClientSettings();
|
||||
const own = hostTerminalValues(host);
|
||||
const ownKey = own ? JSON.stringify(own) : "";
|
||||
return useMemo(() => {
|
||||
const user = client?.user ?? DEFAULT_USER_SETTINGS;
|
||||
return { config: resolveForHost(host, user), user };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ownKey, client]);
|
||||
}
|
||||
@@ -27,10 +27,23 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import {
|
||||
deleteCommandFromHistory,
|
||||
enableHostAutoTmux,
|
||||
getClientSettings,
|
||||
getCommandHistory,
|
||||
hostSetting,
|
||||
} from "../terminal-api";
|
||||
import { useTerminalSettings } from "../terminal-settings";
|
||||
import {
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
TERMINAL_FONTS,
|
||||
resolveTerminalFontFamily,
|
||||
} from "../look/terminal-themes";
|
||||
import { resolveTermixThemeColors } from "../look/terminal-theme";
|
||||
import { ensureTerminalFontsLoaded } from "../look/terminal-global-styles";
|
||||
import {
|
||||
getNextTerminalFontSize,
|
||||
getTerminalFontZoomDirection,
|
||||
} from "../look/terminal-font-zoom";
|
||||
import { TmuxSessionPicker } from "./TmuxSessionPicker";
|
||||
import { getTerminalBufferText } from "./terminal-buffer-text.ts";
|
||||
import { getMacLineNavigationSequence } from "../lib/mac-line-navigation";
|
||||
@@ -90,10 +103,6 @@ import {
|
||||
PassphraseDialog,
|
||||
BrowserSignInDialog,
|
||||
HostKeyVerificationDialog,
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
TERMINAL_FONTS,
|
||||
resolveTerminalFontFamily,
|
||||
ensureTerminalFontsLoaded,
|
||||
useAppTheme as useTheme,
|
||||
globalShortcutHandler,
|
||||
isTabJumpHotkey,
|
||||
@@ -103,14 +112,10 @@ import {
|
||||
useConnectionLog,
|
||||
ConnectionScreen,
|
||||
Button,
|
||||
resolveTermixThemeColors,
|
||||
getNextTerminalFontSize,
|
||||
getTerminalFontZoomDirection,
|
||||
hydrateLocalSharedHostAuth,
|
||||
findMatchingKeybinding,
|
||||
SnippetVariablesDialog,
|
||||
type CustomKeybinding,
|
||||
useConnectionDefaults,
|
||||
isElectron,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
@@ -123,7 +128,7 @@ import {
|
||||
logActivity,
|
||||
getHostPassword,
|
||||
patchOpenTab,
|
||||
setHostAutoTmux,
|
||||
useHost,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
type HostKeyVerificationData = Omit<
|
||||
@@ -159,6 +164,17 @@ interface SSHTerminalProps {
|
||||
onSessionReady?: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's startup snippet. Core still keeps it in the host's terminal
|
||||
* config until snippets owns it.
|
||||
*/
|
||||
function startupSnippetOf(host: object): number | null {
|
||||
const config = (host as { terminalConfig?: { startupSnippetId?: unknown } })
|
||||
.terminalConfig;
|
||||
const id = Number(config?.startupSnippetId);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
const ALTERNATE_SCREEN_SEQUENCE = /\x1b\[\?(47|1047|1049)([hl])/g;
|
||||
|
||||
function updateAlternateScreenMode(output: string, currentMode: boolean) {
|
||||
@@ -205,7 +221,17 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const { theme: appTheme } = useTheme();
|
||||
const { addLog } = useConnectionLog();
|
||||
const { terminal: terminalDefaults } = useConnectionDefaults();
|
||||
// An embedded terminal (a widget, another plugin's window) gets only a
|
||||
// connect config; the shell's record for the host carries its settings.
|
||||
const shellHost = useHost(host ? undefined : hostConfig.id);
|
||||
const settingsHost: object = host ?? shellHost ?? hostConfig;
|
||||
const { config: termSettings, user: termUser } =
|
||||
useTerminalSettings(settingsHost);
|
||||
// Callbacks bound once (socket handlers, xterm hooks) read these.
|
||||
const termSettingsRef = useRef(termSettings);
|
||||
termSettingsRef.current = termSettings;
|
||||
const termUserRef = useRef(termUser);
|
||||
termUserRef.current = termUser;
|
||||
const showToolbar = hostSetting(host, "enableTerminalToolbar", true);
|
||||
const outputListenersRef = useRef(new Set<(data: string) => void>());
|
||||
|
||||
@@ -213,14 +239,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
`terminal_theme_host_${hostConfig.id}`,
|
||||
);
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...terminalDefaults,
|
||||
...hostConfig.terminalConfig,
|
||||
theme:
|
||||
savedTheme ||
|
||||
hostConfig.terminalConfig?.theme ||
|
||||
terminalDefaults.theme ||
|
||||
DEFAULT_TERMINAL_CONFIG.theme,
|
||||
...termSettings,
|
||||
theme: savedTheme || termSettings.theme || DEFAULT_TERMINAL_CONFIG.theme,
|
||||
};
|
||||
|
||||
// Ctrl+/- / Ctrl+wheel terminal zoom is persisted per-host and takes
|
||||
@@ -530,8 +550,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}, [showHistoryDialog, hostConfig.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const autocompleteEnabled =
|
||||
localStorage.getItem("commandAutocomplete") === "true";
|
||||
const autocompleteEnabled = termUser.commandAutocomplete;
|
||||
|
||||
if (hostConfig.id && autocompleteEnabled) {
|
||||
getCommandHistory(api, hostConfig.id!)
|
||||
@@ -545,7 +564,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
} else {
|
||||
autocompleteHistory.current = [];
|
||||
}
|
||||
}, [hostConfig.id]);
|
||||
}, [hostConfig.id, termUser.commandAutocomplete]);
|
||||
|
||||
useEffect(() => {
|
||||
showAutocompleteRef.current = showAutocomplete;
|
||||
@@ -591,7 +610,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}, [autosuggestion]);
|
||||
|
||||
const isAutocompleteEnabled = useCallback(
|
||||
() => localStorage.getItem("commandAutocomplete") === "true",
|
||||
() => termUserRef.current.commandAutocomplete,
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -1208,7 +1227,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
controlStringModeRef.current = controlString.isActive;
|
||||
|
||||
const syntaxHighlightingEnabled =
|
||||
hostConfig.terminalConfig?.syntaxHighlighting !== false;
|
||||
termSettingsRef.current.syntaxHighlighting !== false;
|
||||
if (
|
||||
!syntaxHighlightingEnabled ||
|
||||
alternateScreen.sawSequence ||
|
||||
@@ -1221,7 +1240,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
return highlightTerminalOutput(
|
||||
output,
|
||||
hostConfig.terminalConfig?.syntaxHighlightingOptions,
|
||||
termSettingsRef.current.syntaxHighlightingOptions,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1244,9 +1263,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
async function resolvePasswordForPrompt(isSudoPrompt: boolean) {
|
||||
const sudoPassword = hostConfig.sudoPassword as string | undefined;
|
||||
let passwordToFill = isSudoPrompt
|
||||
? hostConfig.terminalConfig?.sudoPassword || hostConfig.password
|
||||
: hostConfig.password || hostConfig.terminalConfig?.sudoPassword;
|
||||
? sudoPassword || hostConfig.password
|
||||
: hostConfig.password || sudoPassword;
|
||||
|
||||
if (!passwordToFill && hostConfig.id) {
|
||||
passwordToFill = isSudoPrompt
|
||||
@@ -1262,7 +1282,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
function maybeOfferPasswordFill(strippedData: string) {
|
||||
if (hostConfig.terminalConfig?.passwordPromptAutoFill === false) return;
|
||||
if (termSettingsRef.current.passwordPromptAutoFill === false) return;
|
||||
|
||||
// PTY output can split a short prompt like "[sudo] password for user: "
|
||||
// across multiple WebSocket chunks, so match against a rolling buffer
|
||||
@@ -1280,12 +1300,12 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
// Sudo autofill is opt-in: the saved sudo password must not be sent
|
||||
// to a privilege-escalation prompt unless the host explicitly enables it.
|
||||
if (isSudoPrompt && !hostConfig.terminalConfig?.sudoPasswordAutoFill) {
|
||||
if (isSudoPrompt && !termSettingsRef.current.sudoPasswordAutoFill) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasStoredPassword =
|
||||
hostConfig.terminalConfig?.sudoPassword ||
|
||||
hostConfig.sudoPassword ||
|
||||
hostConfig.password ||
|
||||
hostConfig.hasSudoPassword ||
|
||||
hostConfig.hasPassword;
|
||||
@@ -1542,7 +1562,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const canEnable =
|
||||
typeof hostConfig.id === "number" &&
|
||||
!hostConfig.terminalConfig?.autoTmux &&
|
||||
!termSettingsRef.current.autoTmux &&
|
||||
!hostConfig.joinShareId;
|
||||
toast.warning(notice, {
|
||||
duration: 15000,
|
||||
@@ -1551,7 +1571,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
action: {
|
||||
label: t("terminal.enableAutoTmuxAction"),
|
||||
onClick: () => {
|
||||
void setHostAutoTmux(hostConfig.id as number, true)
|
||||
void enableHostAutoTmux(api, hostConfig.id as number)
|
||||
.then(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:hosts-changed"),
|
||||
@@ -1746,8 +1766,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
terminalInputDisposableRef.current?.dispose();
|
||||
localEchoRef.current = new TerminalLocalEcho(
|
||||
resolveLocalEchoMode(
|
||||
hostConfig.terminalConfig?.localEcho,
|
||||
localStorage.getItem("terminalLocalEchoMode"),
|
||||
termSettingsRef.current.localEcho,
|
||||
termUserRef.current.localEcho,
|
||||
),
|
||||
);
|
||||
terminalInputDisposableRef.current = terminal.onData((data) => {
|
||||
@@ -1936,17 +1956,20 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
logTerminalActivity();
|
||||
|
||||
setTimeout(async () => {
|
||||
const terminalConfig = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...terminalDefaults,
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
const settings = termSettingsRef.current;
|
||||
const environmentVariables =
|
||||
(
|
||||
hostConfig.sshOptions as
|
||||
| {
|
||||
environmentVariables?: { key: string; value: string }[];
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
)?.environmentVariables ?? [];
|
||||
const startupSnippetId = startupSnippetOf(settingsHost);
|
||||
|
||||
if (
|
||||
terminalConfig.environmentVariables &&
|
||||
terminalConfig.environmentVariables.length > 0
|
||||
) {
|
||||
for (const envVar of terminalConfig.environmentVariables) {
|
||||
if (environmentVariables.length > 0) {
|
||||
for (const envVar of environmentVariables) {
|
||||
if (envVar.key && envVar.value && ws.readyState === 1) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -1958,11 +1981,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
}
|
||||
|
||||
if (terminalConfig.startupSnippetId) {
|
||||
if (startupSnippetId) {
|
||||
try {
|
||||
const resolved = (await invokeAction(
|
||||
"snippets.resolveForTerminal",
|
||||
terminalConfig.startupSnippetId,
|
||||
startupSnippetId,
|
||||
{
|
||||
ip: hostConfig.ip,
|
||||
username: hostConfig.username,
|
||||
@@ -1990,11 +2013,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
}
|
||||
|
||||
if (terminalConfig.autoMosh && ws.readyState === 1) {
|
||||
if (settings.autoMosh && ws.readyState === 1) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "input",
|
||||
data: terminalConfig.moshCommand + "\n",
|
||||
data: settings.moshCommand + "\n",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -2491,11 +2514,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
useEffect(() => {
|
||||
if (!terminal) return;
|
||||
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...terminalDefaults,
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
const config = termSettingsRef.current;
|
||||
|
||||
const activeTheme = previewTheme || config.theme;
|
||||
const themeColors = resolveTermixThemeColors(
|
||||
@@ -2560,23 +2579,12 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
// Refresh terminal to apply new theme colors to existing buffer content
|
||||
hardRefresh();
|
||||
}, [
|
||||
terminal,
|
||||
terminalDefaults,
|
||||
hostConfig.terminalConfig,
|
||||
previewTheme,
|
||||
appTheme,
|
||||
isFitted,
|
||||
]);
|
||||
}, [terminal, termSettings, previewTheme, appTheme, isFitted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current) return;
|
||||
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...terminalDefaults,
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
const config = termSettingsRef.current;
|
||||
|
||||
const fontFamily = resolveTerminalFontFamily(config.fontFamily);
|
||||
ensureTerminalFontsLoaded(config.fontFamily || TERMINAL_FONTS[0].value);
|
||||
@@ -2647,10 +2655,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
? uri
|
||||
: `https://${uri}`;
|
||||
|
||||
const hostBehavior = hostConfig.terminalConfig?.linkClickBehavior;
|
||||
const globalBehavior =
|
||||
localStorage.getItem("terminalLinkClickBehavior") ?? "confirm";
|
||||
const behavior = hostBehavior ?? globalBehavior;
|
||||
const hostBehavior = termSettingsRef.current.linkClickBehavior;
|
||||
const behavior =
|
||||
hostBehavior && hostBehavior !== "default"
|
||||
? hostBehavior
|
||||
: termUserRef.current.linkClickBehavior;
|
||||
|
||||
if (behavior === "direct") {
|
||||
window.open(url, "_blank");
|
||||
@@ -2701,11 +2710,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return false;
|
||||
}
|
||||
|
||||
const cfg = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...terminalDefaults,
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
const cfg = termSettingsRef.current;
|
||||
const mod = cfg.fastScrollModifier;
|
||||
const modHeld =
|
||||
(mod === "alt" && ev.altKey) ||
|
||||
@@ -2835,12 +2840,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
if (e.key !== "Backspace") return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
|
||||
const config = {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...terminalDefaults,
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
if (config.backspaceMode !== "control-h") return;
|
||||
if (termSettingsRef.current.backspaceMode !== "control-h") return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -3042,7 +3042,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const sequence = getAndroidHardwareKeySequence(
|
||||
e,
|
||||
terminal.modes.applicationCursorKeysMode,
|
||||
hostConfig.terminalConfig?.backspaceMode,
|
||||
termSettingsRef.current.backspaceMode,
|
||||
);
|
||||
if (sequence) {
|
||||
e.preventDefault();
|
||||
@@ -3300,8 +3300,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
};
|
||||
|
||||
const autocompleteEnabled =
|
||||
localStorage.getItem("commandAutocomplete") === "true";
|
||||
const autocompleteEnabled = termUserRef.current.commandAutocomplete;
|
||||
|
||||
if (!autocompleteEnabled) {
|
||||
clearAutosuggestion();
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
type TabProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type { Host } from "../types";
|
||||
import { hostTerminalValues } from "../terminal-settings";
|
||||
import { readHostTerminalSettings } from "../../shared/terminal-settings";
|
||||
|
||||
/** The session fields the shell keeps on a terminal tab. */
|
||||
interface TerminalTabRecord {
|
||||
@@ -63,6 +65,9 @@ export function TerminalTabContent({
|
||||
const { t } = useTranslation();
|
||||
const { previewTerminalTheme } = useTabsSafe();
|
||||
const isMobile = useIsMobile();
|
||||
const useSshTitle = readHostTerminalSettings(
|
||||
hostTerminalValues(host),
|
||||
).useSSHTitle;
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
@@ -102,7 +107,7 @@ export function TerminalTabContent({
|
||||
splitScreen={inSplit}
|
||||
onClose={() => shell.closeTab(tab.id)}
|
||||
onTitleChange={
|
||||
host.terminalConfig?.useSSHTitle
|
||||
useSshTitle
|
||||
? (title) => shell.renameTab(tab.id, title)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TerminalConfig } from "../types";
|
||||
import type { HostSshOptions } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export interface TerminalHostConfig {
|
||||
id?: number;
|
||||
@@ -17,7 +17,7 @@ export interface TerminalHostConfig {
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
credentialId?: number;
|
||||
terminalConfig?: Partial<TerminalConfig>;
|
||||
sshOptions?: HostSshOptions | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,82 +1,12 @@
|
||||
import type { HostSshOptions } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
/**
|
||||
* Host and terminal shapes as the terminal reads them. The plugin keeps its
|
||||
* Host 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;
|
||||
passwordPromptAutoFill?: boolean;
|
||||
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"];
|
||||
export type { BackspaceMode as HostBackspaceMode } from "../shared/terminal-settings";
|
||||
|
||||
/** A host as the shell hands it to a tab. */
|
||||
export interface Host {
|
||||
@@ -85,7 +15,7 @@ export interface Host {
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
terminalConfig?: Partial<TerminalConfig> | null;
|
||||
sshOptions?: HostSshOptions | null;
|
||||
pluginSettings?: Record<string, Record<string, unknown>>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
[key: string]: any;
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* The terminal's look and behavior: this plugin's host, user and admin
|
||||
* settings, and the 2.8 `terminalConfig` shape they replaced.
|
||||
*
|
||||
* Shared by the backend (import, the 2.8 host payload) and the frontend (the
|
||||
* terminal and the host editor), so both read one list of keys.
|
||||
*/
|
||||
|
||||
export interface TerminalThemeColors {
|
||||
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 interface SyntaxHighlightingOptions {
|
||||
logLevels: boolean;
|
||||
paths: boolean;
|
||||
timestamps: boolean;
|
||||
ipAddresses: boolean;
|
||||
urls: boolean;
|
||||
numbers: boolean;
|
||||
}
|
||||
|
||||
export type CursorStyle = "block" | "underline" | "bar";
|
||||
export type BellStyle = "none" | "sound" | "visual" | "both";
|
||||
export type FastScrollModifier = "alt" | "ctrl" | "shift";
|
||||
export type BackspaceMode = "normal" | "control-h";
|
||||
export type HostLocalEcho = "default" | "off" | "auto" | "on";
|
||||
export type HostLinkClickBehavior = "default" | "confirm" | "direct";
|
||||
|
||||
/** How a terminal looks. A host follows the user's defaults unless it opts out. */
|
||||
export interface TerminalAppearance {
|
||||
theme: string;
|
||||
cursorBlink: boolean;
|
||||
cursorStyle: CursorStyle;
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
scrollback: number;
|
||||
letterSpacing: number;
|
||||
lineHeight: number;
|
||||
bellStyle: BellStyle;
|
||||
minimumContrastRatio: number;
|
||||
backgroundImage: string;
|
||||
backgroundImageOpacity: number;
|
||||
customThemeColors: TerminalThemeColors | null;
|
||||
}
|
||||
|
||||
/** How a terminal behaves on one host. Always the host's own. */
|
||||
export interface TerminalBehavior {
|
||||
rightClickSelectsWord: boolean;
|
||||
macOptionIsMeta: boolean;
|
||||
fastScrollModifier: FastScrollModifier;
|
||||
fastScrollSensitivity: number;
|
||||
backspaceMode: BackspaceMode;
|
||||
autoMosh: boolean;
|
||||
moshCommand: string;
|
||||
autoTmux: boolean;
|
||||
useSSHTitle: boolean;
|
||||
syntaxHighlighting: boolean;
|
||||
syntaxHighlightingOptions: SyntaxHighlightingOptions;
|
||||
linkClickBehavior: HostLinkClickBehavior;
|
||||
localEcho: HostLocalEcho;
|
||||
passwordPromptAutoFill: boolean;
|
||||
sudoPasswordAutoFill: boolean;
|
||||
}
|
||||
|
||||
/** Everything the host editor's Terminal tab edits. */
|
||||
export interface HostTerminalSettings
|
||||
extends TerminalAppearance, TerminalBehavior {
|
||||
inheritAppearance: boolean;
|
||||
}
|
||||
|
||||
export const APPEARANCE_KEYS = [
|
||||
"theme",
|
||||
"cursorBlink",
|
||||
"cursorStyle",
|
||||
"fontSize",
|
||||
"fontFamily",
|
||||
"scrollback",
|
||||
"letterSpacing",
|
||||
"lineHeight",
|
||||
"bellStyle",
|
||||
"minimumContrastRatio",
|
||||
"backgroundImage",
|
||||
"backgroundImageOpacity",
|
||||
"customThemeColors",
|
||||
] as const satisfies readonly (keyof TerminalAppearance)[];
|
||||
|
||||
export const BEHAVIOR_KEYS = [
|
||||
"rightClickSelectsWord",
|
||||
"macOptionIsMeta",
|
||||
"fastScrollModifier",
|
||||
"fastScrollSensitivity",
|
||||
"backspaceMode",
|
||||
"autoMosh",
|
||||
"moshCommand",
|
||||
"autoTmux",
|
||||
"useSSHTitle",
|
||||
"syntaxHighlighting",
|
||||
"syntaxHighlightingOptions",
|
||||
"linkClickBehavior",
|
||||
"localEcho",
|
||||
"passwordPromptAutoFill",
|
||||
"sudoPasswordAutoFill",
|
||||
] as const satisfies readonly (keyof TerminalBehavior)[];
|
||||
|
||||
export const INHERIT_APPEARANCE_KEY = "inheritAppearance";
|
||||
|
||||
export const DEFAULT_MOSH_COMMAND = "mosh-server new -s -l LANG=en_US.UTF-8";
|
||||
export const DEFAULT_FONT_FAMILY = "Caskaydia Cove Nerd Font Mono";
|
||||
|
||||
export const DEFAULT_SYNTAX_HIGHLIGHTING: SyntaxHighlightingOptions = {
|
||||
logLevels: true,
|
||||
paths: true,
|
||||
timestamps: true,
|
||||
ipAddresses: true,
|
||||
urls: true,
|
||||
numbers: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_APPEARANCE: TerminalAppearance = {
|
||||
theme: "termix",
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
fontSize: 14,
|
||||
fontFamily: DEFAULT_FONT_FAMILY,
|
||||
scrollback: 10000,
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1,
|
||||
bellStyle: "none",
|
||||
minimumContrastRatio: 1,
|
||||
backgroundImage: "",
|
||||
backgroundImageOpacity: 0.15,
|
||||
customThemeColors: null,
|
||||
};
|
||||
|
||||
export const DEFAULT_BEHAVIOR: TerminalBehavior = {
|
||||
rightClickSelectsWord: false,
|
||||
macOptionIsMeta: false,
|
||||
fastScrollModifier: "alt",
|
||||
fastScrollSensitivity: 5,
|
||||
backspaceMode: "normal",
|
||||
autoMosh: false,
|
||||
moshCommand: "",
|
||||
autoTmux: false,
|
||||
useSSHTitle: false,
|
||||
syntaxHighlighting: true,
|
||||
syntaxHighlightingOptions: DEFAULT_SYNTAX_HIGHLIGHTING,
|
||||
linkClickBehavior: "default",
|
||||
localEcho: "default",
|
||||
passwordPromptAutoFill: true,
|
||||
sudoPasswordAutoFill: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_HOST_TERMINAL_SETTINGS: HostTerminalSettings = {
|
||||
...DEFAULT_APPEARANCE,
|
||||
...DEFAULT_BEHAVIOR,
|
||||
inheritAppearance: true,
|
||||
};
|
||||
|
||||
/** The user's terminal defaults: any appearance key, unset ones inherit. */
|
||||
export type TerminalDefaults = Partial<TerminalAppearance>;
|
||||
|
||||
export interface SavedCustomTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: TerminalThemeColors;
|
||||
}
|
||||
|
||||
/** The ssh-terminal user settings the terminal reads. */
|
||||
export interface TerminalUserSettings {
|
||||
terminalDefaults: TerminalDefaults;
|
||||
customThemes: SavedCustomTheme[];
|
||||
commandAutocomplete: boolean;
|
||||
localEcho: "off" | "auto" | "on";
|
||||
linkClickBehavior: "confirm" | "direct";
|
||||
}
|
||||
|
||||
export const DEFAULT_USER_SETTINGS: TerminalUserSettings = {
|
||||
terminalDefaults: {},
|
||||
customThemes: [],
|
||||
commandAutocomplete: false,
|
||||
localEcho: "auto",
|
||||
linkClickBehavior: "confirm",
|
||||
};
|
||||
|
||||
/** The admin's defaults for a new host's terminal. */
|
||||
export interface NewHostTerminalDefaults {
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
theme: string;
|
||||
cursorStyle: CursorStyle;
|
||||
cursorBlink: boolean;
|
||||
autoTmux: boolean;
|
||||
}
|
||||
|
||||
/** Admin field -> the host field it seeds (manifest defaultFrom). */
|
||||
export const NEW_HOST_ADMIN_KEYS = {
|
||||
newHostFontSize: "fontSize",
|
||||
newHostFontFamily: "fontFamily",
|
||||
newHostTheme: "theme",
|
||||
newHostCursorStyle: "cursorStyle",
|
||||
newHostCursorBlink: "cursorBlink",
|
||||
newHostAutoTmux: "autoTmux",
|
||||
} as const satisfies Record<string, keyof NewHostTerminalDefaults>;
|
||||
|
||||
const ENUMS: Record<string, readonly string[]> = {
|
||||
cursorStyle: ["block", "underline", "bar"],
|
||||
bellStyle: ["none", "sound", "visual", "both"],
|
||||
fastScrollModifier: ["alt", "ctrl", "shift"],
|
||||
backspaceMode: ["normal", "control-h"],
|
||||
linkClickBehavior: ["default", "confirm", "direct"],
|
||||
localEcho: ["default", "off", "auto", "on"],
|
||||
};
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** A JSON string or an object as an object, anything else as null. */
|
||||
export function asObject(value: unknown): Record<string, unknown> | null {
|
||||
let parsed = value;
|
||||
if (typeof parsed === "string") {
|
||||
if (!parsed) return null;
|
||||
try {
|
||||
parsed = JSON.parse(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return isObject(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function finite(value: unknown): number | undefined {
|
||||
if (value === null || value === undefined || value === "") return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
/** One key's value, typed, or undefined when it does not fit the key. */
|
||||
function pick(key: string, value: unknown): unknown {
|
||||
if (value === undefined) return undefined;
|
||||
if (ENUMS[key]) {
|
||||
return typeof value === "string" && ENUMS[key].includes(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
switch (key) {
|
||||
case "theme":
|
||||
if (typeof value !== "string" || !value) return undefined;
|
||||
// 2.8 spelled the app-following theme several ways.
|
||||
return [
|
||||
"Termix Dark",
|
||||
"Termix Light",
|
||||
"termixDark",
|
||||
"termixLight",
|
||||
].includes(value)
|
||||
? "termix"
|
||||
: value;
|
||||
case "fontFamily":
|
||||
return typeof value === "string" && value ? value : undefined;
|
||||
case "backgroundImage":
|
||||
case "moshCommand":
|
||||
return value === null
|
||||
? ""
|
||||
: typeof value === "string"
|
||||
? value
|
||||
: undefined;
|
||||
case "customThemeColors":
|
||||
return value === null ? null : isObject(value) ? value : undefined;
|
||||
case "syntaxHighlightingOptions":
|
||||
return isObject(value)
|
||||
? { ...DEFAULT_SYNTAX_HIGHLIGHTING, ...value }
|
||||
: undefined;
|
||||
case "fontSize":
|
||||
case "scrollback":
|
||||
case "letterSpacing":
|
||||
case "lineHeight":
|
||||
case "minimumContrastRatio":
|
||||
case "backgroundImageOpacity":
|
||||
case "fastScrollSensitivity":
|
||||
return finite(value);
|
||||
default:
|
||||
return typeof value === "boolean" ? value : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The keys of an object that fit, typed, with everything else dropped. */
|
||||
export function pickTerminalValues(
|
||||
source: Record<string, unknown> | null | undefined,
|
||||
keys: readonly string[],
|
||||
): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {};
|
||||
if (!source) return values;
|
||||
for (const key of keys) {
|
||||
const value = pick(key, source[key]);
|
||||
if (value !== undefined) values[key] = value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host settings a 2.8 `terminalConfig` carried: every behavior key it
|
||||
* set, and the appearance keys only when it set any, which is also what
|
||||
* turns `inheritAppearance` off (2.8 stripped them to follow the user).
|
||||
*/
|
||||
export function hostSettingsFromTerminalConfig(
|
||||
terminalConfig: unknown,
|
||||
): Record<string, unknown> {
|
||||
const config = asObject(terminalConfig);
|
||||
if (!config) return {};
|
||||
const behavior = pickTerminalValues(config, BEHAVIOR_KEYS);
|
||||
const appearance = pickTerminalValues(config, APPEARANCE_KEYS);
|
||||
const values: Record<string, unknown> = { ...behavior };
|
||||
if (APPEARANCE_KEYS.some((key) => key in config)) {
|
||||
Object.assign(values, appearance, { [INHERIT_APPEARANCE_KEY]: false });
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** Stored host values over the defaults, each one typed. */
|
||||
export function readHostTerminalSettings(
|
||||
values: Record<string, unknown> | null | undefined,
|
||||
): HostTerminalSettings {
|
||||
const typed = pickTerminalValues(values, [
|
||||
...APPEARANCE_KEYS,
|
||||
...BEHAVIOR_KEYS,
|
||||
]);
|
||||
const inherit = values?.[INHERIT_APPEARANCE_KEY];
|
||||
return {
|
||||
...DEFAULT_HOST_TERMINAL_SETTINGS,
|
||||
...typed,
|
||||
inheritAppearance: typeof inherit === "boolean" ? inherit : true,
|
||||
} as HostTerminalSettings;
|
||||
}
|
||||
|
||||
export function readUserSettings(
|
||||
values: Record<string, unknown> | null | undefined,
|
||||
): TerminalUserSettings {
|
||||
const source = values ?? {};
|
||||
const defaults = asObject(source.terminalDefaults);
|
||||
const themes = Array.isArray(source.customThemes)
|
||||
? source.customThemes
|
||||
: typeof source.customThemes === "string"
|
||||
? (() => {
|
||||
try {
|
||||
const parsed = JSON.parse(source.customThemes as string);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})()
|
||||
: [];
|
||||
return {
|
||||
terminalDefaults: pickTerminalValues(
|
||||
defaults,
|
||||
APPEARANCE_KEYS,
|
||||
) as TerminalDefaults,
|
||||
customThemes: themes.filter(
|
||||
(theme): theme is SavedCustomTheme =>
|
||||
isObject(theme) &&
|
||||
typeof theme.id === "string" &&
|
||||
typeof theme.name === "string" &&
|
||||
isObject(theme.colors),
|
||||
),
|
||||
commandAutocomplete: source.commandAutocomplete === true,
|
||||
localEcho:
|
||||
source.localEcho === "off" || source.localEcho === "on"
|
||||
? source.localEcho
|
||||
: "auto",
|
||||
linkClickBehavior:
|
||||
source.linkClickBehavior === "direct" ? "direct" : "confirm",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What a terminal on a host runs with: the defaults, then the user's
|
||||
* terminal defaults, then the host's own look when it does not follow the
|
||||
* user, then the host's behavior.
|
||||
*/
|
||||
export function resolveTerminalSettings(
|
||||
host: HostTerminalSettings | null | undefined,
|
||||
userDefaults: TerminalDefaults = {},
|
||||
): TerminalAppearance & TerminalBehavior {
|
||||
const own = host ?? DEFAULT_HOST_TERMINAL_SETTINGS;
|
||||
const appearance: TerminalAppearance = {
|
||||
...DEFAULT_APPEARANCE,
|
||||
...pickTerminalValues(userDefaults, APPEARANCE_KEYS),
|
||||
};
|
||||
if (!own.inheritAppearance) {
|
||||
for (const key of APPEARANCE_KEYS) {
|
||||
(appearance as unknown as Record<string, unknown>)[key] = own[key];
|
||||
}
|
||||
}
|
||||
const behavior = {} as TerminalBehavior;
|
||||
for (const key of BEHAVIOR_KEYS) {
|
||||
(behavior as unknown as Record<string, unknown>)[key] = own[key];
|
||||
}
|
||||
return { ...appearance, ...behavior };
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's settings in the 2.8 `terminalConfig` shape, for clients that
|
||||
* still read it (Termix-Mobile). The look is left out while the host follows
|
||||
* the user, the way 2.8 stored it.
|
||||
*/
|
||||
export function terminalConfigFromHostSettings(
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const settings = readHostTerminalSettings(values);
|
||||
const config: Record<string, unknown> = {};
|
||||
for (const key of BEHAVIOR_KEYS) config[key] = settings[key];
|
||||
if (!settings.inheritAppearance) {
|
||||
for (const key of APPEARANCE_KEYS) config[key] = settings[key];
|
||||
}
|
||||
if (config.linkClickBehavior === "default") delete config.linkClickBehavior;
|
||||
if (config.localEcho === "default") delete config.localEcho;
|
||||
return config;
|
||||
}
|
||||
Reference in new issue
Block a user