* feat: shell controller, TabStage, layout and theme plugins with repair view

* feat: move the classic layout into its own bundled plugin

* feat: move color themes into a bundled themes plugin

* fix: plugin tests and type checks work with a linked sdk

* fix: home tab, layout switch and missing layout fallbacks for looks

* chore: add classic layout and themes to the readme

* chore: drop example plugin links from the plugin guide

* feat: plugin filters by type, paged browse, hide plugins needing newer termix

* feat: move desktop, status, import, audit and branding features into plugins

* feat: host lock api so a plugin can load hosts from a config file (#1581)

* chore: add hosts config to the plugin table

* feat: login policy hook for plugins and optional https redirect (#1584)

* chore: add login security to the plugin table

* feat: api key socket tickets so a master can open remote sessions (#1654)

* feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654)

* chore: add sync to the plugin table

* feat: move the sync screens and wizard out of core into the plugin (#1580)

* fix: add a neutral linked desktop hook for plugins (#1580)

* fix: show the linked account's name and admin flag in the app (#1580)

* feat: ssh transport providers, core ssh relay and plugin folders api

* feat: support FIDO2 security keys through ssh agent

* feat: add companion panels beside host tabs

* feat: mTLS client certificates for desktop server links

* feat: native web view for plugins in the desktop app

* fix: detect the desktop link window by Electron's default user agent

Fixes #1675

* fix: changelog for desktop link fix

* fix: keep text primary keys in the plugin data export

* fix: ship sharp binaries for Windows arm64 builds

* feat: client cert on the sync link types (sdk 1.0.18)

* chore: update translations

* feat: themed title bar for the desktop app (#1690)

* chore: changelog for the themed title bar

* fix: draw the title bar in the sidebar color

* feat: per host layout for the terminal panel bar

* chore: sdk 1.0.19 for the panel bar layout

* chore: bundle all 43 plugins for 26.10.3

* chore: update translations

---------

Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com>
Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-20 16:03:29 -05:00
1 parent aff29a9581
commit a89ccc6b07
577 files changed
+76479 -101831

No files matched your search

-162
View File
@@ -1,162 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ExternalLink, MonitorDown } from "lucide-react";
import { Button } from "@/components/button";
import { SectionCard, SettingRow } from "@/components/section-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import {
canConfigureUpdates,
desktopUpdates,
useDesktopUpdate,
type DesktopUpdateMode,
type DesktopUpdateState,
} from "@/lib/desktop-updates";
const MODES: DesktopUpdateMode[] = ["automatic", "prompt", "never"];
function StatusText({ state }: { state: DesktopUpdateState }) {
const { t } = useTranslation();
const version = state.version;
switch (state.status) {
case "checking":
return <>{t("desktopUpdates.statusChecking")}</>;
case "available":
return <>{t("desktopUpdates.statusAvailable", { version })}</>;
case "downloading":
return (
<>
{t("desktopUpdates.statusDownloading", {
version,
progress: state.progress ?? 0,
})}
</>
);
case "downloaded":
return <>{t("desktopUpdates.statusReady", { version })}</>;
case "up-to-date":
return <>{t("desktopUpdates.statusUpToDate")}</>;
case "error":
return <>{t("desktopUpdates.statusError")}</>;
default:
return <>{t("desktopUpdates.statusIdle")}</>;
}
}
/** Desktop only: how this install updates itself. */
export function DesktopUpdatesCard() {
const { t } = useTranslation();
const state = useDesktopUpdate();
const [busy, setBusy] = useState(false);
if (!canConfigureUpdates(state)) return null;
const bridge = desktopUpdates();
const run = async (action: () => Promise<unknown> | undefined) => {
setBusy(true);
try {
await action();
} catch {
toast.error(t("settings.updates.saveFailed"));
} finally {
setBusy(false);
}
};
const modeLabel = (mode: DesktopUpdateMode) =>
mode === "automatic"
? t("desktopUpdates.modeAutomatic")
: mode === "prompt"
? t("desktopUpdates.modePrompt")
: t("desktopUpdates.modeNever");
return (
<SectionCard
title={t("desktopUpdates.title")}
icon={<MonitorDown className="size-3.5" />}
rowId="updates-desktop"
>
<SettingRow
label={t("desktopUpdates.mode")}
description={
state.kind === "manual"
? t("desktopUpdates.manualModeHint")
: t("desktopUpdates.modeHint")
}
>
<Select
disabled={busy}
value={state.mode}
onValueChange={(next) =>
void run(() => bridge?.setMode(next as DesktopUpdateMode))
}
>
<SelectTrigger className="h-8 w-40 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODES.map((mode) => (
<SelectItem key={mode} value={mode}>
{modeLabel(mode)}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
<SettingRow
label={t("desktopUpdates.status", { version: state.currentVersion })}
description={<StatusText state={state} />}
>
{state.status === "downloaded" ? (
<Button
size="sm"
className="h-8"
onClick={() => void bridge?.install()}
>
{t("desktopUpdates.restart")}
</Button>
) : state.status === "available" && state.kind === "auto" ? (
<Button
size="sm"
className="h-8"
disabled={busy}
onClick={() => void run(() => bridge?.download())}
>
{t("desktopUpdates.download")}
</Button>
) : state.status === "available" && state.releaseUrl ? (
<Button size="sm" variant="outline" className="h-8 gap-1.5" asChild>
<a
href={state.releaseUrl}
target="_blank"
rel="noopener noreferrer"
>
{t("desktopUpdates.download")}
<ExternalLink className="size-3" />
</a>
</Button>
) : (
<Button
size="sm"
variant="outline"
className="h-8"
disabled={
busy ||
state.status === "checking" ||
state.status === "downloading"
}
onClick={() => void run(() => bridge?.check())}
>
{t("desktopUpdates.checkNow")}
</Button>
)}
</SettingRow>
</SectionCard>
);
}
-232
View File
@@ -1,232 +0,0 @@
/* eslint-disable react-refresh/only-export-components */
/**
* Manifest-declared settings shown as ordinary sections in the profile and
* admin panels, one per feature, named after it.
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { SectionCard } from "@/components/section-card";
import { AccordionSection } from "@/sidebar/AdminSettingsShared";
import { pluginKey, pluginName } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import {
getPluginAdminSettings,
getPluginUserSettings,
updatePluginAdminSettings,
updatePluginUserSettings,
PluginSettingsValidationError,
type PluginSettingsErrors,
type PluginSettingsField,
type PluginSummary,
} from "@/api/plugins-api";
import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
import { SettingsFieldRow } from "./SettingsFields";
import { hasVisibleFields, isFieldShown } from "./settings-fields-util";
export type FeatureSettingsScope = "admin" | "user";
export type FeatureSectionId = `feature:${string}`;
export function featureSectionId(pluginId: string): FeatureSectionId {
return `feature:${pluginId}`;
}
/** Enabled features with fields in this scope, sorted by name. */
export function useFeatureSettings(
scope: FeatureSettingsScope,
): PluginSummary[] {
const plugins = useInstalledPlugins();
return useMemo(
() =>
plugins
.filter(
(plugin) =>
plugin.enabled &&
hasVisibleFields(plugin.contributes?.settings?.[scope] ?? []),
)
.sort((a, b) => a.name.localeCompare(b.name)),
[plugins, scope],
);
}
export function FeatureSettingsSection({
plugin,
scope,
open,
onToggle,
}: {
plugin: PluginSummary;
scope: FeatureSettingsScope;
open: boolean;
onToggle: () => void;
}) {
const { t } = useTranslation();
return (
<AccordionSection
label={pluginName(t, plugin)}
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
open={open}
onToggle={onToggle}
>
<FeatureSettingsForm
plugin={plugin}
scope={scope}
fields={plugin.contributes?.settings?.[scope] ?? []}
/>
</AccordionSection>
);
}
export function FeatureSettingsForm({
plugin,
scope,
fields,
}: {
plugin: PluginSummary;
scope: FeatureSettingsScope;
fields: PluginSettingsField[];
}) {
const { t } = useTranslation();
const [values, setValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<PluginSettingsErrors>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const running = plugin.enabled && plugin.state !== "failed";
const load = useCallback(async () => {
setLoading(true);
try {
const loaded =
scope === "admin"
? await getPluginAdminSettings(plugin.id)
: await getPluginUserSettings(plugin.id);
setValues(loaded);
setErrors({});
setDirty(false);
} catch {
toast.error(t("settings.pluginSettingsLoadFailed"));
} finally {
setLoading(false);
}
}, [plugin.id, scope, t]);
useEffect(() => {
void load();
}, [load]);
const setValue = (key: string, value: unknown) => {
setValues((current) => ({ ...current, [key]: value }));
setErrors((current) => {
if (!current[key]) return current;
const next = { ...current };
delete next[key];
return next;
});
setDirty(true);
};
const save = async () => {
setSaving(true);
try {
const saved =
scope === "admin"
? await updatePluginAdminSettings(plugin.id, values)
: await updatePluginUserSettings(plugin.id, values);
setValues(saved);
setErrors({});
setDirty(false);
toast.success(t("settings.pluginSettingsSaved"));
} catch (error) {
if (error instanceof PluginSettingsValidationError) {
setErrors(error.errors);
toast.error(t("settings.pluginSettingsInvalid"));
} else {
toast.error(t("settings.pluginSettingsSaveFailed"));
}
} finally {
setSaving(false);
}
};
// Groups appear in the order of their first field, so the manifest sets the layout.
const groups = useMemo(() => {
const ordered: { key?: string; fields: PluginSettingsField[] }[] = [];
for (const field of fields) {
const existing = ordered.find((group) => group.key === field.group);
if (existing) existing.fields.push(field);
else ordered.push({ key: field.group, fields: [field] });
}
return ordered;
}, [fields]);
if (loading) {
return (
<div className="flex items-center gap-2 py-3 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
{t("common.loading")}
</div>
);
}
const rows = (group: PluginSettingsField[]) =>
group
.filter((field) => isFieldShown(field, values))
.map((field) => (
<SettingsFieldRow
key={field.key}
pluginId={plugin.id}
field={field}
values={values}
setValue={setValue}
running={running}
error={errors[field.key]}
/>
));
const icon = <PluginIcon name={plugin.icon} className="size-3.5" />;
return (
<div className="flex flex-col gap-2 pt-2">
{!running && (
<div className="border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
{t("settings.featureUnavailable", {
name: pluginName(t, plugin),
})}
</div>
)}
{groups.map((group, index) =>
group.key ? (
<SectionCard
key={group.key}
title={t(pluginKey(plugin.id, group.key))}
icon={icon}
>
{rows(group.fields)}
</SectionCard>
) : (
<div key={`group-${index}`}>{rows(group.fields)}</div>
),
)}
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
className="h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={!dirty || saving}
onClick={() => void save()}
>
{saving && <Loader2 className="size-3 animate-spin mr-1.5" />}
{t("common.save")}
</Button>
</div>
</div>
);
}
-478
View File
@@ -1,478 +0,0 @@
import { Select2 } from "@/components/select2";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getUserPreferences,
parseCustomKeybindings,
} from "@/api/open-tabs-api";
import { saveUserPreferences } from "@/main-axios";
import {
useKeybindingActions,
useKeybindingDefaults,
type KeybindingActionDef,
} from "@/shell/keybinding-registry";
import type {
CustomKeybinding,
KeybindingAction,
KeyCombo,
} from "@/types/keybindings";
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import { GroupHeading } from "@/components/panel-layout";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Keyboard, Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
import { FixedShortcutsList } from "@/sidebar/FixedShortcutsList";
function formatCombo(combo: KeyCombo): string {
const parts: string[] = [];
if (combo.ctrl) parts.push("Ctrl");
if (combo.alt) parts.push("Alt");
if (combo.shift) parts.push("Shift");
if (combo.meta) parts.push("Cmd");
parts.push(combo.key.length === 1 ? combo.key.toUpperCase() : combo.key);
return parts.join(" + ");
}
function generateId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `kb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
/** An action without the keys an editor cleared, so it saves clean. */
function withoutEmpty(action: KeybindingAction): KeybindingAction {
const out: KeybindingAction = { type: action.type };
for (const [key, value] of Object.entries(action)) {
if (key !== "type" && value !== undefined && value !== null) {
out[key] = value;
}
}
return out;
}
/**
* Settings > Keybindings. The actions come from the keybinding registry:
* the shell's own and whatever the running plugins register, each with its
* own parameter editor. A saved binding whose plugin is off stays in the
* list and keeps working once the plugin is back.
*/
export function KeybindingsSettings() {
const { t } = useTranslation();
const confirm = useConfirm();
const actions = useKeybindingActions();
const defaults = useKeybindingDefaults();
const [bindings, setBindings] = useState<CustomKeybinding[]>([]);
const [loading, setLoading] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [overridesDefaultId, setOverridesDefaultId] = useState<
string | undefined
>(undefined);
const [recordedCombo, setRecordedCombo] = useState<KeyCombo | null>(null);
const [recording, setRecording] = useState(false);
const [draft, setDraft] = useState<KeybindingAction>({ type: "" });
const actionById = useMemo(
() => new Map(actions.map((action) => [action.id, action])),
[actions],
);
const draftDef: KeybindingActionDef | undefined = actionById.get(draft.type);
useEffect(() => {
setLoading(true);
getUserPreferences()
.then((prefs) =>
setBindings(parseCustomKeybindings(prefs.customKeybindings)),
)
.catch(() => setBindings([]))
.finally(() => setLoading(false));
}, []);
const conflictWarning = useMemo(() => {
if (!recordedCombo) return null;
const conflict = bindings.find(
(kb) =>
kb.id !== editingId &&
kb.enabled &&
kb.combo.ctrl === recordedCombo.ctrl &&
kb.combo.alt === recordedCombo.alt &&
kb.combo.shift === recordedCombo.shift &&
kb.combo.meta === recordedCombo.meta &&
kb.combo.key === recordedCombo.key,
);
if (conflict) {
return t("newUi.sidebar.keybindings.conflictWarning", {
combo: formatCombo(conflict.combo),
});
}
return null;
}, [recordedCombo, bindings, editingId, t]);
function actionLabel(type: string): string {
const def = actionById.get(type);
return def
? t(def.labelKey)
: t("newUi.sidebar.keybindings.unavailableAction", { type });
}
function resetForm() {
setEditingId(null);
setOverridesDefaultId(undefined);
setRecordedCombo(null);
setRecording(false);
setDraft({ type: actions[0]?.id ?? "" });
}
function openAddForm() {
resetForm();
setFormOpen(true);
}
function openEditForm(kb: CustomKeybinding) {
setEditingId(kb.id);
setOverridesDefaultId(kb.overridesDefaultId);
setRecordedCombo(kb.combo);
setRecording(false);
setDraft({ ...kb.action });
setFormOpen(true);
}
function openOverrideDefaultForm(defaultId: string, combo: KeyCombo) {
resetForm();
setOverridesDefaultId(defaultId);
setRecordedCombo(combo);
setFormOpen(true);
}
async function persist(updated: CustomKeybinding[]) {
try {
await saveUserPreferences({
customKeybindings: JSON.stringify(updated),
});
setBindings(updated);
window.dispatchEvent(new Event("customKeybindingsChanged"));
} catch {
toast.error(t("newUi.sidebar.keybindings.saveError"));
}
}
async function handleDelete(id: string) {
const ok = await confirm({
title: t("newUi.sidebar.keybindings.deleteConfirm"),
});
if (ok) persist(bindings.filter((kb) => kb.id !== id));
}
async function handleResetAll() {
const ok = await confirm({
title: t("newUi.sidebar.keybindings.resetAllConfirm"),
confirmLabel: t("newUi.sidebar.keybindings.resetAllToDefaults"),
});
if (ok) persist([]);
}
function startRecording(inputEl: HTMLInputElement) {
setRecording(true);
const handler = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) {
return;
}
const isAlnum = /^[a-zA-Z0-9]$/.test(e.key);
const combo: KeyCombo = {
key: isAlnum ? e.key.toLowerCase() : e.code,
isCode: !isAlnum,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
meta: e.metaKey,
};
setRecordedCombo(combo);
setRecording(false);
inputEl.removeEventListener("keydown", handler, true);
inputEl.blur();
};
inputEl.addEventListener("keydown", handler, true);
}
function handleSave() {
if (!recordedCombo) {
toast.error(t("newUi.sidebar.keybindings.comboRequiredError"));
return;
}
if (!draftDef) {
toast.error(t("newUi.sidebar.keybindings.actionRequiredError"));
return;
}
const action = withoutEmpty(draft);
const problem = draftDef.validate?.(action);
if (problem) {
toast.error(t(problem));
return;
}
const now = new Date().toISOString();
const existing = editingId
? bindings.find((kb) => kb.id === editingId)
: undefined;
const newBinding: CustomKeybinding = {
id: editingId ?? generateId(),
combo: recordedCombo,
action,
enabled: true,
overridesDefaultId,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
const updated = editingId
? bindings.map((kb) => (kb.id === editingId ? newBinding : kb))
: [...bindings, newBinding];
persist(updated);
setFormOpen(false);
resetForm();
}
const customOnlyBindings = bindings.filter((kb) => !kb.overridesDefaultId);
const Editor = draftDef?.editor;
return (
<>
<div className="flex flex-col gap-4 border border-border bg-card p-3">
<p className="text-xs text-muted-foreground">
{t("newUi.sidebar.keybindings.description")}
</p>
{defaults.length > 0 && (
<div className="flex flex-col gap-1.5">
<GroupHeading
title={t("newUi.sidebar.keybindings.defaultsHeading")}
/>
{defaults.map((def) => {
const override = bindings.find(
(kb) => kb.overridesDefaultId === def.id,
);
return (
<div
key={def.id}
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-mono">
{override
? formatCombo(override.combo)
: formatCombo(def.combo)}
</span>
<span className="text-xs text-muted-foreground truncate">
{t(def.descriptionKey)}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{override ? (
<>
<span className="text-[10px] font-bold text-accent-brand border border-accent-brand/40 px-1">
{t("newUi.sidebar.keybindings.customizedBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => openEditForm(override)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => void handleDelete(override.id)}
title={t("newUi.sidebar.keybindings.resetToDefault")}
>
<RotateCcw className="size-3.5" />
</Button>
</>
) : (
<>
<span className="text-[10px] text-muted-foreground border border-border px-1">
{t("newUi.sidebar.keybindings.defaultBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() =>
openOverrideDefaultForm(def.id, def.combo)
}
title={t("newUi.sidebar.keybindings.customize")}
>
<Pencil className="size-3.5" />
</Button>
</>
)}
</div>
</div>
);
})}
</div>
)}
<div className="flex flex-col gap-1.5">
<GroupHeading
title={t("newUi.sidebar.keybindings.customHeading")}
action={
<Button variant="outline" size="sm" onClick={openAddForm}>
<Plus className="size-3.5 mr-1" />
{t("newUi.sidebar.keybindings.addBinding")}
</Button>
}
/>
{loading ? (
<span className="text-xs text-muted-foreground">
{t("newUi.sidebar.keybindings.loading")}
</span>
) : customOnlyBindings.length === 0 ? (
<span className="text-xs text-muted-foreground/60">
{t("newUi.sidebar.keybindings.noCustomBindings")}
</span>
) : (
customOnlyBindings.map((kb) => {
const Summary = actionById.get(kb.action.type)?.summary;
return (
<div
key={kb.id}
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-mono">
{formatCombo(kb.combo)}
</span>
<span className="text-xs text-muted-foreground truncate">
{actionLabel(kb.action.type)}
{Summary && (
<>
{" "}
<Summary action={kb.action} onChange={() => {}} />
</>
)}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => openEditForm(kb)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => void handleDelete(kb.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
);
})
)}
</div>
<FixedShortcutsList />
<Button
variant="ghost"
size="sm"
className="self-start text-muted-foreground"
onClick={() => void handleResetAll()}
disabled={bindings.length === 0}
>
<RotateCcw className="size-3.5 mr-1" />
{t("newUi.sidebar.keybindings.resetAllToDefaults")}
</Button>
</div>
<InlineView
open={formOpen}
onOpenChange={setFormOpen}
icon={<Keyboard className="size-4" />}
title={
editingId || overridesDefaultId
? t("newUi.sidebar.keybindings.editBindingTitle")
: t("newUi.sidebar.keybindings.addBindingTitle")
}
footer={
<div className="ml-auto flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setFormOpen(false)}
>
{t("newUi.sidebar.keybindings.cancel")}
</Button>
<Button
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSave}
>
{t("newUi.sidebar.keybindings.saveBinding")}
</Button>
</div>
}
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.pressKeysToRecord")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
data-keybinding-recorder
readOnly
value={
recording
? t("newUi.sidebar.keybindings.recording")
: recordedCombo
? formatCombo(recordedCombo)
: ""
}
placeholder={t("newUi.sidebar.keybindings.pressKeysPlaceholder")}
onFocus={(e) => startRecording(e.currentTarget)}
className="font-mono cursor-pointer"
/>
{conflictWarning && (
<span className="text-xs text-warning">{conflictWarning}</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.actionLabel")}
</label>
<Select2
value={draft.type}
onChange={(e) => setDraft({ type: e.target.value })}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{!draftDef && draft.type && (
<option value={draft.type}>{actionLabel(draft.type)}</option>
)}
{actions.map((action) => (
<option key={action.id} value={action.id}>
{t(action.labelKey)}
</option>
))}
</Select2>
</div>
{Editor && <Editor action={draft} onChange={setDraft} />}
</div>
</InlineView>
</>
);
}
-429
View File
@@ -1,429 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
Bug,
ChevronDown,
ExternalLink,
Search,
Settings as SettingsIcon,
} from "lucide-react";
import {
GroupHeading,
PANEL,
PanelSearch,
PanelShell,
} from "@/components/panel-layout";
import { SurfaceScope } from "@/components/surface/surface-scope";
import { EmptyState } from "@/components/empty-state";
import { isElectron } from "@/lib/electron";
import { reportCoreIssueUrl } from "@/lib/issue-url";
import { cn } from "@/lib/utils";
import { pluginKey, pluginName } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import type { Host } from "@/types/ui-types";
import type { UserPreferences } from "@/main-axios";
import { UserProfilePanel } from "@/sidebar/UserProfilePanel";
import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel";
import {
FeatureSettingsForm,
useFeatureSettings,
} from "./FeatureSettingsSections";
import { KeybindingsSettings } from "./KeybindingsSettings";
import { UpdatesSettings } from "./UpdatesSettings";
import { SettingsEmbedContext } from "./settings-embed";
import { useSettingsPageFilter } from "./page-filter";
import {
CORE_SETTINGS_PAGES,
SETTINGS_BAND_ORDER,
searchSettingsPages,
visibleSettingsPages,
type SettingsBand,
type SettingsPage,
} from "./settings-pages";
import { usePluginDocsUrl } from "@/components/docs-link";
import { docsUrl } from "@/lib/docs";
export interface SettingsTabProps {
/** The page to open, from the tab's data. */
section?: string;
/** Changes on every open request, so asking for the same page again still goes there. */
sectionAt?: string | number;
/** An element id on that page to scroll to, as "id@nonce". */
reveal?: string;
username: string;
isAdmin: boolean;
onLogout?: () => void;
userPrefs?: UserPreferences;
onPrefsChange?: (prefs: Partial<UserPreferences>) => void;
onOpenHostTab?: (host: Host) => void;
}
const BAND_LABEL: Record<SettingsBand, string> = {
you: "settings.bandYou",
app: "settings.bandApp",
plugins: "settings.bandPlugins",
instance: "settings.bandInstance",
};
/** Scrolls to the first text on the page containing the query and marks it. */
function revealText(container: HTMLElement, needle: string): boolean {
const lower = needle.toLowerCase();
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node) {
if (node.textContent?.toLowerCase().includes(lower)) {
const el = node.parentElement;
if (el && el.offsetParent !== null) {
el.scrollIntoView?.({ block: "center", behavior: "smooth" });
el.classList.add("settings-search-hit");
window.setTimeout(
() => el.classList.remove("settings-search-hit"),
1600,
);
return true;
}
}
node = walker.nextNode();
}
return false;
}
/** Everything that configures the app, one page at a time. */
export function SettingsTab({
section,
sectionAt,
reveal,
username,
isAdmin,
onLogout,
userPrefs,
onPrefsChange,
onOpenHostTab,
}: SettingsTabProps) {
const { t } = useTranslation();
const userFeatures = useFeatureSettings("user");
const adminFeatures = useFeatureSettings("admin");
const [query, setQuery] = useState("");
const [navOpen, setNavOpen] = useState(false);
const [pageQuery, setPageQuery] = useState("");
const contentRef = useRef<HTMLDivElement>(null);
const pageRef = useRef<HTMLDivElement>(null);
const pages = useMemo(() => {
const plugins = new Map<string, SettingsPage>();
for (const plugin of [...userFeatures, ...(isAdmin ? adminFeatures : [])]) {
if (plugins.has(plugin.id)) continue;
const fields = [
...(plugin.contributes?.settings?.user ?? []),
...(isAdmin ? (plugin.contributes?.settings?.admin ?? []) : []),
];
plugins.set(plugin.id, {
id: `plugin:${plugin.id}`,
band: "plugins",
labelKey: pluginKey(plugin.id, "plugin.name"),
label: pluginName(t, plugin),
icon: SettingsIcon,
pluginId: plugin.id,
searchKeys: fields.flatMap((field) =>
[field.labelKey, field.descriptionKey]
.filter((key): key is string => !!key)
.map((key) => pluginKey(plugin.id, key)),
),
});
}
return visibleSettingsPages(
[
...CORE_SETTINGS_PAGES,
...[...plugins.values()].sort((a, b) =>
(a.label ?? "").localeCompare(b.label ?? ""),
),
],
{ isAdmin, isElectron: isElectron() },
);
}, [userFeatures, adminFeatures, isAdmin, t]);
const [current, setCurrent] = useState(section ?? "account");
useEffect(() => {
if (section) setCurrent(section);
}, [section, sectionAt]);
// The page renders lazily, so the target may take a moment to appear.
useEffect(() => {
const target = reveal?.split("@")[0];
if (!target) return;
let tries = 0;
let timer = 0;
const attempt = () => {
const el = contentRef.current?.querySelector<HTMLElement>(
`#${CSS.escape(target)}`,
);
if (el) {
el.scrollIntoView?.({ block: "start", behavior: "smooth" });
el.classList.add("settings-search-hit");
timer = window.setTimeout(
() => el.classList.remove("settings-search-hit"),
1600,
);
return;
}
if (++tries < 15) timer = window.setTimeout(attempt, 120);
};
timer = window.setTimeout(attempt, 60);
return () => window.clearTimeout(timer);
}, [reveal]);
const page =
pages.find((p) => p.id === current) ??
pages.find((p) => p.id === "account")!;
const pageHasMatches = useSettingsPageFilter(pageRef, pageQuery, page.id);
const hits = searchSettingsPages(pages, query, (key) => t(key));
const pageLabel = (p: SettingsPage) => p.label ?? t(p.labelKey);
const go = (id: string) => {
setCurrent(id);
setNavOpen(false);
setPageQuery("");
contentRef.current?.scrollTo?.({ top: 0 });
const needle = query.trim();
if (!needle) return;
let tries = 0;
const attempt = () => {
if (!contentRef.current) return;
if (revealText(contentRef.current, needle) || ++tries > 12) return;
window.setTimeout(attempt, 150);
};
window.setTimeout(attempt, 60);
};
const pluginDocs = usePluginDocsUrl(page.pluginId);
const pageDocs = page.pluginId
? pluginDocs
: page.docs
? docsUrl(page.docs)
: null;
const pluginFeature = page.pluginId
? {
user: userFeatures.find((p) => p.id === page.pluginId),
admin: isAdmin
? adminFeatures.find((p) => p.id === page.pluginId)
: undefined,
}
: null;
const nav = (
<div className="flex flex-col gap-3 py-2.5">
<div className="px-2.5">
<PanelSearch
value={query}
onChange={setQuery}
placeholder={t("settings.searchPlaceholder")}
fill
/>
</div>
{query.trim() && hits.length === 0 && (
<p className="px-3 text-xs text-muted-foreground">
{t("settings.noResults")}
</p>
)}
{SETTINGS_BAND_ORDER.map((band) => {
const items = hits.filter((hit) => hit.page.band === band);
const reportBug =
band === "you" &&
(!query.trim() ||
t("dashboard.reportBug")
.toLowerCase()
.includes(query.trim().toLowerCase()));
if (items.length === 0 && !reportBug) return null;
return (
<div key={band} className="flex flex-col">
<GroupHeading
title={t(BAND_LABEL[band])}
className="px-2.5 pb-1.5"
/>
{items.map(({ page: item, match }) => {
const on = item.id === page.id;
const Icon = item.icon;
return (
<button
key={item.id}
type="button"
onClick={() => go(item.id)}
aria-current={on ? "page" : undefined}
className={cn(
"flex w-full flex-col border-l-2 py-1.5 pl-2 pr-2 text-left transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
on
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<span className="flex items-center gap-2">
{item.pluginId ? (
<PluginIcon
name={
[...userFeatures, ...adminFeatures].find(
(p) => p.id === item.pluginId,
)?.icon
}
className="size-3.5 shrink-0"
/>
) : (
<Icon className="size-3.5 shrink-0" />
)}
<span className="truncate text-xs font-medium">
{pageLabel(item)}
</span>
</span>
{match && (
<span className="truncate pl-5.5 text-[10px] text-muted-foreground">
{match}
</span>
)}
</button>
);
})}
{reportBug && (
<a
href={reportCoreIssueUrl(
import.meta.env.VITE_APP_VERSION || undefined,
)}
target="_blank"
rel="noopener noreferrer"
className="flex w-full items-center gap-2 border-l-2 border-transparent py-1.5 pl-2 pr-2 text-left text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring"
>
<Bug className="size-3.5 shrink-0" />
<span className="truncate text-xs font-medium">
{t("dashboard.reportBug")}
</span>
<ExternalLink className="ml-auto size-3 shrink-0" />
</a>
)}
</div>
);
})}
</div>
);
const showProfile = !!page.profileSection && page.id !== "keybindings";
const showAdmin = !!page.adminSection;
return (
<PanelShell
icon={<SettingsIcon className="size-4" />}
title={t("nav.settings")}
status={pageLabel(page)}
scroll={false}
docs={pageDocs}
actions={
<button
type="button"
onClick={() => setNavOpen((v) => !v)}
aria-expanded={navOpen}
className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground hover:text-foreground md:hidden"
>
{t("settings.pages")}
<ChevronDown
className={cn(
"size-3.5 transition-transform",
navOpen && "rotate-180",
)}
/>
</button>
}
>
<div className="relative flex min-h-0 flex-1">
<nav
aria-label={t("settings.pages")}
className={cn(
"w-full shrink-0 overflow-y-auto border-r border-border bg-background thin-scrollbar md:static md:block md:w-60",
navOpen ? "absolute inset-0 z-20 block" : "hidden",
)}
>
{nav}
</nav>
<SurfaceScope kind="tab" className="min-w-0">
<div className={`shrink-0 border-b border-border ${PANEL.band}`}>
<div className="mx-auto w-full max-w-3xl">
<PanelSearch
value={pageQuery}
onChange={setPageQuery}
placeholder={t("settings.searchPage")}
fill
/>
</div>
</div>
<div
ref={contentRef}
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
>
{!pageHasMatches && (
<EmptyState icon={Search} title={t("settings.noPageMatches")} />
)}
<div
ref={pageRef}
className={`mx-auto flex w-full max-w-3xl flex-col ${PANEL.gap} pb-6`}
>
<SettingsEmbedContext.Provider value>
<div className={showProfile ? "contents" : "hidden"}>
<UserProfilePanel
section={page.profileSection ?? "__none__"}
username={username}
onLogout={onLogout}
userPrefs={userPrefs}
onPrefsChange={onPrefsChange}
onOpenSettingsPage={go}
/>
</div>
{isAdmin && (
<div className={showAdmin ? "contents" : "hidden"}>
<AdminSettingsPanel
section={page.adminSection ?? ("__none__" as never)}
onOpenHostTab={onOpenHostTab}
/>
</div>
)}
</SettingsEmbedContext.Provider>
{page.id === "keybindings" && <KeybindingsSettings />}
{page.id === "updates" && <UpdatesSettings isAdmin={isAdmin} />}
{pluginFeature && (
<>
{pluginFeature.user && (
<FeatureSettingsForm
plugin={pluginFeature.user}
scope="user"
fields={
pluginFeature.user.contributes?.settings?.user ?? []
}
/>
)}
{pluginFeature.admin && (
<>
<GroupHeading
title={t("settings.instanceSettings")}
className="pt-2"
/>
<FeatureSettingsForm
plugin={pluginFeature.admin}
scope="admin"
fields={
pluginFeature.admin.contributes?.settings?.admin ?? []
}
/>
</>
)}
{!pluginFeature.user && !pluginFeature.admin && (
<EmptyState
icon={SettingsIcon}
title={t("settings.nothingHere")}
/>
)}
</>
)}
</div>
</div>
</SurfaceScope>
</div>
</PanelShell>
);
}
-330
View File
@@ -1,330 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Copy, ExternalLink, FlaskConical, Puzzle, Tag } from "lucide-react";
import { Button } from "@/components/button";
import { FakeSwitch, SectionCard, SettingRow } from "@/components/section-card";
import {
getChannelReleases,
getUpdateChannel,
setUpdateChannel,
type ReleaseInfo,
type UpdateChannelState,
} from "@/api/system-status-api";
import {
getPlugins,
setAllPluginChannels,
setPluginOptions,
type PluginSummary,
} from "@/api/plugins-api";
import { reportBetaFeedbackUrl } from "@/lib/issue-url";
import { isElectron } from "@/lib/electron";
import { isPrerelease } from "@/plugins/plugin-model";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
import { docsUrl } from "@/lib/docs";
import { pluginName } from "@/lib/plugin-i18n";
import { DesktopUpdatesCard } from "./DesktopUpdatesCard";
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
function onBeta(plugin: PluginSummary): boolean {
return plugin.channel === "beta" || isPrerelease(plugin.version);
}
function ReleaseRow({
label,
release,
}: {
label: string;
release: ReleaseInfo | null;
}) {
const { t } = useTranslation();
return (
<SettingRow
label={label}
description={
release
? new Date(release.publishedAt).toLocaleDateString()
: t("settings.updates.noRelease")
}
>
{release && (
<a
href={release.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs font-medium text-accent-brand hover:underline"
>
v{release.version}
<ExternalLink className="size-3" />
</a>
)}
</SettingRow>
);
}
function InstanceUpdates() {
const { t } = useTranslation();
const [channel, setChannel] = useState<UpdateChannelState | null>(null);
const [releases, setReleases] = useState<{
stable: ReleaseInfo | null;
beta: ReleaseInfo | null;
} | null>(null);
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
const [busy, setBusy] = useState(false);
const version = import.meta.env.VITE_APP_VERSION || undefined;
const loadPlugins = useCallback(async () => {
try {
setPlugins(await getPlugins());
} catch {
setPlugins([]);
}
}, []);
useEffect(() => {
getUpdateChannel()
.then(setChannel)
.catch(() => setChannel(null));
getChannelReleases()
.then(setReleases)
.catch(() => setReleases({ stable: null, beta: null }));
void loadPlugins();
}, [loadPlugins]);
const betaPlugins = plugins.filter(onBeta);
const runningBetas = plugins
.filter((plugin) => isPrerelease(plugin.version))
.map((plugin) => ({ id: plugin.id, version: plugin.version }));
const joined = channel?.channel === "beta";
const changeChannel = async (next: boolean) => {
setBusy(true);
try {
setChannel(await setUpdateChannel(next ? "beta" : "stable"));
} catch {
toast.error(t("settings.updates.saveFailed"));
} finally {
setBusy(false);
}
};
const changePlugins = async (action: () => Promise<unknown>) => {
setBusy(true);
try {
await action();
} catch {
toast.error(t("settings.updates.saveFailed"));
} finally {
await loadPlugins();
setBusy(false);
}
};
const copyImage = async () => {
try {
await navigator.clipboard.writeText(DOCKER_BETA_IMAGE);
toast.success(t("settings.updates.copied"));
} catch {
toast.error(t("settings.updates.copyFailed"));
}
};
return (
<div className="flex flex-col gap-3">
<SectionCard
title={t("settings.updates.betaProgram")}
icon={<FlaskConical className="size-3.5" />}
rowId="updates-beta"
>
<p className="py-3 text-xs leading-relaxed text-muted-foreground">
{t("settings.updates.betaIntro")}
</p>
<SettingRow
label={t("settings.updates.termixBetas")}
description={
channel?.runningBeta
? t("settings.updates.runningBetaBuild")
: t("settings.updates.termixBetasHint")
}
>
<FakeSwitch
checked={joined}
disabled={busy || !channel || channel.runningBeta}
onChange={(next) => void changeChannel(next)}
/>
</SettingRow>
{joined && !channel?.runningBeta && (
<div className="flex flex-col gap-2 py-3 text-xs">
<span className="font-medium">
{t("settings.updates.howToInstall")}
</span>
{isElectron() ? (
<span className="text-muted-foreground">
{t("settings.updates.desktopInstall")}{" "}
{releases?.beta && (
<a
href={releases.beta.url}
target="_blank"
rel="noopener noreferrer"
className="text-accent-brand hover:underline"
>
{t("settings.updates.downloadBeta", {
version: releases.beta.version,
})}
</a>
)}
</span>
) : (
<>
<span className="text-muted-foreground">
{t("settings.updates.dockerInstall")}
</span>
<div className="flex items-center gap-2">
<code className="flex-1 truncate border border-border bg-muted/40 px-2 py-1.5 font-mono text-[11px]">
{DOCKER_BETA_IMAGE}
</code>
<Button
size="sm"
variant="outline"
className="h-8 gap-1.5"
onClick={() => void copyImage()}
>
<Copy className="size-3.5" />
{t("settings.updates.copy")}
</Button>
</div>
</>
)}
<a
href={docsUrl("betas")}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-accent-brand hover:underline"
>
{t("settings.updates.learnMore")}
<ExternalLink className="size-3" />
</a>
</div>
)}
<SettingRow
label={t("settings.updates.feedback")}
description={t("settings.updates.feedbackHint")}
>
<Button size="sm" variant="outline" className="h-8 gap-1.5" asChild>
<a
href={reportBetaFeedbackUrl(version, runningBetas)}
target="_blank"
rel="noopener noreferrer"
>
{t("settings.updates.sendFeedback")}
</a>
</Button>
</SettingRow>
</SectionCard>
<SectionCard
title={t("settings.updates.releases")}
icon={<Tag className="size-3.5" />}
rowId="updates-releases"
>
<SettingRow label={t("settings.updates.thisVersion")}>
<span className="text-xs font-medium">
{version ? `v${version}` : "-"}
</span>
</SettingRow>
<ReleaseRow
label={t("settings.updates.newestStable")}
release={releases?.stable ?? null}
/>
<ReleaseRow
label={t("settings.updates.newestBeta")}
release={releases?.beta ?? null}
/>
</SectionCard>
<SectionCard
title={t("settings.updates.pluginBetas")}
icon={<Puzzle className="size-3.5" />}
rowId="updates-plugins"
>
<p className="py-3 text-xs leading-relaxed text-muted-foreground">
{t("settings.updates.pluginBetasIntro")}{" "}
<button
type="button"
className="text-accent-brand hover:underline"
onClick={() =>
window.dispatchEvent(new CustomEvent(OPEN_PLUGINS_EVENT))
}
>
{t("settings.updates.openPlugins")}
</button>
</p>
{betaPlugins.map((plugin) => (
<SettingRow
key={plugin.id}
label={pluginName(t, plugin)}
description={
plugin.channel === "beta"
? `v${plugin.version}`
: t("settings.updates.waitingForStable", {
version: plugin.version,
})
}
>
{plugin.channel === "beta" && (
<Button
size="sm"
variant="outline"
className="h-8"
disabled={busy}
onClick={() =>
void changePlugins(() =>
setPluginOptions(plugin.id, { channel: "stable" }),
)
}
>
{t("settings.updates.backToStable")}
</Button>
)}
</SettingRow>
))}
{plugins.length > 0 && (
<div className="flex flex-wrap gap-2 py-3">
<Button
size="sm"
variant="outline"
className="h-8"
disabled={busy}
onClick={() =>
void changePlugins(() => setAllPluginChannels("beta"))
}
>
{t("settings.updates.allPluginsBeta")}
</Button>
<Button
size="sm"
variant="outline"
className="h-8"
disabled={busy || betaPlugins.length === 0}
onClick={() =>
void changePlugins(() => setAllPluginChannels("stable"))
}
>
{t("settings.updates.allPluginsStable")}
</Button>
</div>
)}
</SectionCard>
</div>
);
}
export function UpdatesSettings({ isAdmin }: { isAdmin: boolean }) {
return (
<div className="flex flex-col gap-3">
<DesktopUpdatesCard />
{isAdmin && <InstanceUpdates />}
</div>
);
}
@@ -17,50 +17,55 @@ import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
import { LANGUAGES } from "@/i18n/languages";
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types";
import {
BUILTIN_THEMES,
SYSTEM_THEME,
themeLabel,
useThemes,
type ThemeSwatch,
} from "@/look/theme-registry";
import { useChooseTheme } from "@/look/use-look-theme";
export const DEFAULT_ACCENT = "#f59145";
/** The built-in themes, with System first. Plugin themes come from useThemeOptions. */
export const THEMES: { id: ThemeId; preview: string }[] = [
{ id: "system", preview: "auto" },
{ id: "light", preview: "#ffffff" },
{ id: "dark", preview: "#1a1c22" },
{ id: "dracula", preview: "#282a36" },
{ id: "catppuccin", preview: "#1e1e2e" },
{ id: "nord", preview: "#2e3440" },
{ id: "solarized", preview: "#002b36" },
{ id: "tokyo-night", preview: "#1a1b26" },
{ id: "one-dark", preview: "#282c34" },
{ id: "gruvbox", preview: "#282828" },
{ id: SYSTEM_THEME, preview: "auto" },
...BUILTIN_THEMES.map((theme) => ({
id: theme.id,
preview: theme.swatch.background,
})),
];
/** Background, panel and text colours for the little theme previews. */
const THEME_PALETTES: Record<
Exclude<ThemeId, "system">,
{ bg: string; panel: string; text: string }
> = {
light: { bg: "#ffffff", panel: "#eef0f3", text: "#1f2937" },
dark: { bg: "#1a1c22", panel: "#25272e", text: "#e5e7eb" },
dracula: { bg: "#282a36", panel: "#44475a", text: "#f8f8f2" },
catppuccin: { bg: "#1e1e2e", panel: "#313244", text: "#cdd6f4" },
nord: { bg: "#2e3440", panel: "#3b4252", text: "#eceff4" },
solarized: { bg: "#002b36", panel: "#073642", text: "#93a1a1" },
"tokyo-night": { bg: "#1a1b26", panel: "#24283b", text: "#c0caf5" },
"one-dark": { bg: "#282c34", panel: "#21252b", text: "#abb2bf" },
gruvbox: { bg: "#282828", panel: "#3c3836", text: "#ebdbb2" },
};
export interface ThemeOption {
id: ThemeId;
label: string;
/** Null for System, which previews light and dark side by side. */
swatch: ThemeSwatch | null;
}
export const THEME_LABEL_KEYS: Record<ThemeId, string> = {
system: "newUi.sidebar.userProfile.themeSystem",
light: "newUi.sidebar.userProfile.themeLight",
dark: "newUi.sidebar.userProfile.themeDark",
dracula: "newUi.sidebar.userProfile.themeDracula",
catppuccin: "newUi.sidebar.userProfile.themeCatppuccin",
nord: "newUi.sidebar.userProfile.themeNord",
solarized: "newUi.sidebar.userProfile.themeSolarized",
"tokyo-night": "newUi.sidebar.userProfile.themeTokyoNight",
"one-dark": "newUi.sidebar.userProfile.themeOneDark",
gruvbox: "newUi.sidebar.userProfile.themeGruvbox",
};
/** Every theme to pick from, System first, plugin themes included. */
export function useThemeOptions(): ThemeOption[] {
const { t } = useTranslation();
const themes = useThemes();
return [
{
id: SYSTEM_THEME,
label: t("newUi.sidebar.userProfile.themeSystem"),
swatch: null,
},
...themes.map((theme) => ({
id: theme.id,
label: themeLabel(theme, t),
swatch: theme.swatch,
})),
];
}
const LIGHT_SWATCH = BUILTIN_THEMES.find(
(theme) => theme.id === "light",
)!.swatch;
const DARK_SWATCH = BUILTIN_THEMES.find((theme) => theme.id === "dark")!.swatch;
export function isHexColor(value: string): boolean {
return /^#[0-9a-fA-F]{6}$/.test(value) || /^#[0-9a-fA-F]{3}$/.test(value);
@@ -83,7 +88,8 @@ async function saveIfCloud(prefs: CloudPrefs): Promise<void> {
* way the profile settings store them.
*/
export function useAppearanceSettings() {
const { theme, setTheme } = useTheme();
const { theme } = useTheme();
const chooseTheme = useChooseTheme();
const [accent, setAccentState] = useState(
() => localStorage.getItem("termix-accent") ?? DEFAULT_ACCENT,
);
@@ -105,8 +111,7 @@ export function useAppearanceSettings() {
uiFont,
language,
setTheme(id: ThemeId) {
setTheme(id);
void saveIfCloud({ theme: id });
chooseTheme(id);
},
setAccent(value: string) {
setAccentState(value);
@@ -134,37 +139,29 @@ export function useAppearanceSettings() {
};
}
function ThemePreview({ id }: { id: ThemeId }) {
if (id === "system") {
function ThemePreview({ swatch }: { swatch: ThemeSwatch | null }) {
if (!swatch) {
return (
<div className="flex h-14 w-full">
<div className="flex-1">
<MiniWindow {...THEME_PALETTES.light} />
<MiniWindow {...LIGHT_SWATCH} />
</div>
<div className="flex-1">
<MiniWindow {...THEME_PALETTES.dark} />
<MiniWindow {...DARK_SWATCH} />
</div>
</div>
);
}
return (
<div className="h-14 w-full">
<MiniWindow {...THEME_PALETTES[id]} />
<MiniWindow {...swatch} />
</div>
);
}
function MiniWindow({
bg,
panel,
text,
}: {
bg: string;
panel: string;
text: string;
}) {
function MiniWindow({ background, panel, text }: ThemeSwatch) {
return (
<div className="flex h-full w-full" style={{ background: bg }}>
<div className="flex h-full w-full" style={{ background }}>
<div
className="flex w-1/4 flex-col gap-1 p-1"
style={{ background: panel }}
@@ -198,26 +195,34 @@ export function ThemeGrid({
value: ThemeId;
onChange: (id: ThemeId) => void;
}) {
const { t } = useTranslation();
const { setThemePreview } = useTheme();
const options = useThemeOptions();
return (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{THEMES.map(({ id }) => {
{options.map(({ id, label, swatch }) => {
const active = value === id;
return (
<button
key={id}
type="button"
aria-pressed={active}
onClick={() => onChange(id)}
onClick={() => {
setThemePreview(null);
onChange(id);
}}
onMouseEnter={() => setThemePreview(id)}
onMouseLeave={() => setThemePreview(null)}
onFocus={() => setThemePreview(id)}
onBlur={() => setThemePreview(null)}
className={`flex flex-col overflow-hidden border text-left transition-colors ${
active
? "border-accent-brand ring-1 ring-accent-brand"
: "border-border hover:border-foreground/40"
}`}
>
<ThemePreview id={id} />
<ThemePreview swatch={swatch} />
<span className="flex items-center justify-between gap-1 border-t border-border bg-card px-2 py-1.5 text-[11px]">
{t(THEME_LABEL_KEYS[id])}
{label}
{active && <Check size={11} className="text-accent-brand" />}
</span>
</button>
-139
View File
@@ -1,139 +0,0 @@
import { useEffect, useState, type RefObject } from "react";
export const HIDDEN_ATTR = "data-search-hidden";
const HIGHLIGHT = "settings-search";
const BLOCK = ".bg-card, [data-setting-row]";
function matches(el: Element, needle: string) {
return (el.textContent ?? "").toLowerCase().includes(needle);
}
/** Visible blocks with no other block above them inside root. */
function topBlocks(root: HTMLElement, blocks: Element[], selector: string) {
return blocks.filter((el) => {
let parent = el.parentElement;
while (parent && parent !== root) {
if (parent.matches(selector) || parent.classList.contains("hidden"))
return false;
parent = parent.parentElement;
}
return true;
});
}
/**
* Hides the cards and rows on a settings page that do not contain the query.
* A card whose own text matches keeps every row; otherwise only its matching
* rows stay. Returns how many cards are still showing.
*/
export function filterSettingsPage(
root: HTMLElement,
query: string,
blockSelector: string = BLOCK,
): number {
for (const el of root.querySelectorAll(`[${HIDDEN_ATTR}]`)) {
el.removeAttribute(HIDDEN_ATTR);
}
const needle = query.trim().toLowerCase();
const blocks = [...root.querySelectorAll(blockSelector)];
const cards = topBlocks(root, blocks, blockSelector);
if (!needle) return cards.length;
let shown = 0;
for (const card of cards) {
if (!matches(card, needle)) {
card.setAttribute(HIDDEN_ATTR, "");
continue;
}
shown++;
const rows = [...card.querySelectorAll("[data-setting-row]")].filter(
(row) => row !== card,
);
if (!rows.some((row) => matches(row, needle))) continue;
for (const row of rows) {
if (!matches(row, needle)) row.setAttribute(HIDDEN_ATTR, "");
}
for (const inner of card.querySelectorAll(".bg-card")) {
if (inner !== card && !matches(inner, needle)) {
inner.setAttribute(HIDDEN_ATTR, "");
}
}
}
return shown;
}
type HighlightRegistry = Map<string, unknown>;
function highlights(): HighlightRegistry | undefined {
const css = (globalThis as { CSS?: { highlights?: HighlightRegistry } }).CSS;
return css?.highlights;
}
/** Marks every match with the CSS highlight API where the browser has it. */
function highlightMatches(root: HTMLElement, query: string) {
const registry = highlights();
const Highlight = (
globalThis as { Highlight?: new (...r: Range[]) => unknown }
).Highlight;
if (!registry || !Highlight) return;
registry.delete(HIGHLIGHT);
const needle = query.trim().toLowerCase();
if (!needle) return;
const ranges: Range[] = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = node.textContent?.toLowerCase() ?? "";
let at = text.indexOf(needle);
while (at !== -1) {
const range = document.createRange();
range.setStart(node, at);
range.setEnd(node, at + needle.length);
ranges.push(range);
at = text.indexOf(needle, at + needle.length);
}
}
registry.set(HIGHLIGHT, new Highlight(...ranges));
}
/**
* Keeps a settings page filtered while its content loads and changes.
* Returns false when the query hides everything.
*/
export function useSettingsPageFilter(
rootRef: RefObject<HTMLElement | null>,
query: string,
pageKey: string,
blockSelector: string = BLOCK,
onFiltered?: (root: HTMLElement) => void,
): boolean {
const [anyShown, setAnyShown] = useState(true);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
let frame = 0;
const run = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
setAnyShown(
filterSettingsPage(root, query, blockSelector) > 0 || !query.trim(),
);
highlightMatches(root, query);
onFiltered?.(root);
});
};
run();
if (!query.trim()) return () => cancelAnimationFrame(frame);
const observer = new MutationObserver(run);
observer.observe(root, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
observer.disconnect();
cancelAnimationFrame(frame);
highlights()?.delete(HIGHLIGHT);
};
}, [rootRef, query, pageKey, blockSelector, onFiltered]);
return anyShown;
}
-11
View File
@@ -1,11 +0,0 @@
import { createContext, useContext } from "react";
/**
* True inside a Settings tab page. Panels built from accordion sections then
* show only the open one, flat, since the page already names it.
*/
export const SettingsEmbedContext = createContext(false);
export function useSettingsEmbedded(): boolean {
return useContext(SettingsEmbedContext);
}
-264
View File
@@ -1,264 +0,0 @@
import {
Database,
FileText,
FlaskConical,
Globe,
KeyRound,
Keyboard,
Lock,
Monitor,
Palette,
Server,
Settings,
Shield,
SlidersHorizontal,
User,
Users,
type LucideIcon,
} from "lucide-react";
import type { AdminSection } from "@/types/ui-types";
import type { DocsPage } from "@/lib/docs";
import { SETTINGS_SEARCH_INDEX } from "./settings-search-index";
export type SettingsBand = "you" | "app" | "plugins" | "instance";
export const SETTINGS_BAND_ORDER: SettingsBand[] = [
"you",
"app",
"plugins",
"instance",
];
export interface SettingsPage {
id: string;
band: SettingsBand;
labelKey: string;
/** Already translated, for plugin pages named after the plugin. */
label?: string;
icon: LucideIcon;
/** Only for admins. */
admin?: boolean;
/** Also shown to non-admins in the desktop app. */
desktopToo?: boolean;
/** Left out of the desktop app. */
browserOnly?: boolean;
/** The profile section this page shows. */
profileSection?: string;
/** The admin section this page shows. */
adminSection?: AdminSection;
/** A plugin's settings page. */
pluginId?: string;
/** The docs page for this settings page. Plugin pages use the plugin's docs. */
docs?: DocsPage;
/** i18n keys whose text search matches. */
searchKeys: string[];
}
function core(
page: Omit<SettingsPage, "searchKeys"> & { indexId?: string },
): SettingsPage {
return {
...page,
searchKeys: SETTINGS_SEARCH_INDEX[page.indexId ?? page.id] ?? [],
};
}
export const CORE_SETTINGS_PAGES: SettingsPage[] = [
core({
id: "account",
docs: "account",
band: "you",
labelKey: "settings.account",
icon: User,
profileSection: "account",
}),
core({
id: "security",
docs: "accountSecurity",
band: "you",
labelKey: "settings.security",
icon: Shield,
profileSection: "security",
browserOnly: true,
}),
core({
id: "api-keys",
docs: "apiKeys",
band: "you",
labelKey: "settings.apiKeys",
icon: KeyRound,
profileSection: "api-keys",
}),
core({
id: "data",
docs: "accountData",
band: "you",
labelKey: "settings.data",
icon: Database,
profileSection: "data",
}),
core({
id: "interface",
docs: "interfaceSettings",
band: "app",
labelKey: "settings.interface",
icon: SlidersHorizontal,
profileSection: "interface",
}),
core({
id: "appearance",
docs: "appearance",
band: "app",
labelKey: "settings.appearance",
icon: Palette,
profileSection: "appearance",
}),
core({
id: "keybindings",
docs: "keybindings",
band: "app",
labelKey: "settings.keybindings",
icon: Keyboard,
profileSection: "keybindings",
}),
core({
id: "admin-general",
docs: "adminGeneral",
band: "instance",
labelKey: "settings.adminGeneral",
icon: Settings,
admin: true,
adminSection: "general",
}),
core({
id: "updates",
docs: "updating",
band: "instance",
labelKey: "settings.updates.title",
icon: FlaskConical,
admin: true,
desktopToo: true,
}),
core({
id: "admin-users",
docs: "users",
band: "instance",
labelKey: "settings.adminUsers",
icon: Users,
admin: true,
adminSection: "users",
}),
core({
id: "admin-sessions",
docs: "sessions",
band: "instance",
labelKey: "settings.adminSessions",
icon: Monitor,
admin: true,
adminSection: "sessions",
}),
core({
id: "admin-roles",
docs: "roles",
band: "instance",
labelKey: "settings.adminRoles",
icon: Lock,
admin: true,
adminSection: "roles",
}),
core({
id: "admin-host-defaults",
docs: "hostDefaults",
band: "instance",
labelKey: "settings.adminHostDefaults",
icon: Server,
admin: true,
adminSection: "host-defaults",
}),
core({
id: "admin-branding",
docs: "branding",
band: "instance",
labelKey: "settings.adminBranding",
icon: Palette,
admin: true,
adminSection: "branding",
}),
core({
id: "admin-database",
docs: "adminDatabase",
band: "instance",
labelKey: "settings.adminDatabase",
icon: Database,
admin: true,
adminSection: "database",
}),
core({
id: "admin-ssl",
docs: "adminSsl",
band: "instance",
labelKey: "settings.adminSsl",
icon: Globe,
admin: true,
adminSection: "ssl",
}),
core({
id: "admin-api-keys",
docs: "apiKeys",
band: "instance",
labelKey: "settings.adminApiKeys",
icon: KeyRound,
admin: true,
adminSection: "api-keys",
}),
core({
id: "admin-audit-log",
docs: "auditLog",
band: "instance",
labelKey: "settings.adminAuditLog",
icon: FileText,
admin: true,
adminSection: "audit-log",
}),
];
/** The pages this user can reach, in nav order. */
export function visibleSettingsPages(
pages: SettingsPage[],
{ isAdmin, isElectron }: { isAdmin: boolean; isElectron: boolean },
): SettingsPage[] {
return pages.filter(
(page) =>
(!page.admin || isAdmin || (page.desktopToo && isElectron)) &&
(!page.browserOnly || !isElectron),
);
}
export interface SettingsSearchHit {
page: SettingsPage;
/** The first matching phrase, to show under the page name. */
match: string;
}
/** Pages whose name or any label on them contains the query. */
export function searchSettingsPages(
pages: SettingsPage[],
query: string,
translate: (key: string) => string,
): SettingsSearchHit[] {
const needle = query.trim().toLowerCase();
if (!needle) return pages.map((page) => ({ page, match: "" }));
const hits: SettingsSearchHit[] = [];
for (const page of pages) {
const label = page.label ?? translate(page.labelKey);
if (label.toLowerCase().includes(needle)) {
hits.push({ page, match: "" });
continue;
}
const phrase = page.searchKeys
.map((key) => translate(key))
.find((text) => text.toLowerCase().includes(needle));
if (phrase) hits.push({ page, match: phrase });
}
return hits;
}
-487
View File
@@ -1,487 +0,0 @@
// Generated by scripts/generate-settings-search-index.cjs. Do not edit.
/** The i18n keys each core settings section shows, for search. */
export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"account": [
"newUi.sidebar.userProfile.authMethodLabel",
"newUi.sidebar.userProfile.cliDescription",
"newUi.sidebar.userProfile.cliLearnMore",
"newUi.sidebar.userProfile.cliTitle",
"newUi.sidebar.userProfile.deleteAccount",
"newUi.sidebar.userProfile.deleteAccountDescription",
"newUi.sidebar.userProfile.deleteButton",
"newUi.sidebar.userProfile.desktopProfileDescription",
"newUi.sidebar.userProfile.desktopProfileTitle",
"newUi.sidebar.userProfile.linkedProfileDescription",
"newUi.sidebar.userProfile.linkedProfileTitle",
"newUi.sidebar.userProfile.manageSync",
"newUi.sidebar.userProfile.onBetaDescription",
"newUi.sidebar.userProfile.onBetaTitle",
"newUi.sidebar.userProfile.openUpdates",
"newUi.sidebar.userProfile.roleLabel",
"newUi.sidebar.userProfile.sectionAccount",
"newUi.sidebar.userProfile.sendBetaFeedback",
"newUi.sidebar.userProfile.setUpSync",
"newUi.sidebar.userProfile.tryBetaDescription",
"newUi.sidebar.userProfile.tryBetaTitle",
"newUi.sidebar.userProfile.twoFaLabel",
"newUi.sidebar.userProfile.twoFaOff",
"newUi.sidebar.userProfile.twoFaOn",
"newUi.sidebar.userProfile.usernameLabel",
"newUi.sidebar.userProfile.versionLabel",
],
"admin-api-keys": [
"admin.apiKeyCreatedWarning",
"admin.apiKeyExpiresAt",
"admin.apiKeyName",
"admin.apiKeyNoExpiry",
"admin.apiKeySelectUser",
"admin.apiKeyUser",
"admin.apiKeysCliHint",
"admin.apiKeysCliLink",
"admin.apiKeysCount_other",
"admin.apiKeysDocsLink",
"admin.copiedToClipboard",
"admin.createKey",
"admin.createRole",
"admin.creating",
"admin.done",
"admin.newApiKey",
"admin.revokeKey",
"admin.revokeKeyConfirm",
"admin.revokeKeyFailed",
"admin.revokeKeySuccess",
"admin.revokedBadge",
"admin.sectionApiKeys",
"common.cancel",
"placeholders.apiKeyName",
],
"admin-audit-log": [
"admin.auditForwardingDesc",
"admin.auditForwardingEnvNotice",
"admin.auditForwardingSaveError",
"admin.auditForwardingSaved",
"admin.auditForwardingTitle",
"admin.auditForwardingToken",
"admin.auditForwardingTokenStored",
"admin.auditLogClearFilters",
"admin.auditLogEmpty",
"admin.auditLogFailed",
"admin.auditLogFilterAction",
"admin.auditLogFilterAll",
"admin.auditLogFilterFrom",
"admin.auditLogFilterResourceType",
"admin.auditLogFilterStatus",
"admin.auditLogFilterTo",
"admin.auditLogFilterUser",
"admin.auditLogIp",
"admin.auditLogPage",
"admin.auditLogResourceId",
"admin.auditLogSuccess",
"admin.auditLogTotal",
"admin.sectionAuditLog",
"common.save",
],
"admin-branding": [
"admin.brandingAppName",
"admin.brandingAppNameDesc",
"admin.brandingLogo",
"admin.brandingLogoDesc",
"admin.brandingLogoInvalid",
"admin.brandingLogoReset",
"admin.brandingLogoUpload",
"admin.brandingTagline",
"admin.brandingTaglineDesc",
"admin.sectionBranding",
"common.loading",
"common.save",
"common.saving",
],
"admin-database": [
"admin.changeFile",
"admin.export",
"admin.exportDatabase",
"admin.exportDatabaseDesc",
"admin.exporting",
"admin.import",
"admin.importDatabase",
"admin.importDatabaseDesc",
"admin.importDatabaseSelected",
"admin.importing",
"admin.sectionDatabase",
"admin.selectFile",
],
"admin-general": [
"admin.allowPasswordLogin",
"admin.allowPasswordLoginDesc",
"admin.allowPasswordReset",
"admin.allowPasswordResetDesc",
"admin.allowRegistration",
"admin.allowRegistrationDesc",
"admin.externalAutoProvision",
"admin.externalAutoProvisionDesc",
"admin.hours",
"admin.logLevel",
"admin.monitoringDefaults",
"admin.notificationPrivateEndpoints",
"admin.notificationPrivateEndpointsDesc",
"admin.passwordLoginForced",
"admin.sec",
"admin.secondFactorAfterExternalLogin",
"admin.secondFactorAfterExternalLoginDesc",
"admin.sectionGeneral",
"admin.sessionTimeout",
"admin.sessionTimeoutRange",
"admin.statusCheck",
"common.save",
"plugins.manager.developer.enable",
"plugins.manager.developer.enableBody",
"plugins.manager.developer.enableTitle",
"plugins.manager.developer.hint",
"plugins.manager.developer.signedOnly",
"plugins.manager.developer.title",
"plugins.manager.errors.developerMode",
],
"admin-host-defaults": [
"admin.hostTags",
"admin.hostTagsDesc",
"admin.hostTagsError",
"admin.sectionHostDefaults",
"common.remove",
"hostDefaults.adminSectionDescription",
"hostDefaults.editServerDefaults",
"hosts.addTag",
],
"admin-roles": [
"admin.createRole",
"admin.creating",
"admin.customBadge",
"admin.deleteRoleConfirm",
"admin.deleteRoleSuccess",
"admin.newRole",
"admin.roleDescription",
"admin.roleDisplayName",
"admin.roleName",
"admin.rolePermissions.count_other",
"admin.rolePermissions.editAction",
"admin.rolePermissions.loadError",
"admin.rolePermissions.pluginDisabled",
"admin.rolePermissions.save",
"admin.rolePermissions.saveError",
"admin.rolePermissions.saved",
"admin.rolePermissions.saving",
"admin.rolesCount_other",
"admin.sectionRoles",
"admin.systemBadge",
"common.cancel",
"common.optional",
],
"admin-sessions": [
"admin.revokeAll",
"admin.revokeAllSessionsConfirm",
"admin.revokeAllSessionsFailed",
"admin.revokeAllSessionsSuccess",
"admin.revokeSession",
"admin.revokeSessionConfirm",
"admin.revokeSessionFailed",
"admin.sectionSessions",
"admin.sessionActive",
"admin.sessionExpires",
"admin.sessionsActive_other",
"admin.youBadge",
],
"admin-ssl": [
"admin.sectionSsl",
"admin.sslCertExpiresAt",
"admin.sslCertStatus",
"admin.sslCertStatusExpired",
"admin.sslCertStatusExpiring",
"admin.sslCertStatusNone",
"admin.sslCertStatusValid",
"admin.sslDescription",
"admin.sslDocsLink",
"admin.sslInfoNote",
"admin.sslIssuer",
"admin.sslManualCert",
"admin.sslManualCertPlaceholder",
"admin.sslManualDesc",
"admin.sslManualKey",
"admin.sslManualKeyPlaceholder",
"admin.sslManualTitle",
"admin.sslManualUpload",
"admin.sslManualUploadLoading",
"admin.sslNames",
"admin.sslNotRenewed",
"admin.sslRenewedBy",
"admin.sslSelfSigned",
],
"admin-users": [
"admin.addRole",
"admin.adminBadge",
"admin.adminStatusAdministrator",
"admin.adminStatusRegularUser",
"admin.assignRoleFailed",
"admin.authTypeDual",
"admin.authTypeExternal",
"admin.authTypeLocal",
"admin.createUser",
"admin.createUserDesc",
"admin.createUserEnterUsername",
"admin.createUserPassword",
"admin.createUserPasswordHint",
"admin.createUserSubmit",
"admin.createUserTitle",
"admin.createUserUsername",
"admin.creating",
"admin.deleteUser",
"admin.deleteUserConfirm",
"admin.deleteUserFailed",
"admin.deleteUserSuccess",
"admin.deleteUserWarning",
"admin.deleting",
"admin.editUserAdminStatus",
"admin.editUserAuthType",
"admin.editUserDesc",
"admin.editUserTitle",
"admin.editUserUserId",
"admin.editUserUsername",
"admin.linkAccountDesc",
"admin.linkAccountDescLocal",
"admin.linkAccountEffect1",
"admin.linkAccountEffect2",
"admin.linkAccountEffect3",
"admin.linkAccountExternalNotFound",
"admin.linkAccountExternalPlaceholder",
"admin.linkAccountExternalUsername",
"admin.linkAccountFailed",
"admin.linkAccountInProgress",
"admin.linkAccountSuccess",
"admin.linkAccountTargetPlaceholder",
"admin.linkAccountTargetUsername",
"admin.linkAccountTitle",
"admin.linkAccountWarningTitle",
"admin.linkAccounts",
"admin.manageUserData",
"admin.noCustomRoles",
"admin.noUsers",
"admin.noUsersMatch",
"admin.pageOf",
"admin.removeRoleFailed",
"admin.revoke",
"admin.revokeAllUserSessions",
"admin.revokeAllUserSessionsDesc",
"admin.searchUsers",
"admin.sectionUsers",
"admin.unlinkAccount",
"admin.unlinkAccountDesc",
"admin.unlinkAccountFailed",
"admin.unlinkAccountInProgress",
"admin.unlinkAccountSuccess",
"admin.unlinkAccountTitle",
"admin.unlinkAccountWarning",
"admin.userAdminAccess",
"admin.userAdminAccessDesc",
"admin.userRoles",
"admin.usersCount_other",
"common.cancel",
"common.loading",
"common.next",
"common.previous",
"manage.cannotBeUndone",
"placeholders.enterPassword",
],
"api-keys": [
"admin.revokeKey",
"admin.revokeKeyConfirm",
"newUi.sidebar.userProfile.apiKeyActive",
"newUi.sidebar.userProfile.apiKeyCount_other",
"newUi.sidebar.userProfile.apiKeyCreateFailed",
"newUi.sidebar.userProfile.apiKeyCreated",
"newUi.sidebar.userProfile.apiKeyCreatedWarning",
"newUi.sidebar.userProfile.apiKeyExpires",
"newUi.sidebar.userProfile.apiKeyNameLabel",
"newUi.sidebar.userProfile.apiKeyNamePlaceholder",
"newUi.sidebar.userProfile.apiKeyNameRequired",
"newUi.sidebar.userProfile.apiKeyPermissionsHint",
"newUi.sidebar.userProfile.apiKeyRevokeFailed",
"newUi.sidebar.userProfile.apiKeyRevoked",
"newUi.sidebar.userProfile.apiKeyUsageHint",
"newUi.sidebar.userProfile.apiKeyUsageHintHeader",
"newUi.sidebar.userProfile.apiKeyUser",
"newUi.sidebar.userProfile.cancel",
"newUi.sidebar.userProfile.copiedToClipboard",
"newUi.sidebar.userProfile.copyApiKey",
"newUi.sidebar.userProfile.createApiKeyDescription",
"newUi.sidebar.userProfile.createApiKeyTitle",
"newUi.sidebar.userProfile.createKey",
"newUi.sidebar.userProfile.done",
"newUi.sidebar.userProfile.expiryDateLabel",
"newUi.sidebar.userProfile.newKey",
"newUi.sidebar.userProfile.noApiKeys",
"newUi.sidebar.userProfile.optional",
"newUi.sidebar.userProfile.sectionApiKeys",
],
"appearance": [
"newUi.sidebar.userProfile.accentColorLabel",
"newUi.sidebar.userProfile.appRailHoverPushesContent",
"newUi.sidebar.userProfile.appRailHoverPushesContentDesc",
"newUi.sidebar.userProfile.colorPickerTooltip",
"newUi.sidebar.userProfile.commandPalette",
"newUi.sidebar.userProfile.commandPaletteDesc",
"newUi.sidebar.userProfile.confirmTabClose",
"newUi.sidebar.userProfile.confirmTabCloseDesc",
"newUi.sidebar.userProfile.disableUpdateChecks",
"newUi.sidebar.userProfile.disableUpdateChecksDesc",
"newUi.sidebar.userProfile.expandAppRailOnHover",
"newUi.sidebar.userProfile.expandAppRailOnHoverDesc",
"newUi.sidebar.userProfile.fontSizeLabel",
"newUi.sidebar.userProfile.interfaceFontDescription",
"newUi.sidebar.userProfile.interfaceFontLabel",
"newUi.sidebar.userProfile.keyboardShortcuts",
"newUi.sidebar.userProfile.keyboardShortcutsDescription",
"newUi.sidebar.userProfile.languageLabel",
"newUi.sidebar.userProfile.manageShortcuts",
"newUi.sidebar.userProfile.navigationTabsDesc",
"newUi.sidebar.userProfile.pinAppRail",
"newUi.sidebar.userProfile.pinAppRailDesc",
"newUi.sidebar.userProfile.reopenTabsOnLogin",
"newUi.sidebar.userProfile.reopenTabsOnLoginDesc",
"newUi.sidebar.userProfile.sectionAppearance",
"newUi.sidebar.userProfile.settingsAppRail",
"newUi.sidebar.userProfile.settingsNavigation",
"newUi.sidebar.userProfile.settingsTabsAndShortcuts",
"newUi.sidebar.userProfile.settingsUpdates",
"newUi.sidebar.userProfile.showPinAppRailButton",
"newUi.sidebar.userProfile.showPinAppRailButtonDesc",
"newUi.sidebar.userProfile.showTabNumbers",
"newUi.sidebar.userProfile.showTabNumbersDesc",
"newUi.sidebar.userProfile.sidebarSettingsMoved",
"newUi.sidebar.userProfile.themeLabel",
],
"data": [
"newUi.sidebar.userProfile.cancel",
"newUi.sidebar.userProfile.changeFile",
"newUi.sidebar.userProfile.confirmPasswordDeletePlaceholder",
"newUi.sidebar.userProfile.confirmPasswordLabel",
"newUi.sidebar.userProfile.deleteAccount",
"newUi.sidebar.userProfile.deleteAccountPermanent",
"newUi.sidebar.userProfile.deleteAccountWarning",
"newUi.sidebar.userProfile.deleting",
"newUi.sidebar.userProfile.export",
"newUi.sidebar.userProfile.exportData",
"newUi.sidebar.userProfile.exportDataDesc",
"newUi.sidebar.userProfile.exporting",
"newUi.sidebar.userProfile.import",
"newUi.sidebar.userProfile.importData",
"newUi.sidebar.userProfile.importDataDesc",
"newUi.sidebar.userProfile.importDataSelected",
"newUi.sidebar.userProfile.importing",
"newUi.sidebar.userProfile.sectionData",
"newUi.sidebar.userProfile.selectFile",
],
"interface": [
"newUi.sidebar.userProfile.interfacePresetDesc",
"newUi.sidebar.userProfile.presetClearOverrides",
"newUi.sidebar.userProfile.presetConfirmApply",
"newUi.sidebar.userProfile.presetConfirmDesc",
"newUi.sidebar.userProfile.presetConfirmTitle",
"newUi.sidebar.userProfile.presetOverridesTitle",
"newUi.sidebar.userProfile.presetRevertArea",
"newUi.sidebar.userProfile.runSetupAgainButton",
"newUi.sidebar.userProfile.runSetupAgainDesc",
"newUi.sidebar.userProfile.sectionInterface",
],
"keybindings": [
"newUi.sidebar.keybindings.actionLabel",
"newUi.sidebar.keybindings.actionRequiredError",
"newUi.sidebar.keybindings.addBinding",
"newUi.sidebar.keybindings.addBindingTitle",
"newUi.sidebar.keybindings.cancel",
"newUi.sidebar.keybindings.comboRequiredError",
"newUi.sidebar.keybindings.conflictWarning",
"newUi.sidebar.keybindings.customHeading",
"newUi.sidebar.keybindings.customize",
"newUi.sidebar.keybindings.customizedBadge",
"newUi.sidebar.keybindings.defaultBadge",
"newUi.sidebar.keybindings.defaultsHeading",
"newUi.sidebar.keybindings.deleteConfirm",
"newUi.sidebar.keybindings.description",
"newUi.sidebar.keybindings.editBindingTitle",
"newUi.sidebar.keybindings.loading",
"newUi.sidebar.keybindings.noCustomBindings",
"newUi.sidebar.keybindings.pressKeysPlaceholder",
"newUi.sidebar.keybindings.pressKeysToRecord",
"newUi.sidebar.keybindings.recording",
"newUi.sidebar.keybindings.resetAllConfirm",
"newUi.sidebar.keybindings.resetAllToDefaults",
"newUi.sidebar.keybindings.resetToDefault",
"newUi.sidebar.keybindings.saveBinding",
"newUi.sidebar.keybindings.saveError",
"newUi.sidebar.keybindings.unavailableAction",
],
"security": [
"newUi.sidebar.userProfile.changePassword",
"newUi.sidebar.userProfile.confirmPasswordLabel",
"newUi.sidebar.userProfile.confirmPasswordPlaceholder",
"newUi.sidebar.userProfile.currentPasswordLabel",
"newUi.sidebar.userProfile.currentPasswordPlaceholder",
"newUi.sidebar.userProfile.newPasswordLabel",
"newUi.sidebar.userProfile.newPasswordPlaceholder",
"newUi.sidebar.userProfile.passwordFieldsRequired",
"newUi.sidebar.userProfile.passwordMismatch",
"newUi.sidebar.userProfile.passwordTooShort",
"newUi.sidebar.userProfile.passwordUpdateFailed",
"newUi.sidebar.userProfile.passwordUpdated",
"newUi.sidebar.userProfile.sectionSecurity",
"newUi.sidebar.userProfile.updatePassword",
],
"updates": [
"desktopUpdates.checkNow",
"desktopUpdates.download",
"desktopUpdates.manualModeHint",
"desktopUpdates.mode",
"desktopUpdates.modeAutomatic",
"desktopUpdates.modeHint",
"desktopUpdates.modeNever",
"desktopUpdates.modePrompt",
"desktopUpdates.restart",
"desktopUpdates.status",
"desktopUpdates.statusAvailable",
"desktopUpdates.statusChecking",
"desktopUpdates.statusDownloading",
"desktopUpdates.statusError",
"desktopUpdates.statusIdle",
"desktopUpdates.statusReady",
"desktopUpdates.statusUpToDate",
"desktopUpdates.title",
"settings.updates.allPluginsBeta",
"settings.updates.allPluginsStable",
"settings.updates.backToStable",
"settings.updates.betaIntro",
"settings.updates.betaProgram",
"settings.updates.copied",
"settings.updates.copy",
"settings.updates.copyFailed",
"settings.updates.desktopInstall",
"settings.updates.dockerInstall",
"settings.updates.downloadBeta",
"settings.updates.feedback",
"settings.updates.feedbackHint",
"settings.updates.howToInstall",
"settings.updates.learnMore",
"settings.updates.newestBeta",
"settings.updates.newestStable",
"settings.updates.noRelease",
"settings.updates.openPlugins",
"settings.updates.pluginBetas",
"settings.updates.pluginBetasIntro",
"settings.updates.releases",
"settings.updates.runningBetaBuild",
"settings.updates.saveFailed",
"settings.updates.sendFeedback",
"settings.updates.termixBetas",
"settings.updates.termixBetasHint",
"settings.updates.thisVersion",
"settings.updates.waitingForStable",
],
};
-455
View File
@@ -1,455 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { InlineView } from "@/components/surface/surface-scope";
import { toast } from "sonner";
import { AlertCircle, Loader2, X } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Switch } from "@/components/switch";
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
import {
completeLink,
getLinkPreview,
probeServer,
reloginLink,
type ProbeProblem,
type ServerTarget,
type SyncStatus,
} from "@/api/sync-api";
import { ProxySettingsFields } from "./ProxySettingsFields";
import {
forgetServerUrl,
getSavedServerUrls,
rememberServerUrl,
} from "./saved-servers";
import { DocsLink } from "@/components/docs-link";
type Step = "server" | "signin" | "choose" | "finishing";
interface LinkServerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onLinked: (status: SyncStatus) => void;
/** Sign back in to the server this desktop is already linked to. */
relogin?: { serverUrl: string };
}
function problemText(
t: (key: string, options?: Record<string, unknown>) => string,
problem: ProbeProblem | undefined,
message: string | undefined,
): string {
switch (problem) {
case "invalid_url":
return t("sync.problems.invalidUrl");
case "tls_untrusted":
return t("sync.problems.tlsUntrusted");
case "basic_auth_required":
return t("sync.problems.basicAuthRequired");
case "proxy_login":
return t("sync.problems.proxyLogin");
case "not_termix":
return t("sync.problems.notTermix");
case "version_mismatch":
return t("sync.problems.versionMismatch");
case "different_account":
return t("sync.problems.differentAccount");
default:
return message
? t("sync.problems.unreachableWithReason", { reason: message })
: t("sync.problems.unreachable");
}
}
export function LinkServerDialog({
open,
onOpenChange,
onLinked,
relogin,
}: LinkServerDialogProps) {
const { t } = useTranslation();
const [step, setStep] = useState<Step>(relogin ? "signin" : "server");
const [savedUrls, setSavedUrls] = useState<string[]>(getSavedServerUrls);
const [target, setTarget] = useState<ServerTarget>({
serverUrl: relogin?.serverUrl ?? "",
customHeaders: [],
basicAuth: null,
allowInvalidCertificate: false,
});
const [showAdvanced, setShowAdvanced] = useState(false);
const [checking, setChecking] = useState(false);
const [problem, setProblem] = useState<string | null>(null);
const [problemKind, setProblemKind] = useState<ProbeProblem | null>(null);
const [serverName, setServerName] = useState<string | undefined>();
const [token, setToken] = useState<string | null>(null);
const [counts, setCounts] = useState<Record<string, number>>({});
const [mode, setMode] = useState<"merge" | "replace">("merge");
const [finishError, setFinishError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setStep(relogin ? "signin" : "server");
setProblem(null);
setProblemKind(null);
setToken(null);
setFinishError(null);
setMode("merge");
if (relogin) {
setTarget((current) => ({ ...current, serverUrl: relogin.serverUrl }));
return;
}
// An older version of the app may have saved a server address.
window.electronAPI
?.invoke?.("get-previous-server-url")
.then((result) => {
const url = (result as { serverUrl?: string | null } | null)?.serverUrl;
if (url) {
setTarget((current) =>
current.serverUrl ? current : { ...current, serverUrl: url },
);
}
})
.catch(() => {});
}, [open, relogin]);
const handleCheck = async () => {
setChecking(true);
setProblem(null);
setProblemKind(null);
try {
const result = await probeServer(target);
if (!result.ok) {
setProblemKind(result.problem ?? null);
setProblem(problemText(t, result.problem, result.message));
if (
result.problem === "basic_auth_required" ||
result.problem === "proxy_login"
) {
setShowAdvanced(true);
}
if (result.problem === "basic_auth_required" && !target.basicAuth) {
setTarget((current) => ({
...current,
basicAuth: { username: "", password: "" },
}));
}
return;
}
setTarget((current) => ({
...current,
serverUrl: result.serverUrl ?? current.serverUrl,
}));
setServerName(result.name);
setStep("signin");
} catch {
setProblem(problemText(t, "unreachable", undefined));
} finally {
setChecking(false);
}
};
const finish = async (sessionToken: string, chosen: "merge" | "replace") => {
setStep("finishing");
setFinishError(null);
try {
const status = await completeLink({
...target,
token: sessionToken,
mode: chosen,
serverName,
});
rememberServerUrl(target.serverUrl);
toast.success(t("sync.wizard.linked"));
onLinked(status);
onOpenChange(false);
} catch (error) {
const data = (
error as {
response?: { data?: { problem?: ProbeProblem; message?: string } };
}
)?.response?.data;
setFinishError(problemText(t, data?.problem, data?.message));
setStep("choose");
}
};
const handleSignedIn = async (sessionToken: string | null) => {
if (!sessionToken) return;
if (relogin) {
try {
const status = await reloginLink(sessionToken);
toast.success(t("sync.wizard.signedInAgain"));
onLinked(status);
onOpenChange(false);
} catch (error) {
const data = (
error as {
response?: {
data?: {
error?: string;
problem?: ProbeProblem;
message?: string;
};
};
}
)?.response?.data;
throw new Error(
data?.error === "different_account"
? t("sync.problems.differentAccount")
: problemText(t, data?.problem, data?.message),
);
}
return;
}
setToken(sessionToken);
const preview = await getLinkPreview().catch(() => ({}));
setCounts(preview);
const total = Object.values(preview).reduce((sum, count) => sum + count, 0);
if (total === 0) {
await finish(sessionToken, "merge");
return;
}
setStep("choose");
};
const localTotal = Object.values(counts).reduce(
(sum, count) => sum + count,
0,
);
const wide = step === "signin";
const title = relogin
? t("sync.wizard.reloginTitle")
: t("sync.wizard.title");
const description =
step === "server"
? t("sync.wizard.serverDescription")
: step === "signin"
? t("sync.wizard.signinDescription", { url: target.serverUrl })
: step === "choose"
? t("sync.wizard.chooseDescription")
: t("sync.wizard.finishingDescription");
const content = (
<>
{step === "server" && (
<form
className="flex flex-col gap-4"
onSubmit={(event) => {
event.preventDefault();
void handleCheck();
}}
>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold" htmlFor="sync-server-url">
{t("sync.wizard.serverUrl")}
</label>
<Input
id="sync-server-url"
autoFocus
className="rounded-none"
placeholder="https://termix.example.com"
value={target.serverUrl}
onChange={(event) =>
setTarget({ ...target, serverUrl: event.target.value })
}
/>
<p className="text-[10px] text-muted-foreground">
{t("sync.wizard.serverUrlHint")}
</p>
{!relogin && savedUrls.length > 0 && (
<div className="flex flex-col gap-1 pt-1">
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
{t("sync.wizard.savedServers")}
</span>
{savedUrls.map((url) => (
<div
key={url}
className="flex items-center border border-border hover:bg-muted"
>
<button
type="button"
className="flex-1 min-w-0 truncate px-2 py-1.5 text-left font-mono text-xs"
onClick={() => setTarget({ ...target, serverUrl: url })}
>
{url}
</button>
<button
type="button"
title={t("sync.wizard.removeServer")}
className="px-2 py-1.5 text-muted-foreground hover:text-destructive"
onClick={() => setSavedUrls(forgetServerUrl(url))}
>
<X className="size-3" />
</button>
</div>
))}
</div>
)}
</div>
{problem && (
<div className="flex gap-2 border border-destructive/40 bg-destructive/10 p-2.5 text-xs">
<AlertCircle className="size-4 shrink-0 text-destructive" />
<span>{problem}</span>
</div>
)}
{(problemKind === "tls_untrusted" ||
target.allowInvalidCertificate) && (
<div className="flex items-center justify-between gap-3 border border-border p-2.5">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{t("sync.proxy.allowInvalidCertificate")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("sync.proxy.allowInvalidCertificateHint")}
</span>
</div>
<Switch
checked={!!target.allowInvalidCertificate}
onCheckedChange={(checked) =>
setTarget({ ...target, allowInvalidCertificate: checked })
}
/>
</div>
)}
<button
type="button"
className="self-start text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground"
onClick={() => setShowAdvanced((value) => !value)}
>
{showAdvanced
? t("sync.wizard.hideProxySettings")
: t("sync.wizard.showProxySettings")}
</button>
{showAdvanced && (
<ProxySettingsFields
customHeaders={target.customHeaders ?? []}
basicAuth={target.basicAuth ?? null}
onChange={(next) => setTarget({ ...target, ...next })}
/>
)}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
className="rounded-none"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
type="submit"
className="rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={checking || !target.serverUrl.trim()}
>
{checking && <Loader2 className="size-4 animate-spin" />}
{t("sync.wizard.continue")}
</Button>
</div>
</form>
)}
{step === "signin" && (
<div className="flex-1 min-h-0">
<ElectronLoginForm
serverUrl={target.serverUrl}
targetPurpose="link"
onAuthSuccess={handleSignedIn}
onChangeServer={() =>
relogin ? onOpenChange(false) : setStep("server")
}
/>
</div>
)}
{step === "choose" && (
<div className="flex flex-col gap-3">
{finishError && (
<div className="flex gap-2 border border-destructive/40 bg-destructive/10 p-2.5 text-xs">
<AlertCircle className="size-4 shrink-0 text-destructive" />
<span>{finishError}</span>
</div>
)}
{(
[
{
value: "merge",
title: t("sync.wizard.mergeTitle"),
body: t("sync.wizard.mergeBody", { count: localTotal }),
},
{
value: "replace",
title: t("sync.wizard.replaceTitle"),
body: t("sync.wizard.replaceBody", { count: localTotal }),
},
] as const
).map((option) => (
<button
key={option.value}
type="button"
onClick={() => setMode(option.value)}
className={`text-left border p-3 flex flex-col gap-1 transition-colors ${
mode === option.value
? "border-accent-brand bg-accent-brand/10"
: "border-border hover:bg-muted/40"
}`}
>
<span className="text-sm font-semibold">{option.title}</span>
<span className="text-xs text-muted-foreground">
{option.body}
</span>
</button>
))}
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
className="rounded-none"
onClick={() => onOpenChange(false)}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
type="button"
className="rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={!token}
onClick={() => token && void finish(token, mode)}
>
{t("sync.wizard.link")}
</Button>
</div>
</div>
)}
{step === "finishing" && (
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
<Loader2 className="size-5 animate-spin text-accent-brand" />
{t("sync.wizard.linking")}
</div>
)}
</>
);
// The wizard takes over the panel, tab or onboarding step it opens from.
return (
<InlineView
open={open}
onOpenChange={onOpenChange}
title={title}
width="wide"
scroll={!wide}
>
{!wide && (
<p className="text-xs text-muted-foreground">
{description} {step === "server" && <DocsLink core="desktopSync" />}
</p>
)}
{content}
</InlineView>
);
}
@@ -1,147 +0,0 @@
import { useTranslation } from "react-i18next";
import { Plus, Trash2 } from "lucide-react";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { Switch } from "@/components/switch";
import type { SyncBasicAuth, SyncProxyHeader } from "@/api/sync-api";
interface ProxySettingsFieldsProps {
customHeaders: SyncProxyHeader[];
basicAuth: SyncBasicAuth | null;
onChange: (next: {
customHeaders?: SyncProxyHeader[];
basicAuth?: SyncBasicAuth | null;
}) => void;
}
/**
* What a reverse proxy in front of the server wants: extra headers (a
* Cloudflare Access service token, a gateway key) and basic auth.
*/
export function ProxySettingsFields({
customHeaders,
basicAuth,
onChange,
}: ProxySettingsFieldsProps) {
const { t } = useTranslation();
const setHeader = (index: number, patch: Partial<SyncProxyHeader>) => {
onChange({
customHeaders: customHeaders.map((header, i) =>
i === index ? { ...header, ...patch } : header,
),
});
};
return (
<div className="flex flex-col gap-4 border border-border p-3">
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold">
{t("sync.proxy.headersTitle")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("sync.proxy.headersHint")}
</span>
</div>
{customHeaders.map((header, index) => (
<div key={index} className="flex items-center gap-2">
<Input
className="rounded-none h-8 text-xs"
placeholder={t("sync.proxy.headerName")}
value={header.name}
onChange={(event) =>
setHeader(index, { name: event.target.value })
}
/>
<PasswordInput
className="rounded-none h-8 text-xs"
placeholder={
header.set
? t("sync.proxy.savedValue")
: t("sync.proxy.headerValue")
}
value={header.value}
onChange={(event) =>
setHeader(index, { value: event.target.value })
}
/>
<button
type="button"
aria-label={t("sync.proxy.removeHeader")}
className="p-1.5 text-muted-foreground hover:text-destructive"
onClick={() =>
onChange({
customHeaders: customHeaders.filter((_, i) => i !== index),
})
}
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
<button
type="button"
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground"
onClick={() =>
onChange({
customHeaders: [...customHeaders, { name: "", value: "" }],
})
}
>
<Plus className="size-3" />
{t("sync.proxy.addHeader")}
</button>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold">
{t("sync.proxy.basicAuthTitle")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("sync.proxy.basicAuthHint")}
</span>
</div>
<Switch
checked={!!basicAuth}
onCheckedChange={(checked) =>
onChange({
basicAuth: checked ? { username: "", password: "" } : null,
})
}
/>
</div>
{basicAuth && (
<div className="flex items-center gap-2">
<Input
className="rounded-none h-8 text-xs"
placeholder={t("sync.proxy.username")}
value={basicAuth.username}
onChange={(event) =>
onChange({
basicAuth: { ...basicAuth, username: event.target.value },
})
}
/>
<PasswordInput
className="rounded-none h-8 text-xs"
placeholder={
basicAuth.set
? t("sync.proxy.savedValue")
: t("sync.proxy.password")
}
value={basicAuth.password}
onChange={(event) =>
onChange({
basicAuth: { ...basicAuth, password: event.target.value },
})
}
/>
</div>
)}
</div>
</div>
);
}
-635
View File
@@ -1,635 +0,0 @@
import { InlineView } from "@/components/surface/surface-scope";
import {
useCallback,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
AlertTriangle,
Cloud,
CloudOff,
Loader2,
RefreshCw,
Unlink,
} from "lucide-react";
import { Button } from "@/components/button";
import { Switch } from "@/components/switch";
import { useSyncStatus } from "@/hooks/use-sync-status";
import { refreshSyncStatus, setSyncStatus } from "@/lib/sync-status";
import { notifySyncChanged } from "@/lib/linked-server";
import { invalidateServerStatusCache } from "@/lib/hosts-request-cache";
import {
getSyncConflicts,
getSyncErrors,
retrySyncErrors,
settleSyncConflict,
syncNow,
unlinkServer,
updateSyncSettings,
type SyncBasicAuth,
type SyncConflictItem,
type SyncEntityInfo,
type SyncErrorItem,
type SyncProxyHeader,
type SyncStatus,
} from "@/api/sync-api";
import { LinkServerDialog } from "./LinkServerDialog";
import { ProxySettingsFields } from "./ProxySettingsFields";
type DesktopSettings = { defaultConnectionOrigin: "local" | "remote" };
function Section({
title,
description,
children,
}: {
title: string;
description?: string;
children: ReactNode;
}) {
return (
<div className="border border-border bg-muted/10 p-3 flex flex-col gap-3">
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{title}
</span>
{description && (
<span className="text-[10px] text-muted-foreground leading-relaxed">
{description}
</span>
)}
</div>
{children}
</div>
);
}
function useRelativeTime() {
const { t } = useTranslation();
return (value: string | null | undefined) => {
if (!value) return t("sync.never");
const seconds = Math.max(
0,
Math.round((Date.now() - new Date(value).getTime()) / 1000),
);
if (seconds < 60) return t("sync.justNow");
if (seconds < 3600)
return t("sync.minutesAgo", { count: Math.round(seconds / 60) });
if (seconds < 86400)
return t("sync.hoursAgo", { count: Math.round(seconds / 3600) });
return new Date(value).toLocaleString();
};
}
function entityLabel(
t: (key: string, options?: Record<string, unknown>) => string,
entity: SyncEntityInfo,
): string {
return t(`sync.entities.${entity.type}`, { defaultValue: entity.type });
}
export function SyncPanel() {
const { t } = useTranslation();
const status = useSyncStatus();
const relativeTime = useRelativeTime();
const [linkOpen, setLinkOpen] = useState(false);
const [reloginOpen, setReloginOpen] = useState(false);
const [unlinkOpen, setUnlinkOpen] = useState(false);
const [keepData, setKeepData] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [conflicts, setConflicts] = useState<SyncConflictItem[]>([]);
const [errors, setErrors] = useState<SyncErrorItem[]>([]);
const [desktopSettings, setDesktopSettings] = useState<DesktopSettings>({
defaultConnectionOrigin: "local",
});
const [proxyDraft, setProxyDraft] = useState<{
customHeaders: SyncProxyHeader[];
basicAuth: SyncBasicAuth | null;
allowInvalidCertificate: boolean;
} | null>(null);
const apply = useCallback((next: SyncStatus) => {
setSyncStatus(next);
notifySyncChanged();
}, []);
useEffect(() => {
window.electronAPI
?.invoke?.("get-desktop-settings")
.then((settings) => {
if (settings) setDesktopSettings(settings as DesktopSettings);
})
.catch(() => {});
}, []);
const conflictCount = status?.conflicts ?? 0;
const errorCount = status?.errors ?? 0;
useEffect(() => {
if (!status?.linked) return;
getSyncConflicts()
.then(setConflicts)
.catch(() => {});
}, [status?.linked, conflictCount, status?.lastSyncAt]);
useEffect(() => {
if (!status?.linked) return;
getSyncErrors()
.then(setErrors)
.catch(() => {});
}, [status?.linked, errorCount, status?.lastSyncAt]);
const run = async (key: string, action: () => Promise<void>) => {
setBusy(key);
try {
await action();
} catch {
toast.error(t("sync.actionFailed"));
} finally {
setBusy(null);
}
};
const groups = useMemo(() => {
const byOwner = new Map<string, SyncEntityInfo[]>();
for (const entity of status?.entities ?? []) {
const key = entity.owner === "core" ? "core" : entity.owner;
const list = byOwner.get(key) ?? [];
list.push(entity);
byOwner.set(key, list);
}
return [...byOwner.entries()].sort(([a], [b]) =>
a === "core" ? -1 : b === "core" ? 1 : a.localeCompare(b),
);
}, [status?.entities]);
if (!status) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
const linkDialog = (
<LinkServerDialog
open={linkOpen}
onOpenChange={setLinkOpen}
onLinked={apply}
/>
);
if (!status.linked) {
return (
<div className="flex flex-col gap-3 p-3">
<div className="border border-border bg-muted/10 p-4 flex flex-col gap-3">
<div className="flex items-center gap-2">
<CloudOff className="size-4 text-muted-foreground" />
<span className="text-sm font-bold">{t("sync.unlinkedTitle")}</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{t("sync.unlinkedBody")}
</p>
<ul className="text-xs text-muted-foreground list-disc pl-4 flex flex-col gap-1">
<li>{t("sync.unlinkedPointHosts")}</li>
<li>{t("sync.unlinkedPointOffline")}</li>
<li>{t("sync.unlinkedPointAccount")}</li>
</ul>
<Button
variant="outline"
className="self-start rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
onClick={() => setLinkOpen(true)}
>
<Cloud className="size-4" />
{t("sync.linkButton")}
</Button>
</div>
{linkDialog}
</div>
);
}
const state = status.status ?? "idle";
const stateText =
state === "syncing"
? t("sync.state.syncing")
: state === "offline"
? t("sync.state.offline")
: state === "error"
? t("sync.state.error")
: state === "signed_out"
? t("sync.state.signedOut")
: t("sync.state.synced", { time: relativeTime(status.lastSyncAt) });
const toggleType = (type: string, enabled: boolean) =>
run(`type:${type}`, async () => {
const disabled = new Set<string>(
(status.entities ?? [])
.filter((entity) => !entity.enabled)
.map((entity) => entity.type),
);
if (enabled) disabled.delete(type);
else disabled.add(type);
apply(await updateSyncSettings({ disabledTypes: [...disabled] }));
});
const handleOriginChange = async (origin: "local" | "remote") => {
const next = { ...desktopSettings, defaultConnectionOrigin: origin };
setDesktopSettings(next);
await window.electronAPI?.invoke?.("save-desktop-settings", next);
invalidateServerStatusCache();
window.dispatchEvent(new CustomEvent("hosts:refresh"));
};
const draft = proxyDraft ?? {
customHeaders: status.customHeaders ?? [],
basicAuth: status.basicAuth ?? null,
allowInvalidCertificate: !!status.allowInvalidCertificate,
};
return (
<div className="flex flex-col gap-3 p-3">
<div className="border border-border bg-muted/10 p-3 flex flex-col gap-3">
<div className="flex items-start gap-2">
<Cloud className="size-4 text-accent-brand mt-0.5 shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-sm font-bold truncate">
{status.serverName || t("sync.server")}
</span>
<span className="text-[10px] text-muted-foreground break-all">
{status.serverUrl}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">
{t("sync.account")}
</span>
<span className="font-semibold truncate">
{status.account?.username || "-"}
</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] uppercase tracking-widest text-muted-foreground font-semibold">
{t("sync.status")}
</span>
<span
className={`font-semibold flex items-center gap-1 ${
state === "idle" || state === "syncing"
? ""
: "text-destructive"
}`}
>
{state === "syncing" && (
<Loader2 className="size-3 animate-spin" />
)}
{stateText}
</span>
</div>
</div>
{status.lastError && state !== "idle" && state !== "syncing" && (
<p className="text-[10px] text-muted-foreground break-words">
{status.lastError}
</p>
)}
{!!status.pending && (
<p className="text-[10px] text-muted-foreground">
{t("sync.pending", { count: status.pending })}
</p>
)}
<div className="flex flex-wrap gap-2">
{state === "signed_out" ? (
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
onClick={() => setReloginOpen(true)}
>
{t("sync.signInAgain")}
</Button>
) : (
<Button
size="sm"
variant="outline"
className="h-7 text-[10px] rounded-none"
disabled={busy === "sync" || state === "syncing"}
onClick={() =>
run("sync", async () => {
apply(await syncNow());
})
}
>
{busy === "sync" || state === "syncing" ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
)}
{t("sync.syncNow")}
</Button>
)}
<Button
size="sm"
variant="outline"
className="h-7 text-[10px] rounded-none"
onClick={() => {
setKeepData(true);
setUnlinkOpen(true);
}}
>
<Unlink className="size-3" />
{t("sync.unlink")}
</Button>
</div>
</div>
{conflicts.length > 0 && (
<Section
title={t("sync.conflictsTitle")}
description={t("sync.conflictsDescription")}
>
{conflicts.map((conflict) => (
<div
key={conflict.id}
className="flex items-center justify-between gap-2 border border-border p-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold truncate">
{conflict.name}
</span>
<span className="text-[10px] text-muted-foreground">
{t(`sync.entities.${conflict.entityType}`, {
defaultValue: conflict.entityType,
})}
</span>
</div>
<div className="flex gap-1 shrink-0">
<Button
size="sm"
variant="outline"
className="h-6 text-[10px] rounded-none"
disabled={busy === `conflict:${conflict.id}`}
onClick={() =>
run(`conflict:${conflict.id}`, async () => {
await settleSyncConflict(conflict.id, "mine");
refreshSyncStatus();
})
}
>
{t("sync.keepMine")}
</Button>
<Button
size="sm"
variant="outline"
className="h-6 text-[10px] rounded-none"
disabled={busy === `conflict:${conflict.id}`}
onClick={() =>
run(`conflict:${conflict.id}`, async () => {
await settleSyncConflict(conflict.id, "server");
refreshSyncStatus();
})
}
>
{t("sync.keepServer")}
</Button>
</div>
</div>
))}
</Section>
)}
{errors.length > 0 && (
<Section
title={t("sync.errorsTitle")}
description={t("sync.errorsDescription")}
>
{errors.map((error) => (
<div
key={`${error.entityType}:${error.syncId}`}
className="flex items-start gap-2 border border-border p-2"
>
<AlertTriangle className="size-3.5 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold">
{t(`sync.entities.${error.entityType}`, {
defaultValue: error.entityType,
})}
</span>
<span className="text-[10px] text-muted-foreground break-words">
{error.reason === "permission"
? t("sync.errorPermission")
: error.reason}
</span>
</div>
</div>
))}
<Button
size="sm"
variant="outline"
className="self-start h-7 text-[10px] rounded-none"
disabled={busy === "retry"}
onClick={() =>
run("retry", async () => {
apply(await retrySyncErrors());
})
}
>
{t("sync.retry")}
</Button>
</Section>
)}
<Section
title={t("sync.whatSyncs")}
description={t("sync.whatSyncsDescription")}
>
{groups.map(([owner, entities]) => (
<div key={owner} className="flex flex-col gap-1.5">
<span className="text-[10px] font-semibold text-muted-foreground">
{owner === "core"
? t("sync.coreGroup")
: (entities[0]?.ownerName ?? owner)}
</span>
{entities.map((entity) => (
<div
key={entity.type}
className="flex items-center justify-between gap-3"
>
<div className="flex flex-col min-w-0">
<span className="text-xs">{entityLabel(t, entity)}</span>
{!entity.onServer && (
<span className="text-[10px] text-muted-foreground">
{t("sync.notOnServer")}
</span>
)}
{entity.onServer && !entity.onDevice && (
<span className="text-[10px] text-muted-foreground">
{t("sync.notOnDevice")}
</span>
)}
</div>
<Switch
checked={entity.enabled}
disabled={busy === `type:${entity.type}`}
onCheckedChange={(checked) =>
void toggleType(entity.type, checked)
}
/>
</div>
))}
</div>
))}
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t("sync.localOnlyHint")}
</p>
</Section>
<Section
title={t("sync.originTitle")}
description={t("sync.originDescription")}
>
<div className="flex border border-border overflow-hidden w-fit">
{(["local", "remote"] as const).map((origin) => (
<button
key={origin}
type="button"
onClick={() => void handleOriginChange(origin)}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest transition-colors ${
desktopSettings.defaultConnectionOrigin === origin
? "bg-accent-brand text-background"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
{origin === "local"
? t("sync.originLocal")
: t("sync.originRemote")}
</button>
))}
</div>
</Section>
<Section
title={t("sync.proxy.title")}
description={t("sync.proxy.description")}
>
<ProxySettingsFields
customHeaders={draft.customHeaders}
basicAuth={draft.basicAuth}
onChange={(next) => setProxyDraft({ ...draft, ...next })}
/>
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col gap-0.5">
<span className="text-xs">
{t("sync.proxy.allowInvalidCertificate")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("sync.proxy.allowInvalidCertificateHint")}
</span>
</div>
<Switch
checked={draft.allowInvalidCertificate}
onCheckedChange={(checked) =>
setProxyDraft({ ...draft, allowInvalidCertificate: checked })
}
/>
</div>
{proxyDraft && (
<Button
variant="outline"
size="sm"
className="self-start h-7 text-[10px] rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={busy === "proxy"}
onClick={() =>
run("proxy", async () => {
apply(await updateSyncSettings(proxyDraft));
setProxyDraft(null);
toast.success(t("sync.proxy.saved"));
})
}
>
{t("common.save")}
</Button>
)}
</Section>
<LinkServerDialog
open={reloginOpen}
onOpenChange={setReloginOpen}
onLinked={apply}
relogin={status.serverUrl ? { serverUrl: status.serverUrl } : undefined}
/>
<InlineView
open={unlinkOpen}
onOpenChange={setUnlinkOpen}
title={t("sync.unlinkTitle")}
footer={
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
className="rounded-none"
onClick={() => setUnlinkOpen(false)}
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
className="rounded-none"
disabled={busy === "unlink"}
onClick={() =>
run("unlink", async () => {
apply(await unlinkServer(keepData));
setUnlinkOpen(false);
toast.success(t("sync.unlinked"));
})
}
>
{busy === "unlink" && <Loader2 className="size-4 animate-spin" />}
{t("sync.unlink")}
</Button>
</div>
}
>
<p className="text-xs text-muted-foreground">
{t("sync.unlinkDescription")}
</p>
<div className="flex flex-col gap-2">
{(
[
{
value: true,
title: t("sync.unlinkKeepTitle"),
body: t("sync.unlinkKeepBody"),
},
{
value: false,
title: t("sync.unlinkRemoveTitle"),
body: t("sync.unlinkRemoveBody"),
},
] as const
).map((option) => (
<button
key={String(option.value)}
type="button"
onClick={() => setKeepData(option.value)}
className={`text-left border p-3 flex flex-col gap-1 transition-colors ${
keepData === option.value
? "border-accent-brand bg-accent-brand/10"
: "border-border hover:bg-muted/40"
}`}
>
<span className="text-sm font-semibold">{option.title}</span>
<span className="text-xs text-muted-foreground">
{option.body}
</span>
</button>
))}
</div>
</InlineView>
{linkDialog}
</div>
);
}
-39
View File
@@ -1,39 +0,0 @@
// Same key as 2.8, so a desktop keeps the servers it already used.
const SAVED_URLS_KEY = "termix_saved_server_urls";
const MAX_SAVED_URLS = 5;
export function getSavedServerUrls(): string[] {
try {
const parsed = JSON.parse(localStorage.getItem(SAVED_URLS_KEY) ?? "[]");
return Array.isArray(parsed)
? parsed.filter((url): url is string => typeof url === "string")
: [];
} catch {
return [];
}
}
function write(urls: string[]): void {
try {
localStorage.setItem(SAVED_URLS_KEY, JSON.stringify(urls));
} catch {
// Storage can be unavailable.
}
}
export function rememberServerUrl(url: string): string[] {
const trimmed = url.trim().replace(/\/+$/, "");
if (!trimmed) return getSavedServerUrls();
const urls = [
trimmed,
...getSavedServerUrls().filter((saved) => saved !== trimmed),
].slice(0, MAX_SAVED_URLS);
write(urls);
return urls;
}
export function forgetServerUrl(url: string): string[] {
const urls = getSavedServerUrls().filter((saved) => saved !== url);
write(urls);
return urls;
}