* feat: add host list sort

* feat: fixed rdp truncating (taskbar invisible unless resizing window) and improved split screen system

* feat: revamp conneciton persistance system to save to backend with a new connections panel to restore old connections and view current ones. Also added new user profile toggle to reopen all tabs (saves and loads from backend). Added user profile toggle for host tray click vs hover.

* feat: added WOL button, added proper use of BASE_PATH, toggles/buttons in admin/user profile now are always visible regardless of sidebar width, duplicating hosts not adding jumphost/socks5, keepalive internal not mulitplying into seconds causing a keepalive error, and finally guacamole giving 1_0_0 and 1_1_0 errors

* feat: add filter host button, improve alert system UI, save sidebar width to localstorage, and fix host toolbar row overflow (add host going off screen on small sidebar width)

* feat: add pin rail toggle, fix command pallete toggle not working, fixed command pallete toggling when typing in a field, made file manager not uppercase, host manager custom ports not loading, guacd hosts made on >=2.2.1, fixed host tags toggling, added reorder snippet sfeature, made snippet folder clllapse and require confimration toggle work, removed file manager color toggle, and fixed macos not displaying GUI until switching to another app and coming back, and jump host servers failing.

* feat: use blacksmith caching for docker compile and improve keepalive system for ssh to all match the same implementation and use the data from the host config instead of a predefined value

* feat: reset host manager state if the form is left and remove file manager color logic from the removed toggle

* feat: update electron version check to use new ui

