* feat: AI translations at release, 7 new languages and regional fallback

* fix: proper plural forms for count strings and full translations

* fix: plural forms for selected, hidden and active counts

* fix: synced plugin updates failing on desktop and server-only plugins shown as stopped

* fix: restore rounded corners on app icons and favicon

* feat: desktop app auto updates with automatic, ask first or never modes

* feat: community and custom plugin registries

* feat: plugin guide for AI tools in the SDK with a lint check

* fix: keep the terminal WebSocket open past nginx's 60s default (#1588)

* chore: update changelog

* feat: show plugin download counts only and stop CI builds inflating them

* feat: hide installed plugins in the browse tab by default

* chore: pin bundled plugins to latest releases

* chore: update translations

* fix: focusing a saved password field and saving no longer erases it

* fix: tell desktop users when their 2.8 data needs 2.9 first

* fix: point windows update info at the x64 installer

* fix: keep desktop client tunnel sockets alive behind proxies

* chore: pin bundled plugins to latest releases

* fix: cover client tunnel keepalive in the c2s runtime tests

* fix: typing after opening a host no longer goes to the host search

* fix: accept tab and = separators in SSH config import (#1598)

* chore: update changelog

* chore: drop plugin versions from core changelog

* chore: update translations

* chore: lint, format, and bump version to 26.10.1

---------

Co-authored-by: Darnel Hunter <68697450+innotelinc@users.noreply.github.com>
Co-authored-by: Iain <Iain1970@protonmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 11:35:48 -05:00
1 parent 6873bc92bb
commit 62ac931468
174 files changed
+94334 -39995

No files matched your search

+2
View File
@@ -42,6 +42,7 @@ import {
type EditingWidth,
} from "@/components/surface/surface-scope";
import { OnboardingHost } from "@/onboarding/OnboardingHost";
import { DesktopUpdateToasts } from "@/shell/DesktopUpdateToasts";
import { SplitView, type SplitViewActions } from "@/shell/split/SplitView";
import { SplitDropOverlay } from "@/shell/split/SplitDropOverlay";
import {
@@ -3184,6 +3185,7 @@ export function AppShell({
</Suspense>
)}
<OnboardingHost />
{isElectron() && <DesktopUpdateToasts />}
</ServerStatusProvider>
);
}
+77 -4
View File
@@ -221,6 +221,8 @@ export interface PluginSummary {
enabled: boolean;
/** enabled | disabled | blocked | failed, or the loader's live state. */
state: string;
/** A linked desktop leaves this plugin to its server. */
runsOnServer?: boolean;
contributes: PluginContributions | null;
/** Lucide icon name from the manifest. */
icon?: string;
@@ -289,8 +291,14 @@ export interface RegistryPluginVersion {
size: number;
}
export type RegistryTier = "official" | "community" | "custom";
export interface RegistryPluginEntry {
id: string;
/** Which registry this entry comes from. */
registryId?: string;
registryName?: string;
tier?: RegistryTier;
name: string;
description: string;
author: string;
@@ -313,9 +321,18 @@ export interface RegistryPluginEntry {
pinnedVersion: string | null;
autoUpdate: boolean;
bundled: boolean;
/** Active installs when known, release downloads otherwise. */
installCount: number | null;
installCountSource: string | null;
/** How often the release files were downloaded, from the registry. */
downloads: number | null;
}
export interface RegistryListingSource {
id: string;
name: string;
tier: RegistryTier;
url: string;
lastCheckedAt: string | null;
error: string | null;
plugins: number;
}
export interface RegistryListing {
@@ -325,6 +342,7 @@ export interface RegistryListing {
lastCheckedAt: string | null;
error: string | null;
};
registries?: RegistryListingSource[];
managedByServer: boolean;
plugins: RegistryPluginEntry[];
}
@@ -373,7 +391,7 @@ export async function getPluginRegistry(
/** `capabilities` is exactly what the consent prompt showed. */
export async function installPlugin(
pluginId: string,
options: { version?: string; capabilities: string[] },
options: { version?: string; capabilities: string[]; registryId?: string },
): Promise<{ id: string; version: string; state: string }> {
try {
const response = await rbacApi.post(
@@ -405,6 +423,61 @@ export async function updatePlugin(
}
}
export interface CustomRegistryInfo {
id: string;
name: string;
url: string;
keyId: string;
enabled: boolean;
addedAt: string;
}
export interface RegistriesState {
managedByServer: boolean;
official: { id: string; url: string };
community: { id: string; url: string; enabled: boolean };
custom: CustomRegistryInfo[];
}
export interface RegistryPreview {
id: string;
name: string;
url: string;
keyId: string;
plugins: number;
}
export async function getRegistries(): Promise<RegistriesState> {
const response = await rbacApi.get("/plugins/registries");
return response.data;
}
export async function previewRegistry(url: string): Promise<RegistryPreview> {
const response = await rbacApi.post("/plugins/registries/preview", { url });
return response.data;
}
export async function addRegistry(
url: string,
keyId: string,
): Promise<CustomRegistryInfo> {
const response = await rbacApi.post("/plugins/registries", { url, keyId });
return response.data;
}
export async function setRegistryEnabled(
id: string,
enabled: boolean,
): Promise<void> {
await rbacApi.patch(`/plugins/registries/${encodeURIComponent(id)}`, {
enabled,
});
}
export async function removeRegistry(id: string): Promise<void> {
await rbacApi.delete(`/plugins/registries/${encodeURIComponent(id)}`);
}
export async function updateAllPlugins(): Promise<UpdateAllResult> {
try {
const response = await rbacApi.post("/plugins/update-all");
+4 -39
View File
@@ -59,6 +59,7 @@ import {
normalizeLanguageCode,
rememberLoginLanguage,
} from "@/i18n/i18n";
import { LANGUAGES } from "@/i18n/languages";
import {
removeSilentSigninFromSearch,
shouldTriggerSilentSignin,
@@ -66,44 +67,6 @@ import {
import { Select2 } from "@/components/select2";
import { cn } from "@/lib/utils";
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" },
];
function LanguageRow({
label,
value,
@@ -1138,7 +1101,9 @@ export function Auth({ onLogin }: AuthProps) {
port: embeddedServerFailure.port,
})
: t("messages.embeddedServerPortInUseUnknownPort")
: t("messages.embeddedServerCrashed");
: embeddedServerFailure.reason === "old-database"
? t("messages.embeddedServerOldDatabase")
: t("messages.embeddedServerCrashed");
return (
<div className="fixed inset-0 flex items-center justify-center bg-background p-6">
+19 -41
View File
@@ -3,46 +3,14 @@ import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import enTranslation from "../locales/en.json";
import { TRANSLATED_LANGUAGES } from "./languages";
type LocaleModule = { default: ResourceKey };
/** i18n code -> translated file name, shared by core and plugin locales. */
export const LOCALE_FILES = {
af: "af_ZA",
ar: "ar_SA",
bn: "bn_BD",
bg: "bg_BG",
ca: "ca_ES",
cs: "cs_CZ",
da: "da_DK",
de: "de_DE",
el: "el_GR",
"es-ES": "es_ES",
fi: "fi_FI",
fr: "fr_FR",
he: "he_IL",
hi: "hi_IN",
hu: "hu_HU",
id: "id_ID",
it: "it_IT",
ja: "ja_JP",
ko: "ko_KR",
nl: "nl_NL",
no: "no_NO",
pl: "pl_PL",
"pt-PT": "pt_PT",
"pt-BR": "pt_BR",
ro: "ro_RO",
ru: "ru_RU",
sr: "sr_SP",
"sv-SE": "sv_SE",
th: "th_TH",
tr: "tr_TR",
uk: "uk_UA",
vi: "vi_VN",
"zh-CN": "zh_CN",
"zh-TW": "zh_TW",
} as const satisfies Record<string, string>;
export const LOCALE_FILES: Record<string, string> = Object.fromEntries(
TRANSLATED_LANGUAGES.map(({ code, file }) => [code, file]),
);
const translatedFiles = import.meta.glob<LocaleModule>(
"../locales/translated/*.json",
@@ -94,8 +62,20 @@ export function normalizeLanguageCode(language?: string | null): string {
);
if (exactMatch) return exactMatch;
const baseLanguage = normalized.split("-")[0];
return supportedLngs.includes(baseLanguage) ? baseLanguage : "en";
const baseLanguage = normalized.split("-")[0].toLowerCase();
if (supportedLngs.includes(baseLanguage)) return baseLanguage;
// Regional variants we don't ship (es-MX, pt, sv) use the closest one we do.
if (baseLanguage === "zh") {
return /-(tw|hk|mo|hant)\b/i.test(normalized) ? "zh-TW" : "zh-CN";
}
if (baseLanguage === "nb" || baseLanguage === "nn") return "no";
if (baseLanguage === "pt") return "pt-BR";
return (
supportedLngs.find((supported) =>
supported.toLowerCase().startsWith(`${baseLanguage}-`),
) ?? "en"
);
}
const localeBackend: BackendModule = {
@@ -106,9 +86,7 @@ const localeBackend: BackendModule = {
if (namespace !== CORE_NAMESPACE) {
const file =
normalizedLanguage === "en"
? "en"
: (LOCALE_FILES as Record<string, string>)[normalizedLanguage];
normalizedLanguage === "en" ? "en" : LOCALE_FILES[normalizedLanguage];
if (!pluginLocaleResolver || !file) {
callback(null, {});
return;
+17
View File
@@ -0,0 +1,17 @@
import languages from "../locales/languages.json";
/** One translated language. The translator in Termix-Registry keeps a copy of this list. */
export interface Language {
code: string;
file: string;
name: string;
label: string;
}
export const TRANSLATED_LANGUAGES: readonly Language[] = languages;
/** Every language the app can show, English first, for language pickers. */
export const LANGUAGES: readonly { code: string; label: string }[] = [
{ code: "en", label: "English" },
...TRANSLATED_LANGUAGES.map(({ code, label }) => ({ code, label })),
];
+86
View File
@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { isElectron } from "@/lib/electron";
export type DesktopUpdateMode = "automatic" | "prompt" | "never";
export type DesktopInstallKind = "auto" | "manual" | "store" | "disabled";
export type DesktopUpdateStatus =
| "idle"
| "checking"
| "available"
| "downloading"
| "downloaded"
| "up-to-date"
| "error";
export interface DesktopUpdateState {
kind: DesktopInstallKind;
mode: DesktopUpdateMode;
currentVersion: string;
status: DesktopUpdateStatus;
version: string | null;
releaseUrl: string | null;
progress: number | null;
error: string | null;
}
interface DesktopUpdatesBridge {
getState: () => Promise<DesktopUpdateState | null>;
check: () => Promise<DesktopUpdateState | null>;
download: () => Promise<DesktopUpdateState | null>;
install: () => Promise<boolean>;
setMode: (mode: DesktopUpdateMode) => Promise<DesktopUpdateState | null>;
onState: (callback: (state: DesktopUpdateState) => void) => () => void;
}
type UpdatesWindow = Window &
typeof globalThis & {
electronAPI?: { updates?: DesktopUpdatesBridge };
};
/** The main process updater, or null in the browser and on older builds. */
export function desktopUpdates(): DesktopUpdatesBridge | null {
if (!isElectron()) return null;
return (window as UpdatesWindow).electronAPI?.updates ?? null;
}
/** Whether this install has an updater the user can configure. */
export function canConfigureUpdates(
state: DesktopUpdateState | null,
): state is DesktopUpdateState {
return !!state && state.kind !== "store" && state.kind !== "disabled";
}
/** Which toast, if any, this state calls for. */
export function updateToastFor(
state: DesktopUpdateState | null,
): "prompt" | "manual" | "ready" | null {
if (!state || !state.version || state.mode === "never") return null;
if (state.status === "downloaded") return "ready";
if (state.status !== "available") return null;
if (state.kind === "manual") return "manual";
if (state.kind === "auto" && state.mode === "prompt") return "prompt";
return null;
}
export function useDesktopUpdate(): DesktopUpdateState | null {
const [state, setState] = useState<DesktopUpdateState | null>(null);
useEffect(() => {
const bridge = desktopUpdates();
if (!bridge) return;
let active = true;
bridge
.getState()
.then((next) => active && next && setState(next))
.catch(() => {});
const off = bridge.onState((next) => active && setState(next));
return () => {
active = false;
off();
};
}, []);
return state;
}
+3 -1
View File
@@ -38,5 +38,7 @@
"pluginCatalog": "plugins",
"cli": "cli",
"develop": "develop",
"communityRegistry": "develop/community-registry"
"communityRegistry": "develop/community-registry",
"pluginRegistries": "guide/plugins#registries",
"hostRegistry": "develop/registries"
}
+9 -4
View File
@@ -1,6 +1,7 @@
import { isElectron } from "@/lib/electron";
export type EmbeddedServerFailureReason = "port-in-use" | "crashed";
export type EmbeddedServerFailureReason =
"port-in-use" | "old-database" | "crashed";
export interface EmbeddedServerFailure {
reason: EmbeddedServerFailureReason;
@@ -41,9 +42,13 @@ export async function getEmbeddedServerFailure(): Promise<EmbeddedServerFailure
// An unrecognized reason from a newer/older main process still means the
// backend is down, which is the part the caller acts on -- so it is
// reported as the generic crash rather than dropped.
return failure.reason === "port-in-use"
? { reason: "port-in-use", port: failure.port ?? null }
: { reason: "crashed", port: null };
if (failure.reason === "port-in-use") {
return { reason: "port-in-use", port: failure.port ?? null };
}
if (failure.reason === "old-database") {
return { reason: "old-database", port: null };
}
return { reason: "crashed", port: null };
} catch {
// No status channel, or the main process is tearing down. Neither is
// evidence that the backend failed, so keep waiting.
+6 -2
View File
@@ -1,5 +1,9 @@
# Translations
Only `en.json` needs to be updated. Every other language in `translated/` is filled in automatically for any string that doesn't have a translation yet.
Only `en.json` needs to be updated. Every other language in `translated/` is translated automatically on each release (and by the Translate workflow) with the Termix LLM server, using the translator in [Termix-Registry](https://github.com/Termix-SSH/Termix-Registry/tree/main/translate).
If an automatic translation is wrong, fix it in that language's file. Strings that already have a translation are never overwritten.
New strings and strings whose English changed are translated. `translations-lock.json` tracks which English text each translation was made from, so don't edit it by hand.
If an automatic translation is wrong, fix it in that language's file. A fixed string is kept until its English changes.
To add a language, add it to `languages.json` here and in `translate/languages.json` in Termix-Registry.
+120 -28
View File
@@ -233,6 +233,37 @@
"desktopLinked": "Linked to {{server}}",
"pluginsDocsLink": "Read about each one"
},
"desktopUpdates": {
"title": "Desktop app",
"mode": "Updates",
"modeHint": "How this app gets new versions.",
"manualModeHint": "This copy can not update itself, so Termix only tells you when a new version is out.",
"modeAutomatic": "Automatic",
"modePrompt": "Ask first",
"modeNever": "Never",
"automaticBody": "Download new versions in the background and install them when you restart.",
"promptBody": "Tell me when a new version is out and let me choose when to download it.",
"neverBody": "Do not check for updates. You can still check by hand in Settings.",
"onboardingTitle": "App updates",
"onboardingBody": "The desktop app can now update itself. Pick how you want new versions to arrive. You can change this later in Settings, Updates.",
"onboardingManual": "This copy of the app was installed in a way that can not update itself, so Termix will tell you when a new version is out with a link to download it.",
"status": "Version {{version}}",
"statusIdle": "Not checked yet",
"statusChecking": "Checking for updates",
"statusAvailable": "Version {{version}} is available",
"statusDownloading": "Downloading {{version}} ({{progress}}%)",
"statusReady": "Version {{version}} is ready. Restart to install it.",
"statusUpToDate": "You have the newest version",
"statusError": "Could not check for updates. Try again later.",
"checkNow": "Check now",
"download": "Download",
"restart": "Restart now",
"later": "Later",
"available": "Termix {{version}} is available",
"manualHint": "Download it from the release page and install it over this one.",
"ready": "Termix {{version}} is ready to install",
"readyHint": "It installs the next time Termix restarts."
},
"nav": {
"home": "Home",
"terminal": "Terminal",
@@ -280,7 +311,8 @@
},
"searchEverything": "Search hosts, tabs and actions",
"search": "Search",
"hiddenCount": "{{count}} hidden",
"hiddenCount_one": "{{count}} hidden",
"hiddenCount_other": "{{count}} hidden",
"showInRail": "Show in sidebar",
"moveUp": "Move up",
"moveDown": "Move down",
@@ -344,7 +376,8 @@
"unsavedChangesDescription": "Your host changes have not been saved. Discard them?",
"keepEditing": "Keep editing",
"discardChanges": "Discard changes",
"movedToFolder": "Moved {{count}} host(s) to \"{{folder}}\"",
"movedToFolder_one": "Moved {{count}} host to \"{{folder}}\"",
"movedToFolder_other": "Moved {{count}} hosts to \"{{folder}}\"",
"editHostTooltip": "Edit host",
"behavior": "Behavior",
"scrollbackBuffer": "Scrollback Buffer",
@@ -545,7 +578,8 @@
"collapseSubHosts": "Collapse sub-hosts",
"expandSubHosts": "Expand sub-hosts",
"cannotNestUnderDescendant": "Can't nest a host under its own sub-host",
"movedToParent": "Moved {{count}} host(s) under \"{{parent}}\"",
"movedToParent_one": "Moved {{count}} host under \"{{parent}}\"",
"movedToParent_other": "Moved {{count}} hosts under \"{{parent}}\"",
"newFolder": "New folder",
"createFolderTitle": "Create folder",
"editFolderTitle": "Edit folder",
@@ -573,7 +607,8 @@
"folderSaved": "Folder saved",
"failedToSaveFolder": "Failed to save folder",
"folderDeleted": "Deleted folder \"{{name}}\"",
"deleteFolderConfirm": "Delete \"{{name}}\" and its {{count}} host(s)? This cannot be undone.",
"deleteFolderConfirm_one": "Delete \"{{name}}\" and its {{count}} host? This cannot be undone.",
"deleteFolderConfirm_other": "Delete \"{{name}}\" and its {{count}} hosts? This cannot be undone.",
"failedToMoveHosts": "Failed to move hosts",
"failedToReorder": "Failed to reorder",
"expandAll": "Expand all folders",
@@ -641,7 +676,8 @@
"copyLink": "Copy Link",
"deleteCredentialConfirm": "Delete credential \"{{name}}\"?",
"deletedCredential": "Deleted {{name}}",
"deleteHostsConfirm": "Delete {{count}} host{{plural}}? This cannot be undone.",
"deleteHostsConfirm_one": "Delete {{count}} host? This cannot be undone.",
"deleteHostsConfirm_other": "Delete {{count}} hosts? This cannot be undone.",
"movedToRoot": "Moved to root",
"addTagsPlaceholder": "Add tags...",
"authDetails": "Authentication Details",
@@ -700,7 +736,8 @@
"groupFeatureFlags": "Plugin settings",
"groupAdvanced": "Advanced config",
"preview": "Preview",
"moreHosts": "... {{count}} more hosts",
"moreHosts_one": "... {{count}} more host",
"moreHosts_other": "... {{count}} more hosts",
"summary": "{{selected}} of {{total}} hosts",
"credentialsIncluded": "credentials included",
"credentialsExcluded": "credentials excluded",
@@ -713,16 +750,20 @@
"sampleDownloaded": "Sample file downloaded",
"failedToDeleteCredential2": "Failed to delete credential",
"noFolderOption": "(No folder)",
"nSelected": "{{count}} selected",
"nSelected_one": "{{count}} selected",
"nSelected_other": "{{count}} selected",
"featuresMenu": "Plugins",
"moveMenu": "Move",
"connectSelected": "Connect",
"cancelSelection": "Cancel",
"deletedCount": "Deleted {{count}} hosts",
"failedToDeleteCount": "Failed to delete {{count}} hosts",
"deletedCount_one": "Deleted {{count}} host",
"deletedCount_other": "Deleted {{count}} hosts",
"failedToDeleteCount_one": "Failed to delete {{count}} host",
"failedToDeleteCount_other": "Failed to delete {{count}} hosts",
"duplicatedHost": "Duplicated \"{{name}}\"",
"failedToDuplicateHost": "Failed to duplicate host",
"updatedCount": "Updated {{count}} hosts",
"updatedCount_one": "Updated {{count}} host",
"updatedCount_other": "Updated {{count}} hosts",
"friendlyNameLabel": "Friendly Name",
"descriptionLabel": "Description",
"loadingHosts": "Loading hosts...",
@@ -762,7 +803,8 @@
"shareHostTitle": "Share: {{name}}",
"shareFolder": "Share Folder",
"shareFolderTitle": "Share folder: {{name}}",
"folderSharedSuccessfully": "Shared {{count}} host(s) in folder",
"folderSharedSuccessfully_one": "Shared {{count}} host in folder",
"folderSharedSuccessfully_other": "Shared {{count}} hosts in folder",
"failedToShareFolder": "Failed to share folder",
"sharing": {
"loadError": "Failed to load sharing data. Please try again.",
@@ -921,7 +963,8 @@
"unsplit": "Unsplit",
"layouts": "Layouts",
"applyLayout": "Change layout",
"maxPanes": "A split can hold up to {{count}} panes",
"maxPanes_one": "A split can hold up to {{count}} pane",
"maxPanes_other": "A split can hold up to {{count}} panes",
"dropSplit": "Drop to split",
"dropHere": "Drop to show here",
"paneActions": "Pane actions",
@@ -1084,6 +1127,7 @@
"databaseConnectionFailed": "Failed to connect to the database server",
"embeddedServerPortInUse": "Port {{port}} is already in use by another program. This is usually a Termix Docker container or a leftover Termix process from a previous session. Close it, then restart Termix.",
"embeddedServerPortInUseUnknownPort": "One of the ports Termix needs is already in use by another program. Close whatever is using it, then restart Termix.",
"embeddedServerOldDatabase": "Your local data is from Termix 2.8 or older and has to pass through 2.9 first. Install Termix 2.9.1, open it once so it can move your data, then update to this version again.",
"embeddedServerCrashed": "The local server stopped unexpectedly. Restart Termix, and check the desktop log if this keeps happening.",
"resetCodeSent": "Reset code sent to Docker logs",
"codeVerified": "Code verified successfully",
@@ -1237,7 +1281,8 @@
"statusCheck": "Status Check",
"sec": "sec",
"logLevel": "Log Level",
"usersCount": "{{count}} users",
"usersCount_one": "{{count}} user",
"usersCount_other": "{{count}} users",
"searchUsers": "Search users...",
"noUsers": "No users",
"noUsersMatch": "No users match your search",
@@ -1247,7 +1292,8 @@
"roleName": "Name",
"roleDisplayName": "Display Name",
"roleDescription": "Description",
"rolesCount": "{{count}} roles",
"rolesCount_one": "{{count}} role",
"rolesCount_other": "{{count}} roles",
"createRole": "Create",
"creating": "Creating...",
"exportDatabase": "Export Database",
@@ -1261,7 +1307,8 @@
"changeFile": "Change",
"import": "Import",
"importing": "Importing...",
"apiKeysCount": "{{count}} keys",
"apiKeysCount_one": "{{count}} key",
"apiKeysCount_other": "{{count}} keys",
"apiKeysDocsLink": "View docs",
"apiKeysCliHint": "Use an API key with the Termix CLI to manage servers from your terminal.",
"apiKeysCliLink": "View CLI docs",
@@ -1283,7 +1330,8 @@
"systemBadge": "SYS",
"customBadge": "CUSTOM",
"youBadge": "YOU",
"sessionsActive": "{{count}} active",
"sessionsActive_one": "{{count}} active",
"sessionsActive_other": "{{count}} active",
"sessionActive": "Active: {{time}}",
"sessionExpires": "Exp: {{time}}",
"revokeAll": "All",
@@ -1392,14 +1440,16 @@
"exportUserDataDesc": "Download this user's hosts, credentials and file manager data as JSON. Secrets are decrypted.",
"exportUserDataSuccess": "User data exported",
"exportUserDataFailed": "Failed to export user data",
"hostsCount": "{{count}} hosts",
"hostsCount_one": "{{count}} host",
"hostsCount_other": "{{count}} hosts",
"addHostForUser": "Add Host",
"noHostsForUser": "This user has no hosts",
"connectToHost": "Connect",
"deleteHostConfirm": "Delete host \"{{name}}\" belonging to {{username}}?",
"hostDeletedSuccess": "Host deleted",
"hostDeleteFailed": "Failed to delete host",
"credentialsCount": "{{count}} credentials",
"credentialsCount_one": "{{count}} credential",
"credentialsCount_other": "{{count}} credentials",
"addCredentialForUser": "Add Credential",
"noCredentialsForUser": "This user has no credentials",
"deleteCredentialConfirm": "Delete credential \"{{name}}\" belonging to {{username}}?",
@@ -1428,7 +1478,8 @@
"importError": "Database import failed",
"sectionHostDefaults": "Host Defaults",
"rolePermissions": {
"count": "{{count}} permissions",
"count_one": "{{count}} permission",
"count_other": "{{count}} permissions",
"editAction": "Edit permissions",
"loadError": "Failed to load the permissions catalog",
"saved": "Role permissions saved",
@@ -1647,7 +1698,8 @@
"apiKeyCreatedWarning": "Key created - copy it now, it won't be shown again.",
"copyApiKey": "Copy API key",
"copiedToClipboard": "Copied to clipboard",
"apiKeyCount": "{{count}} keys",
"apiKeyCount_one": "{{count}} key",
"apiKeyCount_other": "{{count}} keys",
"newKey": "New Key",
"noApiKeys": "No API keys yet.",
"apiKeyActive": "Active",
@@ -1848,19 +1900,23 @@
"tabs": {
"installed": "Installed",
"browse": "Browse",
"updates": "Updates"
"updates": "Updates",
"registries": "Registries"
},
"status": {
"summary": "{{installed}} installed, {{running}} running",
"running": "Running",
"disabled": "Disabled",
"failed": "Stopped unexpectedly",
"blocked": "Waiting on another plugin"
"blocked": "Waiting on another plugin",
"server": "Runs on your server"
},
"groups": {
"running": "Running",
"notRunning": "Not running"
"notRunning": "Not running",
"onServer": "On your server"
},
"runsOnServerHint": "This plugin only runs on your server, so it stays off in the desktop app. It still works there.",
"sort": {
"popular": "Most popular",
"name": "Name",
@@ -1878,7 +1934,7 @@
"version": "Version",
"status": "Status",
"category": "Category",
"installs": "Installs"
"downloads": "Downloads"
},
"risk": {
"critical": "Critical",
@@ -2030,8 +2086,47 @@
"noneInstalled": "No plugins installed yet. Browse to add some.",
"nothingHere": "Nothing here.",
"nothingHereHint": "Try another category or search.",
"communitySoon": "Community plugins are coming in a later update. Plugin authors can already submit theirs to the community registry.",
"showInstalled": "Show installed",
"allInstalled": "Everything here is already installed.",
"allInstalledHint": "Turn on Show installed to see them.",
"communitySubmit": "How to submit",
"registries": {
"intro": "Registries are lists of plugins you can install. The official one is always on. You can turn on the community registry or add your own.",
"hostYourOwn": "Host your own registry",
"add": "Add registry",
"addTitle": "Add a registry",
"addHint": "Paste the link to the registry's index.json. Termix reads it and shows the key it signs plugins with. Only add registries from people you trust.",
"urlLabel": "Index URL",
"check": "Check",
"addConfirm": "Trust and add",
"added": "Added {{name}}",
"customWarning": "Plugins from this registry are not reviewed by the Termix team. Only the owner of the registry decides what is in it. Every download must be signed by the key above.",
"keyFingerprint": "Key {{keyId}}",
"pluginCount_one": "{{count}} plugin",
"pluginCount_other": "{{count}} plugins",
"officialName": "Official",
"communityName": "Community",
"alwaysOn": "Always on",
"communityHint": "Plugins made by other people. Each version is checked by a person and by automated checks before it is listed.",
"communityConfirmTitle": "Turn on community plugins?",
"communityConfirmBody": "Community plugins are made by other people, not the Termix team. Every version is checked by a person and by automated checks before it is listed, but mistakes can still get through. Only install plugins you trust and check what each one asks for.",
"communityConfirm": "Turn on",
"communityBadge": "Community",
"communityBadgeHint": "From the community registry. Made by other people and reviewed before it was listed.",
"customBadge": "Custom",
"customBadgeHint": "From {{name}}, a registry an admin added. Not reviewed by the Termix team.",
"remove": "Remove",
"removeTitle": "Remove {{name}}?",
"removeBody": "Its plugins will no longer show in Browse. You can add it again later.",
"removed": "Removed {{name}}",
"inUse": "Uninstall the plugins from {{name}} first: {{list}}",
"changeFailed": "Could not change the registry: {{error}}",
"loadFailed": "Could not load registries: {{error}}",
"unreachable": "Could not read it: {{error}}",
"filter": "Registry",
"allRegistries": "All registries",
"browseHint": "Turn on community plugins or add your own registry in the Registries tab."
},
"upToDate": "Everything is up to date.",
"lastChecked": "Last checked {{time}}",
"updatesReady_one": "{{count}} update ready.",
@@ -2054,11 +2149,8 @@
"signedOnly": "TERMIX_REQUIRE_SIGNED_PLUGINS is set, so installing from a file stays blocked."
},
"count": {
"active_one": "{{formatted}} active install",
"active_other": "{{formatted}} active installs",
"downloads_one": "{{formatted}} download",
"downloads_other": "{{formatted}} downloads",
"activeHint": "Instances that reported this plugin turned on in the last week. Instances with usage statistics turned off are not counted.",
"downloadsHint": "How many times a release was downloaded. This is not the number of installs."
},
"unverified": "Unverified",
+103
View File
@@ -0,0 +1,103 @@
[
{ "code": "af", "file": "af_ZA", "name": "Afrikaans", "label": "Afrikaans" },
{ "code": "ar", "file": "ar_SA", "name": "Arabic", "label": "العربية" },
{ "code": "bn", "file": "bn_BD", "name": "Bengali", "label": "বাংলা" },
{ "code": "bg", "file": "bg_BG", "name": "Bulgarian", "label": "Български" },
{ "code": "ca", "file": "ca_ES", "name": "Catalan", "label": "Català" },
{
"code": "zh-CN",
"file": "zh_CN",
"name": "Simplified Chinese",
"label": "中文 (简体)"
},
{
"code": "zh-TW",
"file": "zh_TW",
"name": "Traditional Chinese (Taiwan)",
"label": "中文 (繁體)"
},
{ "code": "hr", "file": "hr_HR", "name": "Croatian", "label": "Hrvatski" },
{ "code": "cs", "file": "cs_CZ", "name": "Czech", "label": "Čeština" },
{ "code": "da", "file": "da_DK", "name": "Danish", "label": "Dansk" },
{ "code": "nl", "file": "nl_NL", "name": "Dutch", "label": "Nederlands" },
{ "code": "et", "file": "et_EE", "name": "Estonian", "label": "Eesti" },
{ "code": "fi", "file": "fi_FI", "name": "Finnish", "label": "Suomi" },
{ "code": "fr", "file": "fr_FR", "name": "French", "label": "Français" },
{ "code": "de", "file": "de_DE", "name": "German", "label": "Deutsch" },
{ "code": "el", "file": "el_GR", "name": "Greek", "label": "Ελληνικά" },
{ "code": "he", "file": "he_IL", "name": "Hebrew", "label": "עברית" },
{ "code": "hi", "file": "hi_IN", "name": "Hindi", "label": "हिन्दी" },
{ "code": "hu", "file": "hu_HU", "name": "Hungarian", "label": "Magyar" },
{
"code": "id",
"file": "id_ID",
"name": "Indonesian",
"label": "Indonesia"
},
{ "code": "it", "file": "it_IT", "name": "Italian", "label": "Italiano" },
{ "code": "ja", "file": "ja_JP", "name": "Japanese", "label": "日本語" },
{ "code": "ko", "file": "ko_KR", "name": "Korean", "label": "한국어" },
{ "code": "lv", "file": "lv_LV", "name": "Latvian", "label": "Latviešu" },
{
"code": "lt",
"file": "lt_LT",
"name": "Lithuanian",
"label": "Lietuvių"
},
{ "code": "ms", "file": "ms_MY", "name": "Malay", "label": "Bahasa Melayu" },
{
"code": "no",
"file": "no_NO",
"name": "Norwegian Bokmål",
"label": "Norsk"
},
{ "code": "pl", "file": "pl_PL", "name": "Polish", "label": "Polski" },
{
"code": "pt-PT",
"file": "pt_PT",
"name": "European Portuguese",
"label": "Português (PT)"
},
{
"code": "pt-BR",
"file": "pt_BR",
"name": "Brazilian Portuguese",
"label": "Português (BR)"
},
{ "code": "ro", "file": "ro_RO", "name": "Romanian", "label": "Română" },
{ "code": "ru", "file": "ru_RU", "name": "Russian", "label": "Русский" },
{
"code": "sr",
"file": "sr_SP",
"name": "Serbian (Cyrillic script)",
"label": "Српски"
},
{ "code": "sk", "file": "sk_SK", "name": "Slovak", "label": "Slovenčina" },
{
"code": "sl",
"file": "sl_SI",
"name": "Slovenian",
"label": "Slovenščina"
},
{
"code": "es-ES",
"file": "es_ES",
"name": "Spanish (Spain)",
"label": "Español"
},
{ "code": "sv-SE", "file": "sv_SE", "name": "Swedish", "label": "Svenska" },
{ "code": "th", "file": "th_TH", "name": "Thai", "label": "ไทย" },
{ "code": "tr", "file": "tr_TR", "name": "Turkish", "label": "Türkçe" },
{
"code": "uk",
"file": "uk_UA",
"name": "Ukrainian",
"label": "Українська"
},
{
"code": "vi",
"file": "vi_VN",
"name": "Vietnamese",
"label": "Tiếng Việt"
}
]
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+21 -1
View File
@@ -1,10 +1,19 @@
import { LayoutTemplate, Monitor, Palette, Puzzle, Shield } from "lucide-react";
import {
LayoutTemplate,
Monitor,
MonitorDown,
Palette,
Puzzle,
Shield,
} from "lucide-react";
import { CORE_ONBOARDING_STEPS } from "@/types/onboarding";
import { PluginsStep } from "./steps/PluginsStep";
import { PresetStep } from "./steps/PresetStep";
import { AppearanceStep } from "./steps/AppearanceStep";
import { SecurityStep } from "./steps/SecurityStep";
import { DesktopSyncStep } from "./steps/DesktopSyncStep";
import { DesktopUpdatesStep } from "./steps/DesktopUpdatesStep";
import { desktopUpdates } from "@/lib/desktop-updates";
import type { OnboardingStepDef } from "./onboarding-registry";
/**
@@ -38,6 +47,17 @@ export const CORE_STEPS: OnboardingStepDef[] = [
position: 20,
isRelevant: (ctx) => ctx.isDesktop,
},
{
id: "desktop-updates",
version: CORE_ONBOARDING_STEPS["desktop-updates"],
titleKey: "desktopUpdates.onboardingTitle",
icon: MonitorDown,
Component: DesktopUpdatesStep,
audience: "all",
position: 25,
// App Store builds have no updater.
isRelevant: (ctx) => ctx.isDesktop && desktopUpdates() !== null,
},
{
id: "preset",
version: CORE_ONBOARDING_STEPS.preset,
@@ -0,0 +1,79 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { BellRing, Download, PauseCircle } from "lucide-react";
import {
desktopUpdates,
useDesktopUpdate,
type DesktopUpdateMode,
} from "@/lib/desktop-updates";
/**
* Desktop only: how the app updates itself. Can be changed later in
* Settings, Updates.
*/
export function DesktopUpdatesStep() {
const { t } = useTranslation();
const state = useDesktopUpdate();
const [picked, setPicked] = useState<DesktopUpdateMode | null>(null);
const mode = picked ?? state?.mode ?? "prompt";
const choose = (next: DesktopUpdateMode) => {
setPicked(next);
void desktopUpdates()
?.setMode(next)
.catch(() => {});
};
const options = [
{
id: "automatic" as const,
icon: Download,
title: t("desktopUpdates.modeAutomatic"),
body: t("desktopUpdates.automaticBody"),
},
{
id: "prompt" as const,
icon: BellRing,
title: t("desktopUpdates.modePrompt"),
body: t("desktopUpdates.promptBody"),
},
{
id: "never" as const,
icon: PauseCircle,
title: t("desktopUpdates.modeNever"),
body: t("desktopUpdates.neverBody"),
},
];
return (
<div className="flex flex-col gap-3">
<p className="text-xs text-muted-foreground">
{state?.kind === "manual"
? t("desktopUpdates.onboardingManual")
: t("desktopUpdates.onboardingBody")}
</p>
<div className="flex flex-col gap-1.5">
{options.map(({ id, icon: Icon, title, body }) => (
<button
key={id}
type="button"
onClick={() => choose(id)}
className={`flex items-start gap-2.5 border p-2.5 text-left transition-colors ${
mode === id
? "border-accent-brand bg-accent-brand/10"
: "border-border bg-card hover:bg-muted/40"
}`}
>
<Icon size={14} className="mt-0.5 shrink-0 text-accent-brand" />
<span className="flex flex-col gap-1">
<span className="text-xs font-medium">{title}</span>
<span className="text-[10px] leading-snug text-muted-foreground">
{body}
</span>
</span>
</button>
))}
</div>
</div>
);
}
+17 -5
View File
@@ -11,6 +11,7 @@ import {
Lightbulb,
Pin,
RotateCcw,
Server,
Settings,
Trash2,
TriangleAlert,
@@ -30,8 +31,9 @@ import { getPluginData, type PluginDataSummary } from "@/api/plugins-api";
import {
BetaBadge,
CapabilityRow,
InstallCountFact,
DownloadCount,
PluginIconBox,
SourceBadge,
UnverifiedBadge,
} from "./plugin-bits";
import {
@@ -107,6 +109,10 @@ export function PluginDetail({
</span>
<Facts className="text-[11px] text-muted-foreground/70">
{plugin.unverified && <UnverifiedBadge />}
<SourceBadge
source={plugin.source}
registryName={plugin.registryName}
/>
{plugin.isBeta && <BetaBadge />}
{plugin.author && <span>{plugin.author}</span>}
{plugin.version ? (
@@ -122,10 +128,7 @@ export function PluginDetail({
})}
</span>
)}
<InstallCountFact
count={plugin.installCount}
source={plugin.installCountSource}
/>
<DownloadCount count={plugin.downloads} />
</Facts>
</div>
<div className="flex shrink-0 flex-col gap-1.5">
@@ -225,6 +228,15 @@ export function PluginDetail({
</div>
)}
{plugin.status === "server" && (
<div className="flex items-start gap-2 border border-border bg-muted/30 px-3 py-2.5">
<Server className="mt-px size-3.5 shrink-0 text-muted-foreground" />
<span className="text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.runsOnServerHint")}
</span>
</div>
)}
<div className="grid grid-cols-1 gap-2 lg:grid-cols-3">
<div className="flex flex-col gap-2 lg:col-span-2">
<PluginVideo videoId={plugin.videoId} name={plugin.name} />
+121 -31
View File
@@ -6,6 +6,7 @@ import {
Columns3,
Download,
Globe,
Library,
Puzzle,
RefreshCw,
RotateCcw,
@@ -40,10 +41,12 @@ import { InlineView, SurfaceScope } from "@/components/surface/surface-scope";
import { cn } from "@/lib/utils";
import {
BetaBadge,
InstallCountFact,
DownloadCount,
PluginIconBox,
SourceBadge,
UnverifiedBadge,
} from "./plugin-bits";
import { RegistriesSection } from "./RegistriesSection";
import { PluginConsentPrompt } from "./PluginConsentPrompt";
import { PluginDetail } from "./PluginDetail";
import {
@@ -60,7 +63,7 @@ import { usePluginsManager, type PluginsManager } from "./use-plugins-manager";
import { docsUrl } from "@/lib/docs";
import { DocsLink } from "@/components/docs-link";
type Section = "installed" | "browse" | "updates";
type Section = "installed" | "browse" | "updates" | "registries";
const GRID_SIZES = ["auto", "2", "3", "4", "5", "6"] as const;
type GridSize = (typeof GRID_SIZES)[number];
@@ -146,6 +149,11 @@ function PluginsTabBody() {
icon: <Download className="size-3" />,
count: updates.filter((p) => !p.pinnedVersion).length || undefined,
},
{
id: "registries",
label: t("plugins.manager.tabs.registries"),
icon: <Library className="size-3" />,
},
];
return (
@@ -217,12 +225,14 @@ function PluginsTabBody() {
layout={layout}
layoutControls={layoutControls}
/>
) : (
) : section === "updates" ? (
<UpdatesSection
plugins={updates}
manager={manager}
onOpen={setDetailId}
/>
) : (
<RegistriesSection manager={manager} />
)}
</div>
</PanelShell>
@@ -442,6 +452,7 @@ function NameCell({ plugin, muted }: { plugin: PluginEntry; muted?: boolean }) {
{plugin.name}
</span>
{plugin.unverified && <UnverifiedBadge />}
<SourceBadge source={plugin.source} registryName={plugin.registryName} />
{plugin.isBeta && <BetaBadge />}
</div>
);
@@ -483,7 +494,10 @@ function InstalledSection({
sort,
);
const running = rows.filter((p) => p.status === "running");
const stopped = rows.filter((p) => p.status !== "running");
const onServer = rows.filter((p) => p.status === "server");
const stopped = rows.filter(
(p) => p.status !== "running" && p.status !== "server",
);
const pending = plugins.filter((p) => p.updateAvailable && !p.pinnedVersion);
const listColumns: DataColumn<PluginEntry>[] = [
@@ -587,6 +601,14 @@ function InstalledSection({
{collection(running)}
</Group>
)}
{onServer.length > 0 && (
<Group
title={t("plugins.manager.groups.onServer")}
count={onServer.length}
>
{collection(onServer)}
</Group>
)}
{stopped.length > 0 && (
<Group
title={t("plugins.manager.groups.notRunning")}
@@ -693,6 +715,10 @@ function InstalledCard({
facts={
<>
{plugin.unverified && <UnverifiedBadge />}
<SourceBadge
source={plugin.source}
registryName={plugin.registryName}
/>
{plugin.isBeta && <BetaBadge />}
{plugin.author && <span>{plugin.author}</span>}
<span>v{plugin.version}</span>
@@ -717,7 +743,9 @@ function InstalledCard({
<div className="flex items-center gap-2 border-t border-border px-3 py-2">
<span className="size-1.5 shrink-0 bg-muted-foreground/40" />
<span className="text-[11px] text-muted-foreground">
{t("plugins.manager.status.disabled")}
{plugin.status === "server"
? t("plugins.manager.status.server")
: t("plugins.manager.status.disabled")}
</span>
</div>
) : (
@@ -806,11 +834,13 @@ function StatusCell({ plugin }: { plugin: PluginEntry }) {
? t("plugins.manager.status.blocked")
: failed
? t("plugins.manager.status.failed")
: !running
? t("plugins.manager.status.disabled")
: plugin.pinnedVersion
? t("plugins.manager.pinnedTo", { version: plugin.pinnedVersion })
: t("plugins.manager.status.running");
: plugin.status === "server"
? t("plugins.manager.status.server")
: !running
? t("plugins.manager.status.disabled")
: plugin.pinnedVersion
? t("plugins.manager.pinnedTo", { version: plugin.pinnedVersion })
: t("plugins.manager.status.running");
return (
<span
className="flex min-w-0 items-center gap-1.5 text-muted-foreground"
@@ -920,18 +950,34 @@ function BrowseSection({
}) {
const { t } = useTranslation();
const [category, setCategory] = useState<string | null>(null);
const [registry, setRegistry] = useState<string | null>(null);
const [sort, setSort] = useState<SortKey>("popular");
const [installedChoice, setInstalledChoice] = useStoredChoice(
"pluginsTab.browseInstalled",
["hide", "show"],
"hide",
);
const showInstalled = installedChoice === "show";
const categories = useMemo(() => categoriesOf(plugins), [plugins]);
const registries = manager.registries;
const registryFilter = registries.some((entry) => entry.id === registry)
? registry
: null;
const rows = sortPlugins(
plugins.filter(
(p) =>
(showInstalled || !p.installed) &&
matchesQuery(p, query) &&
(category === null || p.category === category),
(category === null || p.category === category) &&
(registryFilter === null || p.registryId === registryFilter),
),
sort,
);
const allInstalledHidden =
!showInstalled && plugins.length > 0 && plugins.every((p) => p.installed);
const listColumns: DataColumn<PluginEntry>[] = [
{
key: "name",
@@ -956,16 +1002,13 @@ function BrowseSection({
),
},
{
key: "installs",
header: t("plugins.manager.table.installs"),
key: "downloads",
header: t("plugins.manager.table.downloads"),
width: "110px",
hideBelow: "lg",
cell: (p) => (
<span className="text-muted-foreground">
<InstallCountFact
count={p.installCount}
source={p.installCountSource}
/>
<DownloadCount count={p.downloads} />
</span>
),
},
@@ -996,6 +1039,44 @@ function BrowseSection({
placeholder={t("plugins.manager.searchPlugins")}
>
<div className="ml-auto flex items-center gap-2">
<label className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">
{t("plugins.manager.showInstalled")}
</span>
<FakeSwitch
checked={showInstalled}
onChange={(v) => setInstalledChoice(v ? "show" : "hide")}
/>
</label>
{registries.length > 1 && (
<Select
value={registryFilter ?? "__all"}
onValueChange={(v) => setRegistry(v === "__all" ? null : v)}
>
<SelectTrigger
size="sm"
className="w-auto gap-1.5 text-xs"
aria-label={t("plugins.manager.registries.filter")}
>
<Library className="size-3.5 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all" className="text-xs">
{t("plugins.manager.registries.allRegistries")}
</SelectItem>
{registries.map((entry) => (
<SelectItem
key={entry.id}
value={entry.id}
className="text-xs"
>
{entry.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<SortSelect value={sort} onChange={setSort} />
{layoutControls}
</div>
@@ -1022,15 +1103,23 @@ function BrowseSection({
)}
{rows.length === 0 ? (
<EmptyState
icon={Puzzle}
title={t("plugins.manager.nothingHere")}
hint={
plugins.length === 0
? undefined
: t("plugins.manager.nothingHereHint")
}
/>
allInstalledHidden ? (
<EmptyState
icon={Puzzle}
title={t("plugins.manager.allInstalled")}
hint={t("plugins.manager.allInstalledHint")}
/>
) : (
<EmptyState
icon={Puzzle}
title={t("plugins.manager.nothingHere")}
hint={
plugins.length === 0
? undefined
: t("plugins.manager.nothingHereHint")
}
/>
)
) : (
<PluginCollection
plugins={rows}
@@ -1048,7 +1137,7 @@ function BrowseSection({
)}
<p className="text-[11px] text-muted-foreground">
{t("plugins.manager.communitySoon")}{" "}
{t("plugins.manager.registries.browseHint")}{" "}
<DocsLink core="communityRegistry" className="text-[11px]">
{t("plugins.manager.communitySubmit")}
</DocsLink>
@@ -1076,13 +1165,14 @@ function BrowseCard({
onOpen={onOpen}
facts={
<>
<SourceBadge
source={plugin.source}
registryName={plugin.registryName}
/>
{plugin.author && <span>{plugin.author}</span>}
{plugin.latestVersion && <span>v{plugin.latestVersion}</span>}
{plugin.category && <span>{plugin.category}</span>}
<InstallCountFact
count={plugin.installCount}
source={plugin.installCountSource}
/>
<DownloadCount count={plugin.downloads} />
</>
}
/>
+390
View File
@@ -0,0 +1,390 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Library, Trash2 } from "lucide-react";
import { Input } from "@/components/input";
import { FakeSwitch } from "@/components/section-card";
import { PANEL } from "@/components/panel-layout";
import {
AddButton,
FormFooter,
ListBadge,
ListRow,
ListRowAction,
} from "@/components/list-kit";
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import { DocsLink } from "@/components/docs-link";
import {
addRegistry,
getRegistries,
previewRegistry,
removeRegistry,
setRegistryEnabled,
type RegistriesState,
type RegistryListingSource,
type RegistryPreview,
} from "@/api/plugins-api";
import type { PluginsManager } from "./use-plugins-manager";
function errorOf(error: unknown): {
message: string;
code?: string;
data: Record<string, unknown>;
} {
const data = (error as { response?: { data?: Record<string, unknown> } })
?.response?.data;
return {
message:
typeof data?.error === "string"
? data.error
: error instanceof Error
? error.message
: String(error),
code: typeof data?.code === "string" ? data.code : undefined,
data: data ?? {},
};
}
export function RegistriesSection({ manager }: { manager: PluginsManager }) {
const { t } = useTranslation();
const confirm = useConfirm();
const [state, setState] = useState<RegistriesState | null>(null);
const [adding, setAdding] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setState(await getRegistries());
} catch (error) {
toast.error(
t("plugins.manager.registries.loadFailed", {
error: errorOf(error).message,
}),
);
}
}, [t]);
useEffect(() => {
void load();
}, [load]);
const statusOf = (id: string): RegistryListingSource | undefined =>
manager.registries.find((entry) => entry.id === id);
const toggle = async (id: string, enabled: boolean) => {
if (enabled && id === "community") {
const ok = await confirm({
title: t("plugins.manager.registries.communityConfirmTitle"),
description: t("plugins.manager.registries.communityConfirmBody"),
confirmLabel: t("plugins.manager.registries.communityConfirm"),
destructive: false,
});
if (!ok) return;
}
setBusy(id);
try {
await setRegistryEnabled(id, enabled);
await load();
await manager.refresh();
} catch (error) {
toast.error(
t("plugins.manager.registries.changeFailed", {
error: errorOf(error).message,
}),
);
} finally {
setBusy(null);
}
};
const remove = async (id: string, name: string) => {
const ok = await confirm({
title: t("plugins.manager.registries.removeTitle", { name }),
description: t("plugins.manager.registries.removeBody"),
confirmLabel: t("plugins.manager.registries.remove"),
});
if (!ok) return;
setBusy(id);
try {
await removeRegistry(id);
toast.success(t("plugins.manager.registries.removed", { name }));
await load();
await manager.refresh();
} catch (error) {
const info = errorOf(error);
toast.error(
info.code === "REGISTRY_IN_USE"
? t("plugins.manager.registries.inUse", {
name,
list: ((info.data.plugins as string[]) ?? []).join(", "),
})
: t("plugins.manager.registries.changeFailed", {
error: info.message,
}),
);
} finally {
setBusy(null);
}
};
const readOnly = manager.managedByServer || state?.managedByServer;
const meta = (id: string, url: string, extra?: string) => {
const status = statusOf(id);
const parts = [url];
if (status && !status.error) {
parts.push(
t("plugins.manager.registries.pluginCount", { count: status.plugins }),
);
}
if (extra) parts.push(extra);
return parts.join(" | ");
};
const errorLine = (id: string) => {
const error = statusOf(id)?.error;
return error ? (
<span className="text-[11px] leading-snug text-destructive">
{t("plugins.manager.registries.unreachable", { error })}
</span>
) : null;
};
return (
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
<div className="flex flex-wrap items-center gap-2">
<p className="min-w-0 flex-1 text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.registries.intro")}{" "}
<DocsLink core="hostRegistry" className="text-[11px]">
{t("plugins.manager.registries.hostYourOwn")}
</DocsLink>
</p>
{!readOnly && (
<AddButton
label={t("plugins.manager.registries.add")}
onClick={() => setAdding(true)}
/>
)}
</div>
{state && (
<div className="flex flex-col border-t border-border/40">
<ListRow
stripe={0}
icon={<Library />}
title={t("plugins.manager.registries.officialName")}
badges={
<ListBadge tone="brand">
{t("plugins.manager.registries.alwaysOn")}
</ListBadge>
}
meta={meta("official", state.official.url)}
>
{errorLine("official")}
</ListRow>
<ListRow
stripe={1}
icon={<Library />}
tone={state.community.enabled ? "brand" : "muted"}
dimmed={!state.community.enabled}
title={t("plugins.manager.registries.communityName")}
meta={meta("community", state.community.url)}
trailing={
<FakeSwitch
checked={state.community.enabled}
disabled={readOnly || busy === "community"}
onChange={(next) => void toggle("community", next)}
/>
}
>
<span className="text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.registries.communityHint")}{" "}
<DocsLink core="communityRegistry" className="text-[11px]">
{t("plugins.manager.communitySubmit")}
</DocsLink>
</span>
{state.community.enabled && errorLine("community")}
</ListRow>
{state.custom.map((entry, index) => (
<ListRow
key={entry.id}
stripe={index}
icon={<Library />}
tone={entry.enabled ? "warning" : "muted"}
dimmed={!entry.enabled}
title={entry.name}
badges={
<ListBadge tone="warning">
{t("plugins.manager.registries.customBadge")}
</ListBadge>
}
meta={meta(
entry.id,
entry.url,
t("plugins.manager.registries.keyFingerprint", {
keyId: entry.keyId,
}),
)}
trailing={
<FakeSwitch
checked={entry.enabled}
disabled={readOnly || busy === entry.id}
onChange={(next) => void toggle(entry.id, next)}
/>
}
actions={
readOnly ? undefined : (
<ListRowAction
label={t("plugins.manager.registries.remove")}
tone="destructive"
disabled={busy === entry.id}
onClick={() => void remove(entry.id, entry.name)}
>
<Trash2 />
</ListRowAction>
)
}
>
{entry.enabled && errorLine(entry.id)}
</ListRow>
))}
</div>
)}
<AddRegistryView
open={adding}
onOpenChange={setAdding}
onAdded={async () => {
setAdding(false);
await load();
await manager.refresh();
}}
/>
</div>
);
}
function AddRegistryView({
open,
onOpenChange,
onAdded,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdded: () => Promise<void>;
}) {
const { t } = useTranslation();
const [url, setUrl] = useState("");
const [preview, setPreview] = useState<RegistryPreview | null>(null);
const [error, setError] = useState<string | null>(null);
const [working, setWorking] = useState(false);
useEffect(() => {
if (!open) {
setUrl("");
setPreview(null);
setError(null);
}
}, [open]);
const check = async () => {
setWorking(true);
setError(null);
setPreview(null);
try {
setPreview(await previewRegistry(url.trim()));
} catch (failure) {
setError(errorOf(failure).message);
} finally {
setWorking(false);
}
};
const add = async () => {
if (!preview) return;
setWorking(true);
setError(null);
try {
const added = await addRegistry(preview.url, preview.keyId);
toast.success(
t("plugins.manager.registries.added", { name: added.name }),
);
await onAdded();
} catch (failure) {
const info = errorOf(failure);
if (info.code === "KEY_MISMATCH") setPreview(null);
setError(info.message);
} finally {
setWorking(false);
}
};
return (
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("plugins.manager.registries.addTitle")}
icon={<Library className="size-4" />}
footer={
<FormFooter
onCancel={() => onOpenChange(false)}
onSave={() => void (preview ? add() : check())}
saveLabel={
preview
? t("plugins.manager.registries.addConfirm")
: t("plugins.manager.registries.check")
}
saving={working}
disabled={!url.trim()}
/>
}
>
<div className="flex flex-col gap-3 p-3">
<p className="text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.registries.addHint")}
</p>
<Input
value={url}
onChange={(e) => {
setUrl(e.target.value);
setPreview(null);
setError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter" && url.trim() && !working) {
e.preventDefault();
void (preview ? add() : check());
}
}}
placeholder="https://example.com/termix/index.json"
className="h-8 text-xs"
aria-label={t("plugins.manager.registries.urlLabel")}
autoFocus
/>
{error && (
<p className="text-[11px] leading-snug text-destructive">{error}</p>
)}
{preview && (
<div className="flex flex-col gap-1.5 border border-warning/40 bg-warning/5 p-3">
<span className="text-sm font-semibold">{preview.name}</span>
<span className="text-[11px] text-muted-foreground">
{t("plugins.manager.registries.pluginCount", {
count: preview.plugins,
})}
</span>
<span className="text-[11px] text-muted-foreground">
{t("plugins.manager.registries.keyFingerprint", {
keyId: preview.keyId,
})}
</span>
<p className="text-[11px] leading-snug text-muted-foreground">
{t("plugins.manager.registries.customWarning")}
</p>
</div>
)}
</div>
</InlineView>
);
}
+33 -22
View File
@@ -88,6 +88,32 @@ export function UnverifiedBadge() {
);
}
/** Marks a plugin from the community registry or a custom one. */
export function SourceBadge({
source,
registryName,
}: {
source: string;
registryName?: string | null;
}) {
const { t } = useTranslation();
if (source !== "community" && source !== "custom") return null;
const label =
source === "community"
? t("plugins.manager.registries.communityBadge")
: (registryName ?? t("plugins.manager.registries.customBadge"));
return (
<span
className="max-w-32 shrink-0 truncate border border-border bg-muted/40 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground"
title={t(`plugins.manager.registries.${source}BadgeHint`, {
name: label,
})}
>
{label}
</span>
);
}
export function BetaBadge() {
const { t } = useTranslation();
return (
@@ -100,31 +126,16 @@ export function BetaBadge() {
);
}
/** Active installs when the registry has them, release downloads otherwise. */
export function InstallCountFact({
count,
source,
}: {
count: number | null;
source: string | null;
}) {
/** How often the plugin's release files were downloaded. */
export function DownloadCount({ count }: { count: number | null }) {
const { t } = useTranslation();
if (count === null || count <= 0) return null;
const active = source === "aggregate-telemetry";
return (
<span
title={
active
? t("plugins.manager.count.activeHint")
: t("plugins.manager.count.downloadsHint")
}
>
{t(
active
? "plugins.manager.count.active"
: "plugins.manager.count.downloads",
{ count, formatted: formatCount(count) },
)}
<span title={t("plugins.manager.count.downloadsHint")}>
{t("plugins.manager.count.downloads", {
count,
formatted: formatCount(count),
})}
</span>
);
}
+28 -14
View File
@@ -24,7 +24,8 @@ import {
normalizeGitHubRepo,
} from "@/lib/issue-url";
export type PluginStatus = "running" | "disabled" | "failed" | "blocked";
export type PluginStatus =
"running" | "disabled" | "failed" | "blocked" | "server";
/** One row of the Plugins tab: what is installed merged with the registry. */
export interface PluginEntry {
@@ -61,14 +62,17 @@ export interface PluginEntry {
inRegistry: boolean;
contributes: Record<string, unknown> | null;
publishedAt: string | null;
installCount: number | null;
installCountSource: string | null;
downloads: number | null;
source: PluginSource;
/** Installed from a file or folder with no registry signature behind it. */
unverified: boolean;
/** The registry it is listed in or was installed from. */
registryId: string | null;
registryName: string | null;
}
export type PluginSource = "bundled" | "official" | "unverified";
export type PluginSource =
"bundled" | "official" | "community" | "custom" | "unverified";
export function isPrerelease(version: string | null | undefined): boolean {
return Boolean(
@@ -96,10 +100,14 @@ export function pluginSource(summary: PluginSummary): PluginSource {
if (summary.source === "bundled" || summary.tier === "bundled") {
return "bundled";
}
return summary.signedBy ? "official" : "unverified";
if (!summary.signedBy) return "unverified";
return summary.tier === "community" || summary.tier === "custom"
? summary.tier
: "official";
}
export function pluginStatus(summary: PluginSummary): PluginStatus {
if (summary.runsOnServer) return "server";
if (summary.state === "failed") return "failed";
if (summary.state === "blocked") return "blocked";
if (!summary.enabled) return "disabled";
@@ -136,7 +144,10 @@ export function mergePlugins(
latestBeta: entry?.latestBeta ?? null,
channel: summary.channel ?? entry?.channel ?? "stable",
isBeta: isPrerelease(summary.version),
updateAvailable: entry?.updateAvailable ?? false,
// The server keeps its own copy up to date.
updateAvailable: summary.runsOnServer
? false
: (entry?.updateAvailable ?? false),
addedCapabilities: entry?.addedCapabilities ?? [],
versions: entry?.versions ?? [],
capabilities: summary.capabilities ?? [],
@@ -149,10 +160,11 @@ export function mergePlugins(
inRegistry: Boolean(entry),
contributes: (summary.contributes as Record<string, unknown>) ?? null,
publishedAt: latestPublished(entry?.versions ?? []),
installCount: entry?.installCount ?? null,
installCountSource: entry?.installCountSource ?? null,
downloads: entry?.downloads ?? null,
source: pluginSource(summary),
unverified: pluginSource(summary) === "unverified",
registryId: entry?.registryId ?? summary.registryId ?? null,
registryName: entry?.registryName ?? null,
});
}
@@ -191,10 +203,11 @@ export function mergePlugins(
inRegistry: true,
contributes: null,
publishedAt: latestPublished(entry.versions),
installCount: entry.installCount ?? null,
installCountSource: entry.installCountSource ?? null,
source: "official",
downloads: entry.downloads ?? null,
source: entry.tier ?? "official",
unverified: false,
registryId: entry.registryId ?? "official",
registryName: entry.registryName ?? null,
});
}
@@ -230,10 +243,11 @@ export function uploadEntry(preview: PluginUploadPreview): PluginEntry {
inRegistry: false,
contributes: null,
publishedAt: null,
installCount: null,
installCountSource: null,
downloads: null,
source: "unverified",
unverified: true,
registryId: null,
registryName: null,
};
}
@@ -252,7 +266,7 @@ export function sortPlugins(rows: PluginEntry[], key: SortKey): PluginEntry[] {
if (key === "popular") {
sorted.sort(
(a, b) =>
(b.installCount ?? -1) - (a.installCount ?? -1) ||
(b.downloads ?? -1) - (a.downloads ?? -1) ||
a.name.localeCompare(b.name),
);
} else if (key === "updated") {
+3
View File
@@ -183,6 +183,7 @@ export function usePluginsManager() {
const result = await installPlugin(plugin.id, {
version: version === plugin.latestVersion ? undefined : version,
capabilities,
registryId: plugin.registryId ?? undefined,
});
if (result.state === "failed" || result.state === "blocked") {
toast.error(
@@ -585,6 +586,8 @@ export function usePluginsManager() {
registryError,
managedByServer: registry?.managedByServer ?? false,
lastCheckedAt: registry?.registry.lastCheckedAt ?? null,
registries: registry?.registries ?? [],
reload: () => load(),
busy,
consent,
setConsent,
+162
View File
@@ -0,0 +1,162 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { ExternalLink, MonitorDown } from "lucide-react";
import { Button } from "@/components/button";
import { SectionCard, SettingRow } from "@/components/section-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import {
canConfigureUpdates,
desktopUpdates,
useDesktopUpdate,
type DesktopUpdateMode,
type DesktopUpdateState,
} from "@/lib/desktop-updates";
const MODES: DesktopUpdateMode[] = ["automatic", "prompt", "never"];
function StatusText({ state }: { state: DesktopUpdateState }) {
const { t } = useTranslation();
const version = state.version;
switch (state.status) {
case "checking":
return <>{t("desktopUpdates.statusChecking")}</>;
case "available":
return <>{t("desktopUpdates.statusAvailable", { version })}</>;
case "downloading":
return (
<>
{t("desktopUpdates.statusDownloading", {
version,
progress: state.progress ?? 0,
})}
</>
);
case "downloaded":
return <>{t("desktopUpdates.statusReady", { version })}</>;
case "up-to-date":
return <>{t("desktopUpdates.statusUpToDate")}</>;
case "error":
return <>{t("desktopUpdates.statusError")}</>;
default:
return <>{t("desktopUpdates.statusIdle")}</>;
}
}
/** Desktop only: how this install updates itself. */
export function DesktopUpdatesCard() {
const { t } = useTranslation();
const state = useDesktopUpdate();
const [busy, setBusy] = useState(false);
if (!canConfigureUpdates(state)) return null;
const bridge = desktopUpdates();
const run = async (action: () => Promise<unknown> | undefined) => {
setBusy(true);
try {
await action();
} catch {
toast.error(t("settings.updates.saveFailed"));
} finally {
setBusy(false);
}
};
const modeLabel = (mode: DesktopUpdateMode) =>
mode === "automatic"
? t("desktopUpdates.modeAutomatic")
: mode === "prompt"
? t("desktopUpdates.modePrompt")
: t("desktopUpdates.modeNever");
return (
<SectionCard
title={t("desktopUpdates.title")}
icon={<MonitorDown className="size-3.5" />}
rowId="updates-desktop"
>
<SettingRow
label={t("desktopUpdates.mode")}
description={
state.kind === "manual"
? t("desktopUpdates.manualModeHint")
: t("desktopUpdates.modeHint")
}
>
<Select
disabled={busy}
value={state.mode}
onValueChange={(next) =>
void run(() => bridge?.setMode(next as DesktopUpdateMode))
}
>
<SelectTrigger className="h-8 w-40 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODES.map((mode) => (
<SelectItem key={mode} value={mode}>
{modeLabel(mode)}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
<SettingRow
label={t("desktopUpdates.status", { version: state.currentVersion })}
description={<StatusText state={state} />}
>
{state.status === "downloaded" ? (
<Button
size="sm"
className="h-8"
onClick={() => void bridge?.install()}
>
{t("desktopUpdates.restart")}
</Button>
) : state.status === "available" && state.kind === "auto" ? (
<Button
size="sm"
className="h-8"
disabled={busy}
onClick={() => void run(() => bridge?.download())}
>
{t("desktopUpdates.download")}
</Button>
) : state.status === "available" && state.releaseUrl ? (
<Button size="sm" variant="outline" className="h-8 gap-1.5" asChild>
<a
href={state.releaseUrl}
target="_blank"
rel="noopener noreferrer"
>
{t("desktopUpdates.download")}
<ExternalLink className="size-3" />
</a>
</Button>
) : (
<Button
size="sm"
variant="outline"
className="h-8"
disabled={
busy ||
state.status === "checking" ||
state.status === "downloading"
}
onClick={() => void run(() => bridge?.check())}
>
{t("desktopUpdates.checkNow")}
</Button>
)}
</SettingRow>
</SectionCard>
);
}
+1 -1
View File
@@ -387,7 +387,7 @@ export function SettingsTab({
)}
</SettingsEmbedContext.Provider>
{page.id === "keybindings" && <KeybindingsSettings />}
{page.id === "updates" && isAdmin && <UpdatesSettings />}
{page.id === "updates" && <UpdatesSettings isAdmin={isAdmin} />}
{pluginFeature && (
<>
{pluginFeature.user && (
+11 -1
View File
@@ -22,6 +22,7 @@ import { isElectron } from "@/lib/electron";
import { isPrerelease } from "@/plugins/plugin-model";
import { OPEN_PLUGINS_EVENT } from "@/plugins/open-plugins";
import { docsUrl } from "@/lib/docs";
import { DesktopUpdatesCard } from "./DesktopUpdatesCard";
const DOCKER_BETA_IMAGE = "ghcr.io/termix-ssh/termix:beta";
@@ -61,7 +62,7 @@ function ReleaseRow({
);
}
export function UpdatesSettings() {
function InstanceUpdates() {
const { t } = useTranslation();
const [channel, setChannel] = useState<UpdateChannelState | null>(null);
const [releases, setReleases] = useState<{
@@ -317,3 +318,12 @@ export function UpdatesSettings() {
</div>
);
}
export function UpdatesSettings({ isAdmin }: { isAdmin: boolean }) {
return (
<div className="flex flex-col gap-3">
<DesktopUpdatesCard />
{isAdmin && <InstanceUpdates />}
</div>
);
}
@@ -14,6 +14,7 @@ import {
UI_FONTS,
} from "@/lib/theme";
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
import { LANGUAGES } from "@/i18n/languages";
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types";
@@ -48,44 +49,6 @@ const THEME_PALETTES: Record<
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",
+6 -1
View File
@@ -38,6 +38,8 @@ export interface SettingsPage {
icon: LucideIcon;
/** Only for admins. */
admin?: boolean;
/** Also shown to non-admins in the desktop app. */
desktopToo?: boolean;
/** Left out of the desktop app. */
browserOnly?: boolean;
/** The profile section this page shows. */
@@ -135,6 +137,7 @@ export const CORE_SETTINGS_PAGES: SettingsPage[] = [
labelKey: "settings.updates.title",
icon: FlaskConical,
admin: true,
desktopToo: true,
}),
core({
id: "admin-users",
@@ -225,7 +228,9 @@ export function visibleSettingsPages(
{ isAdmin, isElectron }: { isAdmin: boolean; isElectron: boolean },
): SettingsPage[] {
return pages.filter(
(page) => (!page.admin || isAdmin) && (!page.browserOnly || !isElectron),
(page) =>
(!page.admin || isAdmin || (page.desktopToo && isElectron)) &&
(!page.browserOnly || !isElectron),
);
}
+24 -6
View File
@@ -39,7 +39,7 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"admin.apiKeyUser",
"admin.apiKeysCliHint",
"admin.apiKeysCliLink",
"admin.apiKeysCount",
"admin.apiKeysCount_other",
"admin.apiKeysDocsLink",
"admin.copiedToClipboard",
"admin.createKey",
@@ -162,7 +162,7 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"admin.roleDescription",
"admin.roleDisplayName",
"admin.roleName",
"admin.rolePermissions.count",
"admin.rolePermissions.count_other",
"admin.rolePermissions.editAction",
"admin.rolePermissions.loadError",
"admin.rolePermissions.pluginDisabled",
@@ -170,7 +170,7 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"admin.rolePermissions.saveError",
"admin.rolePermissions.saved",
"admin.rolePermissions.saving",
"admin.rolesCount",
"admin.rolesCount_other",
"admin.sectionRoles",
"admin.systemBadge",
"common.cancel",
@@ -187,7 +187,7 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"admin.sectionSessions",
"admin.sessionActive",
"admin.sessionExpires",
"admin.sessionsActive",
"admin.sessionsActive_other",
"admin.youBadge",
],
"admin-ssl": [
@@ -282,7 +282,7 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"admin.userAdminAccess",
"admin.userAdminAccessDesc",
"admin.userRoles",
"admin.usersCount",
"admin.usersCount_other",
"common.cancel",
"common.loading",
"common.next",
@@ -294,7 +294,7 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"admin.revokeKey",
"admin.revokeKeyConfirm",
"newUi.sidebar.userProfile.apiKeyActive",
"newUi.sidebar.userProfile.apiKeyCount",
"newUi.sidebar.userProfile.apiKeyCount_other",
"newUi.sidebar.userProfile.apiKeyCreateFailed",
"newUi.sidebar.userProfile.apiKeyCreated",
"newUi.sidebar.userProfile.apiKeyCreatedWarning",
@@ -434,6 +434,24 @@ export const SETTINGS_SEARCH_INDEX: Record<string, string[]> = {
"newUi.sidebar.userProfile.updatePassword",
],
"updates": [
"desktopUpdates.checkNow",
"desktopUpdates.download",
"desktopUpdates.manualModeHint",
"desktopUpdates.mode",
"desktopUpdates.modeAutomatic",
"desktopUpdates.modeHint",
"desktopUpdates.modeNever",
"desktopUpdates.modePrompt",
"desktopUpdates.restart",
"desktopUpdates.status",
"desktopUpdates.statusAvailable",
"desktopUpdates.statusChecking",
"desktopUpdates.statusDownloading",
"desktopUpdates.statusError",
"desktopUpdates.statusIdle",
"desktopUpdates.statusReady",
"desktopUpdates.statusUpToDate",
"desktopUpdates.title",
"settings.updates.allPluginsBeta",
"settings.updates.allPluginsStable",
"settings.updates.backToStable",
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
desktopUpdates,
updateToastFor,
useDesktopUpdate,
} from "@/lib/desktop-updates";
const TOAST_ID = "desktop-update";
const LATER_KEY = "termix-desktop-update-later";
function readLater(): string | null {
try {
return localStorage.getItem(LATER_KEY);
} catch {
return null;
}
}
function saveLater(value: string) {
try {
localStorage.setItem(LATER_KEY, value);
} catch {
// Only means the toast comes back next launch.
}
}
/** Desktop only: tells the user about a new version and installs it. */
export function DesktopUpdateToasts() {
const { t } = useTranslation();
const state = useDesktopUpdate();
const shown = useRef<string | null>(null);
useEffect(() => {
const kind = updateToastFor(state);
if (!state || !kind) return;
const key = `${kind}:${state.version}`;
if (shown.current === key || readLater() === key) return;
shown.current = key;
const version = state.version;
const later = {
label: t("desktopUpdates.later"),
onClick: () => saveLater(key),
};
if (kind === "ready") {
toast(t("desktopUpdates.ready", { version }), {
id: TOAST_ID,
duration: Infinity,
description: t("desktopUpdates.readyHint"),
action: {
label: t("desktopUpdates.restart"),
onClick: () => void desktopUpdates()?.install(),
},
cancel: later,
});
} else if (kind === "prompt") {
toast(t("desktopUpdates.available", { version }), {
id: TOAST_ID,
duration: Infinity,
action: {
label: t("desktopUpdates.download"),
onClick: () => void desktopUpdates()?.download(),
},
cancel: later,
});
} else {
const url = state.releaseUrl;
toast(t("desktopUpdates.available", { version }), {
id: TOAST_ID,
duration: Infinity,
description: t("desktopUpdates.manualHint"),
action: {
label: t("desktopUpdates.download"),
onClick: () => {
if (url) window.open(url, "_blank", "noopener,noreferrer");
},
},
cancel: later,
});
}
}, [state, t]);
return null;
}
-4
View File
@@ -489,10 +489,6 @@ export function CredentialEditorView({
? ""
: credForm.passphrase
}
onFocus={() => {
if (credForm.passphrase === "existing_key_password")
setCredField("passphrase", "");
}}
onChange={(e) => setCredField("passphrase", e.target.value)}
/>
</div>
-20
View File
@@ -1094,10 +1094,6 @@ export function HostEditor({
? ""
: form.password
}
onFocus={() => {
if (form.password === "existing_password")
setField("password", "");
}}
onChange={(e) => setField("password", e.target.value)}
/>
<PluginComponent
@@ -1211,10 +1207,6 @@ export function HostEditor({
? ""
: form.keyPassword
}
onFocus={() => {
if (form.keyPassword === "existing_key_password")
setField("keyPassword", "");
}}
onChange={(e) =>
setField("keyPassword", e.target.value)
}
@@ -1236,10 +1228,6 @@ export function HostEditor({
? ""
: form.password
}
onFocus={() => {
if (form.password === "existing_password")
setField("password", "");
}}
onChange={(e) =>
setField("password", e.target.value)
}
@@ -1377,10 +1365,6 @@ export function HostEditor({
? ""
: form.password
}
onFocus={() => {
if (form.password === "existing_password")
setField("password", "");
}}
onChange={(e) =>
setField("password", e.target.value)
}
@@ -1506,10 +1490,6 @@ export function HostEditor({
? ""
: form.sudoPassword
}
onFocus={() => {
if (form.sudoPassword === "existing_sudo_password")
setField("sudoPassword", "");
}}
onChange={(e) =>
setField("sudoPassword", e.target.value)
}
+2 -1
View File
@@ -66,7 +66,8 @@ import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types";
import { toast } from "sonner";
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
import { Select2 } from "@/components/select2";
import { LANGUAGES, THEMES } from "@/settings/appearance/appearance-controls";
import { THEMES } from "@/settings/appearance/appearance-controls";
import { LANGUAGES } from "@/i18n/languages";
import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences";
import { reportBetaFeedbackUrl } from "@/lib/issue-url";
import { isPrerelease } from "@/plugins/plugin-model";
+12 -2
View File
@@ -79,7 +79,7 @@ import { useSidebarDragState } from "./hooks/useSidebarDragState";
export function SidebarTree({
children,
onOpenTab,
onOpenTab: openTab,
onEditHost,
onShareHost,
query: externalQuery = "",
@@ -125,6 +125,16 @@ export function SidebarTree({
rowFields?: HostRowFields;
}) {
const { t } = useTranslation();
const rootRef = useRef<HTMLDivElement>(null);
// Focus left on the clicked row sends the next keys to the host speed
// search instead of the session that just opened.
const onOpenTab: typeof openTab = (...args) => {
const active = document.activeElement;
if (active instanceof HTMLElement && rootRef.current?.contains(active)) {
active.blur();
}
openTab(...args);
};
const hostSettingPlugins = usePluginHostSections();
const hostSwitchPlugins = hostSettingPlugins.filter(
(plugin) => !!plugin.contributes?.settings?.host?.enableKey,
@@ -939,6 +949,7 @@ export function SidebarTree({
return (
<div
ref={rootRef}
className="relative flex flex-col flex-1 min-h-0"
onKeyDown={(event) => {
if (selectionMode || arrangeMode) return;
@@ -1413,7 +1424,6 @@ export function SidebarTree({
setConfirmDialog({
message: t("hosts.deleteHostsConfirm", {
count: selectedHostIds.size,
plural: selectedHostIds.size !== 1 ? "s" : "",
}),
onConfirm: async () => {
const ids = Array.from(selectedHostIds);
+30
View File
@@ -3,9 +3,11 @@ import { describe, expect, it, beforeEach } from "vitest";
import {
changeAppLanguage,
consumeLoginLanguage,
LOCALE_FILES,
normalizeLanguageCode,
rememberLoginLanguage,
} from "../../i18n/i18n";
import { LANGUAGES, TRANSLATED_LANGUAGES } from "../../i18n/languages";
describe("i18n language handling", () => {
beforeEach(() => {
@@ -20,6 +22,34 @@ describe("i18n language handling", () => {
expect(normalizeLanguageCode("unknown")).toBe("en");
});
it("maps regional variants to the closest shipped language", () => {
expect(normalizeLanguageCode("es-MX")).toBe("es-ES");
expect(normalizeLanguageCode("es")).toBe("es-ES");
expect(normalizeLanguageCode("sv")).toBe("sv-SE");
expect(normalizeLanguageCode("pt")).toBe("pt-BR");
expect(normalizeLanguageCode("pt-AO")).toBe("pt-BR");
expect(normalizeLanguageCode("zh")).toBe("zh-CN");
expect(normalizeLanguageCode("zh-HK")).toBe("zh-TW");
expect(normalizeLanguageCode("zh-Hant-TW")).toBe("zh-TW");
expect(normalizeLanguageCode("nb-NO")).toBe("no");
expect(normalizeLanguageCode("de-AT")).toBe("de");
expect(normalizeLanguageCode("sk-SK")).toBe("sk");
});
it("ships a translated file for every listed language", () => {
const files = new Set(
Object.keys(import.meta.glob("../../locales/translated/*.json")).map(
(path) => path.split("/").pop()?.replace(".json", ""),
),
);
for (const language of TRANSLATED_LANGUAGES) {
expect(LOCALE_FILES[language.code]).toBe(language.file);
expect(files.has(language.file), language.file).toBe(true);
}
expect(LANGUAGES[0]).toEqual({ code: "en", label: "English" });
expect(LANGUAGES).toHaveLength(TRANSLATED_LANGUAGES.length + 1);
});
it("stores the normalized language after a successful switch", async () => {
await expect(changeAppLanguage("zh_CN")).resolves.toBe("zh-CN");
expect(localStorage.getItem("i18nextLng")).toBe("zh-CN");
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { updateToastFor, type DesktopUpdateState } from "@/lib/desktop-updates";
function state(patch: Partial<DesktopUpdateState>): DesktopUpdateState {
return {
kind: "auto",
mode: "prompt",
currentVersion: "26.10.1",
status: "available",
version: "26.10.2",
releaseUrl: null,
progress: null,
error: null,
...patch,
};
}
describe("updateToastFor", () => {
it("asks before downloading in ask first mode", () => {
expect(updateToastFor(state({}))).toBe("prompt");
});
it("stays quiet while automatic mode downloads", () => {
expect(updateToastFor(state({ mode: "automatic" }))).toBeNull();
expect(
updateToastFor(state({ mode: "automatic", status: "downloading" })),
).toBeNull();
});
it("offers a restart once downloaded", () => {
expect(updateToastFor(state({ status: "downloaded" }))).toBe("ready");
expect(
updateToastFor(state({ mode: "automatic", status: "downloaded" })),
).toBe("ready");
});
it("links the release for installs that can not update", () => {
expect(updateToastFor(state({ kind: "manual" }))).toBe("manual");
});
it("never toasts when updates are off", () => {
expect(updateToastFor(state({ mode: "never" }))).toBeNull();
expect(
updateToastFor(state({ mode: "never", status: "downloaded" })),
).toBeNull();
expect(updateToastFor(null)).toBeNull();
});
});
@@ -38,6 +38,20 @@ describe("getEmbeddedServerFailure", () => {
});
});
it("surfaces a database too old to open", async () => {
setElectronAPI({
isElectron: true,
getEmbeddedServerStatus: vi.fn().mockResolvedValue({
running: false,
failure: { reason: "old-database", port: null },
}),
});
await expect(getEmbeddedServerFailure()).resolves.toEqual({
reason: "old-database",
port: null,
});
});
it("surfaces a generic crash", async () => {
setElectronAPI({
isElectron: true,
@@ -93,6 +93,22 @@ describe("CORE_STEPS", () => {
]);
});
it("asks how the desktop app updates when it has an updater", () => {
const win = window as unknown as { electronAPI?: unknown };
win.electronAPI = { isElectron: true, updates: {} };
try {
expect(ids(ctx({ isDesktop: true }))).toEqual([
"desktop-sync",
"desktop-updates",
"preset",
"appearance",
]);
expect(ids(ctx())).not.toContain("desktop-updates");
} finally {
delete win.electronAPI;
}
});
it("offers account security only when there is something to set up", () => {
expect(ids(ctx({ canEnrollSecondFactor: true }))).toEqual([
"preset",
@@ -28,8 +28,7 @@ const [plugin] = mergePlugins(
pinnedVersion: null,
autoUpdate: false,
bundled: false,
installCount: null,
installCountSource: null,
downloads: null,
},
],
);
+19 -4
View File
@@ -22,8 +22,7 @@ const plugin = {
capabilities: [],
contributes: {},
versions: [],
installCount: null,
installCountSource: null,
downloads: null,
};
vi.mock("@/plugins/use-plugins-manager", () => ({
@@ -40,6 +39,7 @@ vi.mock("@/plugins/use-plugins-manager", () => ({
confirmConsent: vi.fn(),
developerMode: false,
signedOnly: false,
registries: [],
}),
}));
@@ -60,17 +60,32 @@ beforeEach(() => localStorage.clear());
afterEach(cleanup);
describe("PluginsTab", () => {
it("tells people community plugins are coming and links the guide", async () => {
it("points to the Registries tab and links the submit guide", async () => {
await renderTab();
fireEvent.click(screen.getByText(en.plugins.manager.tabs.browse));
expect(
screen.getByText(en.plugins.manager.communitySoon, { exact: false }),
screen.getByText(en.plugins.manager.registries.browseHint, {
exact: false,
}),
).toBeTruthy();
const link = screen.getByText(en.plugins.manager.communitySubmit);
expect(link.getAttribute("href")).toBe(docsUrl("communityRegistry"));
});
it("hides installed plugins in Browse until shown", async () => {
await renderTab();
fireEvent.click(screen.getByText(en.plugins.manager.tabs.browse));
expect(screen.queryByText("Demo Plugin")).toBeNull();
expect(screen.getByText(en.plugins.manager.allInstalled)).toBeTruthy();
fireEvent.click(screen.getByRole("switch"));
expect(screen.getByText("Demo Plugin")).toBeTruthy();
expect(localStorage.getItem("pluginsTab.browseInstalled")).toBe("show");
});
it("switches to a table and remembers it", async () => {
await renderTab();
expect(screen.queryByRole("table")).toBeNull();
@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import i18next from "i18next";
import { I18nextProvider, initReactI18next } from "react-i18next";
import en from "@/locales/en.json";
import { RegistriesSection } from "@/plugins/RegistriesSection";
import type { PluginsManager } from "@/plugins/use-plugins-manager";
const api = vi.hoisted(() => ({
getRegistries: vi.fn(),
previewRegistry: vi.fn(),
addRegistry: vi.fn(),
setRegistryEnabled: vi.fn(),
removeRegistry: vi.fn(),
}));
vi.mock("@/api/plugins-api", () => api);
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const manager = {
registries: [],
managedByServer: false,
refresh: vi.fn(async () => {}),
} as unknown as PluginsManager;
function state(communityEnabled = false) {
return {
managedByServer: false,
official: { id: "official", url: "https://example.com/official.json" },
community: {
id: "community",
url: "https://example.com/community.json",
enabled: communityEnabled,
},
custom: [],
};
}
async function renderSection() {
const i18n = i18next.createInstance();
await i18n.use(initReactI18next).init({
lng: "en",
resources: { en: { translation: en } },
});
return render(
<I18nextProvider i18n={i18n}>
<RegistriesSection manager={manager} />
</I18nextProvider>,
);
}
beforeEach(() => {
for (const fn of Object.values(api)) fn.mockReset();
api.getRegistries.mockResolvedValue(state());
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("RegistriesSection", () => {
it("asks before turning community plugins on", async () => {
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
await renderSection();
const toggle = await screen.findByRole("switch");
fireEvent.click(toggle);
await waitFor(() => expect(confirm).toHaveBeenCalled());
expect(confirm.mock.calls[0][0]).toContain(
en.plugins.manager.registries.communityConfirmBody,
);
expect(api.setRegistryEnabled).not.toHaveBeenCalled();
confirm.mockReturnValue(true);
api.getRegistries.mockResolvedValue(state(true));
fireEvent.click(toggle);
await waitFor(() =>
expect(api.setRegistryEnabled).toHaveBeenCalledWith("community", true),
);
});
it("does not ask when turning community plugins off", async () => {
const confirm = vi.spyOn(window, "confirm");
api.getRegistries.mockResolvedValue(state(true));
await renderSection();
fireEvent.click(await screen.findByRole("switch"));
await waitFor(() =>
expect(api.setRegistryEnabled).toHaveBeenCalledWith("community", false),
);
expect(confirm).not.toHaveBeenCalled();
});
it("checks a registry before adding it with the key it showed", async () => {
api.previewRegistry.mockResolvedValue({
id: "acme",
name: "Acme Plugins",
url: "https://acme.example/index.json",
keyId: "abcd1234abcd1234",
plugins: 3,
});
api.addRegistry.mockResolvedValue({
id: "acme",
name: "Acme Plugins",
url: "https://acme.example/index.json",
keyId: "abcd1234abcd1234",
enabled: true,
addedAt: "",
});
await renderSection();
fireEvent.click(await screen.findByText(en.plugins.manager.registries.add));
fireEvent.change(
screen.getByLabelText(en.plugins.manager.registries.urlLabel),
{ target: { value: "https://acme.example/index.json" } },
);
fireEvent.click(screen.getByText(en.plugins.manager.registries.check));
expect(await screen.findByText("Acme Plugins")).toBeTruthy();
expect(screen.getByText("Key abcd1234abcd1234")).toBeTruthy();
expect(api.addRegistry).not.toHaveBeenCalled();
fireEvent.click(screen.getByText(en.plugins.manager.registries.addConfirm));
await waitFor(() =>
expect(api.addRegistry).toHaveBeenCalledWith(
"https://acme.example/index.json",
"abcd1234abcd1234",
),
);
});
});
+15 -5
View File
@@ -61,8 +61,7 @@ function entry(
pinnedVersion: null,
autoUpdate: false,
bundled: true,
installCount: null,
installCountSource: null,
downloads: null,
...overrides,
};
}
@@ -125,6 +124,14 @@ describe("mergePlugins", () => {
);
});
it("offers no update for a plugin a linked desktop leaves to its server", () => {
const [row] = mergePlugins(
[summary({ enabled: false, state: "disabled", runsOnServer: true })],
[entry()],
);
expect(row).toMatchObject({ status: "server", updateAvailable: false });
});
it("keeps installed plugins when the registry is unreachable", () => {
const rows = mergePlugins([summary()], null);
expect(rows).toHaveLength(1);
@@ -140,6 +147,9 @@ describe("pluginStatus", () => {
"disabled",
);
expect(pluginStatus(summary())).toBe("running");
expect(pluginStatus(summary({ state: "failed", runsOnServer: true }))).toBe(
"server",
);
});
});
@@ -224,9 +234,9 @@ describe("popularity", () => {
const rows = mergePlugins(
[],
[
entry({ id: "a", name: "A", installed: false, installCount: 5 }),
entry({ id: "b", name: "B", installed: false, installCount: null }),
entry({ id: "c", name: "C", installed: false, installCount: 90 }),
entry({ id: "a", name: "A", installed: false, downloads: 5 }),
entry({ id: "b", name: "B", installed: false, downloads: null }),
entry({ id: "c", name: "C", installed: false, downloads: 90 }),
],
);
expect(sortPlugins(rows, "popular").map((r) => r.id)).toEqual([
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import { DesktopUpdatesCard } from "@/settings/DesktopUpdatesCard";
import type { DesktopUpdateState } from "@/lib/desktop-updates";
type TestWindow = { electronAPI?: unknown };
function state(patch: Partial<DesktopUpdateState> = {}): DesktopUpdateState {
return {
kind: "auto",
mode: "prompt",
currentVersion: "26.10.1",
status: "idle",
version: null,
releaseUrl: null,
progress: null,
error: null,
...patch,
};
}
function bridge(initial: DesktopUpdateState) {
return {
getState: vi.fn(async () => initial),
check: vi.fn(async () => initial),
download: vi.fn(async () => initial),
install: vi.fn(async () => true),
setMode: vi.fn(async () => initial),
onState: vi.fn(() => () => {}),
};
}
async function renderWith(initial: DesktopUpdateState) {
const api = bridge(initial);
(window as TestWindow).electronAPI = { isElectron: true, updates: api };
await act(async () => {
render(<DesktopUpdatesCard />);
});
return api;
}
afterEach(() => {
cleanup();
delete (window as TestWindow).electronAPI;
});
describe("DesktopUpdatesCard", () => {
it("renders nothing in the browser", () => {
const { container } = render(<DesktopUpdatesCard />);
expect(container.innerHTML).toBe("");
});
it("checks on demand", async () => {
const api = await renderWith(state());
fireEvent.click(screen.getByText("desktopUpdates.checkNow"));
expect(api.check).toHaveBeenCalled();
});
it("offers the download in ask first mode", async () => {
const api = await renderWith(
state({ status: "available", version: "26.10.2" }),
);
fireEvent.click(screen.getByText("desktopUpdates.download"));
expect(api.download).toHaveBeenCalled();
});
it("restarts into a downloaded update", async () => {
const api = await renderWith(
state({ status: "downloaded", version: "26.10.2" }),
);
fireEvent.click(screen.getByText("desktopUpdates.restart"));
expect(api.install).toHaveBeenCalled();
});
it("links the release for installs that can not update", async () => {
await renderWith(
state({
kind: "manual",
status: "available",
version: "26.10.2",
releaseUrl: "https://example.com/r",
}),
);
expect(
screen.getByText("desktopUpdates.download").closest("a"),
).toHaveProperty("href", "https://example.com/r");
});
it("hides on dev builds", async () => {
await renderWith(state({ kind: "disabled" }));
expect(screen.queryByText("desktopUpdates.title")).toBeNull();
});
});
@@ -33,6 +33,16 @@ describe("settings pages", () => {
expect(admin).toContain("security");
});
it("shows Updates to everyone on desktop but only admins in the browser", () => {
const ids = (isAdmin: boolean, isElectron: boolean) =>
visibleSettingsPages(CORE_SETTINGS_PAGES, { isAdmin, isElectron }).map(
(page) => page.id,
);
expect(ids(false, true)).toContain("updates");
expect(ids(false, false)).not.toContain("updates");
expect(ids(true, false)).toContain("updates");
});
it("finds a page by a label shown on it, not only its name", () => {
const hits = searchSettingsPages(CORE_SETTINGS_PAGES, "theme", translate);
expect(hits.some((hit) => hit.page.id === "appearance")).toBe(true);
@@ -23,7 +23,9 @@ vi.mock("@/sidebar/tree/FolderItem/FolderItem", () => ({
folderHostCount: () => 1,
}));
vi.mock("@/sidebar/tree/HostItem/HostItem", () => ({
HostItem: () => <div>Host</div>,
HostItem: ({ onOpenTab }: { onOpenTab: (type: string) => void }) => (
<button onClick={() => onOpenTab("terminal")}>Host</button>
),
}));
const observers: Array<{
@@ -161,13 +163,45 @@ it("finds a host inside a closed folder and opens the keyboard-selected match",
const search = view.getByLabelText("hosts.speedSearch");
expect(document.activeElement).toBe(search);
expect(view.container.querySelectorAll("[data-index]")).toHaveLength(3);
fireEvent.keyDown(search, { key: "ArrowDown" });
fireEvent.keyDown(search, { key: "Enter" });
expect(onOpenTab).toHaveBeenCalledWith(beta, "terminal", {
forceNewTab: false,
});
fireEvent.keyDown(search, { key: "Escape" });
expect(view.queryByLabelText("hosts.speedSearch")).toBeNull();
expect(view.container.querySelectorAll("[data-index]")).toHaveLength(1);
expect(document.activeElement).toBe(list);
fireEvent.keyDown(list, { key: "a" });
const again = view.getByLabelText("hosts.speedSearch");
fireEvent.keyDown(again, { key: "ArrowDown" });
fireEvent.keyDown(again, { key: "Enter" });
expect(onOpenTab).toHaveBeenCalledWith(beta, "terminal", {
forceNewTab: false,
});
// The opened session takes over the keyboard.
expect(view.queryByLabelText("hosts.speedSearch")).toBeNull();
expect(view.container.contains(document.activeElement)).toBe(false);
});
it("lets go of focus when a host opens, so typing reaches the session", () => {
const host = {
id: "1",
name: "alpha",
ip: "192.0.2.1",
username: "u",
} as Host;
const onOpenTab = vi.fn();
const view = render(
<SidebarTree
children={[{ name: "group", path: "group", children: [host] }]}
onOpenTab={onOpenTab}
onEditHost={vi.fn()}
selectionMode={false}
onToggleSelectionMode={vi.fn()}
/>,
);
const row = view.getByText("Host");
row.focus();
fireEvent.click(row);
expect(onOpenTab).toHaveBeenCalledWith(host, "terminal", undefined);
expect(document.activeElement).not.toBe(row);
fireEvent.keyDown(document.body, { key: "a" });
expect(view.queryByLabelText("hosts.speedSearch")).toBeNull();
});
+5 -1
View File
@@ -38,8 +38,12 @@ describe("onboarding seen map", () => {
});
it("legacy seen covers every core step at launch", () => {
// Steps added later stay out so upgraded users still see them.
const addedLater = ["desktop-updates"];
expect(Object.keys(LEGACY_SEEN).sort()).toEqual(
Object.keys(CORE_ONBOARDING_STEPS).sort(),
Object.keys(CORE_ONBOARDING_STEPS)
.filter((key) => !addedLater.includes(key))
.sort(),
);
});
});