mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
v26.10.0 (#1573)
* feat: optional auto reconnect for dropped ssh sessions (#1321) * feat: tighter compact host rows with inline tags (#1325) * feat: bring back the alert feed homepage widget * feat: pick target terminals for snippets again * feat: share button on connection tabs again * feat: recent servers list when linking the desktop app * feat: list the fixed app shortcuts in keyboard shortcuts * fix: translate hardcoded toasts and drop em dashes from ui text * fix: translate leftover hardcoded ui text * fix: toolbar files button opens at the shell directory again * fix: show the remote desktop note under connection origin again * chore: openapi docs for host metrics manager routes * chore: openapi docs for undocumented routes and fix two broken doc blocks * fix: declare terminal.listSessions use for snippet targets * revert: drop the alert feed widget, the notification center replaces it * feat: restore the alert feed homepage widget * fix: quick connect fills the host address from plugin auth editors like tailscale * fix: docker container logs use the base background color * chore: fix dependabot alerts and apply safe npm upgrades * fix: proxmox stats tab reads its switch from plugin host settings * fix: count plugin ssh logins toward host status and stop showing reachable as a warning * fix: hide the built-in source pill in the host editor * fix: host status shows online when the port answers and only warns on a failed login * fix: drop the top border on the split layout menu * fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default * fix: remote desktop auth method labels and notes for vnc and telnet * fix: add description to the web endpoint enable switch * fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release * feat: move telemetry out of core into a usage statistics plugin * fix: recent activity card no longer cuts off host names early * fix: remove duplicate top border on split screen menu * fix: redesign session log player controls, fix tooltip overlap and drop dot separators * fix: file manager and sftp text showing raw i18n keys * fix: onboarding rerunning for existing users after upgrading * fix: fleet detail buttons overflowing the sidebar * fix: session log player scrolling off screen horizontally * fix: network graph lag when adding hosts and on every render * fix: maintenance index name, shared host read only view and badge polling * fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag * chore: update release notes * fix: remote desktop tabs reconnecting on every tab switch * fix: build plugin sdk before linting in release workflow * chore: lint, format, and bump version to 2.9.0 * chore: skip crowdin pre-translate in release for now * chore: switch to calver, bump version to 26.10.0 * fix(sso): send the PKCE verifier for GitHub login (#1534) * fix(remote-desktop): clip cursor overflow without disabling zoom (#1535) * test(remote-desktop): preserve sessions beyond one hour (#1536) * fix(hosts): expose controls for overflowing editor tabs (#1537) * fix(hosts): expose controls for overflowing editor tabs * test(hosts): mock resize observation in admin panel tests * fix(status): skip TCP probes while host sessions are active (#1538) * fix(database): batch session activity persistence (#1539) * docs(api): describe cookie and API key authentication (#1540) * fix(snippets): repair missing note column on SQLite upgrades (#1541) * fix(docker): retain stored SSH credential association (#1543) * fix(file-manager): render transfer toasts without plugin hooks (#1546) * feat(hosts): keep compact row actions inline (#1547) * fix(auth): allow retrying unavailable second-factor interfaces (#1549) * fix(auth): reject unresolved legacy identity provisioning (#1550) * fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551) * fix(tmux): pass full session info to the terminal's session picker (#1552) The tmux.sessions service reduced detected sessions to bare names, but the picker reads session.name, so every entry rendered blank and selecting one sent an empty name, which created a new session instead of attaching. * fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553) The column holds the text "true"/"false", and the resolver passed it through as-is. The string "false" is truthy, so the password provider treated every saved host as forced keyboard-interactive and left the password out. Jump hops are built straight from the resolved host, so a password hop whose server doesn't offer keyboard-interactive failed with "All configured authentication methods failed". * fix(database): batch database saves for bulk host writes (#1554) * fix(database): yield to the event loop between database saves Each SQLite save serializes and encrypts the whole database into new buffers, which V8 only releases once the event loop turns. Boot migrations and bulk host import write plugin settings per host and per key in an awaited loop, so hundreds of full copies piled up and the container ran out of memory. * fix(database): save once per bulk host write instead of once per row On SQLite every repository write force-saves the whole database. Boot plugin data migrations, host defaults materialization and the bulk host routes write one row per host per setting, so they serialized and encrypted the full database hundreds of times in a row. DatabaseSaveTrigger.batched wraps a function so force saves made while it runs collapse into a single save when it finishes. Nested scopes fold into the outer one, and work that outlives its scope saves normally. * feat(hosts): add instance-wide predefined tag suggestions (#1548) * feat(hosts): add shared predefined tag suggestions * style(hosts): format tag catalog routes * test(database): advance fake timers past the save yield (#1555) * fix(i18n): complete Simplified Chinese core and plugin translations (#1542) * fix(i18n): complete Chinese onboarding and navigation labels * fix(i18n): translate remaining Chinese core and plugin interfaces * fix(i18n): translate host editor tab overflow controls * fix(i18n): use consistent Chinese fleet terminology * fix(i18n): localize hardcoded controls and plugin views * fix(i18n): translate built-in homepage widget catalog * test(homepage): follow translated timezone placeholder * fix(i18n): translate plugin-provided homepage widgets * fix(i18n): localize feature settings section titles * fix: 2.8 oidc accounts unable to sign in after upgrading (#1381) * fix: plugins losing the saved ssh login when copying a host (#1391) * fix: fleets, proxmox and automations not getting the host sudo password * fix: host imports running a defaults pass and metrics restart per host (#1384) * fix: high cpu from status probes and full database saves on every sample (#1300) * fix: user data export freezing the server (#1393) * fix: op:// secret references rejected as ssh keys on credentials (#1394) * fix: slow file deletes from the trash lookups on every delete (#1390) * fix: add openapi docs and error handling to host tag routes * test: compare download stream buffers directly so it stops timing out * chore: drop em dash from host row comment * chore: update release notes for 2.9.1 * feat(ai): add remote coding agent sessions for SSH hosts (#1545) * feat(ai): add host coding agent sessions with configured providers * Wait for agent initialization and shutdown before resuming sessions * Keep Pi transcripts outside its configuration migration directory * Cover Pi session directory separation * Update core permission catalog expectations for remote agents * Install remote agent runtimes from verified official sources * Wait for the identity panel credentials request in its test * Add scoped SSH forwarding setup for remote agents * Verify forwarding setup through a fresh SSH connection * Bound the SSH forwarding verification timeout * Return the forwarding verification callback result * fix(ai): support agent send and interrupt keyboard shortcuts * feat(ai): add agent queues attachments worktrees and session recovery * fix(ai): handle deleted workspace paths and duplicate status events * fix(ai): accept file references in root workspaces * fix(ai): refresh paused queues after agent errors * fix(ai): store agent sessions in plugin tables and lock down OpenCode Agent sessions lived in a single ctx.kv value holding up to 2000 events, which passed the 256 KB kv limit within minutes of a normal run. Every save after that failed, so history, nativeId, queue and archive changes were lost and every event wrote a kv audit row. Sessions, events and queued prompts now have their own tables. A running agent appends events in batches at most once a second with a lazy save; session and queue changes save immediately. Prompts, drafts and stored event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no longer requested. The OpenCode server now requires a per-session password, so other local accounts on the host cannot drive the agent through its loopback port. * test(upgrade): include agent session tables in the reviewed upgrade SQL * test: run the posix-only ai agent tests on linux only * fix: restore space to add host tags and redesign predefined tags editor * fix: host key silently accepted when the host is missing from the database (#1397) * fix: clearer host login failed status label and fix its translations (#1396) * chore: add host key and status label fixes to release notes * fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles * chore: increment ver * fix(audit): store plugin entries with no acting user as a null user_id (#1556) Plugin audit entries written outside a request used the literal "system" as user_id. That column references users.id, so the insert was refused (Postgres logs it as an FK violation) and the entry was silently dropped. Write null instead and keep "system" / plugin:<id> in username. * chore: add sso/ldap dialog and audit log fixes to release notes * fix: tunnels and host settings missing from shared hosts on desktop * fix: remote desktop logins missing from shared hosts on desktop * fix: simplify host status to online/offline and keep it live without a refresh * chore: sync Crowdin translations for 2.9.1 * fix: build docker frontend/backend natively to stop arm64 hang, build sdk before openapi * chore: rework plugin readmes, json changelogs and standalone repo setup * chore: move plugins to their own repos and drop the plugins folder * chore(deps-dev): bump globals (#1560) Bumps the dev-minor-updates group with 1 update: [globals](https://github.com/sindresorhus/globals). Updates `globals` from 17.12.0 to 17.13.0 - [Release notes](https://github.com/sindresorhus/globals/releases) - [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0) --- updated-dependencies: - dependency-name: globals dependency-version: 17.13.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: drop the 2.8 columns, moves and aliases kept for a downgrade * chore: remove the 2.9 finish list * feat: port the demo redesign with Manage and Settings tabs * chore: pin the plugin signing key and use TERMIX_PAT in workflows * chore: rename the plugin sdk to @termix-ssh/plugin-sdk * feat: add a publish workflow for the plugin sdk * chore: cleanup * fix: harden plugin sockets, public routes, prebuilt binaries and login fallback * chore: add a 2.9.1 upgrade check for postgres and mysql * fix: stop full database rewrites on every plugin db call * fix: stop full database rewrites on every plugin db call * fix: restore release video link in 2.9.2 notes * fix: use TERMIX_PAT in workflows * feat: pin the official plugins in the docker image * feat: implement plugin management UI * feat: shared list kit, settings search, rail order and ui consistency fixes * chore: run all workflows on blacksmith runners * chore: split dashboard quick actions into quarters and drop host status row lines * fix: open plain settings from the profile button, navigation only from the rail menu * feat: consent check, developer mode file installs, install counts and issue links for plugins * feat: row detail options for hosts and credentials, open manage from sidebar header * fix: unify connection failure screens and rebuild stale local plugins * feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev * chore: clean up npm scripts, make npm run dev the full runner and drop concurrently * fix: settle quick connect panel loads in tests so they stop flaking * fix: stop the dev runner rebuilding plugins when windows reports file reads as changes * chore: update the plugin sdk ui export snapshot * feat: full screen onboarding with a plugin picker and per step versions * feat: changelog.md release notes, v tags, plugin release notes and videos * chore: add temp changelog * chore: release the plugin sdk as 1.0.1 * feat: show only the latest plugin release notes and cap long ones * chore: publish docker images to termix-ssh on ghcr and termixssh on docker hub * chore: rename docker hub token secret to DOCKERHUB_BUGATTIGUY527_TOKEN * chore: renew mac app store provisioning profile * chore: drop docs job from release workflow * chore: run sdk publish on github hosted runner * chore: remove dependabot * chore: general file cleanup * chore: rewrite readme and taglines for plugin-based termix * chore: drop optional from ai plugin row in readme * feat: per-repo issue forms and prefilled bug reports instead of the support repo * feat: add report a bug link to the your account settings section * fix: open settings goes to the plugin's page and hides when it has none * feat: show plugin features from the manifest on the plugin page * fix: retry failed or blocked plugins on restart instead of leaving them disabled * fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling * feat: request a feature link for plugins and use the bug form for unverified plugins * feat: beta channels for core and plugins with one stable/beta release workflow * fix(sync): resolve shared hosts for connect-only recipients (#1568) * fix(ssh): accept full passwords in keyboard-interactive prompts (#1569) * fix(ssh): normalize PKCS8 private keys before connecting (#1570) * fix(sidebar): reclaim space from hidden folder actions (#1571) * fix(hosts): display protocol credential usernames for non-SSH hosts (#1572) * fix: ask for a push/TOTP menu choice as text instead of a push confirm * fix: keep client tunnels open when one of their connections fails (Support#1417) * fix: send plugin form uploads to the plugin's route (Support#1406, Support#1420) * fix: add missing sync ids to old snippet tables so snippets sync again (Support#1419) * fix: start the macOS local terminal when the shell or spawn helper is broken (Support#1418) * fix: refresh cached plugin translations when only a translation changes (Support#1411) * fix: import sshpk as a default so the backend starts under ESM * feat: add a plugin sdk changelog and tag sdk releases, release 1.0.2 * fix: drop the ./ from the plugin sdk bin path so npm stops warning * chore: name core and sdk releases Termix vX and Plugin SDK vX * fix: lock the desktop backend to the app's own origin (GHSA-frx8-qqgg-7j7g) * fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh) * fix: keep global settings out of non-admin data exports (GHSA-v3vp-ffww-q7pw) * fix: refuse sessionless tokens and keep the secrets file owner only * feat: add TRUSTED_PROXIES to choose who nginx takes client IPs from (GHSA-7j7m-85p4-2cpm) * chore: standardize repo setup files * chore: remove unused knip config * chore: add LICENSE and use Node 24 from .nvmrc * chore: update translation guidance, only en.json needs editing * feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3 * fix: header separators render as borders and center the status divider * fix: lighter confirm prompt overlay to stop lag when it opens * fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4 * fix: dev stages local plugin builds on paths with spaces and builds them with the local SDK * fix: keep plugin tailwind variants in core's layer so overrides keep their order * fix: manage tab refetches before giving up on a host or credential it cannot find * fix: tab buttons like share show as soon as the terminal is ready * fix: folder rows no longer nest action buttons inside a button * fix: repin remote-desktop after its 1.0.0 re-release * chore: repin bundled plugins after the 1.0.0 re-release * fix: recent activity rows use the same separator as host status * feat: upload banner * fix: reject expired sessions on sockets, api key desktop links and 192.0.0.0/24 * fix: plugin sdk 1.0.5 with optional ui peers, safer timeouts and validate checks * fix: bundled plugins path for source runs and backend cleanup * fix: load plugin strings on guest pages, plugin wording and drop unused keys * fix: beta version stamping, homebrew cask bump, dockerignore and readme platforms * docs: 26.10.0 changelog * fix: current version in the version route example * chore: stop pushing the release- docker tag * feat: remove the donation reminder and its column * docs: note the removed donation reminder * fix: link server wizard always opens inline, onboarding card grows for it * feat: plugin text columns are longtext on mysql so they are not capped at 64KB * fix: linked desktops keep a plugin when its update from the server fails * chore: default mobile download links to v tags * fix: sso only accounts cannot sign in with a passkey or other local method * feat: note in the plugins tab that community plugins are coming * fix: plugin validate accepts beta versions * chore: pin bundled plugins rebuilt on sdk 1.0.5 * fix: skip locale fetches for disabled plugins * fix: show translated names for the built-in admin and user roles * fix: show host keys as SHA256 fingerprints like ssh-keygen * fix: host editor scrolls to a newly enabled protocol section * fix: pass the host to dashboard host row plugins * fix: plugin cards show dividers between author, version and category * fix: refuse plugin archives with paths outside the plugin folder * fix: theme picker shows one dropdown arrow * fix: read activity and audit times as UTC so they show in local time * fix: show the real error for a wrong password or code on plugin sign in * fix: explain that external sign in needs Auto-create external accounts * fix: clear leftover plugin unpack folders at startup * fix: shared session links open for guests again * fix: share panels no longer list yourself as a share target * fix: a host update without an auth type keeps the saved password * fix: guest pages get the tab ids a guest plugin registers * fix: refuse a 2.8 database, which already had a plugins table * fix: dashboard cards fit on a phone * fix: desktop setup no longer asks to secure the local account * fix: the desktop app keeps the desktop layout in a narrow window * fix: the desktop app shows the rail and no phone bar in a narrow window * fix: name every sync type in the Sync panel and pluralize the link prompt * chore: update the sdk ui snapshot for the host key dialog * fix: turning a plugin back on restarts the plugins it turned off * fix: quick connect asks to trust the host key * fix: revoking a manager also removes the shares they handed out * fix: database export works for users with TOTP on (#1426) * fix: rdp and vnc logins use the folder credential when none is picked (#1429) * fix: imported hosts get status checks without a restart * fix: re-pin bundled plugins after 1.0.0 re-release * fix: re-pin bundled plugins after search bar fix * chore: update app icon to smaller version * chore: round the readme logo, keep square source for app icons * feat: grid columns and table view in the plugins tab * fix: match the host key copy button to the fingerprint height * fix: use brand color for toggles in sync settings * fix: re-pin bundled plugins after 1.0.0 re-release * fix: web terminals failing auth behind a proxy that rewrites Host (Support#651) * fix: re-pin snippets and termix-identity after 1.0.0 overwrite * fix: re-pin file-manager after 1.0.0 overwrite * fix: re-pin file-manager after 1.0.0 overwrite * chore: remove Crowdin from the release workflow * chore: remove crowdin config --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
3656 files changed
+116210
-460999
No files matched your search
@@ -14,7 +14,6 @@ import { AccordionSection } from "@/sidebar/AdminSettingsShared";
|
||||
import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import {
|
||||
getPlugins,
|
||||
getPluginAdminSettings,
|
||||
getPluginUserSettings,
|
||||
updatePluginAdminSettings,
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
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";
|
||||
|
||||
@@ -39,19 +39,7 @@ export function featureSectionId(pluginId: string): FeatureSectionId {
|
||||
export function useFeatureSettings(
|
||||
scope: FeatureSettingsScope,
|
||||
): PluginSummary[] {
|
||||
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getPlugins()
|
||||
.then((loaded) => {
|
||||
if (!cancelled) setPlugins(loaded);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
const plugins = useInstalledPlugins();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
@@ -96,7 +84,7 @@ export function FeatureSettingsSection({
|
||||
);
|
||||
}
|
||||
|
||||
function FeatureSettingsForm({
|
||||
export function FeatureSettingsForm({
|
||||
plugin,
|
||||
scope,
|
||||
fields,
|
||||
@@ -208,7 +196,7 @@ function FeatureSettingsForm({
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pt-2">
|
||||
{!running && (
|
||||
<div className="border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
|
||||
<div className="border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
{t("settings.featureUnavailable", {
|
||||
name: t(pluginKey(plugin.id, "plugin.name"), {
|
||||
defaultValue: plugin.name,
|
||||
@@ -233,8 +221,9 @@ function FeatureSettingsForm({
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
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()}
|
||||
>
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
* group does not appear as an empty tab.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import { getPlugins, type PluginSummary } from "@/api/plugins-api";
|
||||
import { type PluginSummary } from "@/api/plugins-api";
|
||||
import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
|
||||
import { usePluginScope } from "@/plugin-host/scope";
|
||||
import { SettingsFieldRow } from "./SettingsFields";
|
||||
import { hasVisibleFields, isFieldShown } from "./settings-fields-util";
|
||||
@@ -24,21 +26,7 @@ export type HostPluginSettings = Record<string, Record<string, unknown>>;
|
||||
|
||||
/** Enabled plugins declaring host-scope settings. */
|
||||
export function usePluginHostSections(): PluginSummary[] {
|
||||
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getPlugins()
|
||||
.then((loaded) => {
|
||||
if (!cancelled) setPlugins(loaded);
|
||||
})
|
||||
.catch(() => {
|
||||
// Additive: the built-in host tabs do not depend on this.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
const plugins = useInstalledPlugins();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
@@ -155,9 +143,10 @@ function HostPluginSection({
|
||||
<SectionCard
|
||||
title={plugin.name}
|
||||
icon={<PluginIcon name={plugin.icon} className="size-3.5" />}
|
||||
action={<DocsLink href={plugin.docs} />}
|
||||
>
|
||||
{!running && (
|
||||
<div className="my-3 border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-500">
|
||||
<div className="my-3 border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
{t("settings.featureUnavailable", { name: plugin.name })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -117,7 +117,7 @@ export function SettingsFieldRow({
|
||||
{label(field.labelKey)}
|
||||
</span>
|
||||
{error && (
|
||||
<span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1 shrink-0">
|
||||
<span className="text-[10px] font-bold text-warning border border-warning/40 px-1 shrink-0">
|
||||
{t("common.error")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
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 } 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: t(pluginKey(plugin.id, "plugin.name"), {
|
||||
defaultValue: plugin.name,
|
||||
}),
|
||||
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" && isAdmin && <UpdatesSettings />}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
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";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export function UpdatesSettings() {
|
||||
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={plugin.name}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check } from "lucide-react";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { Input } from "@/components/input";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import {
|
||||
ACCENT_PRESET_COLORS,
|
||||
applyAccentColor,
|
||||
applyFontSize,
|
||||
applyUiFont,
|
||||
FONT_SIZES,
|
||||
UI_FONTS,
|
||||
} from "@/lib/theme";
|
||||
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
|
||||
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
|
||||
import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types";
|
||||
|
||||
export const DEFAULT_ACCENT = "#f59145";
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
/** 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 const LANGUAGES = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "af", label: "Afrikaans" },
|
||||
{ code: "ar", label: "العربية" },
|
||||
{ code: "bn", label: "বাংলা" },
|
||||
{ code: "bg", label: "Български" },
|
||||
{ code: "ca", label: "Català" },
|
||||
{ code: "zh-CN", label: "中文 (简体)" },
|
||||
{ code: "zh-TW", label: "中文 (繁體)" },
|
||||
{ code: "cs", label: "Čeština" },
|
||||
{ code: "da", label: "Dansk" },
|
||||
{ code: "nl", label: "Nederlands" },
|
||||
{ code: "fi", label: "Suomi" },
|
||||
{ code: "fr", label: "Français" },
|
||||
{ code: "de", label: "Deutsch" },
|
||||
{ code: "el", label: "Ελληνικά" },
|
||||
{ code: "he", label: "עברית" },
|
||||
{ code: "hi", label: "हिन्दी" },
|
||||
{ code: "hu", label: "Magyar" },
|
||||
{ code: "id", label: "Indonesia" },
|
||||
{ code: "it", label: "Italiano" },
|
||||
{ code: "ja", label: "日本語" },
|
||||
{ code: "ko", label: "한국어" },
|
||||
{ code: "no", label: "Norsk" },
|
||||
{ code: "pl", label: "Polski" },
|
||||
{ code: "pt-PT", label: "Português (PT)" },
|
||||
{ code: "pt-BR", label: "Português (BR)" },
|
||||
{ code: "ro", label: "Română" },
|
||||
{ code: "ru", label: "Русский" },
|
||||
{ code: "sr", label: "Српски" },
|
||||
{ code: "es-ES", label: "Español" },
|
||||
{ code: "sv-SE", label: "Svenska" },
|
||||
{ code: "th", label: "ไทย" },
|
||||
{ code: "tr", label: "Türkçe" },
|
||||
{ code: "uk", label: "Українська" },
|
||||
{ code: "vi", label: "Tiếng Việt" },
|
||||
];
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
export function isHexColor(value: string): boolean {
|
||||
return /^#[0-9a-fA-F]{6}$/.test(value) || /^#[0-9a-fA-F]{3}$/.test(value);
|
||||
}
|
||||
|
||||
type CloudPrefs = Parameters<typeof saveUserPreferences>[0];
|
||||
|
||||
/** Saves to the account only for users who sync settings to the server. */
|
||||
async function saveIfCloud(prefs: CloudPrefs): Promise<void> {
|
||||
try {
|
||||
const current = await getUserPreferences();
|
||||
if (current?.storageMode === "cloud") await saveUserPreferences(prefs);
|
||||
} catch {
|
||||
// The local copy already holds the change.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme, accent, font and language, applied right away and stored the same
|
||||
* way the profile settings store them.
|
||||
*/
|
||||
export function useAppearanceSettings() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [accent, setAccentState] = useState(
|
||||
() => localStorage.getItem("termix-accent") ?? DEFAULT_ACCENT,
|
||||
);
|
||||
const [fontSize, setFontSizeState] = useState<FontSizeId>(
|
||||
() => (localStorage.getItem("termix-font-size") as FontSizeId) ?? "md",
|
||||
);
|
||||
const [uiFont, setUiFontState] = useState<UiFontId>(
|
||||
() =>
|
||||
(localStorage.getItem("termix-ui-font") as UiFontId) ?? "jetbrains-mono",
|
||||
);
|
||||
const [language, setLanguageState] = useState(() =>
|
||||
normalizeLanguageCode(localStorage.getItem("i18nextLng")),
|
||||
);
|
||||
|
||||
return {
|
||||
theme,
|
||||
accent,
|
||||
fontSize,
|
||||
uiFont,
|
||||
language,
|
||||
setTheme(id: ThemeId) {
|
||||
setTheme(id);
|
||||
void saveIfCloud({ theme: id });
|
||||
},
|
||||
setAccent(value: string) {
|
||||
setAccentState(value);
|
||||
localStorage.setItem("termix-accent", value);
|
||||
applyAccentColor(value);
|
||||
void saveIfCloud({ accentColor: value });
|
||||
},
|
||||
setFontSize(id: FontSizeId) {
|
||||
setFontSizeState(id);
|
||||
applyFontSize(id);
|
||||
void saveIfCloud({ fontSize: id });
|
||||
},
|
||||
setUiFont(id: UiFontId) {
|
||||
setUiFontState(id);
|
||||
applyUiFont(id);
|
||||
},
|
||||
setLanguage(code: string) {
|
||||
void changeAppLanguage(code)
|
||||
.then((next) => {
|
||||
setLanguageState(next);
|
||||
void saveIfCloud({ language: next });
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ThemePreview({ id }: { id: ThemeId }) {
|
||||
if (id === "system") {
|
||||
return (
|
||||
<div className="flex h-14 w-full">
|
||||
<div className="flex-1">
|
||||
<MiniWindow {...THEME_PALETTES.light} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<MiniWindow {...THEME_PALETTES.dark} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="h-14 w-full">
|
||||
<MiniWindow {...THEME_PALETTES[id]} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniWindow({
|
||||
bg,
|
||||
panel,
|
||||
text,
|
||||
}: {
|
||||
bg: string;
|
||||
panel: string;
|
||||
text: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full w-full" style={{ background: bg }}>
|
||||
<div
|
||||
className="flex w-1/4 flex-col gap-1 p-1"
|
||||
style={{ background: panel }}
|
||||
>
|
||||
<div className="h-1 w-3/4" style={{ background: text, opacity: 0.6 }} />
|
||||
<div
|
||||
className="h-1 w-1/2"
|
||||
style={{ background: text, opacity: 0.35 }}
|
||||
/>
|
||||
<div
|
||||
className="h-1 w-2/3"
|
||||
style={{ background: text, opacity: 0.35 }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-1 p-1.5">
|
||||
<div className="h-1 w-1/2 bg-accent-brand" />
|
||||
<div
|
||||
className="h-1 w-5/6"
|
||||
style={{ background: text, opacity: 0.45 }}
|
||||
/>
|
||||
<div className="h-1 w-2/3" style={{ background: text, opacity: 0.3 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemeGrid({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: ThemeId;
|
||||
onChange: (id: ThemeId) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||
{THEMES.map(({ id }) => {
|
||||
const active = value === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onChange(id)}
|
||||
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} />
|
||||
<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])}
|
||||
{active && <Check size={11} className="text-accent-brand" />}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccentPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [lastValue, setLastValue] = useState(value);
|
||||
if (value !== lastValue) {
|
||||
setLastValue(value);
|
||||
setDraft(value);
|
||||
}
|
||||
|
||||
const commit = () => {
|
||||
const next = draft.trim();
|
||||
if (isHexColor(next)) onChange(next);
|
||||
else setDraft(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-6 gap-1.5 sm:grid-cols-12">
|
||||
{ACCENT_PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
title={color.label}
|
||||
aria-label={color.label}
|
||||
aria-pressed={value === color.value}
|
||||
onClick={() => onChange(color.value)}
|
||||
className="flex h-7 items-center justify-center border border-border"
|
||||
style={{ background: color.value }}
|
||||
>
|
||||
{value === color.value && (
|
||||
<Check size={12} className="text-white drop-shadow" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border border-border bg-muted/30 px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => colorInputRef.current?.click()}
|
||||
className="size-5 shrink-0 cursor-pointer border border-border/60"
|
||||
style={{ background: value }}
|
||||
title={t("newUi.sidebar.userProfile.colorPickerTooltip")}
|
||||
/>
|
||||
<input
|
||||
ref={colorInputRef}
|
||||
type="color"
|
||||
value={
|
||||
value.startsWith("#") && value.length === 7 ? value : "#f97316"
|
||||
}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<Input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commit();
|
||||
}}
|
||||
placeholder="#f97316"
|
||||
className="h-6 min-w-0 flex-1 border-0 bg-transparent p-0 font-mono text-[11px] focus-visible:ring-0"
|
||||
/>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">hex</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FontSizePicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: FontSizeId;
|
||||
onChange: (id: FontSizeId) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
{FONT_SIZES.map((size) => (
|
||||
<button
|
||||
key={size.id}
|
||||
type="button"
|
||||
aria-pressed={value === size.id}
|
||||
onClick={() => onChange(size.id)}
|
||||
className={`flex-1 border py-1.5 text-[10px] font-bold transition-colors ${
|
||||
value === size.id
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
|
||||
: "border-border text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{size.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UiFontSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: UiFontId;
|
||||
onChange: (id: UiFontId) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select2
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as UiFontId)}
|
||||
className="w-full border border-border bg-background px-2.5 py-1.5 text-xs text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{UI_FONTS.map((font) => (
|
||||
<option key={font.id} value={font.id}>
|
||||
{font.label}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
);
|
||||
}
|
||||
|
||||
export function LanguageSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (code: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select2
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full border border-border bg-background px-2.5 py-1.5 text-xs text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<option key={lang.code} value={lang.code}>
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
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,4 +1,4 @@
|
||||
import { isFieldActive } from "@termix/plugin-sdk/settings";
|
||||
import { isFieldActive } from "@termix-ssh/plugin-sdk/settings";
|
||||
import type { PluginSettingsField } from "@/api/plugins-api";
|
||||
|
||||
/**
|
||||
@@ -19,3 +19,24 @@ export function isFieldShown(
|
||||
export function hasVisibleFields(fields: PluginSettingsField[]): boolean {
|
||||
return fields.some((field) => !field.hidden);
|
||||
}
|
||||
|
||||
/** Whether Settings lists a page for this plugin, by the same rule it uses. */
|
||||
export function hasSettingsPage(
|
||||
plugin: {
|
||||
enabled: boolean;
|
||||
contributes?: {
|
||||
settings?: {
|
||||
user?: PluginSettingsField[];
|
||||
admin?: PluginSettingsField[];
|
||||
};
|
||||
} | null;
|
||||
},
|
||||
isAdmin: boolean,
|
||||
): boolean {
|
||||
const settings = plugin.contributes?.settings;
|
||||
return (
|
||||
plugin.enabled &&
|
||||
(hasVisibleFields(settings?.user ?? []) ||
|
||||
(isAdmin && hasVisibleFields(settings?.admin ?? [])))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
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;
|
||||
/** 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,
|
||||
}),
|
||||
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.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;
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
// 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",
|
||||
"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",
|
||||
"admin.rolePermissions.editAction",
|
||||
"admin.rolePermissions.loadError",
|
||||
"admin.rolePermissions.pluginDisabled",
|
||||
"admin.rolePermissions.save",
|
||||
"admin.rolePermissions.saveError",
|
||||
"admin.rolePermissions.saved",
|
||||
"admin.rolePermissions.saving",
|
||||
"admin.rolesCount",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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.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": [
|
||||
"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,17 +1,11 @@
|
||||
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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { ElectronLoginForm } from "@/auth/ElectronLoginForm";
|
||||
import {
|
||||
completeLink,
|
||||
@@ -28,6 +22,7 @@ import {
|
||||
getSavedServerUrls,
|
||||
rememberServerUrl,
|
||||
} from "./saved-servers";
|
||||
import { DocsLink } from "@/components/docs-link";
|
||||
|
||||
type Step = "server" | "signin" | "choose" | "finishing";
|
||||
|
||||
@@ -223,233 +218,238 @@ export function LinkServerDialog({
|
||||
);
|
||||
const wide = step === "signin";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={`bg-card border border-border rounded-none ${
|
||||
wide ? "sm:max-w-4xl h-[80vh] flex flex-col p-0 gap-0" : "sm:max-w-lg"
|
||||
}`}
|
||||
// The sign-in step is a page from another origin in an iframe; clicks
|
||||
// inside it look like outside clicks to the dialog.
|
||||
onPointerDownOutside={(event) => wide && event.preventDefault()}
|
||||
onInteractOutside={(event) => wide && event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className={wide ? "sr-only" : undefined}>
|
||||
<DialogTitle>
|
||||
{relogin ? t("sync.wizard.reloginTitle") : t("sync.wizard.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "server" && t("sync.wizard.serverDescription")}
|
||||
{step === "signin" &&
|
||||
t("sync.wizard.signinDescription", { url: target.serverUrl })}
|
||||
{step === "choose" && t("sync.wizard.chooseDescription")}
|
||||
{step === "finishing" && t("sync.wizard.finishingDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{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
|
||||
type="submit"
|
||||
className="rounded-none"
|
||||
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")
|
||||
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 })
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
<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>
|
||||
)}
|
||||
{(
|
||||
[
|
||||
{
|
||||
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
|
||||
type="button"
|
||||
className="rounded-none"
|
||||
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")}
|
||||
{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>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</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,3 +1,4 @@
|
||||
import { InlineView } from "@/components/surface/surface-scope";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -17,14 +18,6 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { Switch } from "@/components/switch";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { useSyncStatus } from "@/hooks/use-sync-status";
|
||||
import { refreshSyncStatus, setSyncStatus } from "@/lib/sync-status";
|
||||
import { notifySyncChanged } from "@/lib/linked-server";
|
||||
@@ -205,7 +198,8 @@ export function SyncPanel() {
|
||||
<li>{t("sync.unlinkedPointAccount")}</li>
|
||||
</ul>
|
||||
<Button
|
||||
className="self-start rounded-none"
|
||||
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" />
|
||||
@@ -275,7 +269,7 @@ export function SyncPanel() {
|
||||
{t("sync.account")}
|
||||
</span>
|
||||
<span className="font-semibold truncate">
|
||||
{status.account?.username || "—"}
|
||||
{status.account?.username || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
@@ -309,8 +303,9 @@ export function SyncPanel() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{state === "signed_out" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] rounded-none"
|
||||
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")}
|
||||
@@ -542,8 +537,9 @@ export function SyncPanel() {
|
||||
</div>
|
||||
{proxyDraft && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start h-7 text-[10px] rounded-none"
|
||||
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 () => {
|
||||
@@ -565,45 +561,12 @@ export function SyncPanel() {
|
||||
relogin={status.serverUrl ? { serverUrl: status.serverUrl } : undefined}
|
||||
/>
|
||||
|
||||
<Dialog open={unlinkOpen} onOpenChange={setUnlinkOpen}>
|
||||
<DialogContent className="bg-card border border-border rounded-none sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sync.unlinkTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("sync.unlinkDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<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>
|
||||
<DialogFooter>
|
||||
<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"
|
||||
@@ -626,9 +589,46 @@ export function SyncPanel() {
|
||||
{busy === "unlink" && <Loader2 className="size-4 animate-spin" />}
|
||||
{t("sync.unlink")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user