mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +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
+2078
-512
No files matched your search
Generated
+1
-1
@@ -15076,7 +15076,7 @@
|
||||
},
|
||||
"packages/plugin-sdk": {
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "^4.2.4",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"build": "npm run build:sdk && vite build && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
||||
"build:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins",
|
||||
"dev:backend": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins && node ./dist/backend/backend/starter.js",
|
||||
"dev:backend:local": "npm run build:sdk && tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && npm run build:plugins:local && node ./dist/backend/backend/starter.js",
|
||||
"dev:docker": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker build -f docker/Dockerfile -t termix:dev --no-cache . && docker run -d --name termix-dev -p 3000:3000 -p 8080:8080 -p 30001:30001 -v \"%cd%\\db\\data:/app/data\" termix:dev",
|
||||
"dev:docker:restart": "docker stop termix-dev 2>nul & docker rm termix-dev 2>nul & docker run -d --name termix-dev -p 8080:8080 -p 30001:30001 -v \"%cd%\\db\\data:/app/data\" termix:dev",
|
||||
"generate:openapi": "tsc -p tsconfig.node.json && node -e \"require('fs').copyFileSync('src/backend/package.json','dist/backend/package.json')\" && node ./dist/backend/backend/utils/swagger.js",
|
||||
@@ -49,6 +50,7 @@
|
||||
"schema:migrations": "drizzle-kit generate --config=drizzle.config.sqlite.ts && drizzle-kit generate --config=drizzle.config.pg.ts && drizzle-kit generate --config=drizzle.config.mysql.ts",
|
||||
"build:sdk": "npm run build --workspace @termix-ssh/plugin-sdk && node scripts/build-sdk-host.mjs",
|
||||
"build:plugins": "node scripts/build-plugins.cjs",
|
||||
"build:plugins:local": "node scripts/build-plugins.cjs --local ../Termix-Plugins",
|
||||
"plugins:pin": "node scripts/pin-bundled-plugins.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@termix-ssh/plugin-sdk",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "The contract between Termix core and a plugin: types, the capability catalog and the manifest schema.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -10,6 +10,15 @@ import { installSafePorts } from "./safe-ports.js";
|
||||
|
||||
installSafePorts();
|
||||
|
||||
// Radix measures some controls (Checkbox, Switch) and jsdom has no observer.
|
||||
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined" && !window.matchMedia) {
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
ActionSlot (value): ({ slotId, context, className, enabled, renderItem, hideLabels, when, }: ActionSlotProps) => Element
|
||||
AddButton (value): ({ label, compact, className, ...rest }: { label?: string; compact?: boolean; className?: string; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
||||
Alert (value): ({ className, variant, ...props }: ClassAttributes<HTMLDivElement> & HTMLAttributes<HTMLDivElement> & VariantProps<(props?: ConfigVariants<{ variant: { default: string; destructive: string; }; }> & ClassProp) => string>) => Element
|
||||
AlertDescription (value): ({ className, ...props }: DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>) => Element
|
||||
AlertDialog (value): ({ ...props }: AlertDialogProps) => Element
|
||||
@@ -79,6 +80,7 @@ Facts (value): ({ className, children, }: { className?: string; children: ReactN
|
||||
FakeSwitch (value): ({ defaultChecked, disabled, checked, onChange, }: { defaultChecked?: boolean; disabled?: boolean; checked?: boolean; onChange?: (v: boolean) => void; }) => Element
|
||||
Field (value): ({ label, hint, error, aside, htmlFor, className, children, }: { label: string; hint?: ReactNode; error?: string; aside?: ReactNode; htmlFor?: string; className?: string; children: ReactNode; }) => Element
|
||||
FieldPair (value): ({ children }: { children: ReactNode; }) => Element
|
||||
FormFooter (value): ({ onCancel, onSave, saveLabel, cancelLabel, saving, dirty, status, disabled, onDelete, deleteLabel, extra, saveType, form, }: { onCancel?: () => void; onSave?: () => void; saveLabel?: string; cancelLabel?: string; saving?: boolean; dirty?: boolean; status?: ReactNode; disabled?: boolean; onDelete?: () => void; deleteLabel?: string; extra?: ReactNode; saveType?: "button" | "submit"; form?: string; }) => Element
|
||||
FullScreenAppPhase (type): FullScreenAppPhase
|
||||
FullScreenAppWrapper (value): FC<FullScreenAppWrapperProps>
|
||||
GridCardCatalogEntry (type): GridCardCatalogEntry
|
||||
@@ -103,6 +105,11 @@ KeybindingActionType (type): string
|
||||
Label (value): ({ className, ...props }: LabelProps & RefAttributes<HTMLLabelElement>) => Element
|
||||
LineChart (value): ({ series, timestamps, domain, yFormatter, height, className, }: LineChartProps) => Element
|
||||
LineChartSeries (type): LineChartSeries
|
||||
ListBadge (value): ({ tone, className, children, }: { tone?: "destructive" | "muted" | "brand" | "warning" | "success"; className?: string; children: ReactNode; }) => Element
|
||||
ListRow (value): ({ title, icon, badges, meta, stripe, tone, color, selected, active, dimmed, leading, trailing, actions, onClick, className, children, ...rest }: { title: ReactNode; icon?: ReactNode; badges?: ReactNode; meta?: ReactNode; stripe?: number; tone?: ListRowTone; color?: string; selected?: boolean; active?: boolean; dimmed?: boolean; leading?: ReactNode; trailing?: ReactNode; actions?: ReactNode; onClick?: () => void; className?: string; children?: ReactNode; } & Omit<HTMLAttributes<HTMLDivElement>, "title" | "children" | "onClick">) => Element
|
||||
ListRowAction (value): ({ label, tone, className, children, ...rest }: { label: string; tone?: "default" | "destructive" | "brand"; className?: string; children: ReactNode; } & Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> & { ref?: Ref<HTMLButtonElement>; }) => Element
|
||||
ListRowFolder (value): ({ name, icon, count, open, onToggle, stripe, actions, dropActive, onDropItem, emptyText, className, children, }: { name: ReactNode; icon?: ReactNode; count?: number; open: boolean; onToggle: () => void; stripe?: number; actions?: ReactNode; dropActive?: boolean; onDropItem?: () => void; emptyText?: ReactNode; className?: string; children?: ReactNode; }) => Element
|
||||
ListRowTone (type): ListRowTone
|
||||
LogContext (type): LogContext
|
||||
LogEntry (type): LogEntry
|
||||
MFAPromptMode (type): MFAPromptMode
|
||||
@@ -119,7 +126,8 @@ PROMPT_DESTRUCTIVE_BUTTON (value): "inline-flex h-7 items-center justify-center
|
||||
PROMPT_PRIMARY_BUTTON (value): "inline-flex h-7 items-center justify-center gap-1.5 border border-accent-brand/40 px-3 text-xs font-medium text-accent-brand transition-colors hover:bg-accent-brand/10 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
PanePrompt (value): ({ open, title, titleId, description, icon, tone, actions, onCancel, role, labelledBy, layer, backgroundColor, className, children, }: { open: boolean; title: ReactNode; titleId?: string; description?: ReactNode; icon?: ReactNode; tone?: "default" | "destructive"; actions?: ReactNode; onCancel?: () => void; role?: "dialog" | "alertdialog"; labelledBy?: string; layer?: "connection" | "pane"; backgroundColor?: string; className?: string; children?: ReactNode; }) => Element
|
||||
PanelDensity (type): PanelDensity
|
||||
PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; className?: string; }) => Element
|
||||
PanelList (value): ({ empty, className, children, }: { empty?: ReactNode; className?: string; children?: ReactNode; }) => Element
|
||||
PanelSearch (value): ({ value, onChange, placeholder, fill, autoFocus, disabled, title, onEnter, inputRef, className, }: { value: string; onChange: (next: string) => void; placeholder?: string; fill?: boolean; autoFocus?: boolean; disabled?: boolean; title?: string; onEnter?: () => void; inputRef?: Ref<HTMLInputElement>; className?: string; }) => Element
|
||||
PanelShell (value): ({ chrome, icon, title, status, leading, actions, toolbar, tabs, footer, scroll, className, children, }: { chrome?: boolean; icon?: ReactNode; title?: ReactNode; status?: ReactNode; leading?: ReactNode; actions?: ReactNode; toolbar?: ReactNode; tabs?: ReactNode; footer?: ReactNode; scroll?: boolean; className?: string; children: ReactNode; }) => Element
|
||||
PanelViewMode (type): PanelViewMode
|
||||
PassphraseDialog (value): ({ isOpen, onSubmit, onCancel, hostInfo, backgroundColor, }: PassphraseDialogProps) => Element
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
|
||||
* Each .tmxplug is downloaded (or read from a path), checked against its
|
||||
* pinned sha256 and unpacked.
|
||||
*
|
||||
* For development, `--local <dir>` (or TERMIX_LOCAL_PLUGINS) points at a
|
||||
* folder of plugin repos like ../Termix-Plugins. Any plugin built there (has
|
||||
* dist/) is copied in place of its pin, so local plugin changes show up.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
@@ -13,6 +17,8 @@ const {
|
||||
loadBundledPlugins,
|
||||
fetchArtifact,
|
||||
extractArtifact,
|
||||
findLocalPluginBuilds,
|
||||
copyLocalBuild,
|
||||
} = require("./lib/bundled-plugins.cjs");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
@@ -20,11 +26,22 @@ const destination = path.join(root, "dist", "plugins");
|
||||
|
||||
async function main() {
|
||||
const plugins = loadBundledPlugins(root);
|
||||
const flag = process.argv.indexOf("--local");
|
||||
const localArg =
|
||||
flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS;
|
||||
const localDir = localArg ? path.resolve(root, localArg) : null;
|
||||
const local = findLocalPluginBuilds(localDir);
|
||||
|
||||
fs.rmSync(destination, { recursive: true, force: true });
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const repo = local.get(plugin.id);
|
||||
if (repo) {
|
||||
copyLocalBuild(repo, path.join(destination, plugin.id));
|
||||
console.log(`copied local build of ${plugin.id}`);
|
||||
continue;
|
||||
}
|
||||
const buffer = await fetchArtifact(plugin, root);
|
||||
await extractArtifact(buffer, path.join(destination, plugin.id), plugin.id);
|
||||
console.log(`unpacked ${plugin.id}`);
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
fetchArtifact,
|
||||
extractArtifact,
|
||||
pinsFromIndex,
|
||||
findLocalPluginBuilds,
|
||||
copyLocalBuild,
|
||||
} from "./lib/bundled-plugins.cjs";
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
@@ -192,3 +194,39 @@ describe("pinsFromIndex", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("local plugin builds", () => {
|
||||
it("finds built repos by manifest id and skips unbuilt ones", () => {
|
||||
const dir = tempDir();
|
||||
const built = path.join(dir, "Plugin-Files");
|
||||
fs.mkdirSync(path.join(built, "dist"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(built, "manifest.json"),
|
||||
JSON.stringify({ id: "file-manager" }),
|
||||
);
|
||||
const unbuilt = path.join(dir, "Plugin-Other");
|
||||
fs.mkdirSync(unbuilt);
|
||||
fs.writeFileSync(
|
||||
path.join(unbuilt, "manifest.json"),
|
||||
JSON.stringify({ id: "other" }),
|
||||
);
|
||||
|
||||
const builds = findLocalPluginBuilds(dir);
|
||||
expect([...builds.keys()]).toEqual(["file-manager"]);
|
||||
expect(findLocalPluginBuilds(null).size).toBe(0);
|
||||
});
|
||||
|
||||
it("copies only the files a bundled plugin needs", () => {
|
||||
const repo = tempDir();
|
||||
fs.mkdirSync(path.join(repo, "dist"));
|
||||
fs.writeFileSync(path.join(repo, "dist", "frontend.js"), "x");
|
||||
fs.writeFileSync(path.join(repo, "manifest.json"), "{}");
|
||||
fs.mkdirSync(path.join(repo, "src"));
|
||||
|
||||
const out = path.join(tempDir(), "file-manager");
|
||||
copyLocalBuild(repo, out);
|
||||
expect(fs.existsSync(path.join(out, "dist", "frontend.js"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(out, "manifest.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(out, "src"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,7 @@ const TSCONFIG = path.join(ROOT, "tsconfig.app.json");
|
||||
|
||||
const ALLOWED = [
|
||||
"ActionSlot",
|
||||
"AddButton",
|
||||
"Alert",
|
||||
"AlertDescription",
|
||||
"AlertDialog",
|
||||
@@ -100,6 +101,7 @@ const ALLOWED = [
|
||||
"FakeSwitch",
|
||||
"Field",
|
||||
"FieldPair",
|
||||
"FormFooter",
|
||||
"FullScreenAppPhase",
|
||||
"FullScreenAppWrapper",
|
||||
"GridCardCatalogEntry",
|
||||
@@ -124,6 +126,11 @@ const ALLOWED = [
|
||||
"Label",
|
||||
"LineChart",
|
||||
"LineChartSeries",
|
||||
"ListBadge",
|
||||
"ListRow",
|
||||
"ListRowAction",
|
||||
"ListRowFolder",
|
||||
"ListRowTone",
|
||||
"LogContext",
|
||||
"LogEntry",
|
||||
"MFAPromptMode",
|
||||
@@ -140,6 +147,7 @@ const ALLOWED = [
|
||||
"PROMPT_DESTRUCTIVE_BUTTON",
|
||||
"PROMPT_PRIMARY_BUTTON",
|
||||
"PanelDensity",
|
||||
"PanelList",
|
||||
"PanelSearch",
|
||||
"PanelShell",
|
||||
"PanelViewMode",
|
||||
|
||||
@@ -172,7 +172,51 @@ function pinsFromIndex(index, ids) {
|
||||
});
|
||||
}
|
||||
|
||||
const LOCAL_FILES = [
|
||||
"manifest.json",
|
||||
"dist",
|
||||
"locales",
|
||||
"migrations",
|
||||
"README.md",
|
||||
"CHANGELOG.json",
|
||||
];
|
||||
|
||||
/**
|
||||
* Built plugin repos under dir (each with manifest.json and dist/), by id.
|
||||
* Used in development to stage local plugin builds instead of the pins.
|
||||
*/
|
||||
function findLocalPluginBuilds(dir) {
|
||||
const builds = new Map();
|
||||
if (!dir || !fs.existsSync(dir)) return builds;
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
const repo = path.join(dir, name);
|
||||
const manifestPath = path.join(repo, "manifest.json");
|
||||
if (!fs.existsSync(manifestPath)) continue;
|
||||
if (!fs.existsSync(path.join(repo, "dist"))) continue;
|
||||
try {
|
||||
const { id } = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
if (typeof id === "string" && ID.test(id)) builds.set(id, repo);
|
||||
} catch {
|
||||
// not a plugin repo
|
||||
}
|
||||
}
|
||||
return builds;
|
||||
}
|
||||
|
||||
/** Copies a local plugin build into the staging folder. */
|
||||
function copyLocalBuild(repo, destination) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
for (const entry of LOCAL_FILES) {
|
||||
const from = path.join(repo, entry);
|
||||
if (fs.existsSync(from)) {
|
||||
fs.cpSync(from, path.join(destination, entry), { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findLocalPluginBuilds,
|
||||
copyLocalBuild,
|
||||
parseBundledPlugins,
|
||||
pinsFromIndex,
|
||||
loadBundledPlugins,
|
||||
|
||||
@@ -276,7 +276,13 @@ export function createPluginAssetsRouter(
|
||||
: "no-cache",
|
||||
);
|
||||
res.type(CONTENT_TYPES[path.extname(file)] ?? "application/octet-stream");
|
||||
res.sendFile(file);
|
||||
// Installed plugins unpack under .unpacked/, which send would refuse as a
|
||||
// dotfile path. resolvePluginAsset already kept the file in the plugin.
|
||||
res.sendFile(file, { dotfiles: "allow" }, (error) => {
|
||||
if (error && !res.headersSent) {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
|
||||
let root = "";
|
||||
let plugin: { id: string; dir: string; manifest: never };
|
||||
let installed: { id: string; dir: string; manifest: never };
|
||||
let server: Server;
|
||||
let baseUrl = "";
|
||||
|
||||
@@ -32,6 +33,14 @@ beforeAll(async () => {
|
||||
write("locales/translated/de_DE.json", "{}");
|
||||
write("manifest.json", "{}");
|
||||
fs.writeFileSync(path.join(root, "outside.js"), "nope");
|
||||
const unpacked = path.join(root, ".unpacked", "gadget");
|
||||
fs.mkdirSync(path.join(unpacked, "dist"), { recursive: true });
|
||||
fs.writeFileSync(path.join(unpacked, "dist", "frontend.js"), "export {}");
|
||||
installed = {
|
||||
id: "gadget",
|
||||
dir: unpacked,
|
||||
manifest: { id: "gadget", version: "1.0.0" } as never,
|
||||
};
|
||||
plugin = {
|
||||
id: "gizmo",
|
||||
dir: path.join(root, "gizmo"),
|
||||
@@ -41,7 +50,9 @@ beforeAll(async () => {
|
||||
const app = express();
|
||||
app.use(
|
||||
"/plugin-assets",
|
||||
createPluginAssetsRouter((id) => (id === "gizmo" ? plugin : undefined)),
|
||||
createPluginAssetsRouter((id) =>
|
||||
id === "gizmo" ? plugin : id === "gadget" ? installed : undefined,
|
||||
),
|
||||
);
|
||||
server = await new Promise<Server>((resolve) => {
|
||||
const created = app.listen(0, "127.0.0.1", () => resolve(created));
|
||||
@@ -111,6 +122,12 @@ describe("resolvePluginAsset", () => {
|
||||
});
|
||||
|
||||
describe("GET /plugin-assets", () => {
|
||||
it("serves an installed plugin unpacked under a dot folder", async () => {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/gadget/frontend.js`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("export {}");
|
||||
});
|
||||
|
||||
it("serves a bundle as a module any origin can load", async () => {
|
||||
const res = await fetch(`${baseUrl}/plugin-assets/gizmo/frontend.js`);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -57,6 +57,11 @@ export interface HostSidebarDisplayPreferences {
|
||||
/** When false, nested folders hide the parent-path breadcrumb before their name. */
|
||||
showFolderPaths: boolean;
|
||||
hostClickBehavior: HostClickBehavior;
|
||||
/**
|
||||
* Host action id to whether it shows in the row's connect bar. An action
|
||||
* missing here follows its own default. Every action stays in the menu.
|
||||
*/
|
||||
barActions: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface HostSidebarPreferences {
|
||||
@@ -127,10 +132,24 @@ export function defaultHostSidebarPreferences(): HostSidebarPreferences {
|
||||
openOnDoubleClick: false,
|
||||
showFolderPaths: true,
|
||||
hostClickBehavior: "newTab",
|
||||
barActions: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ACTION_ID_PATTERN = /^[a-z0-9][a-z0-9._:-]{0,79}$/;
|
||||
|
||||
function sanitizeBarActions(input: unknown): Record<string, boolean> {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) return {};
|
||||
const out: Record<string, boolean> = {};
|
||||
for (const [id, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
if (ACTION_ID_PATTERN.test(id) && typeof value === "boolean") {
|
||||
out[id] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sanitizeStringArray(input: unknown): string[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
return input.filter((v): v is string => typeof v === "string");
|
||||
@@ -215,6 +234,7 @@ export function sanitizeHostSidebarPreferences(
|
||||
)
|
||||
? (displayObj.hostClickBehavior as HostClickBehavior)
|
||||
: defaults.display.hostClickBehavior,
|
||||
barActions: sanitizeBarActions(displayObj.barActions),
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -65,6 +65,8 @@ export interface UiRailPreferences {
|
||||
* items are only known at runtime, so the preset cannot list them.
|
||||
*/
|
||||
hidePluginItems?: boolean;
|
||||
/** Rail item ids in the user's order. Items not listed keep their place. */
|
||||
order?: string[];
|
||||
}
|
||||
|
||||
export interface UiDashboardPreferences {
|
||||
@@ -243,6 +245,7 @@ const AREA_SPECS: {
|
||||
},
|
||||
rail: {
|
||||
hiddenTabs: { kind: "stringArray" },
|
||||
order: { kind: "stringArray" },
|
||||
hidePluginItems: { kind: "bool" },
|
||||
},
|
||||
dashboard: {
|
||||
|
||||
+7
-1
@@ -2969,7 +2969,13 @@ export function AppShell({
|
||||
onRailClick={handleRailClick}
|
||||
onOpenTab={openSingletonTab}
|
||||
onOpenInRightDock={openInRightDock}
|
||||
onOpenSettings={() => openSingletonTab("settings")}
|
||||
onOpenSettings={() =>
|
||||
openSingletonTab("settings", undefined, undefined, {
|
||||
section: "appearance",
|
||||
reveal: "settings-navigation",
|
||||
revealAt: Date.now(),
|
||||
})
|
||||
}
|
||||
onOpenPalette={() => setCommandPaletteOpen(true)}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
|
||||
@@ -1205,7 +1205,11 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
{t("messages.databaseConnectionFailed")}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.location.reload()}
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
>
|
||||
{t("common.refresh")}
|
||||
</Button>
|
||||
<LanguageRow
|
||||
@@ -1351,11 +1355,13 @@ export function Auth({ onLogin }: AuthProps) {
|
||||
)}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
disabled={authPluginsLoading}
|
||||
onClick={() =>
|
||||
void retryAuthPlugins().catch(() => {})
|
||||
}
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
>
|
||||
{t("auth.retrySecondFactorUI")}
|
||||
</Button>
|
||||
|
||||
@@ -34,7 +34,7 @@ export function Field({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1.5", className)}>
|
||||
<div data-setting-row="" className={cn("flex flex-col gap-1.5", className)}>
|
||||
{(label || aside) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
|
||||
@@ -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,
|
||||
fill,
|
||||
autoFocus,
|
||||
disabled,
|
||||
title,
|
||||
onEnter,
|
||||
inputRef,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
@@ -174,10 +178,15 @@ export function PanelSearch({
|
||||
/** Takes the whole toolbar, for panels where search is the only control. */
|
||||
fill?: boolean;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
/** Runs on Enter, for searches that query a server. */
|
||||
onEnter?: () => void;
|
||||
inputRef?: React.Ref<HTMLInputElement>;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const localRef = useRef<HTMLInputElement>(null);
|
||||
const text = placeholder ?? t("common.search");
|
||||
return (
|
||||
<div
|
||||
@@ -189,10 +198,19 @@ export function PanelSearch({
|
||||
>
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
ref={ref}
|
||||
ref={(el) => {
|
||||
localRef.current = el;
|
||||
if (typeof inputRef === "function") inputRef(el);
|
||||
else if (inputRef)
|
||||
(
|
||||
inputRef as React.MutableRefObject<HTMLInputElement | null>
|
||||
).current = el;
|
||||
}}
|
||||
type="text"
|
||||
value={value}
|
||||
autoFocus={autoFocus}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
aria-label={text}
|
||||
placeholder={text}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
@@ -201,8 +219,12 @@ export function PanelSearch({
|
||||
e.stopPropagation();
|
||||
onChange("");
|
||||
}
|
||||
if (e.key === "Enter" && onEnter) {
|
||||
e.preventDefault();
|
||||
onEnter();
|
||||
}
|
||||
}}
|
||||
className="h-8 w-full min-w-0 border border-input bg-transparent pl-8 pr-7 text-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 dark:bg-input/30"
|
||||
className="h-8 w-full min-w-0 border border-input bg-transparent pl-8 pr-7 text-xs outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-1 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30"
|
||||
/>
|
||||
{value && (
|
||||
<button
|
||||
@@ -211,7 +233,7 @@ export function PanelSearch({
|
||||
title={t("panel.clearSearch")}
|
||||
onClick={() => {
|
||||
onChange("");
|
||||
ref.current?.focus();
|
||||
localRef.current?.focus();
|
||||
}}
|
||||
className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
|
||||
@@ -60,7 +60,7 @@ export function SettingRow({
|
||||
{/* In a gap-4 form stack the gap already spaces the row, so its own
|
||||
padding and divider would double up. */}
|
||||
<div
|
||||
data-setting-row={rowId}
|
||||
data-setting-row={rowId ?? ""}
|
||||
className="flex items-center justify-between gap-3 py-3 border-b border-border last:border-0 [.gap-4>&]:py-0 [.gap-4>&]:border-0"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5 min-w-0 flex-1">
|
||||
|
||||
@@ -15,7 +15,7 @@ function Separator({
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -41,13 +41,14 @@ export function TabStrip({
|
||||
left: el.scrollLeft > 1,
|
||||
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
|
||||
});
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(el);
|
||||
if (el.firstElementChild) observer.observe(el.firstElementChild);
|
||||
const observer =
|
||||
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update);
|
||||
observer?.observe(el);
|
||||
if (el.firstElementChild) observer?.observe(el.firstElementChild);
|
||||
el.addEventListener("scroll", update);
|
||||
update();
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
observer?.disconnect();
|
||||
el.removeEventListener("scroll", update);
|
||||
};
|
||||
}, [tabs]);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface VersionBadgeProps {
|
||||
status: "up_to_date" | "requires_update" | "beta" | "unknown";
|
||||
@@ -14,13 +15,15 @@ export function VersionBadge({
|
||||
const { t } = useTranslation();
|
||||
if (status === "unknown") return null;
|
||||
|
||||
const badgeClassName = `text-[10px] px-1.5 py-0.5 font-semibold leading-none ${
|
||||
const badgeClassName = cn(
|
||||
"text-[10px] px-1.5 py-0.5 font-semibold leading-none",
|
||||
status === "beta"
|
||||
? "bg-blue-500/20 text-blue-400"
|
||||
: status === "requires_update"
|
||||
? "bg-warning/20 text-warning"
|
||||
: "bg-accent-brand/20 text-accent-brand"
|
||||
}${className ? ` ${className}` : ""}`;
|
||||
: "bg-accent-brand/20 text-accent-brand",
|
||||
className,
|
||||
);
|
||||
|
||||
const label =
|
||||
status === "beta"
|
||||
@@ -42,7 +45,7 @@ export function VersionBadge({
|
||||
rel="noopener noreferrer"
|
||||
title={linkLabel}
|
||||
aria-label={linkLabel}
|
||||
className={`${badgeClassName} cursor-pointer hover:underline`}
|
||||
className={cn(badgeClassName, "cursor-pointer hover:underline")}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
|
||||
@@ -828,6 +828,43 @@ export function DashboardTab({
|
||||
scroll={false}
|
||||
actions={
|
||||
<>
|
||||
{isDashboardView && !viewPending && (
|
||||
<>
|
||||
{editMode ? (
|
||||
<>
|
||||
<span className="hidden text-[11px] text-muted-foreground lg:inline">
|
||||
{t("dashboardTab.dragToReorder")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="gap-1"
|
||||
onClick={() => void handleReset()}
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
{t("dashboard.reset")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Facts className="hidden px-1.5 text-[11px] text-muted-foreground md:flex">
|
||||
<span>
|
||||
{t("dashboardTab.onlineCount", { count: online })}
|
||||
</span>
|
||||
<span>
|
||||
{t("dashboardTab.hostCount", { count: hosts.length })}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
{t("dashboardTab.commandPalette")}
|
||||
<Kbd className="h-4">⇧⇧</Kbd>
|
||||
</span>
|
||||
</Facts>
|
||||
)}
|
||||
<span
|
||||
aria-hidden
|
||||
className="hidden h-4 w-px bg-border xl:block"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span className="hidden items-center gap-0.5 xl:flex">
|
||||
{links.map((link) => (
|
||||
<a
|
||||
@@ -867,39 +904,6 @@ export function DashboardTab({
|
||||
)}
|
||||
</>
|
||||
}
|
||||
toolbar={
|
||||
isDashboardView && !viewPending ? (
|
||||
<>
|
||||
{editMode ? (
|
||||
<>
|
||||
<span className="hidden text-[11px] text-muted-foreground sm:inline">
|
||||
{t("dashboardTab.dragToReorder")}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="gap-1"
|
||||
onClick={() => void handleReset()}
|
||||
>
|
||||
<RotateCcw className="size-3" />
|
||||
{t("dashboard.reset")}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span className="hidden items-center gap-1 text-[11px] text-muted-foreground sm:flex">
|
||||
{t("dashboardTab.commandPalette")}
|
||||
<Kbd className="h-4">⇧⇧</Kbd>
|
||||
</span>
|
||||
)}
|
||||
<Facts className="ml-auto text-[11px] text-muted-foreground">
|
||||
<span>{t("dashboardTab.onlineCount", { count: online })}</span>
|
||||
<span>
|
||||
{t("dashboardTab.hostCount", { count: hosts.length })}
|
||||
</span>
|
||||
</Facts>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{viewPending ? (
|
||||
<div className="flex flex-1 flex-col gap-2 p-2.5">
|
||||
|
||||
@@ -30,17 +30,23 @@ export type VersionStatus =
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
aside,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
/** Sits beside the label, like the release channel. */
|
||||
aside?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col justify-center gap-1 px-3 py-2">
|
||||
{children}
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="truncate text-[10px] text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
{aside}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,15 +71,19 @@ export function StatsStrip({
|
||||
const big = "text-2xl font-bold leading-none tracking-tight";
|
||||
return (
|
||||
<div className="grid h-full grid-cols-2 divide-x divide-border md:grid-cols-4">
|
||||
<Stat label={t("dashboard.version")}>
|
||||
<div className="flex flex-wrap items-baseline gap-1.5">
|
||||
<span className={big}>{versionText || "-"}</span>
|
||||
<Stat
|
||||
label={t("dashboard.version")}
|
||||
aside={
|
||||
<VersionBadge
|
||||
status={versionStatus}
|
||||
releaseUrl={releaseUrl}
|
||||
className="w-fit"
|
||||
className="shrink-0 px-1 py-px text-[9px]"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<span className={`${big} truncate`} title={versionText || undefined}>
|
||||
{versionText || "-"}
|
||||
</span>
|
||||
</Stat>
|
||||
<Stat label={t("dashboard.uptime")}>
|
||||
<span className={big}>{uptimeFormatted || "-"}</span>
|
||||
@@ -161,7 +171,7 @@ function ActionButton({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="group/btn flex min-w-0 items-center gap-2 px-2 py-1.5 text-left transition-colors hover:bg-muted/50"
|
||||
className="group/btn flex h-full min-w-0 items-center gap-2 border border-border/60 px-2 py-1.5 text-left transition-colors hover:border-accent-brand/40 hover:bg-muted/50"
|
||||
>
|
||||
<span className="flex size-6 shrink-0 items-center justify-center border border-border bg-muted transition-colors group-hover/btn:border-accent-brand/40 group-hover/btn:bg-accent-brand/20">
|
||||
<Icon className="size-3 text-accent-brand" />
|
||||
@@ -199,8 +209,8 @@ export function QuickActions({
|
||||
return `${host.ip}:${port ?? host.port}`;
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="grid grid-cols-1 gap-1 p-2 sm:grid-cols-2">
|
||||
<div className="flex min-h-full flex-1 flex-col">
|
||||
<div className="grid flex-1 auto-rows-fr grid-cols-1 gap-1 p-2 sm:grid-cols-2">
|
||||
<ActionButton
|
||||
icon={Plus}
|
||||
label={t("dashboard.addHost")}
|
||||
|
||||
@@ -815,3 +815,13 @@ html.fs-xl {
|
||||
outline-offset: 2px;
|
||||
transition: outline-color 1.2s ease;
|
||||
}
|
||||
|
||||
/* Cards and rows the settings page search filtered out. */
|
||||
[data-search-hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
::highlight(settings-search) {
|
||||
background-color: color-mix(in oklab, var(--accent-brand) 35%, transparent);
|
||||
color: inherit;
|
||||
}
|
||||
+14
-6
@@ -195,7 +195,8 @@
|
||||
"sidebar": "Sidebar",
|
||||
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"search": "Search..."
|
||||
"search": "Search...",
|
||||
"new": "New"
|
||||
},
|
||||
"onboarding": {
|
||||
"stepCounter": "Step {{current}} of {{total}}",
|
||||
@@ -327,6 +328,9 @@
|
||||
"search": "Search",
|
||||
"hiddenCount": "{{count}} hidden",
|
||||
"showInRail": "Show in sidebar",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"resetRailOrder": "Reset order",
|
||||
"hideFromRail": "Hide from sidebar",
|
||||
"openSettings": "Open settings",
|
||||
"settings": "Settings",
|
||||
@@ -632,6 +636,8 @@
|
||||
"customizeSidebarDescription": "Choose how the host list looks and behaves.",
|
||||
"customizeDensityTitle": "Density",
|
||||
"customizeBehaviorTitle": "Behavior",
|
||||
"connectBarTitle": "Connect bar",
|
||||
"connectBarDesc": "Choose which actions show as buttons on a host. Every action stays in the host's menu.",
|
||||
"customizeSortHint": "Sort order and grouping live in the sort and group menus in the toolbar.",
|
||||
"densityComfortableDesc": "More detail per host: address, tags, and resource usage.",
|
||||
"densityCompactDesc": "One line per host, for longer lists.",
|
||||
@@ -1564,6 +1570,8 @@
|
||||
"newUi": {
|
||||
"sidebar": {
|
||||
"quickConnect": {
|
||||
"where": "Where",
|
||||
"who": "Who",
|
||||
"hostLabel": "Host",
|
||||
"hostPlaceholder": "192.168.1.1 or example.com",
|
||||
"portLabel": "Port",
|
||||
@@ -1631,10 +1639,6 @@
|
||||
"resetAllConfirm": "Reset every keybinding to its default?"
|
||||
},
|
||||
"userProfile": {
|
||||
"donateTitle": "Keep Termix alive",
|
||||
"donateDescription": "Termix is free, self-hosted, and built by only a few people in their spare time. If it's saved you time or money, a crypto donation helps keep it going.",
|
||||
"donateMilestones": "Donations help fund the time to research and learn what's needed to build SAML, Kubernetes, and Agent support. See the progress and donate.",
|
||||
"donateButton": "Donate crypto",
|
||||
"storageModeLocal": "Browser",
|
||||
"storageModeCloud": "Database",
|
||||
"storageModeDescription": "Browser stores settings in this browser only. Database syncs to the server and loads on any device.",
|
||||
@@ -2155,7 +2159,9 @@
|
||||
"noResults": "No settings match.",
|
||||
"pages": "Pages",
|
||||
"instanceSettings": "Server settings",
|
||||
"nothingHere": "Nothing to set here."
|
||||
"nothingHere": "Nothing to set here.",
|
||||
"searchPage": "Search this page",
|
||||
"noPageMatches": "Nothing on this page matches."
|
||||
},
|
||||
"managerCard": {
|
||||
"refresh": "Refresh",
|
||||
@@ -2345,6 +2351,8 @@
|
||||
"revoke": "Remove access"
|
||||
},
|
||||
"manage": {
|
||||
"searchSettings": "Search settings",
|
||||
"noMatchingSettings": "No settings match.",
|
||||
"sections": "Sections",
|
||||
"sectionNeedsAttention": "Something here needs attention",
|
||||
"needsAttention_one": "One thing needs attention.",
|
||||
|
||||
+102
-16
@@ -1,6 +1,9 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import type React from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
@@ -9,10 +12,16 @@ import {
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GroupHeading, PANEL } from "@/components/panel-layout";
|
||||
import { Search } from "lucide-react";
|
||||
import { GroupHeading, PANEL, PanelSearch } from "@/components/panel-layout";
|
||||
import { TabStrip } from "@/components/tab-strip";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { HIDDEN_ATTR, useSettingsPageFilter } from "@/settings/page-filter";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** True while the editor is searched, so every lazy section mounts. */
|
||||
const SearchingContext = createContext(false);
|
||||
|
||||
export interface EditorNavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -27,6 +36,25 @@ export interface EditorNavBand {
|
||||
items: EditorNavItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The element that really scrolls: the editor's own area, or the page around
|
||||
* it when the editor is embedded somewhere that scrolls itself.
|
||||
*/
|
||||
function scroller(el: HTMLElement): HTMLElement {
|
||||
let node: HTMLElement | null = el;
|
||||
while (node) {
|
||||
const overflow = getComputedStyle(node).overflowY;
|
||||
if (
|
||||
(overflow === "auto" || overflow === "scroll") &&
|
||||
node.scrollHeight > node.clientHeight + 1
|
||||
) {
|
||||
return node;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Which section is in view, and a way to jump to one. */
|
||||
export function useScrollSpy(
|
||||
containerRef: RefObject<HTMLElement | null>,
|
||||
@@ -42,7 +70,8 @@ export function useScrollSpy(
|
||||
if (!container) return;
|
||||
const onScroll = () => {
|
||||
if (Date.now() < lockUntil.current) return;
|
||||
const top = container.getBoundingClientRect().top;
|
||||
const area = scroller(container);
|
||||
const top = area.getBoundingClientRect().top;
|
||||
let current = ids[0] ?? "";
|
||||
for (const id of ids) {
|
||||
const el = container.querySelector<HTMLElement>(
|
||||
@@ -52,17 +81,21 @@ export function useScrollSpy(
|
||||
if (el.getBoundingClientRect().top - top <= 24) current = id;
|
||||
}
|
||||
if (
|
||||
container.scrollHeight > container.clientHeight &&
|
||||
container.scrollTop + container.clientHeight >=
|
||||
container.scrollHeight - 8
|
||||
area.scrollHeight > area.clientHeight &&
|
||||
area.scrollTop + area.clientHeight >= area.scrollHeight - 8
|
||||
) {
|
||||
current = ids[ids.length - 1] ?? current;
|
||||
}
|
||||
setActive(current);
|
||||
};
|
||||
onScroll();
|
||||
container.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => container.removeEventListener("scroll", onScroll);
|
||||
// Captured on the document so an outer scroller is heard too.
|
||||
document.addEventListener("scroll", onScroll, {
|
||||
passive: true,
|
||||
capture: true,
|
||||
});
|
||||
return () =>
|
||||
document.removeEventListener("scroll", onScroll, { capture: true });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [containerRef, idsKey]);
|
||||
|
||||
@@ -76,11 +109,12 @@ export function useScrollSpy(
|
||||
if (!container || !el) return;
|
||||
lockUntil.current = Date.now() + 600;
|
||||
setActive(id);
|
||||
container.scrollTo?.({
|
||||
const area = scroller(container);
|
||||
area.scrollTo?.({
|
||||
top:
|
||||
el.getBoundingClientRect().top -
|
||||
container.getBoundingClientRect().top +
|
||||
container.scrollTop -
|
||||
area.getBoundingClientRect().top +
|
||||
area.scrollTop -
|
||||
8,
|
||||
behavior: "smooth",
|
||||
});
|
||||
@@ -114,9 +148,13 @@ export function EditorSection({
|
||||
*/
|
||||
export function LazySection({ children }: { children: ReactNode }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const searching = useContext(SearchingContext);
|
||||
const [shown, setShown] = useState(
|
||||
() => typeof IntersectionObserver === "undefined",
|
||||
);
|
||||
useEffect(() => {
|
||||
if (searching) setShown(true);
|
||||
}, [searching]);
|
||||
useLayoutEffect(() => {
|
||||
if (shown) return;
|
||||
const el = ref.current;
|
||||
@@ -167,9 +205,39 @@ export function EditorPane({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const ids = bands.flatMap((band) => band.items.map((item) => item.id));
|
||||
const formRef = useRef<HTMLDivElement>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const [hiddenIds, setHiddenIds] = useState<Set<string>>(new Set());
|
||||
const searching = query.trim().length > 0;
|
||||
const onFiltered = useCallback((root: HTMLElement) => {
|
||||
const next = new Set<string>();
|
||||
for (const el of root.querySelectorAll<HTMLElement>(
|
||||
`[data-section][${HIDDEN_ATTR}]`,
|
||||
)) {
|
||||
if (el.dataset.section) next.add(el.dataset.section);
|
||||
}
|
||||
setHiddenIds((prev) =>
|
||||
prev.size === next.size && [...next].every((id) => prev.has(id))
|
||||
? prev
|
||||
: next,
|
||||
);
|
||||
}, []);
|
||||
const anyShown = useSettingsPageFilter(
|
||||
formRef,
|
||||
query,
|
||||
"editor",
|
||||
"[data-section]",
|
||||
onFiltered,
|
||||
);
|
||||
const visibleBands = bands
|
||||
.map((band) => ({
|
||||
...band,
|
||||
items: band.items.filter((item) => !hiddenIds.has(item.id)),
|
||||
}))
|
||||
.filter((band) => band.items.length > 0);
|
||||
const ids = visibleBands.flatMap((band) => band.items.map((item) => item.id));
|
||||
const { active, scrollTo } = useScrollSpy(scrollRef, ids);
|
||||
const showNav = ids.length > 1;
|
||||
const showNav = bands.flatMap((band) => band.items).length > 1;
|
||||
if (revealRef) revealRef.current = scrollTo;
|
||||
|
||||
return (
|
||||
@@ -179,7 +247,7 @@ export function EditorPane({
|
||||
aria-label={t("manage.sections")}
|
||||
className="hidden w-52 shrink-0 flex-col gap-3 overflow-y-auto border-r border-border py-2.5 thin-scrollbar lg:flex"
|
||||
>
|
||||
{bands.map((band) => (
|
||||
{visibleBands.map((band) => (
|
||||
<div key={band.id} className="flex flex-col">
|
||||
<GroupHeading title={band.label} className="px-2.5 pb-1.5" />
|
||||
{band.items.map((item) => {
|
||||
@@ -220,10 +288,20 @@ export function EditorPane({
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div className={`shrink-0 border-b border-border ${PANEL.band}`}>
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<PanelSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t("manage.searchSettings")}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showNav && (
|
||||
<div className="shrink-0 border-b border-border px-1 lg:hidden">
|
||||
<TabStrip
|
||||
tabs={bands.flatMap((band) =>
|
||||
tabs={visibleBands.flatMap((band) =>
|
||||
band.items.map((item) => ({ id: item.id, label: item.label })),
|
||||
)}
|
||||
activeTab={active}
|
||||
@@ -235,9 +313,17 @@ export function EditorPane({
|
||||
ref={scrollRef}
|
||||
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
|
||||
>
|
||||
<div className={`mx-auto flex max-w-3xl flex-col ${PANEL.gap} pb-6`}>
|
||||
{!anyShown && (
|
||||
<EmptyState icon={Search} title={t("manage.noMatchingSettings")} />
|
||||
)}
|
||||
<div
|
||||
ref={formRef}
|
||||
className={`mx-auto flex max-w-3xl flex-col ${PANEL.gap} pb-6`}
|
||||
>
|
||||
{banner}
|
||||
{children}
|
||||
<SearchingContext.Provider value={searching}>
|
||||
{children}
|
||||
</SearchingContext.Provider>
|
||||
</div>
|
||||
</div>
|
||||
{(footer || errorCount > 0) && (
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { PanelSearch, Segmented } from "@/components/panel-layout";
|
||||
import { PanelSearch } from "@/components/panel-layout";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { rem } from "@/lib/rem";
|
||||
import type { Credential, Host } from "@/types/ui-types";
|
||||
@@ -143,24 +143,69 @@ export function ManageList({
|
||||
<div
|
||||
className={cn(
|
||||
editing ? "hidden md:flex" : "flex",
|
||||
"w-full shrink-0 flex-col border-r border-border bg-sidebar md:w-72",
|
||||
"w-full shrink-0 flex-col border-r border-border bg-background md:w-72",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2 border-b border-border px-2.5 py-2">
|
||||
<Segmented
|
||||
value={mode}
|
||||
onChange={onMode}
|
||||
options={[
|
||||
{ value: "hosts", label: t("nav.hosts"), count: hosts.length },
|
||||
<nav
|
||||
aria-label={t("manage.sections")}
|
||||
className="flex shrink-0 flex-col border-b border-border py-1"
|
||||
>
|
||||
{(
|
||||
[
|
||||
{
|
||||
value: "hosts",
|
||||
label: t("nav.hosts"),
|
||||
icon: Server,
|
||||
count: hosts.length,
|
||||
},
|
||||
{
|
||||
value: "credentials",
|
||||
label: t("nav.credentials"),
|
||||
icon: KeyRound,
|
||||
count: credentials.length,
|
||||
},
|
||||
{ value: "defaults", label: t("manage.defaults") },
|
||||
]}
|
||||
className="w-full [&>button]:flex-1"
|
||||
/>
|
||||
{
|
||||
value: "defaults",
|
||||
label: t("manage.defaults"),
|
||||
icon: SlidersHorizontal,
|
||||
},
|
||||
] as {
|
||||
value: ManageMode;
|
||||
label: string;
|
||||
icon: typeof Server;
|
||||
count?: number;
|
||||
}[]
|
||||
).map((item) => {
|
||||
const active = item.value === mode;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={() => onMode(item.value)}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 border-l-2 py-1.5 pl-2 pr-2.5 text-left transition-colors",
|
||||
rowFocus,
|
||||
active
|
||||
? "border-accent-brand bg-accent-brand/10 text-accent-brand"
|
||||
: "border-transparent text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium">
|
||||
{item.label}
|
||||
</span>
|
||||
{item.count !== undefined && (
|
||||
<span className="shrink-0 text-[10px] tabular-nums opacity-60">
|
||||
{item.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="flex flex-col gap-2 border-b border-border px-2.5 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<PanelSearch
|
||||
value={query}
|
||||
|
||||
@@ -289,6 +289,16 @@ export {
|
||||
type PanelDensity,
|
||||
type PanelViewMode,
|
||||
} from "@/components/panel-layout";
|
||||
export {
|
||||
AddButton,
|
||||
FormFooter,
|
||||
ListBadge,
|
||||
ListRow,
|
||||
ListRowAction,
|
||||
ListRowFolder,
|
||||
PanelList,
|
||||
type ListRowTone,
|
||||
} from "@/components/list-kit";
|
||||
export {
|
||||
DataView,
|
||||
type DataColumn,
|
||||
|
||||
@@ -64,7 +64,12 @@ export function PluginConsentPrompt({
|
||||
<Button variant="ghost" onClick={onCancel} disabled={busy}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={onConfirm} disabled={busy}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
>
|
||||
{mode === "update"
|
||||
? t("plugins.manager.consent.agreeUpdate")
|
||||
: t("plugins.manager.install")}
|
||||
|
||||
@@ -98,8 +98,9 @@ export function PluginDetail({
|
||||
plugin.updateAvailable &&
|
||||
!plugin.pinnedVersion && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
className="gap-1.5 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
disabled={busy || locked}
|
||||
onClick={() => void manager.requestUpdate(plugin)}
|
||||
>
|
||||
@@ -148,8 +149,9 @@ export function PluginDetail({
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
className="gap-1.5 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
disabled={busy || locked || !plugin.latestVersion}
|
||||
onClick={() => manager.requestInstall(plugin)}
|
||||
>
|
||||
@@ -446,8 +448,8 @@ function DataCard({
|
||||
{t("plugins.manager.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
<div className="grid grid-cols-2 gap-px border-b border-border bg-border sm:grid-cols-4">
|
||||
<div className="-mx-3 -my-1 flex flex-col md:-mx-4">
|
||||
<div className="grid grid-cols-2 gap-px bg-border sm:grid-cols-4">
|
||||
<Stat
|
||||
label={t("plugins.manager.data.tables")}
|
||||
value={`${data.tables.length}`}
|
||||
@@ -470,7 +472,7 @@ function DataCard({
|
||||
/>
|
||||
</div>
|
||||
{data.tables.length > 0 && (
|
||||
<div className="divide-y divide-border">
|
||||
<div className="divide-y divide-border border-t border-border">
|
||||
{data.tables.map((table) => (
|
||||
<div
|
||||
key={table.name}
|
||||
|
||||
@@ -472,8 +472,9 @@ function InstalledCard({
|
||||
<div className="flex items-center gap-1.5 border-t border-border px-3 py-2">
|
||||
{plugin.updateAvailable && !plugin.pinnedVersion && !failed && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="gap-1"
|
||||
className="gap-1 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
disabled={busy || locked}
|
||||
onClick={() => void manager.requestUpdate(plugin)}
|
||||
>
|
||||
@@ -651,9 +652,11 @@ function BrowseCard({
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={busy || manager.managedByServer || !plugin.latestVersion}
|
||||
onClick={() => manager.requestInstall(plugin)}
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
>
|
||||
{t("plugins.manager.install")}
|
||||
</Button>
|
||||
@@ -706,8 +709,9 @@ function UpdatesSection({
|
||||
` ${t("plugins.manager.updatesNeedReview", { count: gated.length })}`}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
className="gap-1.5 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
disabled={manager.busy.size > 0 || manager.managedByServer}
|
||||
onClick={() => void manager.updateAll()}
|
||||
>
|
||||
@@ -802,9 +806,11 @@ function UpdateRow({
|
||||
</label>
|
||||
{!plugin.pinnedVersion && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
disabled={busy || manager.managedByServer}
|
||||
onClick={() => void manager.requestUpdate(plugin)}
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
>
|
||||
{gated
|
||||
? t("plugins.manager.review")
|
||||
|
||||
@@ -233,8 +233,9 @@ export function FeatureSettingsForm({
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
className="h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
disabled={!dirty || saving}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -453,7 +454,7 @@ export function KeybindingsSettings() {
|
||||
<label className="text-xs font-semibold">
|
||||
{t("newUi.sidebar.keybindings.actionLabel")}
|
||||
</label>
|
||||
<select
|
||||
<Select2
|
||||
value={draft.type}
|
||||
onChange={(e) => setDraft({ type: e.target.value })}
|
||||
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
@@ -466,7 +467,7 @@ export function KeybindingsSettings() {
|
||||
{t(action.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select2>
|
||||
</div>
|
||||
|
||||
{Editor && <Editor action={draft} onChange={setDraft} />}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, Settings as SettingsIcon } from "lucide-react";
|
||||
import { ChevronDown, Search, Settings as SettingsIcon } from "lucide-react";
|
||||
import {
|
||||
GroupHeading,
|
||||
PANEL,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "./FeatureSettingsSections";
|
||||
import { KeybindingsSettings } from "./KeybindingsSettings";
|
||||
import { SettingsEmbedContext } from "./settings-embed";
|
||||
import { useSettingsPageFilter } from "./page-filter";
|
||||
import {
|
||||
CORE_SETTINGS_PAGES,
|
||||
SETTINGS_BAND_ORDER,
|
||||
@@ -35,6 +36,8 @@ import {
|
||||
export interface SettingsTabProps {
|
||||
/** The page to open, from the tab's data. */
|
||||
section?: string;
|
||||
/** An element id on that page to scroll to, as "id@nonce". */
|
||||
reveal?: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
onLogout?: () => void;
|
||||
@@ -76,6 +79,7 @@ function revealText(container: HTMLElement, needle: string): boolean {
|
||||
/** Everything that configures the app, one page at a time. */
|
||||
export function SettingsTab({
|
||||
section,
|
||||
reveal,
|
||||
username,
|
||||
isAdmin,
|
||||
onLogout,
|
||||
@@ -88,7 +92,9 @@ export function SettingsTab({
|
||||
const adminFeatures = useFeatureSettings("admin");
|
||||
const [query, setQuery] = useState("");
|
||||
const [navOpen, setNavOpen] = useState(false);
|
||||
const [pageQuery, setPageQuery] = useState("");
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const pages = useMemo(() => {
|
||||
const plugins = new Map<string, SettingsPage>();
|
||||
@@ -130,15 +136,42 @@ export function SettingsTab({
|
||||
if (section) setCurrent(section);
|
||||
}, [section]);
|
||||
|
||||
// The page renders lazily, so the target may take a moment to appear.
|
||||
useEffect(() => {
|
||||
const target = reveal?.split("@")[0];
|
||||
if (!target) return;
|
||||
let tries = 0;
|
||||
let timer = 0;
|
||||
const attempt = () => {
|
||||
const el = contentRef.current?.querySelector<HTMLElement>(
|
||||
`#${CSS.escape(target)}`,
|
||||
);
|
||||
if (el) {
|
||||
el.scrollIntoView?.({ block: "start", behavior: "smooth" });
|
||||
el.classList.add("settings-search-hit");
|
||||
timer = window.setTimeout(
|
||||
() => el.classList.remove("settings-search-hit"),
|
||||
1600,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (++tries < 15) timer = window.setTimeout(attempt, 120);
|
||||
};
|
||||
timer = window.setTimeout(attempt, 60);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [reveal]);
|
||||
|
||||
const page =
|
||||
pages.find((p) => p.id === current) ??
|
||||
pages.find((p) => p.id === "account")!;
|
||||
const pageHasMatches = useSettingsPageFilter(pageRef, pageQuery, page.id);
|
||||
const hits = searchSettingsPages(pages, query, (key) => t(key));
|
||||
const pageLabel = (p: SettingsPage) => p.label ?? t(p.labelKey);
|
||||
|
||||
const go = (id: string) => {
|
||||
setCurrent(id);
|
||||
setNavOpen(false);
|
||||
setPageQuery("");
|
||||
contentRef.current?.scrollTo?.({ top: 0 });
|
||||
const needle = query.trim();
|
||||
if (!needle) return;
|
||||
@@ -261,7 +294,7 @@ export function SettingsTab({
|
||||
<nav
|
||||
aria-label={t("settings.pages")}
|
||||
className={cn(
|
||||
"w-full shrink-0 overflow-y-auto border-r border-border bg-sidebar thin-scrollbar md:static md:block md:w-60",
|
||||
"w-full shrink-0 overflow-y-auto border-r border-border bg-background thin-scrollbar md:static md:block md:w-60",
|
||||
navOpen ? "absolute inset-0 z-20 block" : "hidden",
|
||||
)}
|
||||
>
|
||||
@@ -269,11 +302,25 @@ export function SettingsTab({
|
||||
</nav>
|
||||
|
||||
<SurfaceScope kind="tab" className="min-w-0">
|
||||
<div className={`shrink-0 border-b border-border ${PANEL.band}`}>
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<PanelSearch
|
||||
value={pageQuery}
|
||||
onChange={setPageQuery}
|
||||
placeholder={t("settings.searchPage")}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={`min-h-0 flex-1 overflow-y-auto thin-scrollbar ${PANEL.body}`}
|
||||
>
|
||||
{!pageHasMatches && (
|
||||
<EmptyState icon={Search} title={t("settings.noPageMatches")} />
|
||||
)}
|
||||
<div
|
||||
ref={pageRef}
|
||||
className={`mx-auto flex w-full max-w-3xl flex-col ${PANEL.gap} pb-6`}
|
||||
>
|
||||
<SettingsEmbedContext.Provider value>
|
||||
|
||||
@@ -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")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="submit"
|
||||
className="rounded-none"
|
||||
className="rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
disabled={checking || !target.serverUrl.trim()}
|
||||
>
|
||||
{checking && <Loader2 className="size-4 animate-spin" />}
|
||||
@@ -420,8 +421,9 @@ export function LinkServerDialog({
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
className="rounded-none"
|
||||
className="rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
disabled={!token}
|
||||
onClick={() => token && void finish(token, mode)}
|
||||
>
|
||||
|
||||
@@ -198,7 +198,8 @@ export function SyncPanel() {
|
||||
<li>{t("sync.unlinkedPointAccount")}</li>
|
||||
</ul>
|
||||
<Button
|
||||
className="self-start rounded-none"
|
||||
variant="outline"
|
||||
className="self-start rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
onClick={() => setLinkOpen(true)}
|
||||
>
|
||||
<Cloud className="size-4" />
|
||||
@@ -302,8 +303,9 @@ export function SyncPanel() {
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{state === "signed_out" ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] rounded-none"
|
||||
className="h-7 text-[10px] rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
onClick={() => setReloginOpen(true)}
|
||||
>
|
||||
{t("sync.signInAgain")}
|
||||
@@ -535,8 +537,9 @@ export function SyncPanel() {
|
||||
</div>
|
||||
{proxyDraft && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start h-7 text-[10px] rounded-none"
|
||||
className="self-start h-7 text-[10px] rounded-none border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
disabled={busy === "proxy"}
|
||||
onClick={() =>
|
||||
run("proxy", async () => {
|
||||
|
||||
@@ -244,7 +244,7 @@ export function CommandPalette({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-start justify-center bg-background/40 pt-[15vh] backdrop-blur-sm"
|
||||
className="fixed inset-0 z-[100] flex items-start justify-center bg-background/70 pt-[15vh]"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<div
|
||||
@@ -270,7 +270,7 @@ export function CommandPalette({
|
||||
<Kbd className="ml-2">ESC</Kbd>
|
||||
</div>
|
||||
|
||||
<CommandList className="thin-scrollbar max-h-[60vh]">
|
||||
<CommandList className="thin-scrollbar max-h-[60vh] [contain:content]">
|
||||
<CommandEmpty className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t("commandPalette.noHostsFound", { search })}
|
||||
</CommandEmpty>
|
||||
|
||||
@@ -573,7 +573,7 @@ export function TabBar({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-full w-12.5 border-y-0 border-r-0 border-border rounded-none text-muted-foreground hover:text-foreground"
|
||||
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
@@ -632,7 +632,7 @@ export function TabBar({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-full w-12.5 rounded-none border-y-0 border-border ${rightDockOpen ? "text-accent-brand bg-accent-brand/10" : "text-muted-foreground hover:text-foreground"}`}
|
||||
className={`h-full w-12.5 rounded-none ${rightDockOpen ? "text-accent-brand bg-accent-brand/10" : "text-muted-foreground hover:text-foreground"}`}
|
||||
title={t("nav.toggleRightDock")}
|
||||
aria-label={t("nav.toggleRightDock")}
|
||||
aria-pressed={!!rightDockOpen}
|
||||
@@ -648,7 +648,7 @@ export function TabBar({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-full w-12.5 rounded-none border-y-0 border-border text-muted-foreground hover:text-foreground"
|
||||
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
|
||||
title={
|
||||
isAppFullscreen
|
||||
? t("nav.exitFullscreen")
|
||||
@@ -673,7 +673,7 @@ export function TabBar({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-full w-12.5 rounded-none border-y-0 border-border text-muted-foreground hover:text-foreground"
|
||||
className="h-full w-12.5 rounded-none text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<ChevronUp
|
||||
|
||||
@@ -71,7 +71,7 @@ export function SplitLayoutMenu({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`h-full w-12.5 rounded-none border-y-0 border-border ${activeIsSplit ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
|
||||
className={`h-full w-12.5 rounded-none ${activeIsSplit ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
|
||||
title={t("splitScreen.title")}
|
||||
aria-label={t("splitScreen.title")}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import {
|
||||
requestFromLegacyEvent,
|
||||
requestManage,
|
||||
} from "@/manage/manage-requests";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
LayoutPanelLeft,
|
||||
@@ -178,7 +182,14 @@ const DashboardTabHost = memo(function DashboardTabHost({
|
||||
}) {
|
||||
return withTabSuspense(
|
||||
<DashboardTab
|
||||
onOpenSingletonTab={(type) => shell.openSingletonTab(type)}
|
||||
onOpenSingletonTab={(type, pendingEvent) => {
|
||||
// "Add host" and friends say what to open in the Manage tab.
|
||||
const request = pendingEvent
|
||||
? requestFromLegacyEvent(pendingEvent)
|
||||
: null;
|
||||
if (request) requestManage(request);
|
||||
else shell.openSingletonTab(type);
|
||||
}}
|
||||
onOpenTab={(host, type) => shell.openTab(host, type)}
|
||||
isVisible={isVisible}
|
||||
/>,
|
||||
@@ -255,6 +266,11 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
|
||||
? tab.data.section
|
||||
: undefined
|
||||
}
|
||||
reveal={
|
||||
typeof tab.data?.reveal === "string"
|
||||
? `${tab.data.reveal}@${String(tab.data.revealAt ?? "")}`
|
||||
: undefined
|
||||
}
|
||||
/>,
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { pluginKey } from "@/lib/plugin-i18n";
|
||||
import { PluginIcon } from "@/lib/plugin-icon";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { PanelSearch } from "@/components/panel-layout";
|
||||
import {
|
||||
Activity,
|
||||
Check,
|
||||
@@ -151,11 +152,11 @@ export function AdminUsersSection({
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-2 border-b border-border">
|
||||
<Input
|
||||
<PanelSearch
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onChange={onSearchChange}
|
||||
placeholder={t("admin.searchUsers")}
|
||||
className="h-7 text-xs rounded-none"
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
{users.map((user) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { useConfirm } from "@/components/surface/surface-scope";
|
||||
import { useEffect, useState } from "react";
|
||||
import { hostProtocolFlags, type HostProtocols } from "./host-protocols";
|
||||
@@ -943,7 +944,7 @@ export function AdminUserManagePanel({
|
||||
</span>
|
||||
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
|
||||
{t("admin.deleteSuccessorLabel")}
|
||||
<select
|
||||
<Select2
|
||||
value={successor}
|
||||
onChange={(e) => setSuccessor(e.target.value)}
|
||||
className="h-7 border border-border bg-background px-2 text-xs text-foreground outline-none"
|
||||
@@ -957,7 +958,7 @@ export function AdminUserManagePanel({
|
||||
<option value="none">
|
||||
{t("admin.deleteSuccessorNone")}
|
||||
</option>
|
||||
</select>
|
||||
</Select2>
|
||||
<span>{t("admin.deleteSuccessorDesc")}</span>
|
||||
</label>
|
||||
<Button
|
||||
|
||||
@@ -16,10 +16,12 @@ import { Skeleton } from "@/components/skeleton";
|
||||
import { readRailPreference, setRailPreference } from "./rail-preferences";
|
||||
import {
|
||||
groupRailItems,
|
||||
moveRailItem,
|
||||
useRailItems,
|
||||
type RailGroup,
|
||||
type RailItemDef,
|
||||
} from "./rail-items";
|
||||
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
|
||||
import { toggleHiddenRailTab, useHiddenRailTabs } from "./hidden-rail-tabs";
|
||||
import { RailBadge } from "./RailBadge";
|
||||
import { rem } from "@/lib/rem";
|
||||
@@ -200,6 +202,9 @@ export function AppRail({
|
||||
|
||||
// Plugins add and remove rail items at runtime, and permissions hide some.
|
||||
const railItems = useRailItems();
|
||||
const uiPrefs = useUiPreferencesContext();
|
||||
const [dragId, setDragId] = useState<string | null>(null);
|
||||
const [dropBefore, setDropBefore] = useState<string | null>(null);
|
||||
|
||||
const railExpanded = pinned || (expandOnHover && hovered) || managing;
|
||||
const mainItems = railItems.filter((item) => item.placement !== "footer");
|
||||
@@ -226,15 +231,48 @@ export function AppRail({
|
||||
setMenuPos(null);
|
||||
};
|
||||
|
||||
const sameBand = (a: string, b: string) =>
|
||||
(mainItems.find((item) => item.id === a)?.group ?? "tools") ===
|
||||
(mainItems.find((item) => item.id === b)?.group ?? "tools");
|
||||
|
||||
const renderItem = (item: RailItemDef) => {
|
||||
const Icon = item.icon;
|
||||
const title = t(item.labelKey);
|
||||
const isTab = item.kind === "tab";
|
||||
const active = !isTab && sidebarOpen && railView === item.id;
|
||||
const reorderable = item.placement !== "footer" && !!uiPrefs;
|
||||
const dropHere =
|
||||
dragId !== null && dropBefore === item.id && dragId !== item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
draggable={reorderable}
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
setDragId(item.id);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDragId(null);
|
||||
setDropBefore(null);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!dragId || dragId === item.id || !sameBand(dragId, item.id))
|
||||
return;
|
||||
e.preventDefault();
|
||||
setDropBefore(item.id);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!dragId || !sameBand(dragId, item.id)) return;
|
||||
e.preventDefault();
|
||||
uiPrefs?.setOverride(
|
||||
"rail",
|
||||
"order",
|
||||
moveRailItem(mainItems, dragId, item.id),
|
||||
);
|
||||
setDragId(null);
|
||||
setDropBefore(null);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (isTab || (item.promotable && (e.ctrlKey || e.metaKey))) {
|
||||
onOpenTab?.(item.id as TabType);
|
||||
@@ -260,7 +298,7 @@ export function AppRail({
|
||||
aria-current={active ? "page" : undefined}
|
||||
title={item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title}
|
||||
style={btnStyle}
|
||||
className={`${btnBase} ${active ? activeClass : idle}`}
|
||||
className={`${btnBase} ${active ? activeClass : idle} ${dragId === item.id ? "opacity-40" : ""} ${dropHere ? "relative before:absolute before:inset-x-1 before:-top-0.5 before:h-0.5 before:bg-accent-brand" : ""}`}
|
||||
>
|
||||
<RailIcon useBadge={item.useBadge}>
|
||||
<Icon size={16} />
|
||||
|
||||
+110
-185
@@ -3,7 +3,7 @@ import { getTabType, isPersistentTabType } from "@/shell/tab-registry";
|
||||
import { ComponentSlot } from "@/shell/ActionSlot";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink, Plug, Search, X, Pencil, Check } from "lucide-react";
|
||||
import { ExternalLink, Plug, X, Pencil, Check } from "lucide-react";
|
||||
import {
|
||||
getActiveSessions,
|
||||
deleteOpenTab,
|
||||
@@ -13,14 +13,9 @@ import {
|
||||
import { tabIcon } from "@/shell/tabUtils";
|
||||
import { getSessionTimeoutMinutes } from "@/api/open-tabs-api";
|
||||
import type { Tab, TabType } from "@/types/ui-types";
|
||||
import { Badge } from "@/components/badge";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/tooltip";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { Facts, GroupHeading, PanelSearch } from "@/components/panel-layout";
|
||||
import { ListBadge, ListRow, ListRowAction } from "@/components/list-kit";
|
||||
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
|
||||
|
||||
@@ -81,6 +76,7 @@ function ConnectionRow({
|
||||
hostName,
|
||||
subLabel,
|
||||
icon,
|
||||
stripe,
|
||||
onSwitch,
|
||||
onClose,
|
||||
switchTitle,
|
||||
@@ -95,6 +91,7 @@ function ConnectionRow({
|
||||
hostName?: string;
|
||||
subLabel: string;
|
||||
icon: React.ReactNode;
|
||||
stripe: number;
|
||||
onSwitch?: () => void;
|
||||
onClose: () => void;
|
||||
switchTitle?: string;
|
||||
@@ -106,12 +103,6 @@ function ConnectionRow({
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(name);
|
||||
|
||||
function startEdit(e: React.MouseEvent) {
|
||||
e.stopPropagation();
|
||||
setEditValue(name);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function commitEdit() {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed && trimmed !== name && onRename) {
|
||||
@@ -120,155 +111,103 @@ function ConnectionRow({
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === "Enter") commitEdit();
|
||||
if (e.key === "Escape") setEditing(false);
|
||||
}
|
||||
const registered = getTabType(tabType)?.titleKey;
|
||||
const typeLabel =
|
||||
CORE_TYPE_LABELS[tabType] ?? (registered ? t(registered) : tabType);
|
||||
const stop = (e: React.PointerEvent) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<div
|
||||
role={onSwitch && !editing ? "button" : undefined}
|
||||
tabIndex={onSwitch && !editing ? 0 : undefined}
|
||||
<ListRow
|
||||
stripe={stripe}
|
||||
tone={isLive ? "success" : "muted"}
|
||||
selected={isActive}
|
||||
active={editing}
|
||||
dimmed={faded || isDragging}
|
||||
onClick={!editing ? onSwitch : undefined}
|
||||
onKeyDown={(e) => !editing && e.key === "Enter" && onSwitch?.()}
|
||||
className={`group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 transition-colors last:border-b-0 ${
|
||||
faded ? "opacity-60" : ""
|
||||
} ${isDragging ? "opacity-30" : ""} ${
|
||||
isActive
|
||||
? "bg-accent-brand/8 cursor-pointer border-l-2 border-l-accent-brand"
|
||||
: onSwitch && !editing
|
||||
? "hover:bg-muted/40 cursor-pointer"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`shrink-0 flex items-center justify-center size-7 rounded ${
|
||||
isActive
|
||||
? "bg-accent-brand/15 text-accent-brand"
|
||||
: "bg-muted/60 text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
className={`shrink-0 size-1.5 rounded-full ${
|
||||
isLive ? "bg-green-500" : "bg-muted-foreground/30"
|
||||
}`}
|
||||
icon={icon}
|
||||
title={
|
||||
editing ? (
|
||||
<input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") commitEdit();
|
||||
if (e.key === "Escape") setEditing(false);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={stop}
|
||||
className="w-full min-w-0 border-b border-accent-brand bg-transparent text-[13px] font-semibold text-foreground outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
{editing ? (
|
||||
<input
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={commitEdit}
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="text-xs font-semibold flex-1 min-w-0 bg-transparent border-b border-accent-brand outline-none text-foreground"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`text-xs font-semibold truncate flex-1 ${
|
||||
isActive ? "text-accent-brand" : "text-foreground"
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[9px] px-1 py-0 h-4 font-mono shrink-0 text-muted-foreground/60 border-border/60"
|
||||
) : (
|
||||
name
|
||||
)
|
||||
}
|
||||
badges={<ListBadge className="ml-auto font-mono">{typeLabel}</ListBadge>}
|
||||
meta={
|
||||
<Facts>
|
||||
{hostName && hostName !== name ? <span>{hostName}</span> : null}
|
||||
<span>{subLabel}</span>
|
||||
</Facts>
|
||||
}
|
||||
actions={
|
||||
editing ? (
|
||||
<ListRowAction
|
||||
label={t("common.save")}
|
||||
tone="brand"
|
||||
onPointerDown={stop}
|
||||
onClick={commitEdit}
|
||||
>
|
||||
{CORE_TYPE_LABELS[tabType] ??
|
||||
(getTabType(tabType)?.titleKey
|
||||
? t(getTabType(tabType)!.titleKey!)
|
||||
: tabType)}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground/60 truncate pl-3">
|
||||
{hostName && hostName !== name ? (
|
||||
<span className="text-muted-foreground/50 mr-2.5">{hostName}</span>
|
||||
) : null}
|
||||
{subLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{editing ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
commitEdit();
|
||||
}}
|
||||
className="size-6 flex items-center justify-center text-accent-brand hover:bg-muted/60 rounded transition-colors"
|
||||
<Check />
|
||||
</ListRowAction>
|
||||
) : (
|
||||
<>
|
||||
{onRename && (
|
||||
<ListRowAction
|
||||
label={t("connections.rename")}
|
||||
onPointerDown={stop}
|
||||
onClick={() => {
|
||||
setEditValue(name);
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
<Pencil />
|
||||
</ListRowAction>
|
||||
)}
|
||||
{switchTitle && onSwitch && (
|
||||
<ListRowAction
|
||||
label={switchTitle}
|
||||
tone="brand"
|
||||
onPointerDown={stop}
|
||||
onClick={onSwitch}
|
||||
>
|
||||
<ExternalLink />
|
||||
</ListRowAction>
|
||||
)}
|
||||
<ListRowAction
|
||||
label={t("common.close")}
|
||||
tone="destructive"
|
||||
onPointerDown={stop}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</button>
|
||||
) : (
|
||||
onRename && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={startEdit}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 rounded transition-colors"
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
{t("connections.rename")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
{switchTitle && onSwitch && !editing && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSwitch();
|
||||
}}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 rounded transition-colors"
|
||||
>
|
||||
<ExternalLink className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">{switchTitle}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!editing && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<X />
|
||||
</ListRowAction>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ label, count }: { label: string; count: number }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/20">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60 flex-1">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-[10px] font-semibold text-muted-foreground/40 bg-muted/60 rounded px-1.5 py-0.5">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
<GroupHeading
|
||||
title={label}
|
||||
count={count}
|
||||
className="border-b border-border/40 px-3 pb-1.5 pt-2.5"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -450,42 +389,28 @@ export function ConnectionsPanel({
|
||||
return (
|
||||
<div className="flex flex-col flex-1">
|
||||
{pluginSections}
|
||||
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
|
||||
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
|
||||
<Plug className="size-5 text-muted-foreground/30" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-muted-foreground/60">
|
||||
{t("connections.noConnections")}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/40">
|
||||
{t("connections.noConnectionsDesc")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Plug}
|
||||
title={t("connections.noConnections")}
|
||||
hint={t("connections.noConnectionsDesc")}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="relative px-3 py-2 border-b border-border/60">
|
||||
<Search className="absolute left-5.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50 pointer-events-none" />
|
||||
<Input
|
||||
placeholder={t("connections.search")}
|
||||
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
||||
<PanelSearch
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8 h-7 text-xs"
|
||||
onChange={setSearch}
|
||||
placeholder={t("connections.search")}
|
||||
fill
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!hasResults && (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
|
||||
<span className="text-xs text-muted-foreground/50">
|
||||
{t("connections.noSearchResults")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!hasResults && <EmptyState title={t("connections.noSearchResults")} />}
|
||||
|
||||
{filteredOpenTabs.length > 0 && (
|
||||
<div className="flex flex-col">
|
||||
@@ -493,7 +418,7 @@ export function ConnectionsPanel({
|
||||
label={t("connections.sectionOpen")}
|
||||
count={filteredOpenTabs.length}
|
||||
/>
|
||||
{filteredOpenTabs.map((tab) => {
|
||||
{filteredOpenTabs.map((tab, index) => {
|
||||
const isActive = tab.id === activeTabId;
|
||||
const liveSession = tab.instanceId
|
||||
? sessionByInstanceId.get(tab.instanceId)
|
||||
@@ -524,7 +449,7 @@ export function ConnectionsPanel({
|
||||
didDragRef.current = false;
|
||||
setDragTabId(tab.id);
|
||||
}}
|
||||
className={`relative ${isDropTarget ? "border-t-2 border-accent-brand" : ""}`}
|
||||
className={`relative ${isDropTarget ? "before:absolute before:inset-x-0 before:-top-px before:z-20 before:h-0.5 before:bg-accent-brand" : ""}`}
|
||||
style={{
|
||||
cursor: dragTabId
|
||||
? isDraggingThis
|
||||
@@ -534,6 +459,7 @@ export function ConnectionsPanel({
|
||||
}}
|
||||
>
|
||||
<ConnectionRow
|
||||
stripe={index}
|
||||
isActive={isActive}
|
||||
isLive={isLive}
|
||||
tabType={tab.type}
|
||||
@@ -565,9 +491,7 @@ export function ConnectionsPanel({
|
||||
)}
|
||||
|
||||
{filteredBackgroundTabs.length > 0 && (
|
||||
<div
|
||||
className={`flex flex-col ${filteredOpenTabs.length > 0 ? "mt-2" : ""}`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<SectionHeader
|
||||
label={t("connections.sectionBackground")}
|
||||
count={filteredBackgroundTabs.length}
|
||||
@@ -579,7 +503,7 @@ export function ConnectionsPanel({
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{filteredBackgroundTabs.map((record) => {
|
||||
{filteredBackgroundTabs.map((record, index) => {
|
||||
const host = record.hostId
|
||||
? allHosts.find((h) => h.id === String(record.hostId))
|
||||
: undefined;
|
||||
@@ -588,6 +512,7 @@ export function ConnectionsPanel({
|
||||
return (
|
||||
<ConnectionRow
|
||||
key={record.id}
|
||||
stripe={index}
|
||||
isLive={false}
|
||||
faded
|
||||
tabType={record.tabType}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { InlineView } from "@/components/surface/surface-scope";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Rows3, SquareStack } from "lucide-react";
|
||||
@@ -47,7 +48,7 @@ export function CustomizeCredentialsSidebarPanel({
|
||||
: t("credentials.densityCompactDesc")
|
||||
}
|
||||
>
|
||||
<select
|
||||
<Select2
|
||||
value={preferences.display.density}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
@@ -66,7 +67,7 @@ export function CustomizeCredentialsSidebarPanel({
|
||||
<option value="compact">
|
||||
{t("credentials.displayDensityCompact")}
|
||||
</option>
|
||||
</select>
|
||||
</Select2>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
@@ -97,7 +98,7 @@ export function CustomizeCredentialsSidebarPanel({
|
||||
{t("credentials.actionsVisibilityDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
<Select2
|
||||
value={preferences.display.trayTrigger}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
@@ -116,7 +117,7 @@ export function CustomizeCredentialsSidebarPanel({
|
||||
</option>
|
||||
<option value="hover">{t("credentials.actionsHover")}</option>
|
||||
<option value="click">{t("credentials.actionsClick")}</option>
|
||||
</select>
|
||||
</Select2>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { InlineView } from "@/components/surface/surface-scope";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Rows3, SquareStack } from "lucide-react";
|
||||
import { PanelTop, Rows3, SquareStack } from "lucide-react";
|
||||
import { actionOrder, useHostActions } from "./host-contributions";
|
||||
import { showsInBar } from "./tree/host-bar-actions";
|
||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
import type {
|
||||
HostClickBehavior,
|
||||
@@ -25,6 +28,9 @@ export function CustomizeSidebarPanel({
|
||||
) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const actions = [...useHostActions()].sort(
|
||||
(a, b) => actionOrder(a) - actionOrder(b) || a.id.localeCompare(b.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<InlineView
|
||||
@@ -48,7 +54,7 @@ export function CustomizeSidebarPanel({
|
||||
: t("hosts.densityCompactDesc")
|
||||
}
|
||||
>
|
||||
<select
|
||||
<Select2
|
||||
value={preferences.display.density}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
@@ -67,7 +73,7 @@ export function CustomizeSidebarPanel({
|
||||
<option value="compact">
|
||||
{t("hosts.displayDensityCompact")}
|
||||
</option>
|
||||
</select>
|
||||
</Select2>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
@@ -112,7 +118,7 @@ export function CustomizeSidebarPanel({
|
||||
{t("hosts.actionsVisibilityDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
<Select2
|
||||
value={preferences.display.trayTrigger}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
@@ -129,7 +135,7 @@ export function CustomizeSidebarPanel({
|
||||
<option value="actionsOnly">{t("hosts.actionsOnly")}</option>
|
||||
<option value="hover">{t("hosts.actionsHover")}</option>
|
||||
<option value="click">{t("hosts.actionsClick")}</option>
|
||||
</select>
|
||||
</Select2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
@@ -140,7 +146,7 @@ export function CustomizeSidebarPanel({
|
||||
{t("hosts.hostClickBehaviorDesc")}
|
||||
</span>
|
||||
</div>
|
||||
<select
|
||||
<Select2
|
||||
value={preferences.display.hostClickBehavior}
|
||||
onChange={(e) =>
|
||||
update((prev) => ({
|
||||
@@ -160,7 +166,7 @@ export function CustomizeSidebarPanel({
|
||||
<option value="focusExistingDoubleClickNew">
|
||||
{t("hosts.hostClickFocusExistingDoubleNew")}
|
||||
</option>
|
||||
</select>
|
||||
</Select2>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t("hosts.openOnDoubleClick")}
|
||||
@@ -199,6 +205,49 @@ export function CustomizeSidebarPanel({
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
{actions.length > 0 && (
|
||||
<SectionCard
|
||||
title={t("hosts.connectBarTitle")}
|
||||
icon={<PanelTop className="size-3.5" />}
|
||||
>
|
||||
<p className="pt-2 text-xs text-muted-foreground">
|
||||
{t("hosts.connectBarDesc")}
|
||||
</p>
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<SettingRow
|
||||
key={action.id}
|
||||
label={t(action.titleKey)}
|
||||
rowId={`bar-action-${action.id}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon className="size-3.5 text-muted-foreground" />
|
||||
<FakeSwitch
|
||||
checked={showsInBar(
|
||||
preferences.display.barActions,
|
||||
action,
|
||||
)}
|
||||
onChange={(v) =>
|
||||
update((prev) => ({
|
||||
...prev,
|
||||
display: {
|
||||
...prev.display,
|
||||
barActions: {
|
||||
...prev.display.barActions,
|
||||
[action.id]: v,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</SettingRow>
|
||||
);
|
||||
})}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-muted-foreground/70 leading-snug">
|
||||
{t("hosts.customizeSortHint")}
|
||||
</p>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { InlineView } from "@/components/surface/surface-scope";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -174,7 +175,7 @@ export function FolderMetadataDialog({
|
||||
<label className="text-xs font-semibold">
|
||||
{t("hosts.folderCredential")}
|
||||
</label>
|
||||
<select
|
||||
<Select2
|
||||
value={credentialId}
|
||||
onChange={(e) => setCredentialId(e.target.value)}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
@@ -185,7 +186,7 @@ export function FolderMetadataDialog({
|
||||
{c.username ? `${c.name} (${c.username})` : c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select2>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.folderCredentialHint")}
|
||||
</p>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { FormFooter } from "@/components/list-kit";
|
||||
import { InlineView } from "@/components/surface/surface-scope";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
getCredentials,
|
||||
getHostAuthOverride,
|
||||
@@ -127,25 +128,12 @@ export function HostAuthOverrideModal({
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={saving}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleSave}
|
||||
disabled={
|
||||
loading || loadError || saving || selectedId === initialId
|
||||
}
|
||||
>
|
||||
{saving ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
<FormFooter
|
||||
onCancel={() => onOpenChange(false)}
|
||||
onSave={() => void handleSave()}
|
||||
saving={saving}
|
||||
disabled={loading || loadError || selectedId === initialId}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -172,7 +160,7 @@ export function HostAuthOverrideModal({
|
||||
>
|
||||
{t("hosts.sharing.authOverrideCredentialLabel")}
|
||||
</label>
|
||||
<select
|
||||
<Select2
|
||||
id={`auth-override-${host.id}`}
|
||||
value={selectedId}
|
||||
onChange={(event) => setSelectedId(event.target.value)}
|
||||
@@ -192,7 +180,7 @@ export function HostAuthOverrideModal({
|
||||
: credential.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select2>
|
||||
{credentials.length === 0 && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{t("hosts.sharing.authOverrideNoCredentials")}
|
||||
|
||||
@@ -809,7 +809,7 @@ export function HostEditorGeneralTab({
|
||||
),
|
||||
].join(" ")}
|
||||
>
|
||||
<select
|
||||
<Select2
|
||||
className="flex h-7 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
value={form.connectionOrigin ?? ""}
|
||||
onChange={(e) =>
|
||||
@@ -826,7 +826,7 @@ export function HostEditorGeneralTab({
|
||||
<option value="remote">
|
||||
{t("hosts.connectionOriginRemote")}
|
||||
</option>
|
||||
</select>
|
||||
</Select2>
|
||||
</SettingRow>
|
||||
</HostOnly>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { InlineView } from "@/components/surface/surface-scope";
|
||||
import { FormFooter } from "@/components/list-kit";
|
||||
import { getErrorMessage } from "../lib/error-message.js";
|
||||
import { usePluginStore } from "@/plugin-host/plugin-store";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Download, Search } from "lucide-react";
|
||||
import { Search } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/button";
|
||||
import { Checkbox } from "@/components/checkbox";
|
||||
@@ -220,20 +221,13 @@ export function HostExportDialog({
|
||||
title={t("hosts.export.title")}
|
||||
width="wide"
|
||||
footer={
|
||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("hosts.export.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleExport}
|
||||
disabled={!canExport}
|
||||
>
|
||||
<Download className="size-3.5 mr-2" />
|
||||
{t("hosts.export.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
<FormFooter
|
||||
onCancel={onClose}
|
||||
cancelLabel={t("hosts.export.cancel")}
|
||||
onSave={() => void handleExport()}
|
||||
saveLabel={t("hosts.export.confirm")}
|
||||
disabled={!canExport}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row gap-4 flex-1 min-h-0 min-w-0 overflow-y-auto md:overflow-visible">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getErrorMessage } from "../lib/error-message.js";
|
||||
import { setBarActions } from "@/sidebar/tree/host-bar-actions";
|
||||
import { enabledHostProtocols, useHostProtocols } from "./host-protocols";
|
||||
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -248,6 +249,9 @@ export function HostsPanel({
|
||||
);
|
||||
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
|
||||
useHostSidebarPreferences();
|
||||
useEffect(() => {
|
||||
setBarActions(sidebarPrefs.display.barActions);
|
||||
}, [sidebarPrefs.display.barActions]);
|
||||
const sortKey = sidebarPrefs.sort.key;
|
||||
const pinnedFirst = sidebarPrefs.sort.pinnedFirst;
|
||||
const { arrangeLocked, toggleArrangeLock } = useArrangeLock(
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronUp, RotateCcw } from "lucide-react";
|
||||
import { FakeSwitch } from "@/components/section-card";
|
||||
import { useRailItems } from "./rail-items";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
useAreaPreferences,
|
||||
useUiPreferencesContext,
|
||||
} from "@/contexts/UiPreferencesContext";
|
||||
import { moveRailItem, useRailItems } from "./rail-items";
|
||||
|
||||
/**
|
||||
* Appearance > Sidebar > Navigation: one switch per rail destination,
|
||||
* including the ones plugins register, which appear and disappear here as
|
||||
* their plugin is turned on and off.
|
||||
* their plugin is turned on and off. The arrows reorder the rail.
|
||||
*/
|
||||
export function NavigationVisibilityToggles({
|
||||
hidden,
|
||||
@@ -16,34 +22,92 @@ export function NavigationVisibilityToggles({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const railItems = useRailItems();
|
||||
const uiPrefs = useUiPreferencesContext();
|
||||
const { order } = useAreaPreferences("rail");
|
||||
const main = railItems.filter((item) => item.placement !== "footer");
|
||||
const band = (id: string) =>
|
||||
main.find((item) => item.id === id)?.group ?? "tools";
|
||||
|
||||
const move = (id: string, direction: -1 | 1) => {
|
||||
const peers = main.filter((item) => band(item.id) === band(id));
|
||||
const at = peers.findIndex((item) => item.id === id);
|
||||
const target = at + direction;
|
||||
if (target < 0 || target >= peers.length) return;
|
||||
// Moving down means sitting before the item after the next one.
|
||||
const before =
|
||||
direction === -1 ? peers[target].id : (peers[target + 1]?.id ?? null);
|
||||
uiPrefs?.setOverride("rail", "order", moveRailItem(main, id, before));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{railItems
|
||||
.filter((item) => item.hideable !== false)
|
||||
.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between py-1.5"
|
||||
data-testid={`nav-toggle-${item.id}`}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
|
||||
<span className="text-muted-foreground">
|
||||
<item.icon size={12} />
|
||||
.map((item) => {
|
||||
const reorderable = item.placement !== "footer" && !!uiPrefs;
|
||||
const peers = main.filter((peer) => band(peer.id) === band(item.id));
|
||||
const index = peers.findIndex((peer) => peer.id === item.id);
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between gap-2 py-1.5"
|
||||
data-testid={`nav-toggle-${item.id}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-xs font-medium text-foreground">
|
||||
<span className="text-muted-foreground">
|
||||
<item.icon size={12} />
|
||||
</span>
|
||||
<span className="truncate">{t(item.labelKey)}</span>
|
||||
</span>
|
||||
{t(item.labelKey)}
|
||||
</span>
|
||||
<FakeSwitch
|
||||
checked={!hidden.has(item.id)}
|
||||
onChange={(visible) => {
|
||||
const next = new Set(hidden);
|
||||
if (visible) next.delete(item.id);
|
||||
else next.add(item.id);
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
{reorderable && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={index <= 0}
|
||||
title={t("nav.moveUp")}
|
||||
aria-label={t("nav.moveUp")}
|
||||
onClick={() => move(item.id, -1)}
|
||||
>
|
||||
<ChevronUp />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={index < 0 || index >= peers.length - 1}
|
||||
title={t("nav.moveDown")}
|
||||
aria-label={t("nav.moveDown")}
|
||||
onClick={() => move(item.id, 1)}
|
||||
>
|
||||
<ChevronDown />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<FakeSwitch
|
||||
checked={!hidden.has(item.id)}
|
||||
onChange={(visible) => {
|
||||
const next = new Set(hidden);
|
||||
if (visible) next.delete(item.id);
|
||||
else next.add(item.id);
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{uiPrefs && order && order.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="mt-1 self-start text-muted-foreground"
|
||||
onClick={() => uiPrefs.setOverride("rail", "order", null)}
|
||||
>
|
||||
<RotateCcw />
|
||||
{t("nav.resetRailOrder")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "@/components/input";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { Textarea } from "@/components/textarea";
|
||||
import { GroupHeading, PANEL } from "@/components/panel-layout";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
import { getCredentials } from "@/api/credentials-api";
|
||||
import { mapCredentials } from "./HostManagerData";
|
||||
@@ -27,7 +29,7 @@ const INLINE_AUTH_TYPES = new Set(["password", "key", "credential"]);
|
||||
const LABEL_CLASS =
|
||||
"text-[10px] font-semibold uppercase tracking-widest text-muted-foreground";
|
||||
const SELECT_CLASS =
|
||||
"flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring";
|
||||
"flex h-8 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring";
|
||||
|
||||
interface QuickConnectPanelProps {
|
||||
onConnect: (host: Host, type: string) => void;
|
||||
@@ -54,7 +56,6 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
const [authFields, setAuthFields] = useState<Record<string, unknown>>({});
|
||||
const [password, setPassword] = useState("");
|
||||
const [privateKey, setPrivateKey] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [credentialId, setCredentialId] = useState("");
|
||||
const [credentials, setCredentials] = useState<
|
||||
{ id: string; name: string; username: string }[]
|
||||
@@ -140,7 +141,8 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
|
||||
<GroupHeading title={t("newUi.sidebar.quickConnect.where")} />
|
||||
<div className="grid grid-cols-[1fr_5rem] gap-2">
|
||||
<Field label={t("newUi.sidebar.quickConnect.protocolLabel")}>
|
||||
<Select2
|
||||
@@ -164,7 +166,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
onKeyDown={onEnter}
|
||||
className="h-7 text-xs"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
@@ -174,9 +176,13 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
value={host}
|
||||
onChange={(e) => setHost(e.target.value)}
|
||||
onKeyDown={onEnter}
|
||||
className="h-7 text-xs"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<GroupHeading
|
||||
title={t("newUi.sidebar.quickConnect.who")}
|
||||
className="pt-1"
|
||||
/>
|
||||
<Field label={t("newUi.sidebar.quickConnect.usernameLabel")}>
|
||||
<Input
|
||||
placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")}
|
||||
@@ -189,7 +195,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
}}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onKeyDown={onEnter}
|
||||
className="h-7 text-xs"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</Field>
|
||||
{!isDesktop && authOptions.length > 0 && (
|
||||
@@ -212,29 +218,13 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
)}
|
||||
{(isDesktop || authType === "password") && (
|
||||
<Field label={t("newUi.sidebar.quickConnect.passwordLabel")}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder={t(
|
||||
"newUi.sidebar.quickConnect.passwordPlaceholder",
|
||||
)}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={onEnter}
|
||||
className="h-7 text-xs pr-8"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((o) => !o)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="size-3.5" />
|
||||
) : (
|
||||
<Eye className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<PasswordInput
|
||||
placeholder={t("newUi.sidebar.quickConnect.passwordPlaceholder")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={onEnter}
|
||||
className="h-8 pr-8 text-xs"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{selected?.quickConnect?.showDomain && (
|
||||
@@ -244,19 +234,19 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
value={domain}
|
||||
onChange={(e) => setDomain(e.target.value)}
|
||||
onKeyDown={onEnter}
|
||||
className="h-7 text-xs"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{!isDesktop && authType === "key" && (
|
||||
<Field label={t("newUi.sidebar.quickConnect.privateKeyLabel")}>
|
||||
<textarea
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
"newUi.sidebar.quickConnect.privateKeyPlaceholder",
|
||||
)}
|
||||
value={privateKey}
|
||||
onChange={(e) => setPrivateKey(e.target.value)}
|
||||
className="w-full h-24 px-2.5 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
className="h-24 resize-none font-mono text-xs"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
@@ -294,7 +284,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-1.5 pt-1">
|
||||
<div className="flex flex-col gap-1.5 pt-2">
|
||||
{targets.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("newUi.sidebar.quickConnect.noTarget")}
|
||||
@@ -306,9 +296,9 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
||||
type="button"
|
||||
disabled={!host.trim()}
|
||||
onClick={() => connect(action)}
|
||||
className={`flex items-center justify-center gap-1.5 h-7 w-full border text-xs font-semibold transition-colors disabled:opacity-50 disabled:pointer-events-none ${
|
||||
className={`flex h-8 w-full items-center justify-center gap-1.5 border text-xs font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 ${
|
||||
index === 0
|
||||
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20"
|
||||
? "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
|
||||
: "border-border text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
|
||||
import { FormFooter } from "@/components/list-kit";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { AuthEnrollmentSections } from "./AuthEnrollmentSections";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -265,36 +266,20 @@ export function NewApiKeyDialog({
|
||||
}}
|
||||
title={t("newUi.sidebar.userProfile.createApiKeyTitle")}
|
||||
footer={
|
||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||
{createdToken ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="rounded-none border-accent-brand/40 text-[10px] font-bold uppercase tracking-widest text-accent-brand"
|
||||
onClick={close}
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.done")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={close}
|
||||
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest gap-1.5"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
>
|
||||
<KeyRound className="size-3" />{" "}
|
||||
{t("newUi.sidebar.userProfile.createKey")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
createdToken ? (
|
||||
<FormFooter
|
||||
onSave={close}
|
||||
saveLabel={t("newUi.sidebar.userProfile.done")}
|
||||
/>
|
||||
) : (
|
||||
<FormFooter
|
||||
onCancel={close}
|
||||
cancelLabel={t("newUi.sidebar.userProfile.cancel")}
|
||||
onSave={() => void handleCreate()}
|
||||
saveLabel={t("newUi.sidebar.userProfile.createKey")}
|
||||
saving={creating}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -1197,29 +1182,6 @@ export function UserProfilePanel({
|
||||
userId={userId}
|
||||
/>
|
||||
|
||||
{/* Donate banner */}
|
||||
{shows("account") && (
|
||||
<div className="border border-accent-brand/40 bg-accent-brand/10 px-3 py-2.5 flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-accent-brand">
|
||||
{t("newUi.sidebar.userProfile.donateTitle")}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{t("newUi.sidebar.userProfile.donateDescription")}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
{t("newUi.sidebar.userProfile.donateMilestones")}
|
||||
</p>
|
||||
<a
|
||||
href="https://donate.termix.site/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest bg-accent-brand text-white px-2 py-1 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{t("newUi.sidebar.userProfile.donateButton")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Storage mode toggle — only meaningful once a remote server is
|
||||
connected; with no sync there's nowhere for "cloud" to sync to,
|
||||
so this stays forced to local storage and hidden. */}
|
||||
@@ -1233,7 +1195,7 @@ export function UserProfilePanel({
|
||||
onClick={() => handleStorageModeChange("local")}
|
||||
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
|
||||
storageMode === "local"
|
||||
? "bg-accent-brand text-white"
|
||||
? "bg-accent-brand/10 text-accent-brand"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
@@ -1243,7 +1205,7 @@ export function UserProfilePanel({
|
||||
onClick={() => handleStorageModeChange("cloud")}
|
||||
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
|
||||
storageMode === "cloud"
|
||||
? "bg-accent-brand text-white"
|
||||
? "bg-accent-brand/10 text-accent-brand"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
|
||||
}`}
|
||||
>
|
||||
@@ -1363,15 +1325,22 @@ export function UserProfilePanel({
|
||||
)}
|
||||
|
||||
<div className="border-t border-border pt-3 mt-1">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("newUi.sidebar.userProfile.versionLabel")}
|
||||
</span>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-sm font-bold text-accent-brand">
|
||||
{version ? `v${version}` : "—"}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
|
||||
{t("newUi.sidebar.userProfile.versionLabel")}
|
||||
</span>
|
||||
<VersionBadge status={versionStatus} releaseUrl={releaseUrl} />
|
||||
<VersionBadge
|
||||
status={versionStatus}
|
||||
releaseUrl={releaseUrl}
|
||||
className="shrink-0 px-1 py-px text-[9px]"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="mt-1.5 block truncate text-sm font-bold text-accent-brand"
|
||||
title={version ? `v${version}` : undefined}
|
||||
>
|
||||
{version ? `v${version}` : "-"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-3 mt-3">
|
||||
@@ -1528,7 +1497,7 @@ export function UserProfilePanel({
|
||||
<Type className="size-3" />
|
||||
{t("newUi.sidebar.userProfile.interfaceFontLabel")}
|
||||
</span>
|
||||
<select
|
||||
<Select2
|
||||
value={uiFont}
|
||||
onChange={(event) =>
|
||||
handleUiFontChange(event.target.value as UiFontId)
|
||||
@@ -1540,7 +1509,7 @@ export function UserProfilePanel({
|
||||
{font.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Select2>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("newUi.sidebar.userProfile.interfaceFontDescription")}
|
||||
</span>
|
||||
@@ -1767,7 +1736,10 @@ export function UserProfilePanel({
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-3">
|
||||
<div
|
||||
id="settings-navigation"
|
||||
className="flex flex-col gap-1 border-t border-border pt-3"
|
||||
>
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
|
||||
{t("newUi.sidebar.userProfile.settingsNavigation")}
|
||||
</span>
|
||||
|
||||
@@ -61,6 +61,11 @@ const menuItems = createRegistry<HostContextMenuItemDef>(byOrderThenId);
|
||||
|
||||
export const registerHostAction = actions.register;
|
||||
export const useHostActions = actions.useList;
|
||||
|
||||
/** Plugin actions sort by order; connect actions default to the end. */
|
||||
export function actionOrder(action: HostActionDef): number {
|
||||
return action.order ?? (action.kind === "connect" ? 100 : 50);
|
||||
}
|
||||
export const listHostActions = actions.list;
|
||||
export const registerHostBadge = badges.register;
|
||||
export const useHostBadges = badges.useList;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
|
||||
import { useSyncAttentionCount } from "@/hooks/use-sync-status";
|
||||
import { isElectron } from "@/lib/electron";
|
||||
import { createRegistry } from "@/lib/registry";
|
||||
@@ -233,12 +234,55 @@ export function useRailItems(): RailItemDef[] {
|
||||
railItemsSnapshot,
|
||||
);
|
||||
const { has, loaded } = usePermissions();
|
||||
const { order } = useAreaPreferences("rail");
|
||||
return useMemo(
|
||||
() => permittedRailItems(items, { has, loaded }),
|
||||
[items, has, loaded],
|
||||
() => applyRailOrder(permittedRailItems(items, { has, loaded }), order),
|
||||
[items, has, loaded, order],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Items in the user's order. Listed ids come first in that order within the
|
||||
* default sequence; unlisted ones keep their default place relative to it.
|
||||
*/
|
||||
export function applyRailOrder(
|
||||
items: RailItemDef[],
|
||||
order: string[] | undefined,
|
||||
): RailItemDef[] {
|
||||
if (!order || order.length === 0) return items;
|
||||
const rank = new Map(order.map((id, index) => [id, index]));
|
||||
const listed = items
|
||||
.filter((item) => rank.has(item.id))
|
||||
.sort((a, b) => rank.get(a.id)! - rank.get(b.id)!);
|
||||
let next = 0;
|
||||
return items.map((item) => (rank.has(item.id) ? listed[next++] : item));
|
||||
}
|
||||
|
||||
/**
|
||||
* The order after moving `id` to sit before `beforeId` (or to the end of its
|
||||
* band when null), as a full list of the given items' ids.
|
||||
*/
|
||||
export function moveRailItem(
|
||||
items: RailItemDef[],
|
||||
id: string,
|
||||
beforeId: string | null,
|
||||
): string[] {
|
||||
const ids = items.map((item) => item.id).filter((other) => other !== id);
|
||||
const at = beforeId ? ids.indexOf(beforeId) : -1;
|
||||
if (at < 0) {
|
||||
const group = items.find((item) => item.id === id)?.group ?? "tools";
|
||||
const lastInBand = items
|
||||
.map((item, index) => ({ item, index }))
|
||||
.filter(({ item }) => item.id !== id && (item.group ?? "tools") === group)
|
||||
.map(({ item }) => ids.indexOf(item.id))
|
||||
.pop();
|
||||
ids.splice(lastInBand === undefined ? ids.length : lastInBand + 1, 0, id);
|
||||
} else {
|
||||
ids.splice(at, 0, id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Places outside the rail that still need a title, like the Settings tab. */
|
||||
export const RAIL_UTILITY_ITEMS: RailItemDef[] = [
|
||||
{ id: "settings", icon: Settings, labelKey: "nav.settings" },
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { actionOrder } from "@/sidebar/host-contributions";
|
||||
import { showsInBar, useBarActions } from "@/sidebar/tree/host-bar-actions";
|
||||
import { rem } from "@/lib/rem";
|
||||
import { enabledHostProtocols } from "@/sidebar/host-protocols";
|
||||
import { useEffect, useRef, useState, type MouseEvent } from "react";
|
||||
@@ -116,11 +118,6 @@ export function buildStatusTooltip(
|
||||
return `${protocols.join(", ")}: ${statusLabel}`;
|
||||
}
|
||||
|
||||
/** Plugin actions sort by order; connect actions default to the end. */
|
||||
function actionOrder(action: HostActionDef): number {
|
||||
return action.order ?? (action.kind === "connect" ? 100 : 50);
|
||||
}
|
||||
|
||||
async function writeClipboardText(value: string): Promise<void> {
|
||||
await copyToClipboard(value);
|
||||
}
|
||||
@@ -275,6 +272,7 @@ export function HostItem({
|
||||
const allowDelete = canDeleteHost(host);
|
||||
const allHostActions = useHostActions();
|
||||
const pluginActions = hostActionsFor(allHostActions, host);
|
||||
const barActions = useBarActions();
|
||||
const badges = hostBadgesFor(useHostBadges(), host);
|
||||
const pluginMenuItems = hostMenuItemsFor(useHostContextMenuItems(), host);
|
||||
const splitTargets = useSplitTargets();
|
||||
@@ -421,7 +419,7 @@ export function HostItem({
|
||||
icon: action.icon as typeof Terminal,
|
||||
label: action.label?.(host) ?? t(action.titleKey),
|
||||
tabType: action.tabType,
|
||||
tray: action.tray !== false,
|
||||
tray: showsInBar(barActions, action),
|
||||
items:
|
||||
items && items.length > 1
|
||||
? items.map((item) => ({
|
||||
|
||||
@@ -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(
|
||||
"hosts.sharing.authOverrideCredentialLabel",
|
||||
);
|
||||
await screen.findByLabelText("hosts.sharing.authOverrideCredentialLabel");
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
expect((select as HTMLSelectElement).value).toBe("7");
|
||||
|
||||
fireEvent.change(select, { target: { value: "8" } });
|
||||
@@ -119,9 +118,8 @@ describe("HostAuthOverrideModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = await screen.findByLabelText(
|
||||
"hosts.sharing.authOverrideCredentialLabel",
|
||||
);
|
||||
await screen.findByLabelText("hosts.sharing.authOverrideCredentialLabel");
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
expect(screen.getByText("hosts.sharing.noPersonalCredential")).toBeTruthy();
|
||||
fireEvent.change(select, { target: { value: "" } });
|
||||
expect(screen.getByText("hosts.sharing.authOverrideRequired")).toBeTruthy();
|
||||
@@ -187,9 +185,8 @@ describe("HostAuthOverrideModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const select = await screen.findByLabelText(
|
||||
"hosts.sharing.authOverrideCredentialLabel",
|
||||
);
|
||||
await screen.findByLabelText("hosts.sharing.authOverrideCredentialLabel");
|
||||
const select = document.querySelector("select") as HTMLSelectElement;
|
||||
expect((select as HTMLSelectElement).value).toBe("19");
|
||||
expect(remote.get).toHaveBeenCalledWith("/credentials");
|
||||
expect(api.getCredentials).not.toHaveBeenCalled();
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
rightDockableIds,
|
||||
railItemLabel,
|
||||
visibleRailItems,
|
||||
applyRailOrder,
|
||||
moveRailItem,
|
||||
} from "@/sidebar/rail-items";
|
||||
import { isCapturableTabType } from "@/shell/shell-layout";
|
||||
import { registerPanel, resetPanels } from "@/shell/panel-registry";
|
||||
@@ -275,3 +277,50 @@ describe("permittedRailItems", () => {
|
||||
expect(ids).toEqual(["open"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rail order", () => {
|
||||
const item = (id: string, group: "objects" | "tools" = "tools") =>
|
||||
({ id, icon: Boxes, labelKey: id, group }) as const;
|
||||
const items = [
|
||||
item("hosts", "objects"),
|
||||
item("credentials", "objects"),
|
||||
item("snippets"),
|
||||
item("macros"),
|
||||
item("history"),
|
||||
];
|
||||
|
||||
it("leaves the default order without a saved one", () => {
|
||||
expect(applyRailOrder(items, undefined)).toBe(items);
|
||||
expect(applyRailOrder(items, [])).toBe(items);
|
||||
});
|
||||
|
||||
it("puts listed items in the saved order and keeps the rest in place", () => {
|
||||
const ids = applyRailOrder(items, ["history", "snippets"]).map(
|
||||
(entry) => entry.id,
|
||||
);
|
||||
expect(ids).toEqual([
|
||||
"hosts",
|
||||
"credentials",
|
||||
"history",
|
||||
"macros",
|
||||
"snippets",
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves an item before another, or to the end of its band", () => {
|
||||
expect(moveRailItem(items, "history", "snippets")).toEqual([
|
||||
"hosts",
|
||||
"credentials",
|
||||
"history",
|
||||
"snippets",
|
||||
"macros",
|
||||
]);
|
||||
expect(moveRailItem(items, "hosts", null)).toEqual([
|
||||
"credentials",
|
||||
"hosts",
|
||||
"snippets",
|
||||
"macros",
|
||||
"history",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { showsInBar } from "@/sidebar/tree/host-bar-actions";
|
||||
|
||||
describe("showsInBar", () => {
|
||||
it("follows the action's own default without a choice", () => {
|
||||
expect(showsInBar({}, { id: "terminal" })).toBe(true);
|
||||
expect(showsInBar({}, { id: "maintenance", tray: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the user's choice win either way", () => {
|
||||
expect(
|
||||
showsInBar({ maintenance: true }, { id: "maintenance", tray: false }),
|
||||
).toBe(true);
|
||||
expect(showsInBar({ "ai-agent": false }, { id: "ai-agent" })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ describe("defaultHostSidebarPreferences", () => {
|
||||
openOnDoubleClick: false,
|
||||
showFolderPaths: true,
|
||||
hostClickBehavior: "newTab",
|
||||
barActions: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -67,6 +68,7 @@ describe("sanitizeHostSidebarPreferences", () => {
|
||||
openOnDoubleClick: true,
|
||||
showFolderPaths: false,
|
||||
hostClickBehavior: "focusExistingDoubleClickNew",
|
||||
barActions: { maintenance: true },
|
||||
},
|
||||
};
|
||||
expect(sanitizeHostSidebarPreferences(valid)).toEqual(valid);
|
||||
@@ -145,3 +147,30 @@ describe("sanitizeHostSidebarPreferences", () => {
|
||||
expect(result.version).toBe(HOST_SIDEBAR_PREFS_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
describe("barActions", () => {
|
||||
it("keeps boolean choices for valid action ids only", () => {
|
||||
const prefs = sanitizeHostSidebarPreferences({
|
||||
display: {
|
||||
barActions: {
|
||||
maintenance: true,
|
||||
"ai-agent": false,
|
||||
"Bad Id": true,
|
||||
docker: "yes",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(prefs.display.barActions).toEqual({
|
||||
maintenance: true,
|
||||
"ai-agent": false,
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to no overrides", () => {
|
||||
expect(sanitizeHostSidebarPreferences({}).display.barActions).toEqual({});
|
||||
expect(
|
||||
sanitizeHostSidebarPreferences({ display: { barActions: [] } }).display
|
||||
.barActions,
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,13 @@ export function DonationReminderModal({
|
||||
<Button variant="outline" onClick={onDismiss}>
|
||||
{t("donation.dismiss")}
|
||||
</Button>
|
||||
<Button onClick={handleDonate}>{t("donation.cta")}</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDonate}
|
||||
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
|
||||
>
|
||||
{t("donation.cta")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in new issue
Block a user