mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
v26.10.3 (#1692)
* 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:
577 files changed
+76479
-101831
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user