* feat: optional auto reconnect for dropped ssh sessions (#1321)

* feat: tighter compact host rows with inline tags (#1325)

* feat: bring back the alert feed homepage widget

* feat: pick target terminals for snippets again

* feat: share button on connection tabs again

* feat: recent servers list when linking the desktop app

* feat: list the fixed app shortcuts in keyboard shortcuts

* fix: translate hardcoded toasts and drop em dashes from ui text

* fix: translate leftover hardcoded ui text

* fix: toolbar files button opens at the shell directory again

* fix: show the remote desktop note under connection origin again

* chore: openapi docs for host metrics manager routes

* chore: openapi docs for undocumented routes and fix two broken doc blocks

* fix: declare terminal.listSessions use for snippet targets

* revert: drop the alert feed widget, the notification center replaces it

* feat: restore the alert feed homepage widget

* fix: quick connect fills the host address from plugin auth editors like tailscale

* fix: docker container logs use the base background color

* chore: fix dependabot alerts and apply safe npm upgrades

* fix: proxmox stats tab reads its switch from plugin host settings

* fix: count plugin ssh logins toward host status and stop showing reachable as a warning

* fix: hide the built-in source pill in the host editor

* fix: host status shows online when the port answers and only warns on a failed login

* fix: drop the top border on the split layout menu

* fix: host defaults tabs sit where the host editor puts them and skip plugins with nothing to default

* fix: remote desktop auth method labels and notes for vnc and telnet

* fix: add description to the web endpoint enable switch

* fix: crowdin sync and pretranslate for plugin locales, run plugin tests on release

* feat: move telemetry out of core into a usage statistics plugin

* fix: recent activity card no longer cuts off host names early

* fix: remove duplicate top border on split screen menu

* fix: redesign session log player controls, fix tooltip overlap and drop dot separators

* fix: file manager and sftp text showing raw i18n keys

* fix: onboarding rerunning for existing users after upgrading

* fix: fleet detail buttons overflowing the sidebar

* fix: session log player scrolling off screen horizontally

* fix: network graph lag when adding hosts and on every render

* fix: maintenance index name, shared host read only view and badge polling

* fix: meeting tabs, workspace tab merging, rail panels as tabs and general ui lag

* chore: update release notes

* fix: remote desktop tabs reconnecting on every tab switch

* fix: build plugin sdk before linting in release workflow

* chore: lint, format, and bump version to 2.9.0

* chore: skip crowdin pre-translate in release for now

* chore: switch to calver, bump version to 26.10.0

* fix(sso): send the PKCE verifier for GitHub login (#1534)

* fix(remote-desktop): clip cursor overflow without disabling zoom (#1535)

* test(remote-desktop): preserve sessions beyond one hour (#1536)

* fix(hosts): expose controls for overflowing editor tabs (#1537)

* fix(hosts): expose controls for overflowing editor tabs

* test(hosts): mock resize observation in admin panel tests

* fix(status): skip TCP probes while host sessions are active (#1538)

* fix(database): batch session activity persistence (#1539)

* docs(api): describe cookie and API key authentication (#1540)

* fix(snippets): repair missing note column on SQLite upgrades (#1541)

* fix(docker): retain stored SSH credential association (#1543)

* fix(file-manager): render transfer toasts without plugin hooks (#1546)

* feat(hosts): keep compact row actions inline (#1547)

* fix(auth): allow retrying unavailable second-factor interfaces (#1549)

* fix(auth): reject unresolved legacy identity provisioning (#1550)

* fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551)

* fix(tmux): pass full session info to the terminal's session picker (#1552)

The tmux.sessions service reduced detected sessions to bare names, but the
picker reads session.name, so every entry rendered blank and selecting one
sent an empty name, which created a new session instead of attaching.

* fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553)

The column holds the text "true"/"false", and the resolver passed it
through as-is. The string "false" is truthy, so the password provider
treated every saved host as forced keyboard-interactive and left the
password out. Jump hops are built straight from the resolved host, so a
password hop whose server doesn't offer keyboard-interactive failed with
"All configured authentication methods failed".

* fix(database): batch database saves for bulk host writes (#1554)

* fix(database): yield to the event loop between database saves

Each SQLite save serializes and encrypts the whole database into new
buffers, which V8 only releases once the event loop turns. Boot
migrations and bulk host import write plugin settings per host and per
key in an awaited loop, so hundreds of full copies piled up and the
container ran out of memory.

* fix(database): save once per bulk host write instead of once per row

On SQLite every repository write force-saves the whole database. Boot
plugin data migrations, host defaults materialization and the bulk
host routes write one row per host per setting, so they serialized and
encrypted the full database hundreds of times in a row.

DatabaseSaveTrigger.batched wraps a function so force saves made while
it runs collapse into a single save when it finishes. Nested scopes fold
into the outer one, and work that outlives its scope saves normally.

* feat(hosts): add instance-wide predefined tag suggestions (#1548)

* feat(hosts): add shared predefined tag suggestions

* style(hosts): format tag catalog routes

* test(database): advance fake timers past the save yield (#1555)

* fix(i18n): complete Simplified Chinese core and plugin translations (#1542)

* fix(i18n): complete Chinese onboarding and navigation labels

* fix(i18n): translate remaining Chinese core and plugin interfaces

* fix(i18n): translate host editor tab overflow controls

* fix(i18n): use consistent Chinese fleet terminology

* fix(i18n): localize hardcoded controls and plugin views

* fix(i18n): translate built-in homepage widget catalog

* test(homepage): follow translated timezone placeholder

* fix(i18n): translate plugin-provided homepage widgets

* fix(i18n): localize feature settings section titles

* fix: 2.8 oidc accounts unable to sign in after upgrading (#1381)

* fix: plugins losing the saved ssh login when copying a host (#1391)

* fix: fleets, proxmox and automations not getting the host sudo password

* fix: host imports running a defaults pass and metrics restart per host (#1384)

* fix: high cpu from status probes and full database saves on every sample (#1300)

* fix: user data export freezing the server (#1393)

* fix: op:// secret references rejected as ssh keys on credentials (#1394)

* fix: slow file deletes from the trash lookups on every delete (#1390)

* fix: add openapi docs and error handling to host tag routes

* test: compare download stream buffers directly so it stops timing out

* chore: drop em dash from host row comment

* chore: update release notes for 2.9.1

* feat(ai): add remote coding agent sessions for SSH hosts (#1545)

* feat(ai): add host coding agent sessions with configured providers

* Wait for agent initialization and shutdown before resuming sessions

* Keep Pi transcripts outside its configuration migration directory

* Cover Pi session directory separation

* Update core permission catalog expectations for remote agents

* Install remote agent runtimes from verified official sources

* Wait for the identity panel credentials request in its test

* Add scoped SSH forwarding setup for remote agents

* Verify forwarding setup through a fresh SSH connection

* Bound the SSH forwarding verification timeout

* Return the forwarding verification callback result

* fix(ai): support agent send and interrupt keyboard shortcuts

* feat(ai): add agent queues attachments worktrees and session recovery

* fix(ai): handle deleted workspace paths and duplicate status events

* fix(ai): accept file references in root workspaces

* fix(ai): refresh paused queues after agent errors

* fix(ai): store agent sessions in plugin tables and lock down OpenCode

Agent sessions lived in a single ctx.kv value holding up to 2000 events,
which passed the 256 KB kv limit within minutes of a normal run. Every
save after that failed, so history, nativeId, queue and archive changes
were lost and every event wrote a kv audit row.

Sessions, events and queued prompts now have their own tables. A running
agent appends events in batches at most once a second with a lazy save;
session and queue changes save immediately. Prompts, drafts and stored
event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no
longer requested.

The OpenCode server now requires a per-session password, so other local
accounts on the host cannot drive the agent through its loopback port.

* test(upgrade): include agent session tables in the reviewed upgrade SQL

* test: run the posix-only ai agent tests on linux only

* fix: restore space to add host tags and redesign predefined tags editor

* fix: host key silently accepted when the host is missing from the database (#1397)

* fix: clearer host login failed status label and fix its translations (#1396)

* chore: add host key and status label fixes to release notes

* fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles

* chore: increment ver

* fix(audit): store plugin entries with no acting user as a null user_id (#1556)

Plugin audit entries written outside a request used the literal "system"
as user_id. That column references users.id, so the insert was refused
(Postgres logs it as an FK violation) and the entry was silently dropped.
Write null instead and keep "system" / plugin:<id> in username.

* chore: add sso/ldap dialog and audit log fixes to release notes

* fix: tunnels and host settings missing from shared hosts on desktop

* fix: remote desktop logins missing from shared hosts on desktop

* fix: simplify host status to online/offline and keep it live without a refresh

* chore: sync Crowdin translations for 2.9.1

* fix: build docker frontend/backend natively to stop arm64 hang, build sdk before openapi

* chore: rework plugin readmes, json changelogs and standalone repo setup

* chore: move plugins to their own repos and drop the plugins folder

* chore(deps-dev): bump globals (#1560)

Bumps the dev-minor-updates group with 1 update: [globals](https://github.com/sindresorhus/globals).


Updates `globals` from 17.12.0 to 17.13.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.12.0...v17.13.0)

---
updated-dependencies:
- dependency-name: globals
  dependency-version: 17.13.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* feat: drop the 2.8 columns, moves and aliases kept for a downgrade

* chore: remove the 2.9 finish list

* feat: port the demo redesign with Manage and Settings tabs

* chore: pin the plugin signing key and use TERMIX_PAT in workflows

* chore: rename the plugin sdk to @termix-ssh/plugin-sdk

* feat: add a publish workflow for the plugin sdk

* chore: cleanup

* fix: harden plugin sockets, public routes, prebuilt binaries and login fallback

* chore: add a 2.9.1 upgrade check for postgres and mysql

* fix: stop full database rewrites on every plugin db call

* fix: stop full database rewrites on every plugin db call

* fix: restore release video link in 2.9.2 notes

* fix: use TERMIX_PAT in workflows

* feat: pin the official plugins in the docker image

* feat: implement plugin management UI

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

* chore: run all workflows on blacksmith runners

* chore: split dashboard quick actions into quarters and drop host status row lines

* fix: open plain settings from the profile button, navigation only from the rail menu

* feat: consent check, developer mode file installs, install counts and issue links for plugins

* feat: row detail options for hosts and credentials, open manage from sidebar header

* fix: unify connection failure screens and rebuild stale local plugins

* feat: dev:all runner for web, electron and docker with plugin hot reload, termix-plugin dev

* chore: clean up npm scripts, make npm run dev the full runner and drop concurrently

* fix: settle quick connect panel loads in tests so they stop flaking

* fix: stop the dev runner rebuilding plugins when windows reports file reads as changes

* chore: update the plugin sdk ui export snapshot

* feat: full screen onboarding with a plugin picker and per step versions

* feat: changelog.md release notes, v tags, plugin release notes and videos

* chore: add temp changelog

* chore: release the plugin sdk as 1.0.1

* feat: show only the latest plugin release notes and cap long ones

* chore: publish docker images to termix-ssh on ghcr and termixssh on docker hub

* chore: rename docker hub token secret to DOCKERHUB_BUGATTIGUY527_TOKEN

* chore: renew mac app store provisioning profile

* chore: drop docs job from release workflow

* chore: run sdk publish on github hosted runner

* chore: remove dependabot

* chore: general file cleanup

* chore: rewrite readme and taglines for plugin-based termix

* chore: drop optional from ai plugin row in readme

* feat: per-repo issue forms and prefilled bug reports instead of the support repo

* feat: add report a bug link to the your account settings section

* fix: open settings goes to the plugin's page and hides when it has none

* feat: show plugin features from the manifest on the plugin page

* fix: retry failed or blocked plugins on restart instead of leaving them disabled

* fix: refresh plugin settings after enabling a plugin and match select and disabled bar styling

* feat: request a feature link for plugins and use the bug form for unverified plugins

* feat: beta channels for core and plugins with one stable/beta release workflow

* fix(sync): resolve shared hosts for connect-only recipients (#1568)

* fix(ssh): accept full passwords in keyboard-interactive prompts (#1569)

* fix(ssh): normalize PKCS8 private keys before connecting (#1570)

* fix(sidebar): reclaim space from hidden folder actions (#1571)

* fix(hosts): display protocol credential usernames for non-SSH hosts (#1572)

* fix: ask for a push/TOTP menu choice as text instead of a push confirm

* fix: keep client tunnels open when one of their connections fails (Support#1417)

* fix: send plugin form uploads to the plugin's route (Support#1406, Support#1420)

* fix: add missing sync ids to old snippet tables so snippets sync again (Support#1419)

* fix: start the macOS local terminal when the shell or spawn helper is broken (Support#1418)

* fix: refresh cached plugin translations when only a translation changes (Support#1411)

* fix: import sshpk as a default so the backend starts under ESM

* feat: add a plugin sdk changelog and tag sdk releases, release 1.0.2

* fix: drop the ./ from the plugin sdk bin path so npm stops warning

* chore: name core and sdk releases Termix vX and Plugin SDK vX

* fix: lock the desktop backend to the app's own origin (GHSA-frx8-qqgg-7j7g)

* fix: trust devices by a server-issued token, not a client header (GHSA-6gr3-r7jx-wfmh)

* fix: keep global settings out of non-admin data exports (GHSA-v3vp-ffww-q7pw)

* fix: refuse sessionless tokens and keep the secrets file owner only

* feat: add TRUSTED_PROXIES to choose who nginx takes client IPs from (GHSA-7j7m-85p4-2cpm)

* chore: standardize repo setup files

* chore: remove unused knip config

* chore: add LICENSE and use Node 24 from .nvmrc

* chore: update translation guidance, only en.json needs editing

* feat: plugin docs and env manifest fields, in-app docs links, SDK 1.0.3

* fix: header separators render as borders and center the status divider

* fix: lighter confirm prompt overlay to stop lag when it opens

* fix: put plugin-only tailwind classes in core's utilities layer, SDK 1.0.4

* fix: dev stages local plugin builds on paths with spaces and builds them with the local SDK

* fix: keep plugin tailwind variants in core's layer so overrides keep their order

* fix: manage tab refetches before giving up on a host or credential it cannot find

* fix: tab buttons like share show as soon as the terminal is ready

* fix: folder rows no longer nest action buttons inside a button

* fix: repin remote-desktop after its 1.0.0 re-release

* chore: repin bundled plugins after the 1.0.0 re-release

* fix: recent activity rows use the same separator as host status

* feat: upload banner

* fix: reject expired sessions on sockets, api key desktop links and 192.0.0.0/24

* fix: plugin sdk 1.0.5 with optional ui peers, safer timeouts and validate checks

* fix: bundled plugins path for source runs and backend cleanup

* fix: load plugin strings on guest pages, plugin wording and drop unused keys

* fix: beta version stamping, homebrew cask bump, dockerignore and readme platforms

* docs: 26.10.0 changelog

* fix: current version in the version route example

* chore: stop pushing the release- docker tag

* feat: remove the donation reminder and its column

* docs: note the removed donation reminder

* fix: link server wizard always opens inline, onboarding card grows for it

* feat: plugin text columns are longtext on mysql so they are not capped at 64KB

* fix: linked desktops keep a plugin when its update from the server fails

* chore: default mobile download links to v tags

* fix: sso only accounts cannot sign in with a passkey or other local method

* feat: note in the plugins tab that community plugins are coming

* fix: plugin validate accepts beta versions

* chore: pin bundled plugins rebuilt on sdk 1.0.5

* fix: skip locale fetches for disabled plugins

* fix: show translated names for the built-in admin and user roles

* fix: show host keys as SHA256 fingerprints like ssh-keygen

* fix: host editor scrolls to a newly enabled protocol section

* fix: pass the host to dashboard host row plugins

* fix: plugin cards show dividers between author, version and category

* fix: refuse plugin archives with paths outside the plugin folder

* fix: theme picker shows one dropdown arrow

* fix: read activity and audit times as UTC so they show in local time

* fix: show the real error for a wrong password or code on plugin sign in

* fix: explain that external sign in needs Auto-create external accounts

* fix: clear leftover plugin unpack folders at startup

* fix: shared session links open for guests again

* fix: share panels no longer list yourself as a share target

* fix: a host update without an auth type keeps the saved password

* fix: guest pages get the tab ids a guest plugin registers

* fix: refuse a 2.8 database, which already had a plugins table

* fix: dashboard cards fit on a phone

* fix: desktop setup no longer asks to secure the local account

* fix: the desktop app keeps the desktop layout in a narrow window

* fix: the desktop app shows the rail and no phone bar in a narrow window

* fix: name every sync type in the Sync panel and pluralize the link prompt

* chore: update the sdk ui snapshot for the host key dialog

* fix: turning a plugin back on restarts the plugins it turned off

* fix: quick connect asks to trust the host key

* fix: revoking a manager also removes the shares they handed out

* fix: database export works for users with TOTP on (#1426)

* fix: rdp and vnc logins use the folder credential when none is picked (#1429)

* fix: imported hosts get status checks without a restart

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: re-pin bundled plugins after search bar fix

* chore: update app icon to smaller version

* chore: round the readme logo, keep square source for app icons

* feat: grid columns and table view in the plugins tab

* fix: match the host key copy button to the fingerprint height

* fix: use brand color for toggles in sync settings

* fix: re-pin bundled plugins after 1.0.0 re-release

* fix: web terminals failing auth behind a proxy that rewrites Host (Support#651)

* fix: re-pin snippets and termix-identity after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* fix: re-pin file-manager after 1.0.0 overwrite

* chore: remove Crowdin from the release workflow

* chore: remove crowdin config

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-10 14:58:27 -05:00
1 parent a075ff4808
commit 0149260926
3656 files changed
+116210 -460999

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
/* eslint-disable react-refresh/only-export-components */
import {
Children,
Fragment,
isValidElement,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import type React from "react";
import { PANEL } from "@/components/panel-layout";
import { cn } from "@/lib/utils";
/** Column counts per container width tier, for each column preference. */
const COLUMN_STEPS: Record<number, [number, number, number, number]> = {
1: [1, 1, 1, 1],
2: [1, 2, 2, 2],
3: [1, 2, 3, 3],
4: [1, 2, 3, 4],
};
const TIERS = { md: 640, lg: 960, xl: 1240 };
/** How many columns fit a container of this width for a column preference. */
export function masonryColumns(width: number, columns: number): number {
const steps = COLUMN_STEPS[columns] ?? COLUMN_STEPS[3];
if (width >= TIERS.xl) return steps[3];
if (width >= TIERS.lg) return steps[2];
if (width >= TIERS.md) return steps[1];
return steps[0];
}
function flattenChildren(children: React.ReactNode): React.ReactNode[] {
return Children.toArray(children).flatMap((child) =>
isValidElement(child) && child.type === Fragment
? flattenChildren(
(child.props as { children?: React.ReactNode }).children,
)
: [child],
);
}
/**
* Cards dealt round-robin into columns that stack tight. The assignment only
* depends on child order and column count, so live updates never reshuffle
* cards between columns. Width comes from the container, not the window, so
* split panes and docks get the right count.
*/
export function CardMasonry({
columns,
stack = false,
className,
children,
}: {
columns: number;
/** Force one full-width column, for cards holding wide tables. */
stack?: boolean;
className?: string;
children: React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(0);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setWidth(el.clientWidth);
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => setWidth(el.clientWidth));
observer.observe(el);
return () => observer.disconnect();
}, []);
const count = stack ? 1 : masonryColumns(width, columns);
const items = useMemo(() => flattenChildren(children), [children]);
const buckets = useMemo(() => {
const next: React.ReactNode[][] = Array.from({ length: count }, () => []);
items.forEach((child, i) => next[i % count].push(child));
return next;
}, [items, count]);
return (
<div
ref={ref}
className={cn(
count === 1
? `flex flex-col ${PANEL.gap}`
: `flex items-start ${PANEL.gap}`,
className,
)}
>
{count === 1
? items
: buckets.map((bucket, i) => (
<div
key={i}
className={`flex min-w-0 flex-1 flex-col ${PANEL.gap}`}
>
{bucket}
</div>
))}
</div>
);
}
+4 -7
View File
@@ -1,5 +1,6 @@
import { useRef, useState, useEffect, useCallback } from "react";
import { cn } from "@/lib/utils";
import { parseServerTime } from "@/lib/server-time";
export interface LineChartSeries {
key: string;
@@ -23,12 +24,8 @@ const X_LABEL_H = 20;
const TOP_PAD = 6;
const TICK_COUNT = 5;
function parseTs(ts: string): Date {
return new Date(ts.includes("T") ? ts : ts.replace(" ", "T") + "Z");
}
function formatTimestamp(ts: string, rangeMs: number): string {
const d = parseTs(ts);
const d = parseServerTime(ts);
if (rangeMs <= 25 * 60 * 60 * 1000) {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
@@ -104,8 +101,8 @@ export function LineChart({
const rangeMs =
timestamps.length >= 2
? parseTs(timestamps[timestamps.length - 1]).getTime() -
parseTs(timestamps[0]).getTime()
? parseServerTime(timestamps[timestamps.length - 1]).getTime() -
parseServerTime(timestamps[0]).getTime()
: 0;
const yTicks = (() => {
@@ -0,0 +1,267 @@
/* eslint-disable react-refresh/only-export-components */
import {
createContext,
useCallback,
useContext,
useMemo,
useRef,
useState,
} from "react";
import type React from "react";
import { useConnectionRetry } from "@termix-ssh/plugin-sdk/frontend";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext";
import {
ConnectionScreen,
type ConnectionUnavailable,
} from "@/components/connection/ConnectionScreen";
import type { ConnectionStatus } from "@/components/connection/connection-status";
import type { ConnectionStage } from "@/types/connection-log";
import { getErrorMessage } from "@/lib/error-message";
function messageOf(err: unknown): string {
return typeof err === "string" ? err : getErrorMessage(err);
}
type LogFn = (message: string, stage?: ConnectionStage) => void;
export interface ConnectionGateController {
status: ConnectionStatus;
attempt: number;
maxAttempts: number;
nextRetryInMs: number | null;
error: string | null;
unavailable: ConnectionUnavailable | null;
log: { info: LogFn; success: LogFn; warning: LogFn; error: LogFn };
markConnected: () => void;
/** It did not come up. Logs the reason and schedules a retry. */
markFailed: (error?: unknown) => void;
/** It was up and dropped. No automatic retry. */
markDisconnected: (error?: unknown) => void;
/** It cannot run here, like a feature that is off for this host. */
markUnavailable: (info: ConnectionUnavailable) => void;
retryNow: () => void;
reset: () => void;
}
export interface ConnectionGateProps {
/** Opens the connection. Throwing or rejecting counts as a failure. */
connect: (gate: ConnectionGateController) => void | Promise<void>;
message: string;
detail?: string;
disconnectedMessage?: string;
retry?: {
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
autoStart?: boolean;
enabled?: boolean;
};
/** Keep children mounted under the screen, for a terminal or display. */
keepMounted?: boolean;
backgroundColor?: string;
extraActions?: React.ReactNode;
className?: string;
children:
React.ReactNode | ((gate: ConnectionGateController) => React.ReactNode);
}
const GateContext = createContext<ConnectionGateController | null>(null);
/** The controller of the ConnectionGate this renders inside. */
export function useConnectionGate(): ConnectionGateController {
const gate = useContext(GateContext);
if (!gate) {
throw new Error("useConnectionGate must be used inside a ConnectionGate");
}
return gate;
}
/**
* One connection lifecycle for every tab: connecting, failed with retry and
* a log, dropped, or unavailable. Children render once it is up.
*/
export function ConnectionGate(props: ConnectionGateProps) {
return (
<ConnectionLogProvider>
<Gate {...props} />
</ConnectionLogProvider>
);
}
function Gate({
connect,
message,
detail,
disconnectedMessage,
retry,
keepMounted,
backgroundColor,
extraActions,
className,
children,
}: ConnectionGateProps) {
const { addLog, clearLogs } = useConnectionLog();
const [error, setError] = useState<string | null>(null);
const [unavailable, setUnavailable] = useState<ConnectionUnavailable | null>(
null,
);
const [dropped, setDropped] = useState(false);
const maxAttempts = retry?.maxAttempts ?? 8;
const gateRef = useRef<ConnectionGateController | null>(null);
const write = useCallback(
(type: "info" | "success" | "warning" | "error"): LogFn =>
(text, stage = "connection") =>
addLog({ type, stage, message: text }),
[addLog],
);
const retryState = useConnectionRetry({
connect: async () => {
setError(null);
setUnavailable(null);
setDropped(false);
try {
await connect(gateRef.current!);
} catch (err) {
gateRef.current?.markFailed(err);
}
},
maxAttempts,
baseDelayMs: retry?.baseDelayMs,
maxDelayMs: retry?.maxDelayMs,
autoStart: retry?.autoStart,
enabled: retry?.enabled,
});
const {
markConnected: retryConnected,
markFailed: retryFailed,
retryNow: retryAgain,
reset: retryReset,
} = retryState;
const log = useMemo(
() => ({
info: write("info"),
success: write("success"),
warning: write("warning"),
error: write("error"),
}),
[write],
);
const markFailed = useCallback(
(err?: unknown) => {
if (err !== undefined) {
const text = messageOf(err);
setError(text);
log.error(text, "error");
}
retryFailed();
},
[log, retryFailed],
);
const markDisconnected = useCallback(
(err?: unknown) => {
if (err !== undefined) {
const text = messageOf(err);
setError(text);
log.warning(text, "connection");
}
setDropped(true);
},
[log],
);
const markUnavailable = useCallback((info: ConnectionUnavailable) => {
setUnavailable(info);
}, []);
const retryNow = useCallback(() => {
clearLogs();
setError(null);
setUnavailable(null);
setDropped(false);
retryAgain();
}, [clearLogs, retryAgain]);
const reset = useCallback(() => {
setError(null);
setUnavailable(null);
setDropped(false);
retryReset();
}, [retryReset]);
let status: ConnectionStatus = retryState.status;
if (dropped) status = "disconnected";
const controller = useMemo<ConnectionGateController>(
() => ({
status,
attempt: retryState.attempt,
maxAttempts,
nextRetryInMs: retryState.nextRetryInMs,
error,
unavailable,
log,
markConnected: () => {
setDropped(false);
setError(null);
retryConnected();
},
markFailed,
markDisconnected,
markUnavailable,
retryNow,
reset,
}),
[
status,
retryState.attempt,
retryState.nextRetryInMs,
maxAttempts,
error,
unavailable,
log,
retryConnected,
markFailed,
markDisconnected,
markUnavailable,
retryNow,
reset,
],
);
gateRef.current = controller;
const up = status === "connected" && !unavailable;
const content =
typeof children === "function" ? children(controller) : children;
return (
<GateContext.Provider value={controller}>
<div className={`relative h-full w-full ${className ?? ""}`}>
{(up || keepMounted) && content}
<ConnectionScreen
status={status}
message={message}
detail={detail}
errorDetail={error}
unavailable={unavailable}
disconnectedMessage={disconnectedMessage}
attempt={retryState.attempt}
maxAttempts={maxAttempts}
nextRetryInMs={retryState.nextRetryInMs}
onManualRetry={retryNow}
extraActions={extraActions}
backgroundColor={backgroundColor}
logPosition={status === "connecting" ? "bottom" : "top"}
/>
</div>
</GateContext.Provider>
);
}
@@ -1,36 +1,43 @@
import React, { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useOptionalConnectionLog } from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard.ts";
import { Button } from "@/components/button.tsx";
import { cn } from "@/lib/utils.ts";
import {
ChevronDown,
ChevronUp,
Copy,
Info,
CheckCircle2,
AlertTriangle,
XCircle,
} from "lucide-react";
import { Check, ChevronDown, ChevronUp, Copy } from "lucide-react";
import { toast } from "sonner";
interface ConnectionLogPanelProps {
isConnecting: boolean;
isConnected: boolean;
hasConnectionError: boolean;
/** Kept for older callers. The log always sits at the bottom. */
position?: "top" | "bottom";
className?: string;
}
const COLLAPSED_HEIGHT = "h-[140px]";
const EXPANDED_HEIGHT = "h-[45%] min-h-[240px]";
const COLLAPSED_HEIGHT = "h-[136px]";
const EXPANDED_HEIGHT = "h-[46%] min-h-[220px]";
/** A dot per line, so the log reads as one column of text. */
const DOT: Record<string, string> = {
info: "bg-muted-foreground/50",
success: "bg-accent-brand",
warning: "bg-warning",
error: "bg-destructive",
};
const TEXT: Record<string, string> = {
info: "text-foreground-secondary",
success: "text-foreground",
warning: "text-warning",
error: "text-destructive",
};
export function ConnectionLogPanel({
isConnecting,
isConnected,
hasConnectionError,
position = "bottom",
className,
}: ConnectionLogPanelProps) {
const { t } = useTranslation();
@@ -39,6 +46,7 @@ export function ConnectionLogPanel({
connectionLog ?? {};
const lastLogRef = useRef<HTMLDivElement>(null);
const [manuallyCollapsed, setManuallyCollapsed] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
if (hasConnectionError && setIsExpanded) {
@@ -56,7 +64,7 @@ export function ConnectionLogPanel({
useEffect(() => {
if (lastLogRef.current) {
lastLogRef.current.scrollIntoView({ block: "end" });
lastLogRef.current.scrollIntoView?.({ block: "end" });
}
}, [logs]);
@@ -88,113 +96,99 @@ export function ConnectionLogPanel({
.join("\n");
const ok = await copyToClipboard(logsText);
if (ok) toast.success(t("terminal.connectionLogCopied"));
else toast.error(t("terminal.connectionLogCopyFailed"));
};
const getIcon = (type: string) => {
switch (type) {
case "info":
return <Info className="h-4 w-4 text-blue-500 shrink-0" />;
case "success":
return <CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />;
case "warning":
return <AlertTriangle className="h-4 w-4 text-yellow-500 shrink-0" />;
case "error":
return <XCircle className="h-4 w-4 text-red-500 shrink-0" />;
default:
return <Info className="h-4 w-4 shrink-0" />;
}
};
const getTextColor = (type: string) => {
switch (type) {
case "info":
return "text-blue-400";
case "success":
return "text-green-400";
case "warning":
return "text-yellow-400";
case "error":
return "text-red-400";
default:
return "text-muted-foreground";
if (ok) {
setCopied(true);
setTimeout(() => setCopied(false), 1400);
toast.success(t("sshAuth.connectionLogCopied"));
} else {
toast.error(t("sshAuth.connectionLogCopyFailed"));
}
};
return (
<div
className={cn(
"relative z-10 shrink-0 flex flex-col bg-bg-base",
position === "bottom"
? "border-t border-border"
: "border-b border-border",
"relative z-10 flex shrink-0 flex-col bg-surface-dim/60",
"border-t border-border",
expanded ? EXPANDED_HEIGHT : COLLAPSED_HEIGHT,
"transition-[height] duration-150",
"transition-[height] duration-200",
className,
)}
>
<div className="flex items-center justify-between px-3 py-1.5 shrink-0 border-b border-border/60">
<Button
variant="ghost"
size="sm"
<div className="flex h-9 shrink-0 items-center gap-2 border-b border-border px-2 pr-1.5">
<button
type="button"
onClick={handleToggle}
className="flex items-center gap-2 -ml-2"
aria-expanded={expanded}
className="flex min-w-0 items-center gap-1.5 px-1.5 py-1 text-foreground-secondary transition-colors hover:text-foreground"
>
{expanded ? (
<ChevronDown className="h-4 w-4" />
<ChevronDown className="size-3.5 shrink-0" />
) : (
<ChevronUp className="h-4 w-4" />
<ChevronUp className="size-3.5 shrink-0" />
)}
<span className="text-sm font-medium">
{t("terminal.connectionLogTitle")} ({logs.length})
<span className="text-[10px] font-semibold uppercase tracking-widest">
{t("sshAuth.connectionLogTitle")}
</span>
</Button>
</button>
<span className="text-xs tabular-nums text-muted-foreground">
{logs.length}
</span>
{logs.length > 0 && (
<Button
variant="ghost"
size="icon-sm"
onClick={copyLogsToClipboard}
title={t("terminal.connectionLogCopy")}
title={t("sshAuth.connectionLogCopy")}
className="ml-auto text-muted-foreground"
>
<Copy className="h-4 w-4" />
{copied ? (
<Check className="size-3.5 text-accent-brand" />
) : (
<Copy className="size-3.5" />
)}
</Button>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden thin-scrollbar">
<div className="px-3 py-2">
{logs.length === 0 ? (
<div className="py-4 text-center text-sm text-muted-foreground">
{isConnecting
? t("terminal.connectionLogWaiting")
: t("terminal.connectionLogEmpty")}
</div>
) : (
<div className="space-y-1 font-mono text-xs">
{logs.map((log, index) => (
<div
key={log.id}
ref={index === logs.length - 1 ? lastLogRef : null}
className="flex items-start gap-2"
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
{logs.length === 0 ? (
<p className="px-3 py-4 text-xs text-muted-foreground">
{isConnecting
? t("sshAuth.connectionLogWaiting")
: t("sshAuth.connectionLogEmpty")}
</p>
) : (
<div className="px-2 py-1.5 font-mono text-[11px] leading-[1.7]">
{logs.map((log, index) => (
<div
key={log.id}
ref={index === logs.length - 1 ? lastLogRef : null}
className="flex items-baseline gap-2 px-1 hover:bg-foreground/[0.03]"
>
<span className="shrink-0 tabular-nums text-muted-foreground/70">
{log.timestamp.toLocaleTimeString([], { hour12: false })}
</span>
<span
className={cn(
"size-1.5 shrink-0 translate-y-[-1px] rounded-full",
DOT[log.type] ?? DOT.info,
)}
/>
<span
className={cn(
"min-w-0 flex-1 whitespace-pre-wrap break-all",
TEXT[log.type] ?? TEXT.info,
)}
>
<span className="shrink-0 text-muted-foreground">
{log.timestamp.toLocaleTimeString()}
</span>
{getIcon(log.type)}
<span
className={cn(
"flex-1 min-w-0 break-all whitespace-pre-wrap",
getTextColor(log.type),
)}
>
{log.message}
</span>
</div>
))}
</div>
)}
</div>
{log.message}
</span>
</div>
))}
</div>
)}
</div>
</div>
);
+170 -45
View File
@@ -2,120 +2,245 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils.ts";
import { Button } from "@/components/button.tsx";
import { Loader2, RefreshCw } from "lucide-react";
import { RefreshCw, X } from "lucide-react";
import { ConnectionLogPanel } from "@/components/connection/ConnectionLogPanel.tsx";
import { useOptionalConnectionLog } from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { useSurfaceClose } from "@/components/surface/surface-scope.tsx";
import type { ConnectionStatus } from "@/components/connection/connection-status.ts";
interface ConnectionScreenProps {
export interface ConnectionUnavailable {
title: string;
hint?: string;
/** Another way forward, like opening the host editor. */
action?: React.ReactNode;
}
export interface ConnectionScreenProps {
status: ConnectionStatus;
/** Headline while connecting. */
message?: string;
/** Second line under the headline, usually user@host:port. */
detail?: string;
/** The reason it failed. Falls back to the last error in the log. */
errorDetail?: string | null;
/** Shown instead of a failure when the feature cannot run here. */
unavailable?: ConnectionUnavailable | null;
backgroundColor?: string;
attempt?: number;
maxAttempts?: number;
nextRetryInMs?: number | null;
onManualRetry?: () => void;
retryLabel?: string;
/** Headline when it was up and dropped. */
disconnectedMessage?: string;
/** Overrides closing the tab this sits in. False hides the button. */
onClose?: (() => void) | false;
extraActions?: React.ReactNode;
logPosition?: "top" | "bottom";
emptyState?: React.ReactNode;
className?: string;
}
/** The one screen every connection shows until it is up. */
export function ConnectionScreen({
status,
message,
detail,
errorDetail,
unavailable,
backgroundColor,
attempt = 0,
maxAttempts = 0,
nextRetryInMs = null,
onManualRetry,
retryLabel,
disconnectedMessage,
onClose,
extraActions,
logPosition = "bottom",
emptyState,
className,
}: ConnectionScreenProps) {
const { t } = useTranslation();
const surfaceClose = useSurfaceClose();
const logs = useOptionalConnectionLog()?.logs;
const close = onClose === false ? null : (onClose ?? surfaceClose);
if (status === "connected" && !emptyState) {
if (status === "connected" && !emptyState && !unavailable) {
return null;
}
const showSpinner = status === "connecting";
const showRetryButton = status === "disconnected" && !!onManualRetry;
const showLog = status !== "connected";
const connecting = status === "connecting" && !unavailable;
const failed = status === "error" || status === "disconnected";
const retrying =
status === "error" && attempt > 0 && !!nextRetryInMs && nextRetryInMs > 0;
const stopped = failed || !!unavailable;
const showRetryButton = stopped && !!onManualRetry;
const showClose = stopped && !!close;
const reason =
errorDetail ||
[...(logs ?? [])].reverse().find((entry) => entry.type === "error")
?.message ||
null;
const showLog = !emptyState && (status !== "connected" || !!unavailable);
let headline = message;
if (unavailable) headline = unavailable.title;
else if (retrying) headline = t("connection.failedRetrying");
else if (status === "disconnected" && disconnectedMessage)
headline = disconnectedMessage;
else if (failed) headline = t("connection.failed");
const showCountdown = attempt > 0 && (connecting || retrying);
return (
<div
role="status"
aria-live="polite"
data-status={status}
data-status={unavailable ? "unavailable" : status}
className={cn(
"motion-context-enter absolute inset-0 z-[100] flex flex-col",
className,
)}
style={{ backgroundColor: backgroundColor || "var(--bg-base)" }}
>
<div className="flex-1 min-h-0 flex items-center justify-center">
<div className="flex min-h-0 flex-1 items-center justify-center p-6">
{emptyState ? (
emptyState
) : (
<div className="flex flex-col items-center gap-4 px-6 text-center">
{showSpinner && (
<Loader2
className="size-8 animate-spin text-accent-brand"
aria-hidden="true"
/>
)}
{message && (
<p className="text-sm text-foreground-secondary font-medium">
{message}
</p>
)}
{attempt > 0 && status !== "disconnected" && (
<p className="text-xs text-muted-foreground">
{nextRetryInMs && nextRetryInMs > 0
? t("connection.retryingIn", {
seconds: Math.ceil(nextRetryInMs / 1000),
attempt,
max: maxAttempts,
})
: t("connection.retryingNow", { attempt, max: maxAttempts })}
</p>
)}
{showRetryButton && (
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-foreground-secondary font-medium">
{disconnectedMessage || t("connection.disconnected")}
<div className="flex max-w-md flex-col items-center gap-3.5 text-center">
<ConnectionMark
state={
unavailable
? "unavailable"
: connecting || retrying
? "busy"
: "failed"
}
/>
<div className="space-y-1.5">
{headline && (
<p className="text-sm font-semibold tracking-tight text-foreground">
{headline}
</p>
<div className="flex gap-2">
)}
{detail && (
<p className="font-mono text-xs text-muted-foreground">
{detail}
</p>
)}
{unavailable?.hint && (
<p className="text-xs leading-snug text-muted-foreground">
{unavailable.hint}
</p>
)}
{failed && !unavailable && reason && (
<p className="line-clamp-3 text-xs leading-snug text-destructive/80">
{reason}
</p>
)}
{showCountdown && (
<p className="text-xs tabular-nums text-muted-foreground">
{nextRetryInMs && nextRetryInMs > 0
? t("connection.retryingIn", {
seconds: Math.ceil(nextRetryInMs / 1000),
attempt,
max: maxAttempts,
})
: t("connection.retryingNow", {
attempt,
max: maxAttempts,
})}
</p>
)}
</div>
{(showRetryButton ||
showClose ||
(stopped && extraActions) ||
unavailable?.action) && (
<div className="flex flex-wrap justify-center gap-2 pt-0.5">
{showRetryButton && (
<Button
variant="outline"
size="default"
size="sm"
onClick={onManualRetry}
className="gap-2 font-semibold"
>
<RefreshCw className="size-3.5" />
{retryLabel || t("connection.reconnect")}
{retrying
? t("connection.retryNow")
: t("connection.reconnect")}
</Button>
{extraActions}
</div>
)}
{unavailable?.action}
{stopped && extraActions}
{showClose && (
<Button
variant="outline"
size="sm"
onClick={close}
className="gap-2 font-semibold"
>
<X className="size-3.5" />
{t("connection.close")}
</Button>
)}
</div>
)}
</div>
)}
</div>
{showLog && !emptyState && (
{showLog && (
<ConnectionLogPanel
isConnecting={status === "connecting"}
isConnecting={connecting}
isConnected={false}
hasConnectionError={status === "error" || status === "disconnected"}
hasConnectionError={stopped}
position={logPosition}
/>
)}
</div>
);
}
/** A thin arc while working, a still ring with a dot once it stops. */
function ConnectionMark({
state,
}: {
state: "busy" | "failed" | "unavailable";
}) {
return (
<div className="relative size-9" aria-hidden="true">
<svg viewBox="0 0 36 36" className="size-full">
<circle
cx="18"
cy="18"
r="15"
fill="none"
strokeWidth="2"
className="stroke-border"
/>
{state === "busy" && (
<circle
cx="18"
cy="18"
r="15"
fill="none"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray="26 68"
className="origin-center animate-spin stroke-accent-brand [animation-duration:900ms]"
/>
)}
</svg>
{state !== "busy" && (
<span
className={cn(
"absolute inset-0 m-auto size-1.5 rounded-full",
state === "failed" ? "bg-destructive" : "bg-muted-foreground",
)}
/>
)}
</div>
);
}
+162
View File
@@ -0,0 +1,162 @@
import type React from "react";
import { cn } from "@/lib/utils";
import {
PANEL,
type PanelDensity,
type PanelViewMode,
} from "@/components/panel-layout";
export interface DataColumn<T> {
key: string;
header: string;
/** Any grid track: "1fr", "120px", "minmax(0,2fr)". */
width: string;
cell: (item: T) => React.ReactNode;
/** Dropped below md, for columns that are nice rather than necessary. */
hideBelow?: "md" | "lg";
align?: "start" | "end";
}
export interface GridColumns {
base?: number;
md?: number;
lg?: number;
xl?: number;
}
// Spelled out so Tailwind can see every class.
const COLS: Record<number, string> = {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
};
const MD_COLS: Record<number, string> = {
1: "md:grid-cols-1",
2: "md:grid-cols-2",
3: "md:grid-cols-3",
4: "md:grid-cols-4",
};
const LG_COLS: Record<number, string> = {
1: "lg:grid-cols-1",
2: "lg:grid-cols-2",
3: "lg:grid-cols-3",
4: "lg:grid-cols-4",
};
const XL_COLS: Record<number, string> = {
1: "xl:grid-cols-1",
2: "xl:grid-cols-2",
3: "xl:grid-cols-3",
4: "xl:grid-cols-4",
};
function gridClass(columns: GridColumns): string {
return cn(
COLS[columns.base ?? 1],
columns.md && MD_COLS[columns.md],
columns.lg && LG_COLS[columns.lg],
columns.xl && XL_COLS[columns.xl],
);
}
/** One collection as a card grid or a dense row table. */
export function DataView<T>({
items,
view,
density,
getKey,
renderCard,
columns,
listColumns,
onRowClick,
isRowActive,
empty,
className,
}: {
items: T[];
view: PanelViewMode;
density?: PanelDensity;
getKey: (item: T) => string;
renderCard: (item: T) => React.ReactNode;
columns: GridColumns;
listColumns: DataColumn<T>[];
onRowClick?: (item: T) => void;
isRowActive?: (item: T) => boolean;
empty: React.ReactNode;
className?: string;
}) {
if (items.length === 0) {
return (
<div className="flex flex-1 items-center justify-center">{empty}</div>
);
}
if (view === "grid") {
return (
<div className={cn("grid", PANEL.gap, gridClass(columns), className)}>
{items.map((item) => (
<div key={getKey(item)}>{renderCard(item)}</div>
))}
</div>
);
}
const template = listColumns.map((c) => c.width).join(" ");
const rowHeight = density === "compact" ? "h-7" : "h-9";
return (
<div className={cn("border border-border bg-card", className)} role="table">
<div
className="sticky top-0 z-10 grid items-center border-b border-border bg-card px-3 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground"
style={{ gridTemplateColumns: template }}
>
{listColumns.map((column) => (
<span
key={column.key}
className={cn(
"truncate py-2",
column.align === "end" && "text-right",
column.hideBelow === "md" && "hidden md:block",
column.hideBelow === "lg" && "hidden lg:block",
)}
>
{column.header}
</span>
))}
</div>
<div>
{items.map((item) => {
const active = isRowActive?.(item) ?? false;
return (
<div
key={getKey(item)}
onClick={onRowClick ? () => onRowClick(item) : undefined}
className={cn(
"grid items-center gap-x-2 border-b border-border/50 px-3 text-xs transition-colors last:border-0",
rowHeight,
onRowClick && "cursor-pointer",
active ? "bg-accent-brand/10" : "hover:bg-muted/40",
)}
style={{ gridTemplateColumns: template }}
>
{listColumns.map((column) => (
<div
key={column.key}
className={cn(
"min-w-0 truncate",
column.align === "end" && "text-right",
column.hideBelow === "md" && "hidden md:block",
column.hideBelow === "lg" && "hidden lg:block",
)}
>
{column.cell(item)}
</div>
))}
</div>
);
})}
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
/* eslint-disable react-refresh/only-export-components */
import { BookOpen } from "lucide-react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { pluginDocsPage } from "@termix-ssh/plugin-sdk/docs";
import { docsUrl, type DocsPage } from "@/lib/docs";
import { cn } from "@/lib/utils";
import { usePluginScope } from "@/plugin-host/scope";
import { usePluginStore } from "@/plugin-host/plugin-store";
/** A plugin's docs link from its manifest, or null when it has none. */
export function usePluginDocsUrl(
pluginId: string | null | undefined,
page?: string,
anchor?: string,
): string | null {
const { records } = usePluginStore();
const base = pluginId ? records.get(pluginId)?.summary.docs : undefined;
return base ? pluginDocsPage(base, page, anchor) : null;
}
export interface DocsLinkProps {
/** A core docs page. */
core?: DocsPage;
/**
* A page of the current plugin's docs, like "setup". Inside a plugin this
* resolves against its manifest docs link; "" is its main page.
*/
page?: string;
anchor?: string;
/** A full URL, used as is. */
href?: string | null;
/** "text" is a small inline link, "icon" a square header button. */
variant?: "text" | "icon";
children?: ReactNode;
className?: string;
}
/**
* The one way the app links to docs. Renders nothing when there is nowhere
* to go, like a community plugin with no docs link.
*/
export function DocsLink({
core,
page,
anchor,
href,
variant = "text",
children,
className,
}: DocsLinkProps) {
const { t } = useTranslation();
const pluginId = usePluginScope();
const pluginUrl = usePluginDocsUrl(
page !== undefined ? pluginId : null,
page,
anchor,
);
const url =
href ?? (core ? withAnchor(docsUrl(core), anchor) : null) ?? pluginUrl;
if (!url) return null;
const label = t("common.docs");
if (variant === "icon") {
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
title={label}
aria-label={label}
className={cn(
"flex size-7 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground",
className,
)}
>
<BookOpen className="size-3.5" />
</a>
);
}
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"shrink-0 text-[10px] text-accent-brand hover:underline",
className,
)}
>
{children ?? label}
</a>
);
}
function withAnchor(url: string, anchor?: string): string {
if (!anchor) return url;
return `${url.split("#")[0]}#${anchor.replace(/^#/, "")}`;
}
+480
View File
@@ -0,0 +1,480 @@
/* eslint-disable react-refresh/only-export-components */
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Plus, Trash2, X } from "lucide-react";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { Button } from "@/components/button";
import { SettingRow, FakeSwitch } from "@/components/section-card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select";
import { cn } from "@/lib/utils";
/** Label + control + optional hint, the shape every field shares. */
export function Field({
label,
hint,
error,
aside,
htmlFor,
className,
children,
}: {
label: string;
hint?: ReactNode;
error?: string;
aside?: ReactNode;
htmlFor?: string;
className?: string;
children: ReactNode;
}) {
return (
<div data-setting-row="" className={cn("flex flex-col gap-1.5", className)}>
{(label || aside) && (
<div className="flex items-center gap-2">
<label
htmlFor={htmlFor}
className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"
>
{label}
</label>
{aside && <span className="ml-auto">{aside}</span>}
</div>
)}
{children}
{error ? (
<span className="text-[10px] text-destructive">{error}</span>
) : hint ? (
<span className="text-[10px] text-muted-foreground">{hint}</span>
) : null}
</div>
);
}
export function TextField({
label,
value,
onChange,
placeholder,
hint,
error,
aside,
disabled,
mono,
className,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
hint?: ReactNode;
error?: string;
aside?: ReactNode;
disabled?: boolean;
mono?: boolean;
className?: string;
}) {
return (
<Field
label={label}
hint={hint}
error={error}
aside={aside}
className={className}
>
<Input
value={value}
placeholder={placeholder}
disabled={disabled}
aria-invalid={!!error}
className={mono ? "font-mono" : undefined}
onChange={(e) => onChange(e.target.value)}
/>
</Field>
);
}
export function NumberField({
label,
value,
onChange,
placeholder,
hint,
error,
min,
max,
disabled,
className,
}: {
label: string;
value: number;
onChange: (value: number) => void;
placeholder?: string;
hint?: ReactNode;
error?: string;
min?: number;
max?: number;
disabled?: boolean;
className?: string;
}) {
return (
<Field label={label} hint={hint} error={error} className={className}>
<Input
type="number"
value={Number.isFinite(value) ? value : ""}
placeholder={placeholder}
min={min}
max={max}
disabled={disabled}
aria-invalid={!!error}
onChange={(e) => onChange(Number(e.target.value))}
/>
</Field>
);
}
/** Values the server sends in place of a stored secret. */
export const SECRET_SENTINELS = [
"existing_password",
"existing_key",
"existing_key_password",
"existing_sudo_password",
"existing_rdp_password",
"existing_vnc_password",
"existing_telnet_password",
];
export function isSecretSentinel(value: string): boolean {
return SECRET_SENTINELS.includes(value);
}
export function SecretField({
label,
value,
onChange,
placeholder,
hint,
error,
disabled,
className,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
hint?: ReactNode;
error?: string;
disabled?: boolean;
className?: string;
}) {
const { t } = useTranslation();
const saved = isSecretSentinel(value);
return (
<Field
label={label}
hint={saved ? t("fields.secretSaved") : hint}
error={error}
className={className}
>
<PasswordInput
value={saved ? "" : value}
placeholder={saved ? "••••••••" : placeholder}
disabled={disabled}
aria-invalid={!!error}
onChange={(e) => onChange(e.target.value)}
/>
</Field>
);
}
/** A Radix dropdown, so its list uses the theme tokens. */
export function SelectField<T extends string>({
label,
value,
onChange,
options,
hint,
error,
disabled,
className,
placeholder,
}: {
label: string;
value: T;
onChange: (value: T) => void;
options: readonly { value: T; label: string }[];
hint?: ReactNode;
error?: string;
disabled?: boolean;
className?: string;
placeholder?: string;
}) {
// Radix treats "" as no selection, so empty options get a stand-in value.
const EMPTY = "__empty__";
return (
<Field label={label} hint={hint} error={error} className={className}>
<Select
value={value === ("" as T) ? EMPTY : value}
disabled={disabled}
onValueChange={(next) => onChange((next === EMPTY ? "" : next) as T)}
>
<SelectTrigger aria-invalid={!!error} className="h-8 w-full text-xs">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem
key={option.value}
value={option.value === "" ? EMPTY : option.value}
className="text-xs"
>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
export function TextAreaField({
label,
value,
onChange,
placeholder,
rows = 3,
hint,
error,
aside,
mono,
disabled,
className,
}: {
label: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
hint?: ReactNode;
error?: string;
aside?: ReactNode;
mono?: boolean;
disabled?: boolean;
className?: string;
}) {
return (
<Field
label={label}
hint={hint}
error={error}
aside={aside}
className={className}
>
<textarea
rows={rows}
value={value}
placeholder={placeholder}
disabled={disabled}
onChange={(e) => onChange(e.target.value)}
className={cn(
"w-full resize-none border border-input bg-background px-3 py-2 text-xs text-foreground 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",
mono && "font-mono text-[10px]",
)}
/>
</Field>
);
}
/** A labelled switch. */
export function SwitchRow({
label,
description,
badge,
checked,
onChange,
disabled,
defaultKey,
rowId,
}: {
label: string;
description?: ReactNode;
badge?: string;
checked: boolean;
onChange: (value: boolean) => void;
disabled?: boolean;
defaultKey?: string;
rowId?: string;
}) {
return (
<SettingRow
label={label}
description={description}
badge={badge}
defaultKey={defaultKey}
rowId={rowId}
>
<FakeSwitch checked={checked} onChange={onChange} disabled={disabled} />
</SettingRow>
);
}
/** A list of rows you add to and remove from. */
export function Repeater<T>({
label,
items,
onChange,
makeItem,
empty,
addLabel,
hint,
renderItem,
}: {
label: string;
items: T[];
onChange: (items: T[]) => void;
makeItem: () => T;
empty: string;
addLabel: string;
hint?: ReactNode;
renderItem: (item: T, update: (next: T) => void, index: number) => ReactNode;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-2 py-3">
<div className="flex items-center gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{label}
</span>
<Button
type="button"
variant="outline"
size="xs"
className="ml-auto"
onClick={() => onChange([...items, makeItem()])}
>
<Plus className="mr-1 size-3" />
{addLabel}
</Button>
</div>
{hint && (
<span className="text-[10px] text-muted-foreground">{hint}</span>
)}
{items.length === 0 ? (
<span className="border border-dashed border-border px-3 py-2 text-[10px] text-muted-foreground">
{empty}
</span>
) : (
<div className="flex flex-col gap-2">
{items.map((item, index) => (
<div
key={index}
className="flex items-start gap-2 border border-border bg-muted/10 p-2"
>
<div className="min-w-0 flex-1">
{renderItem(
item,
(next) =>
onChange(items.map((x, i) => (i === index ? next : x))),
index,
)}
</div>
<button
type="button"
title={t("common.remove")}
aria-label={t("common.remove")}
onClick={() => onChange(items.filter((_, i) => i !== index))}
className="mt-0.5 shrink-0 text-muted-foreground transition-colors hover:text-destructive focus-visible:ring-1 focus-visible:ring-ring"
>
<Trash2 className="size-3.5" />
</button>
</div>
))}
</div>
)}
</div>
);
}
/** Chip input for tags. */
export function TagInput({
tags,
input,
placeholder,
onTagsChange,
onInputChange,
}: {
tags: string[];
input: string;
placeholder: string;
onTagsChange: (tags: string[]) => void;
onInputChange: (value: string) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex min-h-8 flex-wrap items-center gap-1 border border-input bg-background px-2 py-1 focus-within:border-ring focus-within:ring-1 focus-within:ring-ring/50 dark:bg-input/30">
{tags.map((tag) => (
<span
key={tag}
className="flex items-center gap-0.5 border border-border/60 bg-muted px-1.5 py-0.5 text-[10px] text-foreground"
>
{tag}
<button
type="button"
aria-label={t("common.remove")}
onClick={() => onTagsChange(tags.filter((tg) => tg !== tag))}
className="ml-0.5 text-muted-foreground hover:text-destructive"
>
<X className="size-2.5" />
</button>
</span>
))}
<input
className="min-w-16 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground/50"
placeholder={tags.length === 0 ? placeholder : ""}
value={input}
onChange={(e) => onInputChange(e.target.value)}
onKeyDown={(e) => {
if ((e.key === " " || e.key === "Enter") && input.trim()) {
e.preventDefault();
const tag = input.trim();
if (!tags.includes(tag)) onTagsChange([...tags, tag]);
onInputChange("");
} else if (e.key === "Backspace" && !input && tags.length > 0) {
onTagsChange(tags.slice(0, -1));
}
}}
/>
</div>
);
}
/** Two fields side by side above md, stacked below. */
export function FieldPair({ children }: { children: ReactNode }) {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">{children}</div>
);
}
/** Reads a picked file's text, for keys and certificates. */
export function useFileText(onText: (text: string) => void) {
const [reading, setReading] = useState(false);
return {
reading,
onFile: async (file: File | null | undefined) => {
if (!file) return;
setReading(true);
try {
onText((await file.text()).trim());
} finally {
setReading(false);
}
},
};
}
+467
View File
@@ -0,0 +1,467 @@
import { useState } from "react";
import type React from "react";
import { ChevronRight, Plus, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Button } from "@/components/button";
export type ListRowTone =
"brand" | "muted" | "success" | "warning" | "destructive" | "none";
const STRIPE: Record<ListRowTone, string> = {
brand: "bg-accent-brand/60",
muted: "bg-muted-foreground/30",
success: "bg-green-500/70",
warning: "bg-warning/70",
destructive: "bg-destructive/70",
none: "bg-transparent",
};
/** Scrolling list body. Rows sit flush, divided by their own borders. */
export function PanelList({
empty,
className,
children,
}: {
/** Shown instead of the rows when there are none. */
empty?: React.ReactNode;
className?: string;
children?: React.ReactNode;
}) {
const hasRows = Array.isArray(children)
? children.flat().some(Boolean)
: Boolean(children);
return (
<div
className={cn(
"flex min-h-0 flex-1 flex-col overflow-y-auto thin-scrollbar",
className,
)}
>
{hasRows ? children : empty}
</div>
);
}
/**
* One row in a sidebar list, drawn like the host list: a coloured stripe on
* the left, a name with an optional line under it, and a row of action
* buttons that opens under it on hover.
*/
export function ListRow({
title,
icon,
badges,
meta,
stripe = 0,
tone = "brand",
color,
selected,
active,
dimmed,
leading,
trailing,
actions,
onClick,
className,
children,
...rest
}: {
title: React.ReactNode;
icon?: React.ReactNode;
/** Small chips after the title. */
badges?: React.ReactNode;
/** Muted line under the title, like a description or Facts. */
meta?: React.ReactNode;
/** Position in the list, odd rows get the zebra tint. */
stripe?: number;
tone?: ListRowTone;
/** A custom stripe colour, like a group's own colour. */
color?: string;
selected?: boolean;
/** Keeps the tray open, like while its menu is showing. */
active?: boolean;
dimmed?: boolean;
/** Sits between the stripe and the text, like a drag handle. */
leading?: React.ReactNode;
/** Always visible on the right, like a switch. */
trailing?: React.ReactNode;
/** Buttons under the row that open on hover, like the host list. */
actions?: React.ReactNode;
onClick?: () => void;
className?: string;
/** Extra lines under the meta. */
children?: React.ReactNode;
} & Omit<
React.HTMLAttributes<HTMLDivElement>,
"title" | "onClick" | "children"
>) {
return (
<div
{...rest}
role={onClick ? "button" : rest.role}
tabIndex={onClick ? 0 : rest.tabIndex}
onClick={onClick}
onKeyDown={(e) => {
rest.onKeyDown?.(e);
if (!onClick || e.defaultPrevented || e.target !== e.currentTarget)
return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
}}
data-selected={selected || undefined}
className={cn(
"group/row relative flex items-stretch select-none border-b border-border/40 transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
onClick && "cursor-pointer",
selected
? "bg-accent-brand/10 hover:bg-accent-brand/15"
: active
? "bg-muted/50"
: stripe % 2 === 1 && "bg-muted/15",
dimmed && "opacity-60",
className,
)}
>
<div
className={cn(
"w-[3px] shrink-0",
selected ? "bg-accent-brand" : !color && STRIPE[tone],
)}
style={color && !selected ? { backgroundColor: color } : undefined}
/>
{leading}
<div className="flex min-w-0 flex-1 flex-col gap-[3.5px] py-[7px] pl-[8.75px] pr-[7px]">
<div className="flex min-w-0 items-center gap-1.5">
{icon && (
<span className="flex shrink-0 text-muted-foreground/60 [&_svg]:size-3">
{icon}
</span>
)}
<span className="truncate text-[13px] font-semibold leading-none tracking-tight text-foreground">
{title}
</span>
{badges}
</div>
{meta && (
<div className="min-w-0 truncate text-[11px] leading-tight text-muted-foreground/80">
{meta}
</div>
)}
{children}
{actions && (
<div
onClick={(e) => e.stopPropagation()}
className={cn(
"flex-wrap items-center gap-[1.75px] border-t border-border/30 pt-[3.5px]",
active
? "flex"
: "hidden group-hover/row:flex group-focus-within/row:flex pointer-coarse:flex",
)}
>
{actions}
</div>
)}
</div>
{trailing && (
<div className="flex shrink-0 items-center pr-2">{trailing}</div>
)}
</div>
);
}
/** Square icon button for a ListRow tray. */
export function ListRowAction({
label,
tone = "default",
className,
children,
...rest
}: {
label: string;
tone?: "default" | "brand" | "destructive";
className?: string;
children: React.ReactNode;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
ref?: React.Ref<HTMLButtonElement>;
}) {
return (
<button
type="button"
title={label}
aria-label={label}
{...rest}
className={cn(
"flex size-[22.75px] items-center justify-center transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-3.5",
tone === "brand"
? "text-accent-brand/80 hover:text-accent-brand"
: tone === "destructive"
? "text-muted-foreground/60 hover:bg-destructive/10 hover:text-destructive"
: "text-muted-foreground/60 hover:text-foreground",
className,
)}
>
{children}
</button>
);
}
/** Small uppercase chip for a ListRow's badges. */
export function ListBadge({
tone = "muted",
className,
children,
}: {
tone?: "muted" | "brand" | "warning" | "destructive" | "success";
className?: string;
children: React.ReactNode;
}) {
return (
<span
className={cn(
"flex shrink-0 items-center gap-0.5 border px-1 py-px text-[9px] uppercase leading-none tracking-wider [&_svg]:size-2.5",
tone === "brand" &&
"border-accent-brand/30 bg-accent-brand/10 text-accent-brand",
tone === "warning" && "border-warning/30 bg-warning/10 text-warning",
tone === "destructive" &&
"border-destructive/30 bg-destructive/10 text-destructive",
tone === "success" &&
"border-green-500/30 bg-green-500/10 text-green-500",
tone === "muted" && "border-border bg-muted/40 text-muted-foreground",
className,
)}
>
{children}
</span>
);
}
/** A collapsible group in a ListRow list, drawn like a host folder. */
export function ListRowFolder({
name,
icon,
count,
open,
onToggle,
stripe = 0,
actions,
dropActive,
onDropItem,
emptyText,
className,
children,
}: {
name: React.ReactNode;
icon?: React.ReactNode;
count?: number;
open: boolean;
onToggle: () => void;
stripe?: number;
/** Shown on hover, usually a DropdownMenu trigger. */
actions?: React.ReactNode;
/** True while something is being dragged that could drop in here. */
dropActive?: boolean;
onDropItem?: () => void;
emptyText?: React.ReactNode;
className?: string;
children?: React.ReactNode;
}) {
const [dragOver, setDragOver] = useState(false);
const empty = count === 0;
return (
<div className={cn("border-b border-border/40", className)}>
<div
role="button"
tabIndex={0}
aria-expanded={open}
onClick={onToggle}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggle();
}
}}
onDragOver={(e) => {
if (!dropActive) return;
e.preventDefault();
setDragOver(true);
}}
onDragLeave={(e) => {
if (e.currentTarget === e.target) setDragOver(false);
}}
onDrop={(e) => {
if (!dropActive) return;
e.preventDefault();
setDragOver(false);
onDropItem?.();
}}
className={cn(
"group/folder flex w-full cursor-pointer select-none items-center gap-2 py-1.5 pl-2.5 pr-2 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring",
open ? "bg-muted/40" : "hover:bg-muted/30",
stripe % 2 === 1 && !open && "bg-muted/[0.08]",
dragOver &&
dropActive &&
"bg-accent-brand/10 ring-1 ring-inset ring-accent-brand",
)}
>
<ChevronRight
className={cn(
"size-3.5 shrink-0 text-muted-foreground/60 transition-transform",
open && "rotate-90",
)}
/>
{icon && (
<span
className={cn(
"flex shrink-0 [&_svg]:size-4",
open ? "text-accent-brand" : "text-muted-foreground/70",
)}
>
{icon}
</span>
)}
<span className="min-w-0 flex-1 truncate text-[13px] font-bold tracking-tight text-foreground">
{name}
</span>
{count !== undefined && (
<span className="shrink-0 bg-muted/70 px-1.5 py-[1px] text-[10px] tabular-nums text-muted-foreground/70">
{count}
</span>
)}
{actions && (
<div
onClick={(e) => e.stopPropagation()}
className="flex shrink-0 items-center opacity-0 transition-opacity focus-within:opacity-100 group-hover/folder:opacity-100 pointer-coarse:opacity-100 has-[[data-state=open]]:opacity-100"
>
{actions}
</div>
)}
</div>
{open && (
<div className="ml-[27px] border-l border-t border-border/50 border-t-border/40 [&>*:last-child]:border-b-0">
{empty && emptyText ? (
<div className="px-3 py-2 text-[11px] text-muted-foreground/60">
{emptyText}
</div>
) : (
children
)}
</div>
)}
</div>
);
}
/** The one "add" button, placed beside a panel's search box. */
export function AddButton({
label,
compact,
className,
...rest
}: {
label?: string;
/** Icon only, for tight toolbars. */
compact?: boolean;
className?: string;
} & Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> & {
ref?: React.Ref<HTMLButtonElement>;
}) {
const { t } = useTranslation();
const text = label ?? t("common.new");
return (
<Button
variant="outline"
size={compact ? "icon" : "default"}
title={text}
aria-label={text}
{...rest}
className={cn(
"shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10",
className,
)}
>
<Plus className="size-3.5" />
{!compact && <span>{text}</span>}
</Button>
);
}
/** The one Save bar, for a PanelShell or InlineView footer. */
export function FormFooter({
onCancel,
onSave,
saveLabel,
cancelLabel,
saving,
dirty,
status,
disabled,
onDelete,
deleteLabel,
extra,
saveType = "button",
form,
}: {
onCancel?: () => void;
onSave?: () => void;
saveLabel?: string;
cancelLabel?: string;
saving?: boolean;
/** Shows the unsaved changes note. */
dirty?: boolean;
/** Replaces the unsaved note, like an error count. */
status?: React.ReactNode;
disabled?: boolean;
onDelete?: () => void;
deleteLabel?: string;
/** More buttons, placed before Save. */
extra?: React.ReactNode;
saveType?: "button" | "submit";
/** Id of the form a submit Save belongs to. */
form?: string;
}) {
const { t } = useTranslation();
return (
<div className="flex w-full min-w-0 items-center gap-2">
{onDelete && (
<Button
variant="ghost"
size="sm"
onClick={onDelete}
disabled={saving}
className="text-destructive hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/10"
>
<Trash2 />
{deleteLabel ?? t("common.delete")}
</Button>
)}
<div className="min-w-0 flex-1 truncate text-[11px]">
{status ??
(dirty && (
<span className="text-warning">{t("common.unsavedChanges")}</span>
))}
</div>
{onCancel && (
<Button variant="ghost" size="sm" onClick={onCancel} disabled={saving}>
{cancelLabel ?? t("common.cancel")}
</Button>
)}
{extra}
{(onSave || saveType === "submit") && (
<Button
type={saveType}
form={form}
variant="outline"
size="sm"
onClick={onSave}
disabled={disabled || saving}
className="border-accent-brand/40 px-6 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand dark:border-accent-brand/40 dark:bg-transparent dark:hover:bg-accent-brand/10"
>
{saving ? t("common.saving") : (saveLabel ?? t("common.save"))}
</Button>
)}
</div>
);
}
+1 -1
View File
@@ -52,7 +52,7 @@ export function ManagerCardShell({
>
{error ? (
<div className="flex flex-col items-center gap-2 py-6 text-center">
<AlertTriangle className="size-6 text-yellow-500" />
<AlertTriangle className="size-6 text-warning" />
<span className="text-xs text-muted-foreground">{error.message}</span>
{error.code === "SUDO_REQUIRED" && (
<span className="text-[10px] text-muted-foreground">
+55
View File
@@ -0,0 +1,55 @@
import { cn } from "@/lib/utils";
import { usageBarColor, usageColor } from "@/lib/usage-color";
/** A usage bar on the shared color ramp. */
export function Meter({
percent,
className,
}: {
percent: number | null;
className?: string;
}) {
const clamped = percent === null ? 0 : Math.min(100, Math.max(0, percent));
return (
<div className={cn("h-1.5 w-full overflow-hidden bg-muted", className)}>
<div
className={cn("motion-meter h-full", usageBarColor(percent))}
style={{ width: `${clamped}%` }}
/>
</div>
);
}
/**
* A meter with its readout. Pass `label` for the stacked card form; leave it off
* for the bare form a table cell wants.
*/
export function UsagePair({
label,
percent,
detail,
}: {
label?: string;
percent: number;
detail?: string;
}) {
const clamped = Math.min(100, Math.max(0, percent));
return (
<div className="flex min-w-0 items-center gap-2">
{label && (
<span className="w-10 shrink-0 text-[10px] uppercase text-muted-foreground">
{label}
</span>
)}
<Meter percent={clamped} className="flex-1" />
<span
className={cn(
"shrink-0 text-right text-[11px] tabular-nums",
detail ? "text-muted-foreground" : usageColor(clamped),
)}
>
{detail ?? `${Math.round(clamped)}%`}
</span>
</div>
);
}
+458
View File
@@ -0,0 +1,458 @@
/* eslint-disable react-refresh/only-export-components */
import { Children, Fragment, isValidElement, useRef } from "react";
import type React from "react";
import { ArrowLeft, Grid3X3, List, Rows3, Search, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/separator";
import { DocsLink } from "@/components/docs-link";
/** One spacing rhythm for every panel and tab. */
export const PANEL = {
body: "p-2.5",
gap: "gap-2",
band: "px-3 py-2",
cardHead: "px-3 py-2.5",
header: "h-12.5",
} as const;
export type PanelViewMode = "grid" | "list";
export type PanelDensity = "comfortable" | "compact";
/** The header, toolbar band and body every tab and panel draws. */
export function PanelShell({
chrome = true,
icon,
title,
status,
leading,
actions,
toolbar,
tabs,
footer,
scroll = true,
docs,
className,
children,
}: {
/** False inside a sidebar, which draws its own header. */
chrome?: boolean;
icon?: React.ReactNode;
title?: React.ReactNode;
/** Short caption beside the title, like "12 of 18 running". */
status?: React.ReactNode;
/** Sits before the title, usually a BackButton. */
leading?: React.ReactNode;
actions?: React.ReactNode;
toolbar?: React.ReactNode;
/** A full-bleed tab strip in place of a toolbar. */
tabs?: React.ReactNode;
/** A bar pinned under the body, like Cancel and Save. */
footer?: React.ReactNode;
/** False when the body scrolls itself. */
scroll?: boolean;
/** Docs URL for this screen, shown as a book icon in the header. */
docs?: string | null;
className?: string;
children: React.ReactNode;
}) {
const body = (
<div
className={cn(
"flex min-h-0 flex-1 flex-col",
scroll && "overflow-y-auto thin-scrollbar",
className,
)}
>
{children}
</div>
);
const bands = (
<>
{toolbar && (
<div
className={`flex shrink-0 items-center gap-2 border-b border-border ${PANEL.band}`}
>
{toolbar}
</div>
)}
{tabs && (
<div className="shrink-0 border-b border-border px-1">{tabs}</div>
)}
</>
);
const foot = footer && (
<div className="flex shrink-0 items-center gap-2 border-t border-border px-3 py-2">
{footer}
</div>
);
if (!chrome) {
return (
<div className="flex min-h-0 flex-1 flex-col">
{bands}
{body}
{foot}
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden bg-background">
<header
className={`flex ${PANEL.header} shrink-0 flex-row items-center border-b border-border`}
>
{leading && (
<div className="flex h-full shrink-0 border-r border-border">
{leading}
</div>
)}
<div className="flex min-w-0 flex-1 items-center gap-2 px-3">
{icon && <span className="shrink-0 text-accent-brand">{icon}</span>}
<span className="truncate text-base font-bold tracking-tight">
{title}
</span>
{status && (
<>
<Separator
orientation="vertical"
className="h-4 data-[orientation=vertical]:self-center"
/>
<span className="truncate text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{status}
</span>
</>
)}
</div>
{(actions || docs) && (
<div className="flex shrink-0 items-center gap-1 px-2">
{actions}
{docs && <DocsLink href={docs} variant="icon" />}
</div>
)}
</header>
{bands}
{body}
{foot}
</div>
);
}
/** Square back button sized for the PanelShell header. */
export function BackButton({
onClick,
label,
className,
}: {
onClick: () => void;
label?: string;
className?: string;
}) {
const { t } = useTranslation();
const text = label ?? t("common.back");
return (
<button
type="button"
onClick={onClick}
title={text}
aria-label={text}
className={cn(
"flex h-full w-12.5 shrink-0 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/40 hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring",
className,
)}
>
<ArrowLeft className="size-4" />
</button>
);
}
/** Search box sized to match toolbar buttons. Esc clears it. */
export function PanelSearch({
value,
onChange,
placeholder,
fill,
autoFocus,
disabled,
title,
onEnter,
inputRef,
className,
}: {
value: string;
onChange: (next: string) => void;
placeholder?: string;
/** 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 localRef = useRef<HTMLInputElement>(null);
const text = placeholder ?? t("common.search");
return (
<div
className={cn(
"relative",
fill ? "w-full min-w-0 flex-1" : "w-40 md:w-56",
className,
)}
>
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<input
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)}
onKeyDown={(e) => {
if (e.key === "Escape" && value) {
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 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30"
/>
{value && (
<button
type="button"
aria-label={t("panel.clearSearch")}
title={t("panel.clearSearch")}
onClick={() => {
onChange("");
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"
>
<X className="size-3" />
</button>
)}
</div>
);
}
/** Square segmented control. */
export function Segmented<T extends string>({
value,
onChange,
options,
className,
}: {
value: T;
onChange: (next: T) => void;
options: readonly {
value: NoInfer<T>;
label: string;
icon?: React.ReactNode;
count?: number;
title?: string;
}[];
className?: string;
}) {
return (
<div
role="radiogroup"
className={cn("flex items-center border border-border", className)}
>
{options.map((option, i) => {
const active = option.value === value;
return (
<button
key={option.value}
type="button"
role="radio"
aria-checked={active}
title={option.title ?? option.label}
onClick={() => onChange(option.value)}
className={cn(
"flex h-8 items-center justify-center gap-1.5 px-2.5 text-[11px] font-medium transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
i > 0 && "border-l border-border",
active
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground hover:text-foreground",
)}
>
{option.icon}
{option.label}
{option.count !== undefined && (
<span className="text-[10px] tabular-nums opacity-60">
{option.count}
</span>
)}
</button>
);
})}
</div>
);
}
/** Grid or list, plus the compact rows toggle. */
export function ViewToggle({
view,
onView,
density,
onDensity,
}: {
view: PanelViewMode;
onView: (next: PanelViewMode) => void;
density?: PanelDensity;
onDensity?: (next: PanelDensity) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center border border-border">
<ToggleButton
active={view === "grid"}
onClick={() => onView("grid")}
title={t("panel.gridView")}
>
<Grid3X3 className="size-4" />
</ToggleButton>
<ToggleButton
active={view === "list"}
onClick={() => onView("list")}
title={t("panel.listView")}
bordered
>
<List className="size-4" />
</ToggleButton>
{density && onDensity && (
<ToggleButton
active={density === "compact"}
onClick={() =>
onDensity(density === "compact" ? "comfortable" : "compact")
}
title={
density === "compact"
? t("panel.comfortableRows")
: t("panel.compactRows")
}
bordered
>
<Rows3 className="size-4" />
</ToggleButton>
)}
</div>
);
}
function ToggleButton({
active,
onClick,
title,
bordered,
children,
}: {
active: boolean;
onClick: () => void;
title: string;
bordered?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
title={title}
aria-label={title}
aria-pressed={active}
className={cn(
"flex size-8 items-center justify-center transition-colors focus-visible:relative focus-visible:z-10 focus-visible:ring-1 focus-visible:ring-ring",
bordered && "border-l border-border",
active
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground hover:text-foreground",
)}
>
{children}
</button>
);
}
/**
* A run of peer facts with a rule between each pair, like "12 online | 40
* hosts". Never wraps; a fact that does not fit is truncated.
*/
/** Children with fragments opened up, so each fact gets its own divider. */
function flattenFacts(children: React.ReactNode): React.ReactNode[] {
return Children.toArray(children).flatMap((child) =>
isValidElement<{ children?: React.ReactNode }>(child) &&
child.type === Fragment
? flattenFacts(child.props.children)
: [child],
);
}
export function Facts({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
const items = flattenFacts(children);
if (items.length === 0) return null;
return (
<span className={cn("flex min-w-0 items-center gap-2", className)}>
{items.map((child, i) => (
<Fragment key={i}>
{i > 0 && (
<span aria-hidden className="h-3 w-px shrink-0 bg-border" />
)}
<span className="min-w-0 truncate">{child}</span>
</Fragment>
))}
</span>
);
}
/** Section label with a rule running to the edge. */
export function GroupHeading({
title,
count,
action,
className,
}: {
title: string;
count?: number;
action?: React.ReactNode;
className?: string;
}) {
return (
<div className={cn("flex items-center gap-2", className)}>
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
{title}
</span>
{count !== undefined && (
<span className="text-[10px] tabular-nums text-muted-foreground/60">
{count}
</span>
)}
<div className="h-px flex-1 bg-border" />
{action}
</div>
);
}
+17 -4
View File
@@ -8,20 +8,24 @@ export function SectionCard({
action,
children,
className,
rowId,
}: {
title: string;
icon: React.ReactNode;
action?: React.ReactNode;
children: React.ReactNode;
className?: string;
/** Lets settings search jump to this card. */
rowId?: string;
}) {
return (
<div
data-setting-row={rowId}
className={`flex flex-col border border-border bg-card overflow-hidden ${className ?? ""}`}
>
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
<span className="text-muted-foreground">{icon}</span>
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground flex-1">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground flex-1">
{title}
</span>
{action && <div className="ml-auto">{action}</div>}
@@ -36,6 +40,7 @@ export function SettingRow({
badge,
description,
defaultKey,
rowId,
children,
}: {
label: string;
@@ -46,18 +51,23 @@ export function SettingRow({
* field key, or "core.<key>". Shows where the value comes from.
*/
defaultKey?: string;
/** Lets settings search jump to this row. */
rowId?: string;
children: React.ReactNode;
}) {
return (
<DefaultsOnly settingKey={defaultKey ?? ""}>
{/* In a gap-4 form stack the gap already spaces the row, so its own
padding and divider would double up. */}
<div 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
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">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-medium leading-snug">{label}</span>
{badge && (
<span className="text-[10px] font-bold text-yellow-500 border border-yellow-500/40 px-1 shrink-0">
<span className="text-[10px] font-bold text-warning border border-warning/40 px-1 shrink-0">
{badge}
</span>
)}
@@ -90,13 +100,16 @@ export function FakeSwitch({
const on = checked !== undefined ? checked : internalOn;
return (
<button
type="button"
role="switch"
aria-checked={on}
disabled={disabled}
onClick={() => {
const next = !on;
if (checked === undefined) setInternalOn(next);
onChange?.(next);
}}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${on ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${on ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
>
<span
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${on ? "translate-x-4" : "translate-x-0.5"}`}
+1 -1
View File
@@ -154,7 +154,7 @@ function Select2({
aria-expanded={open}
aria-required={required}
className={cn(
"border-input bg-background text-foreground flex h-9 w-full items-center justify-between gap-2 rounded-none border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"border-input bg-transparent dark:bg-input/30 dark:hover:bg-input/50 text-foreground flex h-9 w-full items-center justify-between gap-2 rounded-none border px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
onKeyDown={(event) => {
+1 -1
View File
@@ -15,7 +15,7 @@ function Separator({
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-px data-[orientation=vertical]:self-stretch",
className,
)}
{...props}
@@ -0,0 +1,9 @@
/** Buttons shared by every PanePrompt, so confirms and prompts match. */
export const PROMPT_BUTTON =
"inline-flex h-7 items-center justify-center gap-1.5 border border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50";
export const PROMPT_PRIMARY_BUTTON =
"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";
export const PROMPT_DESTRUCTIVE_BUTTON =
"inline-flex h-7 items-center justify-center gap-1.5 bg-destructive px-3 text-xs font-medium text-white transition-colors hover:bg-destructive/90 focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50";
+487
View File
@@ -0,0 +1,487 @@
/* eslint-disable react-refresh/only-export-components */
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import type React from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { BackButton, PanelShell } from "@/components/panel-layout";
import {
PROMPT_BUTTON,
PROMPT_DESTRUCTIVE_BUTTON,
PROMPT_PRIMARY_BUTTON,
} from "./prompt-styles";
/**
* A surface is a sidebar panel, a tab or a split pane. Inline views and
* confirmations draw over the surface that asked, never over the whole app.
*/
export type EditingWidth = boolean | "wide";
export type SurfaceKind = "panel" | "tab" | "pane";
export interface ConfirmOptions {
title: string;
description?: React.ReactNode;
confirmLabel?: string;
cancelLabel?: string;
/** Red confirm button. Defaults to true, since most confirms delete. */
destructive?: boolean;
}
interface PendingConfirm extends ConfirmOptions {
id: number;
resolve: (ok: boolean) => void;
}
interface ScopeValue {
kind: SurfaceKind;
overlay: HTMLElement | null;
openView: (id: string, width: EditingWidth) => void;
closeView: (id: string) => void;
topView: string | null;
confirm: (options: ConfirmOptions) => Promise<boolean>;
close: (() => void) | null;
}
const ScopeContext = createContext<ScopeValue | null>(null);
function widest(widths: EditingWidth[]): EditingWidth {
if (widths.includes("wide")) return "wide";
return widths.some(Boolean);
}
// Only the most recently opened confirm, anywhere in the app, owns the keyboard.
const keyOwners: number[] = [];
export function SurfaceScope({
kind = "panel",
onEditingChange,
onClose,
className,
children,
}: {
kind?: SurfaceKind;
/** Closes whatever this surface is, like the tab it fills. */
onClose?: () => void;
/** Gets the widest open inline view, so a sidebar can widen for it. */
onEditingChange?: (editing: EditingWidth) => void;
className?: string;
children: React.ReactNode;
}) {
const [overlay, setOverlay] = useState<HTMLElement | null>(null);
const [views, setViews] = useState<{ id: string; width: EditingWidth }[]>([]);
const [confirms, setConfirms] = useState<PendingConfirm[]>([]);
const nextConfirm = useRef(0);
const editingRef = useRef(onEditingChange);
editingRef.current = onEditingChange;
const closeRef = useRef(onClose);
closeRef.current = onClose;
const hasClose = !!onClose;
const close = useMemo(
() => (hasClose ? () => closeRef.current?.() : null),
[hasClose],
);
const openView = useCallback((id: string, width: EditingWidth) => {
setViews((prev) => [...prev.filter((v) => v.id !== id), { id, width }]);
}, []);
const closeView = useCallback((id: string) => {
setViews((prev) => prev.filter((v) => v.id !== id));
}, []);
const editing = widest(views.map((v) => v.width));
useEffect(() => {
editingRef.current?.(editing);
}, [editing]);
useEffect(() => () => editingRef.current?.(false), []);
const confirm = useCallback(
(options: ConfirmOptions) =>
new Promise<boolean>((resolve) => {
const id = ++nextConfirm.current;
setConfirms((prev) => [...prev, { ...options, id, resolve }]);
}),
[],
);
const settle = useCallback((id: number, ok: boolean) => {
setConfirms((prev) => {
prev.find((c) => c.id === id)?.resolve(ok);
return prev.filter((c) => c.id !== id);
});
}, []);
// A scope that unmounts with a question open answers it with no.
const confirmsRef = useRef(confirms);
confirmsRef.current = confirms;
useEffect(
() => () => confirmsRef.current.forEach((c) => c.resolve(false)),
[],
);
const value = useMemo<ScopeValue>(
() => ({
kind,
overlay,
openView,
closeView,
topView: views.length ? views[views.length - 1].id : null,
confirm,
close,
}),
[kind, overlay, openView, closeView, views, confirm, close],
);
const current = confirms[0];
return (
<ScopeContext.Provider value={value}>
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col",
className,
)}
>
{children}
<div
ref={setOverlay}
className={cn(
"absolute inset-0 z-40 flex flex-col bg-background",
views.length === 0 && "hidden",
)}
/>
{current && (
<ConfirmCard
key={current.id}
options={current}
onSettle={(ok) => settle(current.id, ok)}
/>
)}
</div>
</ScopeContext.Provider>
);
}
/** Which kind of surface this code renders in, or null outside any. */
export function useSurfaceKind(): SurfaceKind | null {
return useContext(ScopeContext)?.kind ?? null;
}
/** Closes the surface this code renders in, or null when it can't be closed. */
export function useSurfaceClose(): (() => void) | null {
return useContext(ScopeContext)?.close ?? null;
}
/**
* Asks a yes or no question over the surface that called it. Enter confirms
* and Esc cancels, before a terminal can see the key.
*/
export function useConfirm(): (options: ConfirmOptions) => Promise<boolean> {
const scope = useContext(ScopeContext);
return useCallback(
(options: ConfirmOptions) => {
if (scope) return scope.confirm(options);
const text = [options.title, options.description]
.filter((part) => typeof part === "string")
.join("\n\n");
return Promise.resolve(window.confirm(text));
},
[scope],
);
}
function ConfirmCard({
options,
onSettle,
}: {
options: ConfirmOptions;
onSettle: (ok: boolean) => void;
}) {
const { t } = useTranslation();
const confirmRef = useRef<HTMLButtonElement>(null);
const titleId = useId();
const destructive = options.destructive ?? true;
const settleRef = useRef(onSettle);
settleRef.current = onSettle;
useEffect(() => {
const owner = Math.random();
keyOwners.push(owner);
const previous = document.activeElement as HTMLElement | null;
confirmRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (keyOwners[keyOwners.length - 1] !== owner) return;
if (e.key !== "Enter" && e.key !== "Escape") return;
e.preventDefault();
e.stopPropagation();
settleRef.current(e.key === "Enter");
};
window.addEventListener("keydown", onKey, true);
return () => {
window.removeEventListener("keydown", onKey, true);
keyOwners.splice(keyOwners.indexOf(owner), 1);
if (previous && document.contains(previous)) previous.focus();
};
}, []);
return (
<PanePrompt
open
role="alertdialog"
labelledBy={titleId}
title={options.title}
titleId={titleId}
description={options.description}
tone={destructive ? "destructive" : "default"}
onCancel={() => onSettle(false)}
actions={
<>
<button
type="button"
onClick={() => onSettle(false)}
className={PROMPT_BUTTON}
>
{options.cancelLabel ?? t("common.cancel")}
</button>
<button
ref={confirmRef}
type="button"
onClick={() => onSettle(true)}
className={
destructive ? PROMPT_DESTRUCTIVE_BUTTON : PROMPT_PRIMARY_BUTTON
}
>
{options.confirmLabel ??
(destructive ? t("common.delete") : t("common.confirm"))}
<span aria-hidden className="ml-1.5 opacity-60">
↵
</span>
</button>
</>
}
/>
);
}
/**
* A small card over the surface it sits in. Confirmations use it, and so do
* prompts that belong to a connection, like a sudo password.
*/
export function PanePrompt({
open,
title,
titleId,
description,
icon,
tone = "default",
actions,
onCancel,
role = "dialog",
labelledBy,
layer = "pane",
backgroundColor,
className,
children,
}: {
open: boolean;
title: React.ReactNode;
titleId?: string;
description?: React.ReactNode;
icon?: React.ReactNode;
tone?: "default" | "destructive";
actions?: React.ReactNode;
/** Clicking the dimmed backdrop. */
onCancel?: () => void;
role?: "dialog" | "alertdialog";
labelledBy?: string;
/**
* "connection" covers a connecting surface (a terminal waiting on a login)
* with an opaque backdrop, above everything the surface draws.
*/
layer?: "pane" | "connection";
/** Backdrop color for the connection layer, usually the terminal theme. */
backgroundColor?: string;
className?: string;
children?: React.ReactNode;
}) {
const fallbackId = useId();
if (!open) return null;
const headingId = titleId ?? fallbackId;
return (
<div
className={cn(
"motion-fade-enter absolute inset-0 flex items-center justify-center overflow-y-auto p-4",
layer === "connection" ? "z-500 bg-canvas" : "z-50 bg-background/85",
)}
style={
layer === "connection" && backgroundColor
? { backgroundColor }
: undefined
}
onMouseDown={(e) => {
if (e.target === e.currentTarget) onCancel?.();
}}
>
<div
role={role}
aria-modal="true"
aria-labelledby={labelledBy ?? headingId}
className={cn(
"motion-context-enter my-auto flex w-full max-w-sm flex-col border bg-popover shadow-xl",
tone === "destructive" ? "border-destructive/40" : "border-border",
className,
)}
>
<div className="flex items-start gap-2.5 px-4 pt-4">
{icon && (
<span
className={cn(
"mt-0.5 shrink-0",
tone === "destructive"
? "text-destructive"
: "text-accent-brand",
)}
>
{icon}
</span>
)}
<div className="flex min-w-0 flex-col gap-1">
<p
id={headingId}
className="text-sm font-semibold tracking-tight text-foreground"
>
{title}
</p>
{description && (
<div className="text-xs leading-snug text-muted-foreground">
{description}
</div>
)}
</div>
</div>
{children && <div className="px-4 pt-3">{children}</div>}
{actions && (
<div className="flex justify-end gap-2 px-4 py-3">{actions}</div>
)}
{!actions && <div className="h-4" />}
</div>
</div>
);
}
/**
* What used to be a modal: a view that takes over the surface it was opened
* from, with a Back button. Opened from a sidebar it widens the sidebar.
*/
export function InlineView({
open,
onOpenChange,
title,
icon,
status,
actions,
toolbar,
footer,
width = true,
onBeforeClose,
scroll = true,
bare = false,
className,
children,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: React.ReactNode;
icon?: React.ReactNode;
status?: React.ReactNode;
actions?: React.ReactNode;
toolbar?: React.ReactNode;
/** Pinned under the body, usually Cancel and Save. */
footer?: React.ReactNode;
/** How far a sidebar widens while this is open. */
width?: EditingWidth;
/** Return false to stay open, for an unsaved changes guard. */
onBeforeClose?: () => boolean | Promise<boolean>;
scroll?: boolean;
/** Draw children edge to edge, for views with their own bands. */
bare?: boolean;
className?: string;
children: React.ReactNode;
}) {
const scope = useContext(ScopeContext);
const id = useId();
const { openView, closeView } = scope ?? {};
useLayoutEffect(() => {
if (!open || !openView || !closeView) return;
openView(id, width);
return () => closeView(id);
}, [open, id, width, openView, closeView]);
const requestClose = useCallback(async () => {
if (onBeforeClose && !(await onBeforeClose())) return;
onOpenChange(false);
}, [onBeforeClose, onOpenChange]);
if (!open) return null;
const padded = scope?.kind === "tab" || scope?.kind === "pane";
const view = (
<div
className={cn(
"absolute inset-0 flex flex-col",
scope && scope.topView !== id && "hidden",
)}
onKeyDown={(e) => {
if (e.key === "Escape" && !e.defaultPrevented) {
e.stopPropagation();
void requestClose();
}
}}
>
<PanelShell
leading={<BackButton onClick={() => void requestClose()} />}
icon={icon}
title={title}
status={status}
actions={actions}
toolbar={toolbar}
footer={footer}
scroll={scroll}
>
{bare ? (
children
) : (
<div
className={cn(
"flex w-full flex-col gap-3",
padded ? "mx-auto max-w-3xl p-3" : "p-3",
!scroll && "min-h-0 flex-1",
className,
)}
>
{children}
</div>
)}
</PanelShell>
</div>
);
if (scope?.overlay) return createPortal(view, scope.overlay);
if (scope) return null;
return createPortal(
<div className="fixed inset-0 z-50 bg-background">{view}</div>,
document.body,
);
}
+2 -4
View File
@@ -11,16 +11,14 @@ function Switch({
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-[background-color,border-color,box-shadow,opacity] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-accent-brand data-[state=checked]:border-accent-brand data-[state=unchecked]:bg-muted data-[state=unchecked]:border-border",
className,
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
)}
className="pointer-events-none block h-3 w-3 bg-background shadow-sm transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5"
/>
</SwitchPrimitive.Root>
);
+145
View File
@@ -0,0 +1,145 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
export interface TabStripItem {
id: string;
label: string;
icon?: ReactNode;
/** Small count after the label. */
count?: number;
}
/** Scrollable underline tabs with overflow arrows. */
export function TabStrip({
tabs,
activeTab,
onTabChange,
isActive,
variant = "primary",
trailing,
className,
}: {
tabs: TabStripItem[];
activeTab: string;
onTabChange: (id: string) => void;
isActive?: (id: string) => boolean;
variant?: "primary" | "secondary";
/** Sits after the tabs, outside the scroll area. */
trailing?: ReactNode;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
const { t } = useTranslation();
const [scroll, setScroll] = useState({ left: false, right: false });
useEffect(() => {
const el = ref.current;
if (!el) return;
const update = () =>
setScroll({
left: el.scrollLeft > 1,
right: el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
});
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();
el.removeEventListener("scroll", update);
};
}, [tabs]);
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (e.deltaY === 0) return;
e.preventDefault();
el.scrollLeft += e.deltaY;
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, []);
const renderTab = (tab: TabStripItem) => {
const active = isActive ? isActive(tab.id) : activeTab === tab.id;
return (
<button
key={tab.id}
type="button"
onClick={() => onTabChange(tab.id)}
className={`flex items-center gap-1.5 px-3 ${
variant === "secondary" ? "py-1.5 text-[11px]" : "py-2 text-xs"
} font-medium whitespace-nowrap border-b-2 transition-colors shrink-0 ${
active
? "border-accent-brand text-accent-brand"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{tab.icon}
{tab.label}
{tab.count !== undefined && (
<span className="text-[10px] tabular-nums opacity-60">
{tab.count}
</span>
)}
</button>
);
};
const overflow = scroll.left || scroll.right;
return (
<div
className={cn(
"flex min-w-0 items-center",
variant === "secondary" && "border-t border-border bg-card",
className,
)}
>
{overflow && (
<button
type="button"
aria-label={t("common.scrollTabsLeft")}
title={t("common.scrollTabsLeft")}
disabled={!scroll.left}
className="shrink-0 p-1 disabled:opacity-30"
onClick={() =>
ref.current?.scrollBy({
left: -ref.current.clientWidth * 0.75,
behavior: "smooth",
})
}
>
<ChevronLeft className="size-4" />
</button>
)}
<div ref={ref} className="min-w-0 flex-1 overflow-x-auto scrollbar-none">
<div className="flex min-w-max">{tabs.map(renderTab)}</div>
</div>
{overflow && (
<button
type="button"
aria-label={t("common.scrollTabsRight")}
title={t("common.scrollTabsRight")}
disabled={!scroll.right}
className="shrink-0 p-1 disabled:opacity-30"
onClick={() =>
ref.current?.scrollBy({
left: ref.current.clientWidth * 0.75,
behavior: "smooth",
})
}
>
<ChevronRight className="size-4" />
</button>
)}
{trailing && (
<div className="flex shrink-0 items-center gap-1 px-1">{trailing}</div>
)}
</div>
);
}
+8 -5
View File
@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
interface VersionBadgeProps {
status: "up_to_date" | "requires_update" | "beta" | "unknown";
@@ -14,13 +15,15 @@ export function VersionBadge({
const { t } = useTranslation();
if (status === "unknown") return null;
const badgeClassName = `text-[10px] px-1.5 py-0.5 font-semibold leading-none ${
const badgeClassName = cn(
"text-[10px] px-1.5 py-0.5 font-semibold leading-none",
status === "beta"
? "bg-blue-500/20 text-blue-400"
: status === "requires_update"
? "bg-yellow-500/20 text-yellow-400"
: "bg-accent-brand/20 text-accent-brand"
}${className ? ` ${className}` : ""}`;
? "bg-warning/20 text-warning"
: "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>