feat: improve all connection types

This commit is contained in:
LukeGus
2026-05-13 17:44:13 -05:00
parent 84a8dfa050
commit 5bf59f87c0
38 changed files with 2661 additions and 952 deletions
+1
View File
@@ -1580,6 +1580,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
JSON.stringify({ JSON.stringify({
type: "disconnected", type: "disconnected",
message: "Connection lost", message: "Connection lost",
graceful: true,
}), }),
); );
} }
+6 -2
View File
@@ -118,6 +118,7 @@ export type Host = {
telnetPassword?: string; telnetPassword?: string;
guacamoleConfig?: Record<string, any>; guacamoleConfig?: Record<string, any>;
forceKeyboardInteractive?: boolean;
}; };
export type Credential = { export type Credential = {
@@ -180,6 +181,9 @@ export type Tab = {
type: TabType; type: TabType;
label: string; label: string;
host?: Host; host?: Host;
terminalRef?: import("react").RefObject<{
sendInput?: (data: string) => void;
} | null>;
}; };
export type DockerContainerStatus = export type DockerContainerStatus =
@@ -274,8 +278,8 @@ export type Snippet = {
id: number; id: number;
name: string; name: string;
description?: string; description?: string;
command: string; content: string;
folderId: number | null; folder: string | null;
}; };
export const FOLDER_ICONS = [ export const FOLDER_ICONS = [
+24 -5
View File
@@ -4,7 +4,7 @@ import { Separator } from "@/components/separator";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Sheet, SheetContent } from "@/components/sheet"; import { Sheet, SheetContent } from "@/components/sheet";
import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react"; import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react";
import { useState, useRef, useCallback, useEffect } from "react"; import { useState, useRef, useCallback, useEffect, createRef } from "react";
import { useIsMobile } from "@/hooks/use-mobile"; import { useIsMobile } from "@/hooks/use-mobile";
import { MobileBottomBar } from "@/shell/MobileBottomBar"; import { MobileBottomBar } from "@/shell/MobileBottomBar";
import { CommandPalette } from "@/shell/CommandPalette"; import { CommandPalette } from "@/shell/CommandPalette";
@@ -164,6 +164,9 @@ export function AppShell({
null, null,
); );
const lastShiftTime = useRef(0); const lastShiftTime = useRef(0);
const terminalRefs = useRef<Map<string, ReturnType<typeof createRef>>>(
new Map(),
);
const sidebarTitle: Record<RailView, string> = { const sidebarTitle: Record<RailView, string> = {
hosts: "Hosts", hosts: "Hosts",
@@ -282,11 +285,15 @@ export function AppShell({
t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name, t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name,
); );
if (same.length === 0) { if (same.length === 0) {
const tabId = `${host.name}-${type}`;
const ref = type === "terminal" ? createRef() : undefined;
if (ref) terminalRefs.current.set(tabId, ref);
const tab = { const tab = {
id: `${host.name}-${type}`, id: tabId,
type, type,
label: host.name, label: host.name,
host, host,
terminalRef: ref,
}; };
setActiveTabId(tab.id); setActiveTabId(tab.id);
return [...prev, tab]; return [...prev, tab];
@@ -296,11 +303,15 @@ export function AppShell({
? { ...t, label: `${host.name} (1)`, host } ? { ...t, label: `${host.name} (1)`, host }
: t, : t,
); );
const tabId = `${host.name}-${type}-${Date.now()}`;
const ref = type === "terminal" ? createRef() : undefined;
if (ref) terminalRefs.current.set(tabId, ref);
const tab = { const tab = {
id: `${host.name}-${type}-${Date.now()}`, id: tabId,
type, type,
label: `${host.name} (${same.length + 1})`, label: `${host.name} (${same.length + 1})`,
host, host,
terminalRef: ref,
}; };
setActiveTabId(tab.id); setActiveTabId(tab.id);
return [...next, tab]; return [...next, tab];
@@ -355,6 +366,7 @@ export function AppShell({
} }
function closeTab(id: string) { function closeTab(id: string) {
terminalRefs.current.delete(id);
setTabs((prev) => { setTabs((prev) => {
const next = prev.filter((t) => t.id !== id); const next = prev.filter((t) => t.id !== id);
if (id === activeTabId) setActiveTabId(next[next.length - 1].id); if (id === activeTabId) setActiveTabId(next[next.length - 1].id);
@@ -428,7 +440,14 @@ export function AppShell({
/> />
)} )}
{railView === "quick-connect" && <QuickConnectPanel />} {railView === "quick-connect" && (
<QuickConnectPanel
onConnect={(host, type) => {
openTab(host, type);
if (isMobile) setSidebarOpen(false);
}}
/>
)}
{railView === "ssh-tools" && ( {railView === "ssh-tools" && (
<div className="flex-1 min-h-0 overflow-y-auto"> <div className="flex-1 min-h-0 overflow-y-auto">
@@ -607,7 +626,7 @@ export function AppShell({
onOpenTab={openTab} onOpenTab={openTab}
/> />
) : activeTab ? ( ) : activeTab ? (
renderTabContent(activeTab, openSingletonTab, openTab) renderTabContent(activeTab, openSingletonTab, openTab, closeTab)
) : null} ) : null}
</div> </div>
</div> </div>
+14 -7
View File
@@ -19,6 +19,7 @@ import {
getUserInfo, getUserInfo,
getRegistrationAllowed, getRegistrationAllowed,
getPasswordLoginAllowed, getPasswordLoginAllowed,
getPasswordResetAllowed,
getOIDCConfig, getOIDCConfig,
getSetupRequired, getSetupRequired,
initiatePasswordReset, initiatePasswordReset,
@@ -231,6 +232,7 @@ export function Auth({ onLogin }: AuthProps) {
const [registrationAllowed, setRegistrationAllowed] = useState(true); const [registrationAllowed, setRegistrationAllowed] = useState(true);
const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true); const [passwordLoginAllowed, setPasswordLoginAllowed] = useState(true);
const [passwordResetAllowed, setPasswordResetAllowed] = useState(true);
const [oidcConfigured, setOidcConfigured] = useState(false); const [oidcConfigured, setOidcConfigured] = useState(false);
const [firstUser, setFirstUser] = useState(false); const [firstUser, setFirstUser] = useState(false);
const [dbConnectionFailed, setDbConnectionFailed] = useState(false); const [dbConnectionFailed, setDbConnectionFailed] = useState(false);
@@ -255,6 +257,9 @@ export function Auth({ onLogin }: AuthProps) {
getPasswordLoginAllowed() getPasswordLoginAllowed()
.then((res) => setPasswordLoginAllowed(res.allowed)) .then((res) => setPasswordLoginAllowed(res.allowed))
.catch(() => {}); .catch(() => {});
getPasswordResetAllowed()
.then((allowed) => setPasswordResetAllowed(allowed))
.catch(() => setPasswordResetAllowed(false));
getOIDCConfig() getOIDCConfig()
.then((res) => setOidcConfigured(!!res)) .then((res) => setOidcConfigured(!!res))
.catch(() => setOidcConfigured(false)); .catch(() => setOidcConfigured(false));
@@ -1108,13 +1113,15 @@ export function Auth({ onLogin }: AuthProps) {
{t("auth.rememberMe")} {t("auth.rememberMe")}
</label> </label>
</div> </div>
<button {passwordResetAllowed && (
type="button" <button
onClick={() => switchView("reset")} type="button"
className="text-xs text-muted-foreground hover:text-accent-brand transition-colors" onClick={() => switchView("reset")}
> className="text-xs text-muted-foreground hover:text-accent-brand transition-colors"
{t("auth.forgotPassword")} >
</button> {t("auth.forgotPassword")}
</button>
)}
</div> </div>
<Button <Button
type="submit" type="submit"
+1 -1
View File
@@ -13,7 +13,7 @@ export function SectionCard({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div className="flex flex-col border border-border bg-card"> <div className="flex flex-col border border-border bg-card overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0"> <div className="flex items-center gap-2 px-4 py-2.5 border-b border-border shrink-0">
<span className="text-muted-foreground">{icon}</span> <span className="text-muted-foreground">{icon}</span>
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground flex-1"> <span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground flex-1">
+1 -4
View File
@@ -4,7 +4,7 @@ import { Alert, AlertDescription } from "@/components/alert.tsx";
import { Button } from "@/components/button.tsx"; import { Button } from "@/components/button.tsx";
import { Card } from "@/components/card.tsx"; import { Card } from "@/components/card.tsx";
import { Input } from "@/components/input.tsx"; import { Input } from "@/components/input.tsx";
import { AlertCircle, Box, RefreshCw, Search, Settings } from "lucide-react"; import { AlertCircle, Box, RefreshCw, Search } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type { SSHHost, DockerContainer, DockerValidation } from "@/types"; import type { SSHHost, DockerContainer, DockerValidation } from "@/types";
@@ -709,9 +709,6 @@ function DockerManagerInner({
className={`size-4 text-accent-brand ${isLoadingContainers ? "animate-spin" : ""}`} className={`size-4 text-accent-brand ${isLoadingContainers ? "animate-spin" : ""}`}
/> />
</Button> </Button>
<Button variant="ghost" size="icon">
<Settings className="size-4 text-accent-brand" />
</Button>
</div> </div>
</Card> </Card>
@@ -20,6 +20,12 @@ import type { SSHHost } from "@/types";
import { isElectron } from "@/main-axios.ts"; import { isElectron } from "@/main-axios.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx"; import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {
TERMINAL_THEMES,
DEFAULT_TERMINAL_CONFIG,
TERMINAL_FONTS,
} from "@/lib/terminal-themes";
import { useTheme } from "@/components/theme-provider";
interface ConsoleTerminalProps { interface ConsoleTerminalProps {
containerId: string; containerId: string;
@@ -35,7 +41,35 @@ export function ConsoleTerminal({
hostConfig, hostConfig,
}: ConsoleTerminalProps): React.ReactElement { }: ConsoleTerminalProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const { theme: appTheme } = useTheme();
const { instance: terminal, ref: xtermRef } = useXTerm(); const { instance: terminal, ref: xtermRef } = useXTerm();
const terminalConfig = React.useMemo(
() => ({ ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }),
[hostConfig.terminalConfig],
);
const isDarkMode =
appTheme === "dark" ||
appTheme === "dracula" ||
appTheme === "gentlemansChoice" ||
appTheme === "midnightEspresso" ||
appTheme === "catppuccinMocha" ||
(appTheme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
const themeColors = React.useMemo(() => {
const activeTheme = terminalConfig.theme;
if (activeTheme === "termix") {
return isDarkMode
? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors;
}
return (
TERMINAL_THEMES[activeTheme]?.colors ?? TERMINAL_THEMES.termixDark.colors
);
}, [terminalConfig.theme, isDarkMode]);
const [isConnected, setIsConnected] = React.useState(false); const [isConnected, setIsConnected] = React.useState(false);
const [isConnecting, setIsConnecting] = React.useState(false); const [isConnecting, setIsConnecting] = React.useState(false);
const [selectedShell, setSelectedShell] = React.useState<string>("bash"); const [selectedShell, setSelectedShell] = React.useState<string>("bash");
@@ -57,9 +91,18 @@ export function ConsoleTerminal({
terminal.loadAddon(clipboardAddon); terminal.loadAddon(clipboardAddon);
terminal.loadAddon(webLinksAddon); terminal.loadAddon(webLinksAddon);
terminal.options.cursorBlink = true; const fontConfig = TERMINAL_FONTS.find(
terminal.options.fontSize = 14; (f) => f.value === terminalConfig.fontFamily,
terminal.options.fontFamily = "monospace"; );
const fontFamily = fontConfig?.fallback ?? TERMINAL_FONTS[0].fallback;
terminal.options.cursorBlink = terminalConfig.cursorBlink;
terminal.options.cursorStyle = terminalConfig.cursorStyle;
terminal.options.fontSize = terminalConfig.fontSize;
terminal.options.fontFamily = fontFamily;
terminal.options.scrollback = terminalConfig.scrollback;
terminal.options.letterSpacing = terminalConfig.letterSpacing;
terminal.options.lineHeight = terminalConfig.lineHeight;
const readTextFromClipboard = async (): Promise<string> => { const readTextFromClipboard = async (): Promise<string> => {
if (window.electronClipboard) { if (window.electronClipboard) {
@@ -157,16 +200,29 @@ export function ConsoleTerminal({
return true; return true;
}); });
const backgroundColor = getComputedStyle(document.documentElement)
.getPropertyValue("--bg-elevated")
.trim();
const foregroundColor = getComputedStyle(document.documentElement)
.getPropertyValue("--foreground")
.trim();
terminal.options.theme = { terminal.options.theme = {
background: backgroundColor || "var(--bg-elevated)", background: themeColors.background,
foreground: foregroundColor || "var(--foreground)", foreground: themeColors.foreground,
cursor: themeColors.cursor,
cursorAccent: themeColors.cursorAccent,
selectionBackground: themeColors.selectionBackground,
selectionForeground: themeColors.selectionForeground,
black: themeColors.black,
red: themeColors.red,
green: themeColors.green,
yellow: themeColors.yellow,
blue: themeColors.blue,
magenta: themeColors.magenta,
cyan: themeColors.cyan,
white: themeColors.white,
brightBlack: themeColors.brightBlack,
brightRed: themeColors.brightRed,
brightGreen: themeColors.brightGreen,
brightYellow: themeColors.brightYellow,
brightBlue: themeColors.brightBlue,
brightMagenta: themeColors.brightMagenta,
brightCyan: themeColors.brightCyan,
brightWhite: themeColors.brightWhite,
}; };
setTimeout(() => { setTimeout(() => {
@@ -207,7 +263,7 @@ export function ConsoleTerminal({
terminal.dispose(); terminal.dispose();
}; };
}, [terminal, t]); }, [terminal, t, terminalConfig, themeColors]);
const disconnect = React.useCallback(() => { const disconnect = React.useCallback(() => {
if (wsRef.current) { if (wsRef.current) {
@@ -498,7 +554,10 @@ export function ConsoleTerminal({
</CardContent> </CardContent>
</Card> </Card>
<Card className="flex-1 overflow-hidden pt-1 pb-0"> <Card
className="flex-1 overflow-hidden pt-1 pb-0"
style={{ background: themeColors.background }}
>
<CardContent className="p-0 h-full relative"> <CardContent className="p-0 h-full relative">
<div <div
ref={xtermRef} ref={xtermRef}
+35 -17
View File
@@ -562,8 +562,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
currentLoadingPathRef.current = resolvedPath; currentLoadingPathRef.current = resolvedPath;
setIsLoading(true); setIsLoading(true);
setCreateIntent(null);
try { try {
const response = await listSSHFiles(sshSessionId, resolvedPath); const response = await listSSHFiles(sshSessionId, resolvedPath);
@@ -660,9 +658,17 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return false; return false;
} else if (initialLoadDoneRef.current) { } else if (initialLoadDoneRef.current) {
toast.error( const isPermissionDenied =
t("fileManager.failedToLoadDirectory") + ": " + errorMessage, httpStatus === 403 ||
); errorMessage?.toLowerCase().includes("permission denied") ||
errorMessage?.toLowerCase().includes("eacces");
if (isPermissionDenied) {
toast.error(t("fileManager.permissionDenied"));
} else {
toast.error(
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
);
}
} }
} }
return false; return false;
@@ -1079,14 +1085,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
t("fileManager.newFolderDefault"), t("fileManager.newFolderDefault"),
"directory", "directory",
); );
const newCreateIntent = { setCreateIntent({
id: Date.now().toString(), id: Date.now().toString(),
type: "directory" as const, type: "directory" as const,
defaultName, defaultName,
currentName: defaultName, currentName: defaultName,
}; });
setCreateIntent(newCreateIntent);
} }
function handleCreateNewFile() { function handleCreateNewFile() {
@@ -1094,13 +1098,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
t("fileManager.newFileDefault"), t("fileManager.newFileDefault"),
"file", "file",
); );
const newCreateIntent = { setCreateIntent({
id: Date.now().toString(), id: Date.now().toString(),
type: "file" as const, type: "file" as const,
defaultName, defaultName,
currentName: defaultName, currentName: defaultName,
}; });
setCreateIntent(newCreateIntent);
} }
const handleSymlinkClick = async (file: FileItem) => { const handleSymlinkClick = async (file: FileItem) => {
@@ -2442,7 +2445,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
} }
return ( return (
<div className="h-full flex flex-col bg-background relative"> <div className="h-full flex flex-col bg-background relative overflow-hidden isolate">
<div <div
className="h-full w-full flex flex-col" className="h-full w-full flex flex-col"
style={{ style={{
@@ -2617,16 +2620,21 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
className="w-44 rounded-none border-border bg-card" className="w-44 rounded-none border-border bg-card"
onCloseAutoFocus={(e) => e.preventDefault()}
> >
<DropdownMenuItem <DropdownMenuItem
onClick={handleCreateNewFolder} onSelect={() => {
setTimeout(() => handleCreateNewFolder(), 0);
}}
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand" className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
> >
<FolderPlus className="size-4 text-accent-brand" /> <FolderPlus className="size-4 text-accent-brand" />
{t("fileManager.newFolder")} {t("fileManager.newFolder")}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={handleCreateNewFile} onSelect={() => {
setTimeout(() => handleCreateNewFile(), 0);
}}
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand" className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
> >
<FilePlus className="size-4 text-muted-foreground" /> <FilePlus className="size-4 text-muted-foreground" />
@@ -2767,6 +2775,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onSelectionChange={setSelection} onSelectionChange={setSelection}
currentPath={currentPath} currentPath={currentPath}
isLoading={isLoading} isLoading={isLoading}
isConnected={!!sshSessionId}
onPathChange={navigateTo} onPathChange={navigateTo}
onRefresh={handleRefreshDirectory} onRefresh={handleRefreshDirectory}
onUpload={handleFilesDropped} onUpload={handleFilesDropped}
@@ -2780,7 +2789,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setSortOrder("asc"); setSortOrder("asc");
} }
}} }}
onDownload={(files) => files.forEach(handleDownloadFile)} onDownload={(files) =>
files
.filter((f) => f.type === "file")
.forEach(handleDownloadFile)
}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
viewMode={viewMode} viewMode={viewMode}
onRename={handleRenameConfirm} onRename={handleRenameConfirm}
@@ -2812,7 +2825,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onClose={() => onClose={() =>
setContextMenu((prev) => ({ ...prev, isVisible: false })) setContextMenu((prev) => ({ ...prev, isVisible: false }))
} }
onDownload={(files) => files.forEach(handleDownloadFile)} onDownload={(files) =>
files
.filter((f) => f.type === "file")
.forEach(handleDownloadFile)
}
onPreview={handleFileOpen}
onRename={handleRenameFile} onRename={handleRenameFile}
onCopy={handleCopyFiles} onCopy={handleCopyFiles}
onCut={handleCutFiles} onCut={handleCutFiles}
@@ -536,7 +536,7 @@ export function FileManagerContextMenu({
ref={menuRef} ref={menuRef}
data-context-menu data-context-menu
className={cn( className={cn(
"fixed bg-canvas border border-edge rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-x-hidden overflow-y-auto", "fixed bg-card border border-border rounded-none shadow-md min-w-[220px] max-w-[300px] z-[99995] overflow-x-hidden overflow-y-auto py-1",
)} )}
style={{ style={{
left: menuPosition.x, left: menuPosition.x,
@@ -549,7 +549,7 @@ export function FileManagerContextMenu({
return ( return (
<div <div
key={`separator-${index}`} key={`separator-${index}`}
className="border-t border-border" className="my-1 border-t border-border"
/> />
); );
} }
@@ -558,10 +558,10 @@ export function FileManagerContextMenu({
<button <button
key={index} key={index}
className={cn( className={cn(
"w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center justify-between", "w-full px-3 min-h-8 py-1.5 text-left text-xs font-semibold flex items-center justify-between gap-3 rounded-none transition-colors cursor-pointer",
"hover:bg-accent-brand/10 hover:text-accent-brand transition-colors cursor-pointer rounded-none", "hover:bg-accent-brand/10 hover:text-accent-brand",
item.disabled && item.disabled &&
"opacity-50 cursor-not-allowed hover:bg-transparent hover:text-current", "opacity-40 cursor-not-allowed hover:bg-transparent hover:text-current",
item.danger && item.danger &&
"text-destructive hover:bg-destructive/10 hover:text-destructive", "text-destructive hover:bg-destructive/10 hover:text-destructive",
)} )}
@@ -573,14 +573,14 @@ export function FileManagerContextMenu({
}} }}
disabled={item.disabled} disabled={item.disabled}
> >
<div className="flex items-center gap-2.5 flex-1 min-w-0"> <div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-shrink-0">{item.icon}</div> <div className="flex-shrink-0 text-muted-foreground">
<span className="flex-1 whitespace-normal break-words"> {item.icon}
{item.label} </div>
</span> <span className="flex-1 leading-tight">{item.label}</span>
</div> </div>
{item.shortcut && ( {item.shortcut && (
<div className="ml-2 flex-shrink-0"> <div className="ml-auto flex-shrink-0 opacity-50">
{renderShortcut(item.shortcut)} {renderShortcut(item.shortcut)}
</div> </div>
)} )}
@@ -67,6 +67,7 @@ interface FileManagerGridProps {
onSelectionChange: (files: FileItem[]) => void; onSelectionChange: (files: FileItem[]) => void;
currentPath: string; currentPath: string;
isLoading?: boolean; isLoading?: boolean;
isConnected?: boolean;
onPathChange: (path: string) => void; onPathChange: (path: string) => void;
onRefresh: () => void; onRefresh: () => void;
onUpload?: (files: FileList) => void; onUpload?: (files: FileList) => void;
@@ -191,6 +192,7 @@ export function FileManagerGrid({
onSelectionChange, onSelectionChange,
currentPath, currentPath,
isLoading, isLoading,
isConnected,
onPathChange, onPathChange,
onRefresh, onRefresh,
onUpload, onUpload,
@@ -525,20 +527,30 @@ export function FileManagerGrid({
e.stopPropagation(); e.stopPropagation();
}, []); }, []);
const handleMouseDown = useCallback((e: React.MouseEvent) => { const handleMouseDown = useCallback(
if (e.target === e.currentTarget && e.button === 0) { (e: React.MouseEvent) => {
e.preventDefault(); if (createIntent) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const target = e.target as HTMLElement;
const startX = e.clientX - rect.left; if (target.tagName !== "INPUT") {
const startY = e.clientY - rect.top; e.preventDefault();
}
return;
}
if (e.target === e.currentTarget && e.button === 0) {
e.preventDefault();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const startX = e.clientX - rect.left;
const startY = e.clientY - rect.top;
setIsSelecting(true); setIsSelecting(true);
setSelectionStart({ x: startX, y: startY }); setSelectionStart({ x: startX, y: startY });
setSelectionRect({ x: startX, y: startY, width: 0, height: 0 }); setSelectionRect({ x: startX, y: startY, width: 0, height: 0 });
setJustFinishedSelecting(false); setJustFinishedSelecting(false);
} }
}, []); },
[createIntent],
);
const handleMouseMove = useCallback( const handleMouseMove = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@@ -695,7 +707,7 @@ export function FileManagerGrid({
const handleFileClick = (file: FileItem, event: React.MouseEvent) => { const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
if (gridRef.current) { if (gridRef.current && !createIntent) {
gridRef.current.focus(); gridRef.current.focus();
} }
@@ -730,7 +742,7 @@ export function FileManagerGrid({
}; };
const handleGridClick = (event: React.MouseEvent) => { const handleGridClick = (event: React.MouseEvent) => {
if (gridRef.current) { if (gridRef.current && !createIntent) {
gridRef.current.focus(); gridRef.current.focus();
} }
@@ -974,6 +986,7 @@ export function FileManagerGrid({
onBlur={handleEditConfirm} onBlur={handleEditConfirm}
className="max-w-[120px] min-w-[60px] w-fit border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 text-center pointer-events-auto" className="max-w-[120px] min-w-[60px] w-fit border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 text-center pointer-events-auto"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/> />
) : ( ) : (
<p <p
@@ -1096,6 +1109,7 @@ export function FileManagerGrid({
onBlur={handleEditConfirm} onBlur={handleEditConfirm}
className="flex-1 min-w-0 max-w-[200px] border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 pointer-events-auto" className="flex-1 min-w-0 max-w-[200px] border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50 pointer-events-auto"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/> />
) : ( ) : (
<span <span
@@ -1227,7 +1241,10 @@ export function FileManagerGrid({
document.body, document.body,
)} )}
<SimpleLoader visible={isLoading} message={t("common.connecting")} /> <SimpleLoader
visible={!!isLoading && !!isConnected}
message={t("common.loading")}
/>
</div> </div>
); );
} }
@@ -1244,24 +1261,49 @@ function CreateIntentGridItem({
const { t } = useTranslation(); const { t } = useTranslation();
const [inputName, setInputName] = useState(intent.currentName); const [inputName, setInputName] = useState(intent.currentName);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const doneRef = useRef(false);
useEffect(() => { useEffect(() => {
inputRef.current?.focus(); const timer = setTimeout(() => {
inputRef.current?.select(); if (inputRef.current) {
}, []); inputRef.current.focus();
inputRef.current.select();
}
}, 50);
return () => clearTimeout(timer);
}, [intent.id]);
const commit = useCallback(
(name: string) => {
if (doneRef.current) return;
doneRef.current = true;
if (name) {
onConfirm?.(name);
} else {
onCancel?.();
}
},
[onConfirm, onCancel],
);
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
onConfirm?.(inputName.trim()); commit(inputName.trim());
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
if (doneRef.current) return;
doneRef.current = true;
onCancel?.(); onCancel?.();
} }
}; };
return ( return (
<div className="group flex flex-col items-center p-3 rounded-none border-2 border-dashed border-accent-brand/60 bg-accent-brand/5"> <div
className="group flex flex-col items-center p-3 rounded-none border-2 border-dashed border-accent-brand/60 bg-accent-brand/5"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="mb-2"> <div className="mb-2">
{intent.type === "directory" ? ( {intent.type === "directory" ? (
<Folder className="size-10 text-accent-brand" /> <Folder className="size-10 text-accent-brand" />
@@ -1275,7 +1317,7 @@ function CreateIntentGridItem({
value={inputName} value={inputName}
onChange={(e) => setInputName(e.target.value)} onChange={(e) => setInputName(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={() => onConfirm?.(inputName.trim())} onBlur={() => commit(inputName.trim())}
className="w-full max-w-[120px] border border-accent-brand/60 bg-card px-2 py-1 text-xs text-center rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50" className="w-full max-w-[120px] border border-accent-brand/60 bg-card px-2 py-1 text-xs text-center rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50"
placeholder={ placeholder={
intent.type === "directory" intent.type === "directory"
@@ -1299,24 +1341,49 @@ function CreateIntentListItem({
const { t } = useTranslation(); const { t } = useTranslation();
const [inputName, setInputName] = useState(intent.currentName); const [inputName, setInputName] = useState(intent.currentName);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const doneRef = useRef(false);
useEffect(() => { useEffect(() => {
inputRef.current?.focus(); const timer = setTimeout(() => {
inputRef.current?.select(); if (inputRef.current) {
}, []); inputRef.current.focus();
inputRef.current.select();
}
}, 50);
return () => clearTimeout(timer);
}, [intent.id]);
const commit = useCallback(
(name: string) => {
if (doneRef.current) return;
doneRef.current = true;
if (name) {
onConfirm?.(name);
} else {
onCancel?.();
}
},
[onConfirm, onCancel],
);
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
onConfirm?.(inputName.trim()); commit(inputName.trim());
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
e.preventDefault(); e.preventDefault();
if (doneRef.current) return;
doneRef.current = true;
onCancel?.(); onCancel?.();
} }
}; };
return ( return (
<div className="grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 items-center border-b border-accent-brand/30 bg-accent-brand/5 rounded-none"> <div
className="grid grid-cols-[1fr_120px_150px_80px_90px] gap-2 px-4 py-2 items-center border-b border-accent-brand/30 bg-accent-brand/5 rounded-none"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="shrink-0"> <div className="shrink-0">
{intent.type === "directory" ? ( {intent.type === "directory" ? (
@@ -1331,7 +1398,7 @@ function CreateIntentListItem({
value={inputName} value={inputName}
onChange={(e) => setInputName(e.target.value)} onChange={(e) => setInputName(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={() => onConfirm?.(inputName.trim())} onBlur={() => commit(inputName.trim())}
className="flex-1 min-w-0 max-w-[200px] border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50" className="flex-1 min-w-0 max-w-[200px] border border-accent-brand/60 bg-card px-2 py-1 text-xs rounded-none outline-none focus:ring-1 focus:ring-accent-brand/50"
placeholder={ placeholder={
intent.type === "directory" intent.type === "directory"
@@ -53,14 +53,20 @@ export function DraggableWindow({
const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [windowStart, setWindowStart] = useState({ x: 0, y: 0 }); const [windowStart, setWindowStart] = useState({ x: 0, y: 0 });
const [sizeStart, setSizeStart] = useState({ width: 0, height: 0 }); const [sizeStart, setSizeStart] = useState({ width: 0, height: 0 });
const containerBoundsRef = useRef({ width: 0, height: 0 });
const windowRef = useRef<HTMLDivElement>(null); const windowRef = useRef<HTMLDivElement>(null);
const titleBarRef = useRef<HTMLDivElement>(null); const titleBarRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (targetSize && !isMaximized) { if (targetSize && !isMaximized) {
const maxWidth = Math.min(window.innerWidth * 0.9, 1200); const container = windowRef.current?.offsetParent as HTMLElement | null;
const maxHeight = Math.min(window.innerHeight * 0.8, 800); const maxWidth = container
? Math.min(container.clientWidth * 0.9, 1200)
: Math.min(window.innerWidth * 0.9, 1200);
const maxHeight = container
? Math.min(container.clientHeight * 0.8, 800)
: Math.min(window.innerHeight * 0.8, 800);
let newWidth = Math.min(targetSize.width + 50, maxWidth); let newWidth = Math.min(targetSize.width + 50, maxWidth);
let newHeight = Math.min(targetSize.height + 150, maxHeight); let newHeight = Math.min(targetSize.height + 150, maxHeight);
@@ -80,8 +86,16 @@ export function DraggableWindow({
setSize({ width: newWidth, height: newHeight }); setSize({ width: newWidth, height: newHeight });
setPosition({ setPosition({
x: Math.max(0, (window.innerWidth - newWidth) / 2), x: Math.max(
y: Math.max(0, (window.innerHeight - newHeight) / 2), 0,
(container ? container.clientWidth : window.innerWidth) / 2 -
newWidth / 2,
),
y: Math.max(
0,
(container ? container.clientHeight : window.innerHeight) / 2 -
newHeight / 2,
),
}); });
} }
}, [targetSize, isMaximized, minWidth, minHeight]); }, [targetSize, isMaximized, minWidth, minHeight]);
@@ -98,6 +112,13 @@ export function DraggableWindow({
setIsDragging(true); setIsDragging(true);
setDragStart({ x: e.clientX, y: e.clientY }); setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: position.x, y: position.y }); setWindowStart({ x: position.x, y: position.y });
const container = windowRef.current?.offsetParent as HTMLElement | null;
containerBoundsRef.current = {
width: container ? container.clientWidth : window.innerWidth,
height: container ? container.clientHeight : window.innerHeight,
};
onFocus?.(); onFocus?.();
}, },
[isMaximized, position, onFocus], [isMaximized, position, onFocus],
@@ -112,50 +133,14 @@ export function DraggableWindow({
const newX = windowStart.x + deltaX; const newX = windowStart.x + deltaX;
const newY = windowStart.y + deltaY; const newY = windowStart.y + deltaY;
const windowElement = windowRef.current; const { width: containerW, height: containerH } =
let positioningContainer = null; containerBoundsRef.current;
let currentElement = windowElement?.parentElement; const maxX = containerW - size.width;
const maxY = containerH - size.height;
while (currentElement && currentElement !== document.body) {
const computedStyle = window.getComputedStyle(currentElement);
const position = computedStyle.position;
const transform = computedStyle.transform;
if (
position === "relative" ||
position === "absolute" ||
position === "fixed" ||
transform !== "none"
) {
positioningContainer = currentElement;
break;
}
currentElement = currentElement.parentElement;
}
let maxX, maxY, minX, minY;
if (positioningContainer) {
const containerRect = positioningContainer.getBoundingClientRect();
maxX = containerRect.width - size.width;
maxY = containerRect.height - size.height;
minX = 0;
minY = 0;
} else {
maxX = window.innerWidth - size.width;
maxY = window.innerHeight - size.height;
minX = 0;
minY = 0;
}
const constrainedX = Math.max(minX, Math.min(maxX, newX));
const constrainedY = Math.max(minY, Math.min(maxY, newY));
setPosition({ setPosition({
x: constrainedX, x: Math.max(0, Math.min(maxX, newX)),
y: constrainedY, y: Math.max(49, Math.min(maxY, newY)),
}); });
} }
@@ -194,8 +179,10 @@ export function DraggableWindow({
} }
} }
newX = Math.max(0, Math.min(window.innerWidth - newWidth, newX)); const { width: containerW, height: containerH } =
newY = Math.max(0, Math.min(window.innerHeight - newHeight, newY)); containerBoundsRef.current;
newX = Math.max(0, Math.min(containerW - newWidth, newX));
newY = Math.max(49, Math.min(containerH - newHeight, newY));
setSize({ width: newWidth, height: newHeight }); setSize({ width: newWidth, height: newHeight });
setPosition({ x: newX, y: newY }); setPosition({ x: newX, y: newY });
@@ -213,7 +200,6 @@ export function DraggableWindow({
windowStart, windowStart,
sizeStart, sizeStart,
size, size,
position,
minWidth, minWidth,
minHeight, minHeight,
resizeDirection, resizeDirection,
@@ -238,6 +224,13 @@ export function DraggableWindow({
setDragStart({ x: e.clientX, y: e.clientY }); setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: position.x, y: position.y }); setWindowStart({ x: position.x, y: position.y });
setSizeStart({ width: size.width, height: size.height }); setSizeStart({ width: size.width, height: size.height });
const container = windowRef.current?.offsetParent as HTMLElement | null;
containerBoundsRef.current = {
width: container ? container.clientWidth : window.innerWidth,
height: container ? container.clientHeight : window.innerHeight,
};
onFocus?.(); onFocus?.();
}, },
[isMaximized, position, size, onFocus], [isMaximized, position, size, onFocus],
@@ -4,8 +4,10 @@ import { AlertCircle, Download } from "lucide-react";
import { Button } from "@/components/button.tsx"; import { Button } from "@/components/button.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import workerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url"; pdfjs.GlobalWorkerOptions.workerSrc = new URL(
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl; "pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
interface PdfPreviewProps { interface PdfPreviewProps {
content: string; content: string;
@@ -168,35 +168,40 @@ export function PermissionsDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-md rounded-none border-border bg-card"> <DialogContent className="w-[calc(100vw-2rem)] sm:max-w-lg rounded-none border-border bg-card">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2"> <DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<Lock className="size-4 text-accent-brand" /> <Lock className="size-4 text-accent-brand" />
{t("fileManager.changePermissions")} {t("fileManager.changePermissions")}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground font-mono"> <DialogDescription className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground font-mono break-all">
{file.path} {file.path}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="py-3 flex flex-col gap-4"> <div className="py-3 flex flex-col gap-4">
<div className="grid grid-cols-4 gap-0 border border-border overflow-hidden"> <div className="border border-border overflow-hidden">
<div className="px-3 py-2 bg-muted/50 border-b border-r border-border text-[10px] font-bold uppercase tracking-widest text-muted-foreground" /> <div className="grid grid-cols-[1fr_64px_64px_64px] bg-muted/50 border-b border-border">
{[ <div className="px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground" />
t("fileManager.read"), {[
t("fileManager.write"), t("fileManager.read"),
t("fileManager.execute"), t("fileManager.write"),
].map((h) => ( t("fileManager.execute"),
<div ].map((h) => (
key={h} <div
className="px-3 py-2 bg-muted/50 border-b border-r border-border last:border-r-0 text-[10px] font-bold uppercase tracking-widest text-muted-foreground text-center" key={h}
> className="py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground text-center border-l border-border"
{h} >
</div> {h}
))} </div>
))}
</div>
{rows.map((row, i) => ( {rows.map((row, i) => (
<React.Fragment key={i}> <div
<div className="px-3 py-2.5 border-b border-r border-border last:border-b-0 text-xs font-semibold truncate"> key={i}
className={`grid grid-cols-[1fr_64px_64px_64px] ${i < rows.length - 1 ? "border-b border-border" : ""}`}
>
<div className="px-3 py-3 text-xs font-semibold">
{row.label} {row.label}
</div> </div>
{[ {[
@@ -206,29 +211,28 @@ export function PermissionsDialog({
].map((perm, j) => ( ].map((perm, j) => (
<div <div
key={j} key={j}
className="flex items-center justify-center border-b border-r border-border last:border-r-0 py-2.5" className="flex items-center justify-center border-l border-border py-3"
> >
<input <input
type="checkbox" type="checkbox"
checked={perm.val} checked={perm.val}
onChange={(e) => perm.set(e.target.checked)} onChange={(e) => perm.set(e.target.checked)}
className="accent-[var(--accent-brand)] size-3.5 cursor-pointer" className="accent-[var(--accent-brand)] size-4 cursor-pointer"
/> />
</div> </div>
))} ))}
</React.Fragment> </div>
))} ))}
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground"> <span className="text-xs font-bold uppercase tracking-widest text-muted-foreground shrink-0">
{t("fileManager.octal")} {t("fileManager.octal")}
</span> </span>
<Input <Input
value={octal} value={octal}
readOnly readOnly
className="w-20 rounded-none bg-muted/50 border-border text-xs font-mono text-center h-8" className="w-20 rounded-none bg-muted/50 border-border text-xs font-mono text-center h-8"
maxLength={3}
/> />
<span className="text-[10px] text-muted-foreground font-mono"> <span className="text-[10px] text-muted-foreground font-mono">
{t("fileManager.currentPermissions")}: {file.permissions || "—"} {t("fileManager.currentPermissions")}: {file.permissions || "—"}
@@ -4,20 +4,7 @@ import { Terminal } from "@/features/terminal/Terminal.tsx";
import { useWindowManager } from "./WindowManager.tsx"; import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext.tsx"; import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
import type { SSHHost } from "@/types/index.ts";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
authType: "password" | "key";
credentialId?: number;
userId?: number;
}
interface TerminalWindowProps { interface TerminalWindowProps {
windowId: string; windowId: string;
@@ -114,8 +101,8 @@ export function TerminalWindow({
zIndex={currentWindow.zIndex} zIndex={currentWindow.zIndex}
> >
<Terminal <Terminal
ref={terminalRef} ref={terminalRef as any}
hostConfig={hostConfig} hostConfig={hostConfig as any}
isVisible={!currentWindow.isMinimized} isVisible={!currentWindow.isMinimized}
initialPath={initialPath} initialPath={initialPath}
executeCommand={executeCommand} executeCommand={executeCommand}
@@ -116,14 +116,19 @@ export function WindowManager({ children }: WindowManagerProps) {
return ( return (
<WindowManagerContext.Provider value={contextValue}> <WindowManagerContext.Provider value={contextValue}>
{children} {children}
<div className="window-container"> <div
{windows.map((window) => ( className="window-container absolute inset-0 pointer-events-none overflow-hidden"
<div key={window.id}> style={{ zIndex: 1000 }}
{typeof window.component === "function" >
? window.component(window.id) <div className="relative w-full h-full pointer-events-none">
: window.component} {windows.map((window) => (
</div> <div key={window.id} className="pointer-events-auto">
))} {typeof window.component === "function"
? window.component(window.id)
: window.component}
</div>
))}
</div>
</div> </div>
</WindowManagerContext.Provider> </WindowManagerContext.Provider>
); );
@@ -606,16 +606,6 @@ function ServerStatsInner({
</div> </div>
<div> <div>
<h1 className="text-lg md:text-2xl font-bold">{title}</h1> <h1 className="text-lg md:text-2xl font-bold">{title}</h1>
<div className="flex items-center gap-2">
<span
className={`size-2 rounded-full ${serverStatus === "online" ? "bg-accent-brand" : "bg-destructive"}`}
/>
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{serverStatus === "online"
? t("serverStats.online")
: t("serverStats.offline")}
</span>
</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-0"> <div className="flex items-center gap-0">
@@ -21,36 +21,40 @@ function Sparkline({
current ?? 0, current ?? 0,
].slice(-20); ].slice(-20);
if (points.length < 2) return null;
const w = 300; const w = 300;
const h = 48; const h = 48;
const max = Math.max(...points, 1);
const coords = points.map((v, i) => {
const x = (i / (points.length - 1)) * w;
const y = h - (v / max) * h;
return `${x},${y}`;
});
const d = `M ${coords.join(" L ")}`; const hasData = points.length >= 2;
const fill = `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z`; const max = hasData ? Math.max(...points, 1) : 1;
const coords = hasData
? points.map((v, i) => {
const x = (i / (points.length - 1)) * w;
const y = h - (v / max) * h;
return `${x},${y}`;
})
: [];
const d = hasData ? `M ${coords.join(" L ")}` : "";
const fill = hasData ? `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z` : "";
return ( return (
<div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden"> <div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden">
<svg {hasData && (
className="absolute inset-0 h-full w-full" <svg
viewBox={`0 0 ${w} ${h}`} className="absolute inset-0 h-full w-full"
preserveAspectRatio="none" viewBox={`0 0 ${w} ${h}`}
> preserveAspectRatio="none"
<path d={fill} fill="currentColor" className="text-accent-brand/10" /> >
<path <path d={fill} fill="currentColor" className="text-accent-brand/10" />
d={d} <path
fill="none" d={d}
stroke="currentColor" fill="none"
strokeWidth="1.5" stroke="currentColor"
className="text-accent-brand/60" strokeWidth="1.5"
/> className="text-accent-brand/60"
</svg> />
</svg>
)}
</div> </div>
); );
} }
@@ -20,36 +20,40 @@ function Sparkline({
current ?? 0, current ?? 0,
].slice(-20); ].slice(-20);
if (points.length < 2) return null;
const w = 300; const w = 300;
const h = 48; const h = 48;
const max = Math.max(...points, 1);
const coords = points.map((v, i) => {
const x = (i / (points.length - 1)) * w;
const y = h - (v / max) * h;
return `${x},${y}`;
});
const d = `M ${coords.join(" L ")}`; const hasData = points.length >= 2;
const fill = `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z`; const max = hasData ? Math.max(...points, 1) : 1;
const coords = hasData
? points.map((v, i) => {
const x = (i / (points.length - 1)) * w;
const y = h - (v / max) * h;
return `${x},${y}`;
})
: [];
const d = hasData ? `M ${coords.join(" L ")}` : "";
const fill = hasData ? `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z` : "";
return ( return (
<div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden"> <div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden">
<svg {hasData && (
className="absolute inset-0 h-full w-full" <svg
viewBox={`0 0 ${w} ${h}`} className="absolute inset-0 h-full w-full"
preserveAspectRatio="none" viewBox={`0 0 ${w} ${h}`}
> preserveAspectRatio="none"
<path d={fill} fill="currentColor" className="text-accent-brand/10" /> >
<path <path d={fill} fill="currentColor" className="text-accent-brand/10" />
d={d} <path
fill="none" d={d}
stroke="currentColor" fill="none"
strokeWidth="1.5" stroke="currentColor"
className="text-accent-brand/60" strokeWidth="1.5"
/> className="text-accent-brand/60"
</svg> />
</svg>
)}
</div> </div>
); );
} }
@@ -132,7 +132,7 @@ export function FirewallWidget({ metrics }: FirewallWidgetProps) {
</span> </span>
)} )}
{firewall && firewall.chains.length > 0 ? ( {firewall && firewall.chains.length > 0 ? (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1 overflow-y-auto max-h-[320px]">
{firewall.chains.map((chain) => ( {firewall.chains.map((chain) => (
<ChainSection key={chain.name} chain={chain} /> <ChainSection key={chain.name} chain={chain} />
))} ))}
@@ -46,40 +46,42 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
{t("serverStats.noRecentLoginData")} {t("serverStats.noRecentLoginData")}
</span> </span>
) : ( ) : (
allLogins.map((login, i) => ( <div className="flex flex-col gap-2 overflow-y-auto max-h-[300px]">
<div {allLogins.map((login, i) => (
key={i} <div
className={`flex items-center justify-between p-2 border ${login.status === "success" ? "border-border bg-muted/30" : "border-destructive/30 bg-destructive/5"}`} key={i}
> className={`flex items-center justify-between p-2 border ${login.status === "success" ? "border-border bg-muted/30" : "border-destructive/30 bg-destructive/5"}`}
<div className="flex flex-col"> >
<div className="flex items-center gap-1.5"> <div className="flex flex-col">
{login.status === "failed" ? ( <div className="flex items-center gap-1.5">
<UserX className="size-3 text-destructive" /> {login.status === "failed" ? (
) : ( <UserX className="size-3 text-destructive" />
<UserCheck className="size-3 text-accent-brand" /> ) : (
)} <UserCheck className="size-3 text-accent-brand" />
<span )}
className={`text-xs font-bold ${login.status === "failed" ? "text-destructive" : ""}`} <span
> className={`text-xs font-bold ${login.status === "failed" ? "text-destructive" : ""}`}
{login.user} >
{login.user}
</span>
</div>
<span className="text-[10px] text-muted-foreground font-mono">
{login.ip}
</span>
</div>
<div className="flex flex-col items-end gap-1">
<span
className={`text-[9px] font-bold uppercase px-1.5 py-px border ${login.status === "success" ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10" : "border-destructive/40 text-destructive"}`}
>
{login.status}
</span>
<span className="text-[10px] text-muted-foreground">
{new Date(login.time).toLocaleTimeString()}
</span> </span>
</div> </div>
<span className="text-[10px] text-muted-foreground font-mono">
{login.ip}
</span>
</div> </div>
<div className="flex flex-col items-end gap-1"> ))}
<span </div>
className={`text-[9px] font-bold uppercase px-1.5 py-px border ${login.status === "success" ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10" : "border-destructive/40 text-destructive"}`}
>
{login.status}
</span>
<span className="text-[10px] text-muted-foreground">
{new Date(login.time).toLocaleTimeString()}
</span>
</div>
</div>
))
)} )}
</div> </div>
</SectionCard> </SectionCard>
@@ -20,36 +20,40 @@ function Sparkline({
current ?? 0, current ?? 0,
].slice(-20); ].slice(-20);
if (points.length < 2) return null;
const w = 300; const w = 300;
const h = 48; const h = 48;
const max = Math.max(...points, 1);
const coords = points.map((v, i) => {
const x = (i / (points.length - 1)) * w;
const y = h - (v / max) * h;
return `${x},${y}`;
});
const d = `M ${coords.join(" L ")}`; const hasData = points.length >= 2;
const fill = `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z`; const max = hasData ? Math.max(...points, 1) : 1;
const coords = hasData
? points.map((v, i) => {
const x = (i / (points.length - 1)) * w;
const y = h - (v / max) * h;
return `${x},${y}`;
})
: [];
const d = hasData ? `M ${coords.join(" L ")}` : "";
const fill = hasData ? `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z` : "";
return ( return (
<div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden"> <div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden">
<svg {hasData && (
className="absolute inset-0 h-full w-full" <svg
viewBox={`0 0 ${w} ${h}`} className="absolute inset-0 h-full w-full"
preserveAspectRatio="none" viewBox={`0 0 ${w} ${h}`}
> preserveAspectRatio="none"
<path d={fill} fill="currentColor" className="text-accent-brand/10" /> >
<path <path d={fill} fill="currentColor" className="text-accent-brand/10" />
d={d} <path
fill="none" d={d}
stroke="currentColor" fill="none"
strokeWidth="1.5" stroke="currentColor"
className="text-accent-brand/60" strokeWidth="1.5"
/> className="text-accent-brand/60"
</svg> />
</svg>
)}
</div> </div>
); );
} }
@@ -38,34 +38,36 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
</span> </span>
</div> </div>
) : ( ) : (
interfaces.map((iface, i) => ( <div className="flex flex-col gap-2 overflow-y-auto max-h-[260px]">
<div {interfaces.map((iface, i) => (
key={i} <div
className="flex flex-col p-2 border border-border bg-muted/30 gap-1" key={i}
> className="flex flex-col p-2 border border-border bg-muted/30 gap-1"
<div className="flex items-center justify-between"> >
<div className="flex items-center gap-2"> <div className="flex items-center justify-between">
<div <div className="flex items-center gap-2">
className={`size-1.5 rounded-full ${iface.state === "UP" ? "bg-accent-brand" : "bg-muted-foreground"}`} <div
/> className={`size-1.5 rounded-full ${iface.state === "UP" ? "bg-accent-brand" : "bg-muted-foreground"}`}
<span className="text-sm font-bold font-mono"> />
{iface.name} <span className="text-sm font-bold font-mono">
{iface.name}
</span>
</div>
<span className="text-[10px] font-semibold px-1.5 py-px border border-border text-muted-foreground uppercase">
{iface.state}
</span> </span>
</div> </div>
<span className="text-[10px] font-semibold px-1.5 py-px border border-border text-muted-foreground uppercase"> <div className="flex justify-between text-[10px] font-mono text-muted-foreground">
{iface.state} <span>{iface.ip}</span>
</span> {(iface.rx || iface.tx) && (
<span>
{iface.rx ?? "—"} / {iface.tx ?? "—"}
</span>
)}
</div>
</div> </div>
<div className="flex justify-between text-[10px] font-mono text-muted-foreground"> ))}
<span>{iface.ip}</span> </div>
{(iface.rx || iface.tx) && (
<span>
{iface.rx ?? "—"} / {iface.tx ?? "—"}
</span>
)}
</div>
</div>
))
)} )}
</div> </div>
</SectionCard> </SectionCard>
@@ -14,15 +14,17 @@ function PortRow({ port }: { port: ListeningPort }) {
addr === "0.0.0.0" || addr === "*" || addr === "::" ? "*" : addr; addr === "0.0.0.0" || addr === "*" || addr === "::" ? "*" : addr;
return ( return (
<div className="grid grid-cols-4 text-xs font-mono py-1 border-b border-border/50 last:border-0 min-w-0"> <div className="grid grid-cols-4 text-xs font-mono py-1 border-b border-border/50 last:border-0 min-w-0 overflow-hidden">
<span className="text-accent-brand font-bold">{port.localPort}</span> <span className="text-accent-brand font-bold truncate">
<span className="text-muted-foreground"> {port.localPort}
</span>
<span className="text-muted-foreground truncate">
{port.protocol.toUpperCase()} {port.protocol.toUpperCase()}
</span> </span>
<span className="font-semibold truncate"> <span className="font-semibold truncate">
{port.process ?? (port.pid ? `PID:${port.pid}` : "—")} {port.process ?? (port.pid ? `PID:${port.pid}` : "—")}
</span> </span>
<span className="text-right text-muted-foreground"> <span className="text-right text-muted-foreground truncate">
{formatAddress(port.localAddress)} {formatAddress(port.localAddress)}
</span> </span>
</div> </div>
@@ -41,7 +43,7 @@ export function PortsWidget({ metrics }: PortsWidgetProps) {
title={t("serverStats.ports.title")} title={t("serverStats.ports.title")}
icon={<Unplug className="size-3.5" />} icon={<Unplug className="size-3.5" />}
> >
<div className="flex flex-col gap-1.5 py-1"> <div className="flex flex-col gap-1.5 py-1 overflow-x-hidden">
<div className="grid grid-cols-4 text-[10px] text-muted-foreground font-bold uppercase pb-1 border-b border-border min-w-0"> <div className="grid grid-cols-4 text-[10px] text-muted-foreground font-bold uppercase pb-1 border-b border-border min-w-0">
<span>{t("serverStats.ports.port")}</span> <span>{t("serverStats.ports.port")}</span>
<span>{t("serverStats.ports.protocol")}</span> <span>{t("serverStats.ports.protocol")}</span>
@@ -53,12 +55,14 @@ export function PortsWidget({ metrics }: PortsWidgetProps) {
{t("serverStats.ports.noData")} {t("serverStats.ports.noData")}
</span> </span>
) : ( ) : (
ports.map((port, i) => ( <div className="overflow-y-auto max-h-[300px]">
<PortRow {ports.map((port, i) => (
key={`${port.protocol}-${port.localPort}-${i}`} <PortRow
port={port} key={`${port.protocol}-${port.localPort}-${i}`}
/> port={port}
)) />
))}
</div>
)} )}
</div> </div>
</SectionCard> </SectionCard>
@@ -45,19 +45,21 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
<span className="text-xs">{t("serverStats.noProcessesFound")}</span> <span className="text-xs">{t("serverStats.noProcessesFound")}</span>
</div> </div>
) : ( ) : (
topProcesses.map((proc, i) => ( <div className="overflow-y-auto max-h-[280px]">
<div {topProcesses.map((proc, i) => (
key={i} <div
className="grid grid-cols-4 text-xs font-mono py-1 border-b border-border/50 last:border-0 min-w-0" key={i}
> className="grid grid-cols-4 text-xs font-mono py-1 border-b border-border/50 last:border-0 min-w-0"
<span className="text-muted-foreground">{proc.pid}</span> >
<span className="text-accent-brand font-bold">{proc.cpu}%</span> <span className="text-muted-foreground">{proc.pid}</span>
<span>{proc.mem}%</span> <span className="text-accent-brand font-bold">{proc.cpu}%</span>
<span className="truncate font-semibold" title={proc.command}> <span>{proc.mem}%</span>
{proc.command.split("/").pop()} <span className="truncate font-semibold" title={proc.command}>
</span> {proc.command.split("/").pop()}
</div> </span>
)) </div>
))}
</div>
)} )}
</div> </div>
</SectionCard> </SectionCard>
+20 -4
View File
@@ -1206,7 +1206,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
shouldNotReconnectRef.current = true; shouldNotReconnectRef.current = true;
setIsConnected(false); setIsConnected(false);
setIsConnecting(false); setIsConnecting(false);
if (wasConnectedRef.current) { if (msg.graceful) {
wasConnectedRef.current = false;
if (onClose) onClose();
} else if (wasConnectedRef.current) {
wasConnectedRef.current = false; wasConnectedRef.current = false;
setShowDisconnectedOverlay(true); setShowDisconnectedOverlay(true);
} else if (!connectionErrorRef.current) { } else if (!connectionErrorRef.current) {
@@ -1559,6 +1562,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
setIsConnected(false); setIsConnected(false);
isConnectingRef.current = false; isConnectingRef.current = false;
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
if (pongTimeoutRef.current) { if (pongTimeoutRef.current) {
clearTimeout(pongTimeoutRef.current); clearTimeout(pongTimeoutRef.current);
pongTimeoutRef.current = null; pongTimeoutRef.current = null;
@@ -1649,6 +1657,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
} }
setIsConnecting(false); setIsConnecting(false);
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
if (totpTimeoutRef.current) { if (totpTimeoutRef.current) {
clearTimeout(totpTimeoutRef.current); clearTimeout(totpTimeoutRef.current);
totpTimeoutRef.current = null; totpTimeoutRef.current = null;
@@ -2478,9 +2491,12 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
{showDisconnectedOverlay && !isConnecting && ( {showDisconnectedOverlay && !isConnecting && (
<div <div
className="absolute inset-0 flex flex-col items-center justify-center gap-4 z-10" className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-[120]"
style={{ backgroundColor }} style={{ backgroundColor }}
> >
<p className="text-sm text-muted-foreground">
{t("terminal.connectionLost")}
</p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={() => { onClick={() => {
@@ -2502,7 +2518,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
{t("terminal.reconnect")} {t("terminal.reconnect")}
</Button> </Button>
{onClose && ( {onClose && (
<Button variant="secondary" onClick={onClose}> <Button variant="outline" onClick={onClose}>
{t("terminal.closeTab")} {t("terminal.closeTab")}
</Button> </Button>
)} )}
@@ -2513,7 +2529,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
<ConnectionLog <ConnectionLog
isConnecting={isConnecting} isConnecting={isConnecting}
isConnected={isConnected} isConnected={isConnected}
hasConnectionError={hasConnectionError} hasConnectionError={hasConnectionError && !showDisconnectedOverlay}
position={hasConnectionError ? "top" : "bottom"} position={hasConnectionError ? "top" : "bottom"}
/> />
+1 -8
View File
@@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Card } from "@/components/card"; import { Card } from "@/components/card";
import { Separator } from "@/components/separator";
import { import {
AlertCircle, AlertCircle,
@@ -397,7 +396,7 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
const connectedCount = tunnels.filter((t, i) => { const connectedCount = tunnels.filter((t, i) => {
if (!sshHost) return false; if (!sshHost) return false;
const name = tunnelName(sshHost, i, t); const name = tunnelName(sshHost, i, t);
return tunnelStatuses[name]?.connected; return tunnelStatuses[name]?.status === "connected";
}).length; }).length;
return ( return (
@@ -418,12 +417,6 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-0">
<Separator orientation="vertical" className="h-8 mx-3" />
<Button variant="ghost" size="icon">
<Settings className="size-4 text-accent-brand" />
</Button>
</div>
</Card> </Card>
{tunnels.length > 0 ? ( {tunnels.length > 0 ? (
+2 -2
View File
@@ -34,7 +34,7 @@ export const TERMINAL_THEMES: Record<string, TerminalTheme> = {
colors: { colors: {
background: "#0c0d0b", background: "#0c0d0b",
foreground: "#f7f7f7", foreground: "#f7f7f7",
cursor: "#fb923c", cursor: "#f7f7f7",
cursorAccent: "#0c0d0b", cursorAccent: "#0c0d0b",
selectionBackground: "#3a3a3d", selectionBackground: "#3a3a3d",
black: "#2e3436", black: "#2e3436",
@@ -62,7 +62,7 @@ export const TERMINAL_THEMES: Record<string, TerminalTheme> = {
colors: { colors: {
background: "#0c0d0b", background: "#0c0d0b",
foreground: "#f7f7f7", foreground: "#f7f7f7",
cursor: "#fb923c", cursor: "#f7f7f7",
cursorAccent: "#0c0d0b", cursorAccent: "#0c0d0b",
selectionBackground: "#3a3a3d", selectionBackground: "#3a3a3d",
black: "#2e3436", black: "#2e3436",
+45 -3
View File
@@ -3252,7 +3252,9 @@
"noTerminalSelectedHint": "Open an SSH terminal tab to view its command history", "noTerminalSelectedHint": "Open an SSH terminal tab to view its command history",
"searchPlaceholder": "Search history...", "searchPlaceholder": "Search history...",
"clearAll": "Clear All", "clearAll": "Clear All",
"noHistoryEntries": "No history entries" "noHistoryEntries": "No history entries",
"trackingDisabled": "History tracking is disabled",
"trackingDisabledHint": "Enable it in your profile settings to record commands."
}, },
"sshTools": { "sshTools": {
"keyRecordingTitle": "Key Recording", "keyRecordingTitle": "Key Recording",
@@ -3261,6 +3263,9 @@
"selectNone": "None", "selectNone": "None",
"noTerminalTabsOpen": "No terminal tabs open", "noTerminalTabsOpen": "No terminal tabs open",
"selectTerminalsAbove": "Select terminals above", "selectTerminalsAbove": "Select terminals above",
"broadcastInputPlaceholder": "Type here to broadcast keystrokes...",
"stopRecording": "Stop Recording",
"startRecording": "Start Recording",
"settingsTitle": "Settings", "settingsTitle": "Settings",
"enableRightClickCopyPaste": "Enable right-click copy/paste" "enableRightClickCopyPaste": "Enable right-click copy/paste"
}, },
@@ -3307,7 +3312,33 @@
"newSnippet": "New Snippet", "newSnippet": "New Snippet",
"newFolder": "New Folder", "newFolder": "New Folder",
"run": "Run", "run": "Run",
"noSnippetsInFolder": "No snippets in this folder" "noSnippetsInFolder": "No snippets in this folder",
"uncategorized": "Uncategorized",
"editSnippetTitle": "Edit Snippet",
"editSnippetDescription": "Update this command snippet",
"saveSnippetButton": "Save Changes",
"createSuccess": "Snippet created successfully",
"createFailed": "Failed to create snippet",
"updateSuccess": "Snippet updated successfully",
"updateFailed": "Failed to update snippet",
"deleteFailed": "Failed to delete snippet",
"folderCreateSuccess": "Folder created successfully",
"folderCreateFailed": "Failed to create folder",
"runSuccess": "Ran \"{{name}}\" in {{count}} terminal(s)",
"copySuccess": "Copied \"{{name}}\" to clipboard",
"shareTitle": "Share Snippet",
"shareUser": "User",
"shareRole": "Role",
"selectUser": "Select a user...",
"selectRole": "Select a role...",
"shareSuccess": "Snippet shared successfully",
"shareFailed": "Failed to share snippet",
"revokeSuccess": "Access revoked",
"revokeFailed": "Failed to revoke access",
"currentAccess": "Current Access",
"shareLoadError": "Failed to load share data",
"loading": "Loading...",
"close": "Close"
}, },
"userProfile": { "userProfile": {
"sectionAccount": "Account", "sectionAccount": "Account",
@@ -3427,7 +3458,18 @@
"passwordUpdateFailed": "Failed to update password", "passwordUpdateFailed": "Failed to update password",
"deletePasswordRequired": "Password is required to delete your account", "deletePasswordRequired": "Password is required to delete your account",
"deleteFailed": "Failed to delete account", "deleteFailed": "Failed to delete account",
"deleting": "Deleting..." "deleting": "Deleting...",
"colorPickerTooltip": "Open color picker",
"themeSystem": "System",
"themeLight": "Light",
"themeDark": "Dark",
"themeDracula": "Dracula",
"themeCatppuccin": "Catppuccin",
"themeNord": "Nord",
"themeSolarized": "Solarized",
"themeTokyoNight": "Tokyo Night",
"themeOneDark": "One Dark",
"themeGruvbox": "Gruvbox"
} }
} }
} }
+2 -2
View File
@@ -68,8 +68,8 @@ export function TabBar({
const barRect = tabBarRef.current.getBoundingClientRect(); const barRect = tabBarRef.current.getBoundingClientRect();
const x = Math.max( const x = Math.max(
barRect.left, barRect.left + 2,
Math.min(barRect.right - d.width - 4, e.clientX - d.offsetX), Math.min(barRect.right - d.width - 6, e.clientX - d.offsetX),
); );
const y = d.barTop; const y = d.barTop;
setDragPos({ x, y }); setDragPos({ x, y });
+3 -1
View File
@@ -113,6 +113,7 @@ export function renderTabContent(
tab: Tab, tab: Tab,
onOpenSingletonTab?: (type: TabType) => void, onOpenSingletonTab?: (type: TabType) => void,
onOpenTab?: (host: Host, type: TabType) => void, onOpenTab?: (host: Host, type: TabType) => void,
onCloseTab?: (id: string) => void,
) { ) {
const { host, label } = tab; const { host, label } = tab;
@@ -136,6 +137,7 @@ export function renderTabContent(
return ( return (
<CommandHistoryProvider> <CommandHistoryProvider>
<TerminalFeature <TerminalFeature
ref={tab.terminalRef as any}
hostConfig={ hostConfig={
{ {
...hostToSSHHost(host), ...hostToSSHHost(host),
@@ -146,7 +148,7 @@ export function renderTabContent(
title={label} title={label}
showTitle={false} showTitle={false}
splitScreen={false} splitScreen={false}
onClose={() => {}} onClose={() => onCloseTab?.(tab.id)}
/> />
</CommandHistoryProvider> </CommandHistoryProvider>
); );
+165 -48
View File
@@ -32,8 +32,11 @@ import {
updateOIDCConfig, updateOIDCConfig,
disableOIDCConfig, disableOIDCConfig,
isElectron, isElectron,
getUserRoles,
assignRoleToUser,
removeRoleFromUser,
} from "@/main-axios"; } from "@/main-axios";
import type { ApiKey, CreatedApiKey } from "@/main-axios"; import type { ApiKey, CreatedApiKey, UserRole } from "@/main-axios";
import type React from "react"; import type React from "react";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Input } from "@/components/input"; import { Input } from "@/components/input";
@@ -154,6 +157,8 @@ export function AdminSettingsPanel() {
const [editUserOpen, setEditUserOpen] = useState(false); const [editUserOpen, setEditUserOpen] = useState(false);
const [editUserTarget, setEditUserTarget] = useState<any | null>(null); const [editUserTarget, setEditUserTarget] = useState<any | null>(null);
const [editUserLoading, setEditUserLoading] = useState(false); const [editUserLoading, setEditUserLoading] = useState(false);
const [editUserRoles, setEditUserRoles] = useState<UserRole[]>([]);
const [editUserRolesLoading, setEditUserRolesLoading] = useState(false);
// Link account dialog // Link account dialog
const [linkAccountOpen, setLinkAccountOpen] = useState(false); const [linkAccountOpen, setLinkAccountOpen] = useState(false);
@@ -196,6 +201,17 @@ export function AdminSettingsPanel() {
loadOidcConfig(); loadOidcConfig();
}, []); }, []);
useEffect(() => {
if (editUserOpen && editUserTarget) {
setEditUserRoles([]);
setEditUserRolesLoading(true);
getUserRoles(editUserTarget.id)
.then(({ roles: r }) => setEditUserRoles(r))
.catch(() => {})
.finally(() => setEditUserRolesLoading(false));
}
}, [editUserOpen, editUserTarget?.id]);
function loadUsers() { function loadUsers() {
getUserList() getUserList()
.then(({ users: u }) => setUsers(u)) .then(({ users: u }) => setUsers(u))
@@ -257,18 +273,21 @@ export function AdminSettingsPanel() {
try { try {
const config = await getAdminOIDCConfig(); const config = await getAdminOIDCConfig();
if (!config) return; if (!config) return;
setOidcClientId((config.clientId as string) ?? ""); setOidcClientId((config.client_id as string) ?? "");
setOidcClientSecret((config.clientSecret as string) ?? ""); setOidcClientSecret((config.client_secret as string) ?? "");
setOidcAuthUrl((config.authorizationUrl as string) ?? ""); setOidcAuthUrl((config.authorization_url as string) ?? "");
setOidcIssuerUrl((config.issuerUrl as string) ?? ""); setOidcIssuerUrl((config.issuer_url as string) ?? "");
setOidcTokenUrl((config.tokenUrl as string) ?? ""); setOidcTokenUrl((config.token_url as string) ?? "");
setOidcUserIdentifier((config.userIdentifierPath as string) ?? "sub"); setOidcUserIdentifier((config.identifier_path as string) ?? "sub");
setOidcDisplayName((config.displayNamePath as string) ?? "name"); setOidcDisplayName((config.name_path as string) ?? "name");
setOidcScopes((config.scopes as string) ?? "openid email profile"); setOidcScopes((config.scopes as string) ?? "openid email profile");
setOidcUserinfoUrl((config.overrideUserinfoUrl as string) ?? ""); setOidcUserinfoUrl((config.userinfo_url as string) ?? "");
setOidcAllowedUsers( setOidcAllowedUsers(
Array.isArray(config.allowedUsers) typeof config.allowed_users === "string"
? (config.allowedUsers as string[]).join("\n") ? (config.allowed_users as string)
.split(",")
.filter(Boolean)
.join("\n")
: "", : "",
); );
} catch { } catch {
@@ -378,18 +397,18 @@ export function AdminSettingsPanel() {
setOidcSaving(true); setOidcSaving(true);
try { try {
await updateOIDCConfig({ await updateOIDCConfig({
clientId: oidcClientId, client_id: oidcClientId,
clientSecret: oidcClientSecret, client_secret: oidcClientSecret,
authorizationUrl: oidcAuthUrl, authorization_url: oidcAuthUrl,
issuerUrl: oidcIssuerUrl, issuer_url: oidcIssuerUrl,
tokenUrl: oidcTokenUrl, token_url: oidcTokenUrl,
userIdentifierPath: oidcUserIdentifier, identifier_path: oidcUserIdentifier,
displayNamePath: oidcDisplayName, name_path: oidcDisplayName,
scopes: oidcScopes, scopes: oidcScopes,
overrideUserinfoUrl: oidcUserinfoUrl || null, userinfo_url: oidcUserinfoUrl || "",
allowedUsers: oidcAllowedUsers allowed_users: oidcAllowedUsers
? oidcAllowedUsers.split("\n").filter(Boolean) ? oidcAllowedUsers.split("\n").filter(Boolean).join(",")
: [], : "",
}); });
toast.success("OIDC configuration saved"); toast.success("OIDC configuration saved");
} catch (e: any) { } catch (e: any) {
@@ -497,18 +516,19 @@ export function AdminSettingsPanel() {
return; return;
} }
setCreateRoleLoading(true); setCreateRoleLoading(true);
const displayName = newRoleDisplayName.trim();
try { try {
const { role } = await createRole({ await createRole({
name: newRoleName.trim(), name: newRoleName.trim(),
displayName: newRoleDisplayName.trim(), displayName,
description: newRoleDescription.trim() || null, description: newRoleDescription.trim() || null,
}); });
setRoles((prev) => [...prev, role]);
setShowCreateRole(false); setShowCreateRole(false);
setNewRoleName(""); setNewRoleName("");
setNewRoleDisplayName(""); setNewRoleDisplayName("");
setNewRoleDescription(""); setNewRoleDescription("");
toast.success(`Role "${role.displayName}" created`); toast.success(`Role "${displayName}" created`);
loadRoles();
} catch (e: any) { } catch (e: any) {
toast.error(e?.response?.data?.error || "Failed to create role"); toast.error(e?.response?.data?.error || "Failed to create role");
} finally { } finally {
@@ -532,7 +552,7 @@ export function AdminSettingsPanel() {
newKeyUserId.trim(), newKeyUserId.trim(),
newKeyExpiry ? new Date(newKeyExpiry).toISOString() : undefined, newKeyExpiry ? new Date(newKeyExpiry).toISOString() : undefined,
); );
setApiKeys((prev) => [created, ...prev]); setApiKeys((prev) => [{ ...created, isActive: true }, ...prev]);
setCreatedKeyToken(created.token); setCreatedKeyToken(created.token);
setNewKeyName(""); setNewKeyName("");
setNewKeyUserId(""); setNewKeyUserId("");
@@ -682,7 +702,7 @@ export function AdminSettingsPanel() {
</SettingRow> </SettingRow>
<SettingRow <SettingRow
label="Allow Password Reset" label="Allow Password Reset"
description="Email-based password reset" description="Reset code via Docker logs"
> >
<AdminToggle <AdminToggle
on={allowPasswordReset} on={allowPasswordReset}
@@ -1451,29 +1471,20 @@ export function AdminSettingsPanel() {
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest"> <label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
Scoped User ID{" "} User <span className="text-accent-brand">*</span>
<span className="text-accent-brand">*</span>
</label> </label>
<Input <select
placeholder="User ID" className="px-2 py-1.5 text-xs bg-background border border-border text-foreground outline-none"
value={newKeyUserId} value={newKeyUserId}
onChange={(e) => setNewKeyUserId(e.target.value)} onChange={(e) => setNewKeyUserId(e.target.value)}
className="text-xs font-mono" >
/> <option value="">Select a user...</option>
{users.length > 0 && ( {users.map((u) => (
<select <option key={u.id} value={u.id}>
className="mt-1 px-2 py-1.5 text-xs bg-background border border-border text-foreground outline-none" {u.username}
value={newKeyUserId} </option>
onChange={(e) => setNewKeyUserId(e.target.value)} ))}
> </select>
<option value="">Select a user...</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.username}
</option>
))}
</select>
)}
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest"> <label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
@@ -1693,6 +1704,112 @@ export function AdminSettingsPanel() {
onToggle={() => handleToggleAdmin(editUserTarget)} onToggle={() => handleToggleAdmin(editUserTarget)}
/> />
</div> </div>
<div className="flex flex-col gap-2 py-3">
<span className="text-sm font-medium">Roles</span>
{editUserRolesLoading ? (
<span className="text-xs text-muted-foreground">
Loading...
</span>
) : (
<>
{editUserRoles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{editUserRoles.map((ur) => {
const roleInfo = roles.find(
(r) => r.id === ur.roleId,
);
const isSystem = roleInfo?.isSystem ?? false;
return (
<span
key={ur.roleId}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
>
{ur.roleDisplayName}
{!isSystem && (
<button
onClick={async () => {
try {
await removeRoleFromUser(
editUserTarget.id,
ur.roleId,
);
setEditUserRoles((prev) =>
prev.filter(
(r) => r.roleId !== ur.roleId,
),
);
} catch {
toast.error("Failed to remove role");
}
}}
className="hover:text-destructive ml-0.5"
>
×
</button>
)}
</span>
);
})}
</div>
)}
{roles.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
).length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
Add role
</span>
<div className="flex flex-wrap gap-1.5">
{roles
.filter(
(r) =>
!r.isSystem &&
!editUserRoles.some((ur) => ur.roleId === r.id),
)
.map((r) => (
<button
key={r.id}
onClick={async () => {
try {
await assignRoleToUser(
editUserTarget.id,
r.id,
);
setEditUserRoles((prev) => [
...prev,
{
userId: editUserTarget.id,
roleId: r.id,
roleName: r.name,
roleDisplayName: r.displayName,
grantedBy: "",
grantedByUsername: "",
grantedAt: new Date().toISOString(),
},
]);
} catch {
toast.error("Failed to assign role");
}
}}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 border border-border text-muted-foreground hover:border-accent-brand/40 hover:text-accent-brand transition-colors"
>
+ {r.displayName}
</button>
))}
</div>
</div>
)}
{editUserRoles.length === 0 &&
roles.filter((r) => !r.isSystem).length === 0 && (
<span className="text-xs text-muted-foreground">
No custom roles defined
</span>
)}
</>
)}
</div>
<div className="flex items-center justify-between py-3"> <div className="flex items-center justify-between py-3">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className="text-sm font-medium"> <span className="text-sm font-medium">
+47 -4
View File
@@ -3,7 +3,11 @@ import { useTranslation } from "react-i18next";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Input } from "@/components/input"; import { Input } from "@/components/input";
import { Copy, Search, Terminal, Trash2 } from "lucide-react"; import { Copy, Search, Terminal, Trash2 } from "lucide-react";
import { getCommandHistory, deleteCommandFromHistory } from "@/main-axios"; import {
getCommandHistory,
deleteCommandFromHistory,
clearCommandHistory,
} from "@/main-axios";
import type { Tab } from "@/types/ui-types"; import type { Tab } from "@/types/ui-types";
export function HistoryPanel({ export function HistoryPanel({
@@ -16,20 +20,51 @@ export function HistoryPanel({
const { t } = useTranslation(); const { t } = useTranslation();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [commands, setCommands] = useState<string[]>([]); const [commands, setCommands] = useState<string[]>([]);
const [trackingEnabled, setTrackingEnabled] = useState(
() => localStorage.getItem("commandHistoryTracking") === "true",
);
const activeTab = terminalTabs.find((t) => t.id === activeTabId); const activeTab = terminalTabs.find((t) => t.id === activeTabId);
const activeIsTerminal = !!activeTab; const activeIsTerminal = !!activeTab;
const hostId = activeTab?.host?.id ? parseInt(activeTab.host.id, 10) : null; const hostId = activeTab?.host?.id ? parseInt(activeTab.host.id, 10) : null;
useEffect(() => { useEffect(() => {
if (!hostId) { const handler = () =>
setTrackingEnabled(
localStorage.getItem("commandHistoryTracking") === "true",
);
window.addEventListener("commandHistoryTrackingChanged", handler);
return () =>
window.removeEventListener("commandHistoryTrackingChanged", handler);
}, []);
useEffect(() => {
if (!hostId || !trackingEnabled) {
setCommands([]); setCommands([]);
return; return;
} }
getCommandHistory(hostId) getCommandHistory(hostId)
.then(setCommands) .then(setCommands)
.catch(() => setCommands([])); .catch(() => setCommands([]));
}, [hostId]); }, [hostId, trackingEnabled]);
if (activeIsTerminal && !trackingEnabled) {
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<Terminal 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("newUi.sidebar.history.trackingDisabled")}
</span>
<span className="text-xs text-muted-foreground/40">
{t("newUi.sidebar.history.trackingDisabledHint")}
</span>
</div>
</div>
);
}
if (!activeIsTerminal) { if (!activeIsTerminal) {
return ( return (
@@ -85,7 +120,15 @@ export function HistoryPanel({
{filtered.length} command{filtered.length !== 1 ? "s" : ""} {filtered.length} command{filtered.length !== 1 ? "s" : ""}
</span> </span>
<button <button
onClick={() => setCommands([])} onClick={async () => {
if (!hostId) return;
try {
await clearCommandHistory(hostId);
} catch {
/* ignore */
}
setCommands([]);
}}
className="text-xs text-accent-brand hover:text-accent-brand/70" className="text-xs text-accent-brand hover:text-accent-brand/70"
> >
{t("newUi.sidebar.history.clearAll")} {t("newUi.sidebar.history.clearAll")}
File diff suppressed because it is too large Load Diff
+65 -3
View File
@@ -2,8 +2,13 @@ import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Eye, EyeOff, FolderSearch, Terminal } from "lucide-react"; import { Eye, EyeOff, FolderSearch, Terminal } from "lucide-react";
import { Input } from "@/components/input"; import { Input } from "@/components/input";
import type { Host } from "@/types/ui-types";
export function QuickConnectPanel() { interface QuickConnectPanelProps {
onConnect: (host: Host, type: "terminal" | "files") => void;
}
export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [host, setHost] = useState(""); const [host, setHost] = useState("");
const [port, setPort] = useState("22"); const [port, setPort] = useState("22");
@@ -12,8 +17,45 @@ export function QuickConnectPanel() {
"password", "password",
); );
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [privateKey, setPrivateKey] = useState("");
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const connect = (type: "terminal" | "files") => {
if (!host || !username) return;
const hostConfig: Host = {
id: `quick-connect-${Date.now()}`,
name: `${username}@${host}`,
ip: host,
port: parseInt(port) || 22,
username,
authType,
password: authType === "password" ? password : undefined,
key: authType === "key" ? privateKey : undefined,
folder: "",
online: false,
cpu: null,
ram: null,
lastAccess: new Date().toISOString(),
pin: false,
defaultPath: "",
serverTunnels: [],
quickActions: [],
enableTerminal: true,
enableFileManager: true,
enableTunnel: true,
enableDocker: true,
enableSsh: true,
enableRdp: false,
enableVnc: false,
enableTelnet: false,
sshPort: parseInt(port) || 22,
rdpPort: 3389,
vncPort: 5900,
telnetPort: 23,
};
onConnect(hostConfig, type);
};
return ( return (
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto"> <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 gap-3 p-3">
@@ -25,6 +67,9 @@ export function QuickConnectPanel() {
placeholder={t("newUi.sidebar.quickConnect.hostPlaceholder")} placeholder={t("newUi.sidebar.quickConnect.hostPlaceholder")}
value={host} value={host}
onChange={(e) => setHost(e.target.value)} onChange={(e) => setHost(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connect("terminal");
}}
className="h-7 text-xs" className="h-7 text-xs"
/> />
</div> </div>
@@ -36,6 +81,9 @@ export function QuickConnectPanel() {
placeholder={t("newUi.sidebar.quickConnect.portPlaceholder")} placeholder={t("newUi.sidebar.quickConnect.portPlaceholder")}
value={port} value={port}
onChange={(e) => setPort(e.target.value)} onChange={(e) => setPort(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connect("terminal");
}}
className="h-7 text-xs" className="h-7 text-xs"
/> />
</div> </div>
@@ -47,6 +95,9 @@ export function QuickConnectPanel() {
placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")} placeholder={t("newUi.sidebar.quickConnect.usernamePlaceholder")}
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connect("terminal");
}}
className="h-7 text-xs" className="h-7 text-xs"
/> />
</div> </div>
@@ -83,6 +134,9 @@ export function QuickConnectPanel() {
)} )}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") connect("terminal");
}}
className="h-7 text-xs pr-8" className="h-7 text-xs pr-8"
/> />
<button <button
@@ -107,6 +161,8 @@ export function QuickConnectPanel() {
placeholder={t( placeholder={t(
"newUi.sidebar.quickConnect.privateKeyPlaceholder", "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="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"
/> />
</div> </div>
@@ -125,11 +181,17 @@ export function QuickConnectPanel() {
</div> </div>
)} )}
<div className="flex flex-col gap-1.5 pt-1"> <div className="flex flex-col gap-1.5 pt-1">
<button className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"> <button
onClick={() => connect("terminal")}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
>
<Terminal className="size-3.5" /> <Terminal className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectToTerminal")} {t("newUi.sidebar.quickConnect.connectToTerminal")}
</button> </button>
<button className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"> <button
onClick={() => connect("files")}
className="flex items-center justify-center gap-1.5 h-7 w-full border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-xs font-semibold hover:bg-accent-brand/20 transition-colors"
>
<FolderSearch className="size-3.5" /> <FolderSearch className="size-3.5" />
{t("newUi.sidebar.quickConnect.connectToFiles")} {t("newUi.sidebar.quickConnect.connectToFiles")}
</button> </button>
+518 -149
View File
@@ -3,7 +3,15 @@ import { useTranslation } from "react-i18next";
import { import {
getSnippets, getSnippets,
createSnippet as apiCreateSnippet, createSnippet as apiCreateSnippet,
updateSnippet as apiUpdateSnippet,
deleteSnippet as apiDeleteSnippet, deleteSnippet as apiDeleteSnippet,
getSnippetFolders,
createSnippetFolder as apiCreateSnippetFolder,
shareSnippet as apiShareSnippet,
getSnippetAccess,
revokeSnippetAccess,
getUserList,
getRoles,
} from "@/main-axios"; } from "@/main-axios";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Input } from "@/components/input"; import { Input } from "@/components/input";
@@ -33,6 +41,8 @@ import {
Share2, Share2,
Terminal, Terminal,
Trash2, Trash2,
UserPlus,
X,
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { FOLDER_COLORS } from "@/lib/theme"; import { FOLDER_COLORS } from "@/lib/theme";
@@ -91,47 +101,63 @@ function FolderIconEl({
} }
} }
function CreateSnippetDialog({ function SnippetFormDialog({
open, open,
onOpenChange, onOpenChange,
folders, folders,
onCreate, snippet,
onSave,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (v: boolean) => void; onOpenChange: (v: boolean) => void;
folders: SnippetFolder[]; folders: SnippetFolder[];
onCreate: (s: Omit<Snippet, "id">) => void; snippet: Snippet | null;
onSave: (data: Omit<Snippet, "id">, id?: number) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [folderId, setFolderId] = useState<number | null>(null); const [folder, setFolder] = useState<string | null>(null);
const [command, setCommand] = useState(""); const [content, setContent] = useState("");
function handleCreate() { useEffect(() => {
if (!name.trim() || !command.trim()) return; if (open) {
onCreate({ setName(snippet?.name ?? "");
name: name.trim(), setDescription(snippet?.description ?? "");
description: description.trim() || undefined, setFolder(snippet?.folder ?? null);
command: command.trim(), setContent(snippet?.content ?? "");
folderId, }
}); }, [open, snippet]);
setName("");
setDescription(""); function handleSave() {
setFolderId(null); if (!name.trim() || !content.trim()) return;
setCommand(""); onSave(
{
name: name.trim(),
description: description.trim() || undefined,
content: content.trim(),
folder,
},
snippet?.id,
);
onOpenChange(false); onOpenChange(false);
} }
const isEdit = snippet !== null;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-lg font-bold"> <DialogTitle className="text-lg font-bold">
{t("newUi.sidebar.snippets.createSnippetTitle")} {isEdit
? t("newUi.sidebar.snippets.editSnippetTitle")
: t("newUi.sidebar.snippets.createSnippetTitle")}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs text-muted-foreground"> <DialogDescription className="text-xs text-muted-foreground">
{t("newUi.sidebar.snippets.createSnippetDescription")} {isEdit
? t("newUi.sidebar.snippets.editSnippetDescription")
: t("newUi.sidebar.snippets.createSnippetDescription")}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-4 mt-1"> <div className="flex flex-col gap-4 mt-1">
@@ -168,22 +194,18 @@ function CreateSnippetDialog({
</span> </span>
</label> </label>
<select <select
value={folderId ?? ""} value={folder ?? ""}
onChange={(e) => onChange={(e) =>
setFolderId( setFolder(e.target.value === "" ? null : e.target.value)
e.target.value === "" ? null : Number(e.target.value),
)
} }
className="px-3 py-2 text-sm bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring" className="px-3 py-2 text-sm bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
> >
<option value="">{t("newUi.sidebar.snippets.noFolder")}</option> <option value="">{t("newUi.sidebar.snippets.noFolder")}</option>
{folders {folders.map((f) => (
.filter((f) => f.name !== "Uncategorized") <option key={f.id} value={f.name}>
.map((f) => ( {f.name}
<option key={f.id} value={f.id}> </option>
{f.name} ))}
</option>
))}
</select> </select>
</div> </div>
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
@@ -193,8 +215,8 @@ function CreateSnippetDialog({
</label> </label>
<textarea <textarea
placeholder={t("newUi.sidebar.snippets.commandPlaceholder")} placeholder={t("newUi.sidebar.snippets.commandPlaceholder")}
value={command} value={content}
onChange={(e) => setCommand(e.target.value)} onChange={(e) => setContent(e.target.value)}
className="w-full h-36 px-3 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="w-full h-36 px-3 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"
/> />
</div> </div>
@@ -206,9 +228,11 @@ function CreateSnippetDialog({
<Button <Button
variant="outline" variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand" className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleCreate} onClick={handleSave}
> >
{t("newUi.sidebar.snippets.createSnippetButton")} {isEdit
? t("newUi.sidebar.snippets.saveSnippetButton")
: t("newUi.sidebar.snippets.createSnippetButton")}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
@@ -330,6 +354,317 @@ function CreateFolderDialog({
); );
} }
type AccessRecord = {
id: number;
targetType: "user" | "role";
username: string | null;
roleName: string | null;
roleDisplayName: string | null;
permissionLevel: string;
expiresAt: string | null;
};
function ShareSnippetDialog({
snippet,
onClose,
}: {
snippet: Snippet | null;
onClose: () => void;
}) {
const { t } = useTranslation();
const [users, setUsers] = useState<{ id: string; username: string }[]>([]);
const [roles, setRoles] = useState<
{ id: number; name: string; displayName?: string }[]
>([]);
const [accessList, setAccessList] = useState<AccessRecord[]>([]);
const [targetType, setTargetType] = useState<"user" | "role">("user");
const [targetId, setTargetId] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!snippet) return;
setLoading(true);
Promise.all([getUserList(), getRoles(), getSnippetAccess(snippet.id)])
.then(([usersData, rolesData, accessData]) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setUsers(
(usersData?.users || []).map((u: any) => ({
id: u.id,
username: u.username,
})),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setRoles((rolesData?.roles || []).map((r: any) => r));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setAccessList((accessData as any).accessList || []);
})
.catch(() => toast.error(t("newUi.sidebar.snippets.shareLoadError")))
.finally(() => setLoading(false));
}, [snippet, t]);
async function handleShare() {
if (!snippet || !targetId) return;
try {
await apiShareSnippet(snippet.id, {
targetType,
targetUserId: targetType === "user" ? targetId : undefined,
targetRoleId: targetType === "role" ? parseInt(targetId) : undefined,
});
toast.success(t("newUi.sidebar.snippets.shareSuccess"));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const accessData = (await getSnippetAccess(snippet.id)) as any;
setAccessList(accessData.accessList || []);
setTargetId("");
} catch {
toast.error(t("newUi.sidebar.snippets.shareFailed"));
}
}
async function handleRevoke(accessId: number) {
if (!snippet) return;
try {
await revokeSnippetAccess(snippet.id, accessId);
toast.success(t("newUi.sidebar.snippets.revokeSuccess"));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const accessData = (await getSnippetAccess(snippet.id)) as any;
setAccessList(accessData.accessList || []);
} catch {
toast.error(t("newUi.sidebar.snippets.revokeFailed"));
}
}
return (
<Dialog open={snippet !== null} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-lg font-bold">
{t("newUi.sidebar.snippets.shareTitle")}
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
{snippet?.name}
</DialogDescription>
</DialogHeader>
{loading ? (
<div className="py-6 text-center text-xs text-muted-foreground">
{t("newUi.sidebar.snippets.loading")}
</div>
) : (
<div className="flex flex-col gap-4 mt-1">
<div className="flex gap-2">
<button
onClick={() => {
setTargetType("user");
setTargetId("");
}}
className={`flex-1 py-1.5 text-xs border transition-colors ${targetType === "user" ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{t("newUi.sidebar.snippets.shareUser")}
</button>
<button
onClick={() => {
setTargetType("role");
setTargetId("");
}}
className={`flex-1 py-1.5 text-xs border transition-colors ${targetType === "role" ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
>
{t("newUi.sidebar.snippets.shareRole")}
</button>
</div>
<div className="flex gap-2 items-center">
<select
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
className="flex-1 px-3 py-2 text-sm bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring h-9"
>
<option value="">
{targetType === "user"
? t("newUi.sidebar.snippets.selectUser")
: t("newUi.sidebar.snippets.selectRole")}
</option>
{targetType === "user"
? users.map((u) => (
<option key={u.id} value={u.id}>
{u.username}
</option>
))
: roles.map((r) => (
<option key={r.id} value={String(r.id)}>
{r.displayName || r.name}
</option>
))}
</select>
<Button
variant="outline"
size="lg"
className="shrink-0 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={handleShare}
disabled={!targetId}
>
<UserPlus className="size-4" />
</Button>
</div>
{accessList.length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-xs font-semibold text-muted-foreground">
{t("newUi.sidebar.snippets.currentAccess")}
</span>
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
{accessList.map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between px-2.5 py-1.5 border border-border text-xs"
>
<span className="truncate">
{entry.targetType === "user"
? entry.username
: entry.roleDisplayName || entry.roleName}
<span className="text-muted-foreground ml-1">
({entry.targetType})
</span>
</span>
<button
onClick={() => handleRevoke(entry.id)}
className="shrink-0 text-muted-foreground hover:text-destructive ml-2"
>
<X className="size-3.5" />
</button>
</div>
))}
</div>
</div>
)}
</div>
)}
<div className="flex justify-end mt-2">
<Button variant="ghost" onClick={onClose}>
{t("newUi.sidebar.snippets.close")}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
function SnippetCard({
snippet,
selectedTabIds,
terminalTabs,
onDelete,
onEdit,
onShare,
t,
}: {
snippet: Snippet;
selectedTabIds: Set<string>;
terminalTabs: Tab[];
onDelete: (id: number) => void;
onEdit: (snippet: Snippet) => void;
onShare: (snippet: Snippet) => void;
t: (key: string, opts?: Record<string, unknown>) => string;
}) {
function handleRun() {
const targets = terminalTabs.filter((tab) => selectedTabIds.has(tab.id));
if (targets.length > 0) {
targets.forEach((tab) => {
tab.terminalRef?.current?.sendInput?.(snippet.content + "\r");
});
toast.success(
t("newUi.sidebar.snippets.runSuccess", {
name: snippet.name,
count: targets.length,
}),
);
} else if (terminalTabs.length > 0) {
terminalTabs[0].terminalRef?.current?.sendInput?.(snippet.content + "\r");
toast.success(
t("newUi.sidebar.snippets.runSuccess", {
name: snippet.name,
count: 1,
}),
);
} else {
toast.error(t("newUi.sidebar.snippets.noTerminalTabsOpen"));
}
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
}
function handleCopy() {
navigator.clipboard.writeText(snippet.content);
toast.success(
t("newUi.sidebar.snippets.copySuccess", { name: snippet.name }),
);
}
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>
<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>
<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>
);
}
export function SnippetsPanel({ export function SnippetsPanel({
terminalTabs, terminalTabs,
activeTabId, activeTabId,
@@ -341,6 +676,19 @@ export function SnippetsPanel({
const [snippetSearch, setSnippetSearch] = useState(""); const [snippetSearch, setSnippetSearch] = useState("");
const [folders, setFolders] = useState<SnippetFolder[]>([]); const [folders, setFolders] = useState<SnippetFolder[]>([]);
const [snippets, setSnippets] = useState<Snippet[]>([]); const [snippets, setSnippets] = useState<Snippet[]>([]);
const [snippetFormOpen, setSnippetFormOpen] = useState(false);
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null);
const [createFolderOpen, setCreateFolderOpen] = useState(false);
const [shareSnippet, setShareSnippet] = useState<Snippet | null>(null);
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
() =>
new Set(
activeTabId && terminalTabs.some((tab) => tab.id === activeTabId)
? [activeTabId]
: [],
),
);
const [uncategorizedOpen, setUncategorizedOpen] = useState(true);
useEffect(() => { useEffect(() => {
getSnippets() getSnippets()
@@ -351,23 +699,28 @@ export function SnippetsPanel({
id: s.id, id: s.id,
name: s.name, name: s.name,
description: s.description, description: s.description,
command: s.command, content: s.content,
folderId: s.folderId ?? null, folder: s.folder ?? null,
})); }));
setSnippets(mapped); setSnippets(mapped);
}) })
.catch(() => {}); .catch(() => {});
getSnippetFolders()
.then((data) => {
const arr = Array.isArray(data) ? data : [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mapped: SnippetFolder[] = arr.map((f: any) => ({
id: f.id,
name: f.name,
color: f.color ?? FOLDER_COLORS[0],
icon: (f.icon as FolderIconId) ?? "folder",
open: true,
}));
setFolders(mapped);
})
.catch(() => {});
}, []); }, []);
const [createSnippetOpen, setCreateSnippetOpen] = useState(false);
const [createFolderOpen, setCreateFolderOpen] = useState(false);
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
() =>
new Set(
activeTabId && terminalTabs.some((t) => t.id === activeTabId)
? [activeTabId]
: [],
),
);
function toggleTab(id: string) { function toggleTab(id: string) {
setSelectedTabIds((prev) => { setSelectedTabIds((prev) => {
@@ -377,24 +730,49 @@ export function SnippetsPanel({
}); });
} }
async function handleCreateSnippet(s: Omit<Snippet, "id">) { async function handleSaveSnippet(data: Omit<Snippet, "id">, id?: number) {
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any if (id !== undefined) {
const created = (await apiCreateSnippet(s as any)) as any; // eslint-disable-next-line @typescript-eslint/no-explicit-any
setSnippets((prev) => [ await apiUpdateSnippet(id, data as any);
...prev, setSnippets((prev) =>
{ ...s, id: created.id ?? Math.max(0, ...prev.map((x) => x.id)) + 1 }, prev.map((s) => (s.id === id ? { ...s, ...data } : s)),
]); );
toast.success("Snippet created successfully"); 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) => [
...prev,
{
...data,
id: created.id ?? Math.max(0, ...prev.map((x) => x.id)) + 1,
},
]);
toast.success(t("newUi.sidebar.snippets.createSuccess"));
}
} catch { } catch {
toast.error("Failed to create snippet"); toast.error(
id !== undefined
? t("newUi.sidebar.snippets.updateFailed")
: t("newUi.sidebar.snippets.createFailed"),
);
} }
} }
function handleCreateFolder(f: Omit<SnippetFolder, "id" | "open">) { async function handleCreateFolder(f: Omit<SnippetFolder, "id" | "open">) {
const id = Math.max(0, ...folders.map((x) => x.id)) + 1; try {
setFolders((prev) => [...prev, { ...f, id, open: true }]); // eslint-disable-next-line @typescript-eslint/no-explicit-any
toast.success("Folder created successfully"); const created = (await apiCreateSnippetFolder({
name: f.name,
color: f.color,
icon: f.icon,
})) as any;
setFolders((prev) => [...prev, { ...f, id: created.id, open: true }]);
toast.success(t("newUi.sidebar.snippets.folderCreateSuccess"));
} catch {
toast.error(t("newUi.sidebar.snippets.folderCreateFailed"));
}
} }
function toggleFolder(id: number) { function toggleFolder(id: number) {
@@ -403,29 +781,29 @@ export function SnippetsPanel({
); );
} }
async function deleteSnippet(id: number) { async function handleDeleteSnippet(id: number) {
try { try {
await apiDeleteSnippet(id); await apiDeleteSnippet(id);
setSnippets((prev) => prev.filter((s) => s.id !== id)); setSnippets((prev) => prev.filter((s) => s.id !== id));
} catch { } catch {
toast.error("Failed to delete snippet"); toast.error(t("newUi.sidebar.snippets.deleteFailed"));
} }
} }
function handleEditSnippet(snippet: Snippet) {
setEditingSnippet(snippet);
setSnippetFormOpen(true);
}
const filtered = snippetSearch const filtered = snippetSearch
? snippets.filter( ? snippets.filter(
(s) => (s) =>
s.name.toLowerCase().includes(snippetSearch.toLowerCase()) || s.name.toLowerCase().includes(snippetSearch.toLowerCase()) ||
s.command.toLowerCase().includes(snippetSearch.toLowerCase()), s.content.toLowerCase().includes(snippetSearch.toLowerCase()),
) )
: snippets; : snippets;
const namedFolders = folders.filter((f) => f.name !== "Uncategorized"); const uncategorizedSnippets = filtered.filter((s) => s.folder === null);
const uncategorized = folders.find((f) => f.name === "Uncategorized");
const allFolders = [
...namedFolders,
...(uncategorized ? [uncategorized] : []),
];
return ( return (
<> <>
@@ -442,7 +820,9 @@ export function SnippetsPanel({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => onClick={() =>
setSelectedTabIds(new Set(terminalTabs.map((t) => t.id))) setSelectedTabIds(
new Set(terminalTabs.map((tab) => tab.id)),
)
} }
className="text-[10px] text-accent-brand hover:text-accent-brand/70" className="text-[10px] text-accent-brand hover:text-accent-brand/70"
> >
@@ -511,7 +891,10 @@ export function SnippetsPanel({
<Button <Button
variant="outline" variant="outline"
className="flex-1 text-xs min-w-0 overflow-hidden" className="flex-1 text-xs min-w-0 overflow-hidden"
onClick={() => setCreateSnippetOpen(true)} onClick={() => {
setEditingSnippet(null);
setSnippetFormOpen(true);
}}
> >
<Plus className="size-3.5 shrink-0" /> <Plus className="size-3.5 shrink-0" />
{t("newUi.sidebar.snippets.newSnippet")} {t("newUi.sidebar.snippets.newSnippet")}
@@ -526,11 +909,49 @@ export function SnippetsPanel({
</Button> </Button>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{allFolders.map((folder) => { {(!snippetSearch || uncategorizedSnippets.length > 0) && (
const folderSnippets = filtered.filter((s) => <div className="flex flex-col gap-2">
folder.name === "Uncategorized" <button
? s.folderId === null || s.folderId === folder.id onClick={() => setUncategorizedOpen((v) => !v)}
: s.folderId === folder.id, className="flex items-center gap-1.5 w-full text-left"
>
<ChevronDown
className={`size-3 text-muted-foreground shrink-0 transition-transform ${uncategorizedOpen ? "" : "-rotate-90"}`}
/>
<Folder className="size-3.5 shrink-0 text-muted-foreground" />
<span className="text-xs font-semibold flex-1 truncate text-muted-foreground">
{t("newUi.sidebar.snippets.uncategorized")}
</span>
<span className="text-xs text-muted-foreground shrink-0">
{uncategorizedSnippets.length}
</span>
</button>
{uncategorizedOpen && (
<div className="flex flex-col gap-2 ml-1">
{uncategorizedSnippets.map((snippet) => (
<SnippetCard
key={snippet.id}
snippet={snippet}
selectedTabIds={selectedTabIds}
terminalTabs={terminalTabs}
onDelete={handleDeleteSnippet}
onEdit={handleEditSnippet}
onShare={setShareSnippet}
t={t}
/>
))}
{uncategorizedSnippets.length === 0 && (
<span className="text-xs text-muted-foreground/60 pl-1">
{t("newUi.sidebar.snippets.noSnippetsInFolder")}
</span>
)}
</div>
)}
</div>
)}
{folders.map((folder) => {
const folderSnippets = filtered.filter(
(s) => s.folder === folder.name,
); );
if (folderSnippets.length === 0 && snippetSearch) return null; if (folderSnippets.length === 0 && snippetSearch) return null;
return ( return (
@@ -549,12 +970,7 @@ export function SnippetsPanel({
/> />
<span <span
className="text-xs font-semibold flex-1 truncate" className="text-xs font-semibold flex-1 truncate"
style={{ style={{ color: folder.color }}
color:
folder.name === "Uncategorized"
? undefined
: folder.color,
}}
> >
{folder.name} {folder.name}
</span> </span>
@@ -565,71 +981,16 @@ export function SnippetsPanel({
{folder.open && ( {folder.open && (
<div className="flex flex-col gap-2 ml-1"> <div className="flex flex-col gap-2 ml-1">
{folderSnippets.map((snippet) => ( {folderSnippets.map((snippet) => (
<div <SnippetCard
key={snippet.id} key={snippet.id}
className="border border-border bg-background p-2.5 flex flex-col gap-2" snippet={snippet}
> selectedTabIds={selectedTabIds}
<div className="flex items-start gap-2"> terminalTabs={terminalTabs}
<div className="grid grid-cols-2 gap-px mt-0.5 shrink-0 opacity-30"> onDelete={handleDeleteSnippet}
<div className="size-1 bg-muted-foreground rounded-full" /> onEdit={handleEditSnippet}
<div className="size-1 bg-muted-foreground rounded-full" /> onShare={setShareSnippet}
<div className="size-1 bg-muted-foreground rounded-full" /> t={t}
<div className="size-1 bg-muted-foreground rounded-full" /> />
</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>
)}
</div>
</div>
<span className="text-xs text-muted-foreground font-mono px-1">
{snippet.command}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="flex-1 text-xs h-7 gap-1.5"
>
<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"
>
<Copy className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive shrink-0"
onClick={() => deleteSnippet(snippet.id)}
>
<Trash2 className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground shrink-0"
>
<Share2 className="size-3.5" />
</Button>
</div>
</div>
))} ))}
{folderSnippets.length === 0 && ( {folderSnippets.length === 0 && (
<span className="text-xs text-muted-foreground/60 pl-1"> <span className="text-xs text-muted-foreground/60 pl-1">
@@ -644,17 +1005,25 @@ export function SnippetsPanel({
</div> </div>
</div> </div>
<CreateSnippetDialog <SnippetFormDialog
open={createSnippetOpen} open={snippetFormOpen}
onOpenChange={setCreateSnippetOpen} onOpenChange={(v) => {
setSnippetFormOpen(v);
if (!v) setEditingSnippet(null);
}}
folders={folders} folders={folders}
onCreate={handleCreateSnippet} snippet={editingSnippet}
onSave={handleSaveSnippet}
/> />
<CreateFolderDialog <CreateFolderDialog
open={createFolderOpen} open={createFolderOpen}
onOpenChange={setCreateFolderOpen} onOpenChange={setCreateFolderOpen}
onCreate={handleCreateFolder} onCreate={handleCreateFolder}
/> />
<ShareSnippetDialog
snippet={shareSnippet}
onClose={() => setShareSnippet(null)}
/>
</> </>
); );
} }
+111 -6
View File
@@ -1,9 +1,10 @@
import { useState } from "react"; import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/button"; import { Button } from "@/components/button";
import { Separator } from "@/components/separator"; import { Separator } from "@/components/separator";
import { Terminal } from "lucide-react"; import { Terminal } from "lucide-react";
import type { Tab } from "@/types/ui-types"; import type { Tab } from "@/types/ui-types";
import { getCookie, setCookie } from "@/main-axios";
export function SshToolsPanel({ export function SshToolsPanel({
terminalTabs, terminalTabs,
@@ -14,7 +15,9 @@ export function SshToolsPanel({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [keyRecording, setKeyRecording] = useState(false); const [keyRecording, setKeyRecording] = useState(false);
const [rightClickPaste, setRightClickPaste] = useState(false); const [rightClickPaste, setRightClickPaste] = useState(
() => getCookie("rightClickCopyPaste") !== "false",
);
const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>( const [selectedTabIds, setSelectedTabIds] = useState<Set<string>>(
() => () =>
new Set( new Set(
@@ -23,6 +26,11 @@ export function SshToolsPanel({
: [], : [],
), ),
); );
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (keyRecording) inputRef.current?.focus();
}, [keyRecording]);
function toggleTab(id: string) { function toggleTab(id: string) {
setSelectedTabIds((prev) => { setSelectedTabIds((prev) => {
@@ -40,6 +48,89 @@ export function SshToolsPanel({
setSelectedTabIds(new Set()); setSelectedTabIds(new Set());
} }
function broadcast(data: string) {
for (const tabId of selectedTabIds) {
const tab = terminalTabs.find((t) => t.id === tabId);
(tab?.terminalRef?.current as any)?.sendInput?.(data);
}
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
e.preventDefault();
e.stopPropagation();
const ctrl = e.ctrlKey;
const { key } = e;
if (ctrl) {
const ctrlMap: Record<string, string> = {
c: "\x03",
d: "\x04",
l: "\x0C",
u: "\x15",
k: "\x0B",
a: "\x01",
e: "\x05",
w: "\x17",
z: "\x1A",
r: "\x12",
};
const seq = ctrlMap[key.toLowerCase()];
if (seq) {
broadcast(seq);
return;
}
}
const specialMap: Record<string, string> = {
Enter: "\r",
Backspace: "\x7F",
Delete: "\x1B[3~",
Tab: "\t",
Escape: "\x1B",
ArrowUp: "\x1B[A",
ArrowDown: "\x1B[B",
ArrowRight: "\x1B[C",
ArrowLeft: "\x1B[D",
Home: "\x1B[H",
End: "\x1B[F",
PageUp: "\x1B[5~",
PageDown: "\x1B[6~",
Insert: "\x1B[2~",
F1: "\x1BOP",
F2: "\x1BOQ",
F3: "\x1BOR",
F4: "\x1BOS",
F5: "\x1B[15~",
F6: "\x1B[17~",
F7: "\x1B[18~",
F8: "\x1B[19~",
F9: "\x1B[20~",
F10: "\x1B[21~",
F11: "\x1B[23~",
F12: "\x1B[24~",
};
const seq = specialMap[key];
if (seq) {
broadcast(seq);
return;
}
if (!ctrl && !e.altKey && !e.metaKey && key.length === 1) {
broadcast(key);
}
}
function toggleRecording() {
const next = !keyRecording;
if (!next) {
// clear the phantom text when stopping
if (inputRef.current) inputRef.current.value = "";
}
setKeyRecording(next);
}
return ( return (
<div className="flex flex-col gap-3 p-3"> <div className="flex flex-col gap-3 p-3">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
@@ -114,14 +205,24 @@ export function SshToolsPanel({
variant="outline" variant="outline"
disabled={selectedTabIds.size === 0} disabled={selectedTabIds.size === 0}
className={`w-full ${keyRecording ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10 hover:bg-accent-brand/20 hover:text-accent-brand" : ""}`} className={`w-full ${keyRecording ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10 hover:bg-accent-brand/20 hover:text-accent-brand" : ""}`}
onClick={() => setKeyRecording((o) => !o)} onClick={toggleRecording}
> >
{keyRecording {keyRecording
? `Stop Recording (${selectedTabIds.size})` ? `${t("newUi.sidebar.sshTools.stopRecording")} (${selectedTabIds.size})`
: selectedTabIds.size === 0 : selectedTabIds.size === 0
? t("newUi.sidebar.sshTools.selectTerminalsAbove") ? t("newUi.sidebar.sshTools.selectTerminalsAbove")
: `Start Recording (${selectedTabIds.size})`} : `${t("newUi.sidebar.sshTools.startRecording")} (${selectedTabIds.size})`}
</Button> </Button>
{keyRecording && (
<input
ref={inputRef}
readOnly
onKeyDown={handleKeyDown}
placeholder={t("newUi.sidebar.sshTools.broadcastInputPlaceholder")}
className="w-full px-2.5 py-2 text-xs bg-background border border-accent-brand/40 text-foreground placeholder:text-muted-foreground/40 outline-none focus:border-accent-brand/70 caret-transparent"
/>
)}
</div> </div>
<Separator /> <Separator />
@@ -135,7 +236,11 @@ export function SshToolsPanel({
{t("newUi.sidebar.sshTools.enableRightClickCopyPaste")} {t("newUi.sidebar.sshTools.enableRightClickCopyPaste")}
</span> </span>
<button <button
onClick={() => setRightClickPaste((o) => !o)} onClick={() => {
const next = !rightClickPaste;
setRightClickPaste(next);
setCookie("rightClickCopyPaste", next ? "true" : "false");
}}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${ className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${
rightClickPaste rightClickPaste
? "bg-accent-brand border-accent-brand" ? "bg-accent-brand border-accent-brand"
+71 -20
View File
@@ -12,7 +12,9 @@ import {
enableTOTP, enableTOTP,
disableTOTP, disableTOTP,
getVersionInfo, getVersionInfo,
getUserRoles,
} from "@/main-axios"; } from "@/main-axios";
import type { UserRole } from "@/main-axios";
import type React from "react"; import type React from "react";
import { isElectron } from "@/lib/electron"; import { isElectron } from "@/lib/electron";
import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager"; import { C2STunnelPresetManager } from "@/user/C2STunnelPresetManager";
@@ -65,17 +67,17 @@ type UserProfileSection =
| "api-keys" | "api-keys"
| "c2s-tunnels"; | "c2s-tunnels";
const THEMES: { id: ThemeId; label: string; preview: string }[] = [ const THEMES: { id: ThemeId; preview: string }[] = [
{ id: "system", label: "System", preview: "auto" }, { id: "system", preview: "auto" },
{ id: "light", label: "Light", preview: "#ffffff" }, { id: "light", preview: "#ffffff" },
{ id: "dark", label: "Dark", preview: "#1a1c22" }, { id: "dark", preview: "#1a1c22" },
{ id: "dracula", label: "Dracula", preview: "#282a36" }, { id: "dracula", preview: "#282a36" },
{ id: "catppuccin", label: "Catppuccin", preview: "#1e1e2e" }, { id: "catppuccin", preview: "#1e1e2e" },
{ id: "nord", label: "Nord", preview: "#2e3440" }, { id: "nord", preview: "#2e3440" },
{ id: "solarized", label: "Solarized", preview: "#002b36" }, { id: "solarized", preview: "#002b36" },
{ id: "tokyo-night", label: "Tokyo Night", preview: "#1a1b26" }, { id: "tokyo-night", preview: "#1a1b26" },
{ id: "one-dark", label: "One Dark", preview: "#282c34" }, { id: "one-dark", preview: "#282c34" },
{ id: "gruvbox", label: "Gruvbox", preview: "#282828" }, { id: "gruvbox", preview: "#282828" },
]; ];
const LANGUAGES = [ const LANGUAGES = [
@@ -380,6 +382,18 @@ export function UserProfilePanel({
onLogout?: () => void; onLogout?: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const themeLabel: Record<ThemeId, string> = {
system: t("newUi.sidebar.userProfile.themeSystem"),
light: t("newUi.sidebar.userProfile.themeLight"),
dark: t("newUi.sidebar.userProfile.themeDark"),
dracula: t("newUi.sidebar.userProfile.themeDracula"),
catppuccin: t("newUi.sidebar.userProfile.themeCatppuccin"),
nord: t("newUi.sidebar.userProfile.themeNord"),
solarized: t("newUi.sidebar.userProfile.themeSolarized"),
"tokyo-night": t("newUi.sidebar.userProfile.themeTokyoNight"),
"one-dark": t("newUi.sidebar.userProfile.themeOneDark"),
gruvbox: t("newUi.sidebar.userProfile.themeGruvbox"),
};
const [openSection, setOpenSection] = useState<UserProfileSection | null>( const [openSection, setOpenSection] = useState<UserProfileSection | null>(
"account", "account",
); );
@@ -389,6 +403,9 @@ export function UserProfilePanel({
const [userRole, setUserRole] = useState(""); const [userRole, setUserRole] = useState("");
const [authMethod, setAuthMethod] = useState(""); const [authMethod, setAuthMethod] = useState("");
const [version, setVersion] = useState(""); const [version, setVersion] = useState("");
const [versionStatus, setVersionStatus] = useState<
"up_to_date" | "requires_update" | "beta"
>("up_to_date");
const [isOidc, setIsOidc] = useState(false); const [isOidc, setIsOidc] = useState(false);
const [isDualAuth, setIsDualAuth] = useState(false); const [isDualAuth, setIsDualAuth] = useState(false);
@@ -467,6 +484,9 @@ export function UserProfilePanel({
// API keys // API keys
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]); const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
// RBAC roles
const [userRoles, setUserRoles] = useState<UserRole[]>([]);
useEffect(() => { useEffect(() => {
getUserInfo() getUserInfo()
.then((info) => { .then((info) => {
@@ -486,13 +506,19 @@ export function UserProfilePanel({
} else { } else {
setAuthMethod(t("newUi.sidebar.userProfile.authMethodLocal")); setAuthMethod(t("newUi.sidebar.userProfile.authMethodLocal"));
} }
getUserRoles(info.userId)
.then(({ roles }) => setUserRoles(roles ?? []))
.catch(() => {});
}) })
.catch(() => {}); .catch(() => {});
getApiKeys() getApiKeys()
.then(({ apiKeys: keys }) => setApiKeys(keys)) .then(({ apiKeys: keys }) => setApiKeys(keys))
.catch(() => {}); .catch(() => {});
getVersionInfo(false) getVersionInfo()
.then((info) => setVersion(info.localVersion)) .then((info) => {
setVersion(info.localVersion);
setVersionStatus(info.status ?? "up_to_date");
})
.catch(() => {}); .catch(() => {});
}, []); }, []);
@@ -638,9 +664,19 @@ export function UserProfilePanel({
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold"> <span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
{t("newUi.sidebar.userProfile.roleLabel")} {t("newUi.sidebar.userProfile.roleLabel")}
</span> </span>
<span className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold border border-accent-brand/40 bg-accent-brand/10 text-accent-brand mt-0.5 w-fit"> <div className="flex flex-wrap gap-1 mt-0.5">
{userRole || "—"} <span className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold border border-accent-brand/40 bg-accent-brand/10 text-accent-brand w-fit">
</span> {userRole || "—"}
</span>
{userRoles.map((r) => (
<span
key={r.roleId}
className="inline-flex items-center px-1.5 py-0.5 text-[10px] font-semibold border border-border bg-muted text-muted-foreground w-fit"
>
{r.roleDisplayName}
</span>
))}
</div>
</div> </div>
<div className="flex flex-col py-2"> <div className="flex flex-col py-2">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold"> <span className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold">
@@ -679,6 +715,21 @@ export function UserProfilePanel({
<span className="text-sm font-bold text-accent-brand"> <span className="text-sm font-bold text-accent-brand">
{version ? `v${version}` : "—"} {version ? `v${version}` : "—"}
</span> </span>
<span
className={`text-[10px] px-1.5 py-0.5 font-semibold leading-none ${
versionStatus === "beta"
? "bg-blue-500/20 text-blue-400"
: versionStatus === "requires_update"
? "bg-yellow-500/20 text-yellow-400"
: "bg-accent-brand/20 text-accent-brand"
}`}
>
{versionStatus === "beta"
? t("dashboard.beta").toUpperCase()
: versionStatus === "requires_update"
? t("dashboard.updateAvailable").toUpperCase()
: t("dashboardTab.stable")}
</span>
</div> </div>
</div> </div>
@@ -743,7 +794,7 @@ export function UserProfilePanel({
> >
{THEMES.map((th) => ( {THEMES.map((th) => (
<option key={th.id} value={th.id}> <option key={th.id} value={th.id}>
{th.label} {themeLabel[th.id]}
</option> </option>
))} ))}
</select> </select>
@@ -753,7 +804,7 @@ export function UserProfilePanel({
{THEMES.filter((th) => th.id !== "system").map((th) => ( {THEMES.filter((th) => th.id !== "system").map((th) => (
<button <button
key={th.id} key={th.id}
title={th.label} title={themeLabel[th.id]}
onClick={() => setTheme(th.id)} onClick={() => setTheme(th.id)}
className={`h-4 flex-1 border transition-all ${theme === th.id ? "border-accent-brand ring-1 ring-accent-brand" : "border-border/50"}`} className={`h-4 flex-1 border transition-all ${theme === th.id ? "border-accent-brand ring-1 ring-accent-brand" : "border-border/50"}`}
style={{ background: th.preview }} style={{ background: th.preview }}
@@ -808,7 +859,7 @@ export function UserProfilePanel({
onClick={() => colorInputRef.current?.click()} onClick={() => colorInputRef.current?.click()}
className="size-5 shrink-0 border border-border/60 cursor-pointer" className="size-5 shrink-0 border border-border/60 cursor-pointer"
style={{ background: accentColor }} style={{ background: accentColor }}
title="Open color picker" title={t("newUi.sidebar.userProfile.colorPickerTooltip")}
/> />
<input <input
ref={colorInputRef} ref={colorInputRef}
@@ -1136,7 +1187,7 @@ export function UserProfilePanel({
<div className="flex items-center justify-center p-3 bg-background border border-border"> <div className="flex items-center justify-center p-3 bg-background border border-border">
<img <img
src={totpQrCode} src={totpQrCode}
alt="TOTP QR Code" alt={t("newUi.sidebar.userProfile.qrCode")}
className="size-32" className="size-32"
/> />
</div> </div>
+1 -1
View File
@@ -51,7 +51,7 @@ export function ConnectionLog({
}, [logs, isExpanded]); }, [logs, isExpanded]);
const shouldShow = const shouldShow =
isConnecting || hasConnectionError || (logs.length > 0 && !isConnected); !isConnected && (isConnecting || hasConnectionError || logs.length > 0);
if (!shouldShow) { if (!shouldShow) {
return null; return null;