* 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

+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",