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")}
-