mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
feat: improve all connection types
This commit is contained in:
@@ -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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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 ? (
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
+1086
-352
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user