mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 08:05:23 +00:00
feat: shared list kit, settings search, rail order and ui consistency fixes
This commit is contained in:
1 parent
3ed5775ed5
commit
58c3434e58
68 files changed
+2039
-473
No files matched your search
Generated
+1
-1
@@ -15076,7 +15076,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin-sdk": {
|
"packages/plugin-sdk": {
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"name": "@termix-ssh/plugin-sdk",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/node": "^4.2.4",
|
"@tailwindcss/node": "^4.2.4",
|
||||||
|
|||||||
@@ -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": "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",
|
"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": "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": "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",
|
"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",
|
"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",
|
"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:sdk": "npm run build --workspace @termix-ssh/plugin-sdk && node scripts/build-sdk-host.mjs",
|
||||||
"build:plugins": "node scripts/build-plugins.cjs",
|
"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"
|
"plugins:pin": "node scripts/pin-bundled-plugins.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@termix-ssh/plugin-sdk",
|
"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.",
|
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ import { installSafePorts } from "./safe-ports.js";
|
|||||||
|
|
||||||
installSafePorts();
|
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) {
|
if (typeof window !== "undefined" && !window.matchMedia) {
|
||||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||||
matches: false,
|
matches: false,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element
|
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
|
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
|
AlertDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||||
AlertDialog (value): ({ ...props }: AlertDialogProps) => 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
|
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
|
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
|
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
|
FullScreenAppPhase (type): FullScreenAppPhase
|
||||||
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
|
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
|
||||||
GridCardCatalogEntry (type): GridCardCatalogEntry
|
GridCardCatalogEntry (type): GridCardCatalogEntry
|
||||||
@@ -103,6 +105,11 @@ KeybindingActionType (type): string
|
|||||||
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
|
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
|
||||||
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
|
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
|
||||||
LineChartSeries (type): LineChartSeries
|
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
|
LogContext (type): LogContext
|
||||||
LogEntry (type): LogEntry
|
LogEntry (type): LogEntry
|
||||||
MFAPromptMode (type): MFAPromptMode
|
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"
|
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
|
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
|
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
|
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
|
PanelViewMode (type): PanelViewMode
|
||||||
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
|
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
|
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
|
||||||
* Each .tmxplug is downloaded (or read from a path), checked against its
|
* Each .tmxplug is downloaded (or read from a path), checked against its
|
||||||
* pinned sha256 and unpacked.
|
* 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");
|
const fs = require("node:fs");
|
||||||
@@ -13,6 +17,8 @@ const {
|
|||||||
loadBundledPlugins,
|
loadBundledPlugins,
|
||||||
fetchArtifact,
|
fetchArtifact,
|
||||||
extractArtifact,
|
extractArtifact,
|
||||||
|
findLocalPluginBuilds,
|
||||||
|
copyLocalBuild,
|
||||||
} = require("./lib/bundled-plugins.cjs");
|
} = require("./lib/bundled-plugins.cjs");
|
||||||
|
|
||||||
const root = path.resolve(__dirname, "..");
|
const root = path.resolve(__dirname, "..");
|
||||||
@@ -20,11 +26,22 @@ const destination = path.join(root, "dist", "plugins");
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const plugins = loadBundledPlugins(root);
|
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.rmSync(destination, { recursive: true, force: true });
|
||||||
fs.mkdirSync(destination, { recursive: true });
|
fs.mkdirSync(destination, { recursive: true });
|
||||||
|
|
||||||
for (const plugin of plugins) {
|
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);
|
const buffer = await fetchArtifact(plugin, root);
|
||||||
await extractArtifact(buffer, path.join(destination, plugin.id), plugin.id);
|
await extractArtifact(buffer, path.join(destination, plugin.id), plugin.id);
|
||||||
console.log(`unpacked ${plugin.id}`);
|
console.log(`unpacked ${plugin.id}`);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
fetchArtifact,
|
fetchArtifact,
|
||||||
extractArtifact,
|
extractArtifact,
|
||||||
pinsFromIndex,
|
pinsFromIndex,
|
||||||
|
findLocalPluginBuilds,
|
||||||
|
copyLocalBuild,
|
||||||
} from "./lib/bundled-plugins.cjs";
|
} from "./lib/bundled-plugins.cjs";
|
||||||
|
|
||||||
const cleanups: Array<() => void> = [];
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@ const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
|
|||||||
|
|
||||||
const ALLOWED = [
|
const ALLOWED = [
|
||||||
"ActionSlot",
|
"ActionSlot",
|
||||||
|
"AddButton",
|
||||||
"Alert",
|
"Alert",
|
||||||
"AlertDescription",
|
"AlertDescription",
|
||||||
"AlertDialog",
|
"AlertDialog",
|
||||||
@@ -100,6 +101,7 @@ const ALLOWED = [
|
|||||||
"FakeSwitch",
|
"FakeSwitch",
|
||||||
"Field",
|
"Field",
|
||||||
"FieldPair",
|
"FieldPair",
|
||||||
|
"FormFooter",
|
||||||
"FullScreenAppPhase",
|
"FullScreenAppPhase",
|
||||||
"FullScreenAppWrapper",
|
"FullScreenAppWrapper",
|
||||||
"GridCardCatalogEntry",
|
"GridCardCatalogEntry",
|
||||||
@@ -124,6 +126,11 @@ const ALLOWED = [
|
|||||||
"Label",
|
"Label",
|
||||||
"LineChart",
|
"LineChart",
|
||||||
"LineChartSeries",
|
"LineChartSeries",
|
||||||
|
"ListBadge",
|
||||||
|
"ListRow",
|
||||||
|
"ListRowAction",
|
||||||
|
"ListRowFolder",
|
||||||
|
"ListRowTone",
|
||||||
"LogContext",
|
"LogContext",
|
||||||
"LogEntry",
|
"LogEntry",
|
||||||
"MFAPromptMode",
|
"MFAPromptMode",
|
||||||
@@ -140,6 +147,7 @@ const ALLOWED = [
|
|||||||
"PROMPT_DESTRUCTIVE_BUTTON",
|
"PROMPT_DESTRUCTIVE_BUTTON",
|
||||||
"PROMPT_PRIMARY_BUTTON",
|
"PROMPT_PRIMARY_BUTTON",
|
||||||
"PanelDensity",
|
"PanelDensity",
|
||||||
|
"PanelList",
|
||||||
"PanelSearch",
|
"PanelSearch",
|
||||||
"PanelShell",
|
"PanelShell",
|
||||||
"PanelViewMode",
|
"PanelViewMode",
|
||||||
|
|||||||
@@ -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 = {
|
module.exports = {
|
||||||
|
findLocalPluginBuilds,
|
||||||
|
copyLocalBuild,
|
||||||
parseBundledPlugins,
|
parseBundledPlugins,
|
||||||
pinsFromIndex,
|
pinsFromIndex,
|
||||||
loadBundledPlugins,
|
loadBundledPlugins,
|
||||||
|
|||||||
@@ -276,7 +276,13 @@ export function createPluginAssetsRouter(
|
|||||||
: "no-cache",
|
: "no-cache",
|
||||||
);
|
);
|
||||||
res.type(CONTENT_TYPES[path.extname(file)] ?? "application/octet-stream");
|
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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
|
|
||||||
let root = "";
|
let root = "";
|
||||||
let plugin: { id: string; dir: string; manifest: never };
|
let plugin: { id: string; dir: string; manifest: never };
|
||||||
|
let installed: { id: string; dir: string; manifest: never };
|
||||||
let server: Server;
|
let server: Server;
|
||||||
let baseUrl = "";
|
let baseUrl = "";
|
||||||
|
|
||||||
@@ -32,6 +33,14 @@ beforeAll(async () => {
|
|||||||
write("locales/translated/de_DE.json", "{}");
|
write("locales/translated/de_DE.json", "{}");
|
||||||
write("manifest.json", "{}");
|
write("manifest.json", "{}");
|
||||||
fs.writeFileSync(path.join(root, "outside.js"), "nope");
|
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 = {
|
plugin = {
|
||||||
id: "gizmo",
|
id: "gizmo",
|
||||||
dir: path.join(root, "gizmo"),
|
dir: path.join(root, "gizmo"),
|
||||||
@@ -41,7 +50,9 @@ beforeAll(async () => {
|
|||||||
const app = express();
|
const app = express();
|
||||||
app.use(
|
app.use(
|
||||||
"/plugin-assets",
|
"/plugin-assets",
|
||||||
createPluginAssetsRouter((id) => (id === "gizmo" ? plugin : undefined)),
|
createPluginAssetsRouter((id) =>
|
||||||
|
id === "gizmo" ? plugin : id === "gadget" ? installed : undefined,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
server = await new Promise<Server>((resolve) => {
|
server = await new Promise<Server>((resolve) => {
|
||||||
const created = app.listen(0, "127.0.0.1", () => resolve(created));
|
const created = app.listen(0, "127.0.0.1", () => resolve(created));
|
||||||
@@ -111,6 +122,12 @@ describe("resolvePluginAsset", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /plugin-assets", () => {
|
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 () => {
|
it("serves a bundle as a module any origin can load", async () => {
|
||||||
const res = await fetch(`${baseUrl}/plugin-assets/gizmo/frontend.js`);
|
const res = await fetch(`${baseUrl}/plugin-assets/gizmo/frontend.js`);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|||||||
@@ -57,6 +57,11 @@ export interface HostSidebarDisplayPreferences {
|
|||||||
/** When false, nested folders hide the parent-path breadcrumb before their name. */
|
/** When false, nested folders hide the parent-path breadcrumb before their name. */
|
||||||
showFolderPaths: boolean;
|
showFolderPaths: boolean;
|
||||||
hostClickBehavior: HostClickBehavior;
|
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 {
|
export interface HostSidebarPreferences {
|
||||||
@@ -127,10 +132,24 @@ export function defaultHostSidebarPreferences(): HostSidebarPreferences {
|
|||||||
openOnDoubleClick: false,
|
openOnDoubleClick: false,
|
||||||
showFolderPaths: true,
|
showFolderPaths: true,
|
||||||
hostClickBehavior: "newTab",
|
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[] {
|
function sanitizeStringArray(input: unknown): string[] {
|
||||||
if (!Array.isArray(input)) return [];
|
if (!Array.isArray(input)) return [];
|
||||||
return input.filter((v): v is string => typeof v === "string");
|
return input.filter((v): v is string => typeof v === "string");
|
||||||
@@ -215,6 +234,7 @@ export function sanitizeHostSidebarPreferences(
|
|||||||
)
|
)
|
||||||
? (displayObj.hostClickBehavior as HostClickBehavior)
|
? (displayObj.hostClickBehavior as HostClickBehavior)
|
||||||
: defaults.display.hostClickBehavior,
|
: defaults.display.hostClickBehavior,
|
||||||
|
barActions: sanitizeBarActions(displayObj.barActions),
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export interface UiRailPreferences {
|
|||||||
* items are only known at runtime, so the preset cannot list them.
|
* items are only known at runtime, so the preset cannot list them.
|
||||||
*/
|
*/
|
||||||
hidePluginItems?: boolean;
|
hidePluginItems?: boolean;
|
||||||
|
/** Rail item ids in the user's order. Items not listed keep their place. */
|
||||||
|
order?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UiDashboardPreferences {
|
export interface UiDashboardPreferences {
|
||||||
@@ -243,6 +245,7 @@ const AREA_SPECS: {
|
|||||||
},
|
},
|
||||||
rail: {
|
rail: {
|
||||||
hiddenTabs: { kind: "stringArray" },
|
hiddenTabs: { kind: "stringArray" },
|
||||||
|
order: { kind: "stringArray" },
|
||||||
hidePluginItems: { kind: "bool" },
|
hidePluginItems: { kind: "bool" },
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
|
|||||||
+7
-1
@@ -2969,7 +2969,13 @@ export function AppShell({
|
|||||||
onRailClick={handleRailClick}
|
onRailClick={handleRailClick}
|
||||||
onOpenTab={openSingletonTab}
|
onOpenTab={openSingletonTab}
|
||||||
onOpenInRightDock={openInRightDock}
|
onOpenInRightDock={openInRightDock}
|
||||||
onOpenSettings={() => openSingletonTab("settings")}
|
onOpenSettings={() =>
|
||||||
|
openSingletonTab("settings", undefined, undefined, {
|
||||||
|
section: "appearance",
|
||||||
|
reveal: "settings-navigation",
|
||||||
|
revealAt: Date.now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
onOpenPalette={() => setCommandPaletteOpen(true)}
|
onOpenPalette={() => setCommandPaletteOpen(true)}
|
||||||
onLogout={onLogout}
|
onLogout={onLogout}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1205,7 +1205,11 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
{t("messages.databaseConnectionFailed")}
|
{t("messages.databaseConnectionFailed")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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")}
|
{t("common.refresh")}
|
||||||
</Button>
|
</Button>
|
||||||
<LanguageRow
|
<LanguageRow
|
||||||
@@ -1351,11 +1355,13 @@ export function Auth({ onLogin }: AuthProps) {
|
|||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={authPluginsLoading}
|
disabled={authPluginsLoading}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void retryAuthPlugins().catch(() => {})
|
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")}
|
{t("auth.retrySecondFactorUI")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function Field({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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) && (
|
{(label || aside) && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -166,6 +166,10 @@ export function PanelSearch({
|
|||||||
placeholder,
|
placeholder,
|
||||||
fill,
|
fill,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
|
disabled,
|
||||||
|
title,
|
||||||
|
onEnter,
|
||||||
|
inputRef,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -174,10 +178,15 @@ export function PanelSearch({
|
|||||||
/** Takes the whole toolbar, for panels where search is the only control. */
|
/** Takes the whole toolbar, for panels where search is the only control. */
|
||||||
fill?: boolean;
|
fill?: boolean;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
title?: string;
|
||||||
|
/** Runs on Enter, for searches that query a server. */
|
||||||
|
onEnter?: () => void;
|
||||||
|
inputRef?: React.Ref<HTMLInputElement>;
|
||||||
className?: string;
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
const localRef = useRef<HTMLInputElement>(null);
|
||||||
const text = placeholder ?? t("common.search");
|
const text = placeholder ?? t("common.search");
|
||||||
return (
|
return (
|
||||||
<div
|
<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" />
|
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
<input
|
<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"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
|
disabled={disabled}
|
||||||
|
title={title}
|
||||||
aria-label={text}
|
aria-label={text}
|
||||||
placeholder={text}
|
placeholder={text}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
@@ -201,8 +219,12 @@ export function PanelSearch({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onChange("");
|
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 && (
|
{value && (
|
||||||
<button
|
<button
|
||||||
@@ -211,7 +233,7 @@ export function PanelSearch({
|
|||||||
title={t("panel.clearSearch")}
|
title={t("panel.clearSearch")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange("");
|
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"
|
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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function SettingRow({
|
|||||||
{/* In a gap-4 form stack the gap already spaces the row, so its own
|
{/* In a gap-4 form stack the gap already spaces the row, so its own
|
||||||
padding and divider would double up. */}
|
padding and divider would double up. */}
|
||||||
<div
|
<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"
|
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">
|
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function Separator({
|
|||||||
decorative={decorative}
|
decorative={decorative}
|
||||||
orientation={orientation}
|
orientation={orientation}
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -41,13 +41,14 @@ export function TabStrip({
|
|||||||
left: el.scrollLeft > 1,
|
left: el.scrollLeft > 1,
|
||||||
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
|
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
|
||||||
});
|
});
|
||||||
const observer = new ResizeObserver(update);
|
const observer =
|
||||||
observer.observe(el);
|
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update);
|
||||||
if (el.firstElementChild) observer.observe(el.firstElementChild);
|
observer?.observe(el);
|
||||||
|
if (el.firstElementChild) observer?.observe(el.firstElementChild);
|
||||||
el.addEventListener("scroll", update);
|
el.addEventListener("scroll", update);
|
||||||
update();
|
update();
|
||||||
return () => {
|
return () => {
|
||||||
observer.disconnect();
|
observer?.disconnect();
|
||||||
el.removeEventListener("scroll", update);
|
el.removeEventListener("scroll", update);
|
||||||
};
|
};
|
||||||
}, [tabs]);
|
}, [tabs]);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface VersionBadgeProps {
|
interface VersionBadgeProps {
|
||||||
status: "up_to_date" | "requires_update" | "beta" | "unknown";
|
status: "up_to_date" | "requires_update" | "beta" | "unknown";
|
||||||
@@ -14,13 +15,15 @@ export function VersionBadge({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (status === "unknown") return null;
|
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"
|
status === "beta"
|
||||||
? "bg-blue-500/20 text-blue-400"
|
? "bg-blue-500/20 text-blue-400"
|
||||||
: status === "requires_update"
|
: status === "requires_update"
|
||||||
? "bg-warning/20 text-warning"
|
? "bg-warning/20 text-warning"
|
||||||
: "bg-accent-brand/20 text-accent-brand"
|
: "bg-accent-brand/20 text-accent-brand",
|
||||||
}${className ? ` ${className}` : ""}`;
|
className,
|
||||||
|
);
|
||||||
|
|
||||||
const label =
|
const label =
|
||||||
status === "beta"
|
status === "beta"
|
||||||
@@ -42,7 +45,7 @@ export function VersionBadge({
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
title={linkLabel}
|
title={linkLabel}
|
||||||
aria-label={linkLabel}
|
aria-label={linkLabel}
|
||||||
className={`${badgeClassName} cursor-pointer hover:underline`}
|
className={cn(badgeClassName, "cursor-pointer hover:underline")}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -828,6 +828,43 @@ export function DashboardTab({
|
|||||||
scroll={false}
|
scroll={false}
|
||||||
actions={
|
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">
|
<span className="hidden items-center gap-0.5 xl:flex">
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
<a
|
<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 ? (
|
{viewPending ? (
|
||||||
<div className="flex flex-1 flex-col gap-2 p-2.5">
|
<div className="flex flex-1 flex-col gap-2 p-2.5">
|
||||||
|
|||||||
@@ -30,17 +30,23 @@ export type VersionStatus =
|
|||||||
|
|
||||||
function Stat({
|
function Stat({
|
||||||
label,
|
label,
|
||||||
|
aside,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
|
/** Sits beside the label, like the release channel. */
|
||||||
|
aside?: React.ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 flex-col justify-center gap-1 px-3 py-2">
|
<div className="flex min-w-0 flex-col justify-center gap-1 px-3 py-2">
|
||||||
{children}
|
{children}
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
<span className="truncate text-[10px] text-muted-foreground">
|
<span className="truncate text-[10px] text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
|
{aside}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,15 +71,19 @@ export function StatsStrip({
|
|||||||
const big = "text-2xl font-bold leading-none tracking-tight";
|
const big = "text-2xl font-bold leading-none tracking-tight";
|
||||||
return (
|
return (
|
||||||
<div className="grid h-full grid-cols-2 divide-x divide-border md:grid-cols-4">
|
<div className="grid h-full grid-cols-2 divide-x divide-border md:grid-cols-4">
|
||||||
<Stat label={t("dashboard.version")}>
|
<Stat
|
||||||
<div className="flex flex-wrap items-baseline gap-1.5">
|
label={t("dashboard.version")}
|
||||||
<span className={big}>{versionText || "-"}</span>
|
aside={
|
||||||
<VersionBadge
|
<VersionBadge
|
||||||
status={versionStatus}
|
status={versionStatus}
|
||||||
releaseUrl={releaseUrl}
|
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>
|
||||||
<Stat label={t("dashboard.uptime")}>
|
<Stat label={t("dashboard.uptime")}>
|
||||||
<span className={big}>{uptimeFormatted || "-"}</span>
|
<span className={big}>{uptimeFormatted || "-"}</span>
|
||||||
@@ -161,7 +171,7 @@ function ActionButton({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
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">
|
<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" />
|
<Icon className="size-3 text-accent-brand" />
|
||||||
@@ -199,8 +209,8 @@ export function QuickActions({
|
|||||||
return `${host.ip}:${port ?? host.port}`;
|
return `${host.ip}:${port ?? host.port}`;
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex min-h-full flex-1 flex-col">
|
||||||
<div className="grid grid-cols-1 gap-1 p-2 sm:grid-cols-2">
|
<div className="grid flex-1 auto-rows-fr grid-cols-1 gap-1 p-2 sm:grid-cols-2">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
icon={Plus}
|
icon={Plus}
|
||||||
label={t("dashboard.addHost")}
|
label={t("dashboard.addHost")}
|
||||||
|
|||||||
@@ -815,3 +815,13 @@ html.fs-xl {
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
transition: outline-color 1.2s ease;
|
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
@@ -195,7 +195,8 @@
|
|||||||
"sidebar": "Sidebar",
|
"sidebar": "Sidebar",
|
||||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||||
"toggleSidebar": "Toggle Sidebar",
|
"toggleSidebar": "Toggle Sidebar",
|
||||||
"search": "Search..."
|
"search": "Search...",
|
||||||
|
"new": "New"
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"stepCounter": "Step {{current}} of {{total}}",
|
"stepCounter": "Step {{current}} of {{total}}",
|
||||||
@@ -327,6 +328,9 @@
|
|||||||
"search": "Search",
|
"search": "Search",
|
||||||
"hiddenCount": "{{count}} hidden",
|
"hiddenCount": "{{count}} hidden",
|
||||||
"showInRail": "Show in sidebar",
|
"showInRail": "Show in sidebar",
|
||||||
|
"moveUp": "Move up",
|
||||||
|
"moveDown": "Move down",
|
||||||
|
"resetRailOrder": "Reset order",
|
||||||
"hideFromRail": "Hide from sidebar",
|
"hideFromRail": "Hide from sidebar",
|
||||||
"openSettings": "Open settings",
|
"openSettings": "Open settings",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
@@ -632,6 +636,8 @@
|
|||||||
"customizeSidebarDescription": "Choose how the host list looks and behaves.",
|
"customizeSidebarDescription": "Choose how the host list looks and behaves.",
|
||||||
"customizeDensityTitle": "Density",
|
"customizeDensityTitle": "Density",
|
||||||
"customizeBehaviorTitle": "Behavior",
|
"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.",
|
"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.",
|
"densityComfortableDesc": "More detail per host: address, tags, and resource usage.",
|
||||||
"densityCompactDesc": "One line per host, for longer lists.",
|
"densityCompactDesc": "One line per host, for longer lists.",
|
||||||
@@ -1564,6 +1570,8 @@
|
|||||||
"newUi": {
|
"newUi": {
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"quickConnect": {
|
"quickConnect": {
|
||||||
|
"where": "Where",
|
||||||
|
"who": "Who",
|
||||||
"hostLabel": "Host",
|
"hostLabel": "Host",
|
||||||
"hostPlaceholder": "192.168.1.1 or example.com",
|
"hostPlaceholder": "192.168.1.1 or example.com",
|
||||||
"portLabel": "Port",
|
"portLabel": "Port",
|
||||||
@@ -1631,10 +1639,6 @@
|
|||||||
"resetAllConfirm": "Reset every keybinding to its default?"
|
"resetAllConfirm": "Reset every keybinding to its default?"
|
||||||
},
|
},
|
||||||
"userProfile": {
|
"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",
|
"storageModeLocal": "Browser",
|
||||||
"storageModeCloud": "Database",
|
"storageModeCloud": "Database",
|
||||||
"storageModeDescription": "Browser stores settings in this browser only. Database syncs to the server and loads on any device.",
|
"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.",
|
"noResults": "No settings match.",
|
||||||
"pages": "Pages",
|
"pages": "Pages",
|
||||||
"instanceSettings": "Server settings",
|
"instanceSettings": "Server settings",
|
||||||
"nothingHere": "Nothing to set here."
|
"nothingHere": "Nothing to set here.",
|
||||||
|
"searchPage": "Search this page",
|
||||||
|
"noPageMatches": "Nothing on this page matches."
|
||||||
},
|
},
|
||||||
"managerCard": {
|
"managerCard": {
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
@@ -2345,6 +2351,8 @@
|
|||||||
"revoke": "Remove access"
|
"revoke": "Remove access"
|
||||||
},
|
},
|
||||||
"manage": {
|
"manage": {
|
||||||
|
"searchSettings": "Search settings",
|
||||||
|
"noMatchingSettings": "No settings match.",
|
||||||
"sections": "Sections",
|
"sections": "Sections",
|
||||||
"sectionNeedsAttention": "Something here needs attention",
|
"sectionNeedsAttention": "Something here needs attention",
|
||||||
"needsAttention_one": "One thing needs attention.",
|
"needsAttention_one": "One thing needs attention.",
|
||||||
|
|||||||
+101
-15
@@ -1,6 +1,9 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import {
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
@@ -9,10 +12,16 @@ import {
|
|||||||
type RefObject,
|
type RefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { TabStrip } from "@/components/tab-strip";
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
import { HIDDEN_ATTR, useSettingsPageFilter } from "@/settings/page-filter";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** True while the editor is searched, so every lazy section mounts. */
|
||||||
|
const SearchingContext = createContext(false);
|
||||||
|
|
||||||
export interface EditorNavItem {
|
export interface EditorNavItem {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -27,6 +36,25 @@ export interface EditorNavBand {
|
|||||||
items: EditorNavItem[];
|
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. */
|
/** Which section is in view, and a way to jump to one. */
|
||||||
export function useScrollSpy(
|
export function useScrollSpy(
|
||||||
containerRef: RefObject<HTMLElement | null>,
|
containerRef: RefObject<HTMLElement | null>,
|
||||||
@@ -42,7 +70,8 @@ export function useScrollSpy(
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
const onScroll = () => {
|
const onScroll = () => {
|
||||||
if (Date.now() < lockUntil.current) return;
|
if (Date.now() < lockUntil.current) return;
|
||||||
const top = container.getBoundingClientRect().top;
|
const area = scroller(container);
|
||||||
|
const top = area.getBoundingClientRect().top;
|
||||||
let current = ids[0] ?? "";
|
let current = ids[0] ?? "";
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const el = container.querySelector<HTMLElement>(
|
const el = container.querySelector<HTMLElement>(
|
||||||
@@ -52,17 +81,21 @@ export function useScrollSpy(
|
|||||||
if (el.getBoundingClientRect().top - top <= 24) current = id;
|
if (el.getBoundingClientRect().top - top <= 24) current = id;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
container.scrollHeight > container.clientHeight &&
|
area.scrollHeight > area.clientHeight &&
|
||||||
container.scrollTop + container.clientHeight >=
|
area.scrollTop + area.clientHeight >= area.scrollHeight - 8
|
||||||
container.scrollHeight - 8
|
|
||||||
) {
|
) {
|
||||||
current = ids[ids.length - 1] ?? current;
|
current = ids[ids.length - 1] ?? current;
|
||||||
}
|
}
|
||||||
setActive(current);
|
setActive(current);
|
||||||
};
|
};
|
||||||
onScroll();
|
onScroll();
|
||||||
container.addEventListener("scroll", onScroll, { passive: true });
|
// Captured on the document so an outer scroller is heard too.
|
||||||
return () => container.removeEventListener("scroll", onScroll);
|
document.addEventListener("scroll", onScroll, {
|
||||||
|
passive: true,
|
||||||
|
capture: true,
|
||||||
|
});
|
||||||
|
return () =>
|
||||||
|
document.removeEventListener("scroll", onScroll, { capture: true });
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [containerRef, idsKey]);
|
}, [containerRef, idsKey]);
|
||||||
|
|
||||||
@@ -76,11 +109,12 @@ export function useScrollSpy(
|
|||||||
if (!container || !el) return;
|
if (!container || !el) return;
|
||||||
lockUntil.current = Date.now() + 600;
|
lockUntil.current = Date.now() + 600;
|
||||||
setActive(id);
|
setActive(id);
|
||||||
container.scrollTo?.({
|
const area = scroller(container);
|
||||||
|
area.scrollTo?.({
|
||||||
top:
|
top:
|
||||||
el.getBoundingClientRect().top -
|
el.getBoundingClientRect().top -
|
||||||
container.getBoundingClientRect().top +
|
area.getBoundingClientRect().top +
|
||||||
container.scrollTop -
|
area.scrollTop -
|
||||||
8,
|
8,
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
@@ -114,9 +148,13 @@ export function EditorSection({
|
|||||||
*/
|
*/
|
||||||
export function LazySection({ children }: { children: ReactNode }) {
|
export function LazySection({ children }: { children: ReactNode }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const searching = useContext(SearchingContext);
|
||||||
const [shown, setShown] = useState(
|
const [shown, setShown] = useState(
|
||||||
() => typeof IntersectionObserver === "undefined",
|
() => typeof IntersectionObserver === "undefined",
|
||||||
);
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (searching) setShown(true);
|
||||||
|
}, [searching]);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (shown) return;
|
if (shown) return;
|
||||||
const el = ref.current;
|
const el = ref.current;
|
||||||
@@ -167,9 +205,39 @@ export function EditorPane({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
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 { active, scrollTo } = useScrollSpy(scrollRef, ids);
|
||||||
const showNav = ids.length > 1;
|
const showNav = bands.flatMap((band) => band.items).length > 1;
|
||||||
if (revealRef) revealRef.current = scrollTo;
|
if (revealRef) revealRef.current = scrollTo;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -179,7 +247,7 @@ export function EditorPane({
|
|||||||
aria-label={t("manage.sections")}
|
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"
|
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">
|
<div key={band.id} className="flex flex-col">
|
||||||
<GroupHeading title={band.label} className="px-2.5 pb-1.5" />
|
<GroupHeading title={band.label} className="px-2.5 pb-1.5" />
|
||||||
{band.items.map((item) => {
|
{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="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 && (
|
{showNav && (
|
||||||
<div className="shrink-0 border-b border-border px-1 lg:hidden">
|
<div className="shrink-0 border-b border-border px-1 lg:hidden">
|
||||||
<TabStrip
|
<TabStrip
|
||||||
tabs={bands.flatMap((band) =>
|
tabs={visibleBands.flatMap((band) =>
|
||||||
band.items.map((item) => ({ id: item.id, label: item.label })),
|
band.items.map((item) => ({ id: item.id, label: item.label })),
|
||||||
)}
|
)}
|
||||||
activeTab={active}
|
activeTab={active}
|
||||||
@@ -235,9 +313,17 @@ export function EditorPane({
|
|||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
|
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}
|
{banner}
|
||||||
|
<SearchingContext.Provider value={searching}>
|
||||||
{children}
|
{children}
|
||||||
|
</SearchingContext.Provider>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(footer || errorCount > 0) && (
|
{(footer || errorCount > 0) && (
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { EmptyState } from "@/components/empty-state";
|
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 { cn } from "@/lib/utils";
|
||||||
import { rem } from "@/lib/rem";
|
import { rem } from "@/lib/rem";
|
||||||
import type { Credential, Host } from "@/types/ui-types";
|
import type { Credential, Host } from "@/types/ui-types";
|
||||||
@@ -143,24 +143,69 @@ export function ManageList({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
editing ? "hidden md:flex" : "flex",
|
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">
|
<nav
|
||||||
<Segmented
|
aria-label={t("manage.sections")}
|
||||||
value={mode}
|
className="flex shrink-0 flex-col border-b border-border py-1"
|
||||||
onChange={onMode}
|
>
|
||||||
options={[
|
{(
|
||||||
{ value: "hosts", label: t("nav.hosts"), count: hosts.length },
|
[
|
||||||
|
{
|
||||||
|
value: "hosts",
|
||||||
|
label: t("nav.hosts"),
|
||||||
|
icon: Server,
|
||||||
|
count: hosts.length,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "credentials",
|
value: "credentials",
|
||||||
label: t("nav.credentials"),
|
label: t("nav.credentials"),
|
||||||
|
icon: KeyRound,
|
||||||
count: credentials.length,
|
count: credentials.length,
|
||||||
},
|
},
|
||||||
{ value: "defaults", label: t("manage.defaults") },
|
{
|
||||||
]}
|
value: "defaults",
|
||||||
className="w-full [&>button]:flex-1"
|
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">
|
<div className="flex items-center gap-2">
|
||||||
<PanelSearch
|
<PanelSearch
|
||||||
value={query}
|
value={query}
|
||||||
|
|||||||
@@ -289,6 +289,16 @@ export {
|
|||||||
type PanelDensity,
|
type PanelDensity,
|
||||||
type PanelViewMode,
|
type PanelViewMode,
|
||||||
} from "@/components/panel-layout";
|
} from "@/components/panel-layout";
|
||||||
|
export {
|
||||||
|
AddButton,
|
||||||
|
FormFooter,
|
||||||
|
ListBadge,
|
||||||
|
ListRow,
|
||||||
|
ListRowAction,
|
||||||
|
ListRowFolder,
|
||||||
|
PanelList,
|
||||||
|
type ListRowTone,
|
||||||
|
} from "@/components/list-kit";
|
||||||
export {
|
export {
|
||||||
DataView,
|
DataView,
|
||||||
type DataColumn,
|
type DataColumn,
|
||||||
|
|||||||
@@ -64,7 +64,12 @@ export function PluginConsentPrompt({
|
|||||||
<Button variant="ghost" onClick={onCancel} disabled={busy}>
|
<Button variant="ghost" onClick={onCancel} disabled={busy}>
|
||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</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"
|
{mode === "update"
|
||||||
? t("plugins.manager.consent.agreeUpdate")
|
? t("plugins.manager.consent.agreeUpdate")
|
||||||
: t("plugins.manager.install")}
|
: t("plugins.manager.install")}
|
||||||
|
|||||||
@@ -98,8 +98,9 @@ export function PluginDetail({
|
|||||||
plugin.updateAvailable &&
|
plugin.updateAvailable &&
|
||||||
!plugin.pinnedVersion && (
|
!plugin.pinnedVersion && (
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
size="sm"
|
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}
|
disabled={busy || locked}
|
||||||
onClick={() => void manager.requestUpdate(plugin)}
|
onClick={() => void manager.requestUpdate(plugin)}
|
||||||
>
|
>
|
||||||
@@ -148,8 +149,9 @@ export function PluginDetail({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
size="sm"
|
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}
|
disabled={busy || locked || !plugin.latestVersion}
|
||||||
onClick={() => manager.requestInstall(plugin)}
|
onClick={() => manager.requestInstall(plugin)}
|
||||||
>
|
>
|
||||||
@@ -446,8 +448,8 @@ function DataCard({
|
|||||||
{t("plugins.manager.loading")}
|
{t("plugins.manager.loading")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col">
|
<div className="-mx-3 -my-1 flex flex-col md:-mx-4">
|
||||||
<div className="grid grid-cols-2 gap-px border-b border-border bg-border sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-px bg-border sm:grid-cols-4">
|
||||||
<Stat
|
<Stat
|
||||||
label={t("plugins.manager.data.tables")}
|
label={t("plugins.manager.data.tables")}
|
||||||
value={`${data.tables.length}`}
|
value={`${data.tables.length}`}
|
||||||
@@ -470,7 +472,7 @@ function DataCard({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{data.tables.length > 0 && (
|
{data.tables.length > 0 && (
|
||||||
<div className="divide-y divide-border">
|
<div className="divide-y divide-border border-t border-border">
|
||||||
{data.tables.map((table) => (
|
{data.tables.map((table) => (
|
||||||
<div
|
<div
|
||||||
key={table.name}
|
key={table.name}
|
||||||
|
|||||||
@@ -472,8 +472,9 @@ function InstalledCard({
|
|||||||
<div className="flex items-center gap-1.5 border-t border-border px-3 py-2">
|
<div className="flex items-center gap-1.5 border-t border-border px-3 py-2">
|
||||||
{plugin.updateAvailable && !plugin.pinnedVersion && !failed && (
|
{plugin.updateAvailable && !plugin.pinnedVersion && !failed && (
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
size="xs"
|
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}
|
disabled={busy || locked}
|
||||||
onClick={() => void manager.requestUpdate(plugin)}
|
onClick={() => void manager.requestUpdate(plugin)}
|
||||||
>
|
>
|
||||||
@@ -651,9 +652,11 @@ function BrowseCard({
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
disabled={busy || manager.managedByServer || !plugin.latestVersion}
|
disabled={busy || manager.managedByServer || !plugin.latestVersion}
|
||||||
onClick={() => manager.requestInstall(plugin)}
|
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")}
|
{t("plugins.manager.install")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -706,8 +709,9 @@ function UpdatesSection({
|
|||||||
` ${t("plugins.manager.updatesNeedReview", { count: gated.length })}`}
|
` ${t("plugins.manager.updatesNeedReview", { count: gated.length })}`}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
size="sm"
|
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}
|
disabled={manager.busy.size > 0 || manager.managedByServer}
|
||||||
onClick={() => void manager.updateAll()}
|
onClick={() => void manager.updateAll()}
|
||||||
>
|
>
|
||||||
@@ -802,9 +806,11 @@ function UpdateRow({
|
|||||||
</label>
|
</label>
|
||||||
{!plugin.pinnedVersion && (
|
{!plugin.pinnedVersion && (
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
size="xs"
|
size="xs"
|
||||||
disabled={busy || manager.managedByServer}
|
disabled={busy || manager.managedByServer}
|
||||||
onClick={() => void manager.requestUpdate(plugin)}
|
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
|
{gated
|
||||||
? t("plugins.manager.review")
|
? t("plugins.manager.review")
|
||||||
|
|||||||
@@ -233,8 +233,9 @@ export function FeatureSettingsForm({
|
|||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
size="sm"
|
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}
|
disabled={!dirty || saving}
|
||||||
onClick={() => void save()}
|
onClick={() => void save()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Select2 } from "@/components/select2";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -453,7 +454,7 @@ export function KeybindingsSettings() {
|
|||||||
<label className="text-xs font-semibold">
|
<label className="text-xs font-semibold">
|
||||||
{t("newUi.sidebar.keybindings.actionLabel")}
|
{t("newUi.sidebar.keybindings.actionLabel")}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<Select2
|
||||||
value={draft.type}
|
value={draft.type}
|
||||||
onChange={(e) => setDraft({ type: e.target.value })}
|
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"
|
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)}
|
{t(action.labelKey)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{Editor && <Editor action={draft} onChange={setDraft} />}
|
{Editor && <Editor action={draft} onChange={setDraft} />}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronDown, Settings as SettingsIcon } from "lucide-react";
|
import { ChevronDown, Search, Settings as SettingsIcon } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
GroupHeading,
|
GroupHeading,
|
||||||
PANEL,
|
PANEL,
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from "./FeatureSettingsSections";
|
} from "./FeatureSettingsSections";
|
||||||
import { KeybindingsSettings } from "./KeybindingsSettings";
|
import { KeybindingsSettings } from "./KeybindingsSettings";
|
||||||
import { SettingsEmbedContext } from "./settings-embed";
|
import { SettingsEmbedContext } from "./settings-embed";
|
||||||
|
import { useSettingsPageFilter } from "./page-filter";
|
||||||
import {
|
import {
|
||||||
CORE_SETTINGS_PAGES,
|
CORE_SETTINGS_PAGES,
|
||||||
SETTINGS_BAND_ORDER,
|
SETTINGS_BAND_ORDER,
|
||||||
@@ -35,6 +36,8 @@ import {
|
|||||||
export interface SettingsTabProps {
|
export interface SettingsTabProps {
|
||||||
/** The page to open, from the tab's data. */
|
/** The page to open, from the tab's data. */
|
||||||
section?: string;
|
section?: string;
|
||||||
|
/** An element id on that page to scroll to, as "id@nonce". */
|
||||||
|
reveal?: string;
|
||||||
username: string;
|
username: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
@@ -76,6 +79,7 @@ function revealText(container: HTMLElement, needle: string): boolean {
|
|||||||
/** Everything that configures the app, one page at a time. */
|
/** Everything that configures the app, one page at a time. */
|
||||||
export function SettingsTab({
|
export function SettingsTab({
|
||||||
section,
|
section,
|
||||||
|
reveal,
|
||||||
username,
|
username,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
onLogout,
|
onLogout,
|
||||||
@@ -88,7 +92,9 @@ export function SettingsTab({
|
|||||||
const adminFeatures = useFeatureSettings("admin");
|
const adminFeatures = useFeatureSettings("admin");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [navOpen, setNavOpen] = useState(false);
|
const [navOpen, setNavOpen] = useState(false);
|
||||||
|
const [pageQuery, setPageQuery] = useState("");
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pageRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const pages = useMemo(() => {
|
const pages = useMemo(() => {
|
||||||
const plugins = new Map<string, SettingsPage>();
|
const plugins = new Map<string, SettingsPage>();
|
||||||
@@ -130,15 +136,42 @@ export function SettingsTab({
|
|||||||
if (section) setCurrent(section);
|
if (section) setCurrent(section);
|
||||||
}, [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 =
|
const page =
|
||||||
pages.find((p) => p.id === current) ??
|
pages.find((p) => p.id === current) ??
|
||||||
pages.find((p) => p.id === "account")!;
|
pages.find((p) => p.id === "account")!;
|
||||||
|
const pageHasMatches = useSettingsPageFilter(pageRef, pageQuery, page.id);
|
||||||
const hits = searchSettingsPages(pages, query, (key) => t(key));
|
const hits = searchSettingsPages(pages, query, (key) => t(key));
|
||||||
const pageLabel = (p: SettingsPage) => p.label ?? t(p.labelKey);
|
const pageLabel = (p: SettingsPage) => p.label ?? t(p.labelKey);
|
||||||
|
|
||||||
const go = (id: string) => {
|
const go = (id: string) => {
|
||||||
setCurrent(id);
|
setCurrent(id);
|
||||||
setNavOpen(false);
|
setNavOpen(false);
|
||||||
|
setPageQuery("");
|
||||||
contentRef.current?.scrollTo?.({ top: 0 });
|
contentRef.current?.scrollTo?.({ top: 0 });
|
||||||
const needle = query.trim();
|
const needle = query.trim();
|
||||||
if (!needle) return;
|
if (!needle) return;
|
||||||
@@ -261,7 +294,7 @@ export function SettingsTab({
|
|||||||
<nav
|
<nav
|
||||||
aria-label={t("settings.pages")}
|
aria-label={t("settings.pages")}
|
||||||
className={cn(
|
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",
|
navOpen ? "absolute inset-0 z-20 block" : "hidden",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -269,11 +302,25 @@ export function SettingsTab({
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<SurfaceScope kind="tab" className="min-w-0">
|
<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
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
|
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
|
||||||
>
|
>
|
||||||
|
{!pageHasMatches && (
|
||||||
|
<EmptyState icon={Search} title={t("settings.noPageMatches")} />
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
|
ref={pageRef}
|
||||||
className={`mx-auto flex w-full max-w-3xl flex-col ${PANEL.gap} pb-6`}
|
className={`mx-auto flex w-full max-w-3xl flex-col ${PANEL.gap} pb-6`}
|
||||||
>
|
>
|
||||||
<SettingsEmbedContext.Provider value>
|
<SettingsEmbedContext.Provider value>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -348,8 +348,9 @@ export function LinkServerDialog({
|
|||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
type="submit"
|
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()}
|
disabled={checking || !target.serverUrl.trim()}
|
||||||
>
|
>
|
||||||
{checking && <Loader2 className="size-4 animate-spin" />}
|
{checking && <Loader2 className="size-4 animate-spin" />}
|
||||||
@@ -420,8 +421,9 @@ export function LinkServerDialog({
|
|||||||
{t("common.cancel")}
|
{t("common.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
type="button"
|
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}
|
disabled={!token}
|
||||||
onClick={() => token && void finish(token, mode)}
|
onClick={() => token && void finish(token, mode)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -198,7 +198,8 @@ export function SyncPanel() {
|
|||||||
<li>{t("sync.unlinkedPointAccount")}</li>
|
<li>{t("sync.unlinkedPointAccount")}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<Button
|
<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)}
|
onClick={() => setLinkOpen(true)}
|
||||||
>
|
>
|
||||||
<Cloud className="size-4" />
|
<Cloud className="size-4" />
|
||||||
@@ -302,8 +303,9 @@ export function SyncPanel() {
|
|||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{state === "signed_out" ? (
|
{state === "signed_out" ? (
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
size="sm"
|
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)}
|
onClick={() => setReloginOpen(true)}
|
||||||
>
|
>
|
||||||
{t("sync.signInAgain")}
|
{t("sync.signInAgain")}
|
||||||
@@ -535,8 +537,9 @@ export function SyncPanel() {
|
|||||||
</div>
|
</div>
|
||||||
{proxyDraft && (
|
{proxyDraft && (
|
||||||
<Button
|
<Button
|
||||||
|
variant="outline"
|
||||||
size="sm"
|
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"}
|
disabled={busy === "proxy"}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
run("proxy", async () => {
|
run("proxy", async () => {
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export function CommandPalette({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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)}
|
onClick={() => setIsOpen(false)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -270,7 +270,7 @@ export function CommandPalette({
|
|||||||
<Kbd className="ml-2">ESC</Kbd>
|
<Kbd className="ml-2">ESC</Kbd>
|
||||||
</div>
|
</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">
|
<CommandEmpty className="py-8 text-center text-xs text-muted-foreground">
|
||||||
{t("commandPalette.noHostsFound", { search })}
|
{t("commandPalette.noHostsFound", { search })}
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
|
|||||||
@@ -573,7 +573,7 @@ export function TabBar({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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" />
|
<ChevronDown className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -632,7 +632,7 @@ export function TabBar({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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")}
|
title={t("nav.toggleRightDock")}
|
||||||
aria-label={t("nav.toggleRightDock")}
|
aria-label={t("nav.toggleRightDock")}
|
||||||
aria-pressed={!!rightDockOpen}
|
aria-pressed={!!rightDockOpen}
|
||||||
@@ -648,7 +648,7 @@ export function TabBar({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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={
|
title={
|
||||||
isAppFullscreen
|
isAppFullscreen
|
||||||
? t("nav.exitFullscreen")
|
? t("nav.exitFullscreen")
|
||||||
@@ -673,7 +673,7 @@ export function TabBar({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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)}
|
onClick={() => setOpen((o) => !o)}
|
||||||
>
|
>
|
||||||
<ChevronUp
|
<ChevronUp
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export function SplitLayoutMenu({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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")}
|
title={t("splitScreen.title")}
|
||||||
aria-label={t("splitScreen.title")}
|
aria-label={t("splitScreen.title")}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
import {
|
||||||
|
requestFromLegacyEvent,
|
||||||
|
requestManage,
|
||||||
|
} from "@/manage/manage-requests";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LayoutPanelLeft,
|
LayoutPanelLeft,
|
||||||
@@ -178,7 +182,14 @@ const DashboardTabHost = memo(function DashboardTabHost({
|
|||||||
}) {
|
}) {
|
||||||
return withTabSuspense(
|
return withTabSuspense(
|
||||||
<DashboardTab
|
<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)}
|
onOpenTab={(host, type) => shell.openTab(host, type)}
|
||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
/>,
|
/>,
|
||||||
@@ -255,6 +266,11 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
|
|||||||
? tab.data.section
|
? tab.data.section
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
reveal={
|
||||||
|
typeof tab.data?.reveal === "string"
|
||||||
|
? `${tab.data.reveal}@${String(tab.data.revealAt ?? "")}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>,
|
/>,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { pluginKey } from "@/lib/plugin-i18n";
|
|||||||
import { PluginIcon } from "@/lib/plugin-icon";
|
import { PluginIcon } from "@/lib/plugin-icon";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { Input } from "@/components/input";
|
import { Input } from "@/components/input";
|
||||||
|
import { PanelSearch } from "@/components/panel-layout";
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Check,
|
Check,
|
||||||
@@ -151,11 +152,11 @@ export function AdminUsersSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="py-2 border-b border-border">
|
<div className="py-2 border-b border-border">
|
||||||
<Input
|
<PanelSearch
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={onSearchChange}
|
||||||
placeholder={t("admin.searchUsers")}
|
placeholder={t("admin.searchUsers")}
|
||||||
className="h-7 text-xs rounded-none"
|
fill
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{users.map((user) => {
|
{users.map((user) => {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Select2 } from "@/components/select2";
|
||||||
import { useConfirm } from "@/components/surface/surface-scope";
|
import { useConfirm } from "@/components/surface/surface-scope";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { hostProtocolFlags, type HostProtocols } from "./host-protocols";
|
import { hostProtocolFlags, type HostProtocols } from "./host-protocols";
|
||||||
@@ -943,7 +944,7 @@ export function AdminUserManagePanel({
|
|||||||
</span>
|
</span>
|
||||||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||||||
{t("admin.deleteSuccessorLabel")}
|
{t("admin.deleteSuccessorLabel")}
|
||||||
<select
|
<Select2
|
||||||
value={successor}
|
value={successor}
|
||||||
onChange={(e) => setSuccessor(e.target.value)}
|
onChange={(e) => setSuccessor(e.target.value)}
|
||||||
className="h-7 border border-border bg-background px-2 text-xs text-foreground outline-none"
|
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">
|
<option value="none">
|
||||||
{t("admin.deleteSuccessorNone")}
|
{t("admin.deleteSuccessorNone")}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</Select2>
|
||||||
<span>{t("admin.deleteSuccessorDesc")}</span>
|
<span>{t("admin.deleteSuccessorDesc")}</span>
|
||||||
</label>
|
</label>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ import { Skeleton } from "@/components/skeleton";
|
|||||||
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
||||||
import {
|
import {
|
||||||
groupRailItems,
|
groupRailItems,
|
||||||
|
moveRailItem,
|
||||||
useRailItems,
|
useRailItems,
|
||||||
type RailGroup,
|
type RailGroup,
|
||||||
type RailItemDef,
|
type RailItemDef,
|
||||||
} from "./rail-items";
|
} from "./rail-items";
|
||||||
|
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||||
import { toggleHiddenRailTab, useHiddenRailTabs } from "./hidden-rail-tabs";
|
import { toggleHiddenRailTab, useHiddenRailTabs } from "./hidden-rail-tabs";
|
||||||
import { RailBadge } from "./RailBadge";
|
import { RailBadge } from "./RailBadge";
|
||||||
import { rem } from "@/lib/rem";
|
import { rem } from "@/lib/rem";
|
||||||
@@ -200,6 +202,9 @@ export function AppRail({
|
|||||||
|
|
||||||
// Plugins add and remove rail items at runtime, and permissions hide some.
|
// Plugins add and remove rail items at runtime, and permissions hide some.
|
||||||
const railItems = useRailItems();
|
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 railExpanded = pinned || (expandOnHover && hovered) || managing;
|
||||||
const mainItems = railItems.filter((item) => item.placement !== "footer");
|
const mainItems = railItems.filter((item) => item.placement !== "footer");
|
||||||
@@ -226,15 +231,48 @@ export function AppRail({
|
|||||||
setMenuPos(null);
|
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 renderItem = (item: RailItemDef) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const title = t(item.labelKey);
|
const title = t(item.labelKey);
|
||||||
const isTab = item.kind === "tab";
|
const isTab = item.kind === "tab";
|
||||||
const active = !isTab && sidebarOpen && railView === item.id;
|
const active = !isTab && sidebarOpen && railView === item.id;
|
||||||
|
const reorderable = item.placement !== "footer" && !!uiPrefs;
|
||||||
|
const dropHere =
|
||||||
|
dragId !== null && dropBefore === item.id && dragId !== item.id;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
type="button"
|
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) => {
|
onClick={(e) => {
|
||||||
if (isTab || (item.promotable && (e.ctrlKey || e.metaKey))) {
|
if (isTab || (item.promotable && (e.ctrlKey || e.metaKey))) {
|
||||||
onOpenTab?.(item.id as TabType);
|
onOpenTab?.(item.id as TabType);
|
||||||
@@ -260,7 +298,7 @@ export function AppRail({
|
|||||||
aria-current={active ? "page" : undefined}
|
aria-current={active ? "page" : undefined}
|
||||||
title={item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title}
|
title={item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title}
|
||||||
style={btnStyle}
|
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}>
|
<RailIcon useBadge={item.useBadge}>
|
||||||
<Icon size={16} />
|
<Icon size={16} />
|
||||||
|
|||||||
+100
-175
@@ -3,7 +3,7 @@ import { getTabType, isPersistentTabType } from "@/shell/tab-registry";
|
|||||||
import { ComponentSlot } from "@/shell/ActionSlot";
|
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 {
|
import {
|
||||||
getActiveSessions,
|
getActiveSessions,
|
||||||
deleteOpenTab,
|
deleteOpenTab,
|
||||||
@@ -13,14 +13,9 @@ import {
|
|||||||
import { tabIcon } from "@/shell/tabUtils";
|
import { tabIcon } from "@/shell/tabUtils";
|
||||||
import { getSessionTimeoutMinutes } from "@/api/open-tabs-api";
|
import { getSessionTimeoutMinutes } from "@/api/open-tabs-api";
|
||||||
import type { Tab, TabType } from "@/types/ui-types";
|
import type { Tab, TabType } from "@/types/ui-types";
|
||||||
import { Badge } from "@/components/badge";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { Input } from "@/components/input";
|
import { Facts, GroupHeading, PanelSearch } from "@/components/panel-layout";
|
||||||
import {
|
import { ListBadge, ListRow, ListRowAction } from "@/components/list-kit";
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/tooltip";
|
|
||||||
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
|
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
|
||||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
|
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
|
||||||
|
|
||||||
@@ -81,6 +76,7 @@ function ConnectionRow({
|
|||||||
hostName,
|
hostName,
|
||||||
subLabel,
|
subLabel,
|
||||||
icon,
|
icon,
|
||||||
|
stripe,
|
||||||
onSwitch,
|
onSwitch,
|
||||||
onClose,
|
onClose,
|
||||||
switchTitle,
|
switchTitle,
|
||||||
@@ -95,6 +91,7 @@ function ConnectionRow({
|
|||||||
hostName?: string;
|
hostName?: string;
|
||||||
subLabel: string;
|
subLabel: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
|
stripe: number;
|
||||||
onSwitch?: () => void;
|
onSwitch?: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
switchTitle?: string;
|
switchTitle?: string;
|
||||||
@@ -106,12 +103,6 @@ function ConnectionRow({
|
|||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [editValue, setEditValue] = useState(name);
|
const [editValue, setEditValue] = useState(name);
|
||||||
|
|
||||||
function startEdit(e: React.MouseEvent) {
|
|
||||||
e.stopPropagation();
|
|
||||||
setEditValue(name);
|
|
||||||
setEditing(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitEdit() {
|
function commitEdit() {
|
||||||
const trimmed = editValue.trim();
|
const trimmed = editValue.trim();
|
||||||
if (trimmed && trimmed !== name && onRename) {
|
if (trimmed && trimmed !== name && onRename) {
|
||||||
@@ -120,155 +111,103 @@ function ConnectionRow({
|
|||||||
setEditing(false);
|
setEditing(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
const registered = getTabType(tabType)?.titleKey;
|
||||||
if (e.key === "Enter") commitEdit();
|
const typeLabel =
|
||||||
if (e.key === "Escape") setEditing(false);
|
CORE_TYPE_LABELS[tabType] ?? (registered ? t(registered) : tabType);
|
||||||
}
|
const stop = (e: React.PointerEvent) => e.stopPropagation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ListRow
|
||||||
role={onSwitch && !editing ? "button" : undefined}
|
stripe={stripe}
|
||||||
tabIndex={onSwitch && !editing ? 0 : undefined}
|
tone={isLive ? "success" : "muted"}
|
||||||
|
selected={isActive}
|
||||||
|
active={editing}
|
||||||
|
dimmed={faded || isDragging}
|
||||||
onClick={!editing ? onSwitch : undefined}
|
onClick={!editing ? onSwitch : undefined}
|
||||||
onKeyDown={(e) => !editing && e.key === "Enter" && onSwitch?.()}
|
icon={icon}
|
||||||
className={`group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 transition-colors last:border-b-0 ${
|
title={
|
||||||
faded ? "opacity-60" : ""
|
editing ? (
|
||||||
} ${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"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{editing ? (
|
|
||||||
<input
|
<input
|
||||||
value={editValue}
|
value={editValue}
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
onBlur={commitEdit}
|
onBlur={commitEdit}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === "Enter") commitEdit();
|
||||||
|
if (e.key === "Escape") setEditing(false);
|
||||||
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={stop}
|
||||||
className="text-xs font-semibold flex-1 min-w-0 bg-transparent border-b border-accent-brand outline-none text-foreground"
|
className="w-full min-w-0 border-b border-accent-brand bg-transparent text-[13px] font-semibold text-foreground outline-none"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span
|
name
|
||||||
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"
|
|
||||||
>
|
|
||||||
{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 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 && (
|
badges={<ListBadge className="ml-auto font-mono">{typeLabel}</ListBadge>}
|
||||||
<Tooltip>
|
meta={
|
||||||
<TooltipTrigger asChild>
|
<Facts>
|
||||||
<button
|
{hostName && hostName !== name ? <span>{hostName}</span> : null}
|
||||||
onClick={(e) => {
|
<span>{subLabel}</span>
|
||||||
e.stopPropagation();
|
</Facts>
|
||||||
onSwitch();
|
}
|
||||||
}}
|
actions={
|
||||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 rounded transition-colors"
|
editing ? (
|
||||||
|
<ListRowAction
|
||||||
|
label={t("common.save")}
|
||||||
|
tone="brand"
|
||||||
|
onPointerDown={stop}
|
||||||
|
onClick={commitEdit}
|
||||||
>
|
>
|
||||||
<ExternalLink className="size-3" />
|
<Check />
|
||||||
</button>
|
</ListRowAction>
|
||||||
</TooltipTrigger>
|
) : (
|
||||||
<TooltipContent side="left">{switchTitle}</TooltipContent>
|
<>
|
||||||
</Tooltip>
|
{onRename && (
|
||||||
)}
|
<ListRowAction
|
||||||
{!editing && (
|
label={t("connections.rename")}
|
||||||
<button
|
onPointerDown={stop}
|
||||||
onClick={(e) => {
|
onClick={() => {
|
||||||
e.stopPropagation();
|
setEditValue(name);
|
||||||
onClose();
|
setEditing(true);
|
||||||
}}
|
}}
|
||||||
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" />
|
<Pencil />
|
||||||
</button>
|
</ListRowAction>
|
||||||
)}
|
)}
|
||||||
</div>
|
{switchTitle && onSwitch && (
|
||||||
</TooltipProvider>
|
<ListRowAction
|
||||||
</div>
|
label={switchTitle}
|
||||||
|
tone="brand"
|
||||||
|
onPointerDown={stop}
|
||||||
|
onClick={onSwitch}
|
||||||
|
>
|
||||||
|
<ExternalLink />
|
||||||
|
</ListRowAction>
|
||||||
|
)}
|
||||||
|
<ListRowAction
|
||||||
|
label={t("common.close")}
|
||||||
|
tone="destructive"
|
||||||
|
onPointerDown={stop}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</ListRowAction>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SectionHeader({ label, count }: { label: string; count: number }) {
|
function SectionHeader({ label, count }: { label: string; count: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/20">
|
<GroupHeading
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60 flex-1">
|
title={label}
|
||||||
{label}
|
count={count}
|
||||||
</span>
|
className="border-b border-border/40 px-3 pb-1.5 pt-2.5"
|
||||||
<span className="text-[10px] font-semibold text-muted-foreground/40 bg-muted/60 rounded px-1.5 py-0.5">
|
/>
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,42 +389,28 @@ export function ConnectionsPanel({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1">
|
||||||
{pluginSections}
|
{pluginSections}
|
||||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
|
<EmptyState
|
||||||
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
|
icon={Plug}
|
||||||
<Plug className="size-5 text-muted-foreground/30" />
|
title={t("connections.noConnections")}
|
||||||
</div>
|
hint={t("connections.noConnectionsDesc")}
|
||||||
<div className="flex flex-col gap-1">
|
className="flex-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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="relative px-3 py-2 border-b border-border/60">
|
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||||
<Search className="absolute left-5.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50 pointer-events-none" />
|
<PanelSearch
|
||||||
<Input
|
|
||||||
placeholder={t("connections.search")}
|
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={setSearch}
|
||||||
className="pl-8 h-7 text-xs"
|
placeholder={t("connections.search")}
|
||||||
|
fill
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!hasResults && (
|
{!hasResults && <EmptyState title={t("connections.noSearchResults")} />}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{filteredOpenTabs.length > 0 && (
|
{filteredOpenTabs.length > 0 && (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
@@ -493,7 +418,7 @@ export function ConnectionsPanel({
|
|||||||
label={t("connections.sectionOpen")}
|
label={t("connections.sectionOpen")}
|
||||||
count={filteredOpenTabs.length}
|
count={filteredOpenTabs.length}
|
||||||
/>
|
/>
|
||||||
{filteredOpenTabs.map((tab) => {
|
{filteredOpenTabs.map((tab, index) => {
|
||||||
const isActive = tab.id === activeTabId;
|
const isActive = tab.id === activeTabId;
|
||||||
const liveSession = tab.instanceId
|
const liveSession = tab.instanceId
|
||||||
? sessionByInstanceId.get(tab.instanceId)
|
? sessionByInstanceId.get(tab.instanceId)
|
||||||
@@ -524,7 +449,7 @@ export function ConnectionsPanel({
|
|||||||
didDragRef.current = false;
|
didDragRef.current = false;
|
||||||
setDragTabId(tab.id);
|
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={{
|
style={{
|
||||||
cursor: dragTabId
|
cursor: dragTabId
|
||||||
? isDraggingThis
|
? isDraggingThis
|
||||||
@@ -534,6 +459,7 @@ export function ConnectionsPanel({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ConnectionRow
|
<ConnectionRow
|
||||||
|
stripe={index}
|
||||||
isActive={isActive}
|
isActive={isActive}
|
||||||
isLive={isLive}
|
isLive={isLive}
|
||||||
tabType={tab.type}
|
tabType={tab.type}
|
||||||
@@ -565,9 +491,7 @@ export function ConnectionsPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredBackgroundTabs.length > 0 && (
|
{filteredBackgroundTabs.length > 0 && (
|
||||||
<div
|
<div className="flex flex-col">
|
||||||
className={`flex flex-col ${filteredOpenTabs.length > 0 ? "mt-2" : ""}`}
|
|
||||||
>
|
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
label={t("connections.sectionBackground")}
|
label={t("connections.sectionBackground")}
|
||||||
count={filteredBackgroundTabs.length}
|
count={filteredBackgroundTabs.length}
|
||||||
@@ -579,7 +503,7 @@ export function ConnectionsPanel({
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{filteredBackgroundTabs.map((record) => {
|
{filteredBackgroundTabs.map((record, index) => {
|
||||||
const host = record.hostId
|
const host = record.hostId
|
||||||
? allHosts.find((h) => h.id === String(record.hostId))
|
? allHosts.find((h) => h.id === String(record.hostId))
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -588,6 +512,7 @@ export function ConnectionsPanel({
|
|||||||
return (
|
return (
|
||||||
<ConnectionRow
|
<ConnectionRow
|
||||||
key={record.id}
|
key={record.id}
|
||||||
|
stripe={index}
|
||||||
isLive={false}
|
isLive={false}
|
||||||
faded
|
faded
|
||||||
tabType={record.tabType}
|
tabType={record.tabType}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Select2 } from "@/components/select2";
|
||||||
import { InlineView } from "@/components/surface/surface-scope";
|
import { InlineView } from "@/components/surface/surface-scope";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Rows3, SquareStack } from "lucide-react";
|
import { Rows3, SquareStack } from "lucide-react";
|
||||||
@@ -47,7 +48,7 @@ export function CustomizeCredentialsSidebarPanel({
|
|||||||
: t("credentials.densityCompactDesc")
|
: t("credentials.densityCompactDesc")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<select
|
<Select2
|
||||||
value={preferences.display.density}
|
value={preferences.display.density}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
update((prev) => ({
|
update((prev) => ({
|
||||||
@@ -66,7 +67,7 @@ export function CustomizeCredentialsSidebarPanel({
|
|||||||
<option value="compact">
|
<option value="compact">
|
||||||
{t("credentials.displayDensityCompact")}
|
{t("credentials.displayDensityCompact")}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</Select2>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
@@ -97,7 +98,7 @@ export function CustomizeCredentialsSidebarPanel({
|
|||||||
{t("credentials.actionsVisibilityDesc")}
|
{t("credentials.actionsVisibilityDesc")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<Select2
|
||||||
value={preferences.display.trayTrigger}
|
value={preferences.display.trayTrigger}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
update((prev) => ({
|
update((prev) => ({
|
||||||
@@ -116,7 +117,7 @@ export function CustomizeCredentialsSidebarPanel({
|
|||||||
</option>
|
</option>
|
||||||
<option value="hover">{t("credentials.actionsHover")}</option>
|
<option value="hover">{t("credentials.actionsHover")}</option>
|
||||||
<option value="click">{t("credentials.actionsClick")}</option>
|
<option value="click">{t("credentials.actionsClick")}</option>
|
||||||
</select>
|
</Select2>
|
||||||
</div>
|
</div>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import { Select2 } from "@/components/select2";
|
||||||
import { InlineView } from "@/components/surface/surface-scope";
|
import { InlineView } from "@/components/surface/surface-scope";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||||
import type {
|
import type {
|
||||||
HostClickBehavior,
|
HostClickBehavior,
|
||||||
@@ -25,6 +28,9 @@ export function CustomizeSidebarPanel({
|
|||||||
) => void;
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const actions = [...useHostActions()].sort(
|
||||||
|
(a, b) => actionOrder(a) - actionOrder(b) || a.id.localeCompare(b.id),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InlineView
|
<InlineView
|
||||||
@@ -48,7 +54,7 @@ export function CustomizeSidebarPanel({
|
|||||||
: t("hosts.densityCompactDesc")
|
: t("hosts.densityCompactDesc")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<select
|
<Select2
|
||||||
value={preferences.display.density}
|
value={preferences.display.density}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
update((prev) => ({
|
update((prev) => ({
|
||||||
@@ -67,7 +73,7 @@ export function CustomizeSidebarPanel({
|
|||||||
<option value="compact">
|
<option value="compact">
|
||||||
{t("hosts.displayDensityCompact")}
|
{t("hosts.displayDensityCompact")}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</Select2>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
@@ -112,7 +118,7 @@ export function CustomizeSidebarPanel({
|
|||||||
{t("hosts.actionsVisibilityDesc")}
|
{t("hosts.actionsVisibilityDesc")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<Select2
|
||||||
value={preferences.display.trayTrigger}
|
value={preferences.display.trayTrigger}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
update((prev) => ({
|
update((prev) => ({
|
||||||
@@ -129,7 +135,7 @@ export function CustomizeSidebarPanel({
|
|||||||
<option value="actionsOnly">{t("hosts.actionsOnly")}</option>
|
<option value="actionsOnly">{t("hosts.actionsOnly")}</option>
|
||||||
<option value="hover">{t("hosts.actionsHover")}</option>
|
<option value="hover">{t("hosts.actionsHover")}</option>
|
||||||
<option value="click">{t("hosts.actionsClick")}</option>
|
<option value="click">{t("hosts.actionsClick")}</option>
|
||||||
</select>
|
</Select2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
|
<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">
|
<div className="flex flex-col gap-0.5">
|
||||||
@@ -140,7 +146,7 @@ export function CustomizeSidebarPanel({
|
|||||||
{t("hosts.hostClickBehaviorDesc")}
|
{t("hosts.hostClickBehaviorDesc")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<Select2
|
||||||
value={preferences.display.hostClickBehavior}
|
value={preferences.display.hostClickBehavior}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
update((prev) => ({
|
update((prev) => ({
|
||||||
@@ -160,7 +166,7 @@ export function CustomizeSidebarPanel({
|
|||||||
<option value="focusExistingDoubleClickNew">
|
<option value="focusExistingDoubleClickNew">
|
||||||
{t("hosts.hostClickFocusExistingDoubleNew")}
|
{t("hosts.hostClickFocusExistingDoubleNew")}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</Select2>
|
||||||
</div>
|
</div>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label={t("hosts.openOnDoubleClick")}
|
label={t("hosts.openOnDoubleClick")}
|
||||||
@@ -199,6 +205,49 @@ export function CustomizeSidebarPanel({
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
</SectionCard>
|
</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">
|
<p className="text-[11px] text-muted-foreground/70 leading-snug">
|
||||||
{t("hosts.customizeSortHint")}
|
{t("hosts.customizeSortHint")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Select2 } from "@/components/select2";
|
||||||
import { InlineView } from "@/components/surface/surface-scope";
|
import { InlineView } from "@/components/surface/surface-scope";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -174,7 +175,7 @@ export function FolderMetadataDialog({
|
|||||||
<label className="text-xs font-semibold">
|
<label className="text-xs font-semibold">
|
||||||
{t("hosts.folderCredential")}
|
{t("hosts.folderCredential")}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<Select2
|
||||||
value={credentialId}
|
value={credentialId}
|
||||||
onChange={(e) => setCredentialId(e.target.value)}
|
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"
|
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}
|
{c.username ? `${c.name} (${c.username})` : c.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select2>
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
{t("hosts.folderCredentialHint")}
|
{t("hosts.folderCredentialHint")}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { Select2 } from "@/components/select2";
|
||||||
|
import { FormFooter } from "@/components/list-kit";
|
||||||
import { InlineView } from "@/components/surface/surface-scope";
|
import { InlineView } from "@/components/surface/surface-scope";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/button";
|
|
||||||
import {
|
import {
|
||||||
getCredentials,
|
getCredentials,
|
||||||
getHostAuthOverride,
|
getHostAuthOverride,
|
||||||
@@ -127,25 +128,12 @@ export function HostAuthOverrideModal({
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
footer={
|
footer={
|
||||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
<FormFooter
|
||||||
<Button
|
onCancel={() => onOpenChange(false)}
|
||||||
variant="outline"
|
onSave={() => void handleSave()}
|
||||||
onClick={() => onOpenChange(false)}
|
saving={saving}
|
||||||
disabled={saving}
|
disabled={loading || loadError || selectedId === initialId}
|
||||||
>
|
/>
|
||||||
{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>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -172,7 +160,7 @@ export function HostAuthOverrideModal({
|
|||||||
>
|
>
|
||||||
{t("hosts.sharing.authOverrideCredentialLabel")}
|
{t("hosts.sharing.authOverrideCredentialLabel")}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<Select2
|
||||||
id={`auth-override-${host.id}`}
|
id={`auth-override-${host.id}`}
|
||||||
value={selectedId}
|
value={selectedId}
|
||||||
onChange={(event) => setSelectedId(event.target.value)}
|
onChange={(event) => setSelectedId(event.target.value)}
|
||||||
@@ -192,7 +180,7 @@ export function HostAuthOverrideModal({
|
|||||||
: credential.name}
|
: credential.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select2>
|
||||||
{credentials.length === 0 && (
|
{credentials.length === 0 && (
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
{t("hosts.sharing.authOverrideNoCredentials")}
|
{t("hosts.sharing.authOverrideNoCredentials")}
|
||||||
|
|||||||
@@ -809,7 +809,7 @@ export function HostEditorGeneralTab({
|
|||||||
),
|
),
|
||||||
].join(" ")}
|
].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"
|
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 ?? ""}
|
value={form.connectionOrigin ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
@@ -826,7 +826,7 @@ export function HostEditorGeneralTab({
|
|||||||
<option value="remote">
|
<option value="remote">
|
||||||
{t("hosts.connectionOriginRemote")}
|
{t("hosts.connectionOriginRemote")}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</Select2>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
</HostOnly>
|
</HostOnly>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { InlineView } from "@/components/surface/surface-scope";
|
import { InlineView } from "@/components/surface/surface-scope";
|
||||||
|
import { FormFooter } from "@/components/list-kit";
|
||||||
import { getErrorMessage } from "../lib/error-message.js";
|
import { getErrorMessage } from "../lib/error-message.js";
|
||||||
import { usePluginStore } from "@/plugin-host/plugin-store";
|
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Download, Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
import { Checkbox } from "@/components/checkbox";
|
import { Checkbox } from "@/components/checkbox";
|
||||||
@@ -220,20 +221,13 @@ export function HostExportDialog({
|
|||||||
title={t("hosts.export.title")}
|
title={t("hosts.export.title")}
|
||||||
width="wide"
|
width="wide"
|
||||||
footer={
|
footer={
|
||||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
<FormFooter
|
||||||
<Button variant="outline" onClick={onClose}>
|
onCancel={onClose}
|
||||||
{t("hosts.export.cancel")}
|
cancelLabel={t("hosts.export.cancel")}
|
||||||
</Button>
|
onSave={() => void handleExport()}
|
||||||
<Button
|
saveLabel={t("hosts.export.confirm")}
|
||||||
variant="outline"
|
|
||||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
|
||||||
onClick={handleExport}
|
|
||||||
disabled={!canExport}
|
disabled={!canExport}
|
||||||
>
|
/>
|
||||||
<Download className="size-3.5 mr-2" />
|
|
||||||
{t("hosts.export.confirm")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col md:flex-row gap-4 flex-1 min-h-0 min-w-0 overflow-y-auto md:overflow-visible">
|
<div className="flex flex-col md:flex-row gap-4 flex-1 min-h-0 min-w-0 overflow-y-auto md:overflow-visible">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getErrorMessage } from "../lib/error-message.js";
|
import { getErrorMessage } from "../lib/error-message.js";
|
||||||
|
import { setBarActions } from "@/sidebar/tree/host-bar-actions";
|
||||||
import { enabledHostProtocols, useHostProtocols } from "./host-protocols";
|
import { enabledHostProtocols, useHostProtocols } from "./host-protocols";
|
||||||
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -248,6 +249,9 @@ export function HostsPanel({
|
|||||||
);
|
);
|
||||||
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
|
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
|
||||||
useHostSidebarPreferences();
|
useHostSidebarPreferences();
|
||||||
|
useEffect(() => {
|
||||||
|
setBarActions(sidebarPrefs.display.barActions);
|
||||||
|
}, [sidebarPrefs.display.barActions]);
|
||||||
const sortKey = sidebarPrefs.sort.key;
|
const sortKey = sidebarPrefs.sort.key;
|
||||||
const pinnedFirst = sidebarPrefs.sort.pinnedFirst;
|
const pinnedFirst = sidebarPrefs.sort.pinnedFirst;
|
||||||
const { arrangeLocked, toggleArrangeLock } = useArrangeLock(
|
const { arrangeLocked, toggleArrangeLock } = useArrangeLock(
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ChevronDown, ChevronUp, RotateCcw } from "lucide-react";
|
||||||
import { FakeSwitch } from "@/components/section-card";
|
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,
|
* Appearance > Sidebar > Navigation: one switch per rail destination,
|
||||||
* including the ones plugins register, which appear and disappear here as
|
* 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({
|
export function NavigationVisibilityToggles({
|
||||||
hidden,
|
hidden,
|
||||||
@@ -16,23 +22,68 @@ export function NavigationVisibilityToggles({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const railItems = useRailItems();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{railItems
|
{railItems
|
||||||
.filter((item) => item.hideable !== false)
|
.filter((item) => item.hideable !== false)
|
||||||
.map((item) => (
|
.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
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className="flex items-center justify-between py-1.5"
|
className="flex items-center justify-between gap-2 py-1.5"
|
||||||
data-testid={`nav-toggle-${item.id}`}
|
data-testid={`nav-toggle-${item.id}`}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
|
<span className="flex min-w-0 items-center gap-1.5 text-xs font-medium text-foreground">
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
<item.icon size={12} />
|
<item.icon size={12} />
|
||||||
</span>
|
</span>
|
||||||
{t(item.labelKey)}
|
<span className="truncate">{t(item.labelKey)}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<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
|
<FakeSwitch
|
||||||
checked={!hidden.has(item.id)}
|
checked={!hidden.has(item.id)}
|
||||||
onChange={(visible) => {
|
onChange={(visible) => {
|
||||||
@@ -42,8 +93,21 @@ export function NavigationVisibilityToggles({
|
|||||||
onChange(next);
|
onChange(next);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useEffect, useState, type ReactNode } from "react";
|
import { useEffect, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Eye, EyeOff } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Input } from "@/components/input";
|
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 type { Host } from "@/types/ui-types";
|
||||||
import { getCredentials } from "@/api/credentials-api";
|
import { getCredentials } from "@/api/credentials-api";
|
||||||
import { mapCredentials } from "./HostManagerData";
|
import { mapCredentials } from "./HostManagerData";
|
||||||
@@ -27,7 +29,7 @@ const INLINE_AUTH_TYPES = new Set(["password", "key", "credential"]);
|
|||||||
const LABEL_CLASS =
|
const LABEL_CLASS =
|
||||||
"text-[10px] font-semibold uppercase tracking-widest text-muted-foreground";
|
"text-[10px] font-semibold uppercase tracking-widest text-muted-foreground";
|
||||||
const SELECT_CLASS =
|
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 {
|
interface QuickConnectPanelProps {
|
||||||
onConnect: (host: Host, type: string) => void;
|
onConnect: (host: Host, type: string) => void;
|
||||||
@@ -54,7 +56,6 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
const [authFields, setAuthFields] = useState<Record<string, unknown>>({});
|
const [authFields, setAuthFields] = useState<Record<string, unknown>>({});
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [privateKey, setPrivateKey] = useState("");
|
const [privateKey, setPrivateKey] = useState("");
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [credentialId, setCredentialId] = useState("");
|
const [credentialId, setCredentialId] = useState("");
|
||||||
const [credentials, setCredentials] = useState<
|
const [credentials, setCredentials] = useState<
|
||||||
{ id: string; name: string; username: string }[]
|
{ id: string; name: string; username: string }[]
|
||||||
@@ -140,7 +141,8 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
<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">
|
<div className="grid grid-cols-[1fr_5rem] gap-2">
|
||||||
<Field label={t("newUi.sidebar.quickConnect.protocolLabel")}>
|
<Field label={t("newUi.sidebar.quickConnect.protocolLabel")}>
|
||||||
<Select2
|
<Select2
|
||||||
@@ -164,7 +166,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
value={port}
|
value={port}
|
||||||
onChange={(e) => setPort(e.target.value)}
|
onChange={(e) => setPort(e.target.value)}
|
||||||
onKeyDown={onEnter}
|
onKeyDown={onEnter}
|
||||||
className="h-7 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,9 +176,13 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
value={host}
|
value={host}
|
||||||
onChange={(e) => setHost(e.target.value)}
|
onChange={(e) => setHost(e.target.value)}
|
||||||
onKeyDown={onEnter}
|
onKeyDown={onEnter}
|
||||||
className="h-7 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<GroupHeading
|
||||||
|
title={t("newUi.sidebar.quickConnect.who")}
|
||||||
|
className="pt-1"
|
||||||
|
/>
|
||||||
<Field label={t("newUi.sidebar.quickConnect.usernameLabel")}>
|
<Field label={t("newUi.sidebar.quickConnect.usernameLabel")}>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")}
|
placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")}
|
||||||
@@ -189,7 +195,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
}}
|
}}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
onKeyDown={onEnter}
|
onKeyDown={onEnter}
|
||||||
className="h-7 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
{!isDesktop && authOptions.length > 0 && (
|
{!isDesktop && authOptions.length > 0 && (
|
||||||
@@ -212,29 +218,13 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
)}
|
)}
|
||||||
{(isDesktop || authType === "password") && (
|
{(isDesktop || authType === "password") && (
|
||||||
<Field label={t("newUi.sidebar.quickConnect.passwordLabel")}>
|
<Field label={t("newUi.sidebar.quickConnect.passwordLabel")}>
|
||||||
<div className="relative">
|
<PasswordInput
|
||||||
<Input
|
placeholder={t("newUi.sidebar.quickConnect.passwordPlaceholder")}
|
||||||
type={showPassword ? "text" : "password"}
|
|
||||||
placeholder={t(
|
|
||||||
"newUi.sidebar.quickConnect.passwordPlaceholder",
|
|
||||||
)}
|
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
onKeyDown={onEnter}
|
onKeyDown={onEnter}
|
||||||
className="h-7 text-xs pr-8"
|
className="h-8 pr-8 text-xs"
|
||||||
/>
|
/>
|
||||||
<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>
|
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
{selected?.quickConnect?.showDomain && (
|
{selected?.quickConnect?.showDomain && (
|
||||||
@@ -244,19 +234,19 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
value={domain}
|
value={domain}
|
||||||
onChange={(e) => setDomain(e.target.value)}
|
onChange={(e) => setDomain(e.target.value)}
|
||||||
onKeyDown={onEnter}
|
onKeyDown={onEnter}
|
||||||
className="h-7 text-xs"
|
className="h-8 text-xs"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
{!isDesktop && authType === "key" && (
|
{!isDesktop && authType === "key" && (
|
||||||
<Field label={t("newUi.sidebar.quickConnect.privateKeyLabel")}>
|
<Field label={t("newUi.sidebar.quickConnect.privateKeyLabel")}>
|
||||||
<textarea
|
<Textarea
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"newUi.sidebar.quickConnect.privateKeyPlaceholder",
|
"newUi.sidebar.quickConnect.privateKeyPlaceholder",
|
||||||
)}
|
)}
|
||||||
value={privateKey}
|
value={privateKey}
|
||||||
onChange={(e) => setPrivateKey(e.target.value)}
|
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>
|
</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 ? (
|
{targets.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t("newUi.sidebar.quickConnect.noTarget")}
|
{t("newUi.sidebar.quickConnect.noTarget")}
|
||||||
@@ -306,9 +296,9 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={!host.trim()}
|
disabled={!host.trim()}
|
||||||
onClick={() => connect(action)}
|
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
|
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"
|
: "border-border text-foreground hover:bg-muted"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
|
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
|
||||||
|
import { FormFooter } from "@/components/list-kit";
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
import { AuthEnrollmentSections } from "./AuthEnrollmentSections";
|
import { AuthEnrollmentSections } from "./AuthEnrollmentSections";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -265,36 +266,20 @@ export function NewApiKeyDialog({
|
|||||||
}}
|
}}
|
||||||
title={t("newUi.sidebar.userProfile.createApiKeyTitle")}
|
title={t("newUi.sidebar.userProfile.createApiKeyTitle")}
|
||||||
footer={
|
footer={
|
||||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
createdToken ? (
|
||||||
{createdToken ? (
|
<FormFooter
|
||||||
<Button
|
onSave={close}
|
||||||
variant="outline"
|
saveLabel={t("newUi.sidebar.userProfile.done")}
|
||||||
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>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<FormFooter
|
||||||
<Button
|
onCancel={close}
|
||||||
variant="ghost"
|
cancelLabel={t("newUi.sidebar.userProfile.cancel")}
|
||||||
onClick={close}
|
onSave={() => void handleCreate()}
|
||||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
saveLabel={t("newUi.sidebar.userProfile.createKey")}
|
||||||
>
|
saving={creating}
|
||||||
{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>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
@@ -1197,29 +1182,6 @@ export function UserProfilePanel({
|
|||||||
userId={userId}
|
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
|
{/* Storage mode toggle — only meaningful once a remote server is
|
||||||
connected; with no sync there's nowhere for "cloud" to sync to,
|
connected; with no sync there's nowhere for "cloud" to sync to,
|
||||||
so this stays forced to local storage and hidden. */}
|
so this stays forced to local storage and hidden. */}
|
||||||
@@ -1233,7 +1195,7 @@ export function UserProfilePanel({
|
|||||||
onClick={() => handleStorageModeChange("local")}
|
onClick={() => handleStorageModeChange("local")}
|
||||||
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
|
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
|
||||||
storageMode === "local"
|
storageMode === "local"
|
||||||
? "bg-accent-brand text-white"
|
? "bg-accent-brand/10 text-accent-brand"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -1243,7 +1205,7 @@ export function UserProfilePanel({
|
|||||||
onClick={() => handleStorageModeChange("cloud")}
|
onClick={() => handleStorageModeChange("cloud")}
|
||||||
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
|
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
|
||||||
storageMode === "cloud"
|
storageMode === "cloud"
|
||||||
? "bg-accent-brand text-white"
|
? "bg-accent-brand/10 text-accent-brand"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
: "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">
|
<div className="border-t border-border pt-3 mt-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||||
{t("newUi.sidebar.userProfile.versionLabel")}
|
{t("newUi.sidebar.userProfile.versionLabel")}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center justify-between mt-1.5">
|
<VersionBadge
|
||||||
<span className="text-sm font-bold text-accent-brand">
|
status={versionStatus}
|
||||||
{version ? `v${version}` : "—"}
|
releaseUrl={releaseUrl}
|
||||||
</span>
|
className="shrink-0 px-1 py-px text-[9px]"
|
||||||
<VersionBadge status={versionStatus} releaseUrl={releaseUrl} />
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="border-t border-border pt-3 mt-3">
|
<div className="border-t border-border pt-3 mt-3">
|
||||||
@@ -1528,7 +1497,7 @@ export function UserProfilePanel({
|
|||||||
<Type className="size-3" />
|
<Type className="size-3" />
|
||||||
{t("newUi.sidebar.userProfile.interfaceFontLabel")}
|
{t("newUi.sidebar.userProfile.interfaceFontLabel")}
|
||||||
</span>
|
</span>
|
||||||
<select
|
<Select2
|
||||||
value={uiFont}
|
value={uiFont}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
handleUiFontChange(event.target.value as UiFontId)
|
handleUiFontChange(event.target.value as UiFontId)
|
||||||
@@ -1540,7 +1509,7 @@ export function UserProfilePanel({
|
|||||||
{font.label}
|
{font.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select2>
|
||||||
<span className="text-[10px] text-muted-foreground">
|
<span className="text-[10px] text-muted-foreground">
|
||||||
{t("newUi.sidebar.userProfile.interfaceFontDescription")}
|
{t("newUi.sidebar.userProfile.interfaceFontDescription")}
|
||||||
</span>
|
</span>
|
||||||
@@ -1767,7 +1736,10 @@ export function UserProfilePanel({
|
|||||||
</SettingRow>
|
</SettingRow>
|
||||||
</div>
|
</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">
|
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
|
||||||
{t("newUi.sidebar.userProfile.settingsNavigation")}
|
{t("newUi.sidebar.userProfile.settingsNavigation")}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ const menuItems = createRegistry<HostContextMenuItemDef>(byOrderThenId);
|
|||||||
|
|
||||||
export const registerHostAction = actions.register;
|
export const registerHostAction = actions.register;
|
||||||
export const useHostActions = actions.useList;
|
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 listHostActions = actions.list;
|
||||||
export const registerHostBadge = badges.register;
|
export const registerHostBadge = badges.register;
|
||||||
export const useHostBadges = badges.useList;
|
export const useHostBadges = badges.useList;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useMemo, useSyncExternalStore } from "react";
|
import { useMemo, useSyncExternalStore } from "react";
|
||||||
import { usePermissions } from "@/hooks/use-permissions";
|
import { usePermissions } from "@/hooks/use-permissions";
|
||||||
|
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
|
||||||
import { useSyncAttentionCount } from "@/hooks/use-sync-status";
|
import { useSyncAttentionCount } from "@/hooks/use-sync-status";
|
||||||
import { isElectron } from "@/lib/electron";
|
import { isElectron } from "@/lib/electron";
|
||||||
import { createRegistry } from "@/lib/registry";
|
import { createRegistry } from "@/lib/registry";
|
||||||
@@ -233,12 +234,55 @@ export function useRailItems(): RailItemDef[] {
|
|||||||
railItemsSnapshot,
|
railItemsSnapshot,
|
||||||
);
|
);
|
||||||
const { has, loaded } = usePermissions();
|
const { has, loaded } = usePermissions();
|
||||||
|
const { order } = useAreaPreferences("rail");
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => permittedRailItems(items, { has, loaded }),
|
() => applyRailOrder(permittedRailItems(items, { has, loaded }), order),
|
||||||
[items, has, loaded],
|
[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. */
|
/** Places outside the rail that still need a title, like the Settings tab. */
|
||||||
export const RAIL_UTILITY_ITEMS: RailItemDef[] = [
|
export const RAIL_UTILITY_ITEMS: RailItemDef[] = [
|
||||||
{ id: "settings", icon: Settings, labelKey: "nav.settings" },
|
{ id: "settings", icon: Settings, labelKey: "nav.settings" },
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
/* 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 { rem } from "@/lib/rem";
|
||||||
import { enabledHostProtocols } from "@/sidebar/host-protocols";
|
import { enabledHostProtocols } from "@/sidebar/host-protocols";
|
||||||
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
||||||
@@ -116,11 +118,6 @@ export function buildStatusTooltip(
|
|||||||
return `${protocols.join(", ")}: ${statusLabel}`;
|
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> {
|
async function writeClipboardText(value: string): Promise<void> {
|
||||||
await copyToClipboard(value);
|
await copyToClipboard(value);
|
||||||
}
|
}
|
||||||
@@ -275,6 +272,7 @@ export function HostItem({
|
|||||||
const allowDelete = canDeleteHost(host);
|
const allowDelete = canDeleteHost(host);
|
||||||
const allHostActions = useHostActions();
|
const allHostActions = useHostActions();
|
||||||
const pluginActions = hostActionsFor(allHostActions, host);
|
const pluginActions = hostActionsFor(allHostActions, host);
|
||||||
|
const barActions = useBarActions();
|
||||||
const badges = hostBadgesFor(useHostBadges(), host);
|
const badges = hostBadgesFor(useHostBadges(), host);
|
||||||
const pluginMenuItems = hostMenuItemsFor(useHostContextMenuItems(), host);
|
const pluginMenuItems = hostMenuItemsFor(useHostContextMenuItems(), host);
|
||||||
const splitTargets = useSplitTargets();
|
const splitTargets = useSplitTargets();
|
||||||
@@ -421,7 +419,7 @@ export function HostItem({
|
|||||||
icon: action.icon as typeof Terminal,
|
icon: action.icon as typeof Terminal,
|
||||||
label: action.label?.(host) ?? t(action.titleKey),
|
label: action.label?.(host) ?? t(action.titleKey),
|
||||||
tabType: action.tabType,
|
tabType: action.tabType,
|
||||||
tray: action.tray !== false,
|
tray: showsInBar(barActions, action),
|
||||||
items:
|
items:
|
||||||
items && items.length > 1
|
items && items.length > 1
|
||||||
? items.map((item) => ({
|
? items.map((item) => ({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(
|
await screen.findByLabelText("hosts.sharing.authOverrideCredentialLabel");
|
||||||
"hosts.sharing.authOverrideCredentialLabel",
|
const select = document.querySelector("select") as HTMLSelectElement;
|
||||||
);
|
|
||||||
expect((select as HTMLSelectElement).value).toBe("7");
|
expect((select as HTMLSelectElement).value).toBe("7");
|
||||||
|
|
||||||
fireEvent.change(select, { target: { value: "8" } });
|
fireEvent.change(select, { target: { value: "8" } });
|
||||||
@@ -119,9 +118,8 @@ describe("HostAuthOverrideModal", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const select = await screen.findByLabelText(
|
await screen.findByLabelText("hosts.sharing.authOverrideCredentialLabel");
|
||||||
"hosts.sharing.authOverrideCredentialLabel",
|
const select = document.querySelector("select") as HTMLSelectElement;
|
||||||
);
|
|
||||||
expect(screen.getByText("hosts.sharing.noPersonalCredential")).toBeTruthy();
|
expect(screen.getByText("hosts.sharing.noPersonalCredential")).toBeTruthy();
|
||||||
fireEvent.change(select, { target: { value: "" } });
|
fireEvent.change(select, { target: { value: "" } });
|
||||||
expect(screen.getByText("hosts.sharing.authOverrideRequired")).toBeTruthy();
|
expect(screen.getByText("hosts.sharing.authOverrideRequired")).toBeTruthy();
|
||||||
@@ -187,9 +185,8 @@ describe("HostAuthOverrideModal", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const select = await screen.findByLabelText(
|
await screen.findByLabelText("hosts.sharing.authOverrideCredentialLabel");
|
||||||
"hosts.sharing.authOverrideCredentialLabel",
|
const select = document.querySelector("select") as HTMLSelectElement;
|
||||||
);
|
|
||||||
expect((select as HTMLSelectElement).value).toBe("19");
|
expect((select as HTMLSelectElement).value).toBe("19");
|
||||||
expect(remote.get).toHaveBeenCalledWith("/credentials");
|
expect(remote.get).toHaveBeenCalledWith("/credentials");
|
||||||
expect(api.getCredentials).not.toHaveBeenCalled();
|
expect(api.getCredentials).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
rightDockableIds,
|
rightDockableIds,
|
||||||
railItemLabel,
|
railItemLabel,
|
||||||
visibleRailItems,
|
visibleRailItems,
|
||||||
|
applyRailOrder,
|
||||||
|
moveRailItem,
|
||||||
} from "@/sidebar/rail-items";
|
} from "@/sidebar/rail-items";
|
||||||
import { isCapturableTabType } from "@/shell/shell-layout";
|
import { isCapturableTabType } from "@/shell/shell-layout";
|
||||||
import { registerPanel, resetPanels } from "@/shell/panel-registry";
|
import { registerPanel, resetPanels } from "@/shell/panel-registry";
|
||||||
@@ -275,3 +277,50 @@ describe("permittedRailItems", () => {
|
|||||||
expect(ids).toEqual(["open"]);
|
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,
|
openOnDoubleClick: false,
|
||||||
showFolderPaths: true,
|
showFolderPaths: true,
|
||||||
hostClickBehavior: "newTab",
|
hostClickBehavior: "newTab",
|
||||||
|
barActions: {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -67,6 +68,7 @@ describe("sanitizeHostSidebarPreferences", () => {
|
|||||||
openOnDoubleClick: true,
|
openOnDoubleClick: true,
|
||||||
showFolderPaths: false,
|
showFolderPaths: false,
|
||||||
hostClickBehavior: "focusExistingDoubleClickNew",
|
hostClickBehavior: "focusExistingDoubleClickNew",
|
||||||
|
barActions: { maintenance: true },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
expect(sanitizeHostSidebarPreferences(valid)).toEqual(valid);
|
expect(sanitizeHostSidebarPreferences(valid)).toEqual(valid);
|
||||||
@@ -145,3 +147,30 @@ describe("sanitizeHostSidebarPreferences", () => {
|
|||||||
expect(result.version).toBe(HOST_SIDEBAR_PREFS_VERSION);
|
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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -45,7 +45,13 @@ export function DonationReminderModal({
|
|||||||
<Button variant="outline" onClick={onDismiss}>
|
<Button variant="outline" onClick={onDismiss}>
|
||||||
{t("donation.dismiss")}
|
{t("donation.dismiss")}
|
||||||
</Button>
|
</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>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
Reference in new issue
Block a user