feat: move terminal settings, look and ssh tools into ssh-terminal plugin

This commit is contained in:
LukeGus committed 2026-09-29 02:38:37 -05:00
1 parent ffa872ebd3
commit d9bba10612
242 files changed
+25071 -7207

No files matched your search

-17
View File
@@ -482,23 +482,6 @@ export function AdminHostDefaultsSection({
</SettingRow>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.hostDefaultsTerminal")}
</span>
<SettingRow
label={t("admin.hostDefaultsAutoTmux")}
description={t("admin.hostDefaultsAutoTmuxDesc")}
>
<AdminToggle
on={defaults.autoTmux ?? false}
onToggle={() =>
setDefaults((p) => ({ ...p, autoTmux: !(p.autoTmux ?? false) }))
}
/>
</SettingRow>
</div>
<Button
variant="outline"
size="sm"
@@ -1,346 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import type React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/dialog";
import { SettingRow } from "@/components/section-card";
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
import {
CURSOR_STYLES,
TERMINAL_FONTS,
TERMINAL_THEMES,
} from "@/lib/terminal-themes";
import {
fromTriState,
toTriState,
type TerminalDefaults,
type TriState,
} from "@/lib/connection-defaults";
import {
getUserPreferences,
parseCustomThemes,
type SavedCustomTheme,
} from "@/api/open-tabs-api";
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("newUi.sidebar.connectionDefaults.inherit")}
</option>
<option value="on">{t("newUi.sidebar.connectionDefaults.on")}</option>
<option value="off">{t("newUi.sidebar.connectionDefaults.off")}</option>
</select>
);
}
export function ConnectionDefaultsSettings() {
const { t } = useTranslation();
const defaults = useConnectionDefaults();
const [open, setOpen] = useState(false);
const [terminal, setTerminal] = useState<TerminalDefaults>({});
const [saving, setSaving] = useState(false);
const [savedThemes, setSavedThemes] = useState<SavedCustomTheme[]>([]);
useEffect(() => {
if (!defaults.ready) return;
setTerminal(defaults.terminal);
}, [defaults.ready, defaults.terminal]);
useEffect(() => {
if (!open) return;
getUserPreferences()
.then((prefs) => setSavedThemes(parseCustomThemes(prefs.customThemes)))
.catch(() => {});
}, [open]);
const configuredCount = useMemo(() => {
const count = (source: Record<string, unknown>) =>
Object.values(source).filter((value) => value !== undefined).length;
return count(defaults.terminal);
}, [defaults.terminal]);
const updateTerminal = <K extends keyof TerminalDefaults>(
key: K,
value: TerminalDefaults[K],
) => setTerminal((current) => ({ ...current, [key]: value }));
const applySavedTheme = (id: string) => {
if (!id) return;
const theme = savedThemes.find((entry) => entry.id === id);
if (!theme) return;
setTerminal((current) => ({
...current,
theme: "custom",
customThemeColors: { ...theme.colors },
}));
};
async function save() {
setSaving(true);
try {
await defaults.saveTerminalDefaults(terminal);
toast.success(t("newUi.sidebar.connectionDefaults.saved"));
setOpen(false);
} catch {
toast.error(t("newUi.sidebar.connectionDefaults.saveFailed"));
} finally {
setSaving(false);
}
}
const inheritLabel = t("newUi.sidebar.connectionDefaults.inherit");
return (
<>
<SettingRow
label={t("newUi.sidebar.connectionDefaults.title")}
description={t("newUi.sidebar.connectionDefaults.description")}
badge={configuredCount > 0 ? String(configuredCount) : undefined}
>
<Button
variant="outline"
size="sm"
disabled={!defaults.ready}
onClick={() => setOpen(true)}
>
{t("newUi.sidebar.connectionDefaults.manage")}
</Button>
</SettingRow>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="text-base font-bold">
{t("newUi.sidebar.connectionDefaults.title")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("newUi.sidebar.connectionDefaults.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>
{savedThemes.length > 0 && (
<Field
label={t("newUi.sidebar.connectionDefaults.savedThemeLabel")}
>
<select
className={inputClass}
value=""
onChange={(e) => applySavedTheme(e.target.value)}
>
<option value="">
{t(
"newUi.sidebar.connectionDefaults.savedThemePlaceholder",
)}
</option>
{savedThemes.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("newUi.sidebar.connectionDefaults.clearAll")}
</Button>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={() => setOpen(false)}>
{t("common.cancel")}
</Button>
<Button
size="sm"
disabled={saving || !defaults.ready}
onClick={save}
>
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
+118 -64
View File
@@ -30,11 +30,9 @@ import {
createHostEditorForm,
mapSnippetResponse,
omitOwnerSshAuthFromSharedEdit,
terminalAppearanceKeys,
type HostAuthType,
type HostProtocols,
} from "./HostEditorData";
import { useConnectionDefaults } from "@/contexts/ConnectionDefaultsContext";
import { HostEditorGeneralTab } from "./HostEditorGeneralTab";
import { withProtocolSettings } from "./host-protocols";
import {
@@ -98,24 +96,12 @@ export function HostEditor({
const { t } = useTranslation();
const hostSettingPlugins = usePluginHostSections();
const { setPreviewTerminalTheme } = useTabsSafe();
const connectionDefaults = useConnectionDefaults();
const [form, setForm] = useState(() =>
applyHostDraft(
createHostEditorForm(host, undefined, connectionDefaults),
host ? undefined : draft,
),
applyHostDraft(createHostEditorForm(host), host ? undefined : draft),
);
const setField = <K extends keyof typeof form>(k: K, v: (typeof form)[K]) => {
onDirtyChange?.(true);
setForm((p) => ({
...p,
[k]: v,
...(terminalAppearanceKeys.includes(
k as (typeof terminalAppearanceKeys)[number],
)
? { inheritTerminalAppearance: false }
: {}),
}));
setForm((p) => ({ ...p, [k]: v }));
};
/** Sets several fields at once, from the latest form. For plugin sections. */
@@ -154,15 +140,21 @@ export function HostEditor({
}, [adminTargetUserId]);
useEffect(() => {
if (!connectionDefaults.ready) return;
if (host) {
setForm(createHostEditorForm(host, undefined, connectionDefaults));
setForm(createHostEditorForm(host));
return;
}
getHostDefaults()
.then((d) => setForm(createHostEditorForm(null, d, connectionDefaults)))
.then((d) =>
setForm((current) => ({
...applyHostDraft(createHostEditorForm(null, d), draft),
// A plugin's section may already have filled in its new-host values.
pluginSettings: current.pluginSettings,
})),
)
.catch(() => {});
}, [host, connectionDefaults]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [host]);
useEffect(() => {
if (!host?.id || form.vncAuthType !== "direct" || form.vncPassword) return;
@@ -488,6 +480,87 @@ export function HostEditor({
/>
</div>
</div>
<SettingRow
label={t("hosts.sshAgentForwardingLabel")}
description={t("hosts.sshAgentForwardingShortDesc")}
>
<FakeSwitch
checked={form.agentForwarding}
onChange={(v) => setField("agentForwarding", v)}
/>
</SettingRow>
<div className="flex flex-col gap-3 border-t border-border pt-4">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.environmentVariablesLabel")}
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("environmentVariables", [
...form.environmentVariables,
{ key: "", value: "" },
])
}
>
<Plus className="size-3 mr-1" />{" "}
{t("hosts.addVariableBtn")}
</Button>
</div>
{form.environmentVariables.length === 0 && (
<p className="text-[10px] text-muted-foreground/50">
{t("hosts.noEnvVars")}
</p>
)}
<div className="flex flex-col gap-2">
{form.environmentVariables.map((ev, i) => (
<div key={i} className="flex items-center gap-2">
<Input
className="h-7 text-xs flex-1"
placeholder="KEY"
value={ev.key}
onChange={(e) => {
const updated = [...form.environmentVariables];
updated[i] = {
...updated[i],
key: e.target.value,
};
setField("environmentVariables", updated);
}}
/>
<Input
className="h-7 text-xs flex-1"
placeholder="VALUE"
value={ev.value}
onChange={(e) => {
const updated = [...form.environmentVariables];
updated[i] = {
...updated[i],
value: e.target.value,
};
setField("environmentVariables", updated);
}}
/>
<button
type="button"
className="text-destructive"
onClick={() =>
setField(
"environmentVariables",
form.environmentVariables.filter(
(_, idx) => idx !== i,
),
)
}
>
<X className="size-4" />
</button>
</div>
))}
</div>
</div>
</div>
</SectionCard>
<SectionCard
@@ -1005,51 +1078,32 @@ export function HostEditor({
onChange={(v) => setField("allowLegacyAlgorithms", v)}
/>
</SettingRow>
<SettingRow
label={t("hosts.passwordPromptAutoFillLabel")}
description={t("hosts.passwordPromptAutoFillDesc")}
>
<FakeSwitch
checked={form.passwordPromptAutoFill}
onChange={(v) => setField("passwordPromptAutoFill", v)}
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.sudoPasswordLabel")}
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder={
form.sudoPassword === "existing_sudo_password"
? t("hosts.sudoPasswordSaved")
: "••••••••"
}
value={
form.sudoPassword === "existing_sudo_password"
? ""
: form.sudoPassword
}
onFocus={() => {
if (form.sudoPassword === "existing_sudo_password")
setField("sudoPassword", "");
}}
onChange={(e) => setField("sudoPassword", e.target.value)}
/>
</SettingRow>
<SettingRow
label={t("hosts.sudoPasswordAutoFillLabel")}
description={t("hosts.sudoPasswordAutoFillDesc")}
>
<FakeSwitch
checked={form.sudoPasswordAutoFill}
onChange={(v) => setField("sudoPasswordAutoFill", v)}
/>
</SettingRow>
{form.sudoPasswordAutoFill && (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.sudoPasswordLabel")}
</label>
<PasswordInput
className="h-8 text-xs pr-8"
placeholder={
form.sudoPassword === "existing_sudo_password"
? t("hosts.sudoPasswordSaved")
: "••••••••"
}
value={
form.sudoPassword === "existing_sudo_password"
? ""
: form.sudoPassword
}
onFocus={() => {
if (form.sudoPassword === "existing_sudo_password")
setField("sudoPassword", "");
}}
onChange={(e) =>
setField("sudoPassword", e.target.value)
}
/>
</div>
)}
<p className="text-[10px] text-muted-foreground">
{t("hosts.sudoPasswordDesc")}
</p>
</div>
</div>
</SectionCard>
</>
+32 -173
View File
@@ -1,9 +1,7 @@
import { TERMINAL_THEMES } from "@/lib/terminal-themes";
import type { Host } from "@/types/ui-types";
import type { SSHHostData } from "@/types";
import type { HostDraft } from "@termix/plugin-sdk/frontend";
import type { HostDefaults } from "@/api/settings-api";
import type { TerminalDefaults } from "@/lib/connection-defaults";
import {
listHostProtocols,
protocolPort,
@@ -15,49 +13,6 @@ type HostSocks5ProxyNode = NonNullable<Host["socks5ProxyChain"]>[number];
export type { HostProtocols };
export type HostAuthType = Host["authType"];
export type HostCursorStyle = NonNullable<
Host["terminalConfig"]
>["cursorStyle"];
export type HostBellStyle = NonNullable<Host["terminalConfig"]>["bellStyle"];
export type HostBackspaceMode = NonNullable<
Host["terminalConfig"]
>["backspaceMode"];
export type HostFastScrollModifier = NonNullable<
Host["terminalConfig"]
>["fastScrollModifier"];
export const terminalAppearanceKeys = [
"theme",
"cursorBlink",
"cursorStyle",
"fontSize",
"fontFamily",
"scrollback",
"letterSpacing",
"lineHeight",
"bellStyle",
"minimumContrastRatio",
"backgroundImage",
"backgroundImageOpacity",
"customThemeColors",
] as const satisfies readonly (keyof TerminalDefaults)[];
export interface UserConnectionDefaults {
terminal: TerminalDefaults;
}
function hasOwn(value: object | undefined, key: PropertyKey): boolean {
return !!value && Object.prototype.hasOwnProperty.call(value, key);
}
function stripKeys<T extends Record<string, unknown>>(
value: T,
keys: readonly string[],
): Partial<T> {
const result = { ...value };
for (const key of keys) delete result[key];
return result;
}
type SnippetListItem = {
id: number;
@@ -111,23 +66,9 @@ export function applyHostDraft(
export function createHostEditorForm(
host: Host | null,
defaults?: HostDefaults,
connectionDefaults?: UserConnectionDefaults,
) {
const d = host ? undefined : defaults;
const terminalConfig = {
...(connectionDefaults?.terminal ?? {}),
...(host?.terminalConfig ?? {}),
};
const rawTheme = terminalConfig.theme ?? d?.theme;
const normalizedTheme =
!rawTheme ||
["Termix Dark", "Termix Light", "termixDark", "termixLight"].includes(
rawTheme,
)
? "termix"
: rawTheme === "custom" || TERMINAL_THEMES[rawTheme]
? rawTheme
: "termix";
const sshOptions = host?.sshOptions ?? {};
return {
name: host?.name ?? "",
@@ -169,68 +110,18 @@ export function createHostEditorForm(
"local" | "remote" | null,
localOnly: host?.localOnly ?? false,
forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false,
inheritTerminalAppearance:
!host ||
terminalAppearanceKeys.every((key) => !hasOwn(host.terminalConfig, key)),
localEcho: host?.terminalConfig?.localEcho ?? "default",
fontSize: terminalConfig.fontSize ?? d?.fontSize ?? 14,
fontFamily:
terminalConfig.fontFamily ??
d?.fontFamily ??
"Caskaydia Cove Nerd Font Mono",
theme: normalizedTheme,
cursorStyle: (terminalConfig.cursorStyle ?? d?.cursorStyle ?? "bar") as
"block" | "underline" | "bar",
cursorBlink: terminalConfig.cursorBlink ?? d?.cursorBlink ?? true,
scrollback: terminalConfig.scrollback ?? 10000,
letterSpacing: terminalConfig.letterSpacing ?? 0,
lineHeight: terminalConfig.lineHeight ?? 1.0,
bellStyle: (terminalConfig.bellStyle ?? "none") as
"none" | "sound" | "visual" | "both",
rightClickSelectsWord: host?.terminalConfig?.rightClickSelectsWord ?? false,
macOptionIsMeta: host?.terminalConfig?.macOptionIsMeta ?? false,
fastScrollModifier: (host?.terminalConfig?.fastScrollModifier ?? "alt") as
"alt" | "ctrl" | "shift",
fastScrollSensitivity: host?.terminalConfig?.fastScrollSensitivity ?? 5,
minimumContrastRatio: terminalConfig.minimumContrastRatio ?? 1,
backspaceMode: (host?.terminalConfig?.backspaceMode ?? "normal") as
"normal" | "control-h",
startupSnippetId: host?.terminalConfig?.startupSnippetId ?? null,
moshCommand: host?.terminalConfig?.moshCommand ?? "",
agentForwarding: host?.terminalConfig?.agentForwarding ?? false,
autoMosh: host?.terminalConfig?.autoMosh ?? false,
autoTmux: host?.terminalConfig?.autoTmux ?? d?.autoTmux ?? false,
passwordPromptAutoFill:
host?.terminalConfig?.passwordPromptAutoFill ?? true,
sudoPasswordAutoFill: host?.terminalConfig?.sudoPasswordAutoFill ?? false,
sudoPassword: host?.hasSudoPassword
? "existing_sudo_password"
: (host?.terminalConfig?.sudoPassword ?? ""),
keepaliveInterval: host?.terminalConfig?.keepaliveInterval ?? 60,
keepaliveCountMax: host?.terminalConfig?.keepaliveCountMax ?? 5,
backgroundImage: terminalConfig.backgroundImage ?? "",
backgroundImageOpacity: terminalConfig.backgroundImageOpacity ?? 0.15,
customThemeColors: terminalConfig.customThemeColors ?? null,
allowLegacyAlgorithms: host?.terminalConfig?.allowLegacyAlgorithms ?? true,
linkClickBehavior: (host?.terminalConfig?.linkClickBehavior ??
"default") as "default" | "confirm" | "direct",
agentSocketPath: host?.terminalConfig?.agentSocketPath ?? "",
agentIdentity: host?.terminalConfig?.agentIdentity ?? "",
useSSHTitle: host?.terminalConfig?.useSSHTitle ?? false,
syntaxHighlighting: host?.terminalConfig?.syntaxHighlighting ?? true,
syntaxHighlightingOptions: {
logLevels:
host?.terminalConfig?.syntaxHighlightingOptions?.logLevels ?? true,
paths: host?.terminalConfig?.syntaxHighlightingOptions?.paths ?? true,
timestamps:
host?.terminalConfig?.syntaxHighlightingOptions?.timestamps ?? true,
ipAddresses:
host?.terminalConfig?.syntaxHighlightingOptions?.ipAddresses ?? true,
urls: host?.terminalConfig?.syntaxHighlightingOptions?.urls ?? true,
numbers: host?.terminalConfig?.syntaxHighlightingOptions?.numbers ?? true,
},
: (host?.sudoPassword ?? ""),
keepaliveInterval: sshOptions.keepaliveInterval ?? 60,
keepaliveCountMax: sshOptions.keepaliveCountMax ?? 5,
allowLegacyAlgorithms: sshOptions.allowLegacyAlgorithms ?? true,
agentForwarding: sshOptions.agentForwarding ?? false,
agentSocketPath: sshOptions.agentSocketPath ?? "",
agentIdentity: sshOptions.agentIdentity ?? "",
environmentVariables:
host?.terminalConfig?.environmentVariables ??
sshOptions.environmentVariables ??
([] as { key: string; value: string }[]),
jumpHosts: host?.jumpHosts ?? ([] as { hostId: string }[]),
portKnockSequence:
@@ -295,17 +186,14 @@ export function omitOwnerSshAuthFromSharedEdit(
...editableFields
} = payload;
const terminalConfig = editableFields.terminalConfig
? { ...editableFields.terminalConfig }
const sshOptions = editableFields.sshOptions
? { ...editableFields.sshOptions }
: undefined;
if (terminalConfig) {
delete terminalConfig.sudoPassword;
delete terminalConfig.agentSocketPath;
}
if (sshOptions) delete sshOptions.agentSocketPath;
return {
...editableFields,
terminalConfig,
sshOptions,
} as SSHHostData;
}
@@ -325,53 +213,6 @@ export function buildHostEditorPayload(
const primaryProtocol = protocols.enableSsh
? undefined
: listHostProtocols().find((protocol) => protocols[protocol.settingKey]);
const terminalConfig = {
theme: form.theme,
cursorBlink: form.cursorBlink,
cursorStyle: form.cursorStyle,
fontSize: Number(form.fontSize),
fontFamily: form.fontFamily,
scrollback: Number(form.scrollback),
letterSpacing: Number(form.letterSpacing),
lineHeight: Number(form.lineHeight),
bellStyle: form.bellStyle,
rightClickSelectsWord: form.rightClickSelectsWord,
macOptionIsMeta: form.macOptionIsMeta,
fastScrollModifier: form.fastScrollModifier,
fastScrollSensitivity: Number(form.fastScrollSensitivity),
minimumContrastRatio: Number(form.minimumContrastRatio),
backspaceMode: form.backspaceMode,
startupSnippetId: form.startupSnippetId ?? null,
moshCommand: form.moshCommand || null,
agentForwarding: form.agentForwarding,
autoMosh: form.autoMosh,
autoTmux: form.autoTmux,
passwordPromptAutoFill: form.passwordPromptAutoFill,
sudoPasswordAutoFill: form.sudoPasswordAutoFill,
sudoPassword:
form.sudoPassword === "existing_sudo_password"
? undefined
: form.sudoPassword || null,
keepaliveInterval: Number(form.keepaliveInterval),
keepaliveCountMax: Number(form.keepaliveCountMax),
environmentVariables: form.environmentVariables,
useSSHTitle: form.useSSHTitle,
syntaxHighlighting: form.syntaxHighlighting,
syntaxHighlightingOptions: form.syntaxHighlightingOptions,
backgroundImage: form.backgroundImage || null,
backgroundImageOpacity: Number(form.backgroundImageOpacity),
customThemeColors: form.theme === "custom" ? form.customThemeColors : null,
allowLegacyAlgorithms: form.allowLegacyAlgorithms,
linkClickBehavior:
form.linkClickBehavior !== "default" ? form.linkClickBehavior : undefined,
localEcho: form.localEcho !== "default" ? form.localEcho : undefined,
agentSocketPath: usesAgent ? form.agentSocketPath || null : null,
agentIdentity: usesAgent ? form.agentIdentity || null : null,
};
const terminalOverrides = form.inheritTerminalAppearance
? stripKeys(terminalConfig, terminalAppearanceKeys)
: terminalConfig;
return {
connectionType: primaryProtocol?.id ?? "ssh",
name: form.name,
@@ -485,6 +326,24 @@ export function buildHostEditorPayload(
})),
statusCheckEnabled: form.statusCheckEnabled,
statusCheckInterval: form.statusCheckInterval,
terminalConfig: protocols.enableSsh ? terminalOverrides : null,
// Left out with SSH off, so the stored values stay as they are.
...(protocols.enableSsh
? {
sudoPassword:
form.sudoPassword === "existing_sudo_password"
? undefined
: form.sudoPassword || "",
sshOptions: {
keepaliveInterval: Number(form.keepaliveInterval),
keepaliveCountMax: Number(form.keepaliveCountMax),
allowLegacyAlgorithms: form.allowLegacyAlgorithms,
agentForwarding: form.agentForwarding,
agentSocketPath: usesAgent ? form.agentSocketPath || null : null,
agentIdentity: usesAgent ? form.agentIdentity || null : null,
environmentVariables: form.environmentVariables,
},
terminalConfig: { startupSnippetId: form.startupSnippetId ?? null },
}
: {}),
};
}
+3 -5
View File
@@ -44,8 +44,6 @@ function parseJson<T>(v: unknown): T | undefined {
export function sshHostToHost(h: SSHHostWithStatus): Host {
const host = h as RawSSHHost;
const isSshHost = h.connectionType === "ssh" || !h.connectionType;
const parsedTerminalConfig = parseJson(h.terminalConfig) as
(Host["terminalConfig"] & { sudoPassword?: string }) | undefined;
return {
id: String(h.id),
name: h.name,
@@ -117,9 +115,9 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
hostId: String(j.hostId ?? j.hostid ?? j),
})),
portKnockSequence: parseJson(h.portKnockSequence) ?? [],
terminalConfig: parsedTerminalConfig as Host["terminalConfig"],
hasSudoPassword:
!!host.hasSudoPassword || !!parsedTerminalConfig?.sudoPassword,
terminalConfig: parseJson(h.terminalConfig) as Host["terminalConfig"],
sshOptions: parseJson(h.sshOptions) as Host["sshOptions"],
hasSudoPassword: !!host.hasSudoPassword,
statusCheckEnabled: h.statusCheckEnabled !== false,
statusCheckInterval: h.statusCheckInterval ?? null,
forceKeyboardInteractive: h.forceKeyboardInteractive ?? false,
-920
View File
@@ -1,920 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Info, Palette, Plus, X, Zap } from "lucide-react";
import { toast } from "sonner";
import { Select2 } from "@/components/select2";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Slider } from "@/components/slider";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import { TerminalPreview } from "@/components/terminal-preview/TerminalPreview";
import {
TERMINAL_THEMES,
TERMINAL_FONTS,
BELL_STYLES,
FAST_SCROLL_MODIFIERS,
CURSOR_STYLES,
} from "@/lib/terminal-themes";
import {
TERMINAL_FONT_ZOOM_MIN,
TERMINAL_FONT_ZOOM_MAX,
} from "@/lib/terminal-look/terminal-font-zoom";
import {
getUserPreferences,
saveUserPreferences,
parseCustomThemes,
type SavedCustomTheme,
} from "@/api/open-tabs-api";
import { useTabsSafe } from "@/shell/TabContext";
import type {
HostBackspaceMode,
HostBellStyle,
HostCursorStyle,
HostEditorForm,
HostFastScrollModifier,
} from "./HostEditorData";
const CUSTOM_FONT_OPTION = "__custom__";
export interface HostTerminalSettingsProps {
// The host editor form, as handed to a host editor section.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form: any;
setField: (key: string, value: unknown) => void;
snippets?: unknown[];
}
/** The host editor's terminal appearance and behavior cards. */
export function HostTerminalSettings({
form: rawForm,
setField: rawSetField,
snippets: rawSnippets,
}: HostTerminalSettingsProps) {
const { t } = useTranslation();
const form = rawForm as HostEditorForm;
const setField = rawSetField as <K extends keyof HostEditorForm>(
key: K,
value: HostEditorForm[K],
) => void;
const snippets = (rawSnippets ?? []) as { id: number; name: string }[];
const { setPreviewTerminalTheme } = useTabsSafe();
const [isCustomFont, setIsCustomFont] = useState(
() => !TERMINAL_FONTS.some((f) => f.value === form.fontFamily),
);
const [savedThemes, setSavedThemes] = useState<SavedCustomTheme[]>([]);
const [savingTheme, setSavingTheme] = useState(false);
useEffect(() => {
getUserPreferences()
.then((prefs) => setSavedThemes(parseCustomThemes(prefs.customThemes)))
.catch(() => {});
}, []);
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 {
const newTheme: SavedCustomTheme = {
id: `theme-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: name.trim(),
colors,
};
const updated = [...savedThemes, newTheme];
await saveUserPreferences({
customThemes: JSON.stringify(updated),
});
setSavedThemes(updated);
toast.success(t("hosts.saveGlobalThemeSuccess"));
} catch {
toast.error(t("hosts.saveGlobalThemeError"));
} finally {
setSavingTheme(false);
}
};
const handleDeleteGlobalTheme = async (id: string) => {
const updated = savedThemes.filter((theme) => theme.id !== id);
try {
await saveUserPreferences({
customThemes: JSON.stringify(updated),
});
setSavedThemes(updated);
} 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", {
defaultValue: "Use user defaults",
})}
description={t("hosts.useUserTerminalDefaultsDesc", {
defaultValue:
"Keep this host synchronized with Terminal defaults from User Profile.",
})}
>
<FakeSwitch
checked={form.inheritTerminalAppearance}
onChange={(value) => setField("inheritTerminalAppearance", value)}
/>
</SettingRow>
<fieldset
disabled={form.inheritTerminalAppearance}
className={
form.inheritTerminalAppearance
? "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 HostCursorStyle)
}
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 HostBellStyle)
}
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 HostBackspaceMode,
)
}
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.sshAgentForwardingLabel")}
description={t("hosts.sshAgentForwardingShortDesc")}
>
<FakeSwitch
checked={form.agentForwarding}
onChange={(v) => setField("agentForwarding", 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>
<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="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.environmentVariablesLabel")}
</span>
<Button
variant="outline"
size="sm"
className="h-6 text-[10px] px-2 border-accent-brand/40 text-accent-brand"
onClick={() =>
setField("environmentVariables", [
...form.environmentVariables,
{ key: "", value: "" },
])
}
>
<Plus className="size-3 mr-1" /> {t("hosts.addVariableBtn")}
</Button>
</div>
{form.environmentVariables.length === 0 && (
<p className="text-[10px] text-muted-foreground/50">
{t("hosts.noEnvVars")}
</p>
)}
<div className="flex flex-col gap-2">
{form.environmentVariables.map((ev, i) => (
<div key={i} className="flex items-center gap-2">
<Input
className="h-7 text-xs flex-1"
placeholder="KEY"
value={ev.key}
onChange={(e) => {
const updated = [...form.environmentVariables];
updated[i] = {
...updated[i],
key: e.target.value,
};
setField("environmentVariables", updated);
}}
/>
<Input
className="h-7 text-xs flex-1"
placeholder="VALUE"
value={ev.value}
onChange={(e) => {
const updated = [...form.environmentVariables];
updated[i] = {
...updated[i],
value: e.target.value,
};
setField("environmentVariables", updated);
}}
/>
<button
className="text-destructive"
onClick={() =>
setField(
"environmentVariables",
form.environmentVariables.filter((_, idx) => idx !== i),
)
}
>
<X className="size-4" />
</button>
</div>
))}
</div>
</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 HostFastScrollModifier,
)
}
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) =>
setField(
"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>
</>
);
}
-379
View File
@@ -1,379 +0,0 @@
import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Separator } from "@/components/separator";
import { KeyRound, Terminal } from "lucide-react";
import type { Tab } from "@/types/ui-types";
import { getCookie, getHostPassword, setCookie } from "@/main-axios";
import { toast } from "sonner";
export function SshToolsPanel({
terminalTabs,
activeTabId,
}: {
terminalTabs: Tab[];
activeTabId: string;
}) {
const { t } = useTranslation();
const [keyRecording, setKeyRecording] = useState(false);
const [rightClickPaste, setRightClickPaste] = useState(
() => getCookie("rightClickCopyPaste") !== "false",
);
const [copyOnSelect, setCopyOnSelect] = useState(
() => getCookie("copyOnSelect") === "true",
);
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
() =>
new Set(
activeTabId && terminalTabs.some((t) => t.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((t) => t.id)));
}
function deselectAll() {
setSelectedTabIds(new Set());
}
function broadcast(data: string) {
for (const tabId of selectedTabIds) {
const tab = terminalTabs.find((t) => t.id === tabId);
tab?.terminalRef?.current?.sendInput?.(data);
}
}
async function fillPassword() {
let filled = 0;
let missing = 0;
for (const tabId of selectedTabIds) {
const tab = terminalTabs.find((t) => t.id === tabId);
const hostId = tab?.host?.id ? Number(tab.host.id) : null;
const ref = tab?.terminalRef?.current;
if (!hostId || !ref?.sendInput) {
missing++;
continue;
}
const password = await getHostPassword(hostId, "password");
if (!password) {
missing++;
continue;
}
ref.sendInput(password + "\r");
filled++;
}
if (filled > 0) {
toast.success(
t("newUi.sidebar.sshTools.fillPasswordSuccess", { count: filled }),
);
}
if (missing > 0) {
toast.error(
t("newUi.sidebar.sshTools.fillPasswordMissing", { count: missing }),
);
}
}
function broadcastArrow(normalSeq: string, appSeq: string) {
for (const tabId of selectedTabIds) {
const tab = terminalTabs.find((t) => t.id === tabId);
const ref = tab?.terminalRef?.current;
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("newUi.sidebar.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("newUi.sidebar.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("newUi.sidebar.sshTools.selectAll")}
</button>
<button
onClick={deselectAll}
className="text-[10px] text-accent-brand hover:text-accent-brand/70"
>
{t("newUi.sidebar.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("newUi.sidebar.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}
</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("newUi.sidebar.sshTools.stopRecording")} (${selectedTabIds.size})`
: selectedTabIds.size === 0
? t("newUi.sidebar.sshTools.selectTerminalsAbove")
: `${t("newUi.sidebar.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("newUi.sidebar.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("newUi.sidebar.sshTools.selectTerminalsAbove")
: `${t("newUi.sidebar.sshTools.fillPassword")} (${selectedTabIds.size})`}
</Button>
</div>
<Separator />
<div className="flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-widest">
{t("newUi.sidebar.sshTools.settingsTitle")}
</span>
<div className="flex items-center justify-between gap-4">
<span className="text-sm text-muted-foreground">
{t("newUi.sidebar.sshTools.enableRightClickCopyPaste")}
</span>
<button
onClick={() => {
const next = !rightClickPaste;
setRightClickPaste(next);
setCookie("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("newUi.sidebar.sshTools.copyOnSelect")}
</span>
<button
onClick={() => {
const next = !copyOnSelect;
setCopyOnSelect(next);
setCookie("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>
);
}
+1 -156
View File
@@ -73,7 +73,6 @@ import { toast } from "sonner";
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
import { Select2 } from "@/components/select2";
import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences";
import { ConnectionDefaultsSettings } from "./ConnectionDefaultsSettings";
import {
FeatureSettingsSection,
featureSectionId,
@@ -197,40 +196,6 @@ function apiErrorMessage(error: unknown, fallback: string) {
return (error as ApiErrorLike).response?.data?.error || fallback;
}
// Local transfer concurrency is the file-manager plugin's own setting; this
// panel just offers the control, using the same storage key the plugin reads.
const TRANSFER_CONCURRENCY_STORAGE_KEY =
"termix:file-manager:transfer-concurrency";
const DEFAULT_TRANSFER_CONCURRENCY = 4;
const MAX_TRANSFER_CONCURRENCY = 8;
function clampTransferConcurrency(value: unknown): number {
const n = Math.floor(Number(value));
if (!Number.isFinite(n)) return DEFAULT_TRANSFER_CONCURRENCY;
return Math.min(MAX_TRANSFER_CONCURRENCY, Math.max(1, n));
}
function getTransferConcurrency(): number {
try {
const raw = localStorage.getItem(TRANSFER_CONCURRENCY_STORAGE_KEY);
return raw === null
? DEFAULT_TRANSFER_CONCURRENCY
: clampTransferConcurrency(raw);
} catch {
return DEFAULT_TRANSFER_CONCURRENCY;
}
}
function setTransferConcurrency(value: number): number {
const clamped = clampTransferConcurrency(value);
try {
localStorage.setItem(TRANSFER_CONCURRENCY_STORAGE_KEY, String(clamped));
} catch {
// storage unavailable
}
return clamped;
}
type CreatedProfileApiKey = {
id: string;
name: string;
@@ -526,7 +491,6 @@ export function UserProfilePanel({
userPrefs?: {
reopenTabsOnLogin: boolean;
storageMode?: string | null;
commandAutocomplete?: boolean | null;
commandPaletteEnabled?: boolean | null;
showHostTags?: boolean | null;
hostTrayOnClick?: boolean | null;
@@ -636,15 +600,6 @@ export function UserProfilePanel({
}, [linkedAccount, storageMode, onPrefsChange]);
// Settings toggles — all backed by localStorage
const [commandAutocomplete, setCommandAutocomplete] = useState(
() => localStorage.getItem("commandAutocomplete") === "true",
);
const [terminalLinkClickBehavior, setTerminalLinkClickBehavior] = useState(
() => localStorage.getItem("terminalLinkClickBehavior") ?? "confirm",
);
const [transferConcurrency, setTransferConcurrencyState] = useState(() =>
getTransferConcurrency(),
);
const [commandPaletteEnabled, setCommandPaletteEnabled] = useState(() => {
const v = localStorage.getItem("commandPaletteShortcutEnabled");
return v !== null ? v === "true" : true;
@@ -801,7 +756,6 @@ export function UserProfilePanel({
"termix-font-size",
"termix-ui-font",
"i18nextLng",
"commandAutocomplete",
"commandPaletteShortcutEnabled",
"showHostTags",
"hostTrayOnClick",
@@ -822,7 +776,6 @@ export function UserProfilePanel({
"dashboardTab.mainWidthPct",
"termix-terminal-toolbar-density",
"fileManagerViewMode",
TRANSFER_CONCURRENCY_STORAGE_KEY,
];
const snap: Record<string, string | null> = { __theme: theme };
for (const key of SNAPSHOT_KEYS) snap[key] = localStorage.getItem(key);
@@ -846,13 +799,6 @@ export function UserProfilePanel({
const language = await changeAppLanguage(prefs.language);
setLanguage(language);
}
if (prefs.commandAutocomplete != null) {
setCommandAutocomplete(prefs.commandAutocomplete);
localStorage.setItem(
"commandAutocomplete",
String(prefs.commandAutocomplete),
);
}
if (prefs.commandPaletteEnabled != null) {
setCommandPaletteEnabled(prefs.commandPaletteEnabled);
localStorage.setItem(
@@ -935,8 +881,6 @@ export function UserProfilePanel({
applyAccentColor(DEFAULT_ACCENT);
setLanguage("en");
void changeAppLanguage("en");
setCommandAutocomplete(false);
localStorage.setItem("commandAutocomplete", "false");
setCommandPaletteEnabled(true);
localStorage.setItem("commandPaletteShortcutEnabled", "true");
updateSidebarPrefs((prev) => ({
@@ -976,7 +920,6 @@ export function UserProfilePanel({
fontSize: "md",
accentColor: DEFAULT_ACCENT,
language: "en",
commandAutocomplete: false,
commandPaletteEnabled: true,
pinAppRail: false,
expandAppRailOnHover: true,
@@ -1032,11 +975,6 @@ export function UserProfilePanel({
setLanguage(restoredLang);
void changeAppLanguage(restoredLang);
const restoredAutocomplete =
restore("commandAutocomplete", "false") === "true";
setCommandAutocomplete(restoredAutocomplete);
localStorage.setItem("commandAutocomplete", String(restoredAutocomplete));
const restoredPalette =
restore("commandPaletteShortcutEnabled", "true") !== "false";
setCommandPaletteEnabled(restoredPalette);
@@ -1722,48 +1660,8 @@ export function UserProfilePanel({
<div className="flex flex-col gap-1 border-t border-border pt-3">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
{t("newUi.sidebar.userProfile.settingsTerminal")}
{t("newUi.sidebar.userProfile.settingsTabsAndShortcuts")}
</span>
<ConnectionDefaultsSettings />
<SettingRow
label={t("newUi.sidebar.userProfile.commandAutocomplete")}
description={t(
"newUi.sidebar.userProfile.commandAutocompleteDesc",
)}
>
<FakeSwitch
checked={commandAutocomplete}
onChange={(v) => {
setCommandAutocomplete(v);
localStorage.setItem("commandAutocomplete", v.toString());
if (storageMode === "cloud")
saveToCloud({ commandAutocomplete: v });
}}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("newUi.sidebar.userProfile.localEcho")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("newUi.sidebar.userProfile.localEchoDesc")}
</span>
</div>
<select
defaultValue={
localStorage.getItem("terminalLocalEchoMode") ?? "auto"
}
onChange={(e) =>
localStorage.setItem("terminalLocalEchoMode", e.target.value)
}
className="h-7 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="off">{t("hosts.localEchoOff")}</option>
<option value="auto">{t("hosts.localEchoAuto")}</option>
<option value="on">{t("hosts.localEchoOn")}</option>
</select>
</div>
<SettingRow
label={t("newUi.sidebar.userProfile.keyboardShortcuts")}
description={t(
@@ -1778,59 +1676,6 @@ export function UserProfilePanel({
{t("newUi.sidebar.userProfile.manageShortcuts")}
</Button>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("newUi.sidebar.userProfile.terminalLinkBehavior")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("newUi.sidebar.userProfile.terminalLinkBehaviorDesc")}
</span>
</div>
<Select2
value={terminalLinkClickBehavior}
onChange={(e) => {
setTerminalLinkClickBehavior(e.target.value);
localStorage.setItem(
"terminalLinkClickBehavior",
e.target.value,
);
}}
className="h-7 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="confirm">
{t("hosts.linkClickBehaviorConfirm")}
</option>
<option value="direct">
{t("hosts.linkClickBehaviorDirect")}
</option>
</Select2>
</div>
<div className="flex flex-col gap-1.5 py-3 border-b border-border">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("newUi.sidebar.userProfile.transferConcurrency")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("newUi.sidebar.userProfile.transferConcurrencyDesc")}
</span>
</div>
<Select2
value={transferConcurrency}
onChange={(e) =>
setTransferConcurrencyState(
setTransferConcurrency(Number(e.target.value)),
)
}
className="h-7 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{Array.from({ length: MAX_TRANSFER_CONCURRENCY }, (_, i) => (
<option key={i + 1} value={i + 1}>
{i + 1}
</option>
))}
</Select2>
</div>
<SettingRow
label={t("newUi.sidebar.userProfile.commandPalette")}
description={t("newUi.sidebar.userProfile.commandPaletteDesc")}
+6 -1
View File
@@ -40,7 +40,12 @@ const FIELD_GROUP_KEYS: Record<FieldGroup, string[]> = {
quickActions: ["quickActions"],
// Every plugin's host settings, enable switches included.
featureFlags: ["pluginSettings", "forceKeyboardInteractive"],
advanced: ["statusCheckEnabled", "statusCheckInterval", "terminalConfig"],
advanced: [
"statusCheckEnabled",
"statusCheckInterval",
"terminalConfig",
"sshOptions",
],
};
export const SECRET_KEYS = [
-10
View File
@@ -1,7 +1,6 @@
import {
Braces,
Cloud,
Hammer,
KeyRound,
Plug,
Server,
@@ -94,15 +93,6 @@ export const RAIL_ITEMS: RailItemDef[] = [
separatorAfter: true,
mobilePrimary: true,
},
{
id: "ssh-tools",
icon: Hammer,
labelKey: "nav.sshTools",
separatorAfter: true,
mobilePrimary: true,
promotable: true,
rightDockable: true,
},
{
id: "macros",
icon: Braces,
+1 -5
View File
@@ -141,11 +141,7 @@ function canCopyHostPassword(host: Host): boolean {
}
function canCopyHostSudoPassword(host: Host): boolean {
return (
!!host.hasSudoPassword ||
!!host.sudoPassword ||
!!host.terminalConfig?.sudoPassword
);
return !!host.hasSudoPassword || !!host.sudoPassword;
}
/**
+4 -1
View File
@@ -642,7 +642,10 @@ export function SidebarTree({
})),
statusCheckEnabled: host.statusCheckEnabled,
statusCheckInterval: host.statusCheckInterval,
terminalConfig: host.terminalConfig ?? null,
terminalConfig: host.terminalConfig?.startupSnippetId
? { startupSnippetId: host.terminalConfig.startupSnippetId }
: null,
sshOptions: host.sshOptions ?? null,
};
const created = await createSSHHost(duplicateHost);
// Plugin host settings live outside the host row. Secrets come back