* 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

+10 -16
View File
@@ -1,3 +1,4 @@
import { useConfirm } from "@/components/surface/surface-scope";
import type { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
@@ -9,6 +10,7 @@ import { toast } from "sonner";
import { AccordionSection } from "./AdminSettingsShared";
import type { AdminUser } from "./AdminManagementSections";
import { Select2 } from "@/components/select2";
import { DocsLink } from "@/components/docs-link";
type AdminApiKeysSectionProps = {
open: boolean;
@@ -52,6 +54,7 @@ export function AdminApiKeysSection({
newKeyLoading,
}: AdminApiKeysSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
return (
<AccordionSection
@@ -64,24 +67,10 @@ export function AdminApiKeysSection({
<div className="flex items-center justify-between py-2 border-b border-border">
<span className="text-[10px] text-muted-foreground">
{t("admin.apiKeysCount", { count: apiKeys.length })}{" "}
<a
href="https://docs.termix.site/features/api/api-keys"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("admin.apiKeysDocsLink")}
</a>
<DocsLink core="apiKeys">{t("admin.apiKeysDocsLink")}</DocsLink>
<br />
{t("admin.apiKeysCliHint")}{" "}
<a
href="https://docs.termix.site/cli"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("admin.apiKeysCliLink")}
</a>
<DocsLink core="cli">{t("admin.apiKeysCliLink")}</DocsLink>
</span>
<div className="flex items-center gap-1">
<Button
@@ -244,6 +233,11 @@ export function AdminApiKeysSection({
size="icon"
className="size-6 text-muted-foreground hover:text-destructive shrink-0"
onClick={async () => {
const ok = await confirm({
title: t("admin.revokeKeyConfirm", { name: key.name }),
confirmLabel: t("admin.revokeKey"),
});
if (!ok) return;
try {
await deleteApiKey(key.id);
setApiKeys((prev) => prev.filter((k) => k.id !== key.id));
+2 -1
View File
@@ -21,6 +21,7 @@ import {
import { toast } from "sonner";
import type { AdminUser } from "./AdminManagementSections";
import { Select2 } from "@/components/select2";
import { parseServerTime } from "@/lib/server-time";
const RESOURCE_TYPES = [
"user",
@@ -177,7 +178,7 @@ export function AdminAuditLogSection({
function formatTimestamp(ts: string) {
try {
return new Date(ts).toLocaleString(undefined, {
return parseServerTime(ts).toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
+44 -13
View File
@@ -1,3 +1,4 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -13,6 +14,7 @@ import { pluginKey } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PanelSearch } from "@/components/panel-layout";
import {
Activity,
Check,
@@ -28,12 +30,13 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { AccordionSection } from "./AdminSettingsShared";
import { roleLabel } from "@/lib/role-label";
export type AdminUser = {
id: string;
username: string;
isAdmin: boolean;
isOidc: boolean;
isExternal: boolean;
passwordHash?: string;
dataUnlocked?: boolean;
secondFactorEnabled?: boolean;
@@ -74,7 +77,7 @@ type UsersSectionProps = {
setEditUserTarget: Dispatch<SetStateAction<AdminUser | null>>;
setEditUserOpen: Dispatch<SetStateAction<boolean>>;
setLinkAccountTarget: Dispatch<
SetStateAction<{ id: string; username: string; isOidc: boolean } | null>
SetStateAction<{ id: string; username: string; isExternal: boolean } | null>
>;
setLinkAccountOpen: Dispatch<SetStateAction<boolean>>;
setUnlinkAccountTarget: Dispatch<
@@ -112,6 +115,7 @@ export function AdminUsersSection({
onPageChange,
}: UsersSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const hasPrev = page > 0;
const hasNext = page + 1 < pageCount;
@@ -149,19 +153,19 @@ export function AdminUsersSection({
</div>
</div>
<div className="py-2 border-b border-border">
<Input
<PanelSearch
value={search}
onChange={(e) => onSearchChange(e.target.value)}
onChange={onSearchChange}
placeholder={t("admin.searchUsers")}
className="h-7 text-xs rounded-none"
fill
/>
</div>
{users.map((user) => {
const authLabel =
user.isOidc && user.passwordHash
user.isExternal && user.passwordHash
? t("admin.authTypeDual")
: user.isOidc
? t("admin.authTypeOidc")
: user.isExternal
? t("admin.authTypeExternal")
: t("admin.authTypeLocal");
return (
<div
@@ -209,7 +213,7 @@ export function AdminUsersSection({
>
<Pencil className="size-3" />
</Button>
{user.isOidc && user.passwordHash ? (
{user.isExternal && user.passwordHash ? (
<Button
variant="ghost"
size="icon"
@@ -235,7 +239,7 @@ export function AdminUsersSection({
setLinkAccountTarget({
id: user.id,
username: user.username,
isOidc: user.isOidc,
isExternal: user.isExternal,
});
setLinkAccountOpen(true);
}}
@@ -249,6 +253,13 @@ export function AdminUsersSection({
className="size-6 text-muted-foreground hover:text-destructive"
disabled={user.isAdmin}
onClick={async () => {
const ok = await confirm({
title: t("admin.deleteUserConfirm", {
username: user.username,
}),
description: t("manage.cannotBeUndone"),
});
if (!ok) return;
try {
await deleteUser(user.username);
setUsers((prev) => prev.filter((u) => u.id !== user.id));
@@ -321,6 +332,7 @@ export function AdminSessionsSection({
loadSessions,
}: SessionsSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
return (
<AccordionSection
@@ -375,6 +387,13 @@ export function AdminSessionsSection({
size="sm"
className="text-[10px] text-muted-foreground hover:text-destructive h-6 px-1.5"
onClick={async () => {
const ok = await confirm({
title: t("admin.revokeAllSessionsConfirm", {
username: session.username ?? "",
}),
confirmLabel: t("admin.revokeAll"),
});
if (!ok) return;
try {
await revokeAllUserSessions(session.userId);
setSessions((prev) =>
@@ -393,6 +412,11 @@ export function AdminSessionsSection({
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={async () => {
const ok = await confirm({
title: t("admin.revokeSessionConfirm"),
confirmLabel: t("admin.revokeSession"),
});
if (!ok) return;
try {
await revokeSession(session.id);
setSessions((prev) =>
@@ -447,6 +471,7 @@ export function AdminRolesSection({
createRoleLoading,
}: RolesSectionProps) {
const { t } = useTranslation();
const confirm = useConfirm();
const [catalog, setCatalog] = useState<PermissionCatalogEntry[]>([]);
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
const [editingPermissions, setEditingPermissions] = useState<Set<string>>(
@@ -544,7 +569,7 @@ export function AdminRolesSection({
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder="e.g., developer"
placeholder="developer"
value={newRoleName}
onChange={(e) => setNewRoleName(e.target.value)}
className="text-xs"
@@ -556,7 +581,7 @@ export function AdminRolesSection({
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder="e.g., Developer"
placeholder="Developer"
value={newRoleDisplayName}
onChange={(e) => setNewRoleDisplayName(e.target.value)}
className="text-xs"
@@ -613,7 +638,7 @@ export function AdminRolesSection({
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-semibold truncate">
{role.displayName}
{roleLabel(t, role.displayName)}
</span>
{role.isSystem ? (
<span className="text-[9px] font-semibold px-1 py-px border border-border text-muted-foreground">
@@ -652,6 +677,12 @@ export function AdminRolesSection({
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={async () => {
const ok = await confirm({
title: t("admin.deleteRoleConfirm", {
name: role.displayName,
}),
});
if (!ok) return;
await deleteRole(role.id);
setRoles((prev) =>
prev.filter((r) => r.id !== role.id),
@@ -0,0 +1,77 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { SettingRow } from "@/components/section-card";
import { useConfirm } from "@/components/surface/surface-scope";
import { getDeveloperMode, setDeveloperMode } from "@/api/plugins-api";
import { AdminToggle } from "./AdminSettingsShared";
/** Lets admins install plugins from a file. Asks before turning it on. */
export function AdminPluginDeveloperMode() {
const { t } = useTranslation();
const confirm = useConfirm();
const [enabled, setEnabled] = useState(false);
const [signedOnly, setSignedOnly] = useState(false);
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
getDeveloperMode()
.then((state) => {
if (cancelled) return;
setEnabled(state.enabled);
setSignedOnly(state.signedOnly);
setLoaded(true);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const toggle = async () => {
const next = !enabled;
if (next) {
const ok = await confirm({
title: t("plugins.manager.developer.enableTitle"),
description: t("plugins.manager.developer.enableBody"),
confirmLabel: t("plugins.manager.developer.enable"),
});
if (!ok) return;
}
setSaving(true);
try {
await setDeveloperMode(next);
setEnabled(next);
} catch (error) {
toast.error(
t("plugins.manager.errors.developerMode", {
error: error instanceof Error ? error.message : String(error),
}),
);
} finally {
setSaving(false);
}
};
return (
<>
<SettingRow
label={t("plugins.manager.developer.title")}
description={t("plugins.manager.developer.hint")}
>
<AdminToggle
on={enabled}
onToggle={() => void toggle()}
disabled={!loaded || saving}
/>
</SettingRow>
{enabled && signedOnly && (
<p className="text-[10px] text-destructive">
{t("plugins.manager.developer.signedOnly")}
</p>
)}
</>
);
}
+36 -15
View File
@@ -1,3 +1,4 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useBranding } from "@/contexts/BrandingContext";
@@ -102,11 +103,15 @@ const USERS_PAGE_SIZE = 25;
export function AdminSettingsPanel({
onEditingChange,
onOpenHostTab,
section,
}: {
onEditingChange?: (editing: boolean) => void;
onOpenHostTab?: (host: Host) => void;
/** Shows just this section, flat, as a Settings page. */
section?: AdminSection;
} = {}) {
const { t } = useTranslation();
const confirm = useConfirm();
const [openSections, setOpenSections] = useState<
Set<AdminSection | FeatureSectionId>
>(() => new Set(["general"]));
@@ -151,7 +156,7 @@ export function AdminSettingsPanel({
const [linkAccountTarget, setLinkAccountTarget] = useState<{
id: string;
username: string;
isOidc: boolean;
isExternal: boolean;
} | null>(null);
// Unlink account dialog
@@ -242,7 +247,7 @@ export function AdminSettingsPanel({
id: user.userId,
username: user.username,
isAdmin: user.is_admin,
isOidc: user.is_external ?? user.is_oidc,
isExternal: user.is_external ?? false,
passwordHash: user.password_hash,
dataUnlocked: user.data_unlocked,
secondFactorEnabled: user.second_factor_enabled,
@@ -567,6 +572,13 @@ export function AdminSettingsPanel({
async function handleDeleteEditUser() {
if (!editUserTarget) return;
const ok = await confirm({
title: t("admin.deleteUserConfirm", {
username: editUserTarget.username,
}),
description: t("manage.cannotBeUndone"),
});
if (!ok) return;
setEditUserLoading(true);
try {
await deleteUser(editUserTarget.username);
@@ -734,6 +746,9 @@ export function AdminSettingsPanel({
}
}
const isOpen = (id: AdminSection | FeatureSectionId) =>
section ? section === id : openSections.has(id);
if (manageUser) {
return (
<AdminUserManagePanel
@@ -758,9 +773,15 @@ export function AdminSettingsPanel({
}
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto">
<div
className={
section
? "flex w-full flex-col gap-2"
: "mx-auto flex w-full max-w-5xl flex-col gap-2 p-3 flex-1 min-h-0 overflow-y-auto"
}
>
<AdminGeneralSettingsSection
open={openSections.has("general")}
open={isOpen("general")}
onToggle={() => toggle("general")}
notificationPrivateEndpoints={notificationPrivateEndpoints}
onSaveNotificationPrivateEndpoints={
@@ -790,7 +811,7 @@ export function AdminSettingsPanel({
/>
<AdminUsersSection
open={openSections.has("users")}
open={isOpen("users")}
onToggle={() => toggle("users")}
users={users}
setUsers={setUsers}
@@ -812,7 +833,7 @@ export function AdminSettingsPanel({
/>
<AdminSessionsSection
open={openSections.has("sessions")}
open={isOpen("sessions")}
onToggle={() => toggle("sessions")}
sessions={sessions}
setSessions={setSessions}
@@ -820,7 +841,7 @@ export function AdminSettingsPanel({
/>
<AdminRolesSection
open={openSections.has("roles")}
open={isOpen("roles")}
onToggle={() => toggle("roles")}
roles={roles}
setRoles={setRoles}
@@ -837,12 +858,12 @@ export function AdminSettingsPanel({
/>
<AdminHostDefaultsSection
open={openSections.has("host-defaults")}
open={isOpen("host-defaults")}
onToggle={() => toggle("host-defaults")}
/>
<AdminBrandingSection
open={openSections.has("branding")}
open={isOpen("branding")}
onToggle={() => toggle("branding")}
settings={brandingSettings}
setSettings={setBrandingSettings}
@@ -852,7 +873,7 @@ export function AdminSettingsPanel({
/>
<AdminDatabaseSection
open={openSections.has("database")}
open={isOpen("database")}
onToggle={() => toggle("database")}
importFile={importFile}
setImportFile={setImportFile}
@@ -863,7 +884,7 @@ export function AdminSettingsPanel({
/>
<AdminSSLSection
open={openSections.has("ssl")}
open={isOpen("ssl")}
onToggle={() => toggle("ssl")}
status={tlsStatus}
manualCertDraft={manualCertDraft}
@@ -875,7 +896,7 @@ export function AdminSettingsPanel({
/>
<AdminApiKeysSection
open={openSections.has("api-keys")}
open={isOpen("api-keys")}
onToggle={() => toggle("api-keys")}
apiKeys={apiKeys}
setApiKeys={setApiKeys}
@@ -896,7 +917,7 @@ export function AdminSettingsPanel({
/>
<AdminAuditLogSection
open={openSections.has("audit-log")}
open={isOpen("audit-log")}
onToggle={() => toggle("audit-log")}
users={users}
/>
@@ -906,7 +927,7 @@ export function AdminSettingsPanel({
key={plugin.id}
plugin={plugin}
scope="admin"
open={openSections.has(featureSectionId(plugin.id))}
open={isOpen(featureSectionId(plugin.id))}
onToggle={() => toggle(featureSectionId(plugin.id))}
/>
))}
@@ -954,7 +975,7 @@ export function AdminSettingsPanel({
setUsers((prev) =>
prev.map((u) =>
u.id === userId
? { ...u, isOidc: false, passwordHash: undefined }
? { ...u, isExternal: false, passwordHash: undefined }
: u,
),
)
+8 -12
View File
@@ -6,7 +6,9 @@ import { SettingRow } from "@/components/section-card";
import { Database, Lock, RefreshCw, Server, Settings } from "lucide-react";
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
import { AdminHostTags } from "./AdminHostTags";
import { AdminPluginDeveloperMode } from "./AdminPluginDeveloperMode";
import type { TlsStatus } from "@/api/tls-api";
import { DocsLink } from "@/components/docs-link";
type GeneralSettingsSectionProps = {
open: boolean;
@@ -116,8 +118,8 @@ export function AdminGeneralSettingsSection({
</p>
)}
<SettingRow
label={t("admin.oidcAutoProvision")}
description={t("admin.oidcAutoProvisionDesc")}
label={t("admin.externalAutoProvision")}
description={t("admin.externalAutoProvisionDesc")}
>
<AdminToggle
on={externalAutoProvision}
@@ -142,6 +144,7 @@ export function AdminGeneralSettingsSection({
onToggle={handleTogglePasswordReset}
/>
</SettingRow>
<AdminPluginDeveloperMode />
<div className="flex flex-col gap-2 pt-3 mt-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.sessionTimeout")}
@@ -372,7 +375,7 @@ function certState(
const CERT_STATUS_STYLES: Record<ReturnType<typeof certState>, string> = {
none: "text-muted-foreground",
valid: "text-green-500",
expiring: "text-yellow-500",
expiring: "text-warning",
expired: "text-destructive",
};
@@ -421,14 +424,7 @@ export function AdminSSLSection({
<div className="flex flex-col gap-3 pt-3">
<span className="text-[10px] text-muted-foreground">
{t("admin.sslDescription")}{" "}
<a
href="https://docs.termix.site/features/networking/ssl"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("admin.sslDocsLink")}
</a>
<DocsLink core="https">{t("admin.sslDocsLink")}</DocsLink>
</span>
<div className="flex flex-col gap-0.5 p-2 border border-border bg-background/50">
@@ -470,7 +466,7 @@ export function AdminSSLSection({
</div>
{cert && !cert.selfSigned && !status?.renewal && (
<div className="p-2 border border-yellow-500/40 bg-yellow-500/10 text-[10px] text-yellow-600 dark:text-yellow-400">
<div className="p-2 border border-warning/40 bg-warning/10 text-[10px] text-warning">
{t("admin.sslNotRenewed", { date: expiry })}
</div>
)}
+10
View File
@@ -1,5 +1,6 @@
import type React from "react";
import { ChevronDown } from "lucide-react";
import { useSettingsEmbedded } from "@/settings/settings-embed";
export function AdminToggle({
on,
@@ -36,6 +37,15 @@ export function AccordionSection({
onToggle: () => void;
children: React.ReactNode;
}) {
const embedded = useSettingsEmbedded();
if (embedded) {
if (!open) return null;
return (
<div className="flex flex-col border border-border bg-card px-3 pb-3">
{children}
</div>
);
}
return (
<div className="border border-border bg-card overflow-hidden shrink-0">
<button
+377 -399
View File
@@ -1,3 +1,4 @@
import { InlineView } from "@/components/surface/surface-scope";
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import {
@@ -9,17 +10,11 @@ import {
import type { Role, UserRole } from "@/main-axios";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { AlertCircle, Eye, EyeOff, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { AdminToggle } from "./AdminSettingsShared";
import type { AdminUser } from "./AdminManagementSections";
import { roleLabel } from "@/lib/role-label";
type ApiErrorLike = {
response?: {
@@ -63,83 +58,80 @@ export function AdminCreateUserDialog({
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.createUserTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("admin.createUserDesc")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.createUserTitle")}
>
<p className="text-xs text-muted-foreground">
{t("admin.createUserDesc")}
</p>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder={t("admin.createUserEnterUsername")}
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserPassword")}{" "}
<span className="text-accent-brand">*</span>
</label>
<div className="relative">
<Input
placeholder={t("admin.createUserEnterUsername")}
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
type={showNewPassword ? "text" : "password"}
placeholder={t("placeholders.enterPassword")}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
className="pr-9"
/>
<button
onClick={() => setShowNewPassword((o) => !o)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showNewPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("admin.createUserPassword")}{" "}
<span className="text-accent-brand">*</span>
</label>
<div className="relative">
<Input
type={showNewPassword ? "text" : "password"}
placeholder={t("placeholders.enterPassword")}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreateUser()}
className="pr-9"
/>
<button
onClick={() => setShowNewPassword((o) => !o)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showNewPassword ? (
<EyeOff className="size-4" />
) : (
<Eye className="size-4" />
)}
</button>
</div>
<span className="text-xs text-muted-foreground">
{t("admin.createUserPasswordHint")}
</span>
</div>
<span className="text-xs text-muted-foreground">
{t("admin.createUserPasswordHint")}
</span>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => {
onOpenChange(false);
setNewUsername("");
setNewPassword("");
}}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleCreateUser}
disabled={createUserLoading}
>
{createUserLoading
? t("admin.creating")
: t("admin.createUserSubmit")}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => {
onOpenChange(false);
setNewUsername("");
setNewPassword("");
}}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleCreateUser}
disabled={createUserLoading}
>
{createUserLoading
? t("admin.creating")
: t("admin.createUserSubmit")}
</Button>
</div>
</InlineView>
);
}
@@ -173,222 +165,210 @@ export function AdminEditUserDialog({
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.editUserTitle", { username: editUserTarget?.username })}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("admin.editUserDesc")}
</DialogDescription>
</DialogHeader>
{editUserTarget && (
<div className="flex flex-col gap-0 mt-1 divide-y divide-border">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 py-3">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUsername")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.username}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAuthType")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isOidc && editUserTarget.passwordHash
? t("admin.authTypeDual")
: editUserTarget.isOidc
? t("admin.authTypeOidc")
: t("admin.authTypeLocal")}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAdminStatus")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isAdmin
? t("admin.adminStatusAdministrator")
: t("admin.adminStatusRegularUser")}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUserId")}
</span>
<span className="text-xs font-mono text-muted-foreground truncate">
{editUserTarget.id}
</span>
</div>
</div>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.userAdminAccess")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.userAdminAccessDesc")}
</span>
</div>
<AdminToggle
on={editUserTarget.isAdmin}
onToggle={() => handleToggleAdmin(editUserTarget)}
/>
</div>
<div className="flex flex-col gap-2 py-3">
<span className="text-sm font-medium">
{t("admin.userRoles")}
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.editUserTitle", { username: editUserTarget?.username })}
>
<p className="text-xs text-muted-foreground">{t("admin.editUserDesc")}</p>
{editUserTarget && (
<div className="flex flex-col gap-0 mt-1 divide-y divide-border">
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-2 py-3">
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUsername")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.username}
</span>
{editUserRolesLoading ? (
<span className="text-xs text-muted-foreground">
{t("common.loading")}
</span>
) : (
<>
{editUserRoles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{editUserRoles.map((ur) => {
const roleInfo = roles.find((r) => r.id === ur.roleId);
const isSystem = roleInfo?.isSystem ?? false;
return (
<span
key={ur.roleId}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
>
{ur.roleDisplayName}
{!isSystem && (
<button
onClick={async () => {
try {
await removeRoleFromUser(
editUserTarget.id,
ur.roleId,
);
setEditUserRoles((prev) =>
prev.filter(
(r) => r.roleId !== ur.roleId,
),
);
} catch {
toast.error(t("admin.removeRoleFailed"));
}
}}
className="hover:text-destructive ml-0.5"
>
×
</button>
)}
</span>
);
})}
</div>
)}
{roles.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
).length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.addRole")}
</span>
<div className="flex flex-wrap gap-1.5">
{roles
.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
)
.map((r) => (
<button
key={r.id}
onClick={async () => {
try {
await assignRoleToUser(
editUserTarget.id,
r.id,
);
setEditUserRoles((prev) => [
...prev,
{
userId: editUserTarget.id,
roleId: r.id,
roleName: r.name,
roleDisplayName: r.displayName,
grantedBy: "",
grantedByUsername: "",
grantedAt: new Date().toISOString(),
},
]);
} catch {
toast.error(t("admin.assignRoleFailed"));
}
}}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-border text-muted-foreground hover:border-accent-brand/40 hover:text-accent-brand transition-colors"
>
+ {r.displayName}
</button>
))}
</div>
</div>
)}
{editUserRoles.length === 0 &&
roles.filter((r) => !r.isSystem).length === 0 && (
<span className="text-xs text-muted-foreground">
{t("admin.noCustomRoles")}
</span>
)}
</>
)}
</div>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.revokeAllUserSessions")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.revokeAllUserSessionsDesc")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive shrink-0 ml-8"
onClick={() => handleRevokeUserSessions(editUserTarget.id)}
disabled={editUserLoading}
>
{t("admin.revoke")}
</Button>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAuthType")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isExternal && editUserTarget.passwordHash
? t("admin.authTypeDual")
: editUserTarget.isExternal
? t("admin.authTypeExternal")
: t("admin.authTypeLocal")}
</span>
</div>
<div className="flex flex-col gap-2 py-3">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.deleteUserWarning")}
</span>
</div>
<Button
variant="outline"
className="w-full border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={editUserTarget.isAdmin || editUserLoading}
onClick={handleDeleteEditUser}
>
<Trash2 className="size-3.5" />
{editUserLoading
? t("admin.deleting")
: t("admin.deleteUser", {
username: editUserTarget.username,
})}
</Button>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserAdminStatus")}
</span>
<span className="text-sm font-semibold">
{editUserTarget.isAdmin
? t("admin.adminStatusAdministrator")
: t("admin.adminStatusRegularUser")}
</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.editUserUserId")}
</span>
<span className="text-xs font-mono text-muted-foreground truncate">
{editUserTarget.id}
</span>
</div>
</div>
)}
</DialogContent>
</Dialog>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.userAdminAccess")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.userAdminAccessDesc")}
</span>
</div>
<AdminToggle
on={editUserTarget.isAdmin}
onToggle={() => handleToggleAdmin(editUserTarget)}
/>
</div>
<div className="flex flex-col gap-2 py-3">
<span className="text-sm font-medium">{t("admin.userRoles")}</span>
{editUserRolesLoading ? (
<span className="text-xs text-muted-foreground">
{t("common.loading")}
</span>
) : (
<>
{editUserRoles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{editUserRoles.map((ur) => {
const roleInfo = roles.find((r) => r.id === ur.roleId);
const isSystem = roleInfo?.isSystem ?? false;
return (
<span
key={ur.roleId}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
>
{roleLabel(t, ur.roleDisplayName)}
{!isSystem && (
<button
onClick={async () => {
try {
await removeRoleFromUser(
editUserTarget.id,
ur.roleId,
);
setEditUserRoles((prev) =>
prev.filter((r) => r.roleId !== ur.roleId),
);
} catch {
toast.error(t("admin.removeRoleFailed"));
}
}}
className="hover:text-destructive ml-0.5"
>
×
</button>
)}
</span>
);
})}
</div>
)}
{roles.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
).length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("admin.addRole")}
</span>
<div className="flex flex-wrap gap-1.5">
{roles
.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
)
.map((r) => (
<button
key={r.id}
onClick={async () => {
try {
await assignRoleToUser(editUserTarget.id, r.id);
setEditUserRoles((prev) => [
...prev,
{
userId: editUserTarget.id,
roleId: r.id,
roleName: r.name,
roleDisplayName: r.displayName,
grantedBy: "",
grantedByUsername: "",
grantedAt: new Date().toISOString(),
},
]);
} catch {
toast.error(t("admin.assignRoleFailed"));
}
}}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-border text-muted-foreground hover:border-accent-brand/40 hover:text-accent-brand transition-colors"
>
+ {r.displayName}
</button>
))}
</div>
</div>
)}
{editUserRoles.length === 0 &&
roles.filter((r) => !r.isSystem).length === 0 && (
<span className="text-xs text-muted-foreground">
{t("admin.noCustomRoles")}
</span>
)}
</>
)}
</div>
<div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{t("admin.revokeAllUserSessions")}
</span>
<span className="text-xs text-muted-foreground">
{t("admin.revokeAllUserSessionsDesc")}
</span>
</div>
<Button
variant="outline"
size="sm"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive shrink-0 ml-8"
onClick={() => handleRevokeUserSessions(editUserTarget.id)}
disabled={editUserLoading}
>
{t("admin.revoke")}
</Button>
</div>
<div className="flex flex-col gap-2 py-3">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.deleteUserWarning")}
</span>
</div>
<Button
variant="outline"
className="w-full border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={editUserTarget.isAdmin || editUserLoading}
onClick={handleDeleteEditUser}
>
<Trash2 className="size-3.5" />
{editUserLoading
? t("admin.deleting")
: t("admin.deleteUser", {
username: editUserTarget.username,
})}
</Button>
</div>
</div>
)}
</InlineView>
);
}
@@ -424,52 +404,53 @@ export function AdminUnlinkAccountDialog({
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.unlinkAccountTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("admin.unlinkAccountDesc", {
username: unlinkAccountTarget?.username,
})}
</DialogDescription>
</DialogHeader>
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.unlinkAccountWarning")}
</span>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={submitting || !unlinkAccountTarget}
onClick={handleSubmit}
>
{submitting
? t("admin.unlinkAccountInProgress")
: t("admin.unlinkAccount")}
</Button>
</div>
</DialogContent>
</Dialog>
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.unlinkAccountTitle")}
>
<p className="text-xs text-muted-foreground">
{t("admin.unlinkAccountDesc", {
username: unlinkAccountTarget?.username,
})}
</p>
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("admin.unlinkAccountWarning")}
</span>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={submitting || !unlinkAccountTarget}
onClick={handleSubmit}
>
{submitting
? t("admin.unlinkAccountInProgress")
: t("admin.unlinkAccount")}
</Button>
</div>
</InlineView>
);
}
type LinkAccountDialogProps = {
open: boolean;
onOpenChange: Dispatch<SetStateAction<boolean>>;
linkAccountTarget: { id: string; username: string; isOidc: boolean } | null;
linkAccountTarget: {
id: string;
username: string;
isExternal: boolean;
} | null;
setUsers: Dispatch<SetStateAction<AdminUser[]>>;
users: AdminUser[];
};
@@ -489,7 +470,7 @@ export function AdminLinkAccountDialog({
if (open) setOtherUsername("");
}, [open, linkAccountTarget]);
const isOidcInitiator = linkAccountTarget?.isOidc ?? true;
const isExternalInitiator = linkAccountTarget?.isExternal ?? true;
const handleSubmit = async () => {
const trimmed = otherUsername.trim();
@@ -497,15 +478,15 @@ export function AdminLinkAccountDialog({
setSubmitting(true);
try {
if (isOidcInitiator) {
if (isExternalInitiator) {
await linkExternalToPasswordAccount(linkAccountTarget.id, trimmed);
setUsers((prev) => prev.filter((u) => u.id !== linkAccountTarget.id));
} else {
const oidcUser = users.find(
(u) => u.username === trimmed && u.isOidc && !u.passwordHash,
(u) => u.username === trimmed && u.isExternal && !u.passwordHash,
);
if (!oidcUser) {
toast.error(t("admin.linkAccountOidcNotFound"));
toast.error(t("admin.linkAccountExternalNotFound"));
return;
}
await linkExternalToPasswordAccount(
@@ -525,74 +506,71 @@ export function AdminLinkAccountDialog({
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("admin.linkAccountTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{isOidcInitiator
? t("admin.linkAccountDesc", {
username: linkAccountTarget?.username,
})
: t("admin.linkAccountDescLocal", {
username: linkAccountTarget?.username,
})}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col gap-1 text-xs text-destructive">
<span>{t("admin.linkAccountWarningTitle")}</span>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>{t("admin.linkAccountEffect1")}</li>
<li>{t("admin.linkAccountEffect2")}</li>
<li>{t("admin.linkAccountEffect3")}</li>
</ul>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{isOidcInitiator
? t("admin.linkAccountTargetUsername")
: t("admin.linkAccountOidcUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={otherUsername}
onChange={(e) => setOtherUsername(e.target.value)}
placeholder={
isOidcInitiator
? t("admin.linkAccountTargetPlaceholder")
: t("admin.linkAccountOidcPlaceholder")
}
autoFocus
disabled={submitting}
/>
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("admin.linkAccountTitle")}
>
<p className="text-xs text-muted-foreground">
{isExternalInitiator
? t("admin.linkAccountDesc", {
username: linkAccountTarget?.username,
})
: t("admin.linkAccountDescLocal", {
username: linkAccountTarget?.username,
})}
</p>
<div className="flex flex-col gap-4 mt-1">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col gap-1 text-xs text-destructive">
<span>{t("admin.linkAccountWarningTitle")}</span>
<ul className="list-disc list-inside space-y-0.5 ml-1">
<li>{t("admin.linkAccountEffect1")}</li>
<li>{t("admin.linkAccountEffect2")}</li>
<li>{t("admin.linkAccountEffect3")}</li>
</ul>
</div>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{isExternalInitiator
? t("admin.linkAccountTargetUsername")
: t("admin.linkAccountExternalUsername")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
value={otherUsername}
onChange={(e) => setOtherUsername(e.target.value)}
placeholder={
isExternalInitiator
? t("admin.linkAccountTargetPlaceholder")
: t("admin.linkAccountExternalPlaceholder")
}
autoFocus
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={submitting || !linkAccountTarget || !otherUsername.trim()}
onClick={handleSubmit}
>
{submitting
? t("admin.linkAccountInProgress")
: t("admin.linkAccounts")}
</Button>
/>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={submitting}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={submitting || !linkAccountTarget || !otherUsername.trim()}
onClick={handleSubmit}
>
{submitting
? t("admin.linkAccountInProgress")
: t("admin.linkAccounts")}
</Button>
</div>
</InlineView>
);
}
+39 -87
View File
@@ -1,3 +1,5 @@
import { Select2 } from "@/components/select2";
import { useConfirm } from "@/components/surface/surface-scope";
import { useEffect, useState } from "react";
import { hostProtocolFlags, type HostProtocols } from "./host-protocols";
import { getUserList } from "@/main-axios";
@@ -50,8 +52,11 @@ import { AdminSecondFactorsSection } from "./AdminSecondFactorsSection";
import { HostEditor } from "./HostEditor";
import { mapCredentials, sshHostToHost } from "./HostManagerData";
import type { AdminSession, AdminUser } from "./AdminManagementSections";
import { makeCredentialTabs, makeHostTabs, TabStrip } from "./HostManagerTabs";
import { TabStrip } from "./HostManagerTabs";
import { BackButton, PanelShell } from "@/components/panel-layout";
import { useActionSlot } from "@/hooks/use-action-slot";
import { docsUrl } from "@/lib/docs";
import { roleLabel } from "@/lib/role-label";
type ApiErrorLike = {
response?: {
@@ -114,14 +119,18 @@ export function AdminUserManagePanel({
.catch(() => {});
}, [user.id]);
const [editor, setEditor] = useState<EditorState>(null);
const [editorTab, setEditorTab] = useState("general");
const [editorProtocols, setEditorProtocols] = useState<HostProtocols>(() =>
hostProtocolFlags(null),
);
const [confirmDialog, setConfirmDialog] = useState<{
message: string;
onConfirm: () => void;
} | null>(null);
const confirm = useConfirm();
const setConfirmDialog = (
request: { message: string; onConfirm: () => void } | null,
) => {
if (!request) return;
void confirm({ title: request.message }).then((ok) => {
if (ok) void request.onConfirm();
});
};
const [hosts, setHosts] = useState<Host[]>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
@@ -323,57 +332,35 @@ export function AdminUserManagePanel({
</span>
);
// Full-panel host/credential editor view (mirrors HostManager's editor view)
// Editing one of the user's hosts or credentials takes over the panel.
if (editor) {
const isHost = editor.kind === "host";
const tabs = isHost
? makeHostTabs(
t,
editorProtocols as unknown as Record<string, boolean>,
).filter((tab) => tab.id !== "ssh" || editorProtocols.enableSsh)
: makeCredentialTabs(t);
const close = () => setEditor(null);
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex flex-col shrink-0 border-b border-border">
<button
onClick={() => {
setEditor(null);
setEditorTab("general");
}}
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span>
{t("admin.manageEditorBack", { username: user.username })}
</span>
</button>
<TabStrip
tabs={tabs}
activeTab={editorTab}
onTabChange={setEditorTab}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
{isHost ? (
<PanelShell
leading={<BackButton onClick={close} />}
title={t("admin.manageEditorBack", { username: user.username })}
docs={docsUrl("users")}
status={
editor.kind === "host"
? (editor.host?.name ?? t("manage.newHost"))
: (editor.credential?.name ?? t("manage.newCredential"))
}
scroll={false}
>
{editor.kind === "host" ? (
<HostEditor
key={editor.host ? editor.host.id : "new-host"}
host={editor.host}
activeTab={editorTab}
onBack={() => {
setEditor(null);
setEditorTab("general");
}}
onBack={close}
onSave={() => {
setEditor(null);
setEditorTab("general");
close();
reloadHosts();
}}
protocols={editorProtocols}
onProtocolChange={(p) =>
setEditorProtocols((prev) => ({ ...prev, ...p }))
}
onTabChange={setEditorTab}
hosts={hosts}
credentials={credentials}
adminTargetUserId={user.id}
@@ -382,7 +369,6 @@ export function AdminUserManagePanel({
<CredentialEditorView
key={editor.credential ? editor.credential.id : "new-cred"}
credential={editor.credential}
activeTab={editorTab}
existingFolders={Array.from(
new Set(
credentials
@@ -390,19 +376,15 @@ export function AdminUserManagePanel({
.filter((f): f is string => !!f),
),
).sort()}
onBack={() => {
setEditor(null);
setEditorTab("general");
}}
onBack={close}
onSave={() => {
setEditor(null);
setEditorTab("general");
close();
reloadCredentials();
}}
adminTargetUserId={user.id}
/>
)}
</div>
</PanelShell>
</div>
);
}
@@ -450,9 +432,9 @@ export function AdminUserManagePanel({
{/* Password reset */}
<div className="flex flex-col gap-2">
{sectionHeading(t("admin.resetPasswordTitle"))}
{user.isOidc && !user.passwordHash ? (
{user.isExternal && !user.passwordHash ? (
<span className="text-xs text-muted-foreground">
{t("admin.resetPasswordOidcOnly")}
{t("admin.resetPasswordExternalOnly")}
</span>
) : (
<div className="flex items-center gap-2">
@@ -500,7 +482,7 @@ export function AdminUserManagePanel({
key={ur.roleId}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
>
{ur.roleDisplayName}
{roleLabel(t, ur.roleDisplayName)}
{!isSystem && (
<button
onClick={async () => {
@@ -678,7 +660,6 @@ export function AdminUserManagePanel({
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditorProtocols(hostProtocolFlags(null));
setEditorTab("general");
setEditor({ kind: "host", host: null });
}}
>
@@ -724,7 +705,6 @@ export function AdminUserManagePanel({
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => {
setEditorProtocols(hostProtocolFlags(host));
setEditorTab("general");
setEditor({ kind: "host", host });
}}
>
@@ -790,7 +770,6 @@ export function AdminUserManagePanel({
size="sm"
className="h-6 text-[10px] border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditorTab("general");
setEditor({ kind: "credential", credential: null });
}}
>
@@ -827,7 +806,6 @@ export function AdminUserManagePanel({
size="icon"
className="size-6 text-muted-foreground hover:text-foreground"
onClick={() => {
setEditorTab("general");
setEditor({ kind: "credential", credential: cred });
}}
>
@@ -969,7 +947,7 @@ export function AdminUserManagePanel({
</span>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
{t("admin.deleteSuccessorLabel")}
<select
<Select2
value={successor}
onChange={(e) => setSuccessor(e.target.value)}
className="h-7 border border-border bg-background px-2 text-xs text-foreground outline-none"
@@ -983,7 +961,7 @@ export function AdminUserManagePanel({
<option value="none">
{t("admin.deleteSuccessorNone")}
</option>
</select>
</Select2>
<span>{t("admin.deleteSuccessorDesc")}</span>
</label>
<Button
@@ -1013,32 +991,6 @@ export function AdminUserManagePanel({
</div>
)}
</div>
{/* Confirm dialog overlay */}
{confirmDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
<p className="text-sm text-foreground">{confirmDialog.message}</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setConfirmDialog(null)}
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
>
{t("common.cancel")}
</button>
<button
onClick={() => {
confirmDialog.onConfirm();
setConfirmDialog(null);
}}
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
>
{t("common.confirm")}
</button>
</div>
</div>
</div>
)}
</div>
);
}
+377 -451
View File
@@ -1,106 +1,125 @@
import { useState, useEffect, type ReactElement } from "react";
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
Check,
Eye,
EyeOff,
LogOut,
PanelRight,
Pin,
Settings,
Search,
SlidersHorizontal,
SquareArrowOutUpRight,
User,
} from "lucide-react";
import type { TabType, ToolsTab } from "@/types/ui-types";
import { Skeleton } from "@/components/skeleton";
import { readRailPreference, setRailPreference } from "./rail-preferences";
import { useRailItems, type RailItemDef } from "./rail-items";
import {
groupRailItems,
moveRailItem,
useRailItems,
type RailGroup,
type RailItemDef,
} from "./rail-items";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { toggleHiddenRailTab, useHiddenRailTabs } from "./hidden-rail-tabs";
import { RailBadge } from "./RailBadge";
import { rem } from "@/lib/rem";
import { isElectron } from "@/lib/electron";
/** Core rail views; plugins add their own ids at runtime. */
export type CoreRailView =
| "hosts"
| "credentials"
| "quick-connect"
| ToolsTab
| "connections"
| "user-profile"
| "admin-settings";
"hosts" | "credentials" | "quick-connect" | ToolsTab | "connections";
export type RailView = CoreRailView | (string & {});
type RailItem =
| {
kind?: undefined;
view: RailView;
icon: React.ReactNode;
title: string;
promotable?: boolean;
rightDockable?: boolean;
useBadge?: () => number | null | undefined;
}
| {
kind: "tab";
tabType: TabType;
icon: React.ReactNode;
title: string;
useBadge?: () => number | null | undefined;
}
| { kind: "separator" };
const btnBase =
"relative flex items-center h-7 shrink-0 transition-colors gap-2.5 focus-visible:ring-1 focus-visible:ring-ring";
const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` };
const idle = "text-muted-foreground hover:text-foreground hover:bg-muted/60";
const activeClass = "text-accent-brand bg-accent-brand/10";
function buildRailButtons(
items: RailItemDef[],
t: (key: string) => string,
hidden: Set<string>,
): RailItem[] {
const all: RailItem[] = [];
for (const item of items) {
const Icon = item.icon;
if (item.kind === "tab") {
all.push({
kind: "tab",
tabType: item.id as TabType,
icon: <Icon size={16} />,
title: t(item.labelKey),
useBadge: item.useBadge,
});
} else {
all.push({
view: item.id as RailView,
icon: <Icon size={16} />,
title: t(item.labelKey),
promotable: item.promotable,
rightDockable: item.rightDockable,
useBadge: item.useBadge,
});
}
if (item.separatorAfter) all.push({ kind: "separator" });
}
// Filter out hidden items, then collapse consecutive/leading/trailing separators
const filtered = all.filter((item) => {
if (item.kind === "separator") return true;
if ("tabType" in item) return !hidden.has(item.tabType);
return !hidden.has(item.view);
});
const result: RailItem[] = [];
for (const item of filtered) {
if (item.kind === "separator") {
if (result.length === 0 || result[result.length - 1].kind === "separator")
continue;
result.push(item);
} else {
result.push(item);
}
}
if (result[result.length - 1]?.kind === "separator") result.pop();
return result;
function RailLabel({
expanded,
children,
}: {
expanded: boolean;
children: React.ReactNode;
}) {
return (
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
expanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{children}
</span>
);
}
const btnBase =
"relative flex items-center h-7 rounded shrink-0 transition-colors gap-2.5";
const btnStyle = { margin: `0 ${rem(4)}`, padding: `0 ${rem(8)}` };
function RailIcon({
children,
useBadge,
}: {
children: React.ReactNode;
useBadge?: () => number | null | undefined;
}) {
return (
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
{children}
{useBadge && (
<RailBadge useBadge={useBadge} className="-top-1.5 -right-2" />
)}
</span>
);
}
function Rule({ expanded }: { expanded: boolean }) {
return (
<div
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{ width: expanded ? `calc(100% - ${rem(16)})` : rem(20) }}
/>
);
}
/**
* The band name rides on the divider between bands, so expanding the rail
* never pushes items down from under the pointer.
*/
function GroupDivider({
group,
expanded,
}: {
group: RailGroup;
expanded: boolean;
}) {
const { t } = useTranslation();
return (
<div className="my-0.5 flex h-3 shrink-0 items-center gap-1.5 px-2.5">
<span
aria-hidden={!expanded}
className={`shrink-0 overflow-hidden text-[10px] font-semibold uppercase leading-none tracking-widest whitespace-nowrap text-muted-foreground/60 transition-[opacity,max-width] duration-200 ${
expanded ? "max-w-32 opacity-100 delay-75" : "max-w-0 opacity-0"
}`}
>
{t(`nav.group.${group}`)}
</span>
<div className="h-px min-w-3 flex-1 bg-border" />
</div>
);
}
interface MenuTarget {
id: string;
title: string;
promotable?: boolean;
rightDockable?: boolean;
hideable: boolean;
}
export function AppRail({
railView,
@@ -111,6 +130,9 @@ export function AppRail({
onRailClick,
onOpenTab,
onOpenInRightDock,
onOpenSettings,
onOpenNavigationSettings,
onOpenPalette,
onLogout,
}: {
railView: RailView;
@@ -122,6 +144,10 @@ export function AppRail({
onRailClick: (view: RailView) => void;
onOpenTab?: (type: TabType) => void;
onOpenInRightDock?: (view: RailView) => void;
onOpenSettings: () => void;
/** The rail menu's own entry, which jumps to the navigation settings. */
onOpenNavigationSettings: () => void;
onOpenPalette?: () => void;
onLogout: (options?: { manual?: boolean }) => void;
}) {
const { t } = useTranslation();
@@ -134,22 +160,9 @@ export function AppRail({
readRailPreference("showPinAppRailButton"),
);
const [menuPos, setMenuPos] = useState<{ x: number; y: number } | null>(null);
// Which promotable item was right-clicked, so the menu can offer to open it
// as a tab. Null when the right-click landed on empty rail space.
const [menuTarget, setMenuTarget] = useState<{
view: RailView;
title: string;
promotable?: boolean;
rightDockable?: boolean;
} | null>(null);
const [hiddenTabs, setHiddenTabs] = useState<Set<string>>(() => {
try {
const stored = localStorage.getItem("hiddenRailTabs");
return stored ? new Set(JSON.parse(stored)) : new Set();
} catch {
return new Set();
}
});
const [menuTarget, setMenuTarget] = useState<MenuTarget | null>(null);
const [managing, setManaging] = useState(false);
const hiddenTabs = useHiddenRailTabs();
useEffect(() => {
const pinHandler = () => setPinned(readRailPreference("pinAppRail"));
@@ -193,34 +206,24 @@ export function AppRail({
// Plugins add and remove rail items at runtime, and permissions hide some.
const railItems = useRailItems();
const uiPrefs = useUiPreferencesContext();
const [dragId, setDragId] = useState<string | null>(null);
const [dropBefore, setDropBefore] = useState<string | null>(null);
useEffect(() => {
const handler = () => {
try {
const stored = localStorage.getItem("hiddenRailTabs");
setHiddenTabs(stored ? new Set(JSON.parse(stored)) : new Set());
} catch {
setHiddenTabs(new Set());
}
};
window.addEventListener("hiddenRailTabsChanged", handler);
return () => window.removeEventListener("hiddenRailTabsChanged", handler);
}, []);
const railExpanded = pinned || (expandOnHover && hovered);
const railButtons = buildRailButtons(
railItems.filter((item) => item.placement !== "footer"),
t,
hiddenTabs,
const railExpanded = pinned || (expandOnHover && hovered) || managing;
const mainItems = railItems.filter((item) => item.placement !== "footer");
const bands = useMemo(
() => groupRailItems(mainItems.filter((item) => !hiddenTabs.has(item.id))),
[mainItems, hiddenTabs],
);
const hiddenItems = mainItems.filter((item) => hiddenTabs.has(item.id));
const footerItems = railItems.filter(
(item) => item.placement === "footer" && !hiddenTabs.has(item.id),
);
const setRailPinned = (nextPinned: boolean) => {
setPinned(nextPinned);
localStorage.setItem("pinAppRail", String(nextPinned));
window.dispatchEvent(new Event("pinAppRailChanged"));
};
useEffect(() => {
if (hiddenItems.length === 0) setManaging(false);
}, [hiddenItems.length]);
const togglePinned = () => {
setRailPreference("pinAppRail", !pinned);
@@ -232,29 +235,126 @@ export function AppRail({
setMenuPos(null);
};
const sameBand = (a: string, b: string) =>
(mainItems.find((item) => item.id === a)?.group ?? "tools") ===
(mainItems.find((item) => item.id === b)?.group ?? "tools");
const renderItem = (item: RailItemDef) => {
const Icon = item.icon;
const title = t(item.labelKey);
const isTab = item.kind === "tab";
const active = !isTab && sidebarOpen && railView === item.id;
const reorderable = item.placement !== "footer" && !!uiPrefs;
const dropHere =
dragId !== null && dropBefore === item.id && dragId !== item.id;
return (
<button
key={item.id}
type="button"
draggable={reorderable}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "move";
setDragId(item.id);
}}
onDragEnd={() => {
setDragId(null);
setDropBefore(null);
}}
onDragOver={(e) => {
if (!dragId || dragId === item.id || !sameBand(dragId, item.id))
return;
e.preventDefault();
setDropBefore(item.id);
}}
onDrop={(e) => {
if (!dragId || !sameBand(dragId, item.id)) return;
e.preventDefault();
uiPrefs?.setOverride(
"rail",
"order",
moveRailItem(mainItems, dragId, item.id),
);
setDragId(null);
setDropBefore(null);
}}
onClick={(e) => {
if (isTab || (item.promotable && (e.ctrlKey || e.metaKey))) {
onOpenTab?.(item.id as TabType);
return;
}
onRailClick(item.id as RailView);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.id as TabType);
}}
onContextMenu={() =>
setMenuTarget({
id: item.id,
title,
promotable: item.promotable,
rightDockable: item.rightDockable,
hideable: !item.alwaysVisible && item.hideable !== false,
})
}
data-rail-item=""
aria-current={active ? "page" : undefined}
title={item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title}
style={btnStyle}
className={`${btnBase} ${active ? activeClass : idle} ${dragId === item.id ? "opacity-40" : ""} ${dropHere ? "relative before:absolute before:inset-x-1 before:-top-0.5 before:h-0.5 before:bg-accent-brand" : ""}`}
>
<RailIcon useBadge={item.useBadge}>
<Icon size={16} />
</RailIcon>
<RailLabel expanded={railExpanded}>{title}</RailLabel>
</button>
);
};
return (
<div
className="hidden md:flex flex-col items-stretch bg-sidebar border-r border-border shrink-0 overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0"
style={{ width: rem(railExpanded ? 160 : 40) }}
// The desktop app keeps the rail however narrow its window gets.
className={`${isElectron() ? "flex" : "hidden md:flex"} flex-col items-stretch bg-sidebar border-r border-border shrink-0 overflow-hidden pt-2 gap-1 transition-[width] duration-200 min-h-0`}
style={{ width: rem(railExpanded ? 172 : 40) }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onContextMenu={(e) => {
e.preventDefault();
// Keep the menu on screen when right-clicking near the viewport edges
const MENU_W = 190;
const MENU_H = 96;
const MENU_W = 200;
const MENU_H = 180;
setMenuPos({
x: Math.min(e.clientX, window.innerWidth - MENU_W - 8),
y: Math.min(e.clientY, window.innerHeight - MENU_H - 8),
});
if (!(e.target as HTMLElement).closest("[data-rail-promotable]")) {
if (!(e.target as HTMLElement).closest("[data-rail-item]")) {
setMenuTarget(null);
}
}}
>
{onOpenPalette && (
<>
<button
type="button"
onClick={onOpenPalette}
title={t("nav.searchEverything")}
aria-label={t("nav.searchEverything")}
style={btnStyle}
className={`${btnBase} ${idle}`}
>
<RailIcon>
<Search size={16} />
</RailIcon>
<RailLabel expanded={railExpanded}>{t("nav.search")}</RailLabel>
</button>
<Rule expanded={railExpanded} />
</>
)}
<div className="flex flex-col flex-1 gap-1 overflow-y-auto scrollbar-none min-h-0">
{!pluginsSettled
? Array.from({ length: railButtons.length || 8 }, (_, i) => (
? Array.from({ length: mainItems.length || 8 }, (_, i) => (
<div
key={`rail-skeleton-${i}`}
style={btnStyle}
@@ -263,307 +363,111 @@ export function AppRail({
<Skeleton className="size-4 shrink-0" />
</div>
))
: railButtons.map((item, i) =>
item.kind === "separator" ? (
<div
key={`sep-${i}`}
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{
width: railExpanded ? `calc(100% - ${rem(16)})` : rem(20),
}}
/>
) : "tabType" in item ? (
<button
key={item.tabType}
onClick={() => onOpenTab?.(item.tabType)}
style={btnStyle}
className={`${btnBase} text-muted-foreground hover:text-foreground hover:bg-muted/60`}
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
: bands.map((band, i) => (
<div key={band.group} className="flex flex-col gap-1">
{i > 0 && (
<GroupDivider group={band.group} expanded={railExpanded} />
)}
{band.items.map(renderItem)}
</div>
))}
{pluginsSettled && hiddenItems.length > 0 && (
<>
<Rule expanded={railExpanded} />
<button
type="button"
onClick={() => setManaging((v) => !v)}
aria-expanded={managing}
title={t("nav.hiddenCount", { count: hiddenItems.length })}
style={btnStyle}
className={`${btnBase} ${managing ? activeClass : idle}`}
>
<RailIcon>
<EyeOff size={16} />
</RailIcon>
<RailLabel expanded={railExpanded}>
{t("nav.hiddenCount", { count: hiddenItems.length })}
</RailLabel>
</button>
{managing &&
hiddenItems.map((item) => {
const Icon = item.icon;
return (
<button
key={`hidden-${item.id}`}
type="button"
onClick={() => void toggleHiddenRailTab(item.id)}
title={t("nav.showInRail")}
style={btnStyle}
className={`${btnBase} text-muted-foreground/70 hover:text-foreground hover:bg-muted/60`}
>
{item.icon}
{item.useBadge && (
<RailBadge
useBadge={item.useBadge}
className="-top-1.5 -right-2"
/>
)}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{item.title}
</span>
</button>
) : (
<button
key={item.view}
onClick={(e) => {
if (item.promotable && (e.ctrlKey || e.metaKey)) {
onOpenTab?.(item.view as TabType);
return;
}
onRailClick(item.view);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.view as TabType);
}}
onContextMenu={() => {
if (item.promotable || item.rightDockable)
setMenuTarget({
view: item.view,
title: item.title,
promotable: item.promotable,
rightDockable: item.rightDockable,
});
}}
data-rail-promotable={
item.promotable || item.rightDockable ? "" : undefined
}
title={
item.promotable
? `${item.title}\n${t("nav.openAsTabHint")}`
: item.title
}
style={btnStyle}
className={`${btnBase} ${
sidebarOpen && railView === item.view
? "text-accent-brand bg-accent-brand/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
{item.icon}
{item.useBadge && (
<RailBadge
useBadge={item.useBadge}
className="-top-1.5 -right-2"
/>
)}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{item.title}
</span>
</button>
),
)}
<RailIcon>
<Icon size={16} />
</RailIcon>
<span className="min-w-0 truncate text-xs font-medium whitespace-nowrap">
{t(item.labelKey)}
</span>
<Eye className="ml-auto size-3 shrink-0 opacity-60" />
</button>
);
})}
</>
)}
</div>
<div className="shrink-0 flex flex-col gap-1 border-t border-border pt-1 pb-1">
{showPinButton && (
<button
onClick={() => setRailPinned(!pinned)}
style={btnStyle}
title={
pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")
}
className={`${btnBase} ${
pinned
? "text-accent-brand bg-accent-brand/10 hover:text-accent-brand"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
<Pin size={16} />
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"
}`}
>
{pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")}
</span>
</button>
)}
{showPinButton && (
<div
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{
width: railExpanded ? `calc(100% - ${rem(16)})` : rem(20),
}}
/>
)}
{footerItems.map((item) => {
const Icon = item.icon;
const title = t(item.labelKey);
return (
<>
<button
key={item.id}
onClick={(e) => {
if (item.kind === "tab") {
onOpenTab?.(item.id as TabType);
return;
}
if (item.promotable && (e.ctrlKey || e.metaKey)) {
onOpenTab?.(item.id as TabType);
return;
}
onRailClick(item.id as RailView);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.id as TabType);
}}
onContextMenu={() => {
if (item.promotable || item.rightDockable)
setMenuTarget({
view: item.id as RailView,
title,
promotable: item.promotable,
rightDockable: item.rightDockable,
});
}}
data-rail-promotable={
item.promotable || item.rightDockable ? "" : undefined
}
title={
item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title
}
type="button"
onClick={() => setRailPreference("pinAppRail", !pinned)}
style={btnStyle}
className={`${btnBase} ${
sidebarOpen && railView === item.id
? "text-accent-brand bg-accent-brand/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
<Icon size={16} />
{item.useBadge && (
<RailBadge
useBadge={item.useBadge}
className="-top-1.5 -right-2"
/>
)}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
>
{title}
</span>
</button>
);
})}
{(
[
{
view: "user-profile" as RailView,
icon: <User size={16} />,
title: t("nav.userProfile"),
},
...(isAdmin
? [
{
view: "admin-settings" as RailView,
icon: <Settings size={16} />,
title: t("nav.admin"),
},
]
: []),
] as {
view: RailView;
icon: ReactElement;
title: string;
promotable?: boolean;
}[]
).map((item) => (
<button
key={item.view}
onClick={(e) => {
if (item.promotable && (e.ctrlKey || e.metaKey)) {
onOpenTab?.(item.view as TabType);
return;
title={
pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")
}
onRailClick(item.view);
}}
onAuxClick={(e) => {
if (e.button !== 1 || !item.promotable) return;
e.preventDefault();
onOpenTab?.(item.view as TabType);
}}
onContextMenu={() => {
if (item.promotable)
setMenuTarget({
view: item.view,
title: item.title,
promotable: item.promotable,
rightDockable: true,
});
}}
data-rail-promotable={item.promotable ? "" : undefined}
title={
item.promotable
? `${item.title}\n${t("nav.openAsTabHint")}`
: item.title
}
className={`${btnBase} ${pinned ? activeClass : idle}`}
>
<RailIcon>
<Pin size={16} />
</RailIcon>
<RailLabel expanded={railExpanded}>
{pinned ? t("nav.collapseSideMenu") : t("nav.keepSideMenuOpen")}
</RailLabel>
</button>
<Rule expanded={railExpanded} />
</>
)}
{footerItems.map(renderItem)}
{footerItems.length > 0 && !isElectron() && (
<Rule expanded={railExpanded} />
)}
{/* The desktop signs in to its own local profile by itself. */}
{!isElectron() && (
<button
type="button"
onClick={() => onLogout({ manual: true })}
style={btnStyle}
className={`${btnBase} ${
sidebarOpen && railView === item.view
? "text-accent-brand bg-accent-brand/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted/60"
}`}
title={t("common.logout")}
aria-label={t("common.logout")}
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
>
<span
className="relative shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
{item.icon}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
>
{item.title}
</span>
<RailIcon>
<LogOut size={16} />
</RailIcon>
<RailLabel expanded={railExpanded}>{t("common.logout")}</RailLabel>
</button>
))}
<div className="mx-2 my-1 border-t border-border" />
<button
onClick={() => onLogout({ manual: true })}
style={btnStyle}
title={t("common.logout")}
aria-label={t("common.logout")}
className={`${btnBase} text-muted-foreground hover:text-destructive hover:bg-destructive/10`}
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: rem(16), height: rem(16) }}
>
<LogOut size={16} />
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-[opacity,width] duration-150 ${railExpanded ? "opacity-100 delay-75" : "opacity-0 w-0"}`}
>
{t("common.logout")}
</span>
</button>
)}
</div>
<div className="shrink-0 border-t border-border">
<button
onClick={() => onRailClick("user-profile")}
title={t("nav.userProfile")}
aria-label={t("nav.userProfile")}
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
style={{ padding: "0 8px" }}
type="button"
onClick={onOpenSettings}
title={t("nav.openSettings")}
aria-label={t("nav.openSettings")}
className="flex items-center gap-2.5 w-full h-10 text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors focus-visible:ring-1 focus-visible:ring-ring"
style={{ padding: `0 ${rem(8)}` }}
>
<div
className="rounded-full bg-accent-brand/20 border border-accent-brand/30 flex items-center justify-center font-bold text-accent-brand shrink-0"
@@ -577,7 +481,7 @@ export function AppRail({
}`}
>
<span className="text-xs font-semibold leading-tight whitespace-nowrap">
{username || "User"}
{username || t("nav.roleUser")}
</span>
<span className="text-[10px] text-muted-foreground leading-tight whitespace-nowrap">
{isAdmin ? t("nav.roleAdministrator") : t("nav.roleUser")}
@@ -589,79 +493,101 @@ export function AppRail({
{menuPos && (
<div
data-rail-context-menu
role="menu"
style={{ position: "fixed", left: menuPos.x, top: menuPos.y }}
className="z-[10000] bg-popover border border-border shadow-lg py-1 min-w-[190px]"
className="z-[10000] bg-popover border border-border shadow-lg py-1 min-w-[200px]"
>
{menuTarget && (
<>
{menuTarget.promotable && (
<button
<MenuItem
icon={<SquareArrowOutUpRight className="size-3" />}
onClick={() => {
onOpenTab?.(menuTarget.view as TabType);
onOpenTab?.(menuTarget.id as TabType);
setMenuPos(null);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
>
<span className="shrink-0 w-3 flex items-center justify-center">
<SquareArrowOutUpRight className="size-3" />
</span>
{t("nav.openAsTab")}
</button>
</MenuItem>
)}
{menuTarget.rightDockable && (
<button
<MenuItem
icon={<PanelRight className="size-3" />}
onClick={() => {
onOpenInRightDock?.(menuTarget.view);
onOpenInRightDock?.(menuTarget.id);
setMenuPos(null);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
>
<span className="shrink-0 w-3 flex items-center justify-center">
<PanelRight className="size-3" />
</span>
{t("nav.openInRightDock")}
</button>
</MenuItem>
)}
{menuTarget.hideable && (
<MenuItem
icon={<EyeOff className="size-3" />}
onClick={() => {
void toggleHiddenRailTab(menuTarget.id);
setMenuPos(null);
}}
>
{t("nav.hideFromRail")}
</MenuItem>
)}
<div className="h-px bg-border my-1" />
</>
)}
<button
<MenuItem
icon={pinned ? <Check className="size-3" /> : null}
onClick={togglePinned}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
role="menuitemcheckbox"
aria-checked={pinned}
checked={pinned}
>
<span className="shrink-0 w-3 flex items-center justify-center">
{pinned && <Check className="size-3" />}
</span>
{t("newUi.sidebar.userProfile.pinAppRail")}
</button>
<button
</MenuItem>
<MenuItem
icon={expandOnHover ? <Check className="size-3" /> : null}
onClick={toggleExpandOnHover}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
role="menuitemcheckbox"
aria-checked={expandOnHover}
checked={expandOnHover}
>
<span className="shrink-0 w-3 flex items-center justify-center">
{expandOnHover && <Check className="size-3" />}
</span>
{t("newUi.sidebar.userProfile.expandAppRailOnHover")}
</button>
</MenuItem>
<div className="h-px bg-border my-1" />
<button
<MenuItem
icon={<SlidersHorizontal className="size-3" />}
onClick={() => {
onRailClick("user-profile");
onOpenNavigationSettings();
setMenuPos(null);
}}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
>
<span className="shrink-0 w-3 flex items-center justify-center">
<SlidersHorizontal className="size-3" />
</span>
{t("nav.sidebarSettings")}
</button>
{t("nav.openSettings")}
</MenuItem>
</div>
)}
</div>
);
}
function MenuItem({
icon,
onClick,
checked,
children,
}: {
icon: React.ReactNode;
onClick: () => void;
checked?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
role={checked === undefined ? "menuitem" : "menuitemcheckbox"}
aria-checked={checked}
onClick={onClick}
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
>
<span className="shrink-0 w-3 flex items-center justify-center">
{icon}
</span>
{children}
</button>
);
}
+119 -185
View File
@@ -1,8 +1,9 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { getTabType, isPersistentTabType } from "@/shell/tab-registry";
import { ComponentSlot } from "@/shell/ActionSlot";
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Plug, Search, X, Pencil, Check } from "lucide-react";
import { ExternalLink, Plug, X, Pencil, Check } from "lucide-react";
import {
getActiveSessions,
deleteOpenTab,
@@ -12,14 +13,9 @@ import {
import { tabIcon } from "@/shell/tabUtils";
import { getSessionTimeoutMinutes } from "@/api/open-tabs-api";
import type { Tab, TabType } from "@/types/ui-types";
import { Badge } from "@/components/badge";
import { Input } from "@/components/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip";
import { EmptyState } from "@/components/empty-state";
import { Facts, GroupHeading, PanelSearch } from "@/components/panel-layout";
import { ListBadge, ListRow, ListRowAction } from "@/components/list-kit";
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
@@ -80,6 +76,7 @@ function ConnectionRow({
hostName,
subLabel,
icon,
stripe,
onSwitch,
onClose,
switchTitle,
@@ -94,6 +91,7 @@ function ConnectionRow({
hostName?: string;
subLabel: string;
icon: React.ReactNode;
stripe: number;
onSwitch?: () => void;
onClose: () => void;
switchTitle?: string;
@@ -105,12 +103,6 @@ function ConnectionRow({
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(name);
function startEdit(e: React.MouseEvent) {
e.stopPropagation();
setEditValue(name);
setEditing(true);
}
function commitEdit() {
const trimmed = editValue.trim();
if (trimmed && trimmed !== name && onRename) {
@@ -119,155 +111,103 @@ function ConnectionRow({
setEditing(false);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") commitEdit();
if (e.key === "Escape") setEditing(false);
}
const registered = getTabType(tabType)?.titleKey;
const typeLabel =
CORE_TYPE_LABELS[tabType] ?? (registered ? t(registered) : tabType);
const stop = (e: React.PointerEvent) => e.stopPropagation();
return (
<div
role={onSwitch && !editing ? "button" : undefined}
tabIndex={onSwitch && !editing ? 0 : undefined}
<ListRow
stripe={stripe}
tone={isLive ? "success" : "muted"}
selected={isActive}
active={editing}
dimmed={faded || isDragging}
onClick={!editing ? onSwitch : undefined}
onKeyDown={(e) => !editing && e.key === "Enter" && onSwitch?.()}
className={`group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 transition-colors last:border-b-0 ${
faded ? "opacity-60" : ""
} ${isDragging ? "opacity-30" : ""} ${
isActive
? "bg-accent-brand/8 cursor-pointer border-l-2 border-l-accent-brand"
: onSwitch && !editing
? "hover:bg-muted/40 cursor-pointer"
: ""
}`}
>
<div
className={`shrink-0 flex items-center justify-center size-7 rounded ${
isActive
? "bg-accent-brand/15 text-accent-brand"
: "bg-muted/60 text-muted-foreground"
}`}
>
{icon}
</div>
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
<div className="flex items-center gap-1.5 min-w-0">
<span
className={`shrink-0 size-1.5 rounded-full ${
isLive ? "bg-green-500" : "bg-muted-foreground/30"
}`}
icon={icon}
title={
editing ? (
<input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") commitEdit();
if (e.key === "Escape") setEditing(false);
}}
onClick={(e) => e.stopPropagation()}
onPointerDown={stop}
className="w-full min-w-0 border-b border-accent-brand bg-transparent text-[13px] font-semibold text-foreground outline-none"
autoFocus
/>
{editing ? (
<input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
className="text-xs font-semibold flex-1 min-w-0 bg-transparent border-b border-accent-brand outline-none text-foreground"
autoFocus
/>
) : (
<span
className={`text-xs font-semibold truncate flex-1 ${
isActive ? "text-accent-brand" : "text-foreground"
}`}
>
{name}
</span>
)}
<Badge
variant="outline"
className="text-[9px] px-1 py-0 h-4 font-mono shrink-0 text-muted-foreground/60 border-border/60"
) : (
name
)
}
badges={<ListBadge className="ml-auto font-mono">{typeLabel}</ListBadge>}
meta={
<Facts>
{hostName && hostName !== name ? <span>{hostName}</span> : null}
<span>{subLabel}</span>
</Facts>
}
actions={
editing ? (
<ListRowAction
label={t("common.save")}
tone="brand"
onPointerDown={stop}
onClick={commitEdit}
>
{CORE_TYPE_LABELS[tabType] ??
(getTabType(tabType)?.titleKey
? t(getTabType(tabType)!.titleKey!)
: tabType)}
</Badge>
</div>
<span className="text-[10px] text-muted-foreground/60 truncate pl-3">
{hostName && hostName !== name ? (
<span className="text-muted-foreground/50 mr-2.5">{hostName}</span>
) : null}
{subLabel}
</span>
</div>
<TooltipProvider>
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{editing ? (
<button
onClick={(e) => {
e.stopPropagation();
commitEdit();
}}
className="size-6 flex items-center justify-center text-accent-brand hover:bg-muted/60 rounded transition-colors"
<Check />
</ListRowAction>
) : (
<>
{onRename && (
<ListRowAction
label={t("connections.rename")}
onPointerDown={stop}
onClick={() => {
setEditValue(name);
setEditing(true);
}}
>
<Pencil />
</ListRowAction>
)}
{switchTitle && onSwitch && (
<ListRowAction
label={switchTitle}
tone="brand"
onPointerDown={stop}
onClick={onSwitch}
>
<ExternalLink />
</ListRowAction>
)}
<ListRowAction
label={t("common.close")}
tone="destructive"
onPointerDown={stop}
onClick={onClose}
>
<Check className="size-3" />
</button>
) : (
onRename && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={startEdit}
onPointerDown={(e) => e.stopPropagation()}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 rounded transition-colors"
>
<Pencil className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{t("connections.rename")}
</TooltipContent>
</Tooltip>
)
)}
{switchTitle && onSwitch && !editing && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
onSwitch();
}}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 rounded transition-colors"
>
<ExternalLink className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">{switchTitle}</TooltipContent>
</Tooltip>
)}
{!editing && (
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
>
<X className="size-3" />
</button>
)}
</div>
</TooltipProvider>
</div>
<X />
</ListRowAction>
</>
)
}
/>
);
}
function SectionHeader({ label, count }: { label: string; count: number }) {
return (
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/20">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60 flex-1">
{label}
</span>
<span className="text-[10px] font-semibold text-muted-foreground/40 bg-muted/60 rounded px-1.5 py-0.5">
{count}
</span>
</div>
<GroupHeading
title={label}
count={count}
className="border-b border-border/40 px-3 pb-1.5 pt-2.5"
/>
);
}
@@ -298,6 +238,7 @@ export function ConnectionsPanel({
onReorderTabs?: (tabs: Tab[]) => void;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const [now, setNow] = useState(Date.now());
const [persistMinutes, setPersistMinutes] = useState(30);
@@ -448,42 +389,28 @@ export function ConnectionsPanel({
return (
<div className="flex flex-col flex-1">
{pluginSections}
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<Plug className="size-5 text-muted-foreground/30" />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("connections.noConnections")}
</span>
<span className="text-xs text-muted-foreground/40">
{t("connections.noConnectionsDesc")}
</span>
</div>
</div>
<EmptyState
icon={Plug}
title={t("connections.noConnections")}
hint={t("connections.noConnectionsDesc")}
className="flex-1"
/>
</div>
);
}
return (
<div className="flex flex-col">
<div className="relative px-3 py-2 border-b border-border/60">
<Search className="absolute left-5.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50 pointer-events-none" />
<Input
placeholder={t("connections.search")}
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<PanelSearch
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 h-7 text-xs"
onChange={setSearch}
placeholder={t("connections.search")}
fill
/>
</div>
{!hasResults && (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
<span className="text-xs text-muted-foreground/50">
{t("connections.noSearchResults")}
</span>
</div>
)}
{!hasResults && <EmptyState title={t("connections.noSearchResults")} />}
{filteredOpenTabs.length > 0 && (
<div className="flex flex-col">
@@ -491,7 +418,7 @@ export function ConnectionsPanel({
label={t("connections.sectionOpen")}
count={filteredOpenTabs.length}
/>
{filteredOpenTabs.map((tab) => {
{filteredOpenTabs.map((tab, index) => {
const isActive = tab.id === activeTabId;
const liveSession = tab.instanceId
? sessionByInstanceId.get(tab.instanceId)
@@ -522,7 +449,7 @@ export function ConnectionsPanel({
didDragRef.current = false;
setDragTabId(tab.id);
}}
className={`relative ${isDropTarget ? "border-t-2 border-accent-brand" : ""}`}
className={`relative ${isDropTarget ? "before:absolute before:inset-x-0 before:-top-px before:z-20 before:h-0.5 before:bg-accent-brand" : ""}`}
style={{
cursor: dragTabId
? isDraggingThis
@@ -532,6 +459,7 @@ export function ConnectionsPanel({
}}
>
<ConnectionRow
stripe={index}
isActive={isActive}
isLive={isLive}
tabType={tab.type}
@@ -563,9 +491,7 @@ export function ConnectionsPanel({
)}
{filteredBackgroundTabs.length > 0 && (
<div
className={`flex flex-col ${filteredOpenTabs.length > 0 ? "mt-2" : ""}`}
>
<div className="flex flex-col">
<SectionHeader
label={t("connections.sectionBackground")}
count={filteredBackgroundTabs.length}
@@ -577,7 +503,7 @@ export function ConnectionsPanel({
})}
</span>
</div>
{filteredBackgroundTabs.map((record) => {
{filteredBackgroundTabs.map((record, index) => {
const host = record.hostId
? allHosts.find((h) => h.id === String(record.hostId))
: undefined;
@@ -586,6 +512,7 @@ export function ConnectionsPanel({
return (
<ConnectionRow
key={record.id}
stripe={index}
isLive={false}
faded
tabType={record.tabType}
@@ -597,6 +524,13 @@ export function ConnectionsPanel({
onReopenTab(record, liveSession?.sessionId ?? null);
}}
onClose={async () => {
const ok = await confirm({
title: t("connections.forgetConfirm", {
name: host?.name ?? record.label,
}),
confirmLabel: t("connections.forget"),
});
if (!ok) return;
await deleteOpenTab(record.id).catch(() => {});
onForgetBackground(record.id);
}}
+75 -43
View File
@@ -1,4 +1,6 @@
import type React from "react";
import { useRef, useState } from "react";
import { EditorPane, EditorSection } from "@/manage/EditorPane";
import { PluginComponent } from "@/plugin-host/component-registry";
import { useTranslation } from "react-i18next";
import { copyToClipboard } from "@/lib/clipboard";
@@ -23,7 +25,7 @@ import { FolderPathPicker } from "./FolderPathPicker";
export function CredentialEditorView({
credential,
activeTab,
banner,
onBack,
onSave,
adminTargetUserId,
@@ -31,7 +33,8 @@ export function CredentialEditorView({
saveAsNewHost,
}: {
credential: Credential | null;
activeTab: string;
/** Shown above the form. */
banner?: React.ReactNode;
onBack: () => void;
onSave: (
saved: Record<string, unknown>,
@@ -170,9 +173,73 @@ export function CredentialEditorView({
}
};
const nameMissing = !credForm.name.trim();
const bands = [
{
id: "credential",
label: t("manage.bandCredential"),
items: [
{
id: "general",
label: t("hosts.basicInformation"),
icon: <Info className="size-3.5" />,
error: nameMissing,
},
{
id: "auth",
label: t("hosts.authDetailsSection"),
icon: <Lock className="size-3.5" />,
},
],
},
];
return (
<div className="flex flex-col gap-3">
{activeTab === "general" && (
<EditorPane
bands={bands}
banner={banner}
errorCount={nameMissing ? 1 : 0}
footer={
<>
<Button
variant="ghost"
size="sm"
onClick={onBack}
disabled={saving || savingAsNew}
>
{t("hosts.cancelBtn")}
</Button>
{saveAsNewHost && credential && !adminTargetUserId && (
<Button
variant="outline"
size="sm"
className="px-4"
onClick={handleSaveAsNew}
disabled={saving || savingAsNew || nameMissing}
title={t("hosts.saveCredentialAsNewDesc")}
>
{savingAsNew
? t("hosts.savingBtn")
: t("hosts.saveCredentialAsNewBtn")}
</Button>
)}
<Button
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-6"
onClick={handleSave}
disabled={saving || savingAsNew || nameMissing}
>
{saving
? t("hosts.savingBtn")
: credential
? t("hosts.updateCredentialBtn")
: t("hosts.addCredentialBtn")}
</Button>
</>
}
>
<EditorSection id="general" showHeading={false}>
<SectionCard
title={t("hosts.basicInformation")}
icon={<Info className="size-3.5" />}
@@ -265,9 +332,9 @@ export function CredentialEditorView({
</div>
</div>
</SectionCard>
)}
</EditorSection>
{activeTab === "auth" && (
<EditorSection id="auth" showHeading={false}>
<SectionCard
title={t("hosts.authDetailsSection")}
icon={<Lock className="size-3.5" />}
@@ -544,42 +611,7 @@ export function CredentialEditorView({
</div>
</div>
</SectionCard>
)}
<div className="flex justify-end gap-3 mt-3">
<Button
variant="ghost"
onClick={onBack}
disabled={saving || savingAsNew}
>
{t("hosts.cancelBtn")}
</Button>
{saveAsNewHost && credential && !adminTargetUserId && (
<Button
variant="outline"
className="px-6"
onClick={handleSaveAsNew}
disabled={saving || savingAsNew}
title={t("hosts.saveCredentialAsNewDesc")}
>
{savingAsNew
? t("hosts.savingBtn")
: t("hosts.saveCredentialAsNewBtn")}
</Button>
)}
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand px-8"
onClick={handleSave}
disabled={saving || savingAsNew}
>
{saving
? t("hosts.savingBtn")
: credential
? t("hosts.updateCredentialBtn")
: t("hosts.addCredentialBtn")}
</Button>
</div>
</div>
</EditorSection>
</EditorPane>
);
}
+321
View File
@@ -0,0 +1,321 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Upload } from "lucide-react";
import { Button } from "@/components/button";
import { Select2 } from "@/components/select2";
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import {
deleteCredential,
deployCredentialToHost,
duplicateCredential,
getCredentials,
getSSHHosts,
renameCredentialFolder,
updateCredential,
} from "@/main-axios";
import { requestManage } from "@/manage/manage-requests";
import type { Credential, Host } from "@/types/ui-types";
import type {
CredentialRowFields,
CredentialSidebarFilterState,
CredentialSortKey,
} from "@/types/credential-sidebar-preferences";
import { CredentialShareModal } from "./CredentialShareModal";
import { CredentialSidebarTree } from "./credential-tree";
import { credentialPassesFilters, sortCredentials } from "./credential-sort";
import { enabledHostProtocols } from "./host-protocols";
import { mapCredentials, sshHostToHost } from "./HostManagerData";
const UNCATEGORIZED = "Uncategorized";
/**
* The credentials sidebar list. Editing happens in the Manage tab; this keeps
* the tree, its folder moves and the quick actions on each row.
*/
export function CredentialList({
search = "",
sort = "default",
arrangeLocked = true,
filter,
density = "comfortable",
trayTrigger = "hover",
showTags = true,
rowFields,
onTagsChange,
}: {
search?: string;
sort?: CredentialSortKey;
arrangeLocked?: boolean;
filter?: CredentialSidebarFilterState;
density?: "comfortable" | "compact";
trayTrigger?: "always" | "hover" | "click" | "actionsOnly";
showTags?: boolean;
rowFields?: CredentialRowFields;
onTagsChange?: (tags: string[]) => void;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const [hosts, setHosts] = useState<Host[]>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [loading, setLoading] = useState(true);
const [shareCredential, setShareCredential] = useState<Credential | null>(
null,
);
const [deployTarget, setDeployTarget] = useState<Credential | null>(null);
const [deployHostId, setDeployHostId] = useState("");
const [deploying, setDeploying] = useState(false);
const [editingFolderName, setEditingFolderName] = useState<string | null>(
null,
);
const [editingFolderValue, setEditingFolderValue] = useState("");
useEffect(() => {
onTagsChange?.([...new Set(credentials.flatMap((c) => c.tags ?? []))]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [credentials]);
useEffect(() => {
const reloadHosts = () => {
getSSHHosts()
.then((raw) => setHosts(raw.map(sshHostToHost)))
.catch(() => {});
};
const reloadCredentials = () => {
getCredentials()
.then((res) => setCredentials(mapCredentials(res)))
.catch(() => {})
.finally(() => setLoading(false));
};
reloadHosts();
reloadCredentials();
window.addEventListener("termix:hosts-changed", reloadHosts);
window.addEventListener("ssh-hosts:changed", reloadHosts);
window.addEventListener("termix:credentials-changed", reloadCredentials);
return () => {
window.removeEventListener("termix:hosts-changed", reloadHosts);
window.removeEventListener("ssh-hosts:changed", reloadHosts);
window.removeEventListener(
"termix:credentials-changed",
reloadCredentials,
);
};
}, []);
const needle = search.toLowerCase();
const searched = credentials.filter(
(c) =>
c.name.toLowerCase().includes(needle) ||
c.username.toLowerCase().includes(needle),
);
const filtered = sortCredentials(
filter
? searched.filter((c) => credentialPassesFilters(c, filter))
: searched,
sort,
);
const folderNames = Array.from(
new Set(filtered.map((c) => c.folder || UNCATEGORIZED)),
).sort();
const folderTree = folderNames.map((name) => ({
name,
children: filtered.filter((c) => (c.folder || UNCATEGORIZED) === name),
}));
const usedByCounts = new Map<string, number>();
for (const cred of filtered) {
usedByCounts.set(
cred.id,
hosts.filter((h) => h.credentialId === cred.id).length,
);
}
const reload = async () => {
const res = await getCredentials();
setCredentials(mapCredentials(res));
};
const renameFolder = async (folder: string, newName: string) => {
try {
await renameCredentialFolder(folder, newName);
await reload();
toast.success(t("credentials.folderRenamedTo", { name: newName }));
} catch {
toast.error(t("credentials.failedToRenameFolder"));
}
};
const moveToFolder = async (credentialId: string, targetFolder: string) => {
const cred = credentials.find((c) => c.id === credentialId);
if (!cred || (cred.folder || UNCATEGORIZED) === targetFolder) return;
const folderValue = targetFolder === UNCATEGORIZED ? "" : targetFolder;
try {
await updateCredential(Number(credentialId), { folder: folderValue });
setCredentials((prev) =>
prev.map((c) =>
c.id === credentialId ? { ...c, folder: folderValue } : c,
),
);
} catch {
toast.error(t("credentials.failedToMoveCredential"));
}
};
const clone = async (cred: Credential) => {
try {
await duplicateCredential(Number(cred.id), {
name: t("credentials.clonedCredentialName", { name: cred.name }),
});
await reload();
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
toast.success(t("credentials.clonedCredential", { name: cred.name }));
} catch (err) {
const msg = err instanceof Error ? err.message : null;
toast.error(msg || t("credentials.failedToCloneCredential"));
}
};
const remove = async (cred: Credential) => {
const ok = await confirm({
title: t("credentials.deleteCredentialConfirm", { name: cred.name }),
description: t("manage.cannotBeUndone"),
});
if (!ok) return;
try {
await deleteCredential(Number(cred.id));
setCredentials((prev) => prev.filter((c) => c.id !== cred.id));
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
toast.success(t("credentials.deletedCredential", { name: cred.name }));
} catch {
toast.error(t("credentials.failedToDeleteCredential"));
}
};
const deploy = async () => {
if (!deployTarget || !deployHostId) return;
setDeploying(true);
try {
await deployCredentialToHost(
Number(deployTarget.id),
Number(deployHostId),
);
toast.success(t("credentials.keyDeployedSuccess"));
setDeployTarget(null);
} catch {
toast.error(t("credentials.failedToDeployKey"));
} finally {
setDeploying(false);
}
};
return (
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
{!loading && filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center px-4 py-12 text-center">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("credentials.noCredentialsFound")}
</span>
<Button
variant="outline"
size="sm"
className="mt-3 h-7 border-accent-brand/40 text-xs text-accent-brand hover:bg-accent-brand/10"
onClick={() =>
requestManage({ kind: "credential", credentialId: null })
}
>
{t("credentials.addCredentialBtn")}
</Button>
</div>
) : (
<CredentialSidebarTree
folders={folderTree}
usedByCounts={usedByCounts}
query=""
loading={loading}
arrangeLocked={arrangeLocked}
density={density}
trayTrigger={trayTrigger}
showTags={showTags}
rowFields={rowFields}
editingFolderName={editingFolderName}
editingFolderValue={editingFolderValue}
onEditingFolderNameChange={setEditingFolderName}
onEditingFolderValueChange={setEditingFolderValue}
onRenameFolder={renameFolder}
onMoveCredentialToFolder={moveToFolder}
onDeployCredential={(cred) => {
setDeployHostId("");
setDeployTarget(cred);
}}
onEditCredential={(cred) =>
requestManage({ kind: "credential", credentialId: cred.id })
}
onCloneCredential={clone}
onDeleteCredential={remove}
onShareCredential={setShareCredential}
/>
)}
<CredentialShareModal
credential={shareCredential}
onClose={() => setShareCredential(null)}
/>
<InlineView
open={!!deployTarget}
onOpenChange={(open) => {
if (!open) setDeployTarget(null);
}}
icon={<Upload className="size-4" />}
title={t("credentials.deployDialogTitle")}
footer={
<div className="ml-auto flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setDeployTarget(null)}
disabled={deploying}
>
{t("hosts.cancelBtn")}
</Button>
<Button
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
disabled={!deployHostId || deploying}
onClick={() => void deploy()}
>
{deploying
? t("credentials.deployingBtn")
: t("credentials.deployBtn")}
</Button>
</div>
}
>
<p className="text-xs text-muted-foreground">
{t("credentials.deployDialogDesc", { name: deployTarget?.name })}
</p>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.targetHostLabel")}
</label>
<Select2
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
value={deployHostId}
onChange={(e) => setDeployHostId(e.target.value)}
>
<option value="">{t("credentials.selectHostOption")}</option>
{hosts
.filter(
(h) => h.enableSsh || enabledHostProtocols(h).length === 0,
)
.map((h) => (
<option key={h.id} value={h.id}>
{h.name || h.ip}
</option>
))}
</Select2>
</div>
</InlineView>
</div>
);
}
+34 -31
View File
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
ArrowLeft,
Check,
ListChecks,
Search,
@@ -13,6 +12,7 @@ import {
X,
} from "lucide-react";
import { Button } from "@/components/button";
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import { Input } from "@/components/input";
import {
DropdownMenu,
@@ -29,9 +29,11 @@ import {
type CredentialPermissionLevel,
type ShareTarget,
} from "@/api/rbac-api";
import { getUserList, type AccessRecord } from "@/main-axios";
import { getUserInfo, getUserList, type AccessRecord } from "@/main-axios";
import type { Credential } from "@/types/ui-types";
import { getErrorMessage } from "@/lib/error-message";
import { DocsLink } from "@/components/docs-link";
import { roleLabel } from "@/lib/role-label";
const PERMISSION_LEVELS: CredentialPermissionLevel[] = ["use", "manage"];
@@ -72,6 +74,7 @@ export function CredentialShareModal({
onClose: () => void;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
const [search, setSearch] = useState("");
const [shareUsers, setShareUsers] = useState<
@@ -117,14 +120,15 @@ export function CredentialShareModal({
getCredentialAccess(credentialId).catch(() => ({ access: [] })),
getUserList().catch(() => ({ users: [] })),
getRoles().catch(() => ({ roles: [] })),
getUserInfo().catch(() => null),
])
.then(([accessRes, usersRes, rolesRes]) => {
.then(([accessRes, usersRes, rolesRes, me]) => {
setAccessList(accessRes.access ?? []);
// Nobody shares a credential with themselves.
setShareUsers(
(usersRes.users ?? []).map((u) => ({
id: String(u.userId),
username: u.username,
})),
(usersRes.users ?? [])
.map((u) => ({ id: String(u.userId), username: u.username }))
.filter((u) => u.id !== me?.userId),
);
setShareRoles(
(rolesRes.roles ?? [])
@@ -228,6 +232,16 @@ export function CredentialShareModal({
async function handleRevoke(record: AccessRecord) {
if (!credentialId) return;
const who =
record.username ??
roleLabel(t, record.roleDisplayName) ??
record.roleName ??
"";
const ok = await confirm({
title: t("sharing.revokeConfirm", { name: who }),
confirmLabel: t("sharing.revoke"),
});
if (!ok) return;
try {
await revokeCredentialAccess(credentialId, record.id);
setAccessList((prev) => prev.filter((entry) => entry.id !== record.id));
@@ -237,20 +251,16 @@ export function CredentialShareModal({
}
}
if (!credential) return null;
return (
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
<button
onClick={onClose}
className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border text-xs text-muted-foreground hover:text-foreground transition-colors w-full text-left"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span className="truncate">
{t("credentials.share.title", { name: credential.name ?? "" })}
</span>
</button>
<InlineView
open={!!credential}
onOpenChange={(open) => {
if (!open) onClose();
}}
icon={<Share2 className="size-4" />}
title={t("credentials.share.title", { name: credential?.name ?? "" })}
bare
>
{loadError && (
<div className="flex items-start gap-2 px-3 py-2 shrink-0 border-b border-destructive/30 bg-destructive/5 text-xs text-destructive">
<Shield className="size-3.5 shrink-0 mt-0.5" />
@@ -264,14 +274,7 @@ export function CredentialShareModal({
<Users className="size-3.5" />
{t("hosts.sharing.shareWithSection")}
</div>
<a
href="https://docs.termix.site/features/authentication/rbac"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline shrink-0"
>
{t("hosts.docsLink")}
</a>
<DocsLink core="sharing">{t("hosts.docsLink")}</DocsLink>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
@@ -378,7 +381,7 @@ export function CredentialShareModal({
</div>
<Shield className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">
{role.displayName || role.name}
{roleLabel(t, role.displayName || role.name)}
</span>
</button>
);
@@ -501,7 +504,7 @@ export function CredentialShareModal({
)}
<span className="font-semibold truncate">
{record.username ??
record.roleDisplayName ??
roleLabel(t, record.roleDisplayName) ??
record.roleName ??
record.userId ??
record.roleId}
@@ -570,6 +573,6 @@ export function CredentialShareModal({
})}
</div>
</div>
</div>
</InlineView>
);
}
+14 -32
View File
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
import {
ArrowUpDown,
Check,
ExternalLink,
Filter,
GripVertical,
Plus,
@@ -13,7 +12,8 @@ import {
X,
} from "lucide-react";
import { toast } from "sonner";
import { HostManager } from "@/sidebar/HostManager";
import { CredentialList } from "@/sidebar/CredentialList";
import { requestManage } from "@/manage/manage-requests";
import { useCredentialSidebarPreferences } from "@/sidebar/credential-tree/hooks/useCredentialSidebarPreferences";
import { useArrangeLock } from "@/sidebar/use-arrange-lock";
import { CustomizeCredentialsSidebarPanel } from "@/sidebar/CustomizeCredentialsSidebarPanel";
@@ -28,10 +28,10 @@ import {
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import type { CredentialSortKey } from "@/types/credential-sidebar-preferences";
import { DocsLink } from "@/components/docs-link";
export function CredentialsPanel({
onEditingChange,
active = true,
active: _active = true,
}: {
onEditingChange?: (editing: boolean) => void;
active?: boolean;
@@ -41,7 +41,6 @@ export function CredentialsPanel({
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
useCredentialSidebarPreferences();
const [search, setSearch] = useState("");
const [managerEditing, setManagerEditing] = useState(false);
const [allTags, setAllTags] = useState<string[]>([]);
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
@@ -94,14 +93,9 @@ export function CredentialsPanel({
}));
}
function handleEditingChange(editing: boolean) {
setManagerEditing(editing);
onEditingChange?.(editing);
}
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{!managerEditing && (
{
<div className="flex flex-col px-2 py-1.5 shrink-0 border-b border-border/60 gap-1.5">
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-sm">
<Search className="size-3 text-muted-foreground/60 shrink-0" />
@@ -275,15 +269,7 @@ export function CredentialsPanel({
</DropdownMenuContent>
</DropdownMenu>
<div className="w-px self-stretch bg-border" />
<a
href="https://docs.termix.site/features/files-and-hosts/credentials"
target="_blank"
rel="noreferrer"
title={t("hosts.docsLink")}
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground shrink-0 transition-colors"
>
<ExternalLink className="size-3.5" />
</a>
<DocsLink core="credentials" variant="icon" />
</div>
<div className="flex items-center border border-border shrink-0">
<button
@@ -297,9 +283,7 @@ export function CredentialsPanel({
<div className="flex items-center border border-accent-brand/30 ml-auto shrink-0">
<button
onClick={() =>
window.dispatchEvent(
new CustomEvent("host-manager:add-credential"),
)
requestManage({ kind: "credential", credentialId: null })
}
title={t("credentials.addCredential")}
className="flex items-center justify-center gap-1 h-7 px-2 text-[10px] font-medium text-accent-brand hover:bg-accent-brand/10 transition-colors"
@@ -312,21 +296,19 @@ export function CredentialsPanel({
</div>
</div>
</div>
)}
}
<div className="flex flex-col flex-1 min-h-0">
<HostManager
hideListHeader
externalSearch={managerEditing ? undefined : search}
externalSort={sortKey}
externalArrangeLocked={arrangeLocked}
externalFilter={filterState}
<CredentialList
search={search}
sort={sortKey}
arrangeLocked={arrangeLocked}
filter={filterState}
density={sidebarPrefs.display.density}
trayTrigger={sidebarPrefs.display.trayTrigger}
showTags={sidebarPrefs.display.showTags}
rowFields={sidebarPrefs.display}
onTagsChange={setAllTags}
onEditingChange={handleEditingChange}
active={active}
/>
</div>
@@ -1,17 +1,13 @@
import { Select2 } from "@/components/select2";
import { InlineView } from "@/components/surface/surface-scope";
import { useTranslation } from "react-i18next";
import { Rows3, SquareStack } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { ListChecks, Rows3, SquareStack } from "lucide-react";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type {
CredentialDensity,
CredentialSidebarPreferences,
CredentialTrayTrigger,
import {
CREDENTIAL_ROW_FIELD_KEYS,
type CredentialDensity,
type CredentialSidebarPreferences,
type CredentialTrayTrigger,
} from "@/types/credential-sidebar-preferences";
export function CustomizeCredentialsSidebarPanel({
@@ -32,108 +28,130 @@ export function CustomizeCredentialsSidebarPanel({
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("credentials.customizeSidebar")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("credentials.customizeSidebarDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<SectionCard
title={t("credentials.customizeDensityTitle")}
icon={<Rows3 className="size-3.5" />}
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("credentials.customizeSidebar")}
>
<p className="text-xs text-muted-foreground">
{t("credentials.customizeSidebarDescription")}
</p>
<div className="flex flex-col gap-4 mt-1">
<SectionCard
title={t("credentials.customizeDensityTitle")}
icon={<Rows3 className="size-3.5" />}
>
<SettingRow
label={t("credentials.customizeDensityTitle")}
description={
preferences.display.density === "comfortable"
? t("credentials.densityComfortableDesc")
: t("credentials.densityCompactDesc")
}
>
<SettingRow
label={t("credentials.customizeDensityTitle")}
description={
preferences.display.density === "comfortable"
? t("credentials.densityComfortableDesc")
: t("credentials.densityCompactDesc")
<Select2
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
density: e.target.value as CredentialDensity,
},
}))
}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<select
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
density: e.target.value as CredentialDensity,
},
}))
}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="comfortable">
{t("credentials.displayDensityComfortable")}
</option>
<option value="compact">
{t("credentials.displayDensityCompact")}
</option>
</select>
</SettingRow>
</SectionCard>
<option value="comfortable">
{t("credentials.displayDensityComfortable")}
</option>
<option value="compact">
{t("credentials.displayDensityCompact")}
</option>
</Select2>
</SettingRow>
</SectionCard>
<SectionCard
title={t("credentials.customizeBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
>
<SectionCard
title={t("credentials.rowDetailsTitle")}
icon={<ListChecks className="size-3.5" />}
>
<p className="pt-2 text-xs text-muted-foreground">
{t("credentials.rowDetailsDesc")}
</p>
{CREDENTIAL_ROW_FIELD_KEYS.map((key) => (
<SettingRow
label={t("credentials.showCredentialTags")}
description={t("credentials.showCredentialTagsDesc")}
key={key}
label={t(`credentials.${key}`)}
description={t(`credentials.${key}Desc`)}
>
<FakeSwitch
checked={preferences.display.showTags}
checked={preferences.display[key]}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showTags: v },
display: { ...prev.display, [key]: v },
}))
}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("credentials.actionsVisibility")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("credentials.actionsVisibilityDesc")}
</span>
</div>
<select
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
trayTrigger: e.target.value as CredentialTrayTrigger,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="always">{t("credentials.actionsAlways")}</option>
<option value="actionsOnly">
{t("credentials.actionsOnly")}
</option>
<option value="hover">{t("credentials.actionsHover")}</option>
<option value="click">{t("credentials.actionsClick")}</option>
</select>
</div>
</SectionCard>
))}
</SectionCard>
<p className="text-[11px] text-muted-foreground/70 leading-snug">
{t("credentials.customizeSortHint")}
</p>
</div>
</DialogContent>
</Dialog>
<SectionCard
title={t("credentials.customizeBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
>
<SettingRow
label={t("credentials.showCredentialTags")}
description={t("credentials.showCredentialTagsDesc")}
>
<FakeSwitch
checked={preferences.display.showTags}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showTags: v },
}))
}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("credentials.actionsVisibility")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("credentials.actionsVisibilityDesc")}
</span>
</div>
<Select2
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
trayTrigger: e.target.value as CredentialTrayTrigger,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="always">{t("credentials.actionsAlways")}</option>
<option value="actionsOnly">
{t("credentials.actionsOnly")}
</option>
<option value="hover">{t("credentials.actionsHover")}</option>
<option value="click">{t("credentials.actionsClick")}</option>
</Select2>
</div>
</SectionCard>
<p className="text-[11px] text-muted-foreground/70 leading-snug">
{t("credentials.customizeSortHint")}
</p>
</div>
</InlineView>
);
}
+260 -184
View File
@@ -1,20 +1,27 @@
import { Select2 } from "@/components/select2";
import { InlineView } from "@/components/surface/surface-scope";
import { useTranslation } from "react-i18next";
import { Rows3, SquareStack } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { ListChecks, PanelTop, Rows3, SquareStack } from "lucide-react";
import { actionOrder, useHostActions } from "./host-contributions";
import { showsInBar } from "./tree/host-bar-actions";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type {
HostClickBehavior,
HostDensity,
HostRowFields,
HostSidebarPreferences,
HostTrayTrigger,
} from "@/types/host-sidebar-preferences";
const ROW_FIELDS: { key: keyof HostRowFields; dependsOnAddress?: boolean }[] = [
{ key: "showAddress" },
{ key: "showUsername", dependsOnAddress: true },
{ key: "showPort", dependsOnAddress: true },
{ key: "showPinIcon" },
{ key: "showSharedBadge" },
{ key: "showBadges" },
];
export function CustomizeSidebarPanel({
open,
onOpenChange,
@@ -31,188 +38,257 @@ export function CustomizeSidebarPanel({
) => void;
}) {
const { t } = useTranslation();
const actions = [...useHostActions()].sort(
(a, b) => actionOrder(a) - actionOrder(b) || a.id.localeCompare(b.id),
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("hosts.customizeSidebar")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("hosts.customizeSidebarDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<SectionCard
title={t("hosts.customizeDensityTitle")}
icon={<Rows3 className="size-3.5" />}
<InlineView
open={open}
onOpenChange={onOpenChange}
title={t("hosts.customizeSidebar")}
>
<p className="text-xs text-muted-foreground">
{t("hosts.customizeSidebarDescription")}
</p>
<div className="flex flex-col gap-4 mt-1">
<SectionCard
title={t("hosts.customizeDensityTitle")}
icon={<Rows3 className="size-3.5" />}
>
<SettingRow
label={t("hosts.customizeDensityTitle")}
description={
preferences.display.density === "comfortable"
? t("hosts.densityComfortableDesc")
: t("hosts.densityCompactDesc")
}
>
<SettingRow
label={t("hosts.customizeDensityTitle")}
description={
preferences.display.density === "comfortable"
? t("hosts.densityComfortableDesc")
: t("hosts.densityCompactDesc")
<Select2
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
density: e.target.value as HostDensity,
},
}))
}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<select
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
density: e.target.value as HostDensity,
},
}))
}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="comfortable">
{t("hosts.displayDensityComfortable")}
</option>
<option value="compact">
{t("hosts.displayDensityCompact")}
</option>
</select>
</SettingRow>
</SectionCard>
<option value="comfortable">
{t("hosts.displayDensityComfortable")}
</option>
<option value="compact">
{t("hosts.displayDensityCompact")}
</option>
</Select2>
</SettingRow>
</SectionCard>
<SectionCard
title={t("hosts.customizeBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
>
<SettingRow
label={t("newUi.sidebar.userProfile.showHostTags")}
description={t("newUi.sidebar.userProfile.showHostTagsDesc")}
>
<FakeSwitch
checked={preferences.display.showTags}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showTags: v },
}))
}
/>
</SettingRow>
<SettingRow
label={t("hosts.showFolderPaths")}
description={t("hosts.showFolderPathsDesc")}
>
<FakeSwitch
checked={preferences.display.showFolderPaths}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showFolderPaths: v },
}))
}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("hosts.actionsVisibility")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("hosts.actionsVisibilityDesc")}
</span>
</div>
<select
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
trayTrigger: e.target.value as HostTrayTrigger,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="always">{t("hosts.actionsAlways")}</option>
<option value="actionsOnly">{t("hosts.actionsOnly")}</option>
<option value="hover">{t("hosts.actionsHover")}</option>
<option value="click">{t("hosts.actionsClick")}</option>
</select>
</div>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("hosts.hostClickBehavior")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("hosts.hostClickBehaviorDesc")}
</span>
</div>
<select
value={preferences.display.hostClickBehavior}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
hostClickBehavior: e.target.value as HostClickBehavior,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="newTab">{t("hosts.hostClickNewTab")}</option>
<option value="focusExisting">
{t("hosts.hostClickFocusExisting")}
</option>
<option value="focusExistingDoubleClickNew">
{t("hosts.hostClickFocusExistingDoubleNew")}
</option>
</select>
</div>
<SettingRow
label={t("hosts.openOnDoubleClick")}
description={t("hosts.openOnDoubleClickDesc")}
>
<FakeSwitch
checked={preferences.display.openOnDoubleClick}
disabled={
preferences.display.hostClickBehavior ===
"focusExistingDoubleClickNew"
}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, openOnDoubleClick: v },
}))
}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.statusColors")}
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
>
<FakeSwitch
checked={preferences.display.statusColorScheme === "status"}
onChange={(v) =>
update((prev) => ({
...prev,
display: {
...prev.display,
statusColorScheme: v ? "status" : "accent",
},
}))
}
/>
</SettingRow>
</SectionCard>
<p className="text-[11px] text-muted-foreground/70 leading-snug">
{t("hosts.customizeSortHint")}
<SectionCard
title={t("hosts.rowDetailsTitle")}
icon={<ListChecks className="size-3.5" />}
>
<p className="pt-2 text-xs text-muted-foreground">
{t("hosts.rowDetailsDesc")}
</p>
</div>
</DialogContent>
</Dialog>
{ROW_FIELDS.map(({ key, dependsOnAddress }) => (
<SettingRow
key={key}
label={t(`hosts.${key}`)}
description={t(`hosts.${key}Desc`)}
>
<FakeSwitch
checked={preferences.display[key]}
disabled={dependsOnAddress && !preferences.display.showAddress}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, [key]: v },
}))
}
/>
</SettingRow>
))}
</SectionCard>
<SectionCard
title={t("hosts.customizeBehaviorTitle")}
icon={<SquareStack className="size-3.5" />}
>
<SettingRow
label={t("newUi.sidebar.userProfile.showHostTags")}
description={t("newUi.sidebar.userProfile.showHostTagsDesc")}
>
<FakeSwitch
checked={preferences.display.showTags}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showTags: v },
}))
}
/>
</SettingRow>
<SettingRow
label={t("hosts.showFolderPaths")}
description={t("hosts.showFolderPathsDesc")}
>
<FakeSwitch
checked={preferences.display.showFolderPaths}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, showFolderPaths: v },
}))
}
/>
</SettingRow>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("hosts.actionsVisibility")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("hosts.actionsVisibilityDesc")}
</span>
</div>
<Select2
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
trayTrigger: e.target.value as HostTrayTrigger,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="always">{t("hosts.actionsAlways")}</option>
<option value="actionsOnly">{t("hosts.actionsOnly")}</option>
<option value="hover">{t("hosts.actionsHover")}</option>
<option value="click">{t("hosts.actionsClick")}</option>
</Select2>
</div>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium leading-snug">
{t("hosts.hostClickBehavior")}
</span>
<span className="text-xs text-muted-foreground leading-snug">
{t("hosts.hostClickBehaviorDesc")}
</span>
</div>
<Select2
value={preferences.display.hostClickBehavior}
onChange={(e) =>
update((prev) => ({
...prev,
display: {
...prev.display,
hostClickBehavior: e.target.value as HostClickBehavior,
},
}))
}
className="h-8 w-full border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="newTab">{t("hosts.hostClickNewTab")}</option>
<option value="focusExisting">
{t("hosts.hostClickFocusExisting")}
</option>
<option value="focusExistingDoubleClickNew">
{t("hosts.hostClickFocusExistingDoubleNew")}
</option>
</Select2>
</div>
<SettingRow
label={t("hosts.openOnDoubleClick")}
description={t("hosts.openOnDoubleClickDesc")}
>
<FakeSwitch
checked={preferences.display.openOnDoubleClick}
disabled={
preferences.display.hostClickBehavior ===
"focusExistingDoubleClickNew"
}
onChange={(v) =>
update((prev) => ({
...prev,
display: { ...prev.display, openOnDoubleClick: v },
}))
}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.statusColors")}
description={t("newUi.sidebar.userProfile.statusColorsDesc")}
>
<FakeSwitch
checked={preferences.display.statusColorScheme === "status"}
onChange={(v) =>
update((prev) => ({
...prev,
display: {
...prev.display,
statusColorScheme: v ? "status" : "accent",
},
}))
}
/>
</SettingRow>
</SectionCard>
{actions.length > 0 && (
<SectionCard
title={t("hosts.connectBarTitle")}
icon={<PanelTop className="size-3.5" />}
>
<p className="pt-2 text-xs text-muted-foreground">
{t("hosts.connectBarDesc")}
</p>
{actions.map((action) => {
const Icon = action.icon;
return (
<SettingRow
key={action.id}
label={t(action.titleKey)}
rowId={`bar-action-${action.id}`}
>
<span className="flex items-center gap-2">
<Icon className="size-3.5 text-muted-foreground" />
<FakeSwitch
checked={showsInBar(
preferences.display.barActions,
action,
)}
onChange={(v) =>
update((prev) => ({
...prev,
display: {
...prev.display,
barActions: {
...prev.display.barActions,
[action.id]: v,
},
},
}))
}
/>
</span>
</SettingRow>
);
})}
</SectionCard>
)}
<p className="text-[11px] text-muted-foreground/70 leading-snug">
{t("hosts.customizeSortHint")}
</p>
</div>
</InlineView>
);
}
+118 -122
View File
@@ -1,13 +1,8 @@
import { Select2 } from "@/components/select2";
import { InlineView } from "@/components/surface/surface-scope";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import {
@@ -124,125 +119,126 @@ export function FolderMetadataDialog({
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{mode === "create"
? t("hosts.createFolderTitle")
: t("hosts.editFolderTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("hosts.folderDialogDescription")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderNameLabel")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder={t("hosts.folderNamePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !isDuplicate) handleSubmit();
}}
aria-invalid={isDuplicate}
className={isDuplicate ? "border-destructive" : undefined}
/>
{isDuplicate ? (
<p className="text-[10px] text-destructive">
{t("hosts.folderNameDuplicate")}
</p>
) : (
<p className="text-[10px] text-muted-foreground">
{t("hosts.folderNestingHint")}
</p>
)}
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold">
{t("hosts.folderColor")}
</label>
<HexColorPicker value={color} onChange={setColor} />
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold">
{t("hosts.folderIcon")}
</label>
<IconPicker value={icon} color={color} onChange={setIcon} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderCredential")}
</label>
<select
value={credentialId}
onChange={(e) => setCredentialId(e.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("hosts.folderCredentialNone")}</option>
{credentials.map((c) => (
<option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name}
</option>
))}
</select>
<InlineView
open={open}
onOpenChange={onOpenChange}
title={
<>
{mode === "create"
? t("hosts.createFolderTitle")
: t("hosts.editFolderTitle")}
</>
}
>
<p className="text-xs text-muted-foreground">
{t("hosts.folderDialogDescription")}
</p>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderNameLabel")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
placeholder={t("hosts.folderNamePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !isDuplicate) handleSubmit();
}}
aria-invalid={isDuplicate}
className={isDuplicate ? "border-destructive" : undefined}
/>
{isDuplicate ? (
<p className="text-[10px] text-destructive">
{t("hosts.folderNameDuplicate")}
</p>
) : (
<p className="text-[10px] text-muted-foreground">
{t("hosts.folderCredentialHint")}
{t("hosts.folderNestingHint")}
</p>
</div>
{syncLinked && (
<SettingRow
label={t("hosts.folderLocalOnly")}
description={t("hosts.folderLocalOnlyDesc")}
>
<FakeSwitch checked={localOnly} onChange={setLocalOnly} />
</SettingRow>
)}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderPreview")}
</label>
<div className="flex items-center gap-2 px-3 py-3 border border-border bg-muted/20">
<FolderIconEl
icon={icon}
className="size-4 shrink-0"
style={{ color }}
/>
<span className="flex items-center gap-0.5 text-sm font-semibold flex-wrap">
{name
? splitPath(name).map((seg, i) => (
<span key={i} className="flex items-center gap-0.5">
{i > 0 && (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/50" />
)}
{seg}
</span>
))
: t("hosts.folderNameFallback")}
</span>
</div>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold">
{t("hosts.folderColor")}
</label>
<HexColorPicker value={color} onChange={setColor} />
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-semibold">
{t("hosts.folderIcon")}
</label>
<IconPicker value={icon} color={color} onChange={setIcon} />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderCredential")}
</label>
<Select2
value={credentialId}
onChange={(e) => setCredentialId(e.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">{t("hosts.folderCredentialNone")}</option>
{credentials.map((c) => (
<option key={c.id} value={c.id}>
{c.username ? `${c.name} (${c.username})` : c.name}
</option>
))}
</Select2>
<p className="text-[10px] text-muted-foreground">
{t("hosts.folderCredentialHint")}
</p>
</div>
{syncLinked && (
<SettingRow
label={t("hosts.folderLocalOnly")}
description={t("hosts.folderLocalOnlyDesc")}
>
<FakeSwitch checked={localOnly} onChange={setLocalOnly} />
</SettingRow>
)}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("hosts.folderPreview")}
</label>
<div className="flex items-center gap-2 px-3 py-3 border border-border bg-muted/20">
<FolderIconEl
icon={icon}
className="size-4 shrink-0"
style={{ color }}
/>
<span className="flex items-center gap-0.5 text-sm font-semibold flex-wrap">
{name
? splitPath(name).map((seg, i) => (
<span key={i} className="flex items-center gap-0.5">
{i > 0 && (
<ChevronRight className="size-3.5 shrink-0 text-muted-foreground/50" />
)}
{seg}
</span>
))
: t("hosts.folderNameFallback")}
</span>
</div>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("hosts.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSubmit}
disabled={!name.trim() || isDuplicate}
>
{mode === "create"
? t("hosts.createFolderButton")
: t("hosts.saveFolderButton")}
</Button>
</div>
</DialogContent>
</Dialog>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
{t("hosts.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSubmit}
disabled={!name.trim() || isDuplicate}
>
{mode === "create"
? t("hosts.createFolderButton")
: t("hosts.saveFolderButton")}
</Button>
</div>
</InlineView>
);
}
+80 -98
View File
@@ -1,15 +1,9 @@
import { Select2 } from "@/components/select2";
import { FormFooter } from "@/components/list-kit";
import { InlineView } from "@/components/surface/surface-scope";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import {
getCredentials,
getHostAuthOverride,
@@ -123,98 +117,86 @@ export function HostAuthOverrideModal({
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("hosts.sharing.authOverrideTitleProtocol", {
protocol: authProtocolLabel(protocol, t),
})}
</DialogTitle>
<DialogDescription>
{t(
ownerAuthShared
? "hosts.sharing.authOverrideDescriptionShared"
: "hosts.sharing.authOverrideDescriptionPrivate",
{ host: host.name },
)}
</DialogDescription>
</DialogHeader>
{loading ? (
<p className="py-4 text-center text-muted-foreground">
{t("common.loading")}
</p>
) : loadError ? (
<p className="border border-destructive/30 bg-destructive/5 p-3 text-destructive">
{t("hosts.sharing.authOverrideLoadError")}
</p>
) : (
<div className="flex flex-col gap-2">
<label
htmlFor={`auth-override-${host.id}`}
className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"
>
{t("hosts.sharing.authOverrideCredentialLabel")}
</label>
<select
id={`auth-override-${host.id}`}
value={selectedId}
onChange={(event) => setSelectedId(event.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">
{t(
ownerAuthShared
? "hosts.sharing.useSharedAuthentication"
: "hosts.sharing.noPersonalCredential",
)}
</option>
{credentials.map((credential) => (
<option key={credential.id} value={credential.id}>
{credential.username
? `${credential.name} (${credential.username})`
: credential.name}
</option>
))}
</select>
{credentials.length === 0 && (
<p className="text-[10px] text-muted-foreground">
{t("hosts.sharing.authOverrideNoCredentials")}
</p>
)}
{overrideState?.required && selectedId === "" && (
<p className="border border-amber-500/30 bg-amber-500/5 p-2 text-[10px] text-amber-600 dark:text-amber-400">
{t("hosts.sharing.authOverrideRequired")}
</p>
)}
<p className="text-[10px] text-muted-foreground">
{t("hosts.sharing.authOverridePrivateHint")}
</p>
</div>
<InlineView
open={open}
onOpenChange={onOpenChange}
title={
<>
{t("hosts.sharing.authOverrideTitleProtocol", {
protocol: authProtocolLabel(protocol, t),
})}
</>
}
footer={
<FormFooter
onCancel={() => onOpenChange(false)}
onSave={() => void handleSave()}
saving={saving}
disabled={loading || loadError || selectedId === initialId}
/>
}
>
<p className="text-xs text-muted-foreground">
{t(
ownerAuthShared
? "hosts.sharing.authOverrideDescriptionShared"
: "hosts.sharing.authOverrideDescriptionPrivate",
{ host: host.name },
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
</p>
{loading ? (
<p className="py-4 text-center text-muted-foreground">
{t("common.loading")}
</p>
) : loadError ? (
<p className="border border-destructive/30 bg-destructive/5 p-3 text-destructive">
{t("hosts.sharing.authOverrideLoadError")}
</p>
) : (
<div className="flex flex-col gap-2">
<label
htmlFor={`auth-override-${host.id}`}
className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSave}
disabled={
loading || loadError || saving || selectedId === initialId
}
{t("hosts.sharing.authOverrideCredentialLabel")}
</label>
<Select2
id={`auth-override-${host.id}`}
value={selectedId}
onChange={(event) => setSelectedId(event.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{saving ? t("common.saving") : t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<option value="">
{t(
ownerAuthShared
? "hosts.sharing.useSharedAuthentication"
: "hosts.sharing.noPersonalCredential",
)}
</option>
{credentials.map((credential) => (
<option key={credential.id} value={credential.id}>
{credential.username
? `${credential.name} (${credential.username})`
: credential.name}
</option>
))}
</Select2>
{credentials.length === 0 && (
<p className="text-[10px] text-muted-foreground">
{t("hosts.sharing.authOverrideNoCredentials")}
</p>
)}
{overrideState?.required && selectedId === "" && (
<p className="border border-warning/30 bg-warning/5 p-2 text-[10px] text-warning">
{t("hosts.sharing.authOverrideRequired")}
</p>
)}
<p className="text-[10px] text-muted-foreground">
{t("hosts.sharing.authOverridePrivateHint")}
</p>
</div>
)}
</InlineView>
</span>
);
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -5,7 +5,7 @@ import {
type HostDraft,
type HostProtocolAuthForm,
type HostProtocolAuthSummary,
} from "@termix/plugin-sdk/frontend";
} from "@termix-ssh/plugin-sdk/frontend";
import type { DefaultOverrides } from "@/types/host-defaults";
import {
listHostProtocols,
+14 -11
View File
@@ -14,6 +14,7 @@ import { getSSHFolders, isElectron } from "@/main-axios";
import { connectionOriginAppliesTo } from "./HostEditorData";
import { useSyncStatus } from "@/hooks/use-sync-status";
import type { HostEditorForm, HostProtocols } from "./HostEditorData";
import type { HostFormErrors } from "@/manage/host-validation";
import { Select2 } from "@/components/select2";
import { useHostProtocols } from "./host-protocols";
import {
@@ -21,6 +22,7 @@ import {
HostDefaultBadge,
HostOnly,
} from "@/lib/host-defaults-context";
import { DocsLink } from "@/components/docs-link";
type HostEditorSetField = <K extends keyof HostEditorForm>(
key: K,
@@ -35,6 +37,7 @@ export function HostEditorGeneralTab({
hosts,
host,
simpleMode = false,
errors = {},
}: {
form: HostEditorForm;
setField: HostEditorSetField;
@@ -44,6 +47,7 @@ export function HostEditorGeneralTab({
host: Host | null;
/** Hides organizational/advanced fields; their values still save unchanged. */
simpleMode?: boolean;
errors?: HostFormErrors;
}) {
const { t } = useTranslation();
const syncLinked = !!useSyncStatus()?.linked;
@@ -142,7 +146,7 @@ export function HostEditorGeneralTab({
return (
<>
<HostOnly>
{/* Protocols — enable/disable each connection type */}
{/* Protocols, enable/disable each connection type */}
<SectionCard
title={t("hosts.protocols")}
icon={<Globe className="size-3.5" />}
@@ -210,8 +214,14 @@ export function HostEditorGeneralTab({
<Input
placeholder={t("placeholders.hostAddress")}
value={form.ip}
aria-invalid={!!errors.ip}
onChange={(e) => setField("ip", e.target.value)}
/>
{errors.ip && (
<span className="text-[10px] text-destructive">
{t(errors.ip)}
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -393,14 +403,7 @@ export function HostEditorGeneralTab({
{t("hosts.portKnockingSequence")}
</span>
<HostDefaultBadge settingKey="core.portKnockSequence" />
<a
href="https://docs.termix.site/features/networking/port-knocking"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
<DocsLink core="portKnocking">{t("hosts.docsLink")}</DocsLink>
</div>
<Button
variant="outline"
@@ -800,7 +803,7 @@ export function HostEditorGeneralTab({
),
].join(" ")}
>
<select
<Select2
className="flex h-7 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
value={form.connectionOrigin ?? ""}
onChange={(e) =>
@@ -817,7 +820,7 @@ export function HostEditorGeneralTab({
<option value="remote">
{t("hosts.connectionOriginRemote")}
</option>
</select>
</Select2>
</SettingRow>
</HostOnly>
)}
+123 -135
View File
@@ -1,19 +1,14 @@
import { InlineView } from "@/components/surface/surface-scope";
import { FormFooter } from "@/components/list-kit";
import { getErrorMessage } from "../lib/error-message.js";
import { usePluginStore } from "@/plugin-host/plugin-store";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Download, Search } from "lucide-react";
import { Search } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Checkbox } from "@/components/checkbox";
import { Input } from "@/components/input";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { exportAllSSHHosts, type SSHHostWithStatus } from "@/main-axios";
import { isFolder } from "@/sidebar/SidebarTree";
import {
@@ -220,140 +215,133 @@ export function HostExportDialog({
const canExport = !loading && count > 0;
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="flex flex-col w-[95vw] sm:max-w-6xl max-h-[85dvh] overflow-hidden">
<DialogHeader>
<DialogTitle>{t("hosts.export.title")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col md:flex-row gap-4 flex-1 min-h-0 min-w-0 overflow-y-auto md:overflow-visible">
{/* Scope + host picker */}
<div className="flex flex-col gap-2 md:w-56 shrink-0 min-h-0">
<div className="text-xs text-muted-foreground">
{t("hosts.export.scope")}
</div>
<div className="flex gap-1">
<Button
size="sm"
variant={scope === "all" ? "default" : "outline"}
onClick={() => setScope("all")}
>
{t("hosts.export.scopeAll")}
</Button>
<Button
size="sm"
variant={scope === "selected" ? "default" : "outline"}
onClick={() => setScope("selected")}
>
{t("hosts.export.scopeSelected")}
</Button>
</div>
<div className="relative">
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-7 h-8 text-xs"
placeholder={t("hosts.export.searchHosts")}
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={scope === "all"}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto max-h-64 flex flex-col gap-1 pr-1">
{visibleHosts.map((host) => {
const key = hostKey(host as unknown as Record<string, unknown>);
return (
<label
key={String(host.id)}
className="flex items-center gap-2 text-xs cursor-pointer"
>
<Checkbox
checked={scope === "all" || selectedKeys.has(key)}
disabled={scope === "all"}
onCheckedChange={() => toggleHost(key)}
/>
<span className="truncate">{host.name}</span>
</label>
);
})}
</div>
<InlineView
open={open}
onOpenChange={(next) => !next && onClose()}
title={t("hosts.export.title")}
width="wide"
footer={
<FormFooter
onCancel={onClose}
cancelLabel={t("hosts.export.cancel")}
onSave={() => void handleExport()}
saveLabel={t("hosts.export.confirm")}
disabled={!canExport}
/>
}
>
<div className="flex flex-col md:flex-row gap-4 flex-1 min-h-0 min-w-0 overflow-y-auto md:overflow-visible">
{/* Scope + host picker */}
<div className="flex flex-col gap-2 md:w-56 shrink-0 min-h-0">
<div className="text-xs text-muted-foreground">
{t("hosts.export.scope")}
</div>
<div className="flex gap-1">
<Button
size="sm"
variant={scope === "all" ? "default" : "outline"}
onClick={() => setScope("all")}
>
{t("hosts.export.scopeAll")}
</Button>
<Button
size="sm"
variant={scope === "selected" ? "default" : "outline"}
onClick={() => setScope("selected")}
>
{t("hosts.export.scopeSelected")}
</Button>
</div>
<div className="relative">
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-7 h-8 text-xs"
placeholder={t("hosts.export.searchHosts")}
value={search}
onChange={(e) => setSearch(e.target.value)}
disabled={scope === "all"}
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto max-h-64 flex flex-col gap-1 pr-1">
{visibleHosts.map((host) => {
const key = hostKey(host as unknown as Record<string, unknown>);
return (
<label
key={String(host.id)}
className="flex items-center gap-2 text-xs cursor-pointer"
>
<Checkbox
checked={scope === "all" || selectedKeys.has(key)}
disabled={scope === "all"}
onCheckedChange={() => toggleHost(key)}
/>
<span className="truncate">{host.name}</span>
</label>
);
})}
</div>
</div>
{/* Field groups */}
<div className="flex flex-col gap-2 md:w-48 shrink-0 md:overflow-y-auto">
<div className="text-xs text-muted-foreground">
{t("hosts.export.include")}
</div>
<label className="flex items-center gap-2 text-xs opacity-60">
<Checkbox checked disabled />
<span>{t("hosts.export.groupConnection")}</span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer">
{/* Field groups */}
<div className="flex flex-col gap-2 md:w-48 shrink-0 md:overflow-y-auto">
<div className="text-xs text-muted-foreground">
{t("hosts.export.include")}
</div>
<label className="flex items-center gap-2 text-xs opacity-60">
<Checkbox checked disabled />
<span>{t("hosts.export.groupConnection")}</span>
</label>
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox
checked={withCredentials}
onCheckedChange={(v) => setWithCredentials(v === true)}
/>
<span>{t("hosts.export.groupCredentials")}</span>
</label>
{GROUPS.filter((g) => g.key !== "connection").map((group) => (
<label
key={group.key}
className="flex items-center gap-2 text-xs cursor-pointer"
>
<Checkbox
checked={withCredentials}
onCheckedChange={(v) => setWithCredentials(v === true)}
checked={groups.has(group.key)}
onCheckedChange={() => toggleGroup(group.key)}
/>
<span>{t("hosts.export.groupCredentials")}</span>
<span>{t(`hosts.export.${group.label}`)}</span>
</label>
{GROUPS.filter((g) => g.key !== "connection").map((group) => (
<label
key={group.key}
className="flex items-center gap-2 text-xs cursor-pointer"
>
<Checkbox
checked={groups.has(group.key)}
onCheckedChange={() => toggleGroup(group.key)}
/>
<span>{t(`hosts.export.${group.label}`)}</span>
</label>
))}
</div>
{/* Preview */}
<div className="flex flex-col gap-2 flex-1 min-w-0 min-h-0">
<div className="text-xs text-muted-foreground">
{t("hosts.export.preview")}
</div>
<pre className="flex-1 w-full min-w-0 overflow-auto max-h-64 md:max-h-none rounded-sm bg-muted border border-border p-2 text-[11px] leading-relaxed whitespace-pre">
{preview}
</pre>
</div>
))}
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{count === 0 ? (
t("hosts.export.noneSelected")
) : (
<span className="flex flex-wrap gap-x-3">
<span>
{t("hosts.export.summary", {
selected: count,
total: exportableHosts.length,
})}
</span>
<span>
{withCredentials
? t("hosts.export.credentialsIncluded")
: t("hosts.export.credentialsExcluded")}
</span>
{/* Preview */}
<div className="flex flex-col gap-2 flex-1 min-w-0 min-h-0">
<div className="text-xs text-muted-foreground">
{t("hosts.export.preview")}
</div>
<pre className="flex-1 w-full min-w-0 overflow-auto max-h-64 md:max-h-none rounded-sm bg-muted border border-border p-2 text-[11px] leading-relaxed whitespace-pre">
{preview}
</pre>
</div>
</div>
<div className="shrink-0 text-xs text-muted-foreground">
{count === 0 ? (
t("hosts.export.noneSelected")
) : (
<span className="flex flex-wrap gap-x-3">
<span>
{t("hosts.export.summary", {
selected: count,
total: exportableHosts.length,
})}
</span>
)}
</div>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={onClose}>
{t("hosts.export.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleExport}
disabled={!canExport}
>
<Download className="size-3.5 mr-2" />
{t("hosts.export.confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<span>
{withCredentials
? t("hosts.export.credentialsIncluded")
: t("hosts.export.credentialsExcluded")}
</span>
</span>
)}
</div>
</InlineView>
);
}
-928
View File
@@ -1,928 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
enabledHostProtocols,
hostProtocolFlags,
type HostProtocols,
} from "./host-protocols";
import React, {
useState,
useEffect,
useRef,
type MutableRefObject,
} from "react";
import { useTranslation } from "react-i18next";
import type { HostDefaultsLevel } from "@/types/host-defaults";
import { getFolderDefaultsId } from "@/api/host-defaults-api";
import {
HostDefaultsEditorView,
type HostDefaultsEditorTarget,
} from "./host-defaults/HostDefaultsEditorView";
import type { HostDraft } from "@termix/plugin-sdk/frontend";
import { CredentialShareModal } from "./CredentialShareModal";
import { Button } from "@/components/button";
import { ArrowLeft, ChevronDown, Search, X } from "lucide-react";
import { useUiPreference } from "@/contexts/UiPreferencesContext";
import { toast } from "sonner";
import {
getSSHHosts,
getCredentials,
getCredentialDetails,
deleteCredential,
duplicateCredential,
updateCredential,
deployCredentialToHost,
renameCredentialFolder,
} from "@/main-axios";
import type { Host, Credential } from "@/types/ui-types";
import type { SSHHostWithStatus } from "@/main-axios";
import type {
CredentialSidebarFilterState,
CredentialSortKey,
} from "@/types/credential-sidebar-preferences";
import { CredentialEditorView } from "./CredentialEditorView";
import { HostEditor } from "./HostEditor";
import { mapCredentials, sshHostToHost } from "./HostManagerData";
import { CredentialSidebarTree } from "./credential-tree";
import { sortCredentials, credentialPassesFilters } from "./credential-sort";
import {
makeCredentialTabs,
makeHostTabs,
makeHostSshSubTabs,
isSshGroupTab,
useHostEditorSections,
TabStrip,
} from "./HostManagerTabs";
import { Select2 } from "@/components/select2";
export function HostManager({
pendingEditId,
pendingAction,
onEditingChange,
hideListHeader,
externalSearch,
externalSort,
externalArrangeLocked = true,
externalFilter,
density = "comfortable",
trayTrigger = "hover",
showTags = true,
onTagsChange,
active = true,
}: {
pendingEditId?: MutableRefObject<string | null>;
pendingAction?: MutableRefObject<"add-host" | "add-credential" | null>;
onEditingChange?: (editing: boolean) => void;
hideListHeader?: boolean;
externalSearch?: string;
externalSort?: CredentialSortKey;
externalArrangeLocked?: boolean;
externalFilter?: CredentialSidebarFilterState;
density?: "comfortable" | "compact";
trayTrigger?: "always" | "hover" | "click" | "actionsOnly";
showTags?: boolean;
onTagsChange?: (tags: string[]) => void;
active?: boolean;
} = {}) {
// Re-render when a plugin adds or removes a host editor tab.
useHostEditorSections();
const { t } = useTranslation();
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
// A level of host defaults open in the editor instead of a host.
const [editingDefaults, setEditingDefaults] =
useState<HostDefaultsEditorTarget | null>(null);
// Fields a plugin filled in for a new host, keyed so a second draft remounts
// the editor.
const [hostDraft, setHostDraft] = useState<{
key: number;
draft: HostDraft;
} | null>(null);
const [shareCredential, setShareCredential] = useState<Credential | null>(
null,
);
const [editingCredential, setEditingCredential] = useState<
Credential | "new" | null
>(null);
const [activeHostTab, setActiveHostTab] = useState("general");
const [activeCredentialTab, setActiveCredentialTab] = useState("general");
const [searchQuery, setSearchQuery] = useState("");
const effectiveSearch = externalSearch ?? searchQuery;
const [hosts, setHosts] = useState<Host[]>([]);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [credentialsLoading, setCredentialsLoading] = useState(true);
const [deployDialog, setDeployDialog] = useState<{
cred: Credential;
hostId: string;
} | null>(null);
const [deploying, setDeploying] = useState(false);
const [confirmDialog, setConfirmDialog] = useState<{
message: string;
onConfirm: () => void;
} | null>(null);
const [hostEditorDirty, setHostEditorDirty] = useState(false);
const [showUnsavedHostDialog, setShowUnsavedHostDialog] = useState(false);
const [editingProtocols, setEditingProtocols] = useState<HostProtocols>(() =>
hostProtocolFlags(null),
);
const simpleEditor = useUiPreference("hostEditor", "mode") === "simple";
// Expanding advanced is a per-session choice; fixing one host's SSH options
// shouldn't quietly move the whole app off the Simple preset.
const [showAdvancedEditor, setShowAdvancedEditor] = useState(false);
const hostsRef = useRef<Host[]>([]);
useEffect(() => {
hostsRef.current = hosts;
}, [hosts]);
const [editingCredFolderName, setEditingCredFolderName] = useState<
string | null
>(null);
const [editingCredFolderValue, setEditingCredFolderValue] = useState("");
// Remembers the host being edited when its credential is opened for
// editing, so "back" can return to that host instead of the list.
const [credentialReturnHost, setCredentialReturnHost] = useState<
Host | "new" | null
>(null);
useEffect(() => {
onTagsChange?.([...new Set(credentials.flatMap((c) => c.tags ?? []))]);
}, [credentials]);
const applyPendingEdit = (hostList: Host[]) => {
if (pendingEditId?.current) {
const id = pendingEditId.current;
pendingEditId.current = null;
const host = hostList.find((h) => h.id === id);
if (host) {
setEditingHost(host);
setEditingCredential(null);
setActiveHostTab("general");
setEditingProtocols(hostProtocolFlags(host));
return true;
}
}
return false;
};
const reloadHosts = () => {
getSSHHosts()
.then((raw) => {
const converted = raw.map(sshHostToHost);
setHosts(converted);
applyPendingEdit(converted);
})
.catch(() => {});
};
const reloadCredentials = () => {
getCredentials()
.then((res) => setCredentials(mapCredentials(res)))
.catch(() => {})
.finally(() => setCredentialsLoading(false));
};
useEffect(() => {
reloadHosts();
reloadCredentials();
window.addEventListener("termix:hosts-changed", reloadHosts);
window.addEventListener("ssh-hosts:changed", reloadHosts);
window.addEventListener("hosts:refresh", reloadHosts);
window.addEventListener("termix:credentials-changed", reloadCredentials);
return () => {
window.removeEventListener("termix:hosts-changed", reloadHosts);
window.removeEventListener("ssh-hosts:changed", reloadHosts);
window.removeEventListener("hosts:refresh", reloadHosts);
window.removeEventListener(
"termix:credentials-changed",
reloadCredentials,
);
};
}, []);
useEffect(() => {
if (pendingAction?.current) {
const action = pendingAction.current;
pendingAction.current = null;
if (action === "add-host") {
setEditingHost("new");
setHostDraft(null);
setEditingCredential(null);
setEditingProtocols(hostProtocolFlags(null));
setActiveHostTab("general");
} else if (action === "add-credential") {
setEditingCredential("new");
setEditingHost(null);
setActiveCredentialTab("general");
}
}
}, [pendingEditId, pendingAction]);
useEffect(() => {
if (!active) return;
const handleAddHost = (e: Event) => {
const draft = (e as CustomEvent<HostDraft | undefined>).detail;
setEditingHost("new");
setHostDraft(draft ? { key: Date.now(), draft } : null);
setEditingCredential(null);
setEditingProtocols(hostProtocolFlags(null));
setActiveHostTab("general");
};
const handleAddCredential = () => {
setEditingCredential("new");
setEditingHost(null);
setActiveCredentialTab("general");
};
const handleEditDefaults = (e: Event) => {
const detail = (
e as CustomEvent<{ level: HostDefaultsLevel; folderName?: string }>
).detail;
if (!detail) return;
const open = (target: HostDefaultsEditorTarget) => {
setEditingHost(null);
setEditingCredential(null);
setEditingDefaults(target);
};
if (detail.level === "folder" && detail.folderName) {
const folderName = detail.folderName;
getFolderDefaultsId(folderName)
.then((folderId) => open({ level: "folder", folderId, folderName }))
.catch(() => toast.error(t("hostDefaults.loadFailed")));
} else if (detail.level !== "folder") {
open({ level: detail.level });
}
};
const handleEditHost = (e: Event) => {
const id = (e as CustomEvent<string>).detail;
const host = hostsRef.current.find((h) => h.id === id);
if (host) {
setEditingHost(host);
setEditingCredential(null);
setActiveHostTab("general");
setEditingProtocols(hostProtocolFlags(host));
}
};
window.addEventListener("host-manager:add-host", handleAddHost);
window.addEventListener("host-manager:add-credential", handleAddCredential);
window.addEventListener("host-manager:edit-host", handleEditHost);
window.addEventListener("host-manager:edit-defaults", handleEditDefaults);
return () => {
window.removeEventListener(
"host-manager:edit-defaults",
handleEditDefaults,
);
window.removeEventListener("host-manager:add-host", handleAddHost);
window.removeEventListener(
"host-manager:add-credential",
handleAddCredential,
);
window.removeEventListener("host-manager:edit-host", handleEditHost);
};
}, [active]);
const allHosts = hosts;
const searchedCredentials = credentials.filter(
(c) =>
c.name.toLowerCase().includes(effectiveSearch.toLowerCase()) ||
c.username.toLowerCase().includes(effectiveSearch.toLowerCase()),
);
const filteredCredentials = sortCredentials(
externalFilter
? searchedCredentials.filter((c) =>
credentialPassesFilters(c, externalFilter),
)
: searchedCredentials,
externalSort ?? "default",
);
const credentialFolderNames = Array.from(
new Set(filteredCredentials.map((c) => c.folder || "Uncategorized")),
).sort();
const credentialFolderTree = credentialFolderNames.map((name) => ({
name,
children: filteredCredentials.filter(
(c) => (c.folder || "Uncategorized") === name,
),
}));
const usedByCounts = new Map<string, number>();
for (const cred of filteredCredentials) {
usedByCounts.set(
cred.id,
allHosts.filter((h) => h.credentialId === cred.id).length,
);
}
const handleRenameCredentialFolder = async (
folder: string,
newName: string,
) => {
try {
await renameCredentialFolder(folder, newName);
const res = await getCredentials();
setCredentials(mapCredentials(res));
toast.success(t("credentials.folderRenamedTo", { name: newName }));
} catch {
toast.error(t("credentials.failedToRenameFolder"));
}
};
const handleMoveCredentialToFolder = async (
credentialId: string,
targetFolder: string,
) => {
const cred = credentials.find((c) => c.id === credentialId);
if (!cred || (cred.folder || "Uncategorized") === targetFolder) return;
const folderValue = targetFolder === "Uncategorized" ? "" : targetFolder;
try {
await updateCredential(Number(credentialId), { folder: folderValue });
setCredentials((prev) =>
prev.map((c) =>
c.id === credentialId ? { ...c, folder: folderValue } : c,
),
);
} catch {
toast.error(t("credentials.failedToMoveCredential"));
}
};
const handleCloneCredential = async (cred: Credential) => {
try {
await duplicateCredential(Number(cred.id), {
name: t("credentials.clonedCredentialName", { name: cred.name }),
});
const res = await getCredentials();
setCredentials(mapCredentials(res));
window.dispatchEvent(new CustomEvent("termix:credentials-changed"));
toast.success(t("credentials.clonedCredential", { name: cred.name }));
} catch (err) {
const msg = err instanceof Error ? err.message : null;
toast.error(msg || t("credentials.failedToCloneCredential"));
}
};
const handleDeleteCredential = async (cred: Credential) => {
await deleteCredential(Number(cred.id));
setCredentials((prev) => prev.filter((c) => c.id !== cred.id));
};
function handleConfirmDeleteCredential(cred: Credential) {
setConfirmDialog({
message: t("credentials.deleteCredentialConfirm", { name: cred.name }),
onConfirm: async () => {
try {
await handleDeleteCredential(cred);
toast.success(
t("credentials.deletedCredential", { name: cred.name }),
);
} catch {
toast.error(t("credentials.failedToDeleteCredential"));
}
},
});
}
async function handleEditCredential(cred: Credential) {
try {
const full = (await getCredentialDetails(Number(cred.id))) as {
hasKey?: boolean;
hasKeyPassword?: boolean;
password?: string;
certPublicKey?: string;
};
setEditingCredential({
...cred,
value: full.hasKey ? "existing_key" : (full.password ?? ""),
password: full.password ?? "",
passphrase: full.hasKeyPassword ? "existing_key_password" : "",
publicKey: full.certPublicKey ?? cred.publicKey,
});
setActiveCredentialTab("general");
} catch {
setEditingCredential(cred);
setActiveCredentialTab("general");
}
}
// Opens a host's credential for editing from within the host editor,
// remembering the host so "back" returns to it instead of the list.
async function handleEditCredentialFromHost(credentialId: string) {
const cred = credentials.find((c) => String(c.id) === String(credentialId));
if (!cred) return;
setCredentialReturnHost(editingHost);
await handleEditCredential(cred);
// The editor view keys off editingHost, so it has to be cleared or the
// host editor keeps rendering over the credential we just opened.
setEditingHost(null);
}
// Editor view: full-width with top tab bar instead of side nav
const closeHostEditor = () => {
setHostEditorDirty(false);
setShowUnsavedHostDialog(false);
setEditingHost(null);
setEditingDefaults(null);
setActiveHostTab("general");
};
const requestCloseHostEditor = () => {
if (hostEditorDirty) {
setShowUnsavedHostDialog(true);
return;
}
closeHostEditor();
};
const renderEditorView = () => {
if (editingDefaults) {
return (
<HostDefaultsEditorView
target={editingDefaults}
onClose={requestCloseHostEditor}
onDirtyChange={setHostEditorDirty}
hosts={hosts}
credentials={credentials}
/>
);
}
const isHost = !!editingHost;
// Simple mode keeps General and SSH -- between them they hold everything
// needed to reach a host (name, address, username, auth) -- and hides the
// other protocol tabs plus the seven SSH sub-tabs. Nothing is lost:
// createHostEditorForm still materializes every field with its default and
// buildHostEditorPayload still serializes all of them, so hiding a tab
// hides its inputs, not its values.
const collapseAdvanced = isHost && simpleEditor && !showAdvancedEditor;
const protocols = editingProtocols as unknown as Record<string, boolean>;
// Plugin tabs decide their own visibility from the host's protocols.
const tabs = isHost
? makeHostTabs(t, protocols).filter((tab) => {
if (tab.id === "general") return true;
if (tab.id === "ssh") return editingProtocols.enableSsh;
return !collapseAdvanced;
})
: makeCredentialTabs(t);
// Collapsing while on a now-hidden tab would leave nothing selected. The
// SSH group collapses to its own "ssh" tab, everything else to General.
// The top-level strip only lists general, ssh and protocol tabs -- the SSH
// sub-tabs live in the secondary strip, so they count as visible whenever
// the SSH group is expanded.
const sshSubTabs = makeHostSshSubTabs(t, protocols);
// Registered SSH-group tabs count too: checking a fixed list here is what
// left plugin tabs unreachable.
const hostTabVisible =
tabs.some((tab) => tab.id === activeHostTab) ||
(!collapseAdvanced &&
editingProtocols.enableSsh &&
sshSubTabs.some((tab) => tab.id === activeHostTab));
const effectiveHostTab = hostTabVisible
? activeHostTab
: collapseAdvanced && isSshGroupTab(activeHostTab)
? "ssh"
: "general";
const activeTab = isHost ? effectiveHostTab : activeCredentialTab;
const setActiveTab = isHost ? setActiveHostTab : setActiveCredentialTab;
const showSshSubTabs =
isHost &&
!collapseAdvanced &&
editingProtocols.enableSsh &&
isSshGroupTab(activeHostTab);
return (
<div className="flex flex-col flex-1 min-h-0">
{/* Back bar + tab strip */}
<div className="flex flex-col shrink-0 border-b border-border">
<button
onClick={() => {
if (isHost) {
requestCloseHostEditor();
} else if (credentialReturnHost) {
setEditingHost(credentialReturnHost);
setCredentialReturnHost(null);
setEditingCredential(null);
setActiveCredentialTab("general");
} else {
setEditingCredential(null);
setActiveCredentialTab("general");
}
}}
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span>
{isHost
? t("hosts.backToHosts")
: credentialReturnHost
? t("hosts.backToHost")
: t("credentials.backToCredentials")}
</span>
{isHost && editingHost !== "new" && (
<span
className="ml-auto font-semibold text-foreground truncate max-w-[200px]"
title={(editingHost as Host).name}
>
{(editingHost as Host).name}
</span>
)}
</button>
<TabStrip
tabs={tabs}
activeTab={activeTab}
onTabChange={(id) => {
if (isHost && id === "ssh") {
if (!isSshGroupTab(activeHostTab)) {
setActiveHostTab("ssh");
}
} else {
setActiveTab(id);
}
}}
isActive={
isHost
? (id) =>
id === "ssh"
? isSshGroupTab(activeHostTab)
: activeHostTab === id
: undefined
}
/>
{showSshSubTabs && (
<TabStrip
tabs={sshSubTabs}
activeTab={activeHostTab}
onTabChange={setActiveHostTab}
variant="secondary"
/>
)}
{isHost && simpleEditor && (
<button
onClick={() => {
if (showAdvancedEditor) setActiveHostTab("general");
setShowAdvancedEditor(!showAdvancedEditor);
}}
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-t border-border/50"
>
<ChevronDown
className={`size-3 transition-transform ${showAdvancedEditor ? "rotate-180" : ""}`}
/>
{showAdvancedEditor
? t("hosts.hideAdvancedSettings")
: t("hosts.showAdvancedSettings")}
</button>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
{isHost ? (
<HostEditor
key={
editingHost === "new"
? `new-host-${hostDraft?.key ?? ""}`
: (editingHost as Host).id
}
host={editingHost === "new" ? null : (editingHost as Host)}
draft={editingHost === "new" ? hostDraft?.draft : undefined}
activeTab={effectiveHostTab}
simpleMode={collapseAdvanced}
onBack={requestCloseHostEditor}
onSave={(saved) => {
const updated = sshHostToHost(
saved as unknown as SSHHostWithStatus,
);
setHosts((prev) => {
const idx = prev.findIndex((h) => h.id === updated.id);
if (idx >= 0) {
const next = [...prev];
next[idx] = updated;
return next;
}
return [...prev, updated];
});
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
closeHostEditor();
}}
protocols={editingProtocols}
onProtocolChange={(p) =>
setEditingProtocols((prev) => ({ ...prev, ...p }))
}
onDirtyChange={setHostEditorDirty}
onTabChange={setActiveHostTab}
hosts={hosts}
credentials={credentials}
onEditCredential={handleEditCredentialFromHost}
/>
) : (
<CredentialEditorView
key={
editingCredential === "new"
? "new-cred"
: (editingCredential as Credential).id
}
credential={
editingCredential === "new"
? null
: (editingCredential as Credential)
}
activeTab={activeCredentialTab}
existingFolders={Array.from(
new Set(
credentials
.map((c) => c.folder)
.filter((f): f is string => !!f),
),
).sort()}
saveAsNewHost={
credentialReturnHost ? credentialReturnHost : undefined
}
onBack={() => {
if (credentialReturnHost) {
setEditingHost(credentialReturnHost);
setCredentialReturnHost(null);
setEditingCredential(null);
setActiveCredentialTab("general");
return;
}
setEditingCredential(null);
setActiveCredentialTab("general");
}}
onSave={(saved, options) => {
// The save endpoints are typed as an untyped record; these are
// the fields this view reads back off the response.
const result = saved as Partial<Credential> & {
id: number | string;
authType?: string;
};
setCredentials((prev) => {
const idx = prev.findIndex((c) => c.id === String(result.id));
const updated: Credential = {
id: String(result.id),
name: result.name ?? "",
username: result.username ?? "",
type: result.authType === "key" ? "key" : "password",
value: result.value,
password: result.password,
publicKey: result.publicKey,
passphrase: result.passphrase,
description: result.description,
folder: result.folder ?? "",
tags: result.tags ?? [],
};
if (idx >= 0) {
const next = [...prev];
next[idx] = updated;
return next;
}
return [...prev, updated];
});
if (options?.assignToHost && credentialReturnHost) {
const returnHost = credentialReturnHost;
setCredentialReturnHost(null);
if (returnHost !== "new") {
setHosts((prev) =>
prev.map((h) =>
h.id === returnHost.id
? { ...h, credentialId: String(result.id) }
: h,
),
);
}
setEditingHost(
returnHost === "new"
? "new"
: { ...returnHost, credentialId: String(result.id) },
);
setActiveHostTab("ssh");
setEditingCredential(null);
return;
}
setEditingCredential(null);
setActiveCredentialTab("general");
}}
/>
)}
</div>
</div>
);
};
const isEditing = !!editingHost || !!editingCredential || !!editingDefaults;
useEffect(() => {
if (active) onEditingChange?.(isEditing);
}, [isEditing, active]);
return (
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
{isEditing ? (
renderEditorView()
) : (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
{/* Search bar — hidden when parent supplies its own */}
{!hideListHeader && (
<div className="px-2 py-1.5 shrink-0 border-b border-border/40">
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60">
<Search className="size-3 text-muted-foreground/60 shrink-0" />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("credentials.searchCredentialsPlaceholder")}
className="flex-1 text-xs bg-transparent outline-none placeholder:text-muted-foreground/50 text-foreground min-w-0"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery("")}
className="text-muted-foreground/60 hover:text-muted-foreground transition-colors"
>
<X className="size-3" />
</button>
)}
</div>
</div>
)}
{!credentialsLoading && filteredCredentials.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center px-4">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("credentials.noCredentialsFound")}
</span>
<Button
variant="outline"
size="sm"
className="mt-3 h-7 text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
onClick={() => {
setEditingCredential("new");
setActiveCredentialTab("general");
}}
>
{t("credentials.addCredentialBtn")}
</Button>
</div>
) : (
<CredentialSidebarTree
folders={credentialFolderTree}
usedByCounts={usedByCounts}
query=""
loading={credentialsLoading}
arrangeLocked={externalArrangeLocked}
density={density}
trayTrigger={trayTrigger}
showTags={showTags}
editingFolderName={editingCredFolderName}
editingFolderValue={editingCredFolderValue}
onEditingFolderNameChange={setEditingCredFolderName}
onEditingFolderValueChange={setEditingCredFolderValue}
onRenameFolder={handleRenameCredentialFolder}
onMoveCredentialToFolder={handleMoveCredentialToFolder}
onDeployCredential={(cred) =>
setDeployDialog({ cred, hostId: "" })
}
onEditCredential={handleEditCredential}
onCloneCredential={handleCloneCredential}
onDeleteCredential={handleConfirmDeleteCredential}
onShareCredential={setShareCredential}
/>
)}
</div>
)}
<CredentialShareModal
credential={shareCredential}
onClose={() => setShareCredential(null)}
/>
{/* Confirm dialog */}
{confirmDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
<p className="text-sm text-foreground">{confirmDialog.message}</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setConfirmDialog(null)}
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
>
{t("hosts.cancelBtn")}
</button>
<button
onClick={() => {
confirmDialog.onConfirm();
setConfirmDialog(null);
}}
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
>
{t("hosts.deleteConfirmBtn")}
</button>
</div>
</div>
</div>
)}
{showUnsavedHostDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold text-foreground">
{t("common.unsavedChanges")}
</p>
<p className="text-xs text-muted-foreground">
{t("hosts.unsavedChangesDescription")}
</p>
</div>
<div className="flex justify-end gap-2">
<button
onClick={() => setShowUnsavedHostDialog(false)}
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
>
{t("hosts.keepEditing")}
</button>
<button
onClick={closeHostEditor}
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
>
{t("hosts.discardChanges")}
</button>
</div>
</div>
</div>
)}
{/* Deploy credential dialog */}
{deployDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-sm flex flex-col gap-4 p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-bold">
{t("credentials.deployDialogTitle")}
</span>
<button
onClick={() => setDeployDialog(null)}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
<div className="text-xs text-muted-foreground">
{t("credentials.deployDialogDesc", {
name: deployDialog.cred.name,
})}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("credentials.targetHostLabel")}
</label>
<Select2
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
value={deployDialog.hostId}
onChange={(e) =>
setDeployDialog({ ...deployDialog, hostId: e.target.value })
}
>
<option value="">{t("credentials.selectHostOption")}</option>
{allHosts
.filter(
(h) => h.enableSsh || enabledHostProtocols(h).length === 0,
)
.map((h) => (
<option key={h.id} value={h.id}>
{h.name || h.ip}
</option>
))}
</Select2>
</div>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setDeployDialog(null)}
disabled={deploying}
>
{t("hosts.cancelBtn")}
</Button>
<Button
variant="outline"
size="sm"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
disabled={!deployDialog.hostId || deploying}
onClick={async () => {
setDeploying(true);
try {
await deployCredentialToHost(
Number(deployDialog.cred.id),
Number(deployDialog.hostId),
);
toast.success(t("credentials.keyDeployedSuccess"));
setDeployDialog(null);
} catch {
toast.error(t("credentials.failedToDeployKey"));
} finally {
setDeploying(false);
}
}}
>
{deploying
? t("credentials.deployingBtn")
: t("credentials.deployBtn")}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
+3 -127
View File
@@ -1,19 +1,6 @@
/* eslint-disable react-refresh/only-export-components */
import {
useEffect,
useRef,
useState,
type ComponentType,
type ReactNode,
} from "react";
import {
ChevronLeft,
ChevronRight,
KeyRound,
Settings,
Terminal,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { type ComponentType, type ReactNode } from "react";
import { KeyRound, Settings, Terminal } from "lucide-react";
import { byOrderThenId, createRegistry } from "@/lib/registry";
/** Core host editor tabs. Plugins add theirs through registerHostEditorSection. */
@@ -196,115 +183,4 @@ export function makeCredentialTabs(
];
}
export function TabStrip({
tabs,
activeTab,
onTabChange,
isActive,
variant = "primary",
}: {
tabs: { id: string; label: string; icon: ReactNode }[];
activeTab: string;
onTabChange: (id: string) => void;
isActive?: (id: string) => boolean;
variant?: "primary" | "secondary";
}) {
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 = 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: (typeof tabs)[0]) => {
const active = isActive ? isActive(tab.id) : activeTab === tab.id;
return (
<button
key={tab.id}
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}
</button>
);
};
const overflow = scroll.left || scroll.right;
return (
<div
className={`flex min-w-0 items-center ${variant === "secondary" ? "border-t border-border bg-card" : ""}`}
>
{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>
)}
</div>
);
}
export { TabStrip } from "@/components/tab-strip";
+56 -36
View File
@@ -5,8 +5,8 @@ import {
type FolderAccessRule,
} from "@/api/rbac-api";
import { useTranslation } from "react-i18next";
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import {
ArrowLeft,
Check,
ListChecks,
Search,
@@ -32,6 +32,7 @@ import {
updateHostAccess,
revokeHostAccess,
getUserList,
getUserInfo,
getRoles,
type AccessRecord,
type SharePermissionLevel,
@@ -39,6 +40,8 @@ import {
} from "@/main-axios";
import type { Host } from "@/types/ui-types";
import { Select2 } from "@/components/select2";
import { DocsLink } from "@/components/docs-link";
import { roleLabel } from "@/lib/role-label";
const PERMISSION_LEVELS: SharePermissionLevel[] = [
"connect",
@@ -70,6 +73,7 @@ export function HostShareModal({
folder?: string | null;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const isFolderShare = !host && !!folder;
const [targetTab, setTargetTab] = useState<"user" | "role">("user");
const [search, setSearch] = useState("");
@@ -110,14 +114,18 @@ export function HostShareModal({
: Promise.resolve({ accessList: [] }),
getUserList().catch(() => ({ users: [] })),
getRoles().catch(() => ({ roles: [] })),
getUserInfo().catch(() => null),
])
.then(([accessRes, usersRes, rolesRes]) => {
.then(([accessRes, usersRes, rolesRes, me]) => {
setAccessList(accessRes.accessList ?? []);
// Nobody shares with themselves.
setShareUsers(
(usersRes.users ?? []).map((u) => ({
id: String(u.id ?? u.userId),
username: u.username,
})),
(usersRes.users ?? [])
.map((u) => ({
id: String(u.id ?? u.userId),
username: u.username,
}))
.filter((u) => u.id !== me?.userId),
);
setShareRoles(
(rolesRes.roles ?? [])
@@ -278,23 +286,20 @@ export function HostShareModal({
}
}
if (!open) return null;
return (
<div className="absolute inset-0 z-20 flex flex-col bg-sidebar">
{/* Header */}
<button
onClick={onClose}
className="flex items-center gap-2 px-3 py-2 shrink-0 border-b border-border text-xs text-muted-foreground hover:text-foreground transition-colors w-full text-left"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span className="truncate">
{isFolderShare
? t("hosts.shareFolderTitle", { name: folder ?? "" })
: t("hosts.shareHostTitle", { name: host?.name ?? "" })}
</span>
</button>
<InlineView
open={open}
onOpenChange={(next) => {
if (!next) onClose();
}}
icon={<Share2 className="size-4" />}
title={
isFolderShare
? t("hosts.shareFolderTitle", { name: folder ?? "" })
: t("hosts.shareHostTitle", { name: host?.name ?? "" })
}
bare
>
{sharingLoadError && (
<div className="flex items-start gap-2 px-3 py-2 shrink-0 border-b border-destructive/30 bg-destructive/5 text-xs text-destructive">
<Shield className="size-3.5 shrink-0 mt-0.5" />
@@ -309,14 +314,7 @@ export function HostShareModal({
<Users className="size-3.5" />
{t("hosts.sharing.shareWithSection")}
</div>
<a
href="https://docs.termix.site/features/authentication/rbac"
target="_blank"
rel="noreferrer"
className="text-[10px] text-accent-brand hover:underline shrink-0"
>
{t("hosts.docsLink")}
</a>
<DocsLink core="sharing">{t("hosts.docsLink")}</DocsLink>
</div>
<div className="flex gap-1.5">
@@ -419,7 +417,7 @@ export function HostShareModal({
</div>
<Shield className="size-3 text-muted-foreground shrink-0" />
<span className="truncate">
{role.displayName || role.name}
{roleLabel(t, role.displayName || role.name)}
</span>
</button>
);
@@ -534,7 +532,7 @@ export function HostShareModal({
>
<span className="flex-1 truncate">
{rule.targetType === "role"
? rule.roleDisplayName || rule.roleName
? roleLabel(t, rule.roleDisplayName) || rule.roleName
: rule.username}
</span>
<span className="text-[10px] uppercase text-muted-foreground">
@@ -544,8 +542,19 @@ export function HostShareModal({
type="button"
className="text-[10px] text-muted-foreground hover:text-destructive"
title={t("hosts.sharing.folderRuleRemove")}
onClick={() => {
void revokeFolderAccess(rule.id).then(refreshFolderRules);
onClick={async () => {
const ok = await confirm({
title: t("sharing.revokeConfirm", {
name:
rule.username ??
roleLabel(t, rule.roleDisplayName) ??
rule.roleName ??
"",
}),
confirmLabel: t("sharing.revoke"),
});
if (ok)
void revokeFolderAccess(rule.id).then(refreshFolderRules);
}}
>
<X className="size-3.5" />
@@ -590,7 +599,7 @@ export function HostShareModal({
)}
<span className="font-semibold truncate">
{record.username ??
record.roleDisplayName ??
roleLabel(t, record.roleDisplayName) ??
record.roleName ??
record.userId ??
record.roleId}
@@ -626,6 +635,17 @@ export function HostShareModal({
size="sm"
className="h-6 text-[10px] px-2 text-destructive hover:bg-destructive/10"
onClick={async () => {
const ok = await confirm({
title: t("sharing.revokeConfirm", {
name:
record.username ??
roleLabel(t, record.roleDisplayName) ??
record.roleName ??
"",
}),
confirmLabel: t("sharing.revoke"),
});
if (!ok) return;
try {
await revokeHostAccess(
Number(host!.id),
@@ -676,6 +696,6 @@ export function HostShareModal({
</div>
</div>
)}
</div>
</InlineView>
);
}
+9 -17
View File
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../lib/error-message.js";
import { setBarActions } from "@/sidebar/tree/host-bar-actions";
import { enabledHostProtocols, useHostProtocols } from "./host-protocols";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -26,7 +27,6 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { SidebarTree, isFolder } from "@/sidebar/SidebarTree";
import { HostManager } from "@/sidebar/HostManager";
import { HostShareModal } from "@/sidebar/HostShareModal";
import { HostExportDialog } from "@/sidebar/HostExportDialog";
import { CustomizeSidebarPanel } from "@/sidebar/CustomizeSidebarPanel";
@@ -200,7 +200,6 @@ export function HostsPanel({
onEditHost,
hostTree,
loading,
onEditingChange,
active = true,
}: {
onOpenTab: (
@@ -215,6 +214,7 @@ export function HostsPanel({
onEditHost: (host: Host) => void;
hostTree?: HostFolder;
loading?: boolean;
/** Unused since editing moved to the Manage tab. */
onEditingChange?: (editing: boolean) => void;
active?: boolean;
}) {
@@ -238,7 +238,6 @@ export function HostsPanel({
const hostProtocols = useHostProtocols();
const sshAuthProviders = useSshAuthProviders();
const [hostSearch, setHostSearch] = useState("");
const [managerEditing, setManagerEditing] = useState(false);
const [customizePanelOpen, setCustomizePanelOpen] = useState(false);
const [selectionMode, setSelectionMode] = useState(false);
const [refreshing, setRefreshing] = useState(false);
@@ -250,6 +249,9 @@ export function HostsPanel({
);
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
useHostSidebarPreferences();
useEffect(() => {
setBarActions(sidebarPrefs.display.barActions);
}, [sidebarPrefs.display.barActions]);
const sortKey = sidebarPrefs.sort.key;
const pinnedFirst = sidebarPrefs.sort.pinnedFirst;
const { arrangeLocked, toggleArrangeLock } = useArrangeLock(
@@ -345,11 +347,6 @@ export function HostsPanel({
};
}, []);
function handleEditingChange(editing: boolean) {
setManagerEditing(editing);
onEditingChange?.(editing);
}
function toggleSelectionMode() {
setSelectionMode((v) => !v);
}
@@ -422,7 +419,7 @@ export function HostsPanel({
return (
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
{!managerEditing && (
{
<div className="flex flex-col px-2 py-1.5 shrink-0 border-b border-border/60 gap-1.5">
<div className="flex items-center gap-2 px-2.5 h-7 bg-muted/60 border border-border/60 rounded-none">
<Search className="size-3 text-muted-foreground/60 shrink-0" />
@@ -1023,7 +1020,7 @@ export function HostsPanel({
</div>
</div>
</div>
)}
}
<CustomizeSidebarPanel
open={customizePanelOpen}
onOpenChange={setCustomizePanelOpen}
@@ -1031,7 +1028,7 @@ export function HostsPanel({
update={updateSidebarPrefs}
/>
{active && !managerEditing && (
{active && (
<div className="flex flex-col flex-1 min-h-0">
<SidebarTree
children={
@@ -1064,17 +1061,12 @@ export function HostsPanel({
showTags={sidebarPrefs.display.showTags}
openOnDoubleClick={sidebarPrefs.display.openOnDoubleClick}
showFolderPaths={sidebarPrefs.display.showFolderPaths}
rowFields={sidebarPrefs.display}
hostClickBehavior={sidebarPrefs.display.hostClickBehavior}
/>
</div>
)}
<div
className={managerEditing ? "flex flex-col flex-1 min-h-0" : "hidden"}
>
<HostManager onEditingChange={handleEditingChange} active={active} />
</div>
<HostShareModal
open={shareModalHost !== null}
onClose={() => setShareModalHost(null)}
+16 -56
View File
@@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useConfirm } from "@/components/surface/surface-scope";
import {
Check,
Gauge,
@@ -9,14 +10,6 @@ import {
Wrench,
} from "lucide-react";
import { Button } from "@/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { applyPresetSideEffects } from "@/lib/apply-ui-preset";
import { usePluginStore } from "@/plugin-host/plugin-store";
@@ -45,10 +38,7 @@ export function InterfacePresetSettings({
}) {
const { t } = useTranslation();
const ctx = useUiPreferencesContext();
const [pendingPreset, setPendingPreset] = useState<Exclude<
UiPreset,
"custom"
> | null>(null);
const confirm = useConfirm();
const [applying, setApplying] = useState(false);
// The chosen preset stays highlighted even once knobs diverge from it --
@@ -87,18 +77,25 @@ export function InterfacePresetSettings({
if (!ctx) return null;
async function confirmPreset() {
if (!pendingPreset) return;
async function choosePreset(preset: Exclude<UiPreset, "custom">) {
const ok = await confirm({
title: t("newUi.sidebar.userProfile.presetConfirmTitle", {
preset: t(`newUi.sidebar.userProfile.preset_${preset}`),
}),
description: t("newUi.sidebar.userProfile.presetConfirmDesc"),
confirmLabel: t("newUi.sidebar.userProfile.presetConfirmApply"),
destructive: false,
});
if (!ok) return;
setApplying(true);
try {
ctx.setPreset(pendingPreset);
ctx.setPreset(preset);
// Picking a preset is a deliberate reset, so any knob the user had
// pinned goes back to following the preset.
ctx.clearAllOverrides();
await applyPresetSideEffects(pendingPreset);
await applyPresetSideEffects(preset);
} finally {
setApplying(false);
setPendingPreset(null);
}
}
@@ -118,7 +115,8 @@ export function InterfacePresetSettings({
<button
key={preset}
type="button"
onClick={() => setPendingPreset(preset)}
disabled={applying}
onClick={() => void choosePreset(preset)}
aria-pressed={active}
className={`flex w-full items-start gap-2 border p-2 text-left transition-colors ${
active
@@ -203,44 +201,6 @@ export function InterfacePresetSettings({
</Button>
</div>
)}
<Dialog
open={!!pendingPreset}
onOpenChange={(open) => !open && setPendingPreset(null)}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-base font-bold">
{t("newUi.sidebar.userProfile.presetConfirmTitle", {
preset: pendingPreset
? t(`newUi.sidebar.userProfile.preset_${pendingPreset}`)
: "",
})}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("newUi.sidebar.userProfile.presetConfirmDesc")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setPendingPreset(null)}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
disabled={applying}
onClick={confirmPreset}
>
{t("newUi.sidebar.userProfile.presetConfirmApply")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
-494
View File
@@ -1,494 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
getUserPreferences,
parseCustomKeybindings,
} from "@/api/open-tabs-api";
import { saveUserPreferences } from "@/main-axios";
import {
useKeybindingActions,
useKeybindingDefaults,
type KeybindingActionDef,
} from "@/shell/keybinding-registry";
import type {
CustomKeybinding,
KeybindingAction,
KeyCombo,
} from "@/types/keybindings";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/dialog";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
import { FixedShortcutsList } from "./FixedShortcutsList";
function formatCombo(combo: KeyCombo): string {
const parts: string[] = [];
if (combo.ctrl) parts.push("Ctrl");
if (combo.alt) parts.push("Alt");
if (combo.shift) parts.push("Shift");
if (combo.meta) parts.push("Cmd");
parts.push(combo.key.length === 1 ? combo.key.toUpperCase() : combo.key);
return parts.join(" + ");
}
function generateId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `kb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
/** An action without the keys an editor cleared, so it saves clean. */
function withoutEmpty(action: KeybindingAction): KeybindingAction {
const out: KeybindingAction = { type: action.type };
for (const [key, value] of Object.entries(action)) {
if (key !== "type" && value !== undefined && value !== null) {
out[key] = value;
}
}
return out;
}
/**
* Appearance > Keybindings. The actions come from the keybinding registry:
* the shell's own and whatever the running plugins register, each with its
* own parameter editor. A saved binding whose plugin is off stays in the
* list and keeps working once the plugin is back.
*/
export function KeybindingsDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const { t } = useTranslation();
const actions = useKeybindingActions();
const defaults = useKeybindingDefaults();
const [bindings, setBindings] = useState<CustomKeybinding[]>([]);
const [loading, setLoading] = useState(false);
const [formOpen, setFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [overridesDefaultId, setOverridesDefaultId] = useState<
string | undefined
>(undefined);
const [recordedCombo, setRecordedCombo] = useState<KeyCombo | null>(null);
const [recording, setRecording] = useState(false);
const [draft, setDraft] = useState<KeybindingAction>({ type: "" });
const actionById = useMemo(
() => new Map(actions.map((action) => [action.id, action])),
[actions],
);
const draftDef: KeybindingActionDef | undefined = actionById.get(draft.type);
useEffect(() => {
if (!open) return;
setLoading(true);
getUserPreferences()
.then((prefs) =>
setBindings(parseCustomKeybindings(prefs.customKeybindings)),
)
.catch(() => setBindings([]))
.finally(() => setLoading(false));
}, [open]);
const conflictWarning = useMemo(() => {
if (!recordedCombo) return null;
const conflict = bindings.find(
(kb) =>
kb.id !== editingId &&
kb.enabled &&
kb.combo.ctrl === recordedCombo.ctrl &&
kb.combo.alt === recordedCombo.alt &&
kb.combo.shift === recordedCombo.shift &&
kb.combo.meta === recordedCombo.meta &&
kb.combo.key === recordedCombo.key,
);
if (conflict) {
return t("newUi.sidebar.keybindings.conflictWarning", {
combo: formatCombo(conflict.combo),
});
}
return null;
}, [recordedCombo, bindings, editingId, t]);
function actionLabel(type: string): string {
const def = actionById.get(type);
return def
? t(def.labelKey)
: t("newUi.sidebar.keybindings.unavailableAction", { type });
}
function resetForm() {
setEditingId(null);
setOverridesDefaultId(undefined);
setRecordedCombo(null);
setRecording(false);
setDraft({ type: actions[0]?.id ?? "" });
}
function openAddForm() {
resetForm();
setFormOpen(true);
}
function openEditForm(kb: CustomKeybinding) {
setEditingId(kb.id);
setOverridesDefaultId(kb.overridesDefaultId);
setRecordedCombo(kb.combo);
setRecording(false);
setDraft({ ...kb.action });
setFormOpen(true);
}
function openOverrideDefaultForm(defaultId: string, combo: KeyCombo) {
resetForm();
setOverridesDefaultId(defaultId);
setRecordedCombo(combo);
setFormOpen(true);
}
async function persist(updated: CustomKeybinding[]) {
try {
await saveUserPreferences({
customKeybindings: JSON.stringify(updated),
});
setBindings(updated);
window.dispatchEvent(new Event("customKeybindingsChanged"));
} catch {
toast.error(t("newUi.sidebar.keybindings.saveError"));
}
}
function handleDelete(id: string) {
persist(bindings.filter((kb) => kb.id !== id));
}
function handleResetAll() {
persist([]);
}
function startRecording(inputEl: HTMLInputElement) {
setRecording(true);
const handler = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) {
return;
}
const isAlnum = /^[a-zA-Z0-9]$/.test(e.key);
const combo: KeyCombo = {
key: isAlnum ? e.key.toLowerCase() : e.code,
isCode: !isAlnum,
ctrl: e.ctrlKey,
alt: e.altKey,
shift: e.shiftKey,
meta: e.metaKey,
};
setRecordedCombo(combo);
setRecording(false);
inputEl.removeEventListener("keydown", handler, true);
inputEl.blur();
};
inputEl.addEventListener("keydown", handler, true);
}
function handleSave() {
if (!recordedCombo) {
toast.error(t("newUi.sidebar.keybindings.comboRequiredError"));
return;
}
if (!draftDef) {
toast.error(t("newUi.sidebar.keybindings.actionRequiredError"));
return;
}
const action = withoutEmpty(draft);
const problem = draftDef.validate?.(action);
if (problem) {
toast.error(t(problem));
return;
}
const now = new Date().toISOString();
const existing = editingId
? bindings.find((kb) => kb.id === editingId)
: undefined;
const newBinding: CustomKeybinding = {
id: editingId ?? generateId(),
combo: recordedCombo,
action,
enabled: true,
overridesDefaultId,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
const updated = editingId
? bindings.map((kb) => (kb.id === editingId ? newBinding : kb))
: [...bindings, newBinding];
persist(updated);
setFormOpen(false);
resetForm();
}
const customOnlyBindings = bindings.filter((kb) => !kb.overridesDefaultId);
const Editor = draftDef?.editor;
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("newUi.sidebar.keybindings.title")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("newUi.sidebar.keybindings.description")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1 max-h-[60vh] overflow-y-auto">
{defaults.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold text-muted-foreground">
{t("newUi.sidebar.keybindings.defaultsHeading")}
</span>
{defaults.map((def) => {
const override = bindings.find(
(kb) => kb.overridesDefaultId === def.id,
);
return (
<div
key={def.id}
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-mono">
{override
? formatCombo(override.combo)
: formatCombo(def.combo)}
</span>
<span className="text-xs text-muted-foreground truncate">
{t(def.descriptionKey)}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{override ? (
<>
<span className="text-[10px] font-bold text-accent-brand border border-accent-brand/40 px-1">
{t("newUi.sidebar.keybindings.customizedBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => openEditForm(override)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(override.id)}
title={t(
"newUi.sidebar.keybindings.resetToDefault",
)}
>
<RotateCcw className="size-3.5" />
</Button>
</>
) : (
<>
<span className="text-[10px] text-muted-foreground border border-border px-1">
{t("newUi.sidebar.keybindings.defaultBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() =>
openOverrideDefaultForm(def.id, def.combo)
}
title={t("newUi.sidebar.keybindings.customize")}
>
<Pencil className="size-3.5" />
</Button>
</>
)}
</div>
</div>
);
})}
</div>
)}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold text-muted-foreground">
{t("newUi.sidebar.keybindings.customHeading")}
</span>
<Button variant="outline" size="sm" onClick={openAddForm}>
<Plus className="size-3.5 mr-1" />
{t("newUi.sidebar.keybindings.addBinding")}
</Button>
</div>
{loading ? (
<span className="text-xs text-muted-foreground">
{t("newUi.sidebar.keybindings.loading")}
</span>
) : customOnlyBindings.length === 0 ? (
<span className="text-xs text-muted-foreground/60">
{t("newUi.sidebar.keybindings.noCustomBindings")}
</span>
) : (
customOnlyBindings.map((kb) => {
const Summary = actionById.get(kb.action.type)?.summary;
return (
<div
key={kb.id}
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-mono">
{formatCombo(kb.combo)}
</span>
<span className="text-xs text-muted-foreground truncate">
{actionLabel(kb.action.type)}
{Summary && (
<>
{" "}
<Summary action={kb.action} onChange={() => {}} />
</>
)}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => openEditForm(kb)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(kb.id)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</div>
);
})
)}
</div>
<FixedShortcutsList />
</div>
<div className="flex items-center justify-between gap-2 mt-2">
<Button
variant="ghost"
className="text-muted-foreground"
onClick={handleResetAll}
disabled={bindings.length === 0}
>
{t("newUi.sidebar.keybindings.resetAllToDefaults")}
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("newUi.sidebar.keybindings.close")}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{editingId || overridesDefaultId
? t("newUi.sidebar.keybindings.editBindingTitle")
: t("newUi.sidebar.keybindings.addBindingTitle")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 mt-1">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.pressKeysToRecord")}{" "}
<span className="text-accent-brand">*</span>
</label>
<Input
data-keybinding-recorder
readOnly
value={
recording
? t("newUi.sidebar.keybindings.recording")
: recordedCombo
? formatCombo(recordedCombo)
: ""
}
placeholder={t(
"newUi.sidebar.keybindings.pressKeysPlaceholder",
)}
onFocus={(e) => startRecording(e.currentTarget)}
className="font-mono cursor-pointer"
/>
{conflictWarning && (
<span className="text-xs text-yellow-500">
{conflictWarning}
</span>
)}
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.actionLabel")}
</label>
<select
value={draft.type}
onChange={(e) => setDraft({ type: e.target.value })}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{!draftDef && draft.type && (
<option value={draft.type}>{actionLabel(draft.type)}</option>
)}
{actions.map((action) => (
<option key={action.id} value={action.id}>
{t(action.labelKey)}
</option>
))}
</select>
</div>
{Editor && <Editor action={draft} onChange={setDraft} />}
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={() => setFormOpen(false)}>
{t("newUi.sidebar.keybindings.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSave}
>
{t("newUi.sidebar.keybindings.saveBinding")}
</Button>
</div>
</DialogContent>
</Dialog>
</>
);
}
-93
View File
@@ -1,93 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { PanelRight, SquareArrowOutUpRight, X } from "lucide-react";
const STORAGE_KEY = "termix_multiPanelHintDismissed";
function multiPanelHintDismissed(): boolean {
return localStorage.getItem(STORAGE_KEY) === "true";
}
/**
* One-time nudge telling users a panel can leave the sidebar. Both actions are
* otherwise only reachable through modifier clicks and the rail context menu,
* which nobody finds on their own.
*/
export function MultiPanelHint({
canPromote,
canRightDock,
onOpenAsTab,
onOpenInRightDock,
}: {
canPromote: boolean;
canRightDock: boolean;
onOpenAsTab: () => void;
onOpenInRightDock: () => void;
}) {
const { t } = useTranslation();
const [dismissed, setDismissed] = useState(multiPanelHintDismissed);
if (dismissed || (!canPromote && !canRightDock)) return null;
function dismiss() {
localStorage.setItem(STORAGE_KEY, "true");
setDismissed(true);
}
return (
<div className="relative shrink-0 border-b border-border bg-accent-brand/5 px-3 py-2.5">
<button
onClick={dismiss}
title={t("common.dismiss")}
aria-label={t("common.dismiss")}
className="absolute right-1.5 top-1.5 text-muted-foreground hover:text-foreground"
>
<X className="size-3" />
</button>
<p className="pr-5 text-xs font-semibold text-foreground">
{t("nav.multiPanelHintTitle")}
</p>
<p className="mt-0.5 pr-5 text-[11px] leading-snug text-muted-foreground">
{t("nav.multiPanelHintBody")}
</p>
<div className="mt-2 flex flex-col gap-1.5">
{canPromote && (
<div className="flex items-start gap-1.5 text-[11px] leading-snug text-muted-foreground">
<SquareArrowOutUpRight className="mt-0.5 size-3 shrink-0 text-accent-brand" />
<span>
<button
onClick={() => {
dismiss();
onOpenAsTab();
}}
className="font-medium text-foreground underline underline-offset-2 hover:text-accent-brand"
>
{t("nav.openAsTab")}
</button>
{": "}
{t("nav.multiPanelHintTabHow")}
</span>
</div>
)}
{canRightDock && (
<div className="flex items-start gap-1.5 text-[11px] leading-snug text-muted-foreground">
<PanelRight className="mt-0.5 size-3 shrink-0 text-accent-brand" />
<span>
<button
onClick={() => {
dismiss();
onOpenInRightDock();
}}
className="font-medium text-foreground underline underline-offset-2 hover:text-accent-brand"
>
{t("nav.openInRightDock")}
</button>
{": "}
{t("nav.multiPanelHintDockHow")}
</span>
</div>
)}
</div>
</div>
);
}
+88 -24
View File
@@ -1,11 +1,17 @@
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp, RotateCcw } from "lucide-react";
import { FakeSwitch } from "@/components/section-card";
import { useRailItems } from "./rail-items";
import { Button } from "@/components/button";
import {
useAreaPreferences,
useUiPreferencesContext,
} from "@/contexts/UiPreferencesContext";
import { moveRailItem, useRailItems } from "./rail-items";
/**
* Appearance > Sidebar > Navigation: one switch per rail destination,
* including the ones plugins register, which appear and disappear here as
* their plugin is turned on and off.
* their plugin is turned on and off. The arrows reorder the rail.
*/
export function NavigationVisibilityToggles({
hidden,
@@ -16,34 +22,92 @@ export function NavigationVisibilityToggles({
}) {
const { t } = useTranslation();
const railItems = useRailItems();
const uiPrefs = useUiPreferencesContext();
const { order } = useAreaPreferences("rail");
const main = railItems.filter((item) => item.placement !== "footer");
const band = (id: string) =>
main.find((item) => item.id === id)?.group ?? "tools";
const move = (id: string, direction: -1 | 1) => {
const peers = main.filter((item) => band(item.id) === band(id));
const at = peers.findIndex((item) => item.id === id);
const target = at + direction;
if (target < 0 || target >= peers.length) return;
// Moving down means sitting before the item after the next one.
const before =
direction === -1 ? peers[target].id : (peers[target + 1]?.id ?? null);
uiPrefs?.setOverride("rail", "order", moveRailItem(main, id, before));
};
return (
<>
{railItems
.filter((item) => item.hideable !== false)
.map((item) => (
<div
key={item.id}
className="flex items-center justify-between py-1.5"
data-testid={`nav-toggle-${item.id}`}
>
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
<span className="text-muted-foreground">
<item.icon size={12} />
.map((item) => {
const reorderable = item.placement !== "footer" && !!uiPrefs;
const peers = main.filter((peer) => band(peer.id) === band(item.id));
const index = peers.findIndex((peer) => peer.id === item.id);
return (
<div
key={item.id}
className="flex items-center justify-between gap-2 py-1.5"
data-testid={`nav-toggle-${item.id}`}
>
<span className="flex min-w-0 items-center gap-1.5 text-xs font-medium text-foreground">
<span className="text-muted-foreground">
<item.icon size={12} />
</span>
<span className="truncate">{t(item.labelKey)}</span>
</span>
{t(item.labelKey)}
</span>
<FakeSwitch
checked={!hidden.has(item.id)}
onChange={(visible) => {
const next = new Set(hidden);
if (visible) next.delete(item.id);
else next.add(item.id);
onChange(next);
}}
/>
</div>
))}
<span className="flex shrink-0 items-center gap-1">
{reorderable && (
<>
<Button
variant="ghost"
size="icon-xs"
disabled={index <= 0}
title={t("nav.moveUp")}
aria-label={t("nav.moveUp")}
onClick={() => move(item.id, -1)}
>
<ChevronUp />
</Button>
<Button
variant="ghost"
size="icon-xs"
disabled={index < 0 || index >= peers.length - 1}
title={t("nav.moveDown")}
aria-label={t("nav.moveDown")}
onClick={() => move(item.id, 1)}
>
<ChevronDown />
</Button>
</>
)}
<FakeSwitch
checked={!hidden.has(item.id)}
onChange={(visible) => {
const next = new Set(hidden);
if (visible) next.delete(item.id);
else next.add(item.id);
onChange(next);
}}
/>
</span>
</div>
);
})}
{uiPrefs && order && order.length > 0 && (
<Button
variant="ghost"
size="xs"
className="mt-1 self-start text-muted-foreground"
onClick={() => uiPrefs.setOverride("rail", "order", null)}
>
<RotateCcw />
{t("nav.resetRailOrder")}
</Button>
)}
</>
);
}
+26 -36
View File
@@ -1,8 +1,10 @@
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Eye, EyeOff } from "lucide-react";
import { toast } from "sonner";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { Textarea } from "@/components/textarea";
import { GroupHeading, PANEL } from "@/components/panel-layout";
import type { Host } from "@/types/ui-types";
import { getCredentials } from "@/api/credentials-api";
import { mapCredentials } from "./HostManagerData";
@@ -27,7 +29,7 @@ const INLINE_AUTH_TYPES = new Set(["password", "key", "credential"]);
const LABEL_CLASS =
"text-[10px] font-semibold uppercase tracking-widest text-muted-foreground";
const SELECT_CLASS =
"flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring";
"flex h-8 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring";
interface QuickConnectPanelProps {
onConnect: (host: Host, type: string) => void;
@@ -54,7 +56,6 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
const [authFields, setAuthFields] = useState<Record<string, unknown>>({});
const [password, setPassword] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [credentialId, setCredentialId] = useState("");
const [credentials, setCredentials] = useState<
{ id: string; name: string; username: string }[]
@@ -140,7 +141,8 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col gap-3 p-3">
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
<GroupHeading title={t("newUi.sidebar.quickConnect.where")} />
<div className="grid grid-cols-[1fr_5rem] gap-2">
<Field label={t("newUi.sidebar.quickConnect.protocolLabel")}>
<Select2
@@ -164,7 +166,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
value={port}
onChange={(e) => setPort(e.target.value)}
onKeyDown={onEnter}
className="h-7 text-xs"
className="h-8 text-xs"
/>
</Field>
</div>
@@ -174,9 +176,13 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
value={host}
onChange={(e) => setHost(e.target.value)}
onKeyDown={onEnter}
className="h-7 text-xs"
className="h-8 text-xs"
/>
</Field>
<GroupHeading
title={t("newUi.sidebar.quickConnect.who")}
className="pt-1"
/>
<Field label={t("newUi.sidebar.quickConnect.usernameLabel")}>
<Input
placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")}
@@ -189,7 +195,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
}}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={onEnter}
className="h-7 text-xs"
className="h-8 text-xs"
/>
</Field>
{!isDesktop && authOptions.length > 0 && (
@@ -212,29 +218,13 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
)}
{(isDesktop || authType === "password") && (
<Field label={t("newUi.sidebar.quickConnect.passwordLabel")}>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
placeholder={t(
"newUi.sidebar.quickConnect.passwordPlaceholder",
)}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={onEnter}
className="h-7 text-xs pr-8"
/>
<button
type="button"
onClick={() => setShowPassword((o) => !o)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? (
<EyeOff className="size-3.5" />
) : (
<Eye className="size-3.5" />
)}
</button>
</div>
<PasswordInput
placeholder={t("newUi.sidebar.quickConnect.passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={onEnter}
className="h-8 pr-8 text-xs"
/>
</Field>
)}
{selected?.quickConnect?.showDomain && (
@@ -244,19 +234,19 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
value={domain}
onChange={(e) => setDomain(e.target.value)}
onKeyDown={onEnter}
className="h-7 text-xs"
className="h-8 text-xs"
/>
</Field>
)}
{!isDesktop && authType === "key" && (
<Field label={t("newUi.sidebar.quickConnect.privateKeyLabel")}>
<textarea
<Textarea
placeholder={t(
"newUi.sidebar.quickConnect.privateKeyPlaceholder",
)}
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
className="w-full h-24 px-2.5 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
className="h-24 resize-none font-mono text-xs"
/>
</Field>
)}
@@ -294,7 +284,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
}}
/>
)}
<div className="flex flex-col gap-1.5 pt-1">
<div className="flex flex-col gap-1.5 pt-2">
{targets.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("newUi.sidebar.quickConnect.noTarget")}
@@ -306,9 +296,9 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
type="button"
disabled={!host.trim()}
onClick={() => connect(action)}
className={`flex items-center justify-center gap-1.5 h-7 w-full border text-xs font-semibold transition-colors disabled:opacity-50 disabled:pointer-events-none ${
className={`flex h-8 w-full items-center justify-center gap-1.5 border text-xs font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 ${
index === 0
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20"
? "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
: "border-border text-foreground hover:bg-muted"
}`}
>
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import type { PluginSettingsField } from "@termix/plugin-sdk/manifest";
import type { PluginSettingsField } from "@termix-ssh/plugin-sdk/manifest";
import { SettingsFieldRow } from "@/settings/SettingsFields";
import {
getPluginHostSettings,
+242 -302
View File
@@ -1,3 +1,5 @@
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import { FormFooter } from "@/components/list-kit";
import { useState, useRef, useEffect } from "react";
import { AuthEnrollmentSections } from "./AuthEnrollmentSections";
import { useTranslation } from "react-i18next";
@@ -26,14 +28,6 @@ import { shell } from "@/plugin-host/shell-bridge";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { VersionBadge } from "@/components/version-badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/dialog";
import {
AlertCircle,
ChevronDown,
@@ -56,8 +50,8 @@ import {
import { SettingRow, FakeSwitch } from "@/components/section-card";
import { NavigationVisibilityToggles } from "./NavigationVisibilityToggles";
import { InterfacePresetSettings } from "./InterfacePresetSettings";
import { useSettingsEmbedded } from "@/settings/settings-embed";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { KeybindingsDialog } from "./KeybindingsDialog";
import {
ACCENT_PRESET_COLORS,
applyAccentColor,
@@ -72,13 +66,18 @@ import type { FontSizeId, ThemeId, UiFontId } from "@/types/ui-types";
import { toast } from "sonner";
import { changeAppLanguage, normalizeLanguageCode } from "@/i18n/i18n";
import { Select2 } from "@/components/select2";
import { LANGUAGES, THEMES } from "@/settings/appearance/appearance-controls";
import { clearLocalAdaptivePreferences } from "@/lib/local-adaptive-preferences";
import { reportBetaFeedbackUrl } from "@/lib/issue-url";
import { isPrerelease } from "@/plugins/plugin-model";
import {
FeatureSettingsSection,
featureSectionId,
useFeatureSettings,
type FeatureSectionId,
} from "@/settings/FeatureSettingsSections";
import { docsUrl } from "@/lib/docs";
import { roleLabel } from "@/lib/role-label";
type UserProfileSection =
| "account"
@@ -89,57 +88,6 @@ type UserProfileSection =
| "data"
| FeatureSectionId;
const THEMES: { id: ThemeId; preview: string }[] = [
{ id: "system", preview: "auto" },
{ id: "light", preview: "#ffffff" },
{ id: "dark", preview: "#1a1c22" },
{ id: "dracula", preview: "#282a36" },
{ id: "catppuccin", preview: "#1e1e2e" },
{ id: "nord", preview: "#2e3440" },
{ id: "solarized", preview: "#002b36" },
{ id: "tokyo-night", preview: "#1a1b26" },
{ id: "one-dark", preview: "#282c34" },
{ id: "gruvbox", preview: "#282828" },
];
const LANGUAGES = [
{ code: "en", label: "English" },
{ code: "af", label: "Afrikaans" },
{ code: "ar", label: "العربية" },
{ code: "bn", label: "বাংলা" },
{ code: "bg", label: "Български" },
{ code: "ca", label: "Català" },
{ code: "zh-CN", label: "中文 (简体)" },
{ code: "zh-TW", label: "中文 (繁體)" },
{ code: "cs", label: "Čeština" },
{ code: "da", label: "Dansk" },
{ code: "nl", label: "Nederlands" },
{ code: "fi", label: "Suomi" },
{ code: "fr", label: "Français" },
{ code: "de", label: "Deutsch" },
{ code: "el", label: "Ελληνικά" },
{ code: "he", label: "עברית" },
{ code: "hi", label: "हिन्दी" },
{ code: "hu", label: "Magyar" },
{ code: "id", label: "Indonesia" },
{ code: "it", label: "Italiano" },
{ code: "ja", label: "日本語" },
{ code: "ko", label: "한국어" },
{ code: "no", label: "Norsk" },
{ code: "pl", label: "Polski" },
{ code: "pt-PT", label: "Português (PT)" },
{ code: "pt-BR", label: "Português (BR)" },
{ code: "ro", label: "Română" },
{ code: "ru", label: "Русский" },
{ code: "sr", label: "Српски" },
{ code: "es-ES", label: "Español" },
{ code: "sv-SE", label: "Svenska" },
{ code: "th", label: "ไทย" },
{ code: "tr", label: "Türkçe" },
{ code: "uk", label: "Українська" },
{ code: "vi", label: "Tiếng Việt" },
];
export function AccordionSection({
id,
label,
@@ -157,7 +105,19 @@ export function AccordionSection({
hidden?: boolean;
children: React.ReactNode;
}) {
const embedded = useSettingsEmbedded();
if (hidden) return null;
if (embedded) {
if (!open) return null;
return (
<div
id={`${id}-content`}
className="flex flex-col border border-border bg-card px-3 pb-3"
>
{children}
</div>
);
}
return (
<div className="border border-border bg-card overflow-hidden">
@@ -253,119 +213,88 @@ export function NewApiKeyDialog({
};
return (
<Dialog
<InlineView
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen || !createdToken) onOpenChange(nextOpen);
}}
>
<DialogContent className="sm:max-w-md rounded-none border-border bg-card p-0 gap-0 overflow-hidden">
<DialogHeader className="px-5 pt-5 pb-4 border-b border-border">
<div className="flex items-center gap-2.5">
<div className="size-8 border border-border bg-muted flex items-center justify-center shrink-0">
<Network className="size-3.5 text-accent-brand" />
</div>
<div>
<DialogTitle className="text-base font-bold leading-none">
{t("newUi.sidebar.userProfile.createApiKeyTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
{t("newUi.sidebar.userProfile.createApiKeyDescription")}
</DialogDescription>
</div>
</div>
</DialogHeader>
{createdToken ? (
<div className="flex flex-col gap-3 px-5 py-4">
<span className="text-xs font-semibold text-accent-brand">
{t("newUi.sidebar.userProfile.apiKeyCreatedWarning")}
</span>
<div className="flex items-center gap-2 border border-border bg-muted/30 px-2 py-2">
<code className="min-w-0 flex-1 break-all text-xs text-accent-brand">
{createdToken}
</code>
<Button
variant="ghost"
size="icon"
className="size-7 shrink-0"
aria-label={t("newUi.sidebar.userProfile.copyApiKey")}
onClick={() => {
copyToClipboard(createdToken);
toast.info(t("newUi.sidebar.userProfile.copiedToClipboard"));
}}
>
<Copy className="size-3.5" />
</Button>
</div>
</div>
title={t("newUi.sidebar.userProfile.createApiKeyTitle")}
footer={
createdToken ? (
<FormFooter
onSave={close}
saveLabel={t("newUi.sidebar.userProfile.done")}
/>
) : (
<div className="flex flex-col gap-4 px-5 py-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.apiKeyNameLabel")}
</label>
<Input
autoFocus
placeholder={t(
"newUi.sidebar.userProfile.apiKeyNamePlaceholder",
)}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
className="rounded-none bg-muted/50 border-border text-sm h-9"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.expiryDateLabel")}{" "}
<span className="text-muted-foreground/50 normal-case font-medium">
({t("newUi.sidebar.userProfile.optional")})
</span>
</label>
<Input
type="date"
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
className="rounded-none bg-muted/50 border-border text-sm h-9"
/>
</div>
</div>
)}
<DialogFooter className="px-5 py-3 border-t border-border bg-muted/20">
{createdToken ? (
<FormFooter
onCancel={close}
cancelLabel={t("newUi.sidebar.userProfile.cancel")}
onSave={() => void handleCreate()}
saveLabel={t("newUi.sidebar.userProfile.createKey")}
saving={creating}
/>
)
}
>
<p className="text-xs text-muted-foreground">
{t("newUi.sidebar.userProfile.createApiKeyDescription")}
</p>
{createdToken ? (
<div className="flex flex-col gap-3 px-5 py-4">
<span className="text-xs font-semibold text-accent-brand">
{t("newUi.sidebar.userProfile.apiKeyCreatedWarning")}
</span>
<div className="flex items-center gap-2 border border-border bg-muted/30 px-2 py-2">
<code className="min-w-0 flex-1 break-all text-xs text-accent-brand">
{createdToken}
</code>
<Button
variant="outline"
className="rounded-none border-accent-brand/40 text-[10px] font-bold uppercase tracking-widest text-accent-brand"
onClick={close}
variant="ghost"
size="icon"
className="size-7 shrink-0"
aria-label={t("newUi.sidebar.userProfile.copyApiKey")}
onClick={() => {
copyToClipboard(createdToken);
toast.info(t("newUi.sidebar.userProfile.copiedToClipboard"));
}}
>
{t("newUi.sidebar.userProfile.done")}
<Copy className="size-3.5" />
</Button>
) : (
<>
<Button
variant="ghost"
onClick={close}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("newUi.sidebar.userProfile.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest gap-1.5"
onClick={handleCreate}
disabled={creating}
>
<KeyRound className="size-3" />{" "}
{t("newUi.sidebar.userProfile.createKey")}
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
) : (
<div className="flex flex-col gap-4 px-5 py-4">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.apiKeyNameLabel")}
</label>
<Input
autoFocus
placeholder={t("newUi.sidebar.userProfile.apiKeyNamePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
className="rounded-none bg-muted/50 border-border text-sm h-9"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.expiryDateLabel")}{" "}
<span className="text-muted-foreground/50 normal-case font-medium">
({t("newUi.sidebar.userProfile.optional")})
</span>
</label>
<Input
type="date"
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
className="rounded-none bg-muted/50 border-border text-sm h-9"
/>
</div>
</div>
)}
</InlineView>
);
}
@@ -485,7 +414,13 @@ export function UserProfilePanel({
onLogout,
userPrefs,
onPrefsChange,
section,
onOpenSettingsPage,
}: {
/** Shows just this section, flat, as a Settings page. */
section?: string;
/** Moves the Settings tab to another page. */
onOpenSettingsPage?: (id: string) => void;
username?: string;
onLogout?: () => void;
userPrefs?: {
@@ -509,6 +444,7 @@ export function UserProfilePanel({
}) => void;
}) {
const { t } = useTranslation();
const confirm = useConfirm();
const themeLabel: Record<ThemeId, string> = {
system: t("newUi.sidebar.userProfile.themeSystem"),
light: t("newUi.sidebar.userProfile.themeLight"),
@@ -531,13 +467,14 @@ export function UserProfilePanel({
const [accountUsername, setAccountUsername] = useState(username ?? "");
const [accountTotpEnabled, setAccountTotpEnabled] = useState(false);
const [userRole, setUserRole] = useState("");
const [isAdmin, setIsAdmin] = useState(false);
const [authMethod, setAuthMethod] = useState("");
const [version, setVersion] = useState("");
const [versionStatus, setVersionStatus] = useState<
"up_to_date" | "requires_update" | "beta" | "unknown"
>("up_to_date");
const [releaseUrl, setReleaseUrl] = useState("");
const [isOidc, setIsOidc] = useState(false);
const [isExternal, setIsExternal] = useState(false);
const [isDualAuth, setIsDualAuth] = useState(false);
// Delete account
@@ -557,7 +494,7 @@ export function UserProfilePanel({
const { theme, setTheme } = useTheme();
const localSnapshot = useRef<Record<string, string | null>>({});
// Appearance state — initialized from localStorage
// Appearance state, initialized from localStorage
const [accentColor, setAccentColor] = useState(
() => localStorage.getItem("termix-accent") ?? "#f59145",
);
@@ -598,12 +535,11 @@ export function UserProfilePanel({
}
}, [linkedAccount, storageMode, onPrefsChange]);
// Settings toggles — all backed by localStorage
// Settings toggles, all backed by localStorage
const [commandPaletteEnabled, setCommandPaletteEnabled] = useState(() => {
const v = localStorage.getItem("commandPaletteShortcutEnabled");
return v !== null ? v === "true" : true;
});
const [keybindingsDialogOpen, setKeybindingsDialogOpen] = useState(false);
// Sidebar display customization (density, tags, tray trigger, status
// colors) now lives in the dedicated Customize Sidebar panel opened from
// the Hosts toolbar, not here -- resetToDefaults still needs write access.
@@ -647,13 +583,14 @@ export function UserProfilePanel({
getUserInfo()
.then((info) => {
setUserId(info.userId);
setIsOidc(info.is_external ?? info.is_oidc ?? false);
setIsExternal(info.is_external ?? false);
setIsDualAuth(info.is_dual_auth ?? false);
const linked = info.linked ?? null;
setLinkedAccount(linked);
// A linked desktop is the account it is signed in to on the server.
setAccountUsername(linked?.username || info.username);
setAccountTotpEnabled(info.totp_enabled ?? false);
setIsAdmin(Boolean(linked ? linked.isAdmin : info.is_admin));
setUserRole(
(linked ? linked.isAdmin : info.is_admin)
? t("newUi.sidebar.userProfile.roleAdministrator")
@@ -661,8 +598,8 @@ export function UserProfilePanel({
);
if (info.is_dual_auth) {
setAuthMethod(t("newUi.sidebar.userProfile.authMethodDual"));
} else if (info.is_external ?? info.is_oidc) {
setAuthMethod(t("newUi.sidebar.userProfile.authMethodOidc"));
} else if (info.is_external) {
setAuthMethod(t("newUi.sidebar.userProfile.authMethodExternal"));
} else {
setAuthMethod(t("newUi.sidebar.userProfile.authMethodLocal"));
}
@@ -1181,10 +1118,19 @@ export function UserProfilePanel({
}
}
const canChangePasword = !isOidc || isDualAuth;
const canChangePasword = !isExternal || isDualAuth;
const isOpen = (id: string) =>
section ? section === id : openSections.has(id as UserProfileSection);
const shows = (id: string) => !section || section === id;
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-2 p-3">
<div
className={
section
? "flex w-full flex-col gap-2"
: "mx-auto flex w-full max-w-5xl flex-col gap-2 p-3"
}
>
<NewApiKeyDialog
open={newKeyOpen}
onOpenChange={setNewKeyOpen}
@@ -1192,31 +1138,10 @@ export function UserProfilePanel({
userId={userId}
/>
{/* Donate banner */}
<div className="border border-accent-brand/40 bg-accent-brand/10 px-3 py-2.5 flex flex-col gap-1.5">
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-accent-brand">
{t("newUi.sidebar.userProfile.donateTitle")}
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t("newUi.sidebar.userProfile.donateDescription")}
</p>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t("newUi.sidebar.userProfile.donateMilestones")}
</p>
<a
href="https://donate.termix.site/"
target="_blank"
rel="noopener noreferrer"
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest bg-accent-brand text-white px-2 py-1 hover:opacity-90 transition-opacity"
>
{t("newUi.sidebar.userProfile.donateButton")}
</a>
</div>
{/* Storage mode toggle — only meaningful once a remote server is
{/* Storage mode toggle, only meaningful once a remote server is
connected; with no sync there's nowhere for "cloud" to sync to,
so this stays forced to local storage and hidden. */}
{(!isElectron() || isLinked) && (
{(!isElectron() || isLinked) && shows("data") && (
<div className="border border-border bg-card px-3 py-2.5 flex flex-col gap-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.storageModeSwitch")}
@@ -1226,7 +1151,7 @@ export function UserProfilePanel({
onClick={() => handleStorageModeChange("local")}
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
storageMode === "local"
? "bg-accent-brand text-white"
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
}`}
>
@@ -1236,7 +1161,7 @@ export function UserProfilePanel({
onClick={() => handleStorageModeChange("cloud")}
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
storageMode === "cloud"
? "bg-accent-brand text-white"
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
}`}
>
@@ -1261,7 +1186,7 @@ export function UserProfilePanel({
id="account"
label={t("newUi.sidebar.userProfile.sectionAccount")}
icon={<User className="size-3.5" />}
open={openSections.has("account")}
open={isOpen("account")}
onToggle={() => toggle("account")}
>
<div className="flex flex-col gap-0 pt-2">
@@ -1300,7 +1225,7 @@ export function UserProfilePanel({
{t("newUi.sidebar.userProfile.usernameLabel")}
</span>
<span className="text-sm font-semibold mt-0.5">
{accountUsername || "—"}
{accountUsername || "-"}
</span>
</div>
<div className="flex flex-col py-2">
@@ -1309,14 +1234,14 @@ export function UserProfilePanel({
</span>
<div className="flex flex-wrap gap-1 mt-0.5">
<span className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold border border-accent-brand/40 bg-accent-brand/10 text-accent-brand w-fit">
{userRole || "—"}
{userRole || "-"}
</span>
{userRoles.map((r) => (
<span
key={r.roleId}
className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold border border-border bg-muted text-muted-foreground w-fit"
>
{r.roleDisplayName}
{roleLabel(t, r.roleDisplayName)}
</span>
))}
</div>
@@ -1328,7 +1253,7 @@ export function UserProfilePanel({
{t("newUi.sidebar.userProfile.authMethodLabel")}
</span>
<span className="text-sm font-semibold mt-0.5">
{authMethod || "—"}
{authMethod || "-"}
</span>
</div>
<div className="flex flex-col py-2">
@@ -1356,45 +1281,60 @@ export function UserProfilePanel({
)}
<div className="border-t border-border pt-3 mt-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.versionLabel")}
</span>
<div className="flex items-center justify-between mt-1.5">
<span className="text-sm font-bold text-accent-brand">
{version ? `v${version}` : "—"}
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.versionLabel")}
</span>
<VersionBadge status={versionStatus} releaseUrl={releaseUrl} />
<VersionBadge
status={versionStatus}
releaseUrl={releaseUrl}
className="shrink-0 px-1 py-px text-[9px]"
/>
</div>
<span
className="mt-1.5 block truncate text-sm font-bold text-accent-brand"
title={version ? `v${version}` : undefined}
>
{version ? `v${version}` : "-"}
</span>
</div>
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{t("newUi.sidebar.userProfile.betaProgramTitle")}
</span>
<span className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.betaProgramDescription")}{" "}
{(isPrerelease(version) || isAdmin) && (
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium">
{isPrerelease(version)
? t("newUi.sidebar.userProfile.onBetaTitle")
: t("newUi.sidebar.userProfile.tryBetaTitle")}
</span>
<span className="text-[10px] text-muted-foreground">
{isPrerelease(version)
? t("newUi.sidebar.userProfile.onBetaDescription")
: t("newUi.sidebar.userProfile.tryBetaDescription")}
</span>
</div>
{isPrerelease(version) ? (
<a
href="https://github.com/Termix-SSH/Support/issues/new?template=beta_feedback.yml"
href={reportBetaFeedbackUrl(version)}
target="_blank"
rel="noopener noreferrer"
className="text-accent-brand hover:underline"
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
>
{t("newUi.sidebar.userProfile.betaProgramFeedback")}
{t("newUi.sidebar.userProfile.sendBetaFeedback")}
</a>
</span>
) : (
<button
type="button"
onClick={() => onOpenSettingsPage?.("updates")}
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
>
{t("newUi.sidebar.userProfile.openUpdates")}
</button>
)}
</div>
<a
href="https://docs.termix.site/install/server/docker#beta-builds"
target="_blank"
rel="noopener noreferrer"
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
>
{t("newUi.sidebar.userProfile.betaProgramLearnMore")}
</a>
</div>
</div>
)}
<div className="border-t border-border pt-3 mt-3">
<div className="flex items-center justify-between">
@@ -1407,7 +1347,7 @@ export function UserProfilePanel({
</span>
</div>
<a
href="https://docs.termix.site/cli"
href={docsUrl("cli")}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 ml-3 flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest border border-border px-2 py-1.5 hover:bg-muted/40 transition-colors"
@@ -1447,7 +1387,7 @@ export function UserProfilePanel({
id="interface"
label={t("newUi.sidebar.userProfile.sectionInterface")}
icon={<LayoutTemplate size={13} />}
open={openSections.has("interface")}
open={isOpen("interface")}
onToggle={() => toggle("interface")}
>
<div className="flex flex-col gap-4 pt-3">
@@ -1464,7 +1404,7 @@ export function UserProfilePanel({
id="appearance"
label={t("newUi.sidebar.userProfile.sectionAppearance")}
icon={<Palette className="size-3.5" />}
open={openSections.has("appearance")}
open={isOpen("appearance")}
onToggle={() => toggle("appearance")}
>
<div className="flex flex-col gap-4 pt-3">
@@ -1501,7 +1441,6 @@ export function UserProfilePanel({
</option>
))}
</Select2>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 size-3 text-muted-foreground pointer-events-none" />
</div>
<div className="flex gap-1 mt-0.5">
{THEMES.filter((th) => th.id !== "system").map((th) => (
@@ -1521,7 +1460,7 @@ export function UserProfilePanel({
<Type className="size-3" />
{t("newUi.sidebar.userProfile.interfaceFontLabel")}
</span>
<select
<Select2
value={uiFont}
onChange={(event) =>
handleUiFontChange(event.target.value as UiFontId)
@@ -1533,7 +1472,7 @@ export function UserProfilePanel({
{font.label}
</option>
))}
</select>
</Select2>
<span className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.interfaceFontDescription")}
</span>
@@ -1641,7 +1580,7 @@ export function UserProfilePanel({
<Button
variant="outline"
size="sm"
onClick={() => setKeybindingsDialogOpen(true)}
onClick={() => onOpenSettingsPage?.("keybindings")}
>
{t("newUi.sidebar.userProfile.manageShortcuts")}
</Button>
@@ -1760,7 +1699,10 @@ export function UserProfilePanel({
</SettingRow>
</div>
<div className="flex flex-col gap-1 border-t border-border pt-3">
<div
id="settings-navigation"
className="flex flex-col gap-1 border-t border-border pt-3"
>
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
{t("newUi.sidebar.userProfile.settingsNavigation")}
</span>
@@ -1812,7 +1754,7 @@ export function UserProfilePanel({
id="security"
label={t("newUi.sidebar.userProfile.sectionSecurity")}
icon={<Shield className="size-3.5" />}
open={openSections.has("security")}
open={isOpen("security")}
onToggle={() => toggle("security")}
>
<div className="flex flex-col gap-4 pt-3">
@@ -1833,7 +1775,7 @@ export function UserProfilePanel({
id="api-keys"
label={t("newUi.sidebar.userProfile.sectionApiKeys")}
icon={<Network className="size-3.5" />}
open={openSections.has("api-keys")}
open={isOpen("api-keys")}
onToggle={() => toggle("api-keys")}
>
<div className="flex flex-col gap-2 pt-3">
@@ -1896,6 +1838,13 @@ export function UserProfilePanel({
size="icon"
className="size-6 text-muted-foreground hover:text-destructive"
onClick={async () => {
const ok = await confirm({
title: t("admin.revokeKeyConfirm", {
name: key.name,
}),
confirmLabel: t("admin.revokeKey"),
});
if (!ok) return;
try {
await deleteApiKey(key.id);
setApiKeys((prev) =>
@@ -1938,7 +1887,7 @@ export function UserProfilePanel({
id="data"
label={t("newUi.sidebar.userProfile.sectionData")}
icon={<Database className="size-3.5" />}
open={openSections.has("data")}
open={isOpen("data")}
onToggle={() => toggle("data")}
>
<div className="flex flex-col gap-3 pt-3">
@@ -2013,78 +1962,69 @@ export function UserProfilePanel({
key={plugin.id}
plugin={plugin}
scope="user"
open={openSections.has(featureSectionId(plugin.id))}
open={isOpen(featureSectionId(plugin.id))}
onToggle={() => toggle(featureSectionId(plugin.id))}
/>
))}
{/* Delete account dialog */}
<Dialog
<InlineView
open={showDeleteConfirm}
onOpenChange={(open) => {
setShowDeleteConfirm(open);
if (!open) setDeletePassword("");
}}
title={t("newUi.sidebar.userProfile.deleteAccount")}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-lg font-bold text-destructive">
{t("newUi.sidebar.userProfile.deleteAccount")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{t("newUi.sidebar.userProfile.deleteAccountPermanent")}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 mt-1">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("newUi.sidebar.userProfile.deleteAccountWarning")}
</span>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.confirmPasswordLabel")}
</label>
<Input
type="password"
placeholder={t(
"newUi.sidebar.userProfile.confirmPasswordDeletePlaceholder",
)}
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleDeleteAccount()}
/>
</div>
<p className="text-xs text-muted-foreground">
{t("newUi.sidebar.userProfile.deleteAccountPermanent")}
</p>
<div className="flex flex-col gap-3 mt-1">
<div className="flex items-start gap-2.5 border border-destructive/30 bg-destructive/5 px-3 py-2.5">
<AlertCircle className="size-4 text-destructive shrink-0 mt-0.5" />
<span className="text-xs text-destructive">
{t("newUi.sidebar.userProfile.deleteAccountWarning")}
</span>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => {
setShowDeleteConfirm(false);
setDeletePassword("");
}}
>
{t("newUi.sidebar.userProfile.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={handleDeleteAccount}
disabled={deleteLoading || !deletePassword.trim()}
>
<Trash2 className="size-3.5" />
{deleteLoading
? t("newUi.sidebar.userProfile.deleting")
: t("newUi.sidebar.userProfile.deleteAccount")}
</Button>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("newUi.sidebar.userProfile.confirmPasswordLabel")}
</label>
<Input
type="password"
placeholder={t(
"newUi.sidebar.userProfile.confirmPasswordDeletePlaceholder",
)}
value={deletePassword}
onChange={(e) => setDeletePassword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleDeleteAccount()}
/>
</div>
</DialogContent>
</Dialog>
<KeybindingsDialog
open={keybindingsDialogOpen}
onOpenChange={setKeybindingsDialogOpen}
/>
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button
variant="ghost"
onClick={() => {
setShowDeleteConfirm(false);
setDeletePassword("");
}}
>
{t("newUi.sidebar.userProfile.cancel")}
</Button>
<Button
variant="outline"
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={handleDeleteAccount}
disabled={deleteLoading || !deletePassword.trim()}
>
<Trash2 className="size-3.5" />
{deleteLoading
? t("newUi.sidebar.userProfile.deleting")
: t("newUi.sidebar.userProfile.deleteAccount")}
</Button>
</div>
</InlineView>
</div>
);
}
@@ -15,9 +15,11 @@ import { toast } from "sonner";
import { copyToClipboard } from "@/lib/clipboard";
import { ComponentSlot } from "@/shell/ActionSlot";
import type { Credential } from "@/types/ui-types";
import type {
CredentialDensity,
CredentialTrayTrigger,
import {
defaultCredentialRowFields,
type CredentialDensity,
type CredentialRowFields,
type CredentialTrayTrigger,
} from "@/types/credential-sidebar-preferences";
/**
@@ -40,6 +42,8 @@ const CREDENTIAL_ITEM_DENSITY_TOKENS = {
},
} as const;
const DEFAULT_ROW_FIELDS = defaultCredentialRowFields();
export function CredentialItem({
cred,
usedByCount = 0,
@@ -53,6 +57,7 @@ export function CredentialItem({
density = "comfortable",
trayTrigger = "hover",
showTags = true,
rowFields = DEFAULT_ROW_FIELDS,
arrangeMode = false,
isDragging = false,
onReorderDrop,
@@ -80,6 +85,7 @@ export function CredentialItem({
density?: CredentialDensity;
trayTrigger?: CredentialTrayTrigger;
showTags?: boolean;
rowFields?: CredentialRowFields;
/** When true (rearranging unlocked), the row can be dragged to reorder or
* onto a folder header to move. */
arrangeMode?: boolean;
@@ -103,6 +109,8 @@ export function CredentialItem({
const isKey = cred.type === "key";
const tokens = CREDENTIAL_ITEM_DENSITY_TOKENS[density];
const isCompact = density === "compact";
const showUsername = rowFields.showUsername && !!cred.username;
const showUsage = rowFields.showUsageCount && usedByCount > 0;
const isTouchOnly =
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
@@ -281,7 +289,7 @@ export function CredentialItem({
className={`${tokens.nameTextSize} font-semibold truncate text-foreground leading-none tracking-tight`}
>
{cred.name}
{cred.isShared && (
{rowFields.showSharedBadge && cred.isShared && (
<span
className="ml-1 inline-flex items-center gap-0.5 text-[9px] uppercase text-accent-brand/80"
title={t("credentials.sharedBy", {
@@ -293,21 +301,27 @@ export function CredentialItem({
</span>
)}
</span>
<span
className={`text-[9px] px-1 py-px font-bold border leading-none shrink-0 ${isKey ? "border-accent-brand/30 text-accent-brand" : "border-border/60 text-muted-foreground/60"}`}
>
{isKey ? t("credentials.keyBadge") : t("credentials.passwordBadge")}
</span>
{rowFields.showTypeBadge && (
<span
className={`text-[9px] px-1 py-px font-bold border leading-none shrink-0 ${isKey ? "border-accent-brand/30 text-accent-brand" : "border-border/60 text-muted-foreground/60"}`}
>
{isKey
? t("credentials.keyBadge")
: t("credentials.passwordBadge")}
</span>
)}
<ComponentSlot
slotId="credentials.badges"
props={{ credentialId: Number(cred.id) }}
/>
{cred.pin && (
{rowFields.showPinIcon && cred.pin && (
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
)}
{!shouldUseClickTray && !actionsOnly && (
<span className="text-[11px] text-muted-foreground/45 truncate leading-none ml-auto shrink-0 group-hover:hidden">
{usedByCount > 0 ? `${usedByCount}h` : ""}
{rowFields.showUsageCount && usedByCount > 0
? `${usedByCount}h`
: ""}
</span>
)}
{(shouldUseClickTray || actionsOnly) && (
@@ -331,12 +345,12 @@ export function CredentialItem({
</div>
{/* Username row */}
{tokens.showUsernameRow && (cred.username || usedByCount > 0) && (
{tokens.showUsernameRow && (showUsername || showUsage) && (
<span className="text-[11px] text-muted-foreground/60 truncate leading-none font-mono">
{cred.username}
{usedByCount > 0 && (
{showUsername && cred.username}
{showUsage && (
<span
className={`text-muted-foreground/40${cred.username ? " ml-2" : ""}`}
className={`text-muted-foreground/40${showUsername ? " ml-2" : ""}`}
>
{usedByCount}h
</span>
@@ -15,6 +15,7 @@ import { ReorderIndicator } from "@/sidebar/ReorderIndicator";
import type { Credential } from "@/types/ui-types";
import type {
CredentialDensity,
CredentialRowFields,
CredentialTrayTrigger,
} from "@/types/credential-sidebar-preferences";
import { CredentialItem } from "./CredentialItem/CredentialItem";
@@ -41,6 +42,7 @@ export function CredentialSidebarTree({
density = "comfortable",
trayTrigger = "hover",
showTags = true,
rowFields,
editingFolderName,
editingFolderValue,
onEditingFolderNameChange,
@@ -63,6 +65,7 @@ export function CredentialSidebarTree({
density?: CredentialDensity;
trayTrigger?: CredentialTrayTrigger;
showTags?: boolean;
rowFields?: CredentialRowFields;
editingFolderName: string | null;
editingFolderValue: string;
onEditingFolderNameChange: (name: string | null) => void;
@@ -165,6 +168,11 @@ export function CredentialSidebarTree({
isOpen,
showTags,
tagCount: row.item.tags?.length ?? 0,
hasDetailRow:
!rowFields ||
(rowFields.showUsername && !!row.item.username) ||
(rowFields.showUsageCount &&
(usedByCounts?.get(row.item.id) ?? 0) > 0),
});
},
[
@@ -176,6 +184,8 @@ export function CredentialSidebarTree({
actionsOnly,
density,
showTags,
rowFields,
usedByCounts,
],
);
@@ -223,6 +233,7 @@ export function CredentialSidebarTree({
density,
trayTrigger,
showTags,
rowFields,
]);
function toggleFolder(name: string) {
@@ -422,6 +433,7 @@ export function CredentialSidebarTree({
density={density}
trayTrigger={trayTrigger}
showTags={showTags}
rowFields={rowFields}
arrangeMode={arrangeMode}
isDragging={draggedReorderKey === `cred:${item.id}`}
onReorderDrop={(position) =>
@@ -10,6 +10,7 @@ const HEIGHTS = {
alwaysKey: 100.25,
alwaysPassword: 74,
tags: 18.5,
detail: 15,
},
compact: {
base: 23,
@@ -20,6 +21,7 @@ const HEIGHTS = {
alwaysKey: 74.75,
alwaysPassword: 52,
tags: 12.5,
detail: 0,
},
} as const;
@@ -31,6 +33,7 @@ export function getCredentialRowHeight({
isOpen,
showTags,
tagCount,
hasDetailRow = true,
}: {
density: CredentialDensity;
isKey: boolean;
@@ -39,6 +42,8 @@ export function getCredentialRowHeight({
isOpen: boolean;
showTags: boolean;
tagCount: number;
/** False when the username and usage line has nothing to show. */
hasDetailRow?: boolean;
}): number {
const heights = HEIGHTS[density];
let height: number = heights.base;
@@ -51,5 +56,9 @@ export function getCredentialRowHeight({
height = isKey ? heights.actionsOnlyKey : heights.actionsOnlyPassword;
}
return height + (showTags && tagCount > 0 ? heights.tags : 0);
return (
height +
(showTags && tagCount > 0 ? heights.tags : 0) -
(hasDetailRow ? 0 : heights.detail)
);
}
+49
View File
@@ -1,3 +1,8 @@
import { useEffect, useState } from "react";
import { getUserPreferences, saveUserPreferences } from "@/main-axios";
export const HIDDEN_RAIL_TABS_EVENT = "hiddenRailTabsChanged";
export function readHiddenRailTabs(): Set<string> {
try {
const raw = localStorage.getItem("hiddenRailTabs");
@@ -8,3 +13,47 @@ export function readHiddenRailTabs(): Set<string> {
return new Set();
}
}
/**
* The one writer for hidden rail items: the rail's restore list, the
* Navigation toggles in settings and presets all go through here, so they
* never disagree. Saved to the account when settings sync to the cloud.
*/
export async function writeHiddenRailTabs(
next: Iterable<string>,
): Promise<void> {
const serialized = JSON.stringify([...next]);
try {
localStorage.setItem("hiddenRailTabs", serialized);
} catch {
// A private window refuses the write; the change still applies this session.
}
window.dispatchEvent(new Event(HIDDEN_RAIL_TABS_EVENT));
try {
const prefs = await getUserPreferences();
if (prefs?.storageMode === "cloud") {
await saveUserPreferences({ hiddenRailTabs: serialized });
}
} catch {
// Offline; the local copy is already updated.
}
}
/** Hides one item if it is shown, shows it if hidden. */
export function toggleHiddenRailTab(id: string): Promise<void> {
const hidden = readHiddenRailTabs();
if (hidden.has(id)) hidden.delete(id);
else hidden.add(id);
return writeHiddenRailTabs(hidden);
}
/** The hidden rail items, kept current as anything changes them. */
export function useHiddenRailTabs(): Set<string> {
const [hidden, setHidden] = useState(readHiddenRailTabs);
useEffect(() => {
const handler = () => setHidden(readHiddenRailTabs());
window.addEventListener(HIDDEN_RAIL_TABS_EVENT, handler);
return () => window.removeEventListener(HIDDEN_RAIL_TABS_EVENT, handler);
}, []);
return hidden;
}
+5
View File
@@ -61,6 +61,11 @@ const menuItems = createRegistry<HostContextMenuItemDef>(byOrderThenId);
export const registerHostAction = actions.register;
export const useHostActions = actions.useList;
/** Plugin actions sort by order; connect actions default to the end. */
export function actionOrder(action: HostActionDef): number {
return action.order ?? (action.kind === "connect" ? 100 : 50);
}
export const listHostActions = actions.list;
export const registerHostBadge = badges.register;
export const useHostBadges = badges.useList;
@@ -1,16 +1,10 @@
import { useMemo, useState } from "react";
/* eslint-disable react-refresh/only-export-components */
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ArrowLeft } from "lucide-react";
import type { Host } from "@/types/ui-types";
import type { HostDefaultsTarget } from "@/api/host-defaults-api";
import { HostEditor } from "../HostEditor";
import {
isSshGroupTab,
makeHostSshSubTabs,
makeHostTabs,
TabStrip,
useHostEditorSections,
} from "../HostManagerTabs";
import { useHostEditorSections } from "../HostManagerTabs";
import { listHostProtocols, type HostProtocols } from "../host-protocols";
export interface HostDefaultsEditorTarget extends HostDefaultsTarget {
@@ -18,8 +12,18 @@ export interface HostDefaultsEditorTarget extends HostDefaultsTarget {
folderName?: string;
}
/** The title a level of defaults goes by. */
export function hostDefaultsTitle(
target: HostDefaultsEditorTarget,
t: (key: string, options?: Record<string, unknown>) => string,
): string {
if (target.level === "admin") return t("hostDefaults.titleAdmin");
if (target.level === "user") return t("hostDefaults.titleUser");
return t("hostDefaults.titleFolder", { name: target.folderName ?? "" });
}
/**
* The host editor as a form for one level of host defaults: the same tabs
* The host editor as a form for one level of host defaults: the same sections
* and fields as a new host, minus what only one host can have (its name,
* address, secrets, folder).
*/
@@ -38,25 +42,14 @@ export function HostDefaultsEditorView({
}) {
const { t } = useTranslation();
useHostEditorSections();
const [activeTab, setActiveTab] = useState("general");
// Every protocol counts as on, so every tab that has defaults shows.
// Every protocol counts as on, so every section that has defaults shows.
const protocols = useMemo<HostProtocols>(() => {
const all: HostProtocols = { enableSsh: true };
for (const protocol of listHostProtocols()) all[protocol.settingKey] = true;
return all;
}, []);
const flags = protocols as unknown as Record<string, boolean>;
const tabs = makeHostTabs(t, flags, true);
const sshSubTabs = makeHostSshSubTabs(t, flags, true);
const inSshGroup = isSshGroupTab(activeTab);
const title =
target.level === "admin"
? t("hostDefaults.titleAdmin")
: target.level === "user"
? t("hostDefaults.titleUser")
: t("hostDefaults.titleFolder", { name: target.folderName ?? "" });
const description =
target.level === "admin"
? t("hostDefaults.descriptionAdmin")
@@ -65,61 +58,22 @@ export function HostDefaultsEditorView({
: t("hostDefaults.descriptionFolder");
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex flex-col shrink-0 border-b border-border">
<button
onClick={onClose}
className="flex items-center gap-2 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/40 transition-colors border-b border-border/50"
>
<ArrowLeft className="size-3.5 shrink-0" />
<span>{t("hosts.backToHosts")}</span>
<span
className="ml-auto font-semibold text-foreground truncate max-w-[220px]"
title={title}
>
{title}
</span>
</button>
<TabStrip
tabs={tabs}
activeTab={activeTab}
onTabChange={(id) => {
if (id === "ssh") {
if (!inSshGroup) setActiveTab("ssh");
} else {
setActiveTab(id);
}
}}
isActive={(id) => (id === "ssh" ? inSshGroup : activeTab === id)}
/>
{inSshGroup && (
<TabStrip
tabs={sshSubTabs}
activeTab={activeTab}
onTabChange={setActiveTab}
variant="secondary"
/>
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-3">
<p className="text-xs text-muted-foreground border border-border bg-muted/20 px-3 py-2">
<HostEditor
key={`${target.level}:${target.folderId ?? ""}`}
host={null}
banner={
<p className="border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
{description}
</p>
<HostEditor
key={`${target.level}:${target.folderId ?? ""}`}
host={null}
activeTab={activeTab}
onBack={onClose}
onSave={onClose}
protocols={protocols}
onProtocolChange={() => {}}
onDirtyChange={onDirtyChange}
onTabChange={setActiveTab}
hosts={hosts}
credentials={credentials}
defaultsTarget={target}
/>
</div>
</div>
}
onBack={onClose}
onSave={onClose}
protocols={protocols}
onProtocolChange={() => {}}
onDirtyChange={onDirtyChange}
hosts={hosts}
credentials={credentials}
defaultsTarget={target}
/>
);
}
+3 -14
View File
@@ -3,7 +3,7 @@
* running plugin's host fields that can have one.
*/
import { useEffect, useMemo, useState } from "react";
import { useMemo } from "react";
import {
CORE_HOST_DEFAULTS,
CORE_NAMESPACE,
@@ -11,10 +11,10 @@ import {
type HostDefaultsLevel,
} from "@/types/host-defaults";
import {
getPlugins,
type PluginSettingsField,
type PluginSummary,
} from "@/api/plugins-api";
import { useInstalledPlugins } from "@/hooks/use-installed-plugins";
export interface EditorDefaultKey {
fullKey: string;
@@ -71,18 +71,7 @@ export const CORE_EDITOR_KEYS: EditorDefaultKey[] = CORE_HOST_DEFAULTS.map(
/** Every key, by full key. Refreshed when the plugin list is. */
export function useEditorDefaultKeys(): Map<string, EditorDefaultKey> {
const [plugins, setPlugins] = useState<PluginSummary[]>([]);
useEffect(() => {
let cancelled = false;
void getPlugins()
.then((loaded) => {
if (!cancelled) setPlugins(loaded);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
const plugins = useInstalledPlugins();
return useMemo(
() =>
new Map(
+86 -9
View File
@@ -3,13 +3,15 @@ import {
KeyRound,
Plug,
Server,
LibraryBig,
Puzzle,
Settings,
User,
Zap,
type LucideIcon,
} from "lucide-react";
import { useMemo, useSyncExternalStore } from "react";
import { usePermissions } from "@/hooks/use-permissions";
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
import { useSyncAttentionCount } from "@/hooks/use-sync-status";
import { isElectron } from "@/lib/electron";
import { createRegistry } from "@/lib/registry";
@@ -21,7 +23,7 @@ import { getTabType } from "@/shell/tab-registry";
*
* This used to be duplicated in four places -- AppRail's button array, the
* visibility toggles in UserProfilePanel, AppShell's sidebar title map, and
* MobileBottomBar's own primary/more lists -- which drifted: half the sidebar
* the mobile bar's own primary/more lists -- which drifted: half the sidebar
* titles were hardcoded English, the alerts entry had no visibility toggle,
* and the mobile bar ignored hidden tabs entirely. Everything now derives from
* here, so adding a destination is a single edit.
@@ -38,6 +40,8 @@ export interface RailItemDef {
alwaysVisible?: boolean;
/** Renders a separator after this item in the rail. */
separatorAfter?: boolean;
/** The rail band it sits in. Defaults to "tools". */
group?: RailGroup;
/** Shown on the mobile bottom bar's primary row rather than its More menu. */
mobilePrimary?: boolean;
/**
@@ -72,13 +76,24 @@ export interface RailItemDef {
useBadge?: () => number | null | undefined;
}
/** Things you keep, things you do, and the instance itself. */
export type RailGroup = "objects" | "tools" | "system";
export const RAIL_GROUP_ORDER: RailGroup[] = ["objects", "tools", "system"];
export const RAIL_ITEMS: RailItemDef[] = [
{ id: "hosts", icon: Server, labelKey: "nav.hosts", mobilePrimary: true },
{
id: "hosts",
icon: Server,
labelKey: "nav.hosts",
mobilePrimary: true,
group: "objects",
},
{
id: "credentials",
icon: KeyRound,
labelKey: "nav.credentials",
separatorAfter: true,
group: "objects",
},
{
id: "connections",
@@ -86,6 +101,7 @@ export const RAIL_ITEMS: RailItemDef[] = [
labelKey: "nav.connections",
separatorAfter: true,
rightDockable: true,
group: "tools",
},
{
id: "quick-connect",
@@ -93,6 +109,7 @@ export const RAIL_ITEMS: RailItemDef[] = [
labelKey: "nav.quickConnect",
separatorAfter: true,
mobilePrimary: true,
group: "tools",
},
{
id: "sync",
@@ -100,10 +117,30 @@ export const RAIL_ITEMS: RailItemDef[] = [
labelKey: "nav.sync",
electronOnly: true,
placement: "footer",
group: "system",
useBadge: useSyncAttentionCount,
},
{
id: "plugins",
icon: Puzzle,
labelKey: "nav.plugins",
kind: "tab",
placement: "footer",
group: "system",
permission: "admin.plugins.manage",
},
];
/** Main rail items split into their bands, in band order, empty bands left out. */
export function groupRailItems(
items: RailItemDef[],
): { group: RailGroup; items: RailItemDef[] }[] {
return RAIL_GROUP_ORDER.map((group) => ({
group,
items: items.filter((item) => (item.group ?? "tools") === group),
})).filter((band) => band.items.length > 0);
}
/**
* Rail items registered by plugins at runtime. Reactive, because a plugin can
* be enabled or disabled while the app is open and the rail, the mobile bar
@@ -197,19 +234,59 @@ export function useRailItems(): RailItemDef[] {
railItemsSnapshot,
);
const { has, loaded } = usePermissions();
const { order } = useAreaPreferences("rail");
return useMemo(
() => permittedRailItems(items, { has, loaded }),
[items, has, loaded],
() => applyRailOrder(permittedRailItems(items, { has, loaded }), order),
[items, has, loaded, order],
);
}
/**
* Destinations that live outside the rail's hideable list but still need a
* title and a mobile entry.
* Items in the user's order. Listed ids come first in that order within the
* default sequence; unlisted ones keep their default place relative to it.
*/
export function applyRailOrder(
items: RailItemDef[],
order: string[] | undefined,
): RailItemDef[] {
if (!order || order.length === 0) return items;
const rank = new Map(order.map((id, index) => [id, index]));
const listed = items
.filter((item) => rank.has(item.id))
.sort((a, b) => rank.get(a.id)! - rank.get(b.id)!);
let next = 0;
return items.map((item) => (rank.has(item.id) ? listed[next++] : item));
}
/**
* The order after moving `id` to sit before `beforeId` (or to the end of its
* band when null), as a full list of the given items' ids.
*/
export function moveRailItem(
items: RailItemDef[],
id: string,
beforeId: string | null,
): string[] {
const ids = items.map((item) => item.id).filter((other) => other !== id);
const at = beforeId ? ids.indexOf(beforeId) : -1;
if (at < 0) {
const group = items.find((item) => item.id === id)?.group ?? "tools";
const lastInBand = items
.map((item, index) => ({ item, index }))
.filter(({ item }) => item.id !== id && (item.group ?? "tools") === group)
.map(({ item }) => ids.indexOf(item.id))
.pop();
ids.splice(lastInBand === undefined ? ids.length : lastInBand + 1, 0, id);
} else {
ids.splice(at, 0, id);
}
return ids;
}
/** Places outside the rail that still need a title, like the Settings tab. */
export const RAIL_UTILITY_ITEMS: RailItemDef[] = [
{ id: "user-profile", icon: User, labelKey: "nav.userProfile" },
{ id: "admin-settings", icon: Settings, labelKey: "nav.admin" },
{ id: "settings", icon: Settings, labelKey: "nav.settings" },
{ id: "host-manager", icon: LibraryBig, labelKey: "nav.manage" },
];
/** Ids that may be opened in the right dock. */
@@ -19,7 +19,7 @@ export function FolderActions({
const actionButtonClass =
"flex items-center justify-center size-6 text-muted-foreground/60 hover:text-foreground hover:bg-background/80 transition-colors";
return (
<span className="flex items-center gap-0.5 ml-0.5 opacity-0 group-hover/folder:opacity-100 transition-opacity">
<span className="hidden items-center gap-0.5 ml-0.5 group-hover/folder:flex group-focus-within/folder:flex [@media(hover:none)]:flex">
<button
type="button"
title={t("hosts.openAllSessions")}
+13 -2
View File
@@ -168,7 +168,18 @@ export function FolderItem({
style={depth > 0 ? { paddingLeft: rem(depth * 12) } : undefined}
>
<div className="relative">
<button
{/* A div, not a button: the folder actions inside are buttons. */}
<div
role="button"
tabIndex={0}
aria-expanded={isOpen}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (!query) onToggleFolder(folderPath);
}
}}
draggable={canDragFolder}
onDragStart={(e) => {
if (!canDragFolder) return;
@@ -294,7 +305,7 @@ export function FolderItem({
)}
</>
}
</button>
</div>
</div>
{!flat && isOpen && (
<div className="border-l border-border/50 ml-[27px]">
+52 -24
View File
@@ -1,4 +1,6 @@
/* eslint-disable react-refresh/only-export-components */
import { actionOrder } from "@/sidebar/host-contributions";
import { showsInBar, useBarActions } from "@/sidebar/tree/host-bar-actions";
import { rem } from "@/lib/rem";
import { enabledHostProtocols } from "@/sidebar/host-protocols";
import { useEffect, useRef, useState, type MouseEvent } from "react";
@@ -39,10 +41,12 @@ import {
import { toast } from "sonner";
import { getHostPassword } from "@/main-axios";
import type { Host, TabType } from "@/types/ui-types";
import type {
HostDensity,
HostClickBehavior,
HostTrayTrigger,
import {
defaultHostRowFields,
type HostDensity,
type HostClickBehavior,
type HostRowFields,
type HostTrayTrigger,
} from "@/types/host-sidebar-preferences";
import { copyToClipboard } from "@/lib/clipboard";
import {
@@ -116,11 +120,6 @@ export function buildStatusTooltip(
return `${protocols.join(", ")}: ${statusLabel}`;
}
/** Plugin actions sort by order; connect actions default to the end. */
function actionOrder(action: HostActionDef): number {
return action.order ?? (action.kind === "connect" ? 100 : 50);
}
async function writeClipboardText(value: string): Promise<void> {
await copyToClipboard(value);
}
@@ -146,6 +145,26 @@ function canCopyHostSudoPassword(host: Host): boolean {
*/
const DOUBLE_CLICK_WINDOW_MS = 250;
const DEFAULT_ROW_FIELDS = defaultHostRowFields();
export function formatHostAddress(
host: Pick<Host, "username" | "ip" | "port"> &
Partial<Pick<Host, "enableSsh" | "pluginSettings" | "protocolAuth">>,
fields: Pick<HostRowFields, "showUsername" | "showPort">,
): string {
const protocol =
host.enableSsh === false ? enabledHostProtocols(host)[0] : undefined;
const username =
host.enableSsh === false
? protocol
? host.protocolAuth?.[protocol.id]?.username
: undefined
: host.username;
const user = fields.showUsername && username ? `${username}@` : "";
const port = fields.showPort && host.port ? `:${host.port}` : "";
return `${user}${host.ip}${port}`;
}
const HOST_ITEM_DENSITY_TOKENS = {
comfortable: {
rowPadding: "pl-[8.75px] pr-[7px] py-[7px]",
@@ -191,6 +210,7 @@ export function HostItem({
hostClickBehavior = "newTab",
showResourceBars = true,
showStatusStripes = true,
rowFields = DEFAULT_ROW_FIELDS,
rowActions = "full",
arrangeMode = false,
isDragging = false,
@@ -242,6 +262,7 @@ export function HostItem({
showResourceBars?: boolean;
/** Preset-driven: hides the per-row status color stripe. */
showStatusStripes?: boolean;
rowFields?: HostRowFields;
/** "essential" trims the row's management actions to the common few. */
rowActions?: "essential" | "full";
/** When true (rearranging unlocked), the row can be dragged: its edges
@@ -275,6 +296,7 @@ export function HostItem({
const allowDelete = canDeleteHost(host);
const allHostActions = useHostActions();
const pluginActions = hostActionsFor(allHostActions, host);
const barActions = useBarActions();
const badges = hostBadgesFor(useHostBadges(), host);
const pluginMenuItems = hostMenuItemsFor(useHostContextMenuItems(), host);
const splitTargets = useSplitTargets();
@@ -322,6 +344,7 @@ export function HostItem({
const densityTokens = HOST_ITEM_DENSITY_TOKENS[density];
const tokens = {
...densityTokens,
showAddressRow: densityTokens.showAddressRow && rowFields.showAddress,
showTagsRow: densityTokens.showTagsRow && showTags,
showResourceRow: densityTokens.showResourceRow && showResourceBars,
};
@@ -421,7 +444,7 @@ export function HostItem({
icon: action.icon as typeof Terminal,
label: action.label?.(host) ?? t(action.titleKey),
tabType: action.tabType,
tray: action.tray !== false,
tray: showsInBar(barActions, action),
items:
items && items.length > 1
? items.map((item) => ({
@@ -773,7 +796,12 @@ export function HostItem({
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
writeClipboardText(`${host.username}@${host.ip}`);
writeClipboardText(
formatHostAddress(host, {
showUsername: true,
showPort: false,
}),
);
toast.success(t("hosts.copiedToClipboard"));
}}
>
@@ -1125,14 +1153,15 @@ export function HostItem({
</TooltipContent>
</Tooltip>
</TooltipProvider>
{host.pin && (
{rowFields.showPinIcon && host.pin && (
<Pin className="size-2.5 text-accent-brand/50 shrink-0" />
)}
{badges.map((badge) => {
const Badge = badge.component;
return <Badge key={badge.id} host={host} />;
})}
{host.isShared && (
{rowFields.showBadges &&
badges.map((badge) => {
const Badge = badge.component;
return <Badge key={badge.id} host={host} />;
})}
{rowFields.showSharedBadge && host.isShared && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
@@ -1168,12 +1197,12 @@ export function HostItem({
+{host.tags!.length - 2}
</span>
)}
{isCompact && (
{isCompact && rowFields.showAddress && (
<span
className="text-[11px] text-muted-foreground/70 truncate leading-none ml-auto max-w-[50%] shrink-0"
title={host.ip}
>
{host.ip}
{formatHostAddress(host, { ...rowFields, showUsername: false })}
</span>
)}
{isCompact && !selectionMode && (
@@ -1213,10 +1242,9 @@ export function HostItem({
)}
</div>
{/* Address — always visible in comfortable density */}
{tokens.showAddressRow && (
<span className="text-[11px] text-muted-foreground/60 truncate leading-none font-mono">
{host.username}@{host.ip}
{formatHostAddress(host, rowFields)}
</span>
)}
@@ -1254,7 +1282,7 @@ export function HostItem({
</div>
)}
{/* Action tray — slides open on hover (default) or via chevron in click-tray mode */}
{/* Action tray, slides open on hover (default) or via chevron in click-tray mode */}
{!isCompact && (
<div className={trayVisibilityClass}>
{tokens.showResourceRow &&
@@ -1267,7 +1295,7 @@ export function HostItem({
<Cpu className="size-2.5 shrink-0 text-muted-foreground/40" />
<div className="w-9 h-1 bg-muted-foreground/15 rounded-full overflow-hidden">
<div
className={`motion-meter h-full rounded-full ${host.cpu > 80 ? "bg-red-400" : host.cpu > 50 ? "bg-yellow-400" : "bg-accent-brand"}`}
className={`motion-meter h-full rounded-full ${host.cpu > 80 ? "bg-red-400" : host.cpu > 50 ? "bg-warning" : "bg-accent-brand"}`}
style={{ width: `${host.cpu}%` }}
/>
</div>
@@ -1281,7 +1309,7 @@ export function HostItem({
<MemoryStick className="size-2.5 shrink-0 text-muted-foreground/40" />
<div className="w-9 h-1 bg-muted-foreground/15 rounded-full overflow-hidden">
<div
className={`motion-meter h-full rounded-full ${host.ram > 80 ? "bg-red-400" : host.ram > 60 ? "bg-yellow-400" : "bg-accent-brand/60"}`}
className={`motion-meter h-full rounded-full ${host.ram > 80 ? "bg-red-400" : host.ram > 60 ? "bg-warning" : "bg-accent-brand/60"}`}
style={{ width: `${host.ram}%` }}
/>
</div>
+28 -33
View File
@@ -1,3 +1,4 @@
import { useConfirm } from "@/components/surface/surface-scope";
import { useHostSpeedSearch } from "./hooks/useHostSpeedSearch";
import {
useCallback,
@@ -49,6 +50,7 @@ import type { SSHHostData } from "@/types/index";
import type {
HostDensity,
HostClickBehavior,
HostRowFields,
HostTrayTrigger,
} from "@/types/host-sidebar-preferences";
import { resolveHostTabType } from "@/lib/host-connection-tabs";
@@ -92,6 +94,7 @@ export function SidebarTree({
openOnDoubleClick = false,
showFolderPaths = true,
hostClickBehavior = "newTab",
rowFields,
}: {
children: (Host | HostFolder)[];
onOpenTab: (
@@ -119,6 +122,7 @@ export function SidebarTree({
/** When false, nested folders hide the parent-path breadcrumb before their name. */
showFolderPaths?: boolean;
hostClickBehavior?: HostClickBehavior;
rowFields?: HostRowFields;
}) {
const { t } = useTranslation();
const hostSettingPlugins = usePluginHostSections();
@@ -179,10 +183,15 @@ export function SidebarTree({
useEffect(() => {
if (!selectionMode) setSelectedHostIds(new Set());
}, [selectionMode, setSelectedHostIds]);
const [confirmDialog, setConfirmDialog] = useState<{
message: string;
onConfirm: () => Promise<void> | void;
} | null>(null);
const confirm = useConfirm();
const setConfirmDialog = (
request: { message: string; onConfirm: () => Promise<void> | void } | null,
) => {
if (!request) return;
void confirm({ title: request.message }).then((ok) => {
if (ok) void request.onConfirm();
});
};
const { draggedHostIds, setDraggedHostIds, rootDragOver, setRootDragOver } =
useSidebarDragState();
const [folderDialog, setFolderDialog] = useState<{
@@ -608,7 +617,7 @@ export function SidebarTree({
pin: host.pin ?? false,
notes: host.notes,
// Key material is never sent to the frontend, so a cloned key-auth
// host would have authType "key" with no key — unusable. Reset to
// host would have authType "key" with no key, which is unusable. Reset to
// password so the clone is in a connectable (editable) state.
authType: host.authType === "key" ? "password" : host.authType,
password: host.authType === "key" ? null : (host.password ?? null),
@@ -747,6 +756,8 @@ export function SidebarTree({
// Tag pills are a separate flex row. Comfortable density adds the row plus
// its gap; compact density pulls it upward by 2px but still needs a slot.
const TAG_ROW_EXTRA = isCompactDensity ? 12.5 : 18.5;
// The username@ip line plus its gap.
const ADDRESS_ROW_SAVED = rowFields && !rowFields.showAddress ? 14.5 : 0;
// showResourceBars:false simply drops RESOURCE_ROW_EXTRA from every row.
const rowHeight = useCallback(
@@ -755,7 +766,7 @@ export function SidebarTree({
if (!row) return FOLDER_ROW_HEIGHT;
if (isFolder(row.item)) return FOLDER_ROW_HEIGHT;
if (isCompactDensity) return 29;
const tagExtra = showTags && row.item.tags?.length ? TAG_ROW_EXTRA : 0;
const tagRowExtra = showTags && row.item.tags?.length ? TAG_ROW_EXTRA : 0;
// The resource bars only render for an online host that reported CPU/RAM.
// Reserving their height unconditionally left a gap under every offline
// row. measureElement corrects any drift from live status this estimate
@@ -767,6 +778,7 @@ export function SidebarTree({
)
? RESOURCE_ROW_EXTRA
: 0;
const tagExtra = tagRowExtra - ADDRESS_ROW_SAVED;
if (alwaysShowActions)
return ALWAYS_ROW_HEIGHT + tagExtra + resourceExtra;
const toggledOpen =
@@ -812,6 +824,7 @@ export function SidebarTree({
ACTIONS_ONLY_ROW_HEIGHT,
ACTIONS_ONLY_OPEN_ROW_HEIGHT,
TAG_ROW_EXTRA,
ADDRESS_ROW_SAVED,
showResourceBars,
isCompactDensity,
],
@@ -881,7 +894,14 @@ export function SidebarTree({
parentRef.current
?.querySelectorAll<HTMLElement>("[data-index]")
.forEach((element) => virtualizer.measureElement(element));
}, [virtualizer, density, trayTrigger, showTags, showResourceBars]);
}, [
virtualizer,
density,
trayTrigger,
showTags,
showResourceBars,
ADDRESS_ROW_SAVED,
]);
useEffect(() => {
if (speedSearch.open) speedSearch.inputRef.current?.focus();
@@ -1162,6 +1182,7 @@ export function SidebarTree({
hostClickBehavior={hostClickBehavior}
showResourceBars={showResourceBars}
showStatusStripes={showStatusStripes}
rowFields={rowFields}
rowActions={rowActions}
arrangeMode={arrangeMode}
isDragging={draggedReorderKey === `host:${item.id}`}
@@ -1437,32 +1458,6 @@ export function SidebarTree({
</div>
)}
{/* Confirm dialog */}
{confirmDialog && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<div className="bg-popover border border-border shadow-xl w-full max-w-xs flex flex-col gap-4 p-4">
<p className="text-sm text-foreground">{confirmDialog.message}</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setConfirmDialog(null)}
className="px-3 py-1.5 text-xs border border-border text-muted-foreground hover:text-foreground hover:bg-muted rounded transition-colors"
>
{t("hosts.cancelBtn")}
</button>
<button
onClick={() => {
confirmDialog.onConfirm();
setConfirmDialog(null);
}}
className="px-3 py-1.5 text-xs bg-destructive text-destructive-foreground hover:bg-destructive/90 rounded transition-colors"
>
{t("hosts.deleteConfirmBtn")}
</button>
</div>
</div>
</div>
)}
<FolderMetadataDialog
open={folderDialog !== null}
mode={folderDialog?.mode ?? "create"}
+39
View File
@@ -0,0 +1,39 @@
import { useSyncExternalStore } from "react";
/**
* Which host actions the user put in or took out of the row's connect bar.
* The hosts panel owns the preference and mirrors it here, so every row can
* read it without its own fetch.
*/
let current: Record<string, boolean> = {};
const listeners = new Set<() => void>();
export function setBarActions(next: Record<string, boolean>): void {
const same =
Object.keys(next).length === Object.keys(current).length &&
Object.entries(next).every(([id, value]) => current[id] === value);
if (same) return;
current = next;
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useBarActions(): Record<string, boolean> {
return useSyncExternalStore(
subscribe,
() => current,
() => current,
);
}
/** Whether an action shows in the bar: the user's choice, else its own. */
export function showsInBar(
barActions: Record<string, boolean>,
action: { id: string; tray?: boolean },
): boolean {
return barActions[action.id] ?? action.tray !== false;
}