mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 22:55: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
@@ -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
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user