feat: shared list kit, settings search, rail order and ui consistency fixes

This commit is contained in:
LukeGus committed 2026-10-06 13:02:05 -05:00
1 parent 3ed5775ed5
commit 58c3434e58
68 files changed
+2078 -512

No files matched your search

+1 -1
View File
@@ -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",
+2
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+9
View File
@@ -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,
+9 -1
View File
@@ -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<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
Alert (value): ({ className, variant, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element
AlertDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, 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<FullScreenAppWrapperProps>
GridCardCatalogEntry (type): GridCardCatalogEntry
@@ -103,6 +105,11 @@ KeybindingActionType (type): string
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => 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<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => 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<HTMLInputElement>; 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
+17
View File
@@ -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 <dir>` (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}`);
+38
View File
@@ -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);
});
});
+8
View File
@@ -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",
+44
View File
@@ -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,
+7 -1
View File
@@ -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" });
}
});
},
);
+18 -1
View File
@@ -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<Server>((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);
+20
View File
@@ -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<string, boolean>;
}
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<string, boolean> {
if (!input || typeof input !== "object" || Array.isArray(input)) return {};
const out: Record<string, boolean> = {};
for (const [id, value] of Object.entries(input as Record<string, unknown>)) {
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 {
+3
View File
@@ -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: {
+7 -1
View File
@@ -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}
/>
+7 -1
View File
@@ -1205,7 +1205,11 @@ export function Auth({ onLogin }: AuthProps) {
{t("messages.databaseConnectionFailed")}
</p>
</div>
<Button onClick={() => window.location.reload()}>
<Button
variant="outline"
onClick={() => window.location.reload()}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
>
{t("common.refresh")}
</Button>
<LanguageRow
@@ -1351,11 +1355,13 @@ export function Auth({ onLogin }: AuthProps) {
)}
</p>
<Button
variant="outline"
type="button"
disabled={authPluginsLoading}
onClick={() =>
void retryAuthPlugins().catch(() => {})
}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
>
{t("auth.retrySecondFactorUI")}
</Button>
+1 -1
View File
@@ -34,7 +34,7 @@ export function Field({
children: ReactNode;
}) {
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<div data-setting-row="" className={cn("flex flex-col gap-1.5", className)}>
{(label || aside) && (
<div className="flex items-center gap-2">
<label
+467
View File
@@ -0,0 +1,467 @@
import { useState } from "react";
import type React from "react";
import { ChevronRight, Plus, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Button } from "@/components/button";
export type ListRowTone =
"brand" | "muted" | "success" | "warning" | "destructive" | "none";
const STRIPE: Record<ListRowTone, string> = {
brand: "bg-accent-brand/60",
muted: "bg-muted-foreground/30",
success: "bg-green-500/70",
warning: "bg-warning/70",
destructive: "bg-destructive/70",
none: "bg-transparent",
};
/** Scrolling list body. Rows sit flush, divided by their own borders. */
export function PanelList({
empty,
className,
children,
}: {
/** Shown instead of the rows when there are none. */
empty?: React.ReactNode;
className?: string;
children?: React.ReactNode;
}) {
const hasRows = Array.isArray(children)
? children.flat().some(Boolean)
: Boolean(children);
return (
<div
className={cn(
"flex min-h-0 flex-1 flex-col overflow-y-auto thin-scrollbar",
className,
)}
>
{hasRows ? children : empty}
</div>
);
}
/**
* One row in a sidebar list, drawn like the host list: a coloured stripe on
* the left, a name with an optional line under it, and a row of action
* buttons that opens under it on hover.
*/
export function ListRow({
title,
icon,
badges,
meta,
stripe = 0,
tone = "brand",
color,
selected,
active,
dimmed,
leading,
trailing,
actions,
onClick,
className,
children,
...rest
}: {
title: React.ReactNode;
icon?: React.ReactNode;
/** Small chips after the title. */
badges?: React.ReactNode;
/** Muted line under the title, like a description or Facts. */
meta?: React.ReactNode;
/** Position in the list, odd rows get the zebra tint. */
stripe?: number;
tone?: ListRowTone;
/** A custom stripe colour, like a group's own colour. */
color?: string;
selected?: boolean;
/** Keeps the tray open, like while its menu is showing. */
active?: boolean;
dimmed?: boolean;
/** Sits between the stripe and the text, like a drag handle. */
leading?: React.ReactNode;
/** Always visible on the right, like a switch. */
trailing?: React.ReactNode;
/** Buttons under the row that open on hover, like the host list. */
actions?: React.ReactNode;
onClick?: () => void;
className?: string;
/** Extra lines under the meta. */
children?: React.ReactNode;
} & Omit<
React.HTMLAttributes<HTMLDivElement>,
"title" | "onClick" | "children"
>) {
return (
<div
{...rest}
role={onClick ? "button" : rest.role}
tabIndex={onClick ? 0 : rest.tabIndex}
onClick={onClick}
onKeyDown={(e) => {
rest.onKeyDown?.(e);
if (!onClick || e.defaultPrevented || e.target !== e.currentTarget)
return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
data-selected={selected || undefined}
className={cn(
"group/row relative flex items-stretch select-none border-b border-border/40 transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
onClick && "cursor-pointer",
selected
? "bg-accent-brand/10 hover:bg-accent-brand/15"
: active
? "bg-muted/50"
: stripe % 2 === 1 && "bg-muted/15",
dimmed && "opacity-60",
className,
)}
>
<div
className={cn(
"w-[3px] shrink-0",
selected ? "bg-accent-brand" : !color && STRIPE[tone],
)}
style={color && !selected ? { backgroundColor: color } : undefined}
/>
{leading}
<div className="flex min-w-0 flex-1 flex-col gap-[3.5px] py-[7px] pl-[8.75px] pr-[7px]">
<div className="flex min-w-0 items-center gap-1.5">
{icon && (
<span className="flex shrink-0 text-muted-foreground/60 [&_svg]:size-3">
{icon}
</span>
)}
<span className="truncate text-[13px] font-semibold leading-none tracking-tight text-foreground">
{title}
</span>
{badges}
</div>
{meta && (
<div className="min-w-0 truncate text-[11px] leading-tight text-muted-foreground/80">
{meta}
</div>
)}
{children}
{actions && (
<div
onClick={(e) => e.stopPropagation()}
className={cn(
"flex-wrap items-center gap-[1.75px] border-t border-border/30 pt-[3.5px]",
active
? "flex"
: "hidden group-hover/row:flex group-focus-within/row:flex pointer-coarse:flex",
)}
>
{actions}
</div>
)}
</div>
{trailing && (
<div className="flex shrink-0 items-center pr-2">{trailing}</div>
)}
</div>
);
}
/** Square icon button for a ListRow tray. */
export function ListRowAction({
label,
tone = "default",
className,
children,
...rest
}: {
label: string;
tone?: "default" | "brand" | "destructive";
className?: string;
children: React.ReactNode;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
ref?: React.Ref<HTMLButtonElement>;
}) {
return (
<button
type="button"
title={label}
aria-label={label}
{...rest}
className={cn(
"flex size-[22.75px] items-center justify-center transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-3.5",
tone === "brand"
? "text-accent-brand/80 hover:text-accent-brand"
: tone === "destructive"
? "text-muted-foreground/60 hover:bg-destructive/10 hover:text-destructive"
: "text-muted-foreground/60 hover:text-foreground",
className,
)}
>
{children}
</button>
);
}
/** Small uppercase chip for a ListRow's badges. */
export function ListBadge({
tone = "muted",
className,
children,
}: {
tone?: "muted" | "brand" | "warning" | "destructive" | "success";
className?: string;
children: React.ReactNode;
}) {
return (
<span
className={cn(
"flex shrink-0 items-center gap-0.5 border px-1 py-px text-[9px] uppercase leading-none tracking-wider [&_svg]:size-2.5",
tone === "brand" &&
"border-accent-brand/30 bg-accent-brand/10 text-accent-brand",
tone === "warning" && "border-warning/30 bg-warning/10 text-warning",
tone === "destructive" &&
"border-destructive/30 bg-destructive/10 text-destructive",
tone === "success" &&
"border-green-500/30 bg-green-500/10 text-green-500",
tone === "muted" && "border-border bg-muted/40 text-muted-foreground",
className,
)}
>
{children}
</span>
);
}
/** A collapsible group in a ListRow list, drawn like a host folder. */
export function ListRowFolder({
name,
icon,
count,
open,
onToggle,
stripe = 0,
actions,
dropActive,
onDropItem,
emptyText,
className,
children,
}: {
name: React.ReactNode;
icon?: React.ReactNode;
count?: number;
open: boolean;
onToggle: () => void;
stripe?: number;
/** Shown on hover, usually a DropdownMenu trigger. */
actions?: React.ReactNode;
/** True while something is being dragged that could drop in here. */
dropActive?: boolean;
onDropItem?: () => void;
emptyText?: React.ReactNode;
className?: string;
children?: React.ReactNode;
}) {
const [dragOver, setDragOver] = useState(false);
const empty = count === 0;
return (
<div className={cn("border-b border-border/40", className)}>
<div
role="button"
tabIndex={0}
aria-expanded={open}
onClick={onToggle}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggle();
}
}}
onDragOver={(e) => {
if (!dropActive) return;
e.preventDefault();
setDragOver(true);
}}
onDragLeave={(e) => {
if (e.currentTarget === e.target) setDragOver(false);
}}
onDrop={(e) => {
if (!dropActive) return;
e.preventDefault();
setDragOver(false);
onDropItem?.();
}}
className={cn(
"group/folder flex w-full cursor-pointer select-none items-center gap-2 py-1.5 pl-2.5 pr-2 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
open ? "bg-muted/40" : "hover:bg-muted/30",
stripe % 2 === 1 && !open && "bg-muted/[0.08]",
dragOver &&
dropActive &&
"bg-accent-brand/10 ring-1 ring-inset ring-accent-brand",
)}
>
<ChevronRight
className={cn(
"size-3.5 shrink-0 text-muted-foreground/60 transition-transform",
open && "rotate-90",
)}
/>
{icon && (
<span
className={cn(
"flex shrink-0 [&_svg]:size-4",
open ? "text-accent-brand" : "text-muted-foreground/70",
)}
>
{icon}
</span>
)}
<span className="min-w-0 flex-1 truncate text-[13px] font-bold tracking-tight text-foreground">
{name}
</span>
{count !== undefined && (
<span className="shrink-0 bg-muted/70 px-1.5 py-[1px] text-[10px] tabular-nums text-muted-foreground/70">
{count}
</span>
)}
{actions && (
<div
onClick={(e) => e.stopPropagation()}
className="flex shrink-0 items-center opacity-0 transition-opacity focus-within:opacity-100 group-hover/folder:opacity-100 pointer-coarse:opacity-100 has-[[data-state=open]]:opacity-100"
>
{actions}
</div>
)}
</div>
{open && (
<div className="ml-[27px] border-l border-t border-border/50 border-t-border/40 [&>*:last-child]:border-b-0">
{empty && emptyText ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground/60">
{emptyText}
</div>
) : (
children
)}
</div>
)}
</div>
);
}
/** The one "add" button, placed beside a panel's search box. */
export function AddButton({
label,
compact,
className,
...rest
}: {
label?: string;
/** Icon only, for tight toolbars. */
compact?: boolean;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
ref?: React.Ref<HTMLButtonElement>;
}) {
const { t } = useTranslation();
const text = label ?? t("common.new");
return (
<Button
variant="outline"
size={compact ? "icon" : "default"}
title={text}
aria-label={text}
{...rest}
className={cn(
"shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10",
className,
)}
>
<Plus className="size-3.5" />
{!compact && <span>{text}</span>}
</Button>
);
}
/** The one Save bar, for a PanelShell or InlineView footer. */
export function FormFooter({
onCancel,
onSave,
saveLabel,
cancelLabel,
saving,
dirty,
status,
disabled,
onDelete,
deleteLabel,
extra,
saveType = "button",
form,
}: {
onCancel?: () => void;
onSave?: () => void;
saveLabel?: string;
cancelLabel?: string;
saving?: boolean;
/** Shows the unsaved changes note. */
dirty?: boolean;
/** Replaces the unsaved note, like an error count. */
status?: React.ReactNode;
disabled?: boolean;
onDelete?: () => void;
deleteLabel?: string;
/** More buttons, placed before Save. */
extra?: React.ReactNode;
saveType?: "button" | "submit";
/** Id of the form a submit Save belongs to. */
form?: string;
}) {
const { t } = useTranslation();
return (
<div className="flex w-full min-w-0 items-center gap-2">
{onDelete && (
<Button
variant="ghost"
size="sm"
onClick={onDelete}
disabled={saving}
className="text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/10"
>
<Trash2 />
{deleteLabel ?? t("common.delete")}
</Button>
)}
<div className="min-w-0 flex-1 truncate text-[11px]">
{status ??
(dirty && (
<span className="text-warning">{t("common.unsavedChanges")}</span>
))}
</div>
{onCancel && (
<Button variant="ghost" size="sm" onClick={onCancel} disabled={saving}>
{cancelLabel ?? t("common.cancel")}
</Button>
)}
{extra}
{(onSave || saveType === "submit") && (
<Button
type={saveType}
form={form}
variant="outline"
size="sm"
onClick={onSave}
disabled={disabled || saving}
className="border-accent-brand/40 px-6 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
>
{saving ? t("common.saving") : (saveLabel ?? t("common.save"))}
</Button>
)}
</div>
);
}
+26 -4
View File
@@ -166,6 +166,10 @@ export function PanelSearch({
placeholder,
fill,
autoFocus,
disabled,
title,
onEnter,
inputRef,
className,
}: {
value: string;
@@ -174,10 +178,15 @@ export function PanelSearch({
/** Takes the whole toolbar, for panels where search is the only control. */
fill?: boolean;
autoFocus?: boolean;
disabled?: boolean;
title?: string;
/** Runs on Enter, for searches that query a server. */
onEnter?: () => void;
inputRef?: React.Ref<HTMLInputElement>;
className?: string;
}) {
const { t } = useTranslation();
const ref = useRef<HTMLInputElement>(null);
const localRef = useRef<HTMLInputElement>(null);
const text = placeholder ?? t("common.search");
return (
<div
@@ -189,10 +198,19 @@ export function PanelSearch({
>
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input
ref={ref}
ref={(el) => {
localRef.current = el;
if (typeof inputRef === "function") inputRef(el);
else if (inputRef)
(
inputRef as React.MutableRefObject<HTMLInputElement | null>
).current = el;
}}
type="text"
value={value}
autoFocus={autoFocus}
disabled={disabled}
title={title}
aria-label={text}
placeholder={text}
onChange={(e) => onChange(e.target.value)}
@@ -201,8 +219,12 @@ export function PanelSearch({
e.stopPropagation();
onChange("");
}
if (e.key === "Enter" && onEnter) {
e.preventDefault();
onEnter();
}
}}
className="h-8 w-full min-w-0 border border-input bg-transparent pl-8 pr-7 text-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 dark:bg-input/30"
className="h-8 w-full min-w-0 border border-input bg-transparent pl-8 pr-7 text-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30"
/>
{value && (
<button
@@ -211,7 +233,7 @@ export function PanelSearch({
title={t("panel.clearSearch")}
onClick={() => {
onChange("");
ref.current?.focus();
localRef.current?.focus();
}}
className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center text-muted-foreground hover:text-foreground"
>
+1 -1
View File
@@ -60,7 +60,7 @@ export function SettingRow({
{/* In a gap-4 form stack the gap already spaces the row, so its own
padding and divider would double up. */}
<div
data-setting-row={rowId}
data-setting-row={rowId ?? ""}
className="flex items-center justify-between gap-3 py-3 border-b border-border last:border-0 [.gap-4>&]:py-0 [.gap-4>&]:border-0"
>
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
+1 -1
View File
@@ -15,7 +15,7 @@ function Separator({
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch",
className,
)}
{...props}
+5 -4
View File
@@ -41,13 +41,14 @@ export function TabStrip({
left: el.scrollLeft > 1,
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
});
const observer = new ResizeObserver(update);
observer.observe(el);
if (el.firstElementChild) observer.observe(el.firstElementChild);
const observer =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update);
observer?.observe(el);
if (el.firstElementChild) observer?.observe(el.firstElementChild);
el.addEventListener("scroll", update);
update();
return () => {
observer.disconnect();
observer?.disconnect();
el.removeEventListener("scroll", update);
};
}, [tabs]);
+7 -4
View File
@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
interface VersionBadgeProps {
status: "up_to_date" | "requires_update" | "beta" | "unknown";
@@ -14,13 +15,15 @@ export function VersionBadge({
const { t } = useTranslation();
if (status === "unknown") return null;
const badgeClassName = `text-[10px] px-1.5 py-0.5 font-semibold leading-none ${
const badgeClassName = cn(
"text-[10px] px-1.5 py-0.5 font-semibold leading-none",
status === "beta"
? "bg-blue-500/20 text-blue-400"
: status === "requires_update"
? "bg-warning/20 text-warning"
: "bg-accent-brand/20 text-accent-brand"
}${className ? ` ${className}` : ""}`;
: "bg-accent-brand/20 text-accent-brand",
className,
);
const label =
status === "beta"
@@ -42,7 +45,7 @@ export function VersionBadge({
rel="noopener noreferrer"
title={linkLabel}
aria-label={linkLabel}
className={`${badgeClassName} cursor-pointer hover:underline`}
className={cn(badgeClassName, "cursor-pointer hover:underline")}
>
{label}
</a>
+37 -33
View File
@@ -828,6 +828,43 @@ export function DashboardTab({
scroll={false}
actions={
<>
{isDashboardView && !viewPending && (
<>
{editMode ? (
<>
<span className="hidden text-[11px] text-muted-foreground lg:inline">
{t("dashboardTab.dragToReorder")}
</span>
<Button
variant="ghost"
size="xs"
className="gap-1"
onClick={() => void handleReset()}
>
<RotateCcw className="size-3" />
{t("dashboard.reset")}
</Button>
</>
) : (
<Facts className="hidden px-1.5 text-[11px] text-muted-foreground md:flex">
<span>
{t("dashboardTab.onlineCount", { count: online })}
</span>
<span>
{t("dashboardTab.hostCount", { count: hosts.length })}
</span>
<span className="flex items-center gap-1">
{t("dashboardTab.commandPalette")}
<Kbd className="h-4">⇧⇧</Kbd>
</span>
</Facts>
)}
<span
aria-hidden
className="hidden h-4 w-px bg-border xl:block"
/>
</>
)}
<span className="hidden items-center gap-0.5 xl:flex">
{links.map((link) => (
<a
@@ -867,39 +904,6 @@ export function DashboardTab({
)}
</>
}
toolbar={
isDashboardView && !viewPending ? (
<>
{editMode ? (
<>
<span className="hidden text-[11px] text-muted-foreground sm:inline">
{t("dashboardTab.dragToReorder")}
</span>
<Button
variant="ghost"
size="xs"
className="gap-1"
onClick={() => void handleReset()}
>
<RotateCcw className="size-3" />
{t("dashboard.reset")}
</Button>
</>
) : (
<span className="hidden items-center gap-1 text-[11px] text-muted-foreground sm:flex">
{t("dashboardTab.commandPalette")}
<Kbd className="h-4">⇧⇧</Kbd>
</span>
)}
<Facts className="ml-auto text-[11px] text-muted-foreground">
<span>{t("dashboardTab.onlineCount", { count: online })}</span>
<span>
{t("dashboardTab.hostCount", { count: hosts.length })}
</span>
</Facts>
</>
) : undefined
}
>
{viewPending ? (
<div className="flex flex-1 flex-col gap-2 p-2.5">
+21 -11
View File
@@ -30,17 +30,23 @@ export type VersionStatus =
function Stat({
label,
aside,
children,
}: {
label: string;
/** Sits beside the label, like the release channel. */
aside?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className="flex min-w-0 flex-col justify-center gap-1 px-3 py-2">
{children}
<span className="truncate text-[10px] text-muted-foreground">
{label}
</span>
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-[10px] text-muted-foreground">
{label}
</span>
{aside}
</div>
</div>
);
}
@@ -65,15 +71,19 @@ export function StatsStrip({
const big = "text-2xl font-bold leading-none tracking-tight";
return (
<div className="grid h-full grid-cols-2 divide-x divide-border md:grid-cols-4">
<Stat label={t("dashboard.version")}>
<div className="flex flex-wrap items-baseline gap-1.5">
<span className={big}>{versionText || "-"}</span>
<Stat
label={t("dashboard.version")}
aside={
<VersionBadge
status={versionStatus}
releaseUrl={releaseUrl}
className="w-fit"
className="shrink-0 px-1 py-px text-[9px]"
/>
</div>
}
>
<span className={`${big} truncate`} title={versionText || undefined}>
{versionText || "-"}
</span>
</Stat>
<Stat label={t("dashboard.uptime")}>
<span className={big}>{uptimeFormatted || "-"}</span>
@@ -161,7 +171,7 @@ function ActionButton({
<button
type="button"
onClick={onClick}
className="group/btn flex min-w-0 items-center gap-2 px-2 py-1.5 text-left transition-colors hover:bg-muted/50"
className="group/btn flex h-full min-w-0 items-center gap-2 border border-border/60 px-2 py-1.5 text-left transition-colors hover:border-accent-brand/40 hover:bg-muted/50"
>
<span className="flex size-6 shrink-0 items-center justify-center border border-border bg-muted transition-colors group-hover/btn:border-accent-brand/40 group-hover/btn:bg-accent-brand/20">
<Icon className="size-3 text-accent-brand" />
@@ -199,8 +209,8 @@ export function QuickActions({
return `${host.ip}:${port ?? host.port}`;
};
return (
<div className="flex flex-col">
<div className="grid grid-cols-1 gap-1 p-2 sm:grid-cols-2">
<div className="flex min-h-full flex-1 flex-col">
<div className="grid flex-1 auto-rows-fr grid-cols-1 gap-1 p-2 sm:grid-cols-2">
<ActionButton
icon={Plus}
label={t("dashboard.addHost")}
+10
View File
@@ -815,3 +815,13 @@ html.fs-xl {
outline-offset: 2px;
transition: outline-color 1.2s ease;
}
/* Cards and rows the settings page search filtered out. */
[data-search-hidden] {
display: none !important;
}
::highlight(settings-search) {
background-color: color-mix(in oklab, var(--accent-brand) 35%, transparent);
color: inherit;
}
+14 -6
View File
@@ -195,7 +195,8 @@
"sidebar": "Sidebar",
"mobileSidebarDescription": "Displays the mobile sidebar.",
"toggleSidebar": "Toggle Sidebar",
"search": "Search..."
"search": "Search...",
"new": "New"
},
"onboarding": {
"stepCounter": "Step {{current}} of {{total}}",
@@ -327,6 +328,9 @@
"search": "Search",
"hiddenCount": "{{count}} hidden",
"showInRail": "Show in sidebar",
"moveUp": "Move up",
"moveDown": "Move down",
"resetRailOrder": "Reset order",
"hideFromRail": "Hide from sidebar",
"openSettings": "Open settings",
"settings": "Settings",
@@ -632,6 +636,8 @@
"customizeSidebarDescription": "Choose how the host list looks and behaves.",
"customizeDensityTitle": "Density",
"customizeBehaviorTitle": "Behavior",
"connectBarTitle": "Connect bar",
"connectBarDesc": "Choose which actions show as buttons on a host. Every action stays in the host's menu.",
"customizeSortHint": "Sort order and grouping live in the sort and group menus in the toolbar.",
"densityComfortableDesc": "More detail per host: address, tags, and resource usage.",
"densityCompactDesc": "One line per host, for longer lists.",
@@ -1564,6 +1570,8 @@
"newUi": {
"sidebar": {
"quickConnect": {
"where": "Where",
"who": "Who",
"hostLabel": "Host",
"hostPlaceholder": "192.168.1.1 or example.com",
"portLabel": "Port",
@@ -1631,10 +1639,6 @@
"resetAllConfirm": "Reset every keybinding to its default?"
},
"userProfile": {
"donateTitle": "Keep Termix alive",
"donateDescription": "Termix is free, self-hosted, and built by only a few people in their spare time. If it's saved you time or money, a crypto donation helps keep it going.",
"donateMilestones": "Donations help fund the time to research and learn what's needed to build SAML, Kubernetes, and Agent support. See the progress and donate.",
"donateButton": "Donate crypto",
"storageModeLocal": "Browser",
"storageModeCloud": "Database",
"storageModeDescription": "Browser stores settings in this browser only. Database syncs to the server and loads on any device.",
@@ -2155,7 +2159,9 @@
"noResults": "No settings match.",
"pages": "Pages",
"instanceSettings": "Server settings",
"nothingHere": "Nothing to set here."
"nothingHere": "Nothing to set here.",
"searchPage": "Search this page",
"noPageMatches": "Nothing on this page matches."
},
"managerCard": {
"refresh": "Refresh",
@@ -2345,6 +2351,8 @@
"revoke": "Remove access"
},
"manage": {
"searchSettings": "Search settings",
"noMatchingSettings": "No settings match.",
"sections": "Sections",
"sectionNeedsAttention": "Something here needs attention",
"needsAttention_one": "One thing needs attention.",
+102 -16
View File
@@ -1,6 +1,9 @@
/* eslint-disable react-refresh/only-export-components */
import type React from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
@@ -9,10 +12,16 @@ import {
type RefObject,
} from "react";
import { useTranslation } from "react-i18next";
import { GroupHeading, PANEL } from "@/components/panel-layout";
import { Search } from "lucide-react";
import { GroupHeading, PANEL, PanelSearch } from "@/components/panel-layout";
import { TabStrip } from "@/components/tab-strip";
import { EmptyState } from "@/components/empty-state";
import { HIDDEN_ATTR, useSettingsPageFilter } from "@/settings/page-filter";
import { cn } from "@/lib/utils";
/** True while the editor is searched, so every lazy section mounts. */
const SearchingContext = createContext(false);
export interface EditorNavItem {
id: string;
label: string;
@@ -27,6 +36,25 @@ export interface EditorNavBand {
items: EditorNavItem[];
}
/**
* The element that really scrolls: the editor's own area, or the page around
* it when the editor is embedded somewhere that scrolls itself.
*/
function scroller(el: HTMLElement): HTMLElement {
let node: HTMLElement | null = el;
while (node) {
const overflow = getComputedStyle(node).overflowY;
if (
(overflow === "auto" || overflow === "scroll") &&
node.scrollHeight > node.clientHeight + 1
) {
return node;
}
node = node.parentElement;
}
return el;
}
/** Which section is in view, and a way to jump to one. */
export function useScrollSpy(
containerRef: RefObject<HTMLElement | null>,
@@ -42,7 +70,8 @@ export function useScrollSpy(
if (!container) return;
const onScroll = () => {
if (Date.now() < lockUntil.current) return;
const top = container.getBoundingClientRect().top;
const area = scroller(container);
const top = area.getBoundingClientRect().top;
let current = ids[0] ?? "";
for (const id of ids) {
const el = container.querySelector<HTMLElement>(
@@ -52,17 +81,21 @@ export function useScrollSpy(
if (el.getBoundingClientRect().top - top <= 24) current = id;
}
if (
container.scrollHeight > container.clientHeight &&
container.scrollTop + container.clientHeight >=
container.scrollHeight - 8
area.scrollHeight > area.clientHeight &&
area.scrollTop + area.clientHeight >= area.scrollHeight - 8
) {
current = ids[ids.length - 1] ?? current;
}
setActive(current);
};
onScroll();
container.addEventListener("scroll", onScroll, { passive: true });
return () => container.removeEventListener("scroll", onScroll);
// Captured on the document so an outer scroller is heard too.
document.addEventListener("scroll", onScroll, {
passive: true,
capture: true,
});
return () =>
document.removeEventListener("scroll", onScroll, { capture: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [containerRef, idsKey]);
@@ -76,11 +109,12 @@ export function useScrollSpy(
if (!container || !el) return;
lockUntil.current = Date.now() + 600;
setActive(id);
container.scrollTo?.({
const area = scroller(container);
area.scrollTo?.({
top:
el.getBoundingClientRect().top -
container.getBoundingClientRect().top +
container.scrollTop -
area.getBoundingClientRect().top +
area.scrollTop -
8,
behavior: "smooth",
});
@@ -114,9 +148,13 @@ export function EditorSection({
*/
export function LazySection({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const searching = useContext(SearchingContext);
const [shown, setShown] = useState(
() => typeof IntersectionObserver === "undefined",
);
useEffect(() => {
if (searching) setShown(true);
}, [searching]);
useLayoutEffect(() => {
if (shown) return;
const el = ref.current;
@@ -167,9 +205,39 @@ export function EditorPane({
}) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
const ids = bands.flatMap((band) => band.items.map((item) => item.id));
const formRef = useRef<HTMLDivElement>(null);
const [query, setQuery] = useState("");
const [hiddenIds, setHiddenIds] = useState<Set<string>>(new Set());
const searching = query.trim().length > 0;
const onFiltered = useCallback((root: HTMLElement) => {
const next = new Set<string>();
for (const el of root.querySelectorAll<HTMLElement>(
`[data-section][${HIDDEN_ATTR}]`,
)) {
if (el.dataset.section) next.add(el.dataset.section);
}
setHiddenIds((prev) =>
prev.size === next.size && [...next].every((id) => prev.has(id))
? prev
: next,
);
}, []);
const anyShown = useSettingsPageFilter(
formRef,
query,
"editor",
"[data-section]",
onFiltered,
);
const visibleBands = bands
.map((band) => ({
...band,
items: band.items.filter((item) => !hiddenIds.has(item.id)),
}))
.filter((band) => band.items.length > 0);
const ids = visibleBands.flatMap((band) => band.items.map((item) => item.id));
const { active, scrollTo } = useScrollSpy(scrollRef, ids);
const showNav = ids.length > 1;
const showNav = bands.flatMap((band) => band.items).length > 1;
if (revealRef) revealRef.current = scrollTo;
return (
@@ -179,7 +247,7 @@ export function EditorPane({
aria-label={t("manage.sections")}
className="hidden w-52 shrink-0 flex-col gap-3 overflow-y-auto border-r border-border py-2.5 thin-scrollbar lg:flex"
>
{bands.map((band) => (
{visibleBands.map((band) => (
<div key={band.id} className="flex flex-col">
<GroupHeading title={band.label} className="px-2.5 pb-1.5" />
{band.items.map((item) => {
@@ -220,10 +288,20 @@ export function EditorPane({
)}
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<div className={`shrink-0 border-b border-border ${PANEL.band}`}>
<div className="mx-auto w-full max-w-3xl">
<PanelSearch
value={query}
onChange={setQuery}
placeholder={t("manage.searchSettings")}
fill
/>
</div>
</div>
{showNav && (
<div className="shrink-0 border-b border-border px-1 lg:hidden">
<TabStrip
tabs={bands.flatMap((band) =>
tabs={visibleBands.flatMap((band) =>
band.items.map((item) => ({ id: item.id, label: item.label })),
)}
activeTab={active}
@@ -235,9 +313,17 @@ export function EditorPane({
ref={scrollRef}
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
>
<div className={`mx-auto flex max-w-3xl flex-col ${PANEL.gap} pb-6`}>
{!anyShown && (
<EmptyState icon={Search} title={t("manage.noMatchingSettings")} />
)}
<div
ref={formRef}
className={`mx-auto flex max-w-3xl flex-col ${PANEL.gap} pb-6`}
>
{banner}
{children}
<SearchingContext.Provider value={searching}>
{children}
</SearchingContext.Provider>
</div>
</div>
{(footer || errorCount > 0) && (
+57 -12
View File
@@ -14,7 +14,7 @@ import {
} from "lucide-react";
import { Button } from "@/components/button";
import { EmptyState } from "@/components/empty-state";
import { PanelSearch, Segmented } from "@/components/panel-layout";
import { PanelSearch } from "@/components/panel-layout";
import { cn } from "@/lib/utils";
import { rem } from "@/lib/rem";
import type { Credential, Host } from "@/types/ui-types";
@@ -143,24 +143,69 @@ export function ManageList({
<div
className={cn(
editing ? "hidden md:flex" : "flex",
"w-full shrink-0 flex-col border-r border-border bg-sidebar md:w-72",
"w-full shrink-0 flex-col border-r border-border bg-background md:w-72",
)}
>
<div className="flex flex-col gap-2 border-b border-border px-2.5 py-2">
<Segmented
value={mode}
onChange={onMode}
options={[
{ value: "hosts", label: t("nav.hosts"), count: hosts.length },
<nav
aria-label={t("manage.sections")}
className="flex shrink-0 flex-col border-b border-border py-1"
>
{(
[
{
value: "hosts",
label: t("nav.hosts"),
icon: Server,
count: hosts.length,
},
{
value: "credentials",
label: t("nav.credentials"),
icon: KeyRound,
count: credentials.length,
},
{ value: "defaults", label: t("manage.defaults") },
]}
className="w-full [&>button]:flex-1"
/>
{
value: "defaults",
label: t("manage.defaults"),
icon: SlidersHorizontal,
},
] as {
value: ManageMode;
label: string;
icon: typeof Server;
count?: number;
}[]
).map((item) => {
const active = item.value === mode;
const Icon = item.icon;
return (
<button
key={item.value}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => onMode(item.value)}
className={cn(
"flex w-full items-center gap-2 border-l-2 py-1.5 pl-2 pr-2.5 text-left transition-colors",
rowFocus,
active
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<Icon className="size-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-xs font-medium">
{item.label}
</span>
{item.count !== undefined && (
<span className="shrink-0 text-[10px] tabular-nums opacity-60">
{item.count}
</span>
)}
</button>
);
})}
</nav>
<div className="flex flex-col gap-2 border-b border-border px-2.5 py-2">
<div className="flex items-center gap-2">
<PanelSearch
value={query}
+10
View File
@@ -289,6 +289,16 @@ export {
type PanelDensity,
type PanelViewMode,
} from "@/components/panel-layout";
export {
AddButton,
FormFooter,
ListBadge,
ListRow,
ListRowAction,
ListRowFolder,
PanelList,
type ListRowTone,
} from "@/components/list-kit";
export {
DataView,
type DataColumn,
+6 -1
View File
@@ -64,7 +64,12 @@ export function PluginConsentPrompt({
<Button variant="ghost" onClick={onCancel} disabled={busy}>
{t("common.cancel")}
</Button>
<Button onClick={onConfirm} disabled={busy}>
<Button
variant="outline"
onClick={onConfirm}
disabled={busy}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
>
{mode === "update"
? t("plugins.manager.consent.agreeUpdate")
: t("plugins.manager.install")}
+7 -5
View File
@@ -98,8 +98,9 @@ export function PluginDetail({
plugin.updateAvailable &&
!plugin.pinnedVersion && (
<Button
variant="outline"
size="sm"
className="gap-1.5"
className="gap-1.5 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={busy || locked}
onClick={() => void manager.requestUpdate(plugin)}
>
@@ -148,8 +149,9 @@ export function PluginDetail({
</>
) : (
<Button
variant="outline"
size="sm"
className="gap-1.5"
className="gap-1.5 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={busy || locked || !plugin.latestVersion}
onClick={() => manager.requestInstall(plugin)}
>
@@ -446,8 +448,8 @@ function DataCard({
{t("plugins.manager.loading")}
</div>
) : (
<div className="flex flex-col">
<div className="grid grid-cols-2 gap-px border-b border-border bg-border sm:grid-cols-4">
<div className="-mx-3 -my-1 flex flex-col md:-mx-4">
<div className="grid grid-cols-2 gap-px bg-border sm:grid-cols-4">
<Stat
label={t("plugins.manager.data.tables")}
value={`${data.tables.length}`}
@@ -470,7 +472,7 @@ function DataCard({
/>
</div>
{data.tables.length > 0 && (
<div className="divide-y divide-border">
<div className="divide-y divide-border border-t border-border">
{data.tables.map((table) => (
<div
key={table.name}
+8 -2
View File
@@ -472,8 +472,9 @@ function InstalledCard({
<div className="flex items-center gap-1.5 border-t border-border px-3 py-2">
{plugin.updateAvailable && !plugin.pinnedVersion && !failed && (
<Button
variant="outline"
size="xs"
className="gap-1"
className="gap-1 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={busy || locked}
onClick={() => void manager.requestUpdate(plugin)}
>
@@ -651,9 +652,11 @@ function BrowseCard({
</span>
) : (
<Button
variant="outline"
size="xs"
disabled={busy || manager.managedByServer || !plugin.latestVersion}
onClick={() => manager.requestInstall(plugin)}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
>
{t("plugins.manager.install")}
</Button>
@@ -706,8 +709,9 @@ function UpdatesSection({
` ${t("plugins.manager.updatesNeedReview", { count: gated.length })}`}
</span>
<Button
variant="outline"
size="sm"
className="gap-1.5"
className="gap-1.5 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={manager.busy.size > 0 || manager.managedByServer}
onClick={() => void manager.updateAll()}
>
@@ -802,9 +806,11 @@ function UpdateRow({
</label>
{!plugin.pinnedVersion && (
<Button
variant="outline"
size="xs"
disabled={busy || manager.managedByServer}
onClick={() => void manager.requestUpdate(plugin)}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
>
{gated
? t("plugins.manager.review")
+2 -1
View File
@@ -233,8 +233,9 @@ export function FeatureSettingsForm({
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
className="h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={!dirty || saving}
onClick={() => void save()}
>
+3 -2
View File
@@ -1,3 +1,4 @@
import { Select2 } from "@/components/select2";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -453,7 +454,7 @@ export function KeybindingsSettings() {
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.actionLabel")}
</label>
<select
<Select2
value={draft.type}
onChange={(e) => setDraft({ type: e.target.value })}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
@@ -466,7 +467,7 @@ export function KeybindingsSettings() {
{t(action.labelKey)}
</option>
))}
</select>
</Select2>
</div>
{Editor && <Editor action={draft} onChange={setDraft} />}
+49 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Settings as SettingsIcon } from "lucide-react";
import { ChevronDown, Search, Settings as SettingsIcon } from "lucide-react";
import {
GroupHeading,
PANEL,
@@ -23,6 +23,7 @@ import {
} from "./FeatureSettingsSections";
import { KeybindingsSettings } from "./KeybindingsSettings";
import { SettingsEmbedContext } from "./settings-embed";
import { useSettingsPageFilter } from "./page-filter";
import {
CORE_SETTINGS_PAGES,
SETTINGS_BAND_ORDER,
@@ -35,6 +36,8 @@ import {
export interface SettingsTabProps {
/** The page to open, from the tab's data. */
section?: string;
/** An element id on that page to scroll to, as "id@nonce". */
reveal?: string;
username: string;
isAdmin: boolean;
onLogout?: () => void;
@@ -76,6 +79,7 @@ function revealText(container: HTMLElement, needle: string): boolean {
/** Everything that configures the app, one page at a time. */
export function SettingsTab({
section,
reveal,
username,
isAdmin,
onLogout,
@@ -88,7 +92,9 @@ export function SettingsTab({
const adminFeatures = useFeatureSettings("admin");
const [query, setQuery] = useState("");
const [navOpen, setNavOpen] = useState(false);
const [pageQuery, setPageQuery] = useState("");
const contentRef = useRef<HTMLDivElement>(null);
const pageRef = useRef<HTMLDivElement>(null);
const pages = useMemo(() => {
const plugins = new Map<string, SettingsPage>();
@@ -130,15 +136,42 @@ export function SettingsTab({
if (section) setCurrent(section);
}, [section]);
// The page renders lazily, so the target may take a moment to appear.
useEffect(() => {
const target = reveal?.split("@")[0];
if (!target) return;
let tries = 0;
let timer = 0;
const attempt = () => {
const el = contentRef.current?.querySelector<HTMLElement>(
`#${CSS.escape(target)}`,
);
if (el) {
el.scrollIntoView?.({ block: "start", behavior: "smooth" });
el.classList.add("settings-search-hit");
timer = window.setTimeout(
() => el.classList.remove("settings-search-hit"),
1600,
);
return;
}
if (++tries < 15) timer = window.setTimeout(attempt, 120);
};
timer = window.setTimeout(attempt, 60);
return () => window.clearTimeout(timer);
}, [reveal]);
const page =
pages.find((p) => p.id === current) ??
pages.find((p) => p.id === "account")!;
const pageHasMatches = useSettingsPageFilter(pageRef, pageQuery, page.id);
const hits = searchSettingsPages(pages, query, (key) => t(key));
const pageLabel = (p: SettingsPage) => p.label ?? t(p.labelKey);
const go = (id: string) => {
setCurrent(id);
setNavOpen(false);
setPageQuery("");
contentRef.current?.scrollTo?.({ top: 0 });
const needle = query.trim();
if (!needle) return;
@@ -261,7 +294,7 @@ export function SettingsTab({
<nav
aria-label={t("settings.pages")}
className={cn(
"w-full shrink-0 overflow-y-auto border-r border-border bg-sidebar thin-scrollbar md:static md:block md:w-60",
"w-full shrink-0 overflow-y-auto border-r border-border bg-background thin-scrollbar md:static md:block md:w-60",
navOpen ? "absolute inset-0 z-20 block" : "hidden",
)}
>
@@ -269,11 +302,25 @@ export function SettingsTab({
</nav>
<SurfaceScope kind="tab" className="min-w-0">
<div className={`shrink-0 border-b border-border ${PANEL.band}`}>
<div className="mx-auto w-full max-w-3xl">
<PanelSearch
value={pageQuery}
onChange={setPageQuery}
placeholder={t("settings.searchPage")}
fill
/>
</div>
</div>
<div
ref={contentRef}
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
>
{!pageHasMatches && (
<EmptyState icon={Search} title={t("settings.noPageMatches")} />
)}
<div
ref={pageRef}
className={`mx-auto flex w-full max-w-3xl flex-col ${PANEL.gap} pb-6`}
>
<SettingsEmbedContext.Provider value>
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useState, type RefObject } from "react";
export const HIDDEN_ATTR = "data-search-hidden";
const HIGHLIGHT = "settings-search";
const BLOCK = ".bg-card, [data-setting-row]";
function matches(el: Element, needle: string) {
return (el.textContent ?? "").toLowerCase().includes(needle);
}
/** Visible blocks with no other block above them inside root. */
function topBlocks(root: HTMLElement, blocks: Element[], selector: string) {
return blocks.filter((el) => {
let parent = el.parentElement;
while (parent && parent !== root) {
if (parent.matches(selector) || parent.classList.contains("hidden"))
return false;
parent = parent.parentElement;
}
return true;
});
}
/**
* Hides the cards and rows on a settings page that do not contain the query.
* A card whose own text matches keeps every row; otherwise only its matching
* rows stay. Returns how many cards are still showing.
*/
export function filterSettingsPage(
root: HTMLElement,
query: string,
blockSelector: string = BLOCK,
): number {
for (const el of root.querySelectorAll(`[${HIDDEN_ATTR}]`)) {
el.removeAttribute(HIDDEN_ATTR);
}
const needle = query.trim().toLowerCase();
const blocks = [...root.querySelectorAll(blockSelector)];
const cards = topBlocks(root, blocks, blockSelector);
if (!needle) return cards.length;
let shown = 0;
for (const card of cards) {
if (!matches(card, needle)) {
card.setAttribute(HIDDEN_ATTR, "");
continue;
}
shown++;
const rows = [...card.querySelectorAll("[data-setting-row]")].filter(
(row) => row !== card,
);
if (!rows.some((row) => matches(row, needle))) continue;
for (const row of rows) {
if (!matches(row, needle)) row.setAttribute(HIDDEN_ATTR, "");
}
for (const inner of card.querySelectorAll(".bg-card")) {
if (inner !== card && !matches(inner, needle)) {
inner.setAttribute(HIDDEN_ATTR, "");
}
}
}
return shown;
}
type HighlightRegistry = Map<string, unknown>;
function highlights(): HighlightRegistry | undefined {
const css = (globalThis as { CSS?: { highlights?: HighlightRegistry } }).CSS;
return css?.highlights;
}
/** Marks every match with the CSS highlight API where the browser has it. */
function highlightMatches(root: HTMLElement, query: string) {
const registry = highlights();
const Highlight = (
globalThis as { Highlight?: new (...r: Range[]) => unknown }
).Highlight;
if (!registry || !Highlight) return;
registry.delete(HIGHLIGHT);
const needle = query.trim().toLowerCase();
if (!needle) return;
const ranges: Range[] = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = node.textContent?.toLowerCase() ?? "";
let at = text.indexOf(needle);
while (at !== -1) {
const range = document.createRange();
range.setStart(node, at);
range.setEnd(node, at + needle.length);
ranges.push(range);
at = text.indexOf(needle, at + needle.length);
}
}
registry.set(HIGHLIGHT, new Highlight(...ranges));
}
/**
* Keeps a settings page filtered while its content loads and changes.
* Returns false when the query hides everything.
*/
export function useSettingsPageFilter(
rootRef: RefObject<HTMLElement | null>,
query: string,
pageKey: string,
blockSelector: string = BLOCK,
onFiltered?: (root: HTMLElement) => void,
): boolean {
const [anyShown, setAnyShown] = useState(true);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
let frame = 0;
const run = () => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
setAnyShown(
filterSettingsPage(root, query, blockSelector) > 0 || !query.trim(),
);
highlightMatches(root, query);
onFiltered?.(root);
});
};
run();
if (!query.trim()) return () => cancelAnimationFrame(frame);
const observer = new MutationObserver(run);
observer.observe(root, {
childList: true,
subtree: true,
characterData: true,
});
return () => {
observer.disconnect();
cancelAnimationFrame(frame);
highlights()?.delete(HIGHLIGHT);
};
}, [rootRef, query, pageKey, blockSelector, onFiltered]);
return anyShown;
}
+4 -2
View File
@@ -348,8 +348,9 @@ export function LinkServerDialog({
{t("common.cancel")}
</Button>
<Button
variant="outline"
type="submit"
className="rounded-none"
className="rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={checking || !target.serverUrl.trim()}
>
{checking && <Loader2 className="size-4 animate-spin" />}
@@ -420,8 +421,9 @@ export function LinkServerDialog({
{t("common.cancel")}
</Button>
<Button
variant="outline"
type="button"
className="rounded-none"
className="rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={!token}
onClick={() => token && void finish(token, mode)}
>
+6 -3
View File
@@ -198,7 +198,8 @@ export function SyncPanel() {
<li>{t("sync.unlinkedPointAccount")}</li>
</ul>
<Button
className="self-start rounded-none"
variant="outline"
className="self-start rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
onClick={() => setLinkOpen(true)}
>
<Cloud className="size-4" />
@@ -302,8 +303,9 @@ export function SyncPanel() {
<div className="flex flex-wrap gap-2">
{state === "signed_out" ? (
<Button
variant="outline"
size="sm"
className="h-7 text-[10px] rounded-none"
className="h-7 text-[10px] rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
onClick={() => setReloginOpen(true)}
>
{t("sync.signInAgain")}
@@ -535,8 +537,9 @@ export function SyncPanel() {
</div>
{proxyDraft && (
<Button
variant="outline"
size="sm"
className="self-start h-7 text-[10px] rounded-none"
className="self-start h-7 text-[10px] rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
disabled={busy === "proxy"}
onClick={() =>
run("proxy", async () => {
+2 -2
View File
@@ -244,7 +244,7 @@ export function CommandPalette({
return (
<div
className="fixed inset-0 z-[100] flex items-start justify-center bg-background/40 pt-[15vh] backdrop-blur-sm"
className="fixed inset-0 z-[100] flex items-start justify-center bg-background/70 pt-[15vh]"
onClick={() => setIsOpen(false)}
>
<div
@@ -270,7 +270,7 @@ export function CommandPalette({
<Kbd className="ml-2">ESC</Kbd>
</div>
<CommandList className="thin-scrollbar max-h-[60vh]">
<CommandList className="thin-scrollbar max-h-[60vh] [contain:content]">
<CommandEmpty className="py-8 text-center text-xs text-muted-foreground">
{t("commandPalette.noHostsFound", { search })}
</CommandEmpty>
+4 -4
View File
@@ -573,7 +573,7 @@ export function TabBar({
<Button
variant="ghost"
size="icon"
className="h-full w-12.5 border-y-0 border-r-0 border-border rounded-none text-muted-foreground hover:text-foreground"
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
>
<ChevronDown className="size-4" />
</Button>
@@ -632,7 +632,7 @@ export function TabBar({
<Button
variant="ghost"
size="icon"
className={`h-full w-12.5 rounded-none border-y-0 border-border ${rightDockOpen ? "text-accent-brand bg-accent-brand/10" : "text-muted-foreground hover:text-foreground"}`}
className={`h-full w-12.5 rounded-none ${rightDockOpen ? "text-accent-brand bg-accent-brand/10" : "text-muted-foreground hover:text-foreground"}`}
title={t("nav.toggleRightDock")}
aria-label={t("nav.toggleRightDock")}
aria-pressed={!!rightDockOpen}
@@ -648,7 +648,7 @@ export function TabBar({
<Button
variant="ghost"
size="icon"
className="h-full w-12.5 rounded-none border-y-0 border-border text-muted-foreground hover:text-foreground"
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
title={
isAppFullscreen
? t("nav.exitFullscreen")
@@ -673,7 +673,7 @@ export function TabBar({
<Button
variant="ghost"
size="icon"
className="h-full w-12.5 rounded-none border-y-0 border-border text-muted-foreground hover:text-foreground"
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
onClick={() => setOpen((o) => !o)}
>
<ChevronUp
+1 -1
View File
@@ -71,7 +71,7 @@ export function SplitLayoutMenu({
<Button
variant="ghost"
size="icon"
className={`h-full w-12.5 rounded-none border-y-0 border-border ${activeIsSplit ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
className={`h-full w-12.5 rounded-none ${activeIsSplit ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
title={t("splitScreen.title")}
aria-label={t("splitScreen.title")}
>
+17 -1
View File
@@ -1,4 +1,8 @@
/* eslint-disable react-refresh/only-export-components */
import {
requestFromLegacyEvent,
requestManage,
} from "@/manage/manage-requests";
import {
LayoutDashboard,
LayoutPanelLeft,
@@ -178,7 +182,14 @@ const DashboardTabHost = memo(function DashboardTabHost({
}) {
return withTabSuspense(
<DashboardTab
onOpenSingletonTab={(type) => shell.openSingletonTab(type)}
onOpenSingletonTab={(type, pendingEvent) => {
// "Add host" and friends say what to open in the Manage tab.
const request = pendingEvent
? requestFromLegacyEvent(pendingEvent)
: null;
if (request) requestManage(request);
else shell.openSingletonTab(type);
}}
onOpenTab={(host, type) => shell.openTab(host, type)}
isVisible={isVisible}
/>,
@@ -255,6 +266,11 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
? tab.data.section
: undefined
}
reveal={
typeof tab.data?.reveal === "string"
? `${tab.data.reveal}@${String(tab.data.revealAt ?? "")}`
: undefined
}
/>,
)
: null;
+4 -3
View File
@@ -14,6 +14,7 @@ import { pluginKey } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PanelSearch } from "@/components/panel-layout";
import {
Activity,
Check,
@@ -151,11 +152,11 @@ export function AdminUsersSection({
</div>
</div>
<div className="py-2 border-b border-border">
<Input
<PanelSearch
value={search}
onChange={(e) => onSearchChange(e.target.value)}
onChange={onSearchChange}
placeholder={t("admin.searchUsers")}
className="h-7 text-xs rounded-none"
fill
/>
</div>
{users.map((user) => {
+3 -2
View File
@@ -1,3 +1,4 @@
import { Select2 } from "@/components/select2";
import { useConfirm } from "@/components/surface/surface-scope";
import { useEffect, useState } from "react";
import { hostProtocolFlags, type HostProtocols } from "./host-protocols";
@@ -943,7 +944,7 @@ export function AdminUserManagePanel({
</span>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
{t("admin.deleteSuccessorLabel")}
<select
<Select2
value={successor}
onChange={(e) => setSuccessor(e.target.value)}
className="h-7 border border-border bg-background px-2 text-xs text-foreground outline-none"
@@ -957,7 +958,7 @@ export function AdminUserManagePanel({
<option value="none">
{t("admin.deleteSuccessorNone")}
</option>
</select>
</Select2>
<span>{t("admin.deleteSuccessorDesc")}</span>
</label>
<Button
+39 -1
View File
@@ -16,10 +16,12 @@ import { Skeleton } from "@/components/skeleton";
import { readRailPreference, setRailPreference } from "./rail-preferences";
import {
groupRailItems,
moveRailItem,
useRailItems,
type RailGroup,
type RailItemDef,
} from "./rail-items";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { toggleHiddenRailTab, useHiddenRailTabs } from "./hidden-rail-tabs";
import { RailBadge } from "./RailBadge";
import { rem } from "@/lib/rem";
@@ -200,6 +202,9 @@ export function AppRail({
// Plugins add and remove rail items at runtime, and permissions hide some.
const railItems = useRailItems();
const uiPrefs = useUiPreferencesContext();
const [dragId, setDragId] = useState<string | null>(null);
const [dropBefore, setDropBefore] = useState<string | null>(null);
const railExpanded = pinned || (expandOnHover && hovered) || managing;
const mainItems = railItems.filter((item) => item.placement !== "footer");
@@ -226,15 +231,48 @@ export function AppRail({
setMenuPos(null);
};
const sameBand = (a: string, b: string) =>
(mainItems.find((item) => item.id === a)?.group ?? "tools") ===
(mainItems.find((item) => item.id === b)?.group ?? "tools");
const renderItem = (item: RailItemDef) => {
const Icon = item.icon;
const title = t(item.labelKey);
const isTab = item.kind === "tab";
const active = !isTab && sidebarOpen && railView === item.id;
const reorderable = item.placement !== "footer" && !!uiPrefs;
const dropHere =
dragId !== null && dropBefore === item.id && dragId !== item.id;
return (
<button
key={item.id}
type="button"
draggable={reorderable}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "move";
setDragId(item.id);
}}
onDragEnd={() => {
setDragId(null);
setDropBefore(null);
}}
onDragOver={(e) => {
if (!dragId || dragId === item.id || !sameBand(dragId, item.id))
return;
e.preventDefault();
setDropBefore(item.id);
}}
onDrop={(e) => {
if (!dragId || !sameBand(dragId, item.id)) return;
e.preventDefault();
uiPrefs?.setOverride(
"rail",
"order",
moveRailItem(mainItems, dragId, item.id),
);
setDragId(null);
setDropBefore(null);
}}
onClick={(e) => {
if (isTab || (item.promotable && (e.ctrlKey || e.metaKey))) {
onOpenTab?.(item.id as TabType);
@@ -260,7 +298,7 @@ export function AppRail({
aria-current={active ? "page" : undefined}
title={item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title}
style={btnStyle}
className={`${btnBase} ${active ? activeClass : idle}`}
className={`${btnBase} ${active ? activeClass : idle} ${dragId === item.id ? "opacity-40" : ""} ${dropHere ? "relative before:absolute before:inset-x-1 before:-top-0.5 before:h-0.5 before:bg-accent-brand" : ""}`}
>
<RailIcon useBadge={item.useBadge}>
<Icon size={16} />
+110 -185
View File
@@ -3,7 +3,7 @@ import { getTabType, isPersistentTabType } from "@/shell/tab-registry";
import { ComponentSlot } from "@/shell/ActionSlot";
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Plug, Search, X, Pencil, Check } from "lucide-react";
import { ExternalLink, Plug, X, Pencil, Check } from "lucide-react";
import {
getActiveSessions,
deleteOpenTab,
@@ -13,14 +13,9 @@ import {
import { tabIcon } from "@/shell/tabUtils";
import { getSessionTimeoutMinutes } from "@/api/open-tabs-api";
import type { Tab, TabType } from "@/types/ui-types";
import { Badge } from "@/components/badge";
import { Input } from "@/components/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip";
import { EmptyState } from "@/components/empty-state";
import { Facts, GroupHeading, PanelSearch } from "@/components/panel-layout";
import { ListBadge, ListRow, ListRowAction } from "@/components/list-kit";
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
@@ -81,6 +76,7 @@ function ConnectionRow({
hostName,
subLabel,
icon,
stripe,
onSwitch,
onClose,
switchTitle,
@@ -95,6 +91,7 @@ function ConnectionRow({
hostName?: string;
subLabel: string;
icon: React.ReactNode;
stripe: number;
onSwitch?: () => void;
onClose: () => void;
switchTitle?: string;
@@ -106,12 +103,6 @@ function ConnectionRow({
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(name);
function startEdit(e: React.MouseEvent) {
e.stopPropagation();
setEditValue(name);
setEditing(true);
}
function commitEdit() {
const trimmed = editValue.trim();
if (trimmed && trimmed !== name && onRename) {
@@ -120,155 +111,103 @@ function ConnectionRow({
setEditing(false);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") commitEdit();
if (e.key === "Escape") setEditing(false);
}
const registered = getTabType(tabType)?.titleKey;
const typeLabel =
CORE_TYPE_LABELS[tabType] ?? (registered ? t(registered) : tabType);
const stop = (e: React.PointerEvent) => e.stopPropagation();
return (
<div
role={onSwitch && !editing ? "button" : undefined}
tabIndex={onSwitch && !editing ? 0 : undefined}
<ListRow
stripe={stripe}
tone={isLive ? "success" : "muted"}
selected={isActive}
active={editing}
dimmed={faded || isDragging}
onClick={!editing ? onSwitch : undefined}
onKeyDown={(e) => !editing && e.key === "Enter" && onSwitch?.()}
className={`group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 transition-colors last:border-b-0 ${
faded ? "opacity-60" : ""
} ${isDragging ? "opacity-30" : ""} ${
isActive
? "bg-accent-brand/8 cursor-pointer border-l-2 border-l-accent-brand"
: onSwitch && !editing
? "hover:bg-muted/40 cursor-pointer"
: ""
}`}
>
<div
className={`shrink-0 flex items-center justify-center size-7 rounded ${
isActive
? "bg-accent-brand/15 text-accent-brand"
: "bg-muted/60 text-muted-foreground"
}`}
>
{icon}
</div>
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
<div className="flex items-center gap-1.5 min-w-0">
<span
className={`shrink-0 size-1.5 rounded-full ${
isLive ? "bg-green-500" : "bg-muted-foreground/30"
}`}
icon={icon}
title={
editing ? (
<input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") commitEdit();
if (e.key === "Escape") setEditing(false);
}}
onClick={(e) => e.stopPropagation()}
onPointerDown={stop}
className="w-full min-w-0 border-b border-accent-brand bg-transparent text-[13px] font-semibold text-foreground outline-none"
autoFocus
/>
{editing ? (
<input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
className="text-xs font-semibold flex-1 min-w-0 bg-transparent border-b border-accent-brand outline-none text-foreground"
autoFocus
/>
) : (
<span
className={`text-xs font-semibold truncate flex-1 ${
isActive ? "text-accent-brand" : "text-foreground"
}`}
>
{name}
</span>
)}
<Badge
variant="outline"
className="text-[9px] px-1 py-0 h-4 font-mono shrink-0 text-muted-foreground/60 border-border/60"
) : (
name
)
}
badges={<ListBadge className="ml-auto font-mono">{typeLabel}</ListBadge>}
meta={
<Facts>
{hostName && hostName !== name ? <span>{hostName}</span> : null}
<span>{subLabel}</span>
</Facts>
}
actions={
editing ? (
<ListRowAction
label={t("common.save")}
tone="brand"
onPointerDown={stop}
onClick={commitEdit}
>
{CORE_TYPE_LABELS[tabType] ??
(getTabType(tabType)?.titleKey
? t(getTabType(tabType)!.titleKey!)
: tabType)}
</Badge>
</div>
<span className="text-[10px] text-muted-foreground/60 truncate pl-3">
{hostName && hostName !== name ? (
<span className="text-muted-foreground/50 mr-2.5">{hostName}</span>
) : null}
{subLabel}
</span>
</div>
<TooltipProvider>
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{editing ? (
<button
onClick={(e) => {
e.stopPropagation();
commitEdit();
}}
className="size-6 flex items-center justify-center text-accent-brand hover:bg-muted/60 rounded transition-colors"
<Check />
</ListRowAction>
) : (
<>
{onRename && (
<ListRowAction
label={t("connections.rename")}
onPointerDown={stop}
onClick={() => {
setEditValue(name);
setEditing(true);
}}
>
<Pencil />
</ListRowAction>
)}
{switchTitle && onSwitch && (
<ListRowAction
label={switchTitle}
tone="brand"
onPointerDown={stop}
onClick={onSwitch}
>
<ExternalLink />
</ListRowAction>
)}
<ListRowAction
label={t("common.close")}
tone="destructive"
onPointerDown={stop}
onClick={onClose}
>
<Check className="size-3" />
</button>
) : (
onRename && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={startEdit}
onPointerDown={(e) => e.stopPropagation()}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 rounded transition-colors"
>
<Pencil className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{t("connections.rename")}
</TooltipContent>
</Tooltip>
)
)}
{switchTitle && onSwitch && !editing && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
onSwitch();
}}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 rounded transition-colors"
>
<ExternalLink className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">{switchTitle}</TooltipContent>
</Tooltip>
)}
{!editing && (
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
>
<X className="size-3" />
</button>
)}
</div>
</TooltipProvider>
</div>
<X />
</ListRowAction>
</>
)
}
/>
);
}
function SectionHeader({ label, count }: { label: string; count: number }) {
return (
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/20">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60 flex-1">
{label}
</span>
<span className="text-[10px] font-semibold text-muted-foreground/40 bg-muted/60 rounded px-1.5 py-0.5">
{count}
</span>
</div>
<GroupHeading
title={label}
count={count}
className="border-b border-border/40 px-3 pb-1.5 pt-2.5"
/>
);
}
@@ -450,42 +389,28 @@ export function ConnectionsPanel({
return (
<div className="flex flex-col flex-1">
{pluginSections}
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<Plug className="size-5 text-muted-foreground/30" />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("connections.noConnections")}
</span>
<span className="text-xs text-muted-foreground/40">
{t("connections.noConnectionsDesc")}
</span>
</div>
</div>
<EmptyState
icon={Plug}
title={t("connections.noConnections")}
hint={t("connections.noConnectionsDesc")}
className="flex-1"
/>
</div>
);
}
return (
<div className="flex flex-col">
<div className="relative px-3 py-2 border-b border-border/60">
<Search className="absolute left-5.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50 pointer-events-none" />
<Input
placeholder={t("connections.search")}
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<PanelSearch
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 h-7 text-xs"
onChange={setSearch}
placeholder={t("connections.search")}
fill
/>
</div>
{!hasResults && (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
<span className="text-xs text-muted-foreground/50">
{t("connections.noSearchResults")}
</span>
</div>
)}
{!hasResults && <EmptyState title={t("connections.noSearchResults")} />}
{filteredOpenTabs.length > 0 && (
<div className="flex flex-col">
@@ -493,7 +418,7 @@ export function ConnectionsPanel({
label={t("connections.sectionOpen")}
count={filteredOpenTabs.length}
/>
{filteredOpenTabs.map((tab) => {
{filteredOpenTabs.map((tab, index) => {
const isActive = tab.id === activeTabId;
const liveSession = tab.instanceId
? sessionByInstanceId.get(tab.instanceId)
@@ -524,7 +449,7 @@ export function ConnectionsPanel({
didDragRef.current = false;
setDragTabId(tab.id);
}}
className={`relative ${isDropTarget ? "border-t-2 border-accent-brand" : ""}`}
className={`relative ${isDropTarget ? "before:absolute before:inset-x-0 before:-top-px before:z-20 before:h-0.5 before:bg-accent-brand" : ""}`}
style={{
cursor: dragTabId
? isDraggingThis
@@ -534,6 +459,7 @@ export function ConnectionsPanel({
}}
>
<ConnectionRow
stripe={index}
isActive={isActive}
isLive={isLive}
tabType={tab.type}
@@ -565,9 +491,7 @@ export function ConnectionsPanel({
)}
{filteredBackgroundTabs.length > 0 && (
<div
className={`flex flex-col ${filteredOpenTabs.length > 0 ? "mt-2" : ""}`}
>
<div className="flex flex-col">
<SectionHeader
label={t("connections.sectionBackground")}
count={filteredBackgroundTabs.length}
@@ -579,7 +503,7 @@ export function ConnectionsPanel({
})}
</span>
</div>
{filteredBackgroundTabs.map((record) => {
{filteredBackgroundTabs.map((record, index) => {
const host = record.hostId
? allHosts.find((h) => h.id === String(record.hostId))
: undefined;
@@ -588,6 +512,7 @@ export function ConnectionsPanel({
return (
<ConnectionRow
key={record.id}
stripe={index}
isLive={false}
faded
tabType={record.tabType}
@@ -1,3 +1,4 @@
import { Select2 } from "@/components/select2";
import { InlineView } from "@/components/surface/surface-scope";
import { useTranslation } from "react-i18next";
import { Rows3, SquareStack } from "lucide-react";
@@ -47,7 +48,7 @@ export function CustomizeCredentialsSidebarPanel({
: t("credentials.densityCompactDesc")
}
>
<select
<Select2
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
@@ -66,7 +67,7 @@ export function CustomizeCredentialsSidebarPanel({
<option value="compact">
{t("credentials.displayDensityCompact")}
</option>
</select>
</Select2>
</SettingRow>
</SectionCard>
@@ -97,7 +98,7 @@ export function CustomizeCredentialsSidebarPanel({
{t("credentials.actionsVisibilityDesc")}
</span>
</div>
<select
<Select2
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
@@ -116,7 +117,7 @@ export function CustomizeCredentialsSidebarPanel({
</option>
<option value="hover">{t("credentials.actionsHover")}</option>
<option value="click">{t("credentials.actionsClick")}</option>
</select>
</Select2>
</div>
</SectionCard>
+56 -7
View File
@@ -1,6 +1,9 @@
import { Select2 } from "@/components/select2";
import { InlineView } from "@/components/surface/surface-scope";
import { useTranslation } from "react-i18next";
import { Rows3, SquareStack } from "lucide-react";
import { PanelTop, Rows3, SquareStack } from "lucide-react";
import { actionOrder, useHostActions } from "./host-contributions";
import { showsInBar } from "./tree/host-bar-actions";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type {
HostClickBehavior,
@@ -25,6 +28,9 @@ export function CustomizeSidebarPanel({
) => void;
}) {
const { t } = useTranslation();
const actions = [...useHostActions()].sort(
(a, b) => actionOrder(a) - actionOrder(b) || a.id.localeCompare(b.id),
);
return (
<InlineView
@@ -48,7 +54,7 @@ export function CustomizeSidebarPanel({
: t("hosts.densityCompactDesc")
}
>
<select
<Select2
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
@@ -67,7 +73,7 @@ export function CustomizeSidebarPanel({
<option value="compact">
{t("hosts.displayDensityCompact")}
</option>
</select>
</Select2>
</SettingRow>
</SectionCard>
@@ -112,7 +118,7 @@ export function CustomizeSidebarPanel({
{t("hosts.actionsVisibilityDesc")}
</span>
</div>
<select
<Select2
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
@@ -129,7 +135,7 @@ export function CustomizeSidebarPanel({
<option value="actionsOnly">{t("hosts.actionsOnly")}</option>
<option value="hover">{t("hosts.actionsHover")}</option>
<option value="click">{t("hosts.actionsClick")}</option>
</select>
</Select2>
</div>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
@@ -140,7 +146,7 @@ export function CustomizeSidebarPanel({
{t("hosts.hostClickBehaviorDesc")}
</span>
</div>
<select
<Select2
value={preferences.display.hostClickBehavior}
onChange={(e) =>
update((prev) => ({
@@ -160,7 +166,7 @@ export function CustomizeSidebarPanel({
<option value="focusExistingDoubleClickNew">
{t("hosts.hostClickFocusExistingDoubleNew")}
</option>
</select>
</Select2>
</div>
<SettingRow
label={t("hosts.openOnDoubleClick")}
@@ -199,6 +205,49 @@ export function CustomizeSidebarPanel({
</SettingRow>
</SectionCard>
{actions.length > 0 && (
<SectionCard
title={t("hosts.connectBarTitle")}
icon={<PanelTop className="size-3.5" />}
>
<p className="pt-2 text-xs text-muted-foreground">
{t("hosts.connectBarDesc")}
</p>
{actions.map((action) => {
const Icon = action.icon;
return (
<SettingRow
key={action.id}
label={t(action.titleKey)}
rowId={`bar-action-${action.id}`}
>
<span className="flex items-center gap-2">
<Icon className="size-3.5 text-muted-foreground" />
<FakeSwitch
checked={showsInBar(
preferences.display.barActions,
action,
)}
onChange={(v) =>
update((prev) => ({
...prev,
display: {
...prev.display,
barActions: {
...prev.display.barActions,
[action.id]: v,
},
},
}))
}
/>
</span>
</SettingRow>
);
})}
</SectionCard>
)}
<p className="text-[11px] text-muted-foreground/70 leading-snug">
{t("hosts.customizeSortHint")}
</p>
+3 -2
View File
@@ -1,3 +1,4 @@
import { Select2 } from "@/components/select2";
import { InlineView } from "@/components/surface/surface-scope";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -174,7 +175,7 @@ export function FolderMetadataDialog({
<label className="text-xs font-semibold">
{t("hosts.folderCredential")}
</label>
<select
<Select2
value={credentialId}
onChange={(e) => setCredentialId(e.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
@@ -185,7 +186,7 @@ export function FolderMetadataDialog({
{c.username ? `${c.name} (${c.username})` : c.name}
</option>
))}
</select>
</Select2>
<p className="text-[10px] text-muted-foreground">
{t("hosts.folderCredentialHint")}
</p>
+10 -22
View File
@@ -1,8 +1,9 @@
import { Select2 } from "@/components/select2";
import { FormFooter } from "@/components/list-kit";
import { InlineView } from "@/components/surface/surface-scope";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import {
getCredentials,
getHostAuthOverride,
@@ -127,25 +128,12 @@ export function HostAuthOverrideModal({
</>
}
footer={
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSave}
disabled={
loading || loadError || saving || selectedId === initialId
}
>
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
<FormFooter
onCancel={() => onOpenChange(false)}
onSave={() => void handleSave()}
saving={saving}
disabled={loading || loadError || selectedId === initialId}
/>
}
>
<p className="text-xs text-muted-foreground">
@@ -172,7 +160,7 @@ export function HostAuthOverrideModal({
>
{t("hosts.sharing.authOverrideCredentialLabel")}
</label>
<select
<Select2
id={`auth-override-${host.id}`}
value={selectedId}
onChange={(event) => setSelectedId(event.target.value)}
@@ -192,7 +180,7 @@ export function HostAuthOverrideModal({
: credential.name}
</option>
))}
</select>
</Select2>
{credentials.length === 0 && (
<p className="text-[10px] text-muted-foreground">
{t("hosts.sharing.authOverrideNoCredentials")}
+2 -2
View File
@@ -809,7 +809,7 @@ export function HostEditorGeneralTab({
),
].join(" ")}
>
<select
<Select2
className="flex h-7 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
value={form.connectionOrigin ?? ""}
onChange={(e) =>
@@ -826,7 +826,7 @@ export function HostEditorGeneralTab({
<option value="remote">
{t("hosts.connectionOriginRemote")}
</option>
</select>
</Select2>
</SettingRow>
</HostOnly>
)}
+9 -15
View File
@@ -1,9 +1,10 @@
import { InlineView } from "@/components/surface/surface-scope";
import { FormFooter } from "@/components/list-kit";
import { getErrorMessage } from "../lib/error-message.js";
import { usePluginStore } from "@/plugin-host/plugin-store";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Download, Search } from "lucide-react";
import { Search } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Checkbox } from "@/components/checkbox";
@@ -220,20 +221,13 @@ export function HostExportDialog({
title={t("hosts.export.title")}
width="wide"
footer={
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
<Button variant="outline" onClick={onClose}>
{t("hosts.export.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleExport}
disabled={!canExport}
>
<Download className="size-3.5 mr-2" />
{t("hosts.export.confirm")}
</Button>
</div>
<FormFooter
onCancel={onClose}
cancelLabel={t("hosts.export.cancel")}
onSave={() => void handleExport()}
saveLabel={t("hosts.export.confirm")}
disabled={!canExport}
/>
}
>
<div className="flex flex-col md:flex-row gap-4 flex-1 min-h-0 min-w-0 overflow-y-auto md:overflow-visible">
+4
View File
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../lib/error-message.js";
import { setBarActions } from "@/sidebar/tree/host-bar-actions";
import { enabledHostProtocols, useHostProtocols } from "./host-protocols";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -248,6 +249,9 @@ export function HostsPanel({
);
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
useHostSidebarPreferences();
useEffect(() => {
setBarActions(sidebarPrefs.display.barActions);
}, [sidebarPrefs.display.barActions]);
const sortKey = sidebarPrefs.sort.key;
const pinnedFirst = sidebarPrefs.sort.pinnedFirst;
const { arrangeLocked, toggleArrangeLock } = useArrangeLock(
+88 -24
View File
@@ -1,11 +1,17 @@
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp, RotateCcw } from "lucide-react";
import { FakeSwitch } from "@/components/section-card";
import { useRailItems } from "./rail-items";
import { Button } from "@/components/button";
import {
useAreaPreferences,
useUiPreferencesContext,
} from "@/contexts/UiPreferencesContext";
import { moveRailItem, useRailItems } from "./rail-items";
/**
* Appearance > Sidebar > Navigation: one switch per rail destination,
* including the ones plugins register, which appear and disappear here as
* their plugin is turned on and off.
* their plugin is turned on and off. The arrows reorder the rail.
*/
export function NavigationVisibilityToggles({
hidden,
@@ -16,34 +22,92 @@ export function NavigationVisibilityToggles({
}) {
const { t } = useTranslation();
const railItems = useRailItems();
const uiPrefs = useUiPreferencesContext();
const { order } = useAreaPreferences("rail");
const main = railItems.filter((item) => item.placement !== "footer");
const band = (id: string) =>
main.find((item) => item.id === id)?.group ?? "tools";
const move = (id: string, direction: -1 | 1) => {
const peers = main.filter((item) => band(item.id) === band(id));
const at = peers.findIndex((item) => item.id === id);
const target = at + direction;
if (target < 0 || target >= peers.length) return;
// Moving down means sitting before the item after the next one.
const before =
direction === -1 ? peers[target].id : (peers[target + 1]?.id ?? null);
uiPrefs?.setOverride("rail", "order", moveRailItem(main, id, before));
};
return (
<>
{railItems
.filter((item) => item.hideable !== false)
.map((item) => (
<div
key={item.id}
className="flex items-center justify-between py-1.5"
data-testid={`nav-toggle-${item.id}`}
>
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
<span className="text-muted-foreground">
<item.icon size={12} />
.map((item) => {
const reorderable = item.placement !== "footer" && !!uiPrefs;
const peers = main.filter((peer) => band(peer.id) === band(item.id));
const index = peers.findIndex((peer) => peer.id === item.id);
return (
<div
key={item.id}
className="flex items-center justify-between gap-2 py-1.5"
data-testid={`nav-toggle-${item.id}`}
>
<span className="flex min-w-0 items-center gap-1.5 text-xs font-medium text-foreground">
<span className="text-muted-foreground">
<item.icon size={12} />
</span>
<span className="truncate">{t(item.labelKey)}</span>
</span>
{t(item.labelKey)}
</span>
<FakeSwitch
checked={!hidden.has(item.id)}
onChange={(visible) => {
const next = new Set(hidden);
if (visible) next.delete(item.id);
else next.add(item.id);
onChange(next);
}}
/>
</div>
))}
<span className="flex shrink-0 items-center gap-1">
{reorderable && (
<>
<Button
variant="ghost"
size="icon-xs"
disabled={index <= 0}
title={t("nav.moveUp")}
aria-label={t("nav.moveUp")}
onClick={() => move(item.id, -1)}
>
<ChevronUp />
</Button>
<Button
variant="ghost"
size="icon-xs"
disabled={index < 0 || index >= peers.length - 1}
title={t("nav.moveDown")}
aria-label={t("nav.moveDown")}
onClick={() => move(item.id, 1)}
>
<ChevronDown />
</Button>
</>
)}
<FakeSwitch
checked={!hidden.has(item.id)}
onChange={(visible) => {
const next = new Set(hidden);
if (visible) next.delete(item.id);
else next.add(item.id);
onChange(next);
}}
/>
</span>
</div>
);
})}
{uiPrefs && order && order.length > 0 && (
<Button
variant="ghost"
size="xs"
className="mt-1 self-start text-muted-foreground"
onClick={() => uiPrefs.setOverride("rail", "order", null)}
>
<RotateCcw />
{t("nav.resetRailOrder")}
</Button>
)}
</>
);
}
+26 -36
View File
@@ -1,8 +1,10 @@
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Eye, EyeOff } from "lucide-react";
import { toast } from "sonner";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { Textarea } from "@/components/textarea";
import { GroupHeading, PANEL } from "@/components/panel-layout";
import type { Host } from "@/types/ui-types";
import { getCredentials } from "@/api/credentials-api";
import { mapCredentials } from "./HostManagerData";
@@ -27,7 +29,7 @@ const INLINE_AUTH_TYPES = new Set(["password", "key", "credential"]);
const LABEL_CLASS =
"text-[10px] font-semibold uppercase tracking-widest text-muted-foreground";
const SELECT_CLASS =
"flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring";
"flex h-8 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring";
interface QuickConnectPanelProps {
onConnect: (host: Host, type: string) => void;
@@ -54,7 +56,6 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
const [authFields, setAuthFields] = useState<Record<string, unknown>>({});
const [password, setPassword] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [credentialId, setCredentialId] = useState("");
const [credentials, setCredentials] = useState<
{ id: string; name: string; username: string }[]
@@ -140,7 +141,8 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col gap-3 p-3">
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
<GroupHeading title={t("newUi.sidebar.quickConnect.where")} />
<div className="grid grid-cols-[1fr_5rem] gap-2">
<Field label={t("newUi.sidebar.quickConnect.protocolLabel")}>
<Select2
@@ -164,7 +166,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
value={port}
onChange={(e) => setPort(e.target.value)}
onKeyDown={onEnter}
className="h-7 text-xs"
className="h-8 text-xs"
/>
</Field>
</div>
@@ -174,9 +176,13 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
value={host}
onChange={(e) => setHost(e.target.value)}
onKeyDown={onEnter}
className="h-7 text-xs"
className="h-8 text-xs"
/>
</Field>
<GroupHeading
title={t("newUi.sidebar.quickConnect.who")}
className="pt-1"
/>
<Field label={t("newUi.sidebar.quickConnect.usernameLabel")}>
<Input
placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")}
@@ -189,7 +195,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
}}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={onEnter}
className="h-7 text-xs"
className="h-8 text-xs"
/>
</Field>
{!isDesktop && authOptions.length > 0 && (
@@ -212,29 +218,13 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
)}
{(isDesktop || authType === "password") && (
<Field label={t("newUi.sidebar.quickConnect.passwordLabel")}>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
placeholder={t(
"newUi.sidebar.quickConnect.passwordPlaceholder",
)}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={onEnter}
className="h-7 text-xs pr-8"
/>
<button
type="button"
onClick={() => setShowPassword((o) => !o)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? (
<EyeOff className="size-3.5" />
) : (
<Eye className="size-3.5" />
)}
</button>
</div>
<PasswordInput
placeholder={t("newUi.sidebar.quickConnect.passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={onEnter}
className="h-8 pr-8 text-xs"
/>
</Field>
)}
{selected?.quickConnect?.showDomain && (
@@ -244,19 +234,19 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
value={domain}
onChange={(e) => setDomain(e.target.value)}
onKeyDown={onEnter}
className="h-7 text-xs"
className="h-8 text-xs"
/>
</Field>
)}
{!isDesktop && authType === "key" && (
<Field label={t("newUi.sidebar.quickConnect.privateKeyLabel")}>
<textarea
<Textarea
placeholder={t(
"newUi.sidebar.quickConnect.privateKeyPlaceholder",
)}
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
className="w-full h-24 px-2.5 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
className="h-24 resize-none font-mono text-xs"
/>
</Field>
)}
@@ -294,7 +284,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
}}
/>
)}
<div className="flex flex-col gap-1.5 pt-1">
<div className="flex flex-col gap-1.5 pt-2">
{targets.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("newUi.sidebar.quickConnect.noTarget")}
@@ -306,9 +296,9 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
type="button"
disabled={!host.trim()}
onClick={() => connect(action)}
className={`flex items-center justify-center gap-1.5 h-7 w-full border text-xs font-semibold transition-colors disabled:opacity-50 disabled:pointer-events-none ${
className={`flex h-8 w-full items-center justify-center gap-1.5 border text-xs font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 ${
index === 0
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20"
? "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
: "border-border text-foreground hover:bg-muted"
}`}
>
+37 -65
View File
@@ -1,4 +1,5 @@
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import { FormFooter } from "@/components/list-kit";
import { useState, useRef, useEffect } from "react";
import { AuthEnrollmentSections } from "./AuthEnrollmentSections";
import { useTranslation } from "react-i18next";
@@ -265,36 +266,20 @@ export function NewApiKeyDialog({
}}
title={t("newUi.sidebar.userProfile.createApiKeyTitle")}
footer={
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
{createdToken ? (
<Button
variant="outline"
className="rounded-none border-accent-brand/40 text-[10px] font-bold uppercase tracking-widest text-accent-brand"
onClick={close}
>
{t("newUi.sidebar.userProfile.done")}
</Button>
) : (
<>
<Button
variant="ghost"
onClick={close}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("newUi.sidebar.userProfile.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest gap-1.5"
onClick={handleCreate}
disabled={creating}
>
<KeyRound className="size-3" />{" "}
{t("newUi.sidebar.userProfile.createKey")}
</Button>
</>
)}
</div>
createdToken ? (
<FormFooter
onSave={close}
saveLabel={t("newUi.sidebar.userProfile.done")}
/>
) : (
<FormFooter
onCancel={close}
cancelLabel={t("newUi.sidebar.userProfile.cancel")}
onSave={() => void handleCreate()}
saveLabel={t("newUi.sidebar.userProfile.createKey")}
saving={creating}
/>
)
}
>
<p className="text-xs text-muted-foreground">
@@ -1197,29 +1182,6 @@ export function UserProfilePanel({
userId={userId}
/>
{/* Donate banner */}
{shows("account") && (
<div className="border border-accent-brand/40 bg-accent-brand/10 px-3 py-2.5 flex flex-col gap-1.5">
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-accent-brand">
{t("newUi.sidebar.userProfile.donateTitle")}
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t("newUi.sidebar.userProfile.donateDescription")}
</p>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t("newUi.sidebar.userProfile.donateMilestones")}
</p>
<a
href="https://donate.termix.site/"
target="_blank"
rel="noopener noreferrer"
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest bg-accent-brand text-white px-2 py-1 hover:opacity-90 transition-opacity"
>
{t("newUi.sidebar.userProfile.donateButton")}
</a>
</div>
)}
{/* Storage mode toggle — only meaningful once a remote server is
connected; with no sync there's nowhere for "cloud" to sync to,
so this stays forced to local storage and hidden. */}
@@ -1233,7 +1195,7 @@ export function UserProfilePanel({
onClick={() => handleStorageModeChange("local")}
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
storageMode === "local"
? "bg-accent-brand text-white"
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
}`}
>
@@ -1243,7 +1205,7 @@ export function UserProfilePanel({
onClick={() => handleStorageModeChange("cloud")}
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
storageMode === "cloud"
? "bg-accent-brand text-white"
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
}`}
>
@@ -1363,15 +1325,22 @@ export function UserProfilePanel({
)}
<div className="border-t border-border pt-3 mt-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.versionLabel")}
</span>
<div className="flex items-center justify-between mt-1.5">
<span className="text-sm font-bold text-accent-brand">
{version ? `v${version}` : "—"}
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.versionLabel")}
</span>
<VersionBadge status={versionStatus} releaseUrl={releaseUrl} />
<VersionBadge
status={versionStatus}
releaseUrl={releaseUrl}
className="shrink-0 px-1 py-px text-[9px]"
/>
</div>
<span
className="mt-1.5 block truncate text-sm font-bold text-accent-brand"
title={version ? `v${version}` : undefined}
>
{version ? `v${version}` : "-"}
</span>
</div>
<div className="border-t border-border pt-3 mt-3">
@@ -1528,7 +1497,7 @@ export function UserProfilePanel({
<Type className="size-3" />
{t("newUi.sidebar.userProfile.interfaceFontLabel")}
</span>
<select
<Select2
value={uiFont}
onChange={(event) =>
handleUiFontChange(event.target.value as UiFontId)
@@ -1540,7 +1509,7 @@ export function UserProfilePanel({
{font.label}
</option>
))}
</select>
</Select2>
<span className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.interfaceFontDescription")}
</span>
@@ -1767,7 +1736,10 @@ export function UserProfilePanel({
</SettingRow>
</div>
<div className="flex flex-col gap-1 border-t border-border pt-3">
<div
id="settings-navigation"
className="flex flex-col gap-1 border-t border-border pt-3"
>
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
{t("newUi.sidebar.userProfile.settingsNavigation")}
</span>
+5
View File
@@ -61,6 +61,11 @@ const menuItems = createRegistry<HostContextMenuItemDef>(byOrderThenId);
export const registerHostAction = actions.register;
export const useHostActions = actions.useList;
/** Plugin actions sort by order; connect actions default to the end. */
export function actionOrder(action: HostActionDef): number {
return action.order ?? (action.kind === "connect" ? 100 : 50);
}
export const listHostActions = actions.list;
export const registerHostBadge = badges.register;
export const useHostBadges = badges.useList;
+46 -2
View File
@@ -11,6 +11,7 @@ import {
} from "lucide-react";
import { useMemo, useSyncExternalStore } from "react";
import { usePermissions } from "@/hooks/use-permissions";
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
import { useSyncAttentionCount } from "@/hooks/use-sync-status";
import { isElectron } from "@/lib/electron";
import { createRegistry } from "@/lib/registry";
@@ -233,12 +234,55 @@ export function useRailItems(): RailItemDef[] {
railItemsSnapshot,
);
const { has, loaded } = usePermissions();
const { order } = useAreaPreferences("rail");
return useMemo(
() => permittedRailItems(items, { has, loaded }),
[items, has, loaded],
() => applyRailOrder(permittedRailItems(items, { has, loaded }), order),
[items, has, loaded, order],
);
}
/**
* Items in the user's order. Listed ids come first in that order within the
* default sequence; unlisted ones keep their default place relative to it.
*/
export function applyRailOrder(
items: RailItemDef[],
order: string[] | undefined,
): RailItemDef[] {
if (!order || order.length === 0) return items;
const rank = new Map(order.map((id, index) => [id, index]));
const listed = items
.filter((item) => rank.has(item.id))
.sort((a, b) => rank.get(a.id)! - rank.get(b.id)!);
let next = 0;
return items.map((item) => (rank.has(item.id) ? listed[next++] : item));
}
/**
* The order after moving `id` to sit before `beforeId` (or to the end of its
* band when null), as a full list of the given items' ids.
*/
export function moveRailItem(
items: RailItemDef[],
id: string,
beforeId: string | null,
): string[] {
const ids = items.map((item) => item.id).filter((other) => other !== id);
const at = beforeId ? ids.indexOf(beforeId) : -1;
if (at < 0) {
const group = items.find((item) => item.id === id)?.group ?? "tools";
const lastInBand = items
.map((item, index) => ({ item, index }))
.filter(({ item }) => item.id !== id && (item.group ?? "tools") === group)
.map(({ item }) => ids.indexOf(item.id))
.pop();
ids.splice(lastInBand === undefined ? ids.length : lastInBand + 1, 0, id);
} else {
ids.splice(at, 0, id);
}
return ids;
}
/** Places outside the rail that still need a title, like the Settings tab. */
export const RAIL_UTILITY_ITEMS: RailItemDef[] = [
{ id: "settings", icon: Settings, labelKey: "nav.settings" },
+4 -6
View File
@@ -1,4 +1,6 @@
/* eslint-disable react-refresh/only-export-components */
import { actionOrder } from "@/sidebar/host-contributions";
import { showsInBar, useBarActions } from "@/sidebar/tree/host-bar-actions";
import { rem } from "@/lib/rem";
import { enabledHostProtocols } from "@/sidebar/host-protocols";
import { useEffect, useRef, useState, type MouseEvent } from "react";
@@ -116,11 +118,6 @@ export function buildStatusTooltip(
return `${protocols.join(", ")}: ${statusLabel}`;
}
/** Plugin actions sort by order; connect actions default to the end. */
function actionOrder(action: HostActionDef): number {
return action.order ?? (action.kind === "connect" ? 100 : 50);
}
async function writeClipboardText(value: string): Promise<void> {
await copyToClipboard(value);
}
@@ -275,6 +272,7 @@ export function HostItem({
const allowDelete = canDeleteHost(host);
const allHostActions = useHostActions();
const pluginActions = hostActionsFor(allHostActions, host);
const barActions = useBarActions();
const badges = hostBadgesFor(useHostBadges(), host);
const pluginMenuItems = hostMenuItemsFor(useHostContextMenuItems(), host);
const splitTargets = useSplitTargets();
@@ -421,7 +419,7 @@ export function HostItem({
icon: action.icon as typeof Terminal,
label: action.label?.(host) ?? t(action.titleKey),
tabType: action.tabType,
tray: action.tray !== false,
tray: showsInBar(barActions, action),
items:
items && items.length > 1
? items.map((item) => ({
+39
View File
@@ -0,0 +1,39 @@
import { useSyncExternalStore } from "react";
/**
* Which host actions the user put in or took out of the row's connect bar.
* The hosts panel owns the preference and mirrors it here, so every row can
* read it without its own fetch.
*/
let current: Record<string, boolean> = {};
const listeners = new Set<() => void>();
export function setBarActions(next: Record<string, boolean>): void {
const same =
Object.keys(next).length === Object.keys(current).length &&
Object.entries(next).every(([id, value]) => current[id] === value);
if (same) return;
current = next;
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useBarActions(): Record<string, boolean> {
return useSyncExternalStore(
subscribe,
() => current,
() => current,
);
}
/** Whether an action shows in the bar: the user's choice, else its own. */
export function showsInBar(
barActions: Record<string, boolean>,
action: { id: string; tray?: boolean },
): boolean {
return barActions[action.id] ?? action.tray !== false;
}
+153
View File
@@ -0,0 +1,153 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import {
AddButton,
FormFooter,
ListRow,
ListRowAction,
ListRowFolder,
PanelList,
} from "@/components/list-kit";
afterEach(cleanup);
describe("ListRow", () => {
it("tints odd rows and marks the selected one", () => {
const { container, rerender } = render(<ListRow title="a" stripe={1} />);
const row = container.firstElementChild as HTMLElement;
expect(row.className).toContain("bg-muted/15");
rerender(<ListRow title="a" stripe={1} selected />);
expect(row.className).toContain("bg-accent-brand/10");
expect(row.getAttribute("data-selected")).toBe("true");
});
it("clicks and opens with Enter, but tray clicks do not reach the row", () => {
const onClick = vi.fn();
const onAction = vi.fn();
render(
<ListRow
title="web"
onClick={onClick}
actions={
<ListRowAction label="Edit" onClick={onAction}>
<span />
</ListRowAction>
}
/>,
);
const row = screen.getByRole("button", { name: /web/ });
fireEvent.keyDown(row, { key: "Enter" });
expect(onClick).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByLabelText("Edit"));
expect(onAction).toHaveBeenCalledTimes(1);
expect(onClick).toHaveBeenCalledTimes(1);
});
it("keeps the tray visible while active", () => {
render(
<ListRow
title="a"
active
actions={
<ListRowAction label="More">
<span />
</ListRowAction>
}
/>,
);
const tray = screen.getByLabelText("More").parentElement as HTMLElement;
expect(tray.className).toMatch(/(^| )flex( |$)/);
expect(tray.className).not.toContain("hidden");
});
});
describe("ListRowFolder", () => {
it("toggles and shows the empty text when open with no items", () => {
const onToggle = vi.fn();
const { rerender } = render(
<ListRowFolder
name="Prod"
count={0}
open={false}
onToggle={onToggle}
emptyText="Nothing here"
/>,
);
fireEvent.click(screen.getByRole("button", { name: /Prod/ }));
expect(onToggle).toHaveBeenCalled();
expect(screen.queryByText("Nothing here")).toBeNull();
rerender(
<ListRowFolder
name="Prod"
count={0}
open
onToggle={onToggle}
emptyText="Nothing here"
/>,
);
expect(screen.getByText("Nothing here")).toBeTruthy();
});
});
describe("PanelList", () => {
it("shows the empty slot only without rows", () => {
const { rerender } = render(
<PanelList empty={<span>empty</span>}>{[false, null]}</PanelList>,
);
expect(screen.getByText("empty")).toBeTruthy();
rerender(
<PanelList empty={<span>empty</span>}>
<ListRow title="row" />
</PanelList>,
);
expect(screen.queryByText("empty")).toBeNull();
});
});
describe("AddButton", () => {
it("defaults its label and hides it when compact", () => {
const { rerender } = render(<AddButton />);
expect(screen.getByText("common.new")).toBeTruthy();
rerender(<AddButton compact label="Add host" />);
expect(screen.queryByText("Add host")).toBeNull();
expect(screen.getByLabelText("Add host")).toBeTruthy();
});
});
describe("FormFooter", () => {
it("shows the unsaved note, delete and a saving state", () => {
const onSave = vi.fn();
const onDelete = vi.fn();
const { rerender } = render(
<FormFooter
dirty
onSave={onSave}
onCancel={() => {}}
onDelete={onDelete}
/>,
);
expect(screen.getByText("common.unsavedChanges")).toBeTruthy();
fireEvent.click(screen.getByText("common.delete"));
expect(onDelete).toHaveBeenCalled();
fireEvent.click(screen.getByText("common.save"));
expect(onSave).toHaveBeenCalled();
rerender(<FormFooter saving onSave={onSave} />);
const saving = screen.getByText("common.saving").closest("button");
expect(saving?.disabled).toBe(true);
});
it("lets a status replace the unsaved note", () => {
render(<FormFooter dirty status="2 errors" onSave={() => {}} />);
expect(screen.getByText("2 errors")).toBeTruthy();
expect(screen.queryByText("common.unsavedChanges")).toBeNull();
});
});
+86
View File
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import { EditorPane, EditorSection, LazySection } from "@/manage/EditorPane";
import { Field } from "@/components/form-fields";
import { HIDDEN_ATTR } from "@/settings/page-filter";
afterEach(cleanup);
function renderEditor() {
return render(
<EditorPane
bands={[
{
id: "main",
label: "Main",
items: [
{ id: "general", label: "General" },
{ id: "terminal", label: "Terminal" },
],
},
]}
>
<EditorSection id="general" label="General">
<Field label="Address">
<input />
</Field>
<Field label="Port">
<input />
</Field>
</EditorSection>
<EditorSection id="terminal" label="Terminal">
<LazySection>
<Field label="Font size">
<input />
</Field>
</LazySection>
</EditorSection>
</EditorPane>,
);
}
const hidden = (el: Element | null) => !!el?.closest(`[${HIDDEN_ATTR}]`);
describe("EditorPane search", () => {
it("filters sections and fields, and hides their nav entries", async () => {
renderEditor();
fireEvent.change(screen.getByLabelText("manage.searchSettings"), {
target: { value: "port" },
});
await waitFor(() => {
expect(hidden(screen.getByText("Port"))).toBe(false);
expect(hidden(screen.getByText("Address"))).toBe(true);
expect(hidden(document.querySelector('[data-section="terminal"]'))).toBe(
true,
);
});
await waitFor(() =>
expect(screen.queryByRole("button", { name: "Terminal" })).toBeNull(),
);
});
it("mounts lazy sections so their fields can be found", async () => {
renderEditor();
fireEvent.change(screen.getByLabelText("manage.searchSettings"), {
target: { value: "font" },
});
await waitFor(() => {
expect(hidden(screen.getByText("Font size"))).toBe(false);
expect(hidden(document.querySelector('[data-section="general"]'))).toBe(
true,
);
});
});
});
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { filterSettingsPage, HIDDEN_ATTR } from "@/settings/page-filter";
function page() {
const root = document.createElement("div");
root.innerHTML = `
<div class="bg-card" id="theme">
<span>Appearance</span>
<div data-setting-row="" id="font">Font size</div>
<div data-setting-row="" id="accent">Accent color</div>
</div>
<div class="bg-card" id="account"><span>Account</span><div data-setting-row="" id="name">Username</div></div>
<div class="hidden"><div class="bg-card" id="admin">Font policy</div></div>
`;
return root;
}
const hidden = (root: HTMLElement, id: string) =>
root.querySelector(`#${id}`)!.hasAttribute(HIDDEN_ATTR);
describe("filterSettingsPage", () => {
it("keeps only matching cards and rows", () => {
const root = page();
expect(filterSettingsPage(root, "font")).toBe(1);
expect(hidden(root, "theme")).toBe(false);
expect(hidden(root, "font")).toBe(false);
expect(hidden(root, "accent")).toBe(true);
expect(hidden(root, "account")).toBe(true);
});
it("keeps every row when only the card title matches", () => {
const root = page();
filterSettingsPage(root, "appearance");
expect(hidden(root, "font")).toBe(false);
expect(hidden(root, "accent")).toBe(false);
});
it("ignores cards in hidden panels and clears on an empty query", () => {
const root = page();
expect(filterSettingsPage(root, "policy")).toBe(0);
expect(filterSettingsPage(root, "")).toBe(2);
expect(root.querySelectorAll(`[${HIDDEN_ATTR}]`).length).toBe(0);
});
});
@@ -94,9 +94,8 @@ describe("HostAuthOverrideModal", () => {
/>,
);
const select = await screen.findByLabelText(
"hosts.sharing.authOverrideCredentialLabel",
);
await screen.findByLabelText("hosts.sharing.authOverrideCredentialLabel");
const select = document.querySelector("select") as HTMLSelectElement;
expect((select as HTMLSelectElement).value).toBe("7");
fireEvent.change(select, { target: { value: "8" } });
@@ -119,9 +118,8 @@ describe("HostAuthOverrideModal", () => {
/>,
);
const select = await screen.findByLabelText(
"hosts.sharing.authOverrideCredentialLabel",
);
await screen.findByLabelText("hosts.sharing.authOverrideCredentialLabel");
const select = document.querySelector("select") as HTMLSelectElement;
expect(screen.getByText("hosts.sharing.noPersonalCredential")).toBeTruthy();
fireEvent.change(select, { target: { value: "" } });
expect(screen.getByText("hosts.sharing.authOverrideRequired")).toBeTruthy();
@@ -187,9 +185,8 @@ describe("HostAuthOverrideModal", () => {
/>,
);
const select = await screen.findByLabelText(
"hosts.sharing.authOverrideCredentialLabel",
);
await screen.findByLabelText("hosts.sharing.authOverrideCredentialLabel");
const select = document.querySelector("select") as HTMLSelectElement;
expect((select as HTMLSelectElement).value).toBe("19");
expect(remote.get).toHaveBeenCalledWith("/credentials");
expect(api.getCredentials).not.toHaveBeenCalled();
+49
View File
@@ -11,6 +11,8 @@ import {
rightDockableIds,
railItemLabel,
visibleRailItems,
applyRailOrder,
moveRailItem,
} from "@/sidebar/rail-items";
import { isCapturableTabType } from "@/shell/shell-layout";
import { registerPanel, resetPanels } from "@/shell/panel-registry";
@@ -275,3 +277,50 @@ describe("permittedRailItems", () => {
expect(ids).toEqual(["open"]);
});
});
describe("rail order", () => {
const item = (id: string, group: "objects" | "tools" = "tools") =>
({ id, icon: Boxes, labelKey: id, group }) as const;
const items = [
item("hosts", "objects"),
item("credentials", "objects"),
item("snippets"),
item("macros"),
item("history"),
];
it("leaves the default order without a saved one", () => {
expect(applyRailOrder(items, undefined)).toBe(items);
expect(applyRailOrder(items, [])).toBe(items);
});
it("puts listed items in the saved order and keeps the rest in place", () => {
const ids = applyRailOrder(items, ["history", "snippets"]).map(
(entry) => entry.id,
);
expect(ids).toEqual([
"hosts",
"credentials",
"history",
"macros",
"snippets",
]);
});
it("moves an item before another, or to the end of its band", () => {
expect(moveRailItem(items, "history", "snippets")).toEqual([
"hosts",
"credentials",
"history",
"snippets",
"macros",
]);
expect(moveRailItem(items, "hosts", null)).toEqual([
"credentials",
"hosts",
"snippets",
"macros",
"history",
]);
});
});
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { showsInBar } from "@/sidebar/tree/host-bar-actions";
describe("showsInBar", () => {
it("follows the action's own default without a choice", () => {
expect(showsInBar({}, { id: "terminal" })).toBe(true);
expect(showsInBar({}, { id: "maintenance", tray: false })).toBe(false);
});
it("lets the user's choice win either way", () => {
expect(
showsInBar({ maintenance: true }, { id: "maintenance", tray: false }),
).toBe(true);
expect(showsInBar({ "ai-agent": false }, { id: "ai-agent" })).toBe(false);
});
});
@@ -28,6 +28,7 @@ describe("defaultHostSidebarPreferences", () => {
openOnDoubleClick: false,
showFolderPaths: true,
hostClickBehavior: "newTab",
barActions: {},
},
});
});
@@ -67,6 +68,7 @@ describe("sanitizeHostSidebarPreferences", () => {
openOnDoubleClick: true,
showFolderPaths: false,
hostClickBehavior: "focusExistingDoubleClickNew",
barActions: { maintenance: true },
},
};
expect(sanitizeHostSidebarPreferences(valid)).toEqual(valid);
@@ -145,3 +147,30 @@ describe("sanitizeHostSidebarPreferences", () => {
expect(result.version).toBe(HOST_SIDEBAR_PREFS_VERSION);
});
});
describe("barActions", () => {
it("keeps boolean choices for valid action ids only", () => {
const prefs = sanitizeHostSidebarPreferences({
display: {
barActions: {
maintenance: true,
"ai-agent": false,
"Bad Id": true,
docker: "yes",
},
},
});
expect(prefs.display.barActions).toEqual({
maintenance: true,
"ai-agent": false,
});
});
it("defaults to no overrides", () => {
expect(sanitizeHostSidebarPreferences({}).display.barActions).toEqual({});
expect(
sanitizeHostSidebarPreferences({ display: { barActions: [] } }).display
.barActions,
).toEqual({});
});
});
+7 -1
View File
@@ -45,7 +45,13 @@ export function DonationReminderModal({
<Button variant="outline" onClick={onDismiss}>
{t("donation.dismiss")}
</Button>
<Button onClick={handleDonate}>{t("donation.cta")}</Button>
<Button
variant="outline"
onClick={handleDonate}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
>
{t("donation.cta")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>