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