From 58c3434e5831b0d6ad16a5bbfcc8833bb3fe1240 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Tue, 6 Oct 2026 13:02:05 -0500 Subject: [PATCH] feat: shared list kit, settings search, rail order and ui consistency fixes --- package-lock.json | 2 +- package.json | 2 + packages/plugin-sdk/package.json | 2 +- packages/plugin-sdk/src/testing/setup.ts | 9 + packages/plugin-sdk/ui.api.txt | 10 +- scripts/build-plugins.cjs | 17 + scripts/bundled-plugins.test.ts | 38 ++ scripts/check-sdk-ui-exports.cjs | 8 + scripts/lib/bundled-plugins.cjs | 44 ++ src/backend/plugins/assets.ts | 8 +- src/backend/tests/plugins/assets.test.ts | 19 +- src/types/host-sidebar-preferences.ts | 20 + src/types/ui-preferences.ts | 3 + src/ui/AppShell.tsx | 8 +- src/ui/auth/Auth.tsx | 8 +- src/ui/components/form-fields.tsx | 2 +- src/ui/components/list-kit.tsx | 467 ++++++++++++++++++ src/ui/components/panel-layout.tsx | 30 +- src/ui/components/section-card.tsx | 2 +- src/ui/components/separator.tsx | 2 +- src/ui/components/tab-strip.tsx | 9 +- src/ui/components/version-badge.tsx | 11 +- src/ui/dashboard/DashboardTab.tsx | 70 +-- src/ui/dashboard/dashboard-core-cards.tsx | 32 +- src/ui/index.css | 10 + src/ui/locales/en.json | 20 +- src/ui/manage/EditorPane.tsx | 118 ++++- src/ui/manage/ManageList.tsx | 69 ++- src/ui/plugin-host/sdk-ui.ts | 10 + src/ui/plugins/PluginConsentPrompt.tsx | 7 +- src/ui/plugins/PluginDetail.tsx | 12 +- src/ui/plugins/PluginsTab.tsx | 10 +- src/ui/settings/FeatureSettingsSections.tsx | 3 +- src/ui/settings/KeybindingsSettings.tsx | 5 +- src/ui/settings/SettingsTab.tsx | 51 +- src/ui/settings/page-filter.ts | 139 ++++++ src/ui/settings/sync/LinkServerDialog.tsx | 6 +- src/ui/settings/sync/SyncPanel.tsx | 9 +- src/ui/shell/CommandPalette.tsx | 4 +- src/ui/shell/TabBar.tsx | 8 +- src/ui/shell/split/SplitLayoutMenu.tsx | 2 +- src/ui/shell/tabUtils.tsx | 18 +- src/ui/sidebar/AdminManagementSections.tsx | 7 +- src/ui/sidebar/AdminUserManagePanel.tsx | 5 +- src/ui/sidebar/AppRail.tsx | 40 +- src/ui/sidebar/ConnectionsPanel.tsx | 295 +++++------ .../CustomizeCredentialsSidebarPanel.tsx | 9 +- src/ui/sidebar/CustomizeSidebarPanel.tsx | 63 ++- src/ui/sidebar/FolderMetadataDialog.tsx | 5 +- src/ui/sidebar/HostAuthOverrideModal.tsx | 32 +- src/ui/sidebar/HostEditorGeneralTab.tsx | 4 +- src/ui/sidebar/HostExportDialog.tsx | 24 +- src/ui/sidebar/HostsPanel.tsx | 4 + .../sidebar/NavigationVisibilityToggles.tsx | 112 ++++- src/ui/sidebar/QuickConnectPanel.tsx | 62 +-- src/ui/sidebar/UserProfilePanel.tsx | 102 ++-- src/ui/sidebar/host-contributions.ts | 5 + src/ui/sidebar/rail-items.ts | 48 +- src/ui/sidebar/tree/HostItem/HostItem.tsx | 10 +- src/ui/sidebar/tree/host-bar-actions.ts | 39 ++ src/ui/tests/components/list-kit.test.tsx | 153 ++++++ src/ui/tests/manage/EditorPane.test.tsx | 86 ++++ src/ui/tests/settings/page-filter.test.ts | 44 ++ .../sidebar/HostAuthOverrideModal.test.tsx | 15 +- src/ui/tests/sidebar/rail-items.test.ts | 49 ++ .../sidebar/tree/host-bar-actions.test.ts | 16 + .../types/host-sidebar-preferences.test.ts | 29 ++ src/ui/user/DonationReminderModal.tsx | 8 +- 68 files changed, 2078 insertions(+), 512 deletions(-) create mode 100644 src/ui/components/list-kit.tsx create mode 100644 src/ui/settings/page-filter.ts create mode 100644 src/ui/sidebar/tree/host-bar-actions.ts create mode 100644 src/ui/tests/components/list-kit.test.tsx create mode 100644 src/ui/tests/manage/EditorPane.test.tsx create mode 100644 src/ui/tests/settings/page-filter.test.ts create mode 100644 src/ui/tests/sidebar/tree/host-bar-actions.test.ts diff --git a/package-lock.json b/package-lock.json index 8be03ed14..97cc1df8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15076,7 +15076,7 @@ }, "packages/plugin-sdk": { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.0", + "version": "1.1.0", "license": "Apache-2.0", "dependencies": { "@tailwindcss/node": "^4.2.4", diff --git a/package.json b/package.json index 5defd1bb3..410b2fe7d 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "build": "npm run build:sdk && vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins", "build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins", "dev:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins && node ./dist/backend/backend/starter.js", + "dev:backend:local": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins:local && node ./dist/backend/backend/starter.js", "dev:docker": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker build -f docker/Dockerfile -t termix:dev --no-cache . && docker run -d --name termix-dev -p 3000:3000 -p 8080:8080 -p 30001:30001 -v \"%cd%\\db\\data:/app/data\" termix:dev", "dev:docker:restart": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker run -d --name termix-dev -p 8080:8080 -p 30001:30001 -v \"%cd%\\db\\data:/app/data\" termix:dev", "generate:openapi": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node ./dist/backend/backend/utils/swagger.js", @@ -49,6 +50,7 @@ "schema:migrations": "drizzle-kit generate --config=drizzle.config.sqlite.ts && drizzle-kit generate --config=drizzle.config.pg.ts && drizzle-kit generate --config=drizzle.config.mysql.ts", "build:sdk": "npm run build --workspace @termix-ssh/plugin-sdk && node scripts/build-sdk-host.mjs", "build:plugins": "node scripts/build-plugins.cjs", + "build:plugins:local": "node scripts/build-plugins.cjs --local ../Termix-Plugins", "plugins:pin": "node scripts/pin-bundled-plugins.cjs" }, "dependencies": { diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index e2c40153c..12155d39e 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@termix-ssh/plugin-sdk", - "version": "1.0.0", + "version": "1.1.0", "description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.", "license": "Apache-2.0", "repository": { diff --git a/packages/plugin-sdk/src/testing/setup.ts b/packages/plugin-sdk/src/testing/setup.ts index 19c0d25ca..73d5dc260 100644 --- a/packages/plugin-sdk/src/testing/setup.ts +++ b/packages/plugin-sdk/src/testing/setup.ts @@ -10,6 +10,15 @@ import { installSafePorts } from "./safe-ports.js"; installSafePorts(); +// Radix measures some controls (Checkbox, Switch) and jsdom has no observer. +if (typeof globalThis.ResizeObserver === "undefined") { + globalThis.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +} + if (typeof window !== "undefined" && !window.matchMedia) { window.matchMedia = vi.fn().mockImplementation((query: string) => ({ matches: false, diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt index eada930e3..b90c00b09 100644 --- a/packages/plugin-sdk/ui.api.txt +++ b/packages/plugin-sdk/ui.api.txt @@ -1,4 +1,5 @@ ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element +AddButton (value): ({ label, compact, className, ...rest }: { label?: string; compact?: boolean; className?: string; } & Omit, "children"> & { ref?: Ref; }) => Element Alert (value): ({ className, variant, ...props }: ClassAttributes & HTMLAttributes & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element AlertDescription (value): ({ className, ...props }: DetailedHTMLProps, HTMLDivElement>) => Element AlertDialog (value): ({ ...props }: AlertDialogProps) => Element @@ -79,6 +80,7 @@ Facts (value): ({ className, children, }: { className?: string; children: ReactN FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element Field (value): ({ label, hint, error, aside, htmlFor, className, children, }: { label: string; hint?: ReactNode; error?: string; aside?: ReactNode; htmlFor?: string; className?: string; children: ReactNode; }) => Element FieldPair (value): ({ children }: { children: ReactNode; }) => Element +FormFooter (value): ({ onCancel, onSave, saveLabel, cancelLabel, saving, dirty, status, disabled, onDelete, deleteLabel, extra, saveType, form, }: { onCancel?: () => void; onSave?: () => void; saveLabel?: string; cancelLabel?: string; saving?: boolean; dirty?: boolean; status?: ReactNode; disabled?: boolean; onDelete?: () => void; deleteLabel?: string; extra?: ReactNode; saveType?: "button" | "submit"; form?: string; }) => Element FullScreenAppPhase (type): FullScreenAppPhase FullScreenAppWrapper (value): FC GridCardCatalogEntry (type): GridCardCatalogEntry @@ -103,6 +105,11 @@ KeybindingActionType (type): string Label (value): ({ className, ...props }: LabelProps & RefAttributes) => Element LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element LineChartSeries (type): LineChartSeries +ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element +ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit, "title" | "children" | "onClick">) => Element +ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit, "children"> & { ref?: Ref; }) => Element +ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dropActive, onDropItem, emptyText, className, children, }: { name: ReactNode; icon?: ReactNode; count?: number; open: boolean; onToggle: () => void; stripe?: number; actions?: ReactNode; dropActive?: boolean; onDropItem?: () => void; emptyText?: ReactNode; className?: string; children?: ReactNode; }) => Element +ListRowTone (type): ListRowTone LogContext (type): LogContext LogEntry (type): LogEntry MFAPromptMode (type): MFAPromptMode @@ -119,7 +126,8 @@ PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element PanelDensity (type): PanelDensity -PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; className?: string; }) => Element +PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element +PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, disabled, title, onEnter, inputRef, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; disabled?: boolean; title?: string; onEnter?: () => void; inputRef?: Ref; className?: string; }) => Element PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; className?: string; children: ReactNode; }) => Element PanelViewMode (type): PanelViewMode PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element diff --git a/scripts/build-plugins.cjs b/scripts/build-plugins.cjs index c4a50511b..ccb520a01 100644 --- a/scripts/build-plugins.cjs +++ b/scripts/build-plugins.cjs @@ -5,6 +5,10 @@ * dist/backend/backend/plugins -> dist/plugins, which is where this writes. * Each .tmxplug is downloaded (or read from a path), checked against its * pinned sha256 and unpacked. + * + * For development, `--local ` (or TERMIX_LOCAL_PLUGINS) points at a + * folder of plugin repos like ../Termix-Plugins. Any plugin built there (has + * dist/) is copied in place of its pin, so local plugin changes show up. */ const fs = require("node:fs"); @@ -13,6 +17,8 @@ const { loadBundledPlugins, fetchArtifact, extractArtifact, + findLocalPluginBuilds, + copyLocalBuild, } = require("./lib/bundled-plugins.cjs"); const root = path.resolve(__dirname, ".."); @@ -20,11 +26,22 @@ const destination = path.join(root, "dist", "plugins"); async function main() { const plugins = loadBundledPlugins(root); + const flag = process.argv.indexOf("--local"); + const localArg = + flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS; + const localDir = localArg ? path.resolve(root, localArg) : null; + const local = findLocalPluginBuilds(localDir); fs.rmSync(destination, { recursive: true, force: true }); fs.mkdirSync(destination, { recursive: true }); for (const plugin of plugins) { + const repo = local.get(plugin.id); + if (repo) { + copyLocalBuild(repo, path.join(destination, plugin.id)); + console.log(`copied local build of ${plugin.id}`); + continue; + } const buffer = await fetchArtifact(plugin, root); await extractArtifact(buffer, path.join(destination, plugin.id), plugin.id); console.log(`unpacked ${plugin.id}`); diff --git a/scripts/bundled-plugins.test.ts b/scripts/bundled-plugins.test.ts index 2d6fd6a69..e4f8d25a8 100644 --- a/scripts/bundled-plugins.test.ts +++ b/scripts/bundled-plugins.test.ts @@ -10,6 +10,8 @@ import { fetchArtifact, extractArtifact, pinsFromIndex, + findLocalPluginBuilds, + copyLocalBuild, } from "./lib/bundled-plugins.cjs"; const cleanups: Array<() => void> = []; @@ -192,3 +194,39 @@ describe("pinsFromIndex", () => { ); }); }); + +describe("local plugin builds", () => { + it("finds built repos by manifest id and skips unbuilt ones", () => { + const dir = tempDir(); + const built = path.join(dir, "Plugin-Files"); + fs.mkdirSync(path.join(built, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(built, "manifest.json"), + JSON.stringify({ id: "file-manager" }), + ); + const unbuilt = path.join(dir, "Plugin-Other"); + fs.mkdirSync(unbuilt); + fs.writeFileSync( + path.join(unbuilt, "manifest.json"), + JSON.stringify({ id: "other" }), + ); + + const builds = findLocalPluginBuilds(dir); + expect([...builds.keys()]).toEqual(["file-manager"]); + expect(findLocalPluginBuilds(null).size).toBe(0); + }); + + it("copies only the files a bundled plugin needs", () => { + const repo = tempDir(); + fs.mkdirSync(path.join(repo, "dist")); + fs.writeFileSync(path.join(repo, "dist", "frontend.js"), "x"); + fs.writeFileSync(path.join(repo, "manifest.json"), "{}"); + fs.mkdirSync(path.join(repo, "src")); + + const out = path.join(tempDir(), "file-manager"); + copyLocalBuild(repo, out); + expect(fs.existsSync(path.join(out, "dist", "frontend.js"))).toBe(true); + expect(fs.existsSync(path.join(out, "manifest.json"))).toBe(true); + expect(fs.existsSync(path.join(out, "src"))).toBe(false); + }); +}); diff --git a/scripts/check-sdk-ui-exports.cjs b/scripts/check-sdk-ui-exports.cjs index 21ee67f3b..773d52152 100644 --- a/scripts/check-sdk-ui-exports.cjs +++ b/scripts/check-sdk-ui-exports.cjs @@ -20,6 +20,7 @@ const TSCONFIG = path.join(ROOT, "tsconfig.app.json"); const ALLOWED = [ "ActionSlot", + "AddButton", "Alert", "AlertDescription", "AlertDialog", @@ -100,6 +101,7 @@ const ALLOWED = [ "FakeSwitch", "Field", "FieldPair", + "FormFooter", "FullScreenAppPhase", "FullScreenAppWrapper", "GridCardCatalogEntry", @@ -124,6 +126,11 @@ const ALLOWED = [ "Label", "LineChart", "LineChartSeries", + "ListBadge", + "ListRow", + "ListRowAction", + "ListRowFolder", + "ListRowTone", "LogContext", "LogEntry", "MFAPromptMode", @@ -140,6 +147,7 @@ const ALLOWED = [ "PROMPT_DESTRUCTIVE_BUTTON", "PROMPT_PRIMARY_BUTTON", "PanelDensity", + "PanelList", "PanelSearch", "PanelShell", "PanelViewMode", diff --git a/scripts/lib/bundled-plugins.cjs b/scripts/lib/bundled-plugins.cjs index 9e806474f..8d47924b5 100644 --- a/scripts/lib/bundled-plugins.cjs +++ b/scripts/lib/bundled-plugins.cjs @@ -172,7 +172,51 @@ function pinsFromIndex(index, ids) { }); } +const LOCAL_FILES = [ + "manifest.json", + "dist", + "locales", + "migrations", + "README.md", + "CHANGELOG.json", +]; + +/** + * Built plugin repos under dir (each with manifest.json and dist/), by id. + * Used in development to stage local plugin builds instead of the pins. + */ +function findLocalPluginBuilds(dir) { + const builds = new Map(); + if (!dir || !fs.existsSync(dir)) return builds; + for (const name of fs.readdirSync(dir)) { + const repo = path.join(dir, name); + const manifestPath = path.join(repo, "manifest.json"); + if (!fs.existsSync(manifestPath)) continue; + if (!fs.existsSync(path.join(repo, "dist"))) continue; + try { + const { id } = JSON.parse(fs.readFileSync(manifestPath, "utf8")); + if (typeof id === "string" && ID.test(id)) builds.set(id, repo); + } catch { + // not a plugin repo + } + } + return builds; +} + +/** Copies a local plugin build into the staging folder. */ +function copyLocalBuild(repo, destination) { + fs.mkdirSync(destination, { recursive: true }); + for (const entry of LOCAL_FILES) { + const from = path.join(repo, entry); + if (fs.existsSync(from)) { + fs.cpSync(from, path.join(destination, entry), { recursive: true }); + } + } +} + module.exports = { + findLocalPluginBuilds, + copyLocalBuild, parseBundledPlugins, pinsFromIndex, loadBundledPlugins, diff --git a/src/backend/plugins/assets.ts b/src/backend/plugins/assets.ts index 94e1b9910..ece4f14cf 100644 --- a/src/backend/plugins/assets.ts +++ b/src/backend/plugins/assets.ts @@ -276,7 +276,13 @@ export function createPluginAssetsRouter( : "no-cache", ); res.type(CONTENT_TYPES[path.extname(file)] ?? "application/octet-stream"); - res.sendFile(file); + // Installed plugins unpack under .unpacked/, which send would refuse as a + // dotfile path. resolvePluginAsset already kept the file in the plugin. + res.sendFile(file, { dotfiles: "allow" }, (error) => { + if (error && !res.headersSent) { + res.status(404).json({ error: "Not found" }); + } + }); }, ); diff --git a/src/backend/tests/plugins/assets.test.ts b/src/backend/tests/plugins/assets.test.ts index bc088dd9c..379e081ca 100644 --- a/src/backend/tests/plugins/assets.test.ts +++ b/src/backend/tests/plugins/assets.test.ts @@ -13,6 +13,7 @@ import { let root = ""; let plugin: { id: string; dir: string; manifest: never }; +let installed: { id: string; dir: string; manifest: never }; let server: Server; let baseUrl = ""; @@ -32,6 +33,14 @@ beforeAll(async () => { write("locales/translated/de_DE.json", "{}"); write("manifest.json", "{}"); fs.writeFileSync(path.join(root, "outside.js"), "nope"); + const unpacked = path.join(root, ".unpacked", "gadget"); + fs.mkdirSync(path.join(unpacked, "dist"), { recursive: true }); + fs.writeFileSync(path.join(unpacked, "dist", "frontend.js"), "export {}"); + installed = { + id: "gadget", + dir: unpacked, + manifest: { id: "gadget", version: "1.0.0" } as never, + }; plugin = { id: "gizmo", dir: path.join(root, "gizmo"), @@ -41,7 +50,9 @@ beforeAll(async () => { const app = express(); app.use( "/plugin-assets", - createPluginAssetsRouter((id) => (id === "gizmo" ? plugin : undefined)), + createPluginAssetsRouter((id) => + id === "gizmo" ? plugin : id === "gadget" ? installed : undefined, + ), ); server = await new Promise((resolve) => { const created = app.listen(0, "127.0.0.1", () => resolve(created)); @@ -111,6 +122,12 @@ describe("resolvePluginAsset", () => { }); describe("GET /plugin-assets", () => { + it("serves an installed plugin unpacked under a dot folder", async () => { + const res = await fetch(`${baseUrl}/plugin-assets/gadget/frontend.js`); + expect(res.status).toBe(200); + expect(await res.text()).toBe("export {}"); + }); + it("serves a bundle as a module any origin can load", async () => { const res = await fetch(`${baseUrl}/plugin-assets/gizmo/frontend.js`); expect(res.status).toBe(200); diff --git a/src/types/host-sidebar-preferences.ts b/src/types/host-sidebar-preferences.ts index d7b4f932d..c4624bf0c 100644 --- a/src/types/host-sidebar-preferences.ts +++ b/src/types/host-sidebar-preferences.ts @@ -57,6 +57,11 @@ export interface HostSidebarDisplayPreferences { /** When false, nested folders hide the parent-path breadcrumb before their name. */ showFolderPaths: boolean; hostClickBehavior: HostClickBehavior; + /** + * Host action id to whether it shows in the row's connect bar. An action + * missing here follows its own default. Every action stays in the menu. + */ + barActions: Record; } export interface HostSidebarPreferences { @@ -127,10 +132,24 @@ export function defaultHostSidebarPreferences(): HostSidebarPreferences { openOnDoubleClick: false, showFolderPaths: true, hostClickBehavior: "newTab", + barActions: {}, }, }; } +const ACTION_ID_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,79}$/; + +function sanitizeBarActions(input: unknown): Record { + if (!input || typeof input !== "object" || Array.isArray(input)) return {}; + const out: Record = {}; + for (const [id, value] of Object.entries(input as Record)) { + if (ACTION_ID_PATTERN.test(id) && typeof value === "boolean") { + out[id] = value; + } + } + return out; +} + function sanitizeStringArray(input: unknown): string[] { if (!Array.isArray(input)) return []; return input.filter((v): v is string => typeof v === "string"); @@ -215,6 +234,7 @@ export function sanitizeHostSidebarPreferences( ) ? (displayObj.hostClickBehavior as HostClickBehavior) : defaults.display.hostClickBehavior, + barActions: sanitizeBarActions(displayObj.barActions), }; return { diff --git a/src/types/ui-preferences.ts b/src/types/ui-preferences.ts index ed39c2e5b..52cb4fca5 100644 --- a/src/types/ui-preferences.ts +++ b/src/types/ui-preferences.ts @@ -65,6 +65,8 @@ export interface UiRailPreferences { * items are only known at runtime, so the preset cannot list them. */ hidePluginItems?: boolean; + /** Rail item ids in the user's order. Items not listed keep their place. */ + order?: string[]; } export interface UiDashboardPreferences { @@ -243,6 +245,7 @@ const AREA_SPECS: { }, rail: { hiddenTabs: { kind: "stringArray" }, + order: { kind: "stringArray" }, hidePluginItems: { kind: "bool" }, }, dashboard: { diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx index 3bfd93595..a3b6712a2 100644 --- a/src/ui/AppShell.tsx +++ b/src/ui/AppShell.tsx @@ -2969,7 +2969,13 @@ export function AppShell({ onRailClick={handleRailClick} onOpenTab={openSingletonTab} onOpenInRightDock={openInRightDock} - onOpenSettings={() => openSingletonTab("settings")} + onOpenSettings={() => + openSingletonTab("settings", undefined, undefined, { + section: "appearance", + reveal: "settings-navigation", + revealAt: Date.now(), + }) + } onOpenPalette={() => setCommandPaletteOpen(true)} onLogout={onLogout} /> diff --git a/src/ui/auth/Auth.tsx b/src/ui/auth/Auth.tsx index 1f3bcf1ff..d035e8392 100644 --- a/src/ui/auth/Auth.tsx +++ b/src/ui/auth/Auth.tsx @@ -1205,7 +1205,11 @@ export function Auth({ onLogin }: AuthProps) { {t("messages.databaseConnectionFailed")}

- diff --git a/src/ui/components/form-fields.tsx b/src/ui/components/form-fields.tsx index 33efb9abf..2ff4a2d7e 100644 --- a/src/ui/components/form-fields.tsx +++ b/src/ui/components/form-fields.tsx @@ -34,7 +34,7 @@ export function Field({ children: ReactNode; }) { return ( -
+
{(label || aside) && (