* feat: improve duplication system to proplery map all fields
This commit is contained in:
Luke Gustafson
2026-05-28 22:05:25 -04:00
committed by LukeGus
parent b5ab1479ce
commit 7370e8f3df
50 changed files with 3337 additions and 577 deletions
+25 -3
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Clock,
@@ -7,6 +7,7 @@ import {
LayoutPanelLeft,
Network,
Play,
Plug,
Server,
Settings,
User,
@@ -25,6 +26,7 @@ export type RailView =
| "credentials"
| "quick-connect"
| ToolsTab
| "connections"
| "user-profile"
| "admin-settings";
@@ -42,6 +44,7 @@ type RailItem =
function buildRailButtons(
splitMode: SplitMode,
t: (key: string) => string,
connectionCount: number,
): RailItem[] {
return [
{ view: "hosts", icon: <Server size={16} />, title: t("nav.hosts") },
@@ -51,6 +54,12 @@ function buildRailButtons(
title: t("nav.credentials"),
},
{ kind: "separator" },
{
view: "connections",
icon: <Plug size={16} />,
title: t("nav.connections"),
},
{ kind: "separator" },
{
view: "quick-connect",
icon: <Zap size={16} />,
@@ -88,6 +97,7 @@ export function AppRail({
railView,
sidebarOpen,
splitMode,
connectionCount,
username,
isAdmin,
profileDropdownOpen,
@@ -99,6 +109,7 @@ export function AppRail({
railView: RailView;
sidebarOpen: boolean;
splitMode: SplitMode;
connectionCount: number;
username: string;
isAdmin: boolean;
profileDropdownOpen: boolean;
@@ -109,8 +120,19 @@ export function AppRail({
}) {
const { t } = useTranslation();
const [hovered, setHovered] = useState(false);
const railExpanded = hovered || profileDropdownOpen;
const railButtons = buildRailButtons(splitMode, t);
const [pinned, setPinned] = useState(
() => localStorage.getItem("pinAppRail") === "true",
);
useEffect(() => {
const handler = () =>
setPinned(localStorage.getItem("pinAppRail") === "true");
window.addEventListener("pinAppRailChanged", handler);
return () => window.removeEventListener("pinAppRailChanged", handler);
}, []);
const railExpanded = pinned || hovered || profileDropdownOpen;
const railButtons = buildRailButtons(splitMode, t, connectionCount);
return (
<div
+347
View File
@@ -0,0 +1,347 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { ExternalLink, Plug, Search, X } from "lucide-react";
import { getActiveSessions, deleteOpenTab, type ActiveSessionInfo, type OpenTabRecord } from "@/main-axios";
import { tabIcon } from "@/shell/tabUtils";
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";
const CONNECTION_TAB_TYPES: TabType[] = [
"terminal",
"rdp",
"vnc",
"telnet",
"files",
"docker",
"stats",
"tunnel",
];
const TYPE_LABELS: Record<string, string> = {
terminal: "SSH",
rdp: "RDP",
vnc: "VNC",
telnet: "Telnet",
files: "Files",
docker: "Docker",
stats: "Stats",
tunnel: "Tunnel",
};
function formatDuration(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000));
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${s % 60}s`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
function formatExpiry(updatedAt: string): string {
const TTL_MS = 30 * 60 * 1000;
const elapsed = Date.now() - new Date(updatedAt).getTime();
const remaining = TTL_MS - elapsed;
if (remaining <= 0) return "expiring";
return formatDuration(remaining);
}
function ConnectionRow({
isActive,
isLive,
tabType,
name,
subLabel,
icon,
onSwitch,
onClose,
switchTitle,
faded,
}: {
isActive?: boolean;
isLive: boolean;
tabType: string;
name: string;
subLabel: string;
icon: React.ReactNode;
onSwitch?: () => void;
onClose: () => void;
switchTitle?: string;
faded?: boolean;
}) {
return (
<div
role={onSwitch ? "button" : undefined}
tabIndex={onSwitch ? 0 : undefined}
onClick={onSwitch}
onKeyDown={(e) => 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" : ""
} ${
isActive
? "bg-accent-brand/8 cursor-pointer border-l-2 border-l-accent-brand"
: onSwitch
? "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"
}`}
/>
<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"
>
{TYPE_LABELS[tabType] ?? tabType}
</Badge>
</div>
<span className="text-[10px] text-muted-foreground/60 truncate pl-3">
{subLabel}
</span>
</div>
<TooltipProvider>
<div className="shrink-0 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{switchTitle && onSwitch && (
<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>
)}
<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>
);
}
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>
);
}
export function ConnectionsPanel({
tabs,
activeTabId,
allHosts,
backgroundTabRecords,
onSwitchToTab,
onCloseTab,
onReopenTab,
onForgetBackground,
}: {
tabs: Tab[];
activeTabId: string;
allHosts: { id: string; name: string }[];
backgroundTabRecords: OpenTabRecord[];
onSwitchToTab: (tabId: string) => void;
onCloseTab: (tabId: string) => void;
onReopenTab: (record: OpenTabRecord, restoredSessionId: string | null) => void;
onForgetBackground: (recordId: string) => void;
}) {
const { t } = useTranslation();
const [now, setNow] = useState(Date.now());
const [activeSessions, setActiveSessions] = useState<ActiveSessionInfo[]>([]);
const [search, setSearch] = useState("");
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const openTabs = tabs.filter((tab) => CONNECTION_TAB_TYPES.includes(tab.type));
// Filter background records to only those not already open in the tab bar
const openInstanceIds = new Set(tabs.map((t) => t.instanceId).filter(Boolean));
const backgroundTabs = backgroundTabRecords.filter((r) => !openInstanceIds.has(r.id));
const q = search.trim().toLowerCase();
const filteredOpenTabs = q
? openTabs.filter((tab) => (tab.host?.name ?? tab.label).toLowerCase().includes(q))
: openTabs;
const filteredBackgroundTabs = q
? backgroundTabs.filter((r) => {
const host = allHosts.find((h) => h.id === String(r.hostId));
return (host?.name ?? r.label).toLowerCase().includes(q);
})
: backgroundTabs;
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, []);
const refresh = useCallback(async () => {
try {
const sessions = await getActiveSessions();
setActiveSessions((prev) => {
const next = Array.isArray(sessions) ? sessions : [];
if (JSON.stringify(prev) === JSON.stringify(next)) return prev;
return next;
});
} catch {
// silently ignore
}
}, []);
useEffect(() => {
refresh();
pollTimerRef.current = setInterval(refresh, 5000);
return () => { if (pollTimerRef.current) clearInterval(pollTimerRef.current); };
}, [refresh]);
const sessionByInstanceId = new Map(activeSessions.map((s) => [s.tabInstanceId, s]));
const hasAnything = openTabs.length > 0 || backgroundTabs.length > 0;
const hasResults = filteredOpenTabs.length > 0 || filteredBackgroundTabs.length > 0;
if (!hasAnything) {
return (
<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>
);
}
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")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 h-7 text-xs"
/>
</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>
)}
{filteredOpenTabs.length > 0 && (
<div className="flex flex-col">
<SectionHeader label={t("connections.sectionOpen")} count={filteredOpenTabs.length} />
{filteredOpenTabs.map((tab) => {
const isActive = tab.id === activeTabId;
const liveSession = tab.instanceId ? sessionByInstanceId.get(tab.instanceId) : undefined;
const isLive = tab.type === "terminal"
? (liveSession?.isConnected ?? false)
: true;
const duration = liveSession?.createdAt
? formatDuration(now - liveSession.createdAt)
: formatDuration(now - tab.openedAt);
return (
<ConnectionRow
key={tab.id}
isActive={isActive}
isLive={isLive}
tabType={tab.type}
name={tab.host?.name ?? tab.label}
subLabel={
isLive && tab.type === "terminal"
? t("connections.connectedFor", { duration })
: isLive
? t("connections.connected")
: t("connections.disconnected")
}
icon={tabIcon(tab.type)}
onSwitch={() => onSwitchToTab(tab.id)}
onClose={() => onCloseTab(tab.id)}
/>
);
})}
</div>
)}
{filteredBackgroundTabs.length > 0 && (
<div className={`flex flex-col ${filteredOpenTabs.length > 0 ? "mt-2" : ""}`}>
<SectionHeader label={t("connections.sectionBackground")} count={filteredBackgroundTabs.length} />
<div className="px-3 py-1.5 border-b border-border/40">
<span className="text-[10px] text-muted-foreground/50">
{t("connections.backgroundDesc")}
</span>
</div>
{filteredBackgroundTabs.map((record) => {
const host = record.hostId
? allHosts.find((h) => h.id === String(record.hostId))
: undefined;
const expiresIn = formatExpiry(record.updatedAt);
return (
<ConnectionRow
key={record.id}
isLive={false}
faded
tabType={record.tabType}
name={host?.name ?? record.label}
subLabel={t("connections.expiresIn", { duration: expiresIn })}
icon={tabIcon(record.tabType as TabType)}
onSwitch={() => {
const liveSession = sessionByInstanceId.get(record.id);
onReopenTab(record, liveSession?.sessionId ?? null);
}}
onClose={async () => {
await deleteOpenTab(record.id).catch(() => {});
onForgetBackground(record.id);
}}
switchTitle={t("connections.reconnect")}
/>
);
})}
</div>
)}
</div>
);
}
+14 -4
View File
@@ -356,7 +356,7 @@ function HostEditor({
name: host?.name ?? "",
ip: host?.ip ?? "",
username: host?.username ?? "",
sshPort: host?.sshPort ?? 22,
sshPort: host?.sshPort ?? host?.port ?? 22,
rdpPort: host?.rdpPort ?? 3389,
vncPort: host?.vncPort ?? 5900,
telnetPort: host?.telnetPort ?? 23,
@@ -430,8 +430,8 @@ function HostEditor({
autoTmux: host?.terminalConfig?.autoTmux ?? false,
sudoPasswordAutoFill: host?.terminalConfig?.sudoPasswordAutoFill ?? false,
sudoPassword: host?.terminalConfig?.sudoPassword ?? "",
keepaliveInterval: host?.terminalConfig?.keepaliveInterval ?? 30,
keepaliveCountMax: host?.terminalConfig?.keepaliveCountMax ?? 3,
keepaliveInterval: host?.terminalConfig?.keepaliveInterval ?? 60,
keepaliveCountMax: host?.terminalConfig?.keepaliveCountMax ?? 5,
environmentVariables:
host?.terminalConfig?.environmentVariables ??
([] as { key: string; value: string }[]),
@@ -4703,7 +4703,7 @@ export function HostManager({
return false;
};
useEffect(() => {
const reloadHosts = () => {
getSSHHosts()
.then((raw) => {
const converted = raw.map(sshHostToHost);
@@ -4711,6 +4711,10 @@ export function HostManager({
applyPendingEdit(converted);
})
.catch(() => {});
};
useEffect(() => {
reloadHosts();
getCredentials()
.then((res: any) => {
const arr = Array.isArray(res) ? res : [];
@@ -4729,6 +4733,10 @@ export function HostManager({
})
.catch(() => {})
.finally(() => setCredentialsLoading(false));
window.addEventListener("termix:hosts-changed", reloadHosts);
return () =>
window.removeEventListener("termix:hosts-changed", reloadHosts);
}, []);
useEffect(() => {
@@ -4749,6 +4757,7 @@ export function HostManager({
} else if (action === "add-credential") {
setEditingCredential("new");
setEditingHost(null);
setActiveCredentialTab("general");
}
}
}, [pendingEditId, pendingAction]);
@@ -4770,6 +4779,7 @@ export function HostManager({
const handleAddCredential = () => {
setEditingCredential("new");
setEditingHost(null);
setActiveCredentialTab("general");
};
const handleEditHost = (e: Event) => {
const id = (e as CustomEvent<string>).detail;
+351 -4
View File
@@ -1,7 +1,10 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowUpDown,
Check,
Download,
Filter,
ListChecks,
Plus,
RefreshCw,
@@ -10,20 +13,137 @@ import {
X,
} from "lucide-react";
import { toast } from "sonner";
import { SidebarTree } from "@/sidebar/SidebarTree";
import { SidebarTree, isFolder } from "@/sidebar/SidebarTree";
import { HostManager } from "@/sidebar/HostManager";
import { HostShareModal } from "@/sidebar/HostShareModal";
import { Button } from "@/components/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import { getSSHHosts, bulkImportSSHHosts } from "@/main-axios";
import type { SSHHostWithStatus } from "@/main-axios";
import type { Host, HostFolder, TabType } from "@/types/ui-types";
type SortKey =
| "default"
| "name-asc"
| "name-desc"
| "ip-asc"
| "ip-desc"
| "status-online"
| "status-offline"
| "pinned";
type FilterState = {
status: ("online" | "offline" | "pinned")[];
authType: ("password" | "key" | "credential" | "none" | "opkssh")[];
protocol: ("ssh" | "rdp" | "vnc" | "telnet")[];
features: ("terminal" | "fileManager" | "tunnel" | "docker")[];
tags: string[];
};
const DEFAULT_FILTERS: FilterState = {
status: [],
authType: [],
protocol: [],
features: [],
tags: [],
};
function sortHostTree(folder: HostFolder, key: SortKey): HostFolder {
if (key === "default") return folder;
const comparator = (a: Host | HostFolder, b: Host | HostFolder): number => {
const aIsFolder = isFolder(a);
const bIsFolder = isFolder(b);
if (aIsFolder && !bIsFolder) return -1;
if (!aIsFolder && bIsFolder) return 1;
if (aIsFolder && bIsFolder)
return (a as HostFolder).name.localeCompare((b as HostFolder).name);
const ha = a as Host,
hb = b as Host;
switch (key) {
case "name-asc":
return ha.name.localeCompare(hb.name);
case "name-desc":
return hb.name.localeCompare(ha.name);
case "ip-asc":
return ha.ip.localeCompare(hb.ip);
case "ip-desc":
return hb.ip.localeCompare(ha.ip);
case "status-online":
return (hb.online ? 1 : 0) - (ha.online ? 1 : 0);
case "status-offline":
return (ha.online ? 1 : 0) - (hb.online ? 1 : 0);
case "pinned":
return (hb.pin ? 1 : 0) - (ha.pin ? 1 : 0);
}
return 0;
};
const sortedChildren = [...folder.children]
.sort(comparator)
.map((child) => (isFolder(child) ? sortHostTree(child, key) : child));
return { ...folder, children: sortedChildren };
}
function hostPassesFilters(host: Host, filters: FilterState): boolean {
if (filters.status.length > 0) {
const ok =
(filters.status.includes("online") && host.online) ||
(filters.status.includes("offline") && !host.online) ||
(filters.status.includes("pinned") && !!host.pin);
if (!ok) return false;
}
if (filters.authType.length > 0) {
if (!filters.authType.includes(host.authType as FilterState["authType"][number])) return false;
}
if (filters.protocol.length > 0) {
const ok =
(filters.protocol.includes("ssh") && host.enableSsh) ||
(filters.protocol.includes("rdp") && host.enableRdp) ||
(filters.protocol.includes("vnc") && host.enableVnc) ||
(filters.protocol.includes("telnet") && host.enableTelnet);
if (!ok) return false;
}
if (filters.features.length > 0) {
const ok =
(filters.features.includes("terminal") && host.enableTerminal) ||
(filters.features.includes("fileManager") && host.enableFileManager) ||
(filters.features.includes("tunnel") && host.enableTunnel) ||
(filters.features.includes("docker") && host.enableDocker);
if (!ok) return false;
}
if (filters.tags.length > 0) {
const ok = filters.tags.some((tag) => host.tags?.includes(tag));
if (!ok) return false;
}
return true;
}
function applyFilters(folder: HostFolder, filters: FilterState): HostFolder {
const active = Object.values(filters).some((arr) => arr.length > 0);
if (!active) return folder;
const filteredChildren = folder.children
.map((child) => {
if (isFolder(child)) return applyFilters(child, filters);
return hostPassesFilters(child, filters) ? child : null;
})
.filter((child): child is Host | HostFolder => {
if (child === null) return false;
if (isFolder(child)) return child.children.length > 0;
return true;
});
return { ...folder, children: filteredChildren };
}
export function HostsPanel({
onOpenTab,
onEditHost,
@@ -46,8 +166,46 @@ export function HostsPanel({
const [refreshing, setRefreshing] = useState(false);
const [rawHosts, setRawHosts] = useState<SSHHostWithStatus[]>([]);
const [shareModalHost, setShareModalHost] = useState<Host | null>(null);
const [sortKey, setSortKey] = useState<SortKey>(
() => (localStorage.getItem("hostSortKey") as SortKey) ?? "default",
);
const [filterState, setFilterState] = useState<FilterState>(() => {
try {
const saved = localStorage.getItem("hostFilterState");
return saved ? (JSON.parse(saved) as FilterState) : DEFAULT_FILTERS;
} catch {
return DEFAULT_FILTERS;
}
});
const filterActive = Object.values(filterState).some((arr) => arr.length > 0);
const fileInputRef = useRef<HTMLInputElement>(null);
const importOverwriteRef = useRef(false);
const allTags = [...new Set(rawHosts.flatMap((h) => h.tags ?? []))];
function handleSortChange(key: SortKey) {
setSortKey(key);
localStorage.setItem("hostSortKey", key);
}
function handleFilterToggle<K extends keyof FilterState>(
group: K,
value: FilterState[K][number],
) {
setFilterState((prev) => {
const arr = prev[group] as string[];
const next = arr.includes(value as string)
? arr.filter((v) => v !== value)
: [...arr, value as string];
const updated = { ...prev, [group]: next };
localStorage.setItem("hostFilterState", JSON.stringify(updated));
return updated as FilterState;
});
}
function handleFilterClear() {
setFilterState(DEFAULT_FILTERS);
localStorage.setItem("hostFilterState", JSON.stringify(DEFAULT_FILTERS));
}
useEffect(() => {
getSSHHosts()
@@ -226,8 +384,8 @@ export function HostsPanel({
}}
/>
<div className="flex items-center gap-1">
<div className="flex items-center gap-0.5 flex-1">
<div className="flex flex-wrap items-center gap-1">
<div className="flex items-center gap-0.5 shrink-0 flex-1">
<Button
variant="ghost"
size="icon"
@@ -294,6 +452,195 @@ export function HostsPanel({
>
<ListChecks className="size-3.5" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`size-7 ${sortKey !== "default" ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
title={t("hosts.sortHosts")}
>
<ArrowUpDown className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="text-xs min-w-[160px]"
>
<DropdownMenuItem
onClick={() => handleSortChange("default")}
className="flex items-center gap-1.5"
>
{sortKey === "default" ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t("hosts.sortDefault")}
</DropdownMenuItem>
<DropdownMenuSeparator />
{(["name-asc", "name-desc"] as const).map((key) => (
<DropdownMenuItem
key={key}
onClick={() => handleSortChange(key)}
className="flex items-center gap-1.5"
>
{sortKey === key ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t(
`hosts.sort${key === "name-asc" ? "NameAsc" : "NameDesc"}`,
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{(["ip-asc", "ip-desc"] as const).map((key) => (
<DropdownMenuItem
key={key}
onClick={() => handleSortChange(key)}
className="flex items-center gap-1.5"
>
{sortKey === key ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t(`hosts.sort${key === "ip-asc" ? "IpAsc" : "IpDesc"}`)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
{(["status-online", "status-offline", "pinned"] as const).map(
(key) => (
<DropdownMenuItem
key={key}
onClick={() => handleSortChange(key)}
className="flex items-center gap-1.5"
>
{sortKey === key ? (
<Check className="size-3 shrink-0 text-accent-brand" />
) : (
<span className="size-3 shrink-0 inline-block" />
)}
{t(
key === "status-online"
? "hosts.sortOnlineFirst"
: key === "status-offline"
? "hosts.sortOfflineFirst"
: "hosts.sortPinnedFirst",
)}
</DropdownMenuItem>
),
)}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className={`size-7 ${filterActive ? "text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
title={t("hosts.filterHosts")}
>
<Filter className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="text-xs min-w-[180px]"
>
{filterActive && (
<>
<DropdownMenuItem
onClick={handleFilterClear}
className="flex items-center gap-1.5 text-accent-brand"
>
<X className="size-3 shrink-0" />
{t("hosts.filterClearAll")}
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuLabel>{t("hosts.filterStatusGroup")}</DropdownMenuLabel>
{(["online", "offline", "pinned"] as const).map((val) => (
<DropdownMenuCheckboxItem
key={val}
checked={filterState.status.includes(val)}
onCheckedChange={() => handleFilterToggle("status", val)}
onSelect={(e) => e.preventDefault()}
>
{t(`hosts.filter${val.charAt(0).toUpperCase() + val.slice(1)}`)}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("hosts.filterAuthGroup")}</DropdownMenuLabel>
{(["password", "key", "credential", "none", "opkssh"] as const).map((val) => (
<DropdownMenuCheckboxItem
key={val}
checked={filterState.authType.includes(val)}
onCheckedChange={() => handleFilterToggle("authType", val)}
onSelect={(e) => e.preventDefault()}
>
{t(`hosts.filterAuth${val.charAt(0).toUpperCase() + val.slice(1)}`)}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("hosts.filterProtocolGroup")}</DropdownMenuLabel>
{(
[
["ssh", "Ssh"],
["rdp", "Rdp"],
["vnc", "Vnc"],
["telnet", "Telnet"],
] as const
).map(([val, key]) => (
<DropdownMenuCheckboxItem
key={val}
checked={filterState.protocol.includes(val)}
onCheckedChange={() => handleFilterToggle("protocol", val)}
onSelect={(e) => e.preventDefault()}
>
{t(`hosts.filterProtocol${key}`)}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("hosts.filterFeaturesGroup")}</DropdownMenuLabel>
{(
[
["terminal", "Terminal"],
["fileManager", "FileManager"],
["tunnel", "Tunnel"],
["docker", "Docker"],
] as const
).map(([val, key]) => (
<DropdownMenuCheckboxItem
key={val}
checked={filterState.features.includes(val)}
onCheckedChange={() => handleFilterToggle("features", val)}
onSelect={(e) => e.preventDefault()}
>
{t(`hosts.filterFeature${key}`)}
</DropdownMenuCheckboxItem>
))}
{allTags.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("hosts.filterTagsGroup")}</DropdownMenuLabel>
{allTags.map((tag) => (
<DropdownMenuCheckboxItem
key={tag}
checked={filterState.tags.includes(tag)}
onCheckedChange={() => handleFilterToggle("tags", tag)}
onSelect={(e) => e.preventDefault()}
>
{tag}
</DropdownMenuCheckboxItem>
))}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<button
onClick={() =>
@@ -313,7 +660,7 @@ export function HostsPanel({
className={`flex flex-col flex-1 min-h-0 ${managerEditing ? "hidden" : ""}`}
>
<SidebarTree
children={hostTree?.children ?? []}
children={hostTree ? applyFilters(sortHostTree(hostTree, sortKey), filterState).children : []}
onOpenTab={onOpenTab}
onEditHost={onEditHost}
onShareHost={(host) => setShareModalHost(host)}
+145 -21
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import {
Box,
@@ -24,6 +24,7 @@ import {
Share2,
Terminal,
Trash2,
Zap,
} from "lucide-react";
import {
DropdownMenu,
@@ -36,7 +37,12 @@ import {
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import { toast } from "sonner";
import { bulkUpdateSSHHosts, createSSHHost, deleteSSHHost } from "@/main-axios";
import {
bulkUpdateSSHHosts,
createSSHHost,
deleteSSHHost,
wakeOnLan,
} from "@/main-axios";
import type { Host, HostFolder, TabType } from "@/types/ui-types";
export function isFolder(item: Host | HostFolder): item is HostFolder {
@@ -186,6 +192,8 @@ export function HostItem({
onToggleSelect,
isMenuOpen = false,
onMenuOpenChange,
isTrayOpen = false,
onTrayOpenChange,
}: {
host: Host;
onOpenTab: (type: TabType) => void;
@@ -200,10 +208,43 @@ export function HostItem({
onToggleSelect?: () => void;
isMenuOpen?: boolean;
onMenuOpenChange?: (open: boolean) => void;
isTrayOpen?: boolean;
onTrayOpenChange?: (open: boolean) => void;
}) {
const { t } = useTranslation();
const metricsEnabled =
host.enableSsh && host.statsConfig?.metricsEnabled !== false;
const [trayOnClick, setTrayOnClick] = useState(
() => localStorage.getItem("hostTrayOnClick") === "true",
);
const [showHostTags, setShowHostTags] = useState<boolean>(() => {
const v = localStorage.getItem("showHostTags");
return v !== null ? v === "true" : true;
});
useEffect(() => {
const handler = () =>
setTrayOnClick(localStorage.getItem("hostTrayOnClick") === "true");
window.addEventListener("storage", handler);
window.addEventListener("hostTrayOnClickChanged", handler);
return () => {
window.removeEventListener("storage", handler);
window.removeEventListener("hostTrayOnClickChanged", handler);
};
}, []);
useEffect(() => {
const handler = () => {
const v = localStorage.getItem("showHostTags");
setShowHostTags(v !== null ? v === "true" : true);
};
window.addEventListener("storage", handler);
window.addEventListener("showHostTagsChanged", handler);
return () => {
window.removeEventListener("storage", handler);
window.removeEventListener("showHostTagsChanged", handler);
};
}, []);
if (query && !hostMatchesQuery(host, query)) return null;
@@ -224,7 +265,11 @@ export function HostItem({
// On touch devices open the action tray instead of immediately launching a tab
if (window.matchMedia("(hover: none)").matches) {
e.stopPropagation();
onMenuOpenChange?.(!isMenuOpen);
onTrayOpenChange?.(!isTrayOpen);
return;
}
if (trayOnClick) {
onTrayOpenChange?.(!isTrayOpen);
return;
}
if (host.enableSsh) onOpenTab("terminal");
@@ -260,15 +305,15 @@ export function HostItem({
)}
</div>
{/* Address — only visible on hover or while menu is open */}
{/* Address — only visible on hover (or click when trayOnClick) or while menu is open */}
<span
className={`text-[11px] text-muted-foreground/55 truncate leading-none pl-3 transition-opacity duration-100 group-hover:opacity-100 group-hover:h-auto ${isMenuOpen ? "opacity-100 h-auto" : "opacity-0 h-0 overflow-hidden"}`}
className={`text-[11px] text-muted-foreground/55 truncate leading-none pl-3 transition-opacity duration-100 ${!trayOnClick ? "group-hover:opacity-100 group-hover:h-auto" : ""} ${isMenuOpen || (trayOnClick && isTrayOpen) ? "opacity-100 h-auto" : "opacity-0 h-0 overflow-hidden"}`}
>
{host.username}@{host.ip}
</span>
{/* Tag pills */}
{host.tags && host.tags.length > 0 && (
{showHostTags && host.tags && host.tags.length > 0 && (
<div className="flex items-center gap-1 min-w-0 overflow-hidden pl-3">
{host.tags.slice(0, 4).map((tag) => (
<span
@@ -286,9 +331,9 @@ export function HostItem({
</div>
)}
{/* Action tray — slides open on CSS hover or while menu is open */}
{/* Action tray — slides open on CSS hover, on click (when trayOnClick), or while menu is open */}
<div
className={`overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 group-hover:max-h-[300px] group-hover:opacity-100 ${selectionMode ? "!max-h-0 !opacity-0" : ""} ${isMenuOpen && !selectionMode ? "!max-h-[300px] !opacity-100" : ""}`}
className={`overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 ${!trayOnClick ? "group-hover:max-h-[300px] group-hover:opacity-100" : ""} ${selectionMode ? "!max-h-0 !opacity-0" : ""} ${(isMenuOpen || (trayOnClick && isTrayOpen)) && !selectionMode ? "!max-h-[300px] !opacity-100" : ""}`}
>
{host.online &&
((host.cpu != null && host.cpu > 0) ||
@@ -382,6 +427,25 @@ export function HostItem({
<MessagesSquare className="size-3.5" />
</button>
)}
{host.macAddress && (
<button
title={t("hosts.wakeOnLanAction")}
onClick={async (e) => {
e.stopPropagation();
try {
await wakeOnLan(host.id);
toast.success(
t("hosts.wakeOnLanSuccess", { name: host.name }),
);
} catch {
toast.error(t("hosts.wakeOnLanError"));
}
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Zap className="size-3.5" />
</button>
)}
</div>
{/* Separator + management buttons row — always fixed position */}
@@ -601,6 +665,8 @@ export function FolderItem({
onToggleSelect,
openMenuHostId,
onMenuOpenChange,
openTrayHostId,
onTrayOpenChange,
}: {
folder: HostFolder;
depth?: number;
@@ -618,6 +684,8 @@ export function FolderItem({
onToggleSelect: (id: string) => void;
openMenuHostId: string | null;
onMenuOpenChange: (hostId: string | null) => void;
openTrayHostId: string | null;
onTrayOpenChange: (hostId: string | null) => void;
}) {
const { total, online } = folderHostCount(folder);
@@ -670,6 +738,8 @@ export function FolderItem({
onToggleSelect={onToggleSelect}
openMenuHostId={openMenuHostId}
onMenuOpenChange={onMenuOpenChange}
openTrayHostId={openTrayHostId}
onTrayOpenChange={onTrayOpenChange}
/>
) : (
<HostItem
@@ -689,6 +759,10 @@ export function FolderItem({
onMenuOpenChange={(open) =>
onMenuOpenChange(open ? child.id : null)
}
isTrayOpen={openTrayHostId === child.id}
onTrayOpenChange={(open) =>
onTrayOpenChange(open ? child.id : null)
}
/>
),
)}
@@ -723,6 +797,7 @@ export function SidebarTree({
new Set(),
);
const [openMenuHostId, setOpenMenuHostId] = useState<string | null>(null);
const [openTrayHostId, setOpenTrayHostId] = useState<string | null>(null);
const [confirmDialog, setConfirmDialog] = useState<{
message: string;
onConfirm: () => Promise<void> | void;
@@ -761,21 +836,64 @@ export function SidebarTree({
async function handleDuplicateHost(host: Host) {
try {
const {
id: _id,
online: _online,
cpu: _cpu,
ram: _ram,
lastAccess: _la,
hasKey: _hk,
hasKeyPassword: _hkp,
...rest
} = host as any;
await createSSHHost({
...rest,
name: `${host.name} (copy)`,
key: undefined,
});
ip: host.ip,
port: host.port,
username: host.username,
folder: host.folder,
tags: host.tags ?? [],
pin: host.pin ?? false,
notes: host.notes,
macAddress: host.macAddress,
authType: host.authType,
password: host.password ?? null,
keyPassword: host.keyPassword ?? null,
keyType: host.keyType ?? null,
credentialId: host.credentialId ? Number(host.credentialId) : null,
overrideCredentialUsername: host.overrideCredentialUsername ?? false,
enableSsh: host.enableSsh,
enableRdp: host.enableRdp,
enableVnc: host.enableVnc,
enableTelnet: host.enableTelnet,
enableTerminal: host.enableTerminal,
enableTunnel: host.enableTunnel,
enableFileManager: host.enableFileManager,
enableDocker: host.enableDocker,
sshPort: host.sshPort,
rdpPort: host.rdpPort,
vncPort: host.vncPort,
telnetPort: host.telnetPort,
rdpUser: host.rdpUser ?? null,
rdpPassword: host.rdpPassword ?? null,
rdpDomain: host.domain ?? null,
rdpSecurity: host.security ?? null,
rdpIgnoreCert: host.ignoreCert ?? false,
vncPassword: host.vncPassword ?? null,
vncUser: host.vncUser ?? null,
telnetUser: host.telnetUser ?? null,
telnetPassword: host.telnetPassword ?? null,
defaultPath: host.defaultPath ?? "/",
forceKeyboardInteractive: host.forceKeyboardInteractive ?? false,
useSocks5: host.useSocks5,
socks5Host: host.socks5Host ?? null,
socks5Port: host.socks5Port ?? null,
socks5Username: host.socks5Username ?? null,
socks5Password: host.socks5Password ?? null,
socks5ProxyChain: host.socks5ProxyChain ?? null,
jumpHosts: (host.jumpHosts ?? []).map((j) => ({
hostId: Number(j.hostId),
})),
portKnockSequence: host.portKnockSequence ?? [],
tunnelConnections: host.serverTunnels ?? [],
quickActions: (host.quickActions ?? []).map((a) => ({
name: a.name,
snippetId: Number(a.snippetId),
})),
statsConfig: host.statsConfig,
guacamoleConfig: host.guacamoleConfig ?? null,
terminalConfig: host.terminalConfig ?? null,
} as any);
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
toast.success(t("hosts.duplicatedHost", { name: host.name }));
} catch {
@@ -846,6 +964,8 @@ export function SidebarTree({
onToggleSelect={toggleSelect}
openMenuHostId={openMenuHostId}
onMenuOpenChange={setOpenMenuHostId}
openTrayHostId={openTrayHostId}
onTrayOpenChange={setOpenTrayHostId}
/>
) : (
<HostItem
@@ -865,6 +985,10 @@ export function SidebarTree({
onMenuOpenChange={(open) =>
setOpenMenuHostId(open ? child.id : null)
}
isTrayOpen={openTrayHostId === child.id}
onTrayOpenChange={(open) =>
setOpenTrayHostId(open ? child.id : null)
}
/>
),
)
+253 -74
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getSnippets,
createSnippet as apiCreateSnippet,
@@ -15,6 +16,7 @@ import {
revokeSnippetAccess,
getUserList,
getRoles,
reorderSnippets,
} from "@/main-axios";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
@@ -34,6 +36,7 @@ import {
Database,
Folder,
Globe,
GripVertical,
Network,
Pencil,
Play,
@@ -122,7 +125,7 @@ function SnippetFormDialog({
onOpenChange: (v: boolean) => void;
folders: SnippetFolder[];
snippet: Snippet | null;
onSave: (data: Omit<Snippet, "id">, id?: number) => void;
onSave: (data: Omit<Snippet, "id" | "order">, id?: number) => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
@@ -685,6 +688,12 @@ function SnippetCard({
onDelete,
onEdit,
onShare,
onConfirmRun,
onDragStart,
onDragEnd,
onDragOver,
dropIndicator,
isDragging,
t,
}: {
snippet: Snippet;
@@ -693,9 +702,15 @@ function SnippetCard({
onDelete: (id: number) => void;
onEdit: (snippet: Snippet) => void;
onShare: (snippet: Snippet) => void;
onConfirmRun: (snippet: Snippet, execute: () => void) => void;
onDragStart: () => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
dropIndicator: "above" | "below" | null;
isDragging: boolean;
t: (key: string, opts?: Record<string, unknown>) => string;
}) {
function handleRun() {
function executeRun() {
const targets = terminalTabs.filter((tab) => selectedTabIds.has(tab.id));
if (targets.length > 0) {
targets.forEach((tab) => {
@@ -723,6 +738,10 @@ function SnippetCard({
}
}
function handleRun() {
onConfirmRun(snippet, executeRun);
}
function handleCopy() {
navigator.clipboard.writeText(snippet.content);
toast.success(
@@ -731,69 +750,80 @@ function SnippetCard({
}
return (
<div className="border border-border bg-background p-2.5 flex flex-col gap-2">
<div className="flex items-start gap-2">
<div className="grid grid-cols-2 gap-px mt-0.5 shrink-0 opacity-30">
<div className="size-1 bg-muted-foreground rounded-full" />
<div className="size-1 bg-muted-foreground rounded-full" />
<div className="size-1 bg-muted-foreground rounded-full" />
<div className="size-1 bg-muted-foreground rounded-full" />
<div className="relative" onDragOver={onDragOver}>
{dropIndicator === "above" && (
<div className="absolute -top-1 left-0 right-0 h-0.5 bg-accent-brand z-10 pointer-events-none" />
)}
<div
draggable
onDragStart={(e) => {
e.dataTransfer.effectAllowed = "move";
onDragStart();
}}
onDragEnd={onDragEnd}
className={`border bg-background p-2.5 flex flex-col gap-2 group/card transition-opacity ${isDragging ? "opacity-40" : "opacity-100"} border-border`}
>
<div className="flex items-start gap-2">
<GripVertical className="size-3.5 mt-0.5 shrink-0 text-muted-foreground/30 group-hover/card:text-muted-foreground/60 cursor-grab active:cursor-grabbing transition-colors" />
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold">{snippet.name}</span>
{snippet.description && (
<span className="text-xs text-muted-foreground">
{snippet.description}
</span>
)}
</div>
</div>
<div className="flex flex-col min-w-0">
<span className="text-xs font-semibold">{snippet.name}</span>
{snippet.description && (
<span className="text-xs text-muted-foreground">
{snippet.description}
</span>
)}
<span className="text-xs text-muted-foreground font-mono px-1">
{snippet.content}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs h-7 gap-1.5"
onClick={handleRun}
>
<Play className="size-3" />
{t("newUi.sidebar.snippets.run")}
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
onClick={handleCopy}
>
<Copy className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
onClick={() => onEdit(snippet)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive shrink-0"
onClick={() => onDelete(snippet.id)}
>
<Trash2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
onClick={() => onShare(snippet)}
>
<Share2 className="size-3.5" />
</Button>
</div>
</div>
<span className="text-xs text-muted-foreground font-mono px-1">
{snippet.content}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs h-7 gap-1.5"
onClick={handleRun}
>
<Play className="size-3" />
{t("newUi.sidebar.snippets.run")}
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
onClick={handleCopy}
>
<Copy className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
onClick={() => onEdit(snippet)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive shrink-0"
onClick={() => onDelete(snippet.id)}
>
<Trash2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
onClick={() => onShare(snippet)}
>
<Share2 className="size-3.5" />
</Button>
</div>
{dropIndicator === "below" && (
<div className="absolute -bottom-1 left-0 right-0 h-0.5 bg-accent-brand z-10 pointer-events-none" />
)}
</div>
);
}
@@ -806,9 +836,11 @@ export function SnippetsPanel({
activeTabId: string;
}) {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [snippetSearch, setSnippetSearch] = useState("");
const [folders, setFolders] = useState<SnippetFolder[]>([]);
const [snippets, setSnippets] = useState<Snippet[]>([]);
const snippetsRef = useRef<Snippet[]>([]);
const [snippetFormOpen, setSnippetFormOpen] = useState(false);
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null);
const [createFolderOpen, setCreateFolderOpen] = useState(false);
@@ -823,9 +855,123 @@ export function SnippetsPanel({
: [],
),
);
const [uncategorizedOpen, setUncategorizedOpen] = useState(true);
function updateSnippets(next: Snippet[] | ((prev: Snippet[]) => Snippet[])) {
setSnippets((prev) => {
const resolved = typeof next === "function" ? next(prev) : next;
snippetsRef.current = resolved;
return resolved;
});
}
const getFoldersCollapsed = () =>
localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
const [uncategorizedOpen, setUncategorizedOpen] = useState(
() => !getFoldersCollapsed(),
);
useEffect(() => {
const handler = () => {
const collapsed = getFoldersCollapsed();
setUncategorizedOpen(!collapsed);
setFolders((prev) => prev.map((f) => ({ ...f, open: !collapsed })));
};
window.addEventListener("defaultSnippetFoldersCollapsedChanged", handler);
return () =>
window.removeEventListener(
"defaultSnippetFoldersCollapsedChanged",
handler,
);
}, []);
const [draggedSnippet, setDraggedSnippet] = useState<Snippet | null>(null);
const [dropTarget, setDropTarget] = useState<{
id: number;
position: "above" | "below";
} | null>(null);
function handleDragStart(snippet: Snippet) {
setDraggedSnippet(snippet);
}
function handleDragOver(e: React.DragEvent, snippetId: number) {
e.preventDefault();
if (!draggedSnippet || draggedSnippet.id === snippetId) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const position = e.clientY < rect.top + rect.height / 2 ? "above" : "below";
setDropTarget((prev) =>
prev?.id === snippetId && prev?.position === position
? prev
: { id: snippetId, position },
);
}
async function handleDrop(folder: string | null) {
const dragged = draggedSnippet;
const target = dropTarget;
setDraggedSnippet(null);
setDropTarget(null);
if (!dragged || !target || dragged.id === target.id) return;
const group = snippetsRef.current.filter((s) => s.folder === folder);
const draggedIdx = group.findIndex((s) => s.id === dragged.id);
const targetIdx = group.findIndex((s) => s.id === target.id);
if (draggedIdx === -1 || targetIdx === -1) return;
const reordered = [...group];
reordered.splice(draggedIdx, 1);
const insertAt =
target.position === "below"
? reordered.findIndex((s) => s.id === target.id) + 1
: reordered.findIndex((s) => s.id === target.id);
reordered.splice(insertAt, 0, dragged);
const rest = snippetsRef.current.filter((s) => s.folder !== folder);
const next = [...rest, ...reordered];
snippetsRef.current = next;
setSnippets(next);
const payload = reordered.map((s, idx) => ({
id: s.id,
order: idx,
folder: folder ?? undefined,
}));
try {
await reorderSnippets(payload);
} catch {
toast.error(t("newUi.sidebar.snippets.reorderFailed"));
}
}
function handleDragEnd() {
setDraggedSnippet(null);
setDropTarget(null);
}
const handleConfirmRun = useCallback(
(snippet: Snippet, execute: () => void) => {
const shouldConfirm =
localStorage.getItem("confirmSnippetExecution") === "true";
if (!shouldConfirm) {
execute();
return;
}
confirmWithToast(
t("newUi.sidebar.snippets.confirmRunMessage", { name: snippet.name }),
execute,
t("newUi.sidebar.snippets.confirmRunButton"),
t("newUi.sidebar.snippets.cancel"),
{ confirmOnEnter: true, duration: 6000 },
);
},
[confirmWithToast, t],
);
useEffect(() => {
const collapsed = getFoldersCollapsed();
getSnippets()
.then((data) => {
const arr = Array.isArray(data) ? data : [];
@@ -836,8 +982,9 @@ export function SnippetsPanel({
description: s.description,
content: s.content,
folder: s.folder ?? null,
order: s.order ?? 0,
}));
setSnippets(mapped);
updateSnippets(mapped);
})
.catch(() => {});
@@ -850,7 +997,7 @@ export function SnippetsPanel({
name: f.name,
color: f.color ?? FOLDER_COLORS[0],
icon: (f.icon as FolderIconId) ?? "folder",
open: true,
open: !collapsed,
}));
setFolders(mapped);
})
@@ -865,23 +1012,27 @@ export function SnippetsPanel({
});
}
async function handleSaveSnippet(data: Omit<Snippet, "id">, id?: number) {
async function handleSaveSnippet(
data: Omit<Snippet, "id" | "order">,
id?: number,
) {
try {
if (id !== undefined) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await apiUpdateSnippet(id, data as any);
setSnippets((prev) =>
updateSnippets((prev) =>
prev.map((s) => (s.id === id ? { ...s, ...data } : s)),
);
toast.success(t("newUi.sidebar.snippets.updateSuccess"));
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const created = (await apiCreateSnippet(data as any)) as any;
setSnippets((prev) => [
updateSnippets((prev) => [
...prev,
{
...data,
id: created.id ?? Math.max(0, ...prev.map((x) => x.id)) + 1,
order: created.order ?? prev.length,
},
]);
toast.success(t("newUi.sidebar.snippets.createSuccess"));
@@ -919,7 +1070,7 @@ export function SnippetsPanel({
try {
await apiDeleteSnippetFolder(folder.name);
setFolders((prev) => prev.filter((f) => f.id !== folder.id));
setSnippets((prev) =>
updateSnippets((prev) =>
prev.map((s) =>
s.folder === folder.name ? { ...s, folder: null } : s,
),
@@ -940,7 +1091,7 @@ export function SnippetsPanel({
const nameChanged = data.name !== oldName;
if (nameChanged) {
await apiRenameSnippetFolder(oldName, data.name);
setSnippets((prev) =>
updateSnippets((prev) =>
prev.map((s) =>
s.folder === oldName ? { ...s, folder: data.name } : s,
),
@@ -966,7 +1117,7 @@ export function SnippetsPanel({
async function handleDeleteSnippet(id: number) {
try {
await apiDeleteSnippet(id);
setSnippets((prev) => prev.filter((s) => s.id !== id));
updateSnippets((prev) => prev.filter((s) => s.id !== id));
} catch {
toast.error(t("newUi.sidebar.snippets.deleteFailed"));
}
@@ -1109,7 +1260,11 @@ export function SnippetsPanel({
</span>
</button>
{uncategorizedOpen && (
<div className="flex flex-col gap-2 ml-1">
<div
className="flex flex-col gap-2 ml-1"
onDrop={() => handleDrop(null)}
onDragOver={(e) => e.preventDefault()}
>
{uncategorizedSnippets.map((snippet) => (
<SnippetCard
key={snippet.id}
@@ -1119,6 +1274,16 @@ export function SnippetsPanel({
onDelete={handleDeleteSnippet}
onEdit={handleEditSnippet}
onShare={setShareSnippet}
onConfirmRun={handleConfirmRun}
onDragStart={() => handleDragStart(snippet)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, snippet.id)}
dropIndicator={
dropTarget?.id === snippet.id
? dropTarget.position
: null
}
isDragging={draggedSnippet?.id === snippet.id}
t={t}
/>
))}
@@ -1188,7 +1353,11 @@ export function SnippetsPanel({
</DropdownMenu>
</div>
{folder.open && (
<div className="flex flex-col gap-2 ml-1">
<div
className="flex flex-col gap-2 ml-1"
onDragOver={(e) => e.preventDefault()}
onDrop={() => handleDrop(folder.name)}
>
{folderSnippets.map((snippet) => (
<SnippetCard
key={snippet.id}
@@ -1198,6 +1367,16 @@ export function SnippetsPanel({
onDelete={handleDeleteSnippet}
onEdit={handleEditSnippet}
onShare={setShareSnippet}
onConfirmRun={handleConfirmRun}
onDragStart={() => handleDragStart(snippet)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, snippet.id)}
dropIndicator={
dropTarget?.id === snippet.id
? dropTarget.position
: null
}
isDragging={draggedSnippet?.id === snippet.id}
t={t}
/>
))}
+145 -77
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/button";
import { Separator } from "@/components/separator";
import { LayoutPanelLeft, X } from "lucide-react";
import { LayoutPanelLeft, X, ChevronDown } from "lucide-react";
import { PANE_COUNTS, SPLIT_MODES } from "@/lib/theme";
import { tabIcon } from "@/shell/tabUtils";
import type { Tab, SplitMode } from "@/types/ui-types";
@@ -72,23 +72,24 @@ export function SplitScreenPanel({
setSplitMode,
paneTabIds,
setPaneTabIds,
onAssignPane,
}: {
tabs: Tab[];
splitMode: SplitMode;
setSplitMode: (m: SplitMode) => void;
paneTabIds: (string | null)[];
setPaneTabIds: (ids: (string | null)[]) => void;
onAssignPane: (paneIndex: number, tabId: string) => void;
}) {
const { t } = useTranslation();
const paneCount = PANE_COUNTS[splitMode];
const [draggingTabId, setDraggingTabId] = useState<string | null>(null);
const [dragOverPane, setDragOverPane] = useState<number | null>(null);
const [quickAssignPane, setQuickAssignPane] = useState<number | null>(null);
function handleDrop(paneIndex: number) {
if (draggingTabId === null) return;
const next = [...paneTabIds];
next[paneIndex] = draggingTabId;
setPaneTabIds(next);
onAssignPane(paneIndex, draggingTabId);
setDraggingTabId(null);
setDragOverPane(null);
}
@@ -172,51 +173,104 @@ export function SplitScreenPanel({
? tabs.find((t) => t.id === assignedId)
: null;
const isOver = dragOverPane === i;
const isQuickAssignOpen = quickAssignPane === i;
return (
<div
key={i}
onDragOver={(e) => {
e.preventDefault();
setDragOverPane(i);
}}
onDragLeave={() => setDragOverPane(null)}
onDrop={() => handleDrop(i)}
className={`relative flex flex-col items-center justify-center gap-1 p-2 min-h-[52px] border transition-colors ${
isOver
? "border-accent-brand bg-accent-brand/10"
: assignedTab
? "border-border bg-muted/20"
: "border-dashed border-border/60 bg-muted/5 hover:border-border hover:bg-muted/10"
}`}
>
<span className="absolute top-1 left-1.5 text-[10px] text-muted-foreground/40 font-mono leading-none">
{i + 1}
</span>
{assignedTab ? (
<>
<div className="flex items-center gap-1 px-1 w-full justify-center">
<span className="text-muted-foreground shrink-0">
{tabIcon(assignedTab.type)}
</span>
<span className="text-xs font-semibold truncate max-w-[70px]">
{assignedTab.type === "dashboard"
? t("newUi.sidebar.splitScreen.dashboard")
: assignedTab.label}
</span>
</div>
<button
onClick={() => clearPane(i)}
className="absolute top-0.5 right-0.5 size-4 flex items-center justify-center text-muted-foreground hover:text-foreground"
>
<X className="size-2.5" />
</button>
</>
) : (
<span className="text-xs text-muted-foreground/40">
{isOver
? t("newUi.sidebar.splitScreen.dropHere")
: t("newUi.sidebar.splitScreen.emptyPane")}
<div key={i} className="flex flex-col gap-0.5">
<div
onDragOver={(e) => {
e.preventDefault();
setDragOverPane(i);
}}
onDragLeave={() => setDragOverPane(null)}
onDrop={() => handleDrop(i)}
className={`relative flex flex-col items-center justify-center gap-1 p-2 min-h-[52px] border transition-colors ${
isOver
? "border-accent-brand bg-accent-brand/10"
: assignedTab
? "border-accent-brand/30 bg-accent-brand/5"
: "border-dashed border-border/60 bg-muted/5 hover:border-border hover:bg-muted/10"
}`}
>
<span className="absolute top-1 left-1.5 text-[10px] text-muted-foreground/40 font-mono leading-none">
{i + 1}
</span>
{assignedTab ? (
<>
<div className="flex items-center gap-1 px-1 w-full justify-center">
<span className="text-accent-brand shrink-0">
{tabIcon(assignedTab.type)}
</span>
<span className="text-xs font-semibold truncate max-w-[70px] text-foreground">
{assignedTab.type === "dashboard"
? t("newUi.sidebar.splitScreen.dashboard")
: assignedTab.label}
</span>
</div>
<button
onClick={() => clearPane(i)}
className="absolute top-0.5 right-0.5 size-4 flex items-center justify-center text-muted-foreground hover:text-foreground"
>
<X className="size-2.5" />
</button>
</>
) : (
<span className="text-xs text-muted-foreground/40">
{isOver
? t("newUi.sidebar.splitScreen.dropHere")
: t("newUi.sidebar.splitScreen.emptyPane")}
</span>
)}
</div>
{/* Quick assign button */}
{!assignedTab && (
<div className="relative">
<button
onClick={() =>
setQuickAssignPane(isQuickAssignOpen ? null : i)
}
className="flex items-center justify-center gap-1 w-full py-0.5 text-[10px] text-muted-foreground/60 hover:text-accent-brand hover:bg-accent-brand/5 border border-dashed border-border/40 hover:border-accent-brand/30 transition-colors"
>
{t("newUi.sidebar.splitScreen.quickAssign")}
<ChevronDown
className={`size-2.5 transition-transform ${isQuickAssignOpen ? "rotate-180" : ""}`}
/>
</button>
{isQuickAssignOpen && (
<div className="absolute top-full left-0 right-0 z-20 bg-popover border border-border shadow-md mt-0.5 max-h-40 overflow-y-auto">
{tabs
.filter(
(tab) =>
tab.type !== "dashboard" &&
!paneTabIds.includes(tab.id),
)
.map((tab) => (
<button
key={tab.id}
onClick={() => {
onAssignPane(i, tab.id);
setQuickAssignPane(null);
}}
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs text-left hover:bg-accent hover:text-accent-foreground"
>
<span className="text-muted-foreground shrink-0">
{tabIcon(tab.type)}
</span>
<span className="truncate">{tab.label}</span>
</button>
))}
{tabs.filter(
(tab) =>
tab.type !== "dashboard" &&
!paneTabIds.includes(tab.id),
).length === 0 && (
<div className="px-2 py-2 text-xs text-muted-foreground/60">
All tabs assigned
</div>
)}
</div>
)}
</div>
)}
</div>
);
@@ -234,37 +288,51 @@ export function SplitScreenPanel({
{t("newUi.sidebar.splitScreen.dragTabsHint")}
</span>
<div className="flex flex-col gap-1 mt-0.5">
{tabs.map((tab) => (
<div
key={tab.id}
draggable
onDragStart={() => setDraggingTabId(tab.id)}
onDragEnd={() => {
setDraggingTabId(null);
setDragOverPane(null);
}}
className={`flex items-center gap-2 px-2.5 py-2 border cursor-grab active:cursor-grabbing select-none transition-colors ${
draggingTabId === tab.id
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/30"
}`}
>
<span className="text-muted-foreground shrink-0">
{tabIcon(tab.type)}
</span>
<span className="text-xs font-medium flex-1 truncate">
{tab.type === "dashboard"
? t("newUi.sidebar.splitScreen.dashboard")
: tab.label}
</span>
<div className="grid grid-cols-2 gap-px opacity-30 shrink-0">
<div className="size-1 bg-muted-foreground rounded-full" />
<div className="size-1 bg-muted-foreground rounded-full" />
<div className="size-1 bg-muted-foreground rounded-full" />
<div className="size-1 bg-muted-foreground rounded-full" />
{tabs.map((tab) => {
const paneIdx = paneTabIds.indexOf(tab.id);
const isAssigned = paneIdx !== -1;
return (
<div
key={tab.id}
draggable={!isAssigned}
onDragStart={() => !isAssigned && setDraggingTabId(tab.id)}
onDragEnd={() => {
setDraggingTabId(null);
setDragOverPane(null);
}}
className={`flex items-center gap-2 px-2.5 py-2 border select-none transition-colors ${
isAssigned
? "border-accent-brand/20 bg-accent-brand/5 opacity-60 cursor-default"
: draggingTabId === tab.id
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand cursor-grabbing"
: "border-border hover:border-muted-foreground/40 hover:bg-muted/30 cursor-grab active:cursor-grabbing"
}`}
>
<span className="text-muted-foreground shrink-0">
{tabIcon(tab.type)}
</span>
<span className="text-xs font-medium flex-1 truncate">
{tab.type === "dashboard"
? t("newUi.sidebar.splitScreen.dashboard")
: tab.label}
</span>
{isAssigned ? (
<span className="text-[10px] text-accent-brand/60 font-mono shrink-0">
{t("newUi.sidebar.splitScreen.alreadyAssigned", {
index: paneIdx + 1,
})}
</span>
) : (
<div className="grid grid-cols-2 gap-px opacity-30 shrink-0">
<div className="size-1 bg-muted-foreground rounded-full" />
<div className="size-1 bg-muted-foreground rounded-full" />
<div className="size-1 bg-muted-foreground rounded-full" />
<div className="size-1 bg-muted-foreground rounded-full" />
</div>
)}
</div>
</div>
))}
);
})}
</div>
</div>
+46 -36
View File
@@ -377,9 +377,13 @@ function PasswordChangeSection({
export function UserProfilePanel({
username,
onLogout,
userPrefs,
onPrefsChange,
}: {
username?: string;
onLogout?: () => void;
userPrefs?: { reopenTabsOnLogin: boolean };
onPrefsChange?: (prefs: { reopenTabsOnLogin: boolean }) => void;
}) {
const { t } = useTranslation();
const themeLabel: Record<ThemeId, string> = {
@@ -448,9 +452,6 @@ export function UserProfilePanel({
);
// Settings toggles — all backed by localStorage
const [fileColorCoding, setFileColorCoding] = useState(
() => localStorage.getItem("fileColorCoding") !== "false",
);
const [commandAutocomplete, setCommandAutocomplete] = useState(
() => localStorage.getItem("commandAutocomplete") === "true",
);
@@ -464,13 +465,16 @@ export function UserProfilePanel({
const v = localStorage.getItem("commandPaletteShortcutEnabled");
return v !== null ? v === "true" : true;
});
const [sessionPersistence, setSessionPersistence] = useState(
() => localStorage.getItem("enableTerminalSessionPersistence") !== "false",
);
const [showHostTags, setShowHostTags] = useState(() => {
const v = localStorage.getItem("showHostTags");
return v !== null ? v === "true" : true;
});
const [hostTrayOnClick, setHostTrayOnClick] = useState(
() => localStorage.getItem("hostTrayOnClick") === "true",
);
const [pinAppRail, setPinAppRail] = useState(
() => localStorage.getItem("pinAppRail") === "true",
);
const [foldersCollapsed, setFoldersCollapsed] = useState(
() => localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
);
@@ -905,25 +909,6 @@ export function UserProfilePanel({
</div>
</div>
<div 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.settingsFileManager")}
</span>
<SettingRow
label={t("newUi.sidebar.userProfile.fileColorCoding")}
description={t("newUi.sidebar.userProfile.fileColorCodingDesc")}
>
<FakeSwitch
checked={fileColorCoding}
onChange={(v) => {
setFileColorCoding(v);
localStorage.setItem("fileColorCoding", v.toString());
window.dispatchEvent(new Event("fileColorCodingChanged"));
}}
/>
</SettingRow>
</div>
<div 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.settingsTerminal")}
@@ -990,24 +975,23 @@ export function UserProfilePanel({
"commandPaletteShortcutEnabled",
v.toString(),
);
window.dispatchEvent(
new Event("commandPaletteShortcutEnabledChanged"),
);
}}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.sessionPersistence")}
description={t(
"newUi.sidebar.userProfile.sessionPersistenceDesc",
)}
badge="BETA"
label={t("newUi.sidebar.userProfile.reopenTabsOnLogin")}
description={t("newUi.sidebar.userProfile.reopenTabsOnLoginDesc")}
>
<FakeSwitch
checked={sessionPersistence}
checked={userPrefs?.reopenTabsOnLogin ?? false}
onChange={(v) => {
setSessionPersistence(v);
localStorage.setItem(
"enableTerminalSessionPersistence",
v.toString(),
);
onPrefsChange?.({ reopenTabsOnLogin: v });
import("@/main-axios").then(({ saveUserPreferences }) => {
saveUserPreferences({ reopenTabsOnLogin: v }).catch(() => {});
});
}}
/>
</SettingRow>
@@ -1042,6 +1026,32 @@ export function UserProfilePanel({
}}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.hostTrayOnClick")}
description={t("newUi.sidebar.userProfile.hostTrayOnClickDesc")}
>
<FakeSwitch
checked={hostTrayOnClick}
onChange={(v) => {
setHostTrayOnClick(v);
localStorage.setItem("hostTrayOnClick", v.toString());
window.dispatchEvent(new Event("hostTrayOnClickChanged"));
}}
/>
</SettingRow>
<SettingRow
label={t("newUi.sidebar.userProfile.pinAppRail")}
description={t("newUi.sidebar.userProfile.pinAppRailDesc")}
>
<FakeSwitch
checked={pinAppRail}
onChange={(v) => {
setPinAppRail(v);
localStorage.setItem("pinAppRail", v.toString());
window.dispatchEvent(new Event("pinAppRailChanged"));
}}
/>
</SettingRow>
</div>
<div className="flex flex-col gap-1 border-t border-border pt-3">