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

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

No files matched your search

+4 -3
View File
@@ -14,6 +14,7 @@ import { pluginKey } from "@/lib/plugin-i18n";
import { PluginIcon } from "@/lib/plugin-icon";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PanelSearch } from "@/components/panel-layout";
import {
Activity,
Check,
@@ -151,11 +152,11 @@ export function AdminUsersSection({
</div>
</div>
<div className="py-2 border-b border-border">
<Input
<PanelSearch
value={search}
onChange={(e) => onSearchChange(e.target.value)}
onChange={onSearchChange}
placeholder={t("admin.searchUsers")}
className="h-7 text-xs rounded-none"
fill
/>
</div>
{users.map((user) => {
+3 -2
View File
@@ -1,3 +1,4 @@
import { Select2 } from "@/components/select2";
import { useConfirm } from "@/components/surface/surface-scope";
import { useEffect, useState } from "react";
import { hostProtocolFlags, type HostProtocols } from "./host-protocols";
@@ -943,7 +944,7 @@ export function AdminUserManagePanel({
</span>
<label className="flex flex-col gap-1 text-[10px] text-muted-foreground">
{t("admin.deleteSuccessorLabel")}
<select
<Select2
value={successor}
onChange={(e) => setSuccessor(e.target.value)}
className="h-7 border border-border bg-background px-2 text-xs text-foreground outline-none"
@@ -957,7 +958,7 @@ export function AdminUserManagePanel({
<option value="none">
{t("admin.deleteSuccessorNone")}
</option>
</select>
</Select2>
<span>{t("admin.deleteSuccessorDesc")}</span>
</label>
<Button
+39 -1
View File
@@ -16,10 +16,12 @@ import { Skeleton } from "@/components/skeleton";
import { readRailPreference, setRailPreference } from "./rail-preferences";
import {
groupRailItems,
moveRailItem,
useRailItems,
type RailGroup,
type RailItemDef,
} from "./rail-items";
import { useUiPreferencesContext } from "@/contexts/UiPreferencesContext";
import { toggleHiddenRailTab, useHiddenRailTabs } from "./hidden-rail-tabs";
import { RailBadge } from "./RailBadge";
import { rem } from "@/lib/rem";
@@ -200,6 +202,9 @@ export function AppRail({
// Plugins add and remove rail items at runtime, and permissions hide some.
const railItems = useRailItems();
const uiPrefs = useUiPreferencesContext();
const [dragId, setDragId] = useState<string | null>(null);
const [dropBefore, setDropBefore] = useState<string | null>(null);
const railExpanded = pinned || (expandOnHover && hovered) || managing;
const mainItems = railItems.filter((item) => item.placement !== "footer");
@@ -226,15 +231,48 @@ export function AppRail({
setMenuPos(null);
};
const sameBand = (a: string, b: string) =>
(mainItems.find((item) => item.id === a)?.group ?? "tools") ===
(mainItems.find((item) => item.id === b)?.group ?? "tools");
const renderItem = (item: RailItemDef) => {
const Icon = item.icon;
const title = t(item.labelKey);
const isTab = item.kind === "tab";
const active = !isTab && sidebarOpen && railView === item.id;
const reorderable = item.placement !== "footer" && !!uiPrefs;
const dropHere =
dragId !== null && dropBefore === item.id && dragId !== item.id;
return (
<button
key={item.id}
type="button"
draggable={reorderable}
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "move";
setDragId(item.id);
}}
onDragEnd={() => {
setDragId(null);
setDropBefore(null);
}}
onDragOver={(e) => {
if (!dragId || dragId === item.id || !sameBand(dragId, item.id))
return;
e.preventDefault();
setDropBefore(item.id);
}}
onDrop={(e) => {
if (!dragId || !sameBand(dragId, item.id)) return;
e.preventDefault();
uiPrefs?.setOverride(
"rail",
"order",
moveRailItem(mainItems, dragId, item.id),
);
setDragId(null);
setDropBefore(null);
}}
onClick={(e) => {
if (isTab || (item.promotable && (e.ctrlKey || e.metaKey))) {
onOpenTab?.(item.id as TabType);
@@ -260,7 +298,7 @@ export function AppRail({
aria-current={active ? "page" : undefined}
title={item.promotable ? `${title}\n${t("nav.openAsTabHint")}` : title}
style={btnStyle}
className={`${btnBase} ${active ? activeClass : idle}`}
className={`${btnBase} ${active ? activeClass : idle} ${dragId === item.id ? "opacity-40" : ""} ${dropHere ? "relative before:absolute before:inset-x-1 before:-top-0.5 before:h-0.5 before:bg-accent-brand" : ""}`}
>
<RailIcon useBadge={item.useBadge}>
<Icon size={16} />
+110 -185
View File
@@ -3,7 +3,7 @@ import { getTabType, isPersistentTabType } from "@/shell/tab-registry";
import { ComponentSlot } from "@/shell/ActionSlot";
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Plug, Search, X, Pencil, Check } from "lucide-react";
import { ExternalLink, Plug, X, Pencil, Check } from "lucide-react";
import {
getActiveSessions,
deleteOpenTab,
@@ -13,14 +13,9 @@ import {
import { tabIcon } from "@/shell/tabUtils";
import { getSessionTimeoutMinutes } from "@/api/open-tabs-api";
import type { Tab, TabType } from "@/types/ui-types";
import { Badge } from "@/components/badge";
import { Input } from "@/components/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip";
import { EmptyState } from "@/components/empty-state";
import { Facts, GroupHeading, PanelSearch } from "@/components/panel-layout";
import { ListBadge, ListRow, ListRowAction } from "@/components/list-kit";
import { usePageVisibleInterval } from "@/hooks/use-page-visible-interval";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling";
@@ -81,6 +76,7 @@ function ConnectionRow({
hostName,
subLabel,
icon,
stripe,
onSwitch,
onClose,
switchTitle,
@@ -95,6 +91,7 @@ function ConnectionRow({
hostName?: string;
subLabel: string;
icon: React.ReactNode;
stripe: number;
onSwitch?: () => void;
onClose: () => void;
switchTitle?: string;
@@ -106,12 +103,6 @@ function ConnectionRow({
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(name);
function startEdit(e: React.MouseEvent) {
e.stopPropagation();
setEditValue(name);
setEditing(true);
}
function commitEdit() {
const trimmed = editValue.trim();
if (trimmed && trimmed !== name && onRename) {
@@ -120,155 +111,103 @@ function ConnectionRow({
setEditing(false);
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter") commitEdit();
if (e.key === "Escape") setEditing(false);
}
const registered = getTabType(tabType)?.titleKey;
const typeLabel =
CORE_TYPE_LABELS[tabType] ?? (registered ? t(registered) : tabType);
const stop = (e: React.PointerEvent) => e.stopPropagation();
return (
<div
role={onSwitch && !editing ? "button" : undefined}
tabIndex={onSwitch && !editing ? 0 : undefined}
<ListRow
stripe={stripe}
tone={isLive ? "success" : "muted"}
selected={isActive}
active={editing}
dimmed={faded || isDragging}
onClick={!editing ? onSwitch : undefined}
onKeyDown={(e) => !editing && e.key === "Enter" && onSwitch?.()}
className={`group flex items-center gap-2.5 px-3 py-2.5 border-b border-border/40 transition-colors last:border-b-0 ${
faded ? "opacity-60" : ""
} ${isDragging ? "opacity-30" : ""} ${
isActive
? "bg-accent-brand/8 cursor-pointer border-l-2 border-l-accent-brand"
: onSwitch && !editing
? "hover:bg-muted/40 cursor-pointer"
: ""
}`}
>
<div
className={`shrink-0 flex items-center justify-center size-7 rounded ${
isActive
? "bg-accent-brand/15 text-accent-brand"
: "bg-muted/60 text-muted-foreground"
}`}
>
{icon}
</div>
<div className="flex flex-col flex-1 min-w-0 gap-0.5">
<div className="flex items-center gap-1.5 min-w-0">
<span
className={`shrink-0 size-1.5 rounded-full ${
isLive ? "bg-green-500" : "bg-muted-foreground/30"
}`}
icon={icon}
title={
editing ? (
<input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") commitEdit();
if (e.key === "Escape") setEditing(false);
}}
onClick={(e) => e.stopPropagation()}
onPointerDown={stop}
className="w-full min-w-0 border-b border-accent-brand bg-transparent text-[13px] font-semibold text-foreground outline-none"
autoFocus
/>
{editing ? (
<input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={commitEdit}
onKeyDown={handleKeyDown}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
className="text-xs font-semibold flex-1 min-w-0 bg-transparent border-b border-accent-brand outline-none text-foreground"
autoFocus
/>
) : (
<span
className={`text-xs font-semibold truncate flex-1 ${
isActive ? "text-accent-brand" : "text-foreground"
}`}
>
{name}
</span>
)}
<Badge
variant="outline"
className="text-[9px] px-1 py-0 h-4 font-mono shrink-0 text-muted-foreground/60 border-border/60"
) : (
name
)
}
badges={<ListBadge className="ml-auto font-mono">{typeLabel}</ListBadge>}
meta={
<Facts>
{hostName && hostName !== name ? <span>{hostName}</span> : null}
<span>{subLabel}</span>
</Facts>
}
actions={
editing ? (
<ListRowAction
label={t("common.save")}
tone="brand"
onPointerDown={stop}
onClick={commitEdit}
>
{CORE_TYPE_LABELS[tabType] ??
(getTabType(tabType)?.titleKey
? t(getTabType(tabType)!.titleKey!)
: tabType)}
</Badge>
</div>
<span className="text-[10px] text-muted-foreground/60 truncate pl-3">
{hostName && hostName !== name ? (
<span className="text-muted-foreground/50 mr-2.5">{hostName}</span>
) : null}
{subLabel}
</span>
</div>
<TooltipProvider>
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{editing ? (
<button
onClick={(e) => {
e.stopPropagation();
commitEdit();
}}
className="size-6 flex items-center justify-center text-accent-brand hover:bg-muted/60 rounded transition-colors"
<Check />
</ListRowAction>
) : (
<>
{onRename && (
<ListRowAction
label={t("connections.rename")}
onPointerDown={stop}
onClick={() => {
setEditValue(name);
setEditing(true);
}}
>
<Pencil />
</ListRowAction>
)}
{switchTitle && onSwitch && (
<ListRowAction
label={switchTitle}
tone="brand"
onPointerDown={stop}
onClick={onSwitch}
>
<ExternalLink />
</ListRowAction>
)}
<ListRowAction
label={t("common.close")}
tone="destructive"
onPointerDown={stop}
onClick={onClose}
>
<Check className="size-3" />
</button>
) : (
onRename && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={startEdit}
onPointerDown={(e) => e.stopPropagation()}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 rounded transition-colors"
>
<Pencil className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{t("connections.rename")}
</TooltipContent>
</Tooltip>
)
)}
{switchTitle && onSwitch && !editing && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
onSwitch();
}}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-foreground hover:bg-muted/60 rounded transition-colors"
>
<ExternalLink className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent side="left">{switchTitle}</TooltipContent>
</Tooltip>
)}
{!editing && (
<button
onClick={(e) => {
e.stopPropagation();
onClose();
}}
className="size-6 flex items-center justify-center text-muted-foreground/50 hover:text-destructive hover:bg-destructive/10 rounded transition-colors"
>
<X className="size-3" />
</button>
)}
</div>
</TooltipProvider>
</div>
<X />
</ListRowAction>
</>
)
}
/>
);
}
function SectionHeader({ label, count }: { label: string; count: number }) {
return (
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/20">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground/60 flex-1">
{label}
</span>
<span className="text-[10px] font-semibold text-muted-foreground/40 bg-muted/60 rounded px-1.5 py-0.5">
{count}
</span>
</div>
<GroupHeading
title={label}
count={count}
className="border-b border-border/40 px-3 pb-1.5 pt-2.5"
/>
);
}
@@ -450,42 +389,28 @@ export function ConnectionsPanel({
return (
<div className="flex flex-col flex-1">
{pluginSections}
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center py-16">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<Plug className="size-5 text-muted-foreground/30" />
</div>
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-muted-foreground/60">
{t("connections.noConnections")}
</span>
<span className="text-xs text-muted-foreground/40">
{t("connections.noConnectionsDesc")}
</span>
</div>
</div>
<EmptyState
icon={Plug}
title={t("connections.noConnections")}
hint={t("connections.noConnectionsDesc")}
className="flex-1"
/>
</div>
);
}
return (
<div className="flex flex-col">
<div className="relative px-3 py-2 border-b border-border/60">
<Search className="absolute left-5.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground/50 pointer-events-none" />
<Input
placeholder={t("connections.search")}
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<PanelSearch
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 h-7 text-xs"
onChange={setSearch}
placeholder={t("connections.search")}
fill
/>
</div>
{!hasResults && (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-center">
<span className="text-xs text-muted-foreground/50">
{t("connections.noSearchResults")}
</span>
</div>
)}
{!hasResults && <EmptyState title={t("connections.noSearchResults")} />}
{filteredOpenTabs.length > 0 && (
<div className="flex flex-col">
@@ -493,7 +418,7 @@ export function ConnectionsPanel({
label={t("connections.sectionOpen")}
count={filteredOpenTabs.length}
/>
{filteredOpenTabs.map((tab) => {
{filteredOpenTabs.map((tab, index) => {
const isActive = tab.id === activeTabId;
const liveSession = tab.instanceId
? sessionByInstanceId.get(tab.instanceId)
@@ -524,7 +449,7 @@ export function ConnectionsPanel({
didDragRef.current = false;
setDragTabId(tab.id);
}}
className={`relative ${isDropTarget ? "border-t-2 border-accent-brand" : ""}`}
className={`relative ${isDropTarget ? "before:absolute before:inset-x-0 before:-top-px before:z-20 before:h-0.5 before:bg-accent-brand" : ""}`}
style={{
cursor: dragTabId
? isDraggingThis
@@ -534,6 +459,7 @@ export function ConnectionsPanel({
}}
>
<ConnectionRow
stripe={index}
isActive={isActive}
isLive={isLive}
tabType={tab.type}
@@ -565,9 +491,7 @@ export function ConnectionsPanel({
)}
{filteredBackgroundTabs.length > 0 && (
<div
className={`flex flex-col ${filteredOpenTabs.length > 0 ? "mt-2" : ""}`}
>
<div className="flex flex-col">
<SectionHeader
label={t("connections.sectionBackground")}
count={filteredBackgroundTabs.length}
@@ -579,7 +503,7 @@ export function ConnectionsPanel({
})}
</span>
</div>
{filteredBackgroundTabs.map((record) => {
{filteredBackgroundTabs.map((record, index) => {
const host = record.hostId
? allHosts.find((h) => h.id === String(record.hostId))
: undefined;
@@ -588,6 +512,7 @@ export function ConnectionsPanel({
return (
<ConnectionRow
key={record.id}
stripe={index}
isLive={false}
faded
tabType={record.tabType}
@@ -1,3 +1,4 @@
import { Select2 } from "@/components/select2";
import { InlineView } from "@/components/surface/surface-scope";
import { useTranslation } from "react-i18next";
import { Rows3, SquareStack } from "lucide-react";
@@ -47,7 +48,7 @@ export function CustomizeCredentialsSidebarPanel({
: t("credentials.densityCompactDesc")
}
>
<select
<Select2
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
@@ -66,7 +67,7 @@ export function CustomizeCredentialsSidebarPanel({
<option value="compact">
{t("credentials.displayDensityCompact")}
</option>
</select>
</Select2>
</SettingRow>
</SectionCard>
@@ -97,7 +98,7 @@ export function CustomizeCredentialsSidebarPanel({
{t("credentials.actionsVisibilityDesc")}
</span>
</div>
<select
<Select2
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
@@ -116,7 +117,7 @@ export function CustomizeCredentialsSidebarPanel({
</option>
<option value="hover">{t("credentials.actionsHover")}</option>
<option value="click">{t("credentials.actionsClick")}</option>
</select>
</Select2>
</div>
</SectionCard>
+56 -7
View File
@@ -1,6 +1,9 @@
import { Select2 } from "@/components/select2";
import { InlineView } from "@/components/surface/surface-scope";
import { useTranslation } from "react-i18next";
import { Rows3, SquareStack } from "lucide-react";
import { PanelTop, Rows3, SquareStack } from "lucide-react";
import { actionOrder, useHostActions } from "./host-contributions";
import { showsInBar } from "./tree/host-bar-actions";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import type {
HostClickBehavior,
@@ -25,6 +28,9 @@ export function CustomizeSidebarPanel({
) => void;
}) {
const { t } = useTranslation();
const actions = [...useHostActions()].sort(
(a, b) => actionOrder(a) - actionOrder(b) || a.id.localeCompare(b.id),
);
return (
<InlineView
@@ -48,7 +54,7 @@ export function CustomizeSidebarPanel({
: t("hosts.densityCompactDesc")
}
>
<select
<Select2
value={preferences.display.density}
onChange={(e) =>
update((prev) => ({
@@ -67,7 +73,7 @@ export function CustomizeSidebarPanel({
<option value="compact">
{t("hosts.displayDensityCompact")}
</option>
</select>
</Select2>
</SettingRow>
</SectionCard>
@@ -112,7 +118,7 @@ export function CustomizeSidebarPanel({
{t("hosts.actionsVisibilityDesc")}
</span>
</div>
<select
<Select2
value={preferences.display.trayTrigger}
onChange={(e) =>
update((prev) => ({
@@ -129,7 +135,7 @@ export function CustomizeSidebarPanel({
<option value="actionsOnly">{t("hosts.actionsOnly")}</option>
<option value="hover">{t("hosts.actionsHover")}</option>
<option value="click">{t("hosts.actionsClick")}</option>
</select>
</Select2>
</div>
<div className="flex flex-col gap-1.5 py-3 border-b border-border last:border-0">
<div className="flex flex-col gap-0.5">
@@ -140,7 +146,7 @@ export function CustomizeSidebarPanel({
{t("hosts.hostClickBehaviorDesc")}
</span>
</div>
<select
<Select2
value={preferences.display.hostClickBehavior}
onChange={(e) =>
update((prev) => ({
@@ -160,7 +166,7 @@ export function CustomizeSidebarPanel({
<option value="focusExistingDoubleClickNew">
{t("hosts.hostClickFocusExistingDoubleNew")}
</option>
</select>
</Select2>
</div>
<SettingRow
label={t("hosts.openOnDoubleClick")}
@@ -199,6 +205,49 @@ export function CustomizeSidebarPanel({
</SettingRow>
</SectionCard>
{actions.length > 0 && (
<SectionCard
title={t("hosts.connectBarTitle")}
icon={<PanelTop className="size-3.5" />}
>
<p className="pt-2 text-xs text-muted-foreground">
{t("hosts.connectBarDesc")}
</p>
{actions.map((action) => {
const Icon = action.icon;
return (
<SettingRow
key={action.id}
label={t(action.titleKey)}
rowId={`bar-action-${action.id}`}
>
<span className="flex items-center gap-2">
<Icon className="size-3.5 text-muted-foreground" />
<FakeSwitch
checked={showsInBar(
preferences.display.barActions,
action,
)}
onChange={(v) =>
update((prev) => ({
...prev,
display: {
...prev.display,
barActions: {
...prev.display.barActions,
[action.id]: v,
},
},
}))
}
/>
</span>
</SettingRow>
);
})}
</SectionCard>
)}
<p className="text-[11px] text-muted-foreground/70 leading-snug">
{t("hosts.customizeSortHint")}
</p>
+3 -2
View File
@@ -1,3 +1,4 @@
import { Select2 } from "@/components/select2";
import { InlineView } from "@/components/surface/surface-scope";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -174,7 +175,7 @@ export function FolderMetadataDialog({
<label className="text-xs font-semibold">
{t("hosts.folderCredential")}
</label>
<select
<Select2
value={credentialId}
onChange={(e) => setCredentialId(e.target.value)}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
@@ -185,7 +186,7 @@ export function FolderMetadataDialog({
{c.username ? `${c.name} (${c.username})` : c.name}
</option>
))}
</select>
</Select2>
<p className="text-[10px] text-muted-foreground">
{t("hosts.folderCredentialHint")}
</p>
+10 -22
View File
@@ -1,8 +1,9 @@
import { Select2 } from "@/components/select2";
import { FormFooter } from "@/components/list-kit";
import { InlineView } from "@/components/surface/surface-scope";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Button } from "@/components/button";
import {
getCredentials,
getHostAuthOverride,
@@ -127,25 +128,12 @@ export function HostAuthOverrideModal({
</>
}
footer={
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={saving}
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleSave}
disabled={
loading || loadError || saving || selectedId === initialId
}
>
{saving ? t("common.saving") : t("common.save")}
</Button>
</div>
<FormFooter
onCancel={() => onOpenChange(false)}
onSave={() => void handleSave()}
saving={saving}
disabled={loading || loadError || selectedId === initialId}
/>
}
>
<p className="text-xs text-muted-foreground">
@@ -172,7 +160,7 @@ export function HostAuthOverrideModal({
>
{t("hosts.sharing.authOverrideCredentialLabel")}
</label>
<select
<Select2
id={`auth-override-${host.id}`}
value={selectedId}
onChange={(event) => setSelectedId(event.target.value)}
@@ -192,7 +180,7 @@ export function HostAuthOverrideModal({
: credential.name}
</option>
))}
</select>
</Select2>
{credentials.length === 0 && (
<p className="text-[10px] text-muted-foreground">
{t("hosts.sharing.authOverrideNoCredentials")}
+2 -2
View File
@@ -809,7 +809,7 @@ export function HostEditorGeneralTab({
),
].join(" ")}
>
<select
<Select2
className="flex h-7 border border-border bg-background px-2 py-0 text-xs outline-none focus:ring-1 focus:ring-ring"
value={form.connectionOrigin ?? ""}
onChange={(e) =>
@@ -826,7 +826,7 @@ export function HostEditorGeneralTab({
<option value="remote">
{t("hosts.connectionOriginRemote")}
</option>
</select>
</Select2>
</SettingRow>
</HostOnly>
)}
+9 -15
View File
@@ -1,9 +1,10 @@
import { InlineView } from "@/components/surface/surface-scope";
import { FormFooter } from "@/components/list-kit";
import { getErrorMessage } from "../lib/error-message.js";
import { usePluginStore } from "@/plugin-host/plugin-store";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Download, Search } from "lucide-react";
import { Search } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Checkbox } from "@/components/checkbox";
@@ -220,20 +221,13 @@ export function HostExportDialog({
title={t("hosts.export.title")}
width="wide"
footer={
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
<Button variant="outline" onClick={onClose}>
{t("hosts.export.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleExport}
disabled={!canExport}
>
<Download className="size-3.5 mr-2" />
{t("hosts.export.confirm")}
</Button>
</div>
<FormFooter
onCancel={onClose}
cancelLabel={t("hosts.export.cancel")}
onSave={() => void handleExport()}
saveLabel={t("hosts.export.confirm")}
disabled={!canExport}
/>
}
>
<div className="flex flex-col md:flex-row gap-4 flex-1 min-h-0 min-w-0 overflow-y-auto md:overflow-visible">
+4
View File
@@ -1,4 +1,5 @@
import { getErrorMessage } from "../lib/error-message.js";
import { setBarActions } from "@/sidebar/tree/host-bar-actions";
import { enabledHostProtocols, useHostProtocols } from "./host-protocols";
import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -248,6 +249,9 @@ export function HostsPanel({
);
const { preferences: sidebarPrefs, update: updateSidebarPrefs } =
useHostSidebarPreferences();
useEffect(() => {
setBarActions(sidebarPrefs.display.barActions);
}, [sidebarPrefs.display.barActions]);
const sortKey = sidebarPrefs.sort.key;
const pinnedFirst = sidebarPrefs.sort.pinnedFirst;
const { arrangeLocked, toggleArrangeLock } = useArrangeLock(
+88 -24
View File
@@ -1,11 +1,17 @@
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronUp, RotateCcw } from "lucide-react";
import { FakeSwitch } from "@/components/section-card";
import { useRailItems } from "./rail-items";
import { Button } from "@/components/button";
import {
useAreaPreferences,
useUiPreferencesContext,
} from "@/contexts/UiPreferencesContext";
import { moveRailItem, useRailItems } from "./rail-items";
/**
* Appearance > Sidebar > Navigation: one switch per rail destination,
* including the ones plugins register, which appear and disappear here as
* their plugin is turned on and off.
* their plugin is turned on and off. The arrows reorder the rail.
*/
export function NavigationVisibilityToggles({
hidden,
@@ -16,34 +22,92 @@ export function NavigationVisibilityToggles({
}) {
const { t } = useTranslation();
const railItems = useRailItems();
const uiPrefs = useUiPreferencesContext();
const { order } = useAreaPreferences("rail");
const main = railItems.filter((item) => item.placement !== "footer");
const band = (id: string) =>
main.find((item) => item.id === id)?.group ?? "tools";
const move = (id: string, direction: -1 | 1) => {
const peers = main.filter((item) => band(item.id) === band(id));
const at = peers.findIndex((item) => item.id === id);
const target = at + direction;
if (target < 0 || target >= peers.length) return;
// Moving down means sitting before the item after the next one.
const before =
direction === -1 ? peers[target].id : (peers[target + 1]?.id ?? null);
uiPrefs?.setOverride("rail", "order", moveRailItem(main, id, before));
};
return (
<>
{railItems
.filter((item) => item.hideable !== false)
.map((item) => (
<div
key={item.id}
className="flex items-center justify-between py-1.5"
data-testid={`nav-toggle-${item.id}`}
>
<span className="flex items-center gap-1.5 text-xs font-medium text-foreground">
<span className="text-muted-foreground">
<item.icon size={12} />
.map((item) => {
const reorderable = item.placement !== "footer" && !!uiPrefs;
const peers = main.filter((peer) => band(peer.id) === band(item.id));
const index = peers.findIndex((peer) => peer.id === item.id);
return (
<div
key={item.id}
className="flex items-center justify-between gap-2 py-1.5"
data-testid={`nav-toggle-${item.id}`}
>
<span className="flex min-w-0 items-center gap-1.5 text-xs font-medium text-foreground">
<span className="text-muted-foreground">
<item.icon size={12} />
</span>
<span className="truncate">{t(item.labelKey)}</span>
</span>
{t(item.labelKey)}
</span>
<FakeSwitch
checked={!hidden.has(item.id)}
onChange={(visible) => {
const next = new Set(hidden);
if (visible) next.delete(item.id);
else next.add(item.id);
onChange(next);
}}
/>
</div>
))}
<span className="flex shrink-0 items-center gap-1">
{reorderable && (
<>
<Button
variant="ghost"
size="icon-xs"
disabled={index <= 0}
title={t("nav.moveUp")}
aria-label={t("nav.moveUp")}
onClick={() => move(item.id, -1)}
>
<ChevronUp />
</Button>
<Button
variant="ghost"
size="icon-xs"
disabled={index < 0 || index >= peers.length - 1}
title={t("nav.moveDown")}
aria-label={t("nav.moveDown")}
onClick={() => move(item.id, 1)}
>
<ChevronDown />
</Button>
</>
)}
<FakeSwitch
checked={!hidden.has(item.id)}
onChange={(visible) => {
const next = new Set(hidden);
if (visible) next.delete(item.id);
else next.add(item.id);
onChange(next);
}}
/>
</span>
</div>
);
})}
{uiPrefs && order && order.length > 0 && (
<Button
variant="ghost"
size="xs"
className="mt-1 self-start text-muted-foreground"
onClick={() => uiPrefs.setOverride("rail", "order", null)}
>
<RotateCcw />
{t("nav.resetRailOrder")}
</Button>
)}
</>
);
}
+26 -36
View File
@@ -1,8 +1,10 @@
import { useEffect, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Eye, EyeOff } from "lucide-react";
import { toast } from "sonner";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { Textarea } from "@/components/textarea";
import { GroupHeading, PANEL } from "@/components/panel-layout";
import type { Host } from "@/types/ui-types";
import { getCredentials } from "@/api/credentials-api";
import { mapCredentials } from "./HostManagerData";
@@ -27,7 +29,7 @@ const INLINE_AUTH_TYPES = new Set(["password", "key", "credential"]);
const LABEL_CLASS =
"text-[10px] font-semibold uppercase tracking-widest text-muted-foreground";
const SELECT_CLASS =
"flex h-7 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring";
"flex h-8 w-full border border-border bg-background px-2.5 py-1 text-xs outline-none focus:ring-1 focus:ring-ring";
interface QuickConnectPanelProps {
onConnect: (host: Host, type: string) => void;
@@ -54,7 +56,6 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
const [authFields, setAuthFields] = useState<Record<string, unknown>>({});
const [password, setPassword] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [credentialId, setCredentialId] = useState("");
const [credentials, setCredentials] = useState<
{ id: string; name: string; username: string }[]
@@ -140,7 +141,8 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto">
<div className="flex flex-col gap-3 p-3">
<div className={`flex flex-col ${PANEL.gap} ${PANEL.body}`}>
<GroupHeading title={t("newUi.sidebar.quickConnect.where")} />
<div className="grid grid-cols-[1fr_5rem] gap-2">
<Field label={t("newUi.sidebar.quickConnect.protocolLabel")}>
<Select2
@@ -164,7 +166,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
value={port}
onChange={(e) => setPort(e.target.value)}
onKeyDown={onEnter}
className="h-7 text-xs"
className="h-8 text-xs"
/>
</Field>
</div>
@@ -174,9 +176,13 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
value={host}
onChange={(e) => setHost(e.target.value)}
onKeyDown={onEnter}
className="h-7 text-xs"
className="h-8 text-xs"
/>
</Field>
<GroupHeading
title={t("newUi.sidebar.quickConnect.who")}
className="pt-1"
/>
<Field label={t("newUi.sidebar.quickConnect.usernameLabel")}>
<Input
placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")}
@@ -189,7 +195,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
}}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={onEnter}
className="h-7 text-xs"
className="h-8 text-xs"
/>
</Field>
{!isDesktop && authOptions.length > 0 && (
@@ -212,29 +218,13 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
)}
{(isDesktop || authType === "password") && (
<Field label={t("newUi.sidebar.quickConnect.passwordLabel")}>
<div className="relative">
<Input
type={showPassword ? "text" : "password"}
placeholder={t(
"newUi.sidebar.quickConnect.passwordPlaceholder",
)}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={onEnter}
className="h-7 text-xs pr-8"
/>
<button
type="button"
onClick={() => setShowPassword((o) => !o)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showPassword ? (
<EyeOff className="size-3.5" />
) : (
<Eye className="size-3.5" />
)}
</button>
</div>
<PasswordInput
placeholder={t("newUi.sidebar.quickConnect.passwordPlaceholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
onKeyDown={onEnter}
className="h-8 pr-8 text-xs"
/>
</Field>
)}
{selected?.quickConnect?.showDomain && (
@@ -244,19 +234,19 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
value={domain}
onChange={(e) => setDomain(e.target.value)}
onKeyDown={onEnter}
className="h-7 text-xs"
className="h-8 text-xs"
/>
</Field>
)}
{!isDesktop && authType === "key" && (
<Field label={t("newUi.sidebar.quickConnect.privateKeyLabel")}>
<textarea
<Textarea
placeholder={t(
"newUi.sidebar.quickConnect.privateKeyPlaceholder",
)}
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
className="w-full h-24 px-2.5 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
className="h-24 resize-none font-mono text-xs"
/>
</Field>
)}
@@ -294,7 +284,7 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
}}
/>
)}
<div className="flex flex-col gap-1.5 pt-1">
<div className="flex flex-col gap-1.5 pt-2">
{targets.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("newUi.sidebar.quickConnect.noTarget")}
@@ -306,9 +296,9 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
type="button"
disabled={!host.trim()}
onClick={() => connect(action)}
className={`flex items-center justify-center gap-1.5 h-7 w-full border text-xs font-semibold transition-colors disabled:opacity-50 disabled:pointer-events-none ${
className={`flex h-8 w-full items-center justify-center gap-1.5 border text-xs font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 ${
index === 0
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand hover:bg-accent-brand/20"
? "border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10"
: "border-border text-foreground hover:bg-muted"
}`}
>
+37 -65
View File
@@ -1,4 +1,5 @@
import { InlineView, useConfirm } from "@/components/surface/surface-scope";
import { FormFooter } from "@/components/list-kit";
import { useState, useRef, useEffect } from "react";
import { AuthEnrollmentSections } from "./AuthEnrollmentSections";
import { useTranslation } from "react-i18next";
@@ -265,36 +266,20 @@ export function NewApiKeyDialog({
}}
title={t("newUi.sidebar.userProfile.createApiKeyTitle")}
footer={
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
{createdToken ? (
<Button
variant="outline"
className="rounded-none border-accent-brand/40 text-[10px] font-bold uppercase tracking-widest text-accent-brand"
onClick={close}
>
{t("newUi.sidebar.userProfile.done")}
</Button>
) : (
<>
<Button
variant="ghost"
onClick={close}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("newUi.sidebar.userProfile.cancel")}
</Button>
<Button
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest gap-1.5"
onClick={handleCreate}
disabled={creating}
>
<KeyRound className="size-3" />{" "}
{t("newUi.sidebar.userProfile.createKey")}
</Button>
</>
)}
</div>
createdToken ? (
<FormFooter
onSave={close}
saveLabel={t("newUi.sidebar.userProfile.done")}
/>
) : (
<FormFooter
onCancel={close}
cancelLabel={t("newUi.sidebar.userProfile.cancel")}
onSave={() => void handleCreate()}
saveLabel={t("newUi.sidebar.userProfile.createKey")}
saving={creating}
/>
)
}
>
<p className="text-xs text-muted-foreground">
@@ -1197,29 +1182,6 @@ export function UserProfilePanel({
userId={userId}
/>
{/* Donate banner */}
{shows("account") && (
<div className="border border-accent-brand/40 bg-accent-brand/10 px-3 py-2.5 flex flex-col gap-1.5">
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-accent-brand">
{t("newUi.sidebar.userProfile.donateTitle")}
</div>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t("newUi.sidebar.userProfile.donateDescription")}
</p>
<p className="text-[10px] text-muted-foreground leading-relaxed">
{t("newUi.sidebar.userProfile.donateMilestones")}
</p>
<a
href="https://donate.termix.site/"
target="_blank"
rel="noopener noreferrer"
className="self-start flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest bg-accent-brand text-white px-2 py-1 hover:opacity-90 transition-opacity"
>
{t("newUi.sidebar.userProfile.donateButton")}
</a>
</div>
)}
{/* Storage mode toggle — only meaningful once a remote server is
connected; with no sync there's nowhere for "cloud" to sync to,
so this stays forced to local storage and hidden. */}
@@ -1233,7 +1195,7 @@ export function UserProfilePanel({
onClick={() => handleStorageModeChange("local")}
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
storageMode === "local"
? "bg-accent-brand text-white"
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
}`}
>
@@ -1243,7 +1205,7 @@ export function UserProfilePanel({
onClick={() => handleStorageModeChange("cloud")}
className={`flex-1 py-1 text-[10px] font-bold uppercase tracking-widest transition-colors ${
storageMode === "cloud"
? "bg-accent-brand text-white"
? "bg-accent-brand/10 text-accent-brand"
: "text-muted-foreground hover:text-foreground hover:bg-muted/40"
}`}
>
@@ -1363,15 +1325,22 @@ export function UserProfilePanel({
)}
<div className="border-t border-border pt-3 mt-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.versionLabel")}
</span>
<div className="flex items-center justify-between mt-1.5">
<span className="text-sm font-bold text-accent-brand">
{version ? `v${version}` : "—"}
<div className="flex items-center gap-1.5">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.versionLabel")}
</span>
<VersionBadge status={versionStatus} releaseUrl={releaseUrl} />
<VersionBadge
status={versionStatus}
releaseUrl={releaseUrl}
className="shrink-0 px-1 py-px text-[9px]"
/>
</div>
<span
className="mt-1.5 block truncate text-sm font-bold text-accent-brand"
title={version ? `v${version}` : undefined}
>
{version ? `v${version}` : "-"}
</span>
</div>
<div className="border-t border-border pt-3 mt-3">
@@ -1528,7 +1497,7 @@ export function UserProfilePanel({
<Type className="size-3" />
{t("newUi.sidebar.userProfile.interfaceFontLabel")}
</span>
<select
<Select2
value={uiFont}
onChange={(event) =>
handleUiFontChange(event.target.value as UiFontId)
@@ -1540,7 +1509,7 @@ export function UserProfilePanel({
{font.label}
</option>
))}
</select>
</Select2>
<span className="text-[10px] text-muted-foreground">
{t("newUi.sidebar.userProfile.interfaceFontDescription")}
</span>
@@ -1767,7 +1736,10 @@ export function UserProfilePanel({
</SettingRow>
</div>
<div className="flex flex-col gap-1 border-t border-border pt-3">
<div
id="settings-navigation"
className="flex flex-col gap-1 border-t border-border pt-3"
>
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-1">
{t("newUi.sidebar.userProfile.settingsNavigation")}
</span>
+5
View File
@@ -61,6 +61,11 @@ const menuItems = createRegistry<HostContextMenuItemDef>(byOrderThenId);
export const registerHostAction = actions.register;
export const useHostActions = actions.useList;
/** Plugin actions sort by order; connect actions default to the end. */
export function actionOrder(action: HostActionDef): number {
return action.order ?? (action.kind === "connect" ? 100 : 50);
}
export const listHostActions = actions.list;
export const registerHostBadge = badges.register;
export const useHostBadges = badges.useList;
+46 -2
View File
@@ -11,6 +11,7 @@ import {
} from "lucide-react";
import { useMemo, useSyncExternalStore } from "react";
import { usePermissions } from "@/hooks/use-permissions";
import { useAreaPreferences } from "@/contexts/UiPreferencesContext";
import { useSyncAttentionCount } from "@/hooks/use-sync-status";
import { isElectron } from "@/lib/electron";
import { createRegistry } from "@/lib/registry";
@@ -233,12 +234,55 @@ export function useRailItems(): RailItemDef[] {
railItemsSnapshot,
);
const { has, loaded } = usePermissions();
const { order } = useAreaPreferences("rail");
return useMemo(
() => permittedRailItems(items, { has, loaded }),
[items, has, loaded],
() => applyRailOrder(permittedRailItems(items, { has, loaded }), order),
[items, has, loaded, order],
);
}
/**
* Items in the user's order. Listed ids come first in that order within the
* default sequence; unlisted ones keep their default place relative to it.
*/
export function applyRailOrder(
items: RailItemDef[],
order: string[] | undefined,
): RailItemDef[] {
if (!order || order.length === 0) return items;
const rank = new Map(order.map((id, index) => [id, index]));
const listed = items
.filter((item) => rank.has(item.id))
.sort((a, b) => rank.get(a.id)! - rank.get(b.id)!);
let next = 0;
return items.map((item) => (rank.has(item.id) ? listed[next++] : item));
}
/**
* The order after moving `id` to sit before `beforeId` (or to the end of its
* band when null), as a full list of the given items' ids.
*/
export function moveRailItem(
items: RailItemDef[],
id: string,
beforeId: string | null,
): string[] {
const ids = items.map((item) => item.id).filter((other) => other !== id);
const at = beforeId ? ids.indexOf(beforeId) : -1;
if (at < 0) {
const group = items.find((item) => item.id === id)?.group ?? "tools";
const lastInBand = items
.map((item, index) => ({ item, index }))
.filter(({ item }) => item.id !== id && (item.group ?? "tools") === group)
.map(({ item }) => ids.indexOf(item.id))
.pop();
ids.splice(lastInBand === undefined ? ids.length : lastInBand + 1, 0, id);
} else {
ids.splice(at, 0, id);
}
return ids;
}
/** Places outside the rail that still need a title, like the Settings tab. */
export const RAIL_UTILITY_ITEMS: RailItemDef[] = [
{ id: "settings", icon: Settings, labelKey: "nav.settings" },
+4 -6
View File
@@ -1,4 +1,6 @@
/* eslint-disable react-refresh/only-export-components */
import { actionOrder } from "@/sidebar/host-contributions";
import { showsInBar, useBarActions } from "@/sidebar/tree/host-bar-actions";
import { rem } from "@/lib/rem";
import { enabledHostProtocols } from "@/sidebar/host-protocols";
import { useEffect, useRef, useState, type MouseEvent } from "react";
@@ -116,11 +118,6 @@ export function buildStatusTooltip(
return `${protocols.join(", ")}: ${statusLabel}`;
}
/** Plugin actions sort by order; connect actions default to the end. */
function actionOrder(action: HostActionDef): number {
return action.order ?? (action.kind === "connect" ? 100 : 50);
}
async function writeClipboardText(value: string): Promise<void> {
await copyToClipboard(value);
}
@@ -275,6 +272,7 @@ export function HostItem({
const allowDelete = canDeleteHost(host);
const allHostActions = useHostActions();
const pluginActions = hostActionsFor(allHostActions, host);
const barActions = useBarActions();
const badges = hostBadgesFor(useHostBadges(), host);
const pluginMenuItems = hostMenuItemsFor(useHostContextMenuItems(), host);
const splitTargets = useSplitTargets();
@@ -421,7 +419,7 @@ export function HostItem({
icon: action.icon as typeof Terminal,
label: action.label?.(host) ?? t(action.titleKey),
tabType: action.tabType,
tray: action.tray !== false,
tray: showsInBar(barActions, action),
items:
items && items.length > 1
? items.map((item) => ({
+39
View File
@@ -0,0 +1,39 @@
import { useSyncExternalStore } from "react";
/**
* Which host actions the user put in or took out of the row's connect bar.
* The hosts panel owns the preference and mirrors it here, so every row can
* read it without its own fetch.
*/
let current: Record<string, boolean> = {};
const listeners = new Set<() => void>();
export function setBarActions(next: Record<string, boolean>): void {
const same =
Object.keys(next).length === Object.keys(current).length &&
Object.entries(next).every(([id, value]) => current[id] === value);
if (same) return;
current = next;
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useBarActions(): Record<string, boolean> {
return useSyncExternalStore(
subscribe,
() => current,
() => current,
);
}
/** Whether an action shows in the bar: the user's choice, else its own. */
export function showsInBar(
barActions: Record<string, boolean>,
action: { id: string; tray?: boolean },
): boolean {
return barActions[action.id] ?? action.tray !== false;
}