This commit is contained in:
LukeGus
2026-05-28 22:29:20 -05:00
parent 33dcde0827
commit 5777351145
238 changed files with 62303 additions and 98955 deletions
+14 -8
View File
@@ -7,6 +7,8 @@ import type { SSHHost } from "@/types";
import { Dashboard } from "@/dashboard/Dashboard.tsx";
import { Toaster } from "@/components/sonner.tsx";
import { dbHealthMonitor } from "@/lib/db-health-monitor.ts";
import { useTranslation } from "react-i18next";
import { RefreshCw } from "lucide-react";
interface FullScreenAppWrapperProps {
hostId?: string;
@@ -17,6 +19,7 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
hostId,
children,
}) => {
const { t } = useTranslation();
const [hostConfig, setHostConfig] = useState<SSHHost | null>(null);
const [loading, setLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
@@ -84,13 +87,16 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
if (authLoading) {
return (
<div
className="w-full h-screen overflow-hidden flex items-center justify-center"
style={{ backgroundColor: "#18181b" }}
className="w-full h-screen overflow-hidden flex flex-col items-center justify-center gap-4"
style={{ backgroundColor: "var(--bg-base)" }}
>
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">Loading...</p>
</div>
<RefreshCw
className="size-8 animate-spin"
style={{ color: "var(--foreground)" }}
/>
<p className="text-sm" style={{ color: "var(--foreground-secondary)" }}>
{t("common.loading")}
</p>
</div>
);
}
@@ -102,7 +108,7 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
<CommandHistoryProvider>
<div
className="w-full h-screen overflow-hidden flex items-center justify-center"
style={{ backgroundColor: "#18181b" }}
style={{ backgroundColor: "var(--bg-base)" }}
>
<Dashboard
isAuthenticated={false}
@@ -131,7 +137,7 @@ export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
<CommandHistoryProvider>
<div
className="w-full h-screen overflow-hidden"
style={{ backgroundColor: "#18181b" }}
style={{ backgroundColor: "var(--bg-base)" }}
>
{children(hostConfig, loading)}
</div>
+6 -2
View File
@@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { DockerManager } from "@/features/docker/DockerManager.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
@@ -7,6 +8,7 @@ interface DockerAppProps {
}
const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, loading) => {
@@ -15,7 +17,9 @@ const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">Loading host...</p>
<p className="text-muted-foreground">
{t("hosts.loadingHost")}
</p>
</div>
</div>
);
@@ -25,7 +29,7 @@ const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-red-500 mb-4">Host not found</p>
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
</div>
</div>
);
+50 -15
View File
@@ -4,7 +4,7 @@ import { Alert, AlertDescription } from "@/components/alert.tsx";
import { Button } from "@/components/button.tsx";
import { Card } from "@/components/card.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 type { SSHHost, DockerContainer, DockerValidation } from "@/types";
@@ -94,6 +94,7 @@ function DockerManagerInner({
const [hasConnectionError, setHasConnectionError] = React.useState(false);
const [search, setSearch] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
const [retryCount, setRetryCount] = React.useState(0);
const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
@@ -278,7 +279,7 @@ function DockerManagerInner({
});
}
};
}, [currentHostConfig?.id, currentHostConfig?.enableDocker]);
}, [currentHostConfig?.id, currentHostConfig?.enableDocker, retryCount]);
React.useEffect(() => {
if (!sessionId || !isVisible) return;
@@ -539,6 +540,15 @@ function DockerManagerInner({
onClose?.();
};
const handleRetry = () => {
initializingRef.current = false;
setSessionId(null);
setHasConnectionError(false);
setDockerValidation(null);
clearLogs();
setRetryCount((c) => c + 1);
};
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = 8;
const bottomMarginPx = 8;
@@ -610,21 +620,32 @@ function DockerManagerInner({
}
if (dockerValidation && !dockerValidation.available) {
const isError =
hasConnectionError || (!!dockerValidation && !dockerValidation.available);
return (
<div style={wrapperStyle} className={`${containerClass} relative`}>
{!isConnectionLogExpanded && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10">
<Box className="size-12 opacity-20" />
<p className="text-sm text-muted-foreground opacity-60">
{t("docker.connectionFailed")}
</p>
<Button
variant="outline"
size="default"
onClick={handleRetry}
className="gap-2 font-semibold"
>
<RefreshCw className="size-3.5" />
{t("terminal.retry")}
</Button>
</div>
)}
<ConnectionLog
isConnecting={isConnecting}
isConnected={!!sessionId && !!dockerValidation?.available}
hasConnectionError={
hasConnectionError ||
(!!dockerValidation && !dockerValidation.available)
}
position={
hasConnectionError ||
(!!dockerValidation && !dockerValidation.available)
? "top"
: "bottom"
}
hasConnectionError={isError}
position={isError ? "top" : "bottom"}
/>
</div>
);
@@ -709,9 +730,6 @@ function DockerManagerInner({
className={`size-4 text-accent-brand ${isLoadingContainers ? "animate-spin" : ""}`}
/>
</Button>
<Button variant="ghost" size="icon">
<Settings className="size-4 text-accent-brand" />
</Button>
</div>
</Card>
@@ -773,6 +791,23 @@ function DockerManagerInner({
visible={isConnecting && !isConnectionLogExpanded}
message={t("docker.connecting")}
/>
{hasConnectionError && !isConnectionLogExpanded && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10">
<Box className="size-12 opacity-20" />
<p className="text-sm text-muted-foreground opacity-60">
{t("docker.connectionFailed")}
</p>
<Button
variant="outline"
size="default"
onClick={handleRetry}
className="gap-2 font-semibold"
>
<RefreshCw className="size-3.5" />
{t("terminal.retry")}
</Button>
</div>
)}
<ConnectionLog
isConnecting={isConnecting}
isConnected={!!sessionId && !!dockerValidation?.available}
@@ -20,6 +20,12 @@ import type { SSHHost } from "@/types";
import { isElectron } from "@/main-axios.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
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 {
containerId: string;
@@ -35,7 +41,35 @@ export function ConsoleTerminal({
hostConfig,
}: ConsoleTerminalProps): React.ReactElement {
const { t } = useTranslation();
const { theme: appTheme } = useTheme();
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 [isConnecting, setIsConnecting] = React.useState(false);
const [selectedShell, setSelectedShell] = React.useState<string>("bash");
@@ -57,9 +91,18 @@ export function ConsoleTerminal({
terminal.loadAddon(clipboardAddon);
terminal.loadAddon(webLinksAddon);
terminal.options.cursorBlink = true;
terminal.options.fontSize = 14;
terminal.options.fontFamily = "monospace";
const fontConfig = TERMINAL_FONTS.find(
(f) => f.value === terminalConfig.fontFamily,
);
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> => {
if (window.electronClipboard) {
@@ -157,16 +200,29 @@ export function ConsoleTerminal({
return true;
});
const backgroundColor = getComputedStyle(document.documentElement)
.getPropertyValue("--bg-elevated")
.trim();
const foregroundColor = getComputedStyle(document.documentElement)
.getPropertyValue("--foreground")
.trim();
terminal.options.theme = {
background: backgroundColor || "var(--bg-elevated)",
foreground: foregroundColor || "var(--foreground)",
background: themeColors.background,
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(() => {
@@ -207,7 +263,7 @@ export function ConsoleTerminal({
terminal.dispose();
};
}, [terminal, t]);
}, [terminal, t, terminalConfig, themeColors]);
const disconnect = React.useCallback(() => {
if (wsRef.current) {
@@ -498,7 +554,10 @@ export function ConsoleTerminal({
</CardContent>
</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">
<div
ref={xtermRef}
@@ -6,7 +6,6 @@ import {
Activity,
ArrowLeft,
Box,
Info,
List,
Settings,
Terminal,
+306 -109
View File
@@ -38,7 +38,6 @@ import {
Search,
Grid3X3,
List,
ArrowUpDown,
ChevronLeft,
ChevronRight,
ArrowUp,
@@ -48,8 +47,6 @@ import {
Copy,
Layout,
} from "lucide-react";
import { Card } from "@/components/card.tsx";
import { Separator } from "@/components/separator.tsx";
import {
DropdownMenu,
DropdownMenuContent,
@@ -73,7 +70,6 @@ import {
listSSHFiles,
resolveSSHPath,
uploadSSHFile,
downloadSSHFile,
createSSHFile,
createSSHFolder,
deleteSSHItem,
@@ -260,6 +256,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const { dragHandlers } = useDragAndDrop({
onFilesDropped: handleFilesDropped,
onItemsDropped: handleItemsDropped,
onError: (error) => toast.error(error),
maxFileSize: 5120,
});
@@ -412,6 +409,17 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
async function initializeSSHConnection() {
if (!currentHost || isConnectingRef.current) return;
if (currentHost.enableSsh === false) {
setHasConnectionError(true);
addLog({
type: "error",
message: t("fileManager.sshRequiredForFileManager"),
timestamp: new Date().toISOString(),
});
setIsLoading(false);
return;
}
isConnectingRef.current = true;
try {
@@ -532,7 +540,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}
handleCloseWithError(
t("fileManager.failedToConnect") + ": " + (error.message || error),
t("fileManager.failedToConnect") +
": " +
(error instanceof Error ? error.message : String(error)),
);
} finally {
setIsLoading(false);
@@ -547,10 +557,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return false;
}
if (isLoading && currentLoadingPathRef.current !== path) {
return false;
}
let resolvedPath = path;
if (path.includes("$") || path.startsWith("~")) {
resolvedPath = await resolveSSHPath(sshSessionId, path);
@@ -563,8 +569,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
currentLoadingPathRef.current = resolvedPath;
setIsLoading(true);
setCreateIntent(null);
try {
const response = await listSSHFiles(sshSessionId, resolvedPath);
@@ -661,9 +665,17 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return false;
} else if (initialLoadDoneRef.current) {
toast.error(
t("fileManager.failedToLoadDirectory") + ": " + errorMessage,
);
const isPermissionDenied =
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;
@@ -695,6 +707,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const navigateTo = useCallback(
(path: string) => {
if (sshSessionId) setIsLoading(true);
setCurrentPath(path);
setNavHistory((prev) => {
const next = [...prev.slice(0, navIndex + 1), path];
@@ -702,24 +715,26 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return next;
});
},
[navIndex],
[navIndex, sshSessionId],
);
const goBack = useCallback(() => {
if (navIndex > 0) {
if (sshSessionId) setIsLoading(true);
const newIndex = navIndex - 1;
setNavIndex(newIndex);
setCurrentPath(navHistory[newIndex]);
}
}, [navIndex, navHistory]);
}, [navIndex, navHistory, sshSessionId]);
const goForward = useCallback(() => {
if (navIndex < navHistory.length - 1) {
if (sshSessionId) setIsLoading(true);
const newIndex = navIndex + 1;
setNavIndex(newIndex);
setCurrentPath(navHistory[newIndex]);
}
}, [navIndex, navHistory]);
}, [navIndex, navHistory, sshSessionId]);
const goUp = useCallback(() => {
if (currentPath === "/") return;
@@ -783,6 +798,125 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return () => document.removeEventListener("keydown", handleKeyDown);
}, [currentPath]);
async function handleItemsDropped(items: DataTransferItemList) {
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
const entries: FileSystemEntry[] = [];
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry?.();
if (entry) entries.push(entry);
}
const files: { file: File; relativePath: string }[] = [];
async function readEntry(
entry: FileSystemEntry,
path: string,
): Promise<void> {
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) =>
(entry as FileSystemFileEntry).file(resolve, reject),
);
files.push({ file, relativePath: path });
} else if (entry.isDirectory) {
const reader = (entry as FileSystemDirectoryEntry).createReader();
const dirEntries = await new Promise<FileSystemEntry[]>(
(resolve, reject) => reader.readEntries(resolve, reject),
);
for (const child of dirEntries) {
await readEntry(child, `${path}/${child.name}`);
}
}
}
for (const entry of entries) {
await readEntry(entry, entry.name);
}
if (files.length === 0) return;
const progressToast = toast.loading(
`Uploading ${files.length} file(s)...`,
{ duration: Infinity },
);
try {
await ensureSSHConnection();
const dirs = new Set<string>();
for (const { relativePath } of files) {
const parts = relativePath.split("/");
for (let i = 1; i < parts.length; i++) {
dirs.add(parts.slice(0, i).join("/"));
}
}
const sortedDirs = Array.from(dirs).sort();
for (const dir of sortedDirs) {
const parentPath = currentPath.endsWith("/")
? currentPath + dir.split("/").slice(0, -1).join("/")
: currentPath + "/" + dir.split("/").slice(0, -1).join("/");
const folderName = dir.split("/").pop()!;
const targetPath = parentPath.endsWith("/")
? parentPath
: parentPath + "/";
try {
await createSSHFolder(
sshSessionId,
targetPath,
folderName,
currentHost?.id,
);
} catch {
// directory may already exist
}
}
for (const { file, relativePath } of files) {
const dirPart = relativePath.includes("/")
? relativePath.substring(0, relativePath.lastIndexOf("/"))
: "";
const uploadPath = dirPart
? (currentPath.endsWith("/") ? currentPath : currentPath + "/") +
dirPart +
"/"
: currentPath;
const fileContent = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(reader.error);
reader.onload = () => {
if (typeof reader.result === "string") {
resolve(reader.result.split(",")[1] || "");
} else {
reject(new Error("Failed to read file"));
}
};
reader.readAsDataURL(file);
});
await uploadSSHFile(
sshSessionId,
uploadPath,
file.name,
fileContent,
currentHost?.id,
);
}
toast.dismiss(progressToast);
toast.success(`Uploaded ${files.length} file(s) successfully`);
handleRefreshDirectory();
} catch (error) {
toast.dismiss(progressToast);
toast.error(t("fileManager.failedToUploadFile"));
console.error("Folder upload failed:", error);
}
}
function handleFilesDropped(fileList: FileList) {
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
@@ -840,10 +974,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
handleRefreshDirectory();
} catch (error: unknown) {
toast.dismiss(progressToast);
const uploadErr = error instanceof Error ? error : null;
if (
error.message?.includes("connection") ||
error.message?.includes("established")
uploadErr?.message?.includes("connection") ||
uploadErr?.message?.includes("established")
) {
toast.error(
t("fileManager.sshConnectionFailed", {
@@ -865,38 +999,17 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
try {
await ensureSSHConnection();
const response = await downloadSSHFile(sshSessionId, file.path);
const { downloadSSHFileStream } = await import("@/main-axios.ts");
await downloadSSHFileStream(sshSessionId, file.path);
if (response?.content) {
const byteCharacters = atob(response.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], {
type: response.mimeType || "application/octet-stream",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(
t("fileManager.fileDownloadedSuccessfully", { name: file.name }),
);
} else {
toast.error(t("fileManager.failedToDownloadFile"));
}
toast.success(
t("fileManager.fileDownloadedSuccessfully", { name: file.name }),
);
} catch (error: unknown) {
const err = error instanceof Error ? error : null;
if (
error.message?.includes("connection") ||
error.message?.includes("established")
err?.message?.includes("connection") ||
err?.message?.includes("established")
) {
toast.error(
t("fileManager.sshConnectionFailed", {
@@ -980,7 +1093,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
clearSelection();
} catch (error: unknown) {
const axiosError = error as {
response?: { data?: { needsSudo?: boolean; error?: string } };
response?: {
data?: { needsSudo?: boolean; error?: string };
status?: number;
};
message?: string;
};
if (axiosError.response?.data?.needsSudo) {
@@ -989,11 +1105,22 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return;
}
if (
axiosError.response?.status === 403 ||
axiosError.response?.data?.error
?.toLowerCase()
.includes("permission denied")
) {
toast.error(t("fileManager.permissionDenied"));
} else if (
axiosError.message?.includes("connection") ||
axiosError.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`,
t("fileManager.sshConnectionFailed", {
name: currentHost?.name,
ip: currentHost?.ip,
port: currentHost?.port,
}),
);
} else {
toast.error(t("fileManager.failedToDeleteItems"));
@@ -1061,14 +1188,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
t("fileManager.newFolderDefault"),
"directory",
);
const newCreateIntent = {
setCreateIntent({
id: Date.now().toString(),
type: "directory" as const,
defaultName,
currentName: defaultName,
};
setCreateIntent(newCreateIntent);
});
}
function handleCreateNewFile() {
@@ -1076,13 +1201,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
t("fileManager.newFileDefault"),
"file",
);
const newCreateIntent = {
setCreateIntent({
id: Date.now().toString(),
type: "file" as const,
defaultName,
currentName: defaultName,
};
setCreateIntent(newCreateIntent);
});
}
const handleSymlinkClick = async (file: FileItem) => {
@@ -1166,6 +1290,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
async function handleFileOpen(file: FileItem) {
if (file.type === "directory") {
if (sshSessionId) setIsLoading(true);
setCurrentPath(file.path);
} else if (file.type === "link") {
await handleSymlinkClick(file);
@@ -1308,16 +1433,28 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}
} catch (error: unknown) {
console.error(`Failed to ${operation} file ${file.name}:`, error);
toast.error(
t("fileManager.operationFailed", {
operation:
operation === "copy"
? t("fileManager.copy")
: t("fileManager.move"),
name: file.name,
error: error.message,
}),
);
const axiosError = error as {
response?: { status?: number; data?: { error?: string } };
};
if (
axiosError.response?.status === 403 ||
axiosError.response?.data?.error
?.toLowerCase()
.includes("permission denied")
) {
toast.error(t("fileManager.permissionDenied"));
} else {
toast.error(
t("fileManager.operationFailed", {
operation:
operation === "copy"
? t("fileManager.copy")
: t("fileManager.move"),
name: file.name,
error: error instanceof Error ? error.message : String(error),
}),
);
}
}
}
@@ -1408,9 +1545,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setClipboard(null);
}
} catch (error: unknown) {
toast.error(
`${t("fileManager.pasteFailed")}: ${error.message || t("fileManager.unknownError")}`,
);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`${t("fileManager.pasteFailed")}: ${errorMessage}`);
}
}
@@ -1436,10 +1573,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
handleRefreshDirectory();
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(
`${t("fileManager.extractFailed")}: ${err.message || t("fileManager.unknownError")}`,
);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`${t("fileManager.extractFailed")}: ${errorMessage}`);
}
}
@@ -1481,10 +1617,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
handleRefreshDirectory();
clearSelection();
} catch (error: unknown) {
const err = error as { message?: string };
toast.error(
`${t("fileManager.compressFailed")}: ${err.message || t("fileManager.unknownError")}`,
);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`${t("fileManager.compressFailed")}: ${errorMessage}`);
}
}
@@ -1524,7 +1659,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
toast.error(
t("fileManager.deleteCopiedFileFailed", {
name: copiedFile.targetName,
error: error.message,
error:
error instanceof Error ? error.message : String(error),
}),
);
}
@@ -1566,7 +1702,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
toast.error(
t("fileManager.moveBackFileFailed", {
name: movedFile.targetName,
error: error.message,
error:
error instanceof Error ? error.message : String(error),
}),
);
}
@@ -1599,9 +1736,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
handleRefreshDirectory();
} catch (error: unknown) {
toast.error(
`${t("fileManager.undoOperationFailed")}: ${error.message || t("fileManager.unknownError")}`,
);
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(`${t("fileManager.undoOperationFailed")}: ${errorMessage}`);
console.error("Undo failed:", error);
}
}
@@ -1696,8 +1833,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setCreateIntent(null);
handleRefreshDirectory();
} catch (error: unknown) {
const axiosError = error as {
response?: { status?: number; data?: { error?: string } };
};
if (
axiosError.response?.status === 403 ||
axiosError.response?.data?.error
?.toLowerCase()
.includes("permission denied")
) {
toast.error(t("fileManager.permissionDenied"));
} else {
toast.error(t("fileManager.failedToCreateItem"));
}
console.error("Create failed:", error);
toast.error(t("fileManager.failedToCreateItem"));
}
}
@@ -1725,8 +1874,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setEditingFile(null);
handleRefreshDirectory();
} catch (error: unknown) {
const axiosError = error as {
response?: { status?: number; data?: { error?: string } };
};
if (
axiosError.response?.status === 403 ||
axiosError.response?.data?.error
?.toLowerCase()
.includes("permission denied")
) {
toast.error(t("fileManager.permissionDenied"));
} else {
toast.error(t("fileManager.failedToRenameItem"));
}
console.error("Rename failed:", error);
toast.error(t("fileManager.failedToRenameItem"));
}
}
@@ -1910,7 +2071,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setAuthDialogReason("auth_failed");
setShowAuthDialog(true);
toast.error(
t("fileManager.failedToConnect") + ": " + (error.message || error),
t("fileManager.failedToConnect") +
": " +
(error instanceof Error ? error.message : String(error)),
);
} finally {
setIsLoading(false);
@@ -1979,7 +2142,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
toast.error(
t("fileManager.moveFileFailed", { name: file.name }) +
": " +
error.message,
(error instanceof Error ? error.message : String(error)),
);
}
}
@@ -2022,7 +2185,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}
} catch (error: unknown) {
console.error("Drag move operation failed:", error);
toast.error(t("fileManager.moveOperationFailed") + ": " + error.message);
toast.error(
t("fileManager.moveOperationFailed") +
": " +
(error instanceof Error ? error.message : String(error)),
);
}
}
@@ -2096,7 +2263,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
toast.error(
t("fileManager.dragFailed") +
": " +
(error.message || t("fileManager.unknownError")),
(error instanceof Error ? error.message : String(error)),
);
}
}
@@ -2362,15 +2529,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
);
}
if ((isLoading || isReconnecting) && !sshSessionId) {
return (
<div className="h-full w-full flex flex-col bg-background relative">
<div className="flex-1 overflow-hidden min-h-0 relative">
<SimpleLoader
visible={!isConnectionLogExpanded}
message={t("fileManager.connecting")}
/>
</div>
<ConnectionLog
isConnecting={isLoading || isReconnecting}
isConnected={false}
hasConnectionError={hasConnectionError}
position={hasConnectionError ? "top" : "bottom"}
/>
</div>
);
}
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
className="h-full w-full flex flex-col"
className="h-full w-full flex flex-col min-h-0"
style={{
visibility: isConnectionLogExpanded ? "hidden" : "visible",
}}
>
<Card className="flex flex-col shrink-0 mx-3 mt-3 rounded-none shadow-none border-border">
<div className="flex flex-col shrink-0 mx-3 mt-3 border border-border bg-card">
<div className="flex flex-row items-center justify-between px-3 py-2 gap-2">
<div className="flex items-center gap-1">
<Button
@@ -2413,11 +2599,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
variant="ghost"
size="icon"
onClick={handleRefreshDirectory}
disabled={isLoading}
className="size-8 rounded-none"
>
<RefreshCw
className={`size-4 ${isLoading ? "animate-spin" : ""}`}
className={`size-4 ${isLoading && !!sshSessionId ? "animate-spin [animation-duration:0.5s]" : ""}`}
/>
</Button>
</div>
@@ -2538,16 +2723,21 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
<DropdownMenuContent
align="end"
className="w-44 rounded-none border-border bg-card"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<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"
>
<FolderPlus className="size-4 text-accent-brand" />
{t("fileManager.newFolder")}
</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"
>
<FilePlus className="size-4 text-muted-foreground" />
@@ -2640,7 +2830,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
</div>
</div>
</div>
</Card>
</div>
<div
className="flex-1 flex px-3 pb-3 pt-2 gap-3 min-h-0 relative"
@@ -2664,7 +2854,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
: "hidden md:flex",
)}
>
<Card className="flex-1 flex flex-col rounded-none shadow-none p-0 gap-0 overflow-hidden border-border">
<div className="flex-1 flex flex-col overflow-hidden min-h-0 border border-border bg-card">
<FileManagerSidebar
currentHost={currentHost}
currentPath={currentPath}
@@ -2675,10 +2865,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
refreshTrigger={sidebarRefreshTrigger}
diskInfo={diskInfo ?? undefined}
/>
</Card>
</div>
</div>
<Card className="flex-1 relative overflow-hidden rounded-none shadow-none p-0 gap-0 min-h-0 flex flex-col border-border">
<div className="flex-1 relative overflow-hidden min-h-0 flex flex-col border border-border bg-card">
<div className="flex-1 relative min-h-0 h-full">
<FileManagerGrid
files={filteredFiles}
@@ -2687,7 +2877,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onFileOpen={handleFileOpen}
onSelectionChange={setSelection}
currentPath={currentPath}
isLoading={isLoading}
onPathChange={navigateTo}
onRefresh={handleRefreshDirectory}
onUpload={handleFilesDropped}
@@ -2701,7 +2890,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
setSortOrder("asc");
}
}}
onDownload={(files) => files.forEach(handleDownloadFile)}
onDownload={(files) =>
files
.filter((f) => f.type === "file")
.forEach(handleDownloadFile)
}
onContextMenu={handleContextMenu}
viewMode={viewMode}
onRename={handleRenameConfirm}
@@ -2733,7 +2926,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onClose={() =>
setContextMenu((prev) => ({ ...prev, isVisible: false }))
}
onDownload={(files) => files.forEach(handleDownloadFile)}
onDownload={(files) =>
files
.filter((f) => f.type === "file")
.forEach(handleDownloadFile)
}
onPreview={handleFileOpen}
onRename={handleRenameFile}
onCopy={handleCopyFiles}
onCut={handleCutFiles}
@@ -2767,7 +2965,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onCopyPath={handleCopyPath}
/>
</div>
</Card>
</div>
</div>
</div>
@@ -2783,6 +2981,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
prompt={totpPrompt}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
backgroundColor="var(--bg-canvas)"
/>
<WarpgateDialog
@@ -2792,6 +2991,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onContinue={handleWarpgateContinue}
onCancel={handleWarpgateCancel}
onOpenUrl={handleWarpgateOpenUrl}
backgroundColor="var(--bg-canvas)"
/>
{currentHost && (
@@ -2806,6 +3006,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
username: currentHost.username,
name: currentHost.name,
}}
backgroundColor="var(--bg-canvas)"
/>
)}
@@ -2826,10 +3027,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}}
onSubmit={handleSudoPasswordSubmit}
/>
<SimpleLoader
visible={(isReconnecting || isLoading) && !isConnectionLogExpanded}
message={t("fileManager.connecting")}
/>
<ConnectionLog
isConnecting={isReconnecting || isLoading}
isConnected={!!sshSessionId}
@@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { FileManager } from "@/features/file-manager/FileManager.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
@@ -7,6 +8,7 @@ interface FileManagerAppProps {
}
const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, loading) => {
@@ -15,7 +17,9 @@ const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">Loading host...</p>
<p className="text-muted-foreground">
{t("hosts.loadingHost")}
</p>
</div>
</div>
);
@@ -25,7 +29,7 @@ const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-red-500 mb-4">Host not found</p>
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
</div>
</div>
);
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils.ts";
import {
Download,
@@ -143,14 +143,6 @@ export function FileManagerContextMenu({
setIsMounted(true);
const adjustPosition = () => {
const menuWidth = menuRef.current?.offsetWidth ?? 260;
const menuHeight = menuRef.current?.offsetHeight ?? 400;
setMenuPosition(getClampedMenuPosition(x, y, menuWidth, menuHeight));
};
adjustPosition();
let cleanupFn: (() => void) | null = null;
const timeoutId = setTimeout(() => {
@@ -206,6 +198,21 @@ export function FileManagerContextMenu({
};
}, [isVisible, x, y, onClose]);
useLayoutEffect(() => {
if (!isVisible || !menuRef.current) return;
const menuWidth = menuRef.current.offsetWidth;
const menuHeight = menuRef.current.offsetHeight;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let adjustedX = x;
let adjustedY = y;
if (x + menuWidth > viewportWidth)
adjustedX = viewportWidth - menuWidth - 10;
if (y + menuHeight > viewportHeight)
adjustedY = Math.max(10, viewportHeight - menuHeight - 10);
setMenuPosition({ x: adjustedX, y: adjustedY });
}, [isVisible, x, y, files.length]);
const isFileContext = files.length > 0;
const isSingleFile = files.length === 1;
const isMultipleFiles = files.length > 1;
@@ -528,9 +535,8 @@ export function FileManagerContextMenu({
<div
ref={menuRef}
data-context-menu
<<<<<<< HEAD:src/ui/desktop/apps/features/file-manager/FileManagerContextMenu.tsx
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={{
left: menuPosition.x,
@@ -543,7 +549,7 @@ export function FileManagerContextMenu({
return (
<div
key={`separator-${index}`}
className="border-t border-border"
className="my-1 border-t border-border"
/>
);
}
@@ -552,10 +558,10 @@ export function FileManagerContextMenu({
<button
key={index}
className={cn(
"w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center justify-between",
"hover:bg-accent-brand/10 hover:text-accent-brand transition-colors cursor-pointer rounded-none",
"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",
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 &&
"text-destructive hover:bg-destructive/10 hover:text-destructive",
)}
@@ -567,12 +573,14 @@ export function FileManagerContextMenu({
}}
disabled={item.disabled}
>
<div className="flex items-center gap-2.5 flex-1 min-w-0">
<div className="flex-shrink-0">{item.icon}</div>
<span className="flex-1 truncate">{item.label}</span>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex-shrink-0 text-muted-foreground">
{item.icon}
</div>
<span className="flex-1 leading-tight">{item.label}</span>
</div>
{item.shortcut && (
<div className="ml-2 flex-shrink-0">
<div className="ml-auto flex-shrink-0 opacity-50">
{renderShortcut(item.shortcut)}
</div>
)}
@@ -13,19 +13,14 @@ import {
Settings,
Download,
Upload,
ChevronLeft,
ChevronRight,
RefreshCw,
ArrowUp,
ArrowDown,
FileSymlink,
Move,
GitCompare,
Edit,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { FileItem } from "@/types/index";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
interface CreateIntent {
id: string;
@@ -70,7 +65,6 @@ interface FileManagerGridProps {
onFileOpen: (file: FileItem) => void;
onSelectionChange: (files: FileItem[]) => void;
currentPath: string;
isLoading?: boolean;
onPathChange: (path: string) => void;
onRefresh: () => void;
onUpload?: (files: FileList) => void;
@@ -194,7 +188,6 @@ export function FileManagerGrid({
onFileOpen,
onSelectionChange,
currentPath,
isLoading,
onPathChange,
onRefresh,
onUpload,
@@ -529,20 +522,30 @@ export function FileManagerGrid({
e.stopPropagation();
}, []);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
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;
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (createIntent) {
const target = e.target as HTMLElement;
if (target.tagName !== "INPUT") {
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);
setSelectionStart({ x: startX, y: startY });
setSelectionRect({ x: startX, y: startY, width: 0, height: 0 });
setIsSelecting(true);
setSelectionStart({ x: startX, y: startY });
setSelectionRect({ x: startX, y: startY, width: 0, height: 0 });
setJustFinishedSelecting(false);
}
}, []);
setJustFinishedSelecting(false);
}
},
[createIntent],
);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
@@ -699,7 +702,7 @@ export function FileManagerGrid({
const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
event.stopPropagation();
if (gridRef.current) {
if (gridRef.current && !createIntent) {
gridRef.current.focus();
}
@@ -734,7 +737,7 @@ export function FileManagerGrid({
};
const handleGridClick = (event: React.MouseEvent) => {
if (gridRef.current) {
if (gridRef.current && !createIntent) {
gridRef.current.focus();
}
@@ -978,6 +981,7 @@ export function FileManagerGrid({
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"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/>
) : (
<p
@@ -1100,6 +1104,7 @@ export function FileManagerGrid({
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"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/>
) : (
<span
@@ -1230,8 +1235,6 @@ export function FileManagerGrid({
</div>,
document.body,
)}
<SimpleLoader visible={isLoading} message={t("common.connecting")} />
</div>
);
}
@@ -1248,24 +1251,49 @@ function CreateIntentGridItem({
const { t } = useTranslation();
const [inputName, setInputName] = useState(intent.currentName);
const inputRef = useRef<HTMLInputElement>(null);
const doneRef = useRef(false);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const timer = setTimeout(() => {
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) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm?.(inputName.trim());
commit(inputName.trim());
} else if (e.key === "Escape") {
e.preventDefault();
if (doneRef.current) return;
doneRef.current = true;
onCancel?.();
}
};
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">
{intent.type === "directory" ? (
<Folder className="size-10 text-accent-brand" />
@@ -1279,7 +1307,7 @@ function CreateIntentGridItem({
value={inputName}
onChange={(e) => setInputName(e.target.value)}
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"
placeholder={
intent.type === "directory"
@@ -1303,24 +1331,49 @@ function CreateIntentListItem({
const { t } = useTranslation();
const [inputName, setInputName] = useState(intent.currentName);
const inputRef = useRef<HTMLInputElement>(null);
const doneRef = useRef(false);
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const timer = setTimeout(() => {
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) => {
if (e.key === "Enter") {
e.preventDefault();
onConfirm?.(inputName.trim());
commit(inputName.trim());
} else if (e.key === "Escape") {
e.preventDefault();
if (doneRef.current) return;
doneRef.current = true;
onCancel?.();
}
};
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="shrink-0">
{intent.type === "directory" ? (
@@ -1335,7 +1388,7 @@ function CreateIntentListItem({
value={inputName}
onChange={(e) => setInputName(e.target.value)}
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"
placeholder={
intent.type === "directory"
@@ -551,7 +551,7 @@ export function FileManagerSidebar({
return (
<>
<div className="h-full flex flex-col bg-card border-r border-border overflow-hidden">
<div className="h-full flex flex-col bg-card overflow-hidden">
<div className="flex-1 overflow-y-auto thin-scrollbar">
{/* ── Recent files ──────────────────────────────────────── */}
{renderSection(t("fileManager.recent"), recentItems, (item) =>
@@ -53,14 +53,20 @@ export function DraggableWindow({
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [windowStart, setWindowStart] = useState({ x: 0, y: 0 });
const [sizeStart, setSizeStart] = useState({ width: 0, height: 0 });
const containerBoundsRef = useRef({ width: 0, height: 0 });
const windowRef = useRef<HTMLDivElement>(null);
const titleBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (targetSize && !isMaximized) {
const maxWidth = Math.min(window.innerWidth * 0.9, 1200);
const maxHeight = Math.min(window.innerHeight * 0.8, 800);
const container = windowRef.current?.offsetParent as HTMLElement | null;
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 newHeight = Math.min(targetSize.height + 150, maxHeight);
@@ -80,8 +86,16 @@ export function DraggableWindow({
setSize({ width: newWidth, height: newHeight });
setPosition({
x: Math.max(0, (window.innerWidth - newWidth) / 2),
y: Math.max(0, (window.innerHeight - newHeight) / 2),
x: Math.max(
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]);
@@ -98,6 +112,13 @@ export function DraggableWindow({
setIsDragging(true);
setDragStart({ x: e.clientX, y: e.clientY });
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?.();
},
[isMaximized, position, onFocus],
@@ -112,50 +133,14 @@ export function DraggableWindow({
const newX = windowStart.x + deltaX;
const newY = windowStart.y + deltaY;
const windowElement = windowRef.current;
let positioningContainer = null;
let currentElement = windowElement?.parentElement;
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));
const { width: containerW, height: containerH } =
containerBoundsRef.current;
const maxX = containerW - size.width;
const maxY = containerH - size.height;
setPosition({
x: constrainedX,
y: constrainedY,
x: Math.max(0, Math.min(maxX, newX)),
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));
newY = Math.max(0, Math.min(window.innerHeight - newHeight, newY));
const { width: containerW, height: containerH } =
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 });
setPosition({ x: newX, y: newY });
@@ -213,7 +200,6 @@ export function DraggableWindow({
windowStart,
sizeStart,
size,
position,
minWidth,
minHeight,
resizeDirection,
@@ -238,6 +224,13 @@ export function DraggableWindow({
setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: position.x, y: position.y });
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?.();
},
[isMaximized, position, size, onFocus],
@@ -3,8 +3,9 @@ import { Document, Page, pdfjs } from "react-pdf";
import { AlertCircle, Download } from "lucide-react";
import { Button } from "@/components/button.tsx";
import { useTranslation } from "react-i18next";
import pdfjsWorkerUrl from "pdfjs-dist/build/pdf.worker.min.mjs?url";
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";
pdfjs.GlobalWorkerOptions.workerSrc = pdfjsWorkerUrl;
interface PdfPreviewProps {
content: string;
@@ -85,10 +86,10 @@ export function PdfPreview({
{pdfError ? (
<div className="text-center text-muted-foreground p-8">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">Cannot load PDF</h3>
<p className="text-sm mb-4">
There was an error loading this PDF file.
</p>
<h3 className="text-lg font-medium mb-2">
{t("fileManager.cannotLoadPdf")}
</h3>
<p className="text-sm mb-4">{t("fileManager.pdfLoadError")}</p>
{onDownload && (
<Button
variant="outline"
@@ -120,7 +121,7 @@ export function PdfPreview({
<div className="text-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">
Loading PDF...
{t("fileManager.loadingPdf")}
</p>
</div>
}
@@ -133,7 +134,7 @@ export function PdfPreview({
<div className="text-center p-4">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-xs text-muted-foreground">
Loading page...
{t("fileManager.loadingPage")}
</p>
</div>
}
@@ -168,35 +168,40 @@ export function PermissionsDialog({
return (
<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>
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<Lock className="size-4 text-accent-brand" />
{t("fileManager.changePermissions")}
</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}
</DialogDescription>
</DialogHeader>
<div className="py-3 flex flex-col gap-4">
<div className="grid grid-cols-4 gap-0 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" />
{[
t("fileManager.read"),
t("fileManager.write"),
t("fileManager.execute"),
].map((h) => (
<div
key={h}
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"
>
{h}
</div>
))}
<div className="border border-border overflow-hidden">
<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.execute"),
].map((h) => (
<div
key={h}
className="py-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground text-center border-l border-border"
>
{h}
</div>
))}
</div>
{rows.map((row, i) => (
<React.Fragment key={i}>
<div className="px-3 py-2.5 border-b border-r border-border last:border-b-0 text-xs font-semibold truncate">
<div
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}
</div>
{[
@@ -206,29 +211,28 @@ export function PermissionsDialog({
].map((perm, j) => (
<div
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
type="checkbox"
checked={perm.val}
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>
))}
</React.Fragment>
</div>
))}
</div>
<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")}
</span>
<Input
value={octal}
readOnly
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">
{t("fileManager.currentPermissions")}: {file.permissions || "—"}
@@ -3,20 +3,8 @@ import { DraggableWindow } from "./DraggableWindow.tsx";
import { Terminal } from "@/features/terminal/Terminal.tsx";
import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next";
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;
}
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
import type { SSHHost } from "@/types/index.ts";
interface TerminalWindowProps {
windowId: string;
@@ -96,29 +84,31 @@ export function TerminalWindow({
: t("terminal.terminalTitle", { host: hostConfig.name });
return (
<DraggableWindow
title={terminalTitle}
initialX={initialX}
initialY={initialY}
initialWidth={800}
initialHeight={500}
minWidth={600}
minHeight={400}
onClose={handleClose}
onMaximize={handleMaximize}
onFocus={handleFocus}
onResize={handleResize}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
>
<Terminal
ref={terminalRef}
hostConfig={hostConfig}
isVisible={!currentWindow.isMinimized}
initialPath={initialPath}
executeCommand={executeCommand}
<CommandHistoryProvider>
<DraggableWindow
title={terminalTitle}
initialX={initialX}
initialY={initialY}
initialWidth={800}
initialHeight={500}
minWidth={600}
minHeight={400}
onClose={handleClose}
/>
</DraggableWindow>
onMaximize={handleMaximize}
onFocus={handleFocus}
onResize={handleResize}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
>
<Terminal
ref={terminalRef as any}
hostConfig={hostConfig as any}
isVisible={!currentWindow.isMinimized}
initialPath={initialPath}
executeCommand={executeCommand}
onClose={handleClose}
/>
</DraggableWindow>
</CommandHistoryProvider>
);
}
@@ -116,14 +116,19 @@ export function WindowManager({ children }: WindowManagerProps) {
return (
<WindowManagerContext.Provider value={contextValue}>
{children}
<div className="window-container">
{windows.map((window) => (
<div key={window.id}>
{typeof window.component === "function"
? window.component(window.id)
: window.component}
</div>
))}
<div
className="window-container absolute inset-0 pointer-events-none overflow-hidden"
style={{ zIndex: 1000 }}
>
<div className="relative w-full h-full pointer-events-none">
{windows.map((window) => (
<div key={window.id} className="pointer-events-auto">
{typeof window.component === "function"
? window.component(window.id)
: window.component}
</div>
))}
</div>
</div>
</WindowManagerContext.Provider>
);
@@ -8,6 +8,7 @@ interface DragAndDropState {
interface UseDragAndDropProps {
onFilesDropped: (files: FileList) => void;
onItemsDropped?: (items: DataTransferItemList) => void;
onError?: (error: string) => void;
maxFileSize?: number;
allowedTypes?: string[];
@@ -15,6 +16,7 @@ interface UseDragAndDropProps {
export function useDragAndDrop({
onFilesDropped,
onItemsDropped,
onError,
maxFileSize = 5120,
allowedTypes = [],
@@ -123,6 +125,16 @@ export function useDragAndDrop({
draggedFiles: [],
});
if (onItemsDropped && e.dataTransfer.items?.length > 0) {
const hasDirectory = Array.from(e.dataTransfer.items).some(
(item) => item.webkitGetAsEntry?.()?.isDirectory,
);
if (hasDirectory) {
onItemsDropped(e.dataTransfer.items);
return;
}
}
const files = e.dataTransfer.files;
if (files.length === 0) {
@@ -137,7 +149,7 @@ export function useDragAndDrop({
onFilesDropped(files);
},
[validateFiles, onFilesDropped, onError],
[validateFiles, onFilesDropped, onItemsDropped, onError],
);
const resetDragState = useCallback(() => {
+170 -27
View File
@@ -1,16 +1,28 @@
import React, { useState, useEffect } from "react";
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
import React, { useState, useEffect, useRef, useCallback } from "react";
import {
GuacamoleDisplay,
type GuacamoleDisplayHandle,
} from "@/features/guacamole/GuacamoleDisplay.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { getGuacamoleTokenFromHost } from "@/main-axios.ts";
import { getGuacamoleTokenFromHost, getGuacdStatus } from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
import { AlertCircle, RefreshCw } from "lucide-react";
import { GuacamoleToolbar } from "@/features/guacamole/GuacamoleToolbar.tsx";
import { Button } from "@/components/button.tsx";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import type { SSHHost } from "@/types";
interface GuacamoleAppProps {
hostId?: string;
tabId?: string;
protocol?: "rdp" | "vnc" | "telnet";
}
const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
const GuacamoleApp: React.FC<GuacamoleAppProps> = ({
hostId,
tabId,
protocol,
}) => {
const { t } = useTranslation();
return (
@@ -18,20 +30,46 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
{(hostConfig, loading) => {
if (loading) {
return (
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
<RefreshCw className="size-8 animate-spin" />
<span className="text-sm font-semibold uppercase tracking-widest">
{t("common.loading")}
</span>
<div className="relative w-full h-full">
<SimpleLoader visible={true} message={t("common.loading")} />
</div>
);
}
if (!hostConfig) {
return (
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
<AlertCircle className="size-8 text-destructive" />
<span className="text-sm font-semibold text-destructive">
<div
className="flex flex-col items-center justify-center h-full gap-4"
style={{ backgroundColor: "var(--bg-base)" }}
>
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
<span
className="text-sm font-semibold"
style={{ color: "var(--foreground)" }}
>
{t("guacamole.hostNotFound")}
</span>
</div>
);
}
if (!hostId) {
return (
<div
className="flex flex-col items-center justify-center h-full gap-4"
style={{ backgroundColor: "var(--bg-base)" }}
>
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
<span
className="text-sm font-semibold"
style={{ color: "var(--foreground)" }}
>
{t("guacamole.hostNotFound")}
</span>
</div>
@@ -40,8 +78,10 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
return (
<GuacamoleAppInner
hostId={parseInt(hostId!, 10)}
hostId={parseInt(hostId, 10)}
hostConfig={hostConfig}
tabId={tabId}
protocol={protocol}
/>
);
}}
@@ -52,51 +92,154 @@ const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
interface GuacamoleAppInnerProps {
hostId: number;
hostConfig: Pick<SSHHost, "connectionType">;
tabId?: string;
protocol?: "rdp" | "vnc" | "telnet";
}
const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
hostId,
hostConfig,
tabId,
protocol,
}) => {
const { t } = useTranslation();
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [retryCount, setRetryCount] = useState(0);
const displayRef = useRef<GuacamoleDisplayHandle>(null);
useEffect(() => {
getGuacamoleTokenFromHost(hostId)
.then((result) => setToken(result.token))
setToken(null);
setError(null);
getGuacdStatus()
.then((status) => {
if (status.guacd.status !== "connected") {
setError(t("guacamole.guacdUnavailable"));
return;
}
return getGuacamoleTokenFromHost(hostId, protocol);
})
.then((result) => {
if (result) setToken(result.token);
})
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
}, [hostId]);
}, [hostId, retryCount]);
const handleReconnect = useCallback(() => {
setConnectionError(null);
setError(null);
setToken(null);
setRetryCount((c) => c + 1);
}, []);
useEffect(() => {
if (!tabId) return;
const handler = (e: Event) => {
const { tabId: eventTabId } = (e as CustomEvent).detail;
if (eventTabId === tabId) handleReconnect();
};
window.addEventListener("termix:refresh-guacamole", handler);
return () =>
window.removeEventListener("termix:refresh-guacamole", handler);
}, [tabId, handleReconnect]);
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
<AlertCircle className="size-8 text-destructive" />
<span className="text-sm font-semibold text-destructive">{error}</span>
<div
className="flex flex-col items-center justify-center h-full gap-4"
style={{ backgroundColor: "var(--bg-base)" }}
>
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
<p
className="text-sm font-semibold"
style={{ color: "var(--foreground)" }}
>
{t("guacamole.connectionFailed")}
</p>
<p
className="text-xs max-w-xs text-center"
style={{ color: "var(--foreground-secondary)" }}
>
{error}
</p>
<Button variant="outline" size="sm" onClick={handleReconnect}>
<RefreshCw className="size-4 mr-2" />
{t("guacamole.retry")}
</Button>
</div>
);
}
if (!token) {
return (
<div className="flex flex-col items-center justify-center h-full opacity-40 gap-4">
<RefreshCw className="size-8 animate-spin" />
<span className="text-sm font-semibold uppercase tracking-widest">
{t("guacamole.connecting", {
type: (hostConfig.connectionType || "remote").toUpperCase(),
<div className="relative w-full h-full">
<SimpleLoader
visible={true}
message={t("guacamole.connecting", {
type: (
protocol ||
hostConfig.connectionType ||
"remote"
).toUpperCase(),
})}
</span>
/>
</div>
);
}
const protocol = hostConfig.connectionType as "rdp" | "vnc" | "telnet";
const resolvedProtocol = (protocol ?? hostConfig.connectionType) as
| "rdp"
| "vnc"
| "telnet";
return (
<div className="relative w-full h-full">
{connectionError && (
<div
className="absolute inset-0 flex flex-col items-center justify-center gap-4 z-50"
style={{ backgroundColor: "var(--bg-base)" }}
>
<AlertCircle
className="size-10"
style={{ color: "var(--foreground)" }}
/>
<p
className="text-sm font-semibold"
style={{ color: "var(--foreground)" }}
>
{t("guacamole.connectionFailed")}
</p>
<p
className="text-xs max-w-xs text-center"
style={{ color: "var(--foreground-secondary)" }}
>
{connectionError}
</p>
<Button variant="outline" size="sm" onClick={handleReconnect}>
<RefreshCw className="size-4 mr-2" />
{t("guacamole.reconnect")}
</Button>
</div>
)}
<GuacamoleDisplay
connectionConfig={{ token, protocol, type: protocol }}
key={token}
ref={displayRef}
connectionConfig={{
token,
protocol: resolvedProtocol,
type: resolvedProtocol,
}}
isVisible={true}
onError={(err) => setConnectionError(err)}
/>
<GuacamoleToolbar
displayRef={displayRef}
protocol={resolvedProtocol}
onReconnect={handleReconnect}
/>
</div>
);
+55 -19
View File
@@ -65,6 +65,7 @@ export const GuacamoleDisplay = forwardRef<
typeof document === "undefined" ? true : document.hasFocus(),
);
const [isReady, setIsReady] = useState(false);
const [hasError, setHasError] = useState(false);
useImperativeHandle(ref, () => ({
disconnect: () => {
@@ -267,13 +268,20 @@ export const GuacamoleDisplay = forwardRef<
if (isConnectingRef.current) return;
isConnectingRef.current = true;
setIsReady(false);
setHasError(false);
let containerWidth = containerRef.current?.clientWidth || 0;
let containerHeight = containerRef.current?.clientHeight || 0;
// Wait two frames so the container is fully laid out before measuring.
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
);
const rect = containerRef.current?.getBoundingClientRect();
let containerWidth = rect?.width || 0;
let containerHeight = rect?.height || 0;
if (containerWidth < 100 || containerHeight < 100) {
containerWidth = 1280;
containerHeight = 720;
containerWidth = window.innerWidth || 1280;
containerHeight = window.innerHeight || 720;
}
const wsUrl = await getWebSocketUrl(containerWidth, containerHeight);
@@ -303,6 +311,11 @@ export const GuacamoleDisplay = forwardRef<
setIsReady(true);
};
const protocol = connectionConfig.protocol ?? connectionConfig.type;
if (protocol === "telnet") {
setIsReady(true);
}
const mouse = new Guacamole.Mouse(displayElement);
const sendMouseState = (state: Guacamole.Mouse.State) => {
displayElement.focus({ preventScroll: true });
@@ -351,8 +364,15 @@ export const GuacamoleDisplay = forwardRef<
case 2:
break;
case 3:
isConnectingRef.current = false;
setIsReady(true);
onConnect?.();
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
const w = Math.round(rect.width);
const h = Math.round(rect.height);
if (w > 0 && h > 0) client.sendSize(w, h);
}
break;
case 4:
break;
@@ -366,8 +386,10 @@ export const GuacamoleDisplay = forwardRef<
};
client.onerror = (error: Guacamole.Status) => {
const errorMessage = error.message || "Connection error";
const errorMessage = error.message || t("guacamole.connectionError");
setIsReady(false);
setHasError(true);
isConnectingRef.current = false;
onError?.(errorMessage);
};
@@ -388,6 +410,24 @@ export const GuacamoleDisplay = forwardRef<
Guacamole.AudioPlayer.getInstance(stream, mimetype);
};
client.onfile = (
stream: Guacamole.InputStream,
mimetype: string,
filename: string,
) => {
const reader = new Guacamole.BlobReader(stream, mimetype);
reader.onend = () => {
const blob = reader.getBlob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
stream.sendAck("OK", Guacamole.Status.Code.SUCCESS);
};
client.connect();
}, [
getWebSocketUrl,
@@ -407,11 +447,7 @@ export const GuacamoleDisplay = forwardRef<
if (isVisible && !hasInitiatedRef.current) {
hasInitiatedRef.current = true;
requestAnimationFrame(() => {
if (isMountedRef.current) {
connect();
}
});
connect();
}
}, [isVisible, connect]);
@@ -475,26 +511,23 @@ export const GuacamoleDisplay = forwardRef<
if (!containerRef.current) return;
const resizeObserver = new ResizeObserver(() => {
rescaleDisplay(false);
if (resizeTimeoutRef.current) clearTimeout(resizeTimeoutRef.current);
resizeTimeoutRef.current = setTimeout(() => {
if (clientRef.current && containerRef.current) {
const w = containerRef.current.clientWidth;
const h = containerRef.current.clientHeight;
const rect = containerRef.current.getBoundingClientRect();
const w = Math.round(rect.width);
const h = Math.round(rect.height);
if (w > 0 && h > 0) clientRef.current.sendSize(w, h);
}
}, 200);
}, 150);
});
resizeObserver.observe(containerRef.current);
const initialTimeout = setTimeout(() => rescaleDisplay(true), 100);
return () => {
resizeObserver.disconnect();
clearTimeout(initialTimeout);
};
}, [rescaleDisplay]);
}, []);
const syncClipboard = useCallback(() => {
const client = clientRef.current;
@@ -553,7 +586,10 @@ export const GuacamoleDisplay = forwardRef<
}}
/>
<SimpleLoader visible={!isReady} message={connectingMessage} />
<SimpleLoader
visible={!isReady && !hasError}
message={connectingMessage}
/>
</div>
);
});
@@ -0,0 +1,478 @@
import React, {
useState,
useRef,
useCallback,
useLayoutEffect,
useEffect,
} from "react";
import {
GripVertical,
Monitor,
ChevronLeft,
ChevronRight,
ChevronUp,
ChevronDown,
ChevronsLeftRight,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/tooltip.tsx";
import type { GuacamoleDisplayHandle } from "@/features/guacamole/GuacamoleDisplay.tsx";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
interface GuacamoleToolbarProps {
displayRef: React.RefObject<GuacamoleDisplayHandle>;
protocol: "rdp" | "vnc" | "telnet";
onReconnect: () => void;
}
const MODIFIER_KEYSYMS = {
ctrl: 0xffe3,
alt: 0xffe9,
shift: 0xffe1,
win: 0xff67,
} as const;
const FKEY_KEYSYMS = Array.from({ length: 12 }, (_, i) => 0xffbe + i);
const BTN_BASE =
"flex items-center justify-center gap-1 h-7 px-2 text-[10px] font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm whitespace-nowrap select-none";
const BTN_ICON =
"flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm select-none";
const SEP = "w-px h-5 bg-border mx-0.5 shrink-0";
function TipBtn({
tooltip,
onClick,
className,
children,
}: {
tooltip: string;
onClick: () => void;
className?: string;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
className={cn(BTN_BASE, className)}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{tooltip}
</TooltipContent>
</Tooltip>
);
}
function TipIconBtn({
tooltip,
onClick,
className,
children,
}: {
tooltip: string;
onClick: () => void;
className?: string;
children: React.ReactNode;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onClick}
className={cn(BTN_ICON, className)}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{tooltip}
</TooltipContent>
</Tooltip>
);
}
export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
displayRef,
protocol,
onReconnect,
}) => {
const { t } = useTranslation();
const [position, setPosition] = useState({ x: 0, y: 12 });
const [collapsed, setCollapsed] = useState(false);
const [showFKeys, setShowFKeys] = useState(false);
const [stickyKeys, setStickyKeys] = useState<Record<number, boolean>>({
[MODIFIER_KEYSYMS.ctrl]: false,
[MODIFIER_KEYSYMS.alt]: false,
[MODIFIER_KEYSYMS.shift]: false,
[MODIFIER_KEYSYMS.win]: false,
});
const toolbarRef = useRef<HTMLDivElement>(null);
const isDraggingRef = useRef(false);
const dragOriginRef = useRef({ mouseX: 0, mouseY: 0, posX: 0, posY: 0 });
const [isDragging, setIsDragging] = useState(false);
useLayoutEffect(() => {
const el = toolbarRef.current;
if (!el) return;
const parent = el.offsetParent as HTMLElement | null;
if (!parent) return;
const parentW = parent.clientWidth;
const toolbarW = el.offsetWidth;
setPosition((p) => ({ ...p, x: Math.max(0, (parentW - toolbarW) / 2) }));
}, [collapsed]);
useEffect(() => {
if (!isDragging) return;
const onMove = (e: MouseEvent) => {
if (!isDraggingRef.current) return;
const parent = toolbarRef.current?.offsetParent as HTMLElement | null;
const parentW = parent?.clientWidth ?? Infinity;
const parentH = parent?.clientHeight ?? Infinity;
const toolbarW = toolbarRef.current?.offsetWidth ?? 0;
const toolbarH = toolbarRef.current?.offsetHeight ?? 0;
const dx = e.clientX - dragOriginRef.current.mouseX;
const dy = e.clientY - dragOriginRef.current.mouseY;
setPosition({
x: Math.max(
0,
Math.min(dragOriginRef.current.posX + dx, parentW - toolbarW),
),
y: Math.max(
0,
Math.min(dragOriginRef.current.posY + dy, parentH - toolbarH),
),
});
};
const onUp = () => {
isDraggingRef.current = false;
setIsDragging(false);
document.body.style.userSelect = "";
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
return () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
};
}, [isDragging]);
const startDrag = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
isDraggingRef.current = true;
dragOriginRef.current = {
mouseX: e.clientX,
mouseY: e.clientY,
posX: position.x,
posY: position.y,
};
document.body.style.userSelect = "none";
setIsDragging(true);
},
[position],
);
const releaseStickyKeys = useCallback(() => {
const display = displayRef.current;
if (!display) return;
setStickyKeys((prev) => {
const next = { ...prev };
for (const [ksStr, active] of Object.entries(prev)) {
if (active) {
display.sendKey(Number(ksStr), false);
next[Number(ksStr)] = false;
}
}
return next;
});
}, [displayRef]);
const sendCombo = useCallback(
(...keysyms: number[]) => {
const display = displayRef.current;
if (!display) return;
for (const k of keysyms) display.sendKey(k, true);
for (const k of [...keysyms].reverse()) display.sendKey(k, false);
releaseStickyKeys();
},
[displayRef, releaseStickyKeys],
);
const toggleStickyKey = useCallback(
(keysym: number) => {
const display = displayRef.current;
if (!display) return;
setStickyKeys((prev) => {
const isActive = prev[keysym];
display.sendKey(keysym, !isActive);
return { ...prev, [keysym]: !isActive };
});
},
[displayRef],
);
const isRdpVnc = protocol === "rdp" || protocol === "vnc";
const containerStyle: React.CSSProperties = {
position: "absolute",
left: position.x,
top: position.y,
zIndex: 20,
};
return (
<TooltipProvider delayDuration={500}>
<div
ref={toolbarRef}
style={containerStyle}
onMouseDown={(e) => e.preventDefault()}
>
{collapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center bg-background/85 backdrop-blur-sm border border-border shadow-lg rounded-sm overflow-hidden">
<button
type="button"
onMouseDown={startDrag}
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-grab active:cursor-grabbing"
>
<GripVertical className="size-3" />
</button>
<div className="w-px h-4 bg-border" />
<button
type="button"
onClick={() => setCollapsed(false)}
className="flex items-center justify-center size-7 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<Monitor className="size-3.5" />
</button>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("guacamole.toolbar.expand")}
</TooltipContent>
</Tooltip>
) : (
<div className="flex items-center bg-background/85 backdrop-blur-sm border border-border shadow-lg rounded-sm px-0.5 py-0.5 gap-0">
{/* Drag handle */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onMouseDown={startDrag}
className="flex items-center justify-center h-7 px-1 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors rounded-sm cursor-grab active:cursor-grabbing"
>
<GripVertical className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("guacamole.toolbar.dragHandle")}
</TooltipContent>
</Tooltip>
{/* System combos — RDP/VNC only */}
{isRdpVnc && (
<>
<div className={SEP} />
<TipBtn
tooltip={t("guacamole.toolbar.ctrlAltDel")}
onClick={() => sendCombo(0xffe3, 0xffe9, 0xffff)}
>
CAD
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.winL")}
onClick={() => sendCombo(0xff67, 0x006c)}
>
Win+L
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.winKey")}
onClick={() => sendCombo(0xff67)}
>
Win
</TipBtn>
</>
)}
{/* Sticky modifiers — RDP/VNC only */}
{isRdpVnc && (
<>
<div className={SEP} />
{(
[
[
"ctrl",
MODIFIER_KEYSYMS.ctrl,
t("guacamole.toolbar.ctrl"),
],
["alt", MODIFIER_KEYSYMS.alt, t("guacamole.toolbar.alt")],
[
"shift",
MODIFIER_KEYSYMS.shift,
t("guacamole.toolbar.shift"),
],
["win", MODIFIER_KEYSYMS.win, t("guacamole.toolbar.win")],
] as [string, number, string][]
).map(([key, ks, label]) => (
<Tooltip key={key}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => toggleStickyKey(ks)}
className={cn(
BTN_BASE,
stickyKeys[ks] &&
"bg-primary/15 text-primary border border-primary/30",
)}
>
{label}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{stickyKeys[ks]
? t("guacamole.toolbar.stickyActive", { key: label })
: t("guacamole.toolbar.stickyInactive", { key: label })}
</TooltipContent>
</Tooltip>
))}
</>
)}
{/* Function key toggle */}
<div className={SEP} />
<TipBtn
tooltip={t("guacamole.toolbar.fnToggle")}
onClick={() => setShowFKeys((v) => !v)}
className={cn(
showFKeys &&
"bg-primary/15 text-primary border border-primary/30",
)}
>
Fn
</TipBtn>
{/* F1-F12 row */}
{showFKeys &&
FKEY_KEYSYMS.map((ks, i) => (
<TipBtn
key={ks}
tooltip={`F${i + 1}`}
onClick={() => sendCombo(ks)}
>
F{i + 1}
</TipBtn>
))}
{/* Navigation */}
<div className={SEP} />
<TipBtn
tooltip={t("guacamole.toolbar.esc")}
onClick={() => sendCombo(0xff1b)}
>
Esc
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.tab")}
onClick={() => sendCombo(0xff09)}
>
Tab
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.home")}
onClick={() => sendCombo(0xff50)}
>
Home
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.end")}
onClick={() => sendCombo(0xff57)}
>
End
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.pageUp")}
onClick={() => sendCombo(0xff55)}
>
PgUp
</TipBtn>
<TipBtn
tooltip={t("guacamole.toolbar.pageDown")}
onClick={() => sendCombo(0xff56)}
>
PgDn
</TipBtn>
{/* Arrow cluster */}
<div className="flex flex-col ml-0.5">
<div className="flex justify-center">
<TipIconBtn
tooltip={t("guacamole.toolbar.arrowUp")}
onClick={() => sendCombo(0xff52)}
>
<ChevronUp className="size-3" />
</TipIconBtn>
</div>
<div className="flex">
<TipIconBtn
tooltip={t("guacamole.toolbar.arrowLeft")}
onClick={() => sendCombo(0xff51)}
>
<ChevronLeft className="size-3" />
</TipIconBtn>
<TipIconBtn
tooltip={t("guacamole.toolbar.arrowDown")}
onClick={() => sendCombo(0xff54)}
>
<ChevronDown className="size-3" />
</TipIconBtn>
<TipIconBtn
tooltip={t("guacamole.toolbar.arrowRight")}
onClick={() => sendCombo(0xff53)}
>
<ChevronRight className="size-3" />
</TipIconBtn>
</div>
</div>
{/* Collapse */}
<div className="w-px h-5 bg-border mx-0.5 shrink-0" />
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setCollapsed(true)}
className={cn(BTN_ICON)}
>
<ChevronsLeftRight className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{t("guacamole.toolbar.collapse")}
</TooltipContent>
</Tooltip>
</div>
)}
</div>
</TooltipProvider>
);
};
+31 -26
View File
@@ -405,6 +405,11 @@ function ServerStatsInner({
try {
if (!totpVerified) {
addLog({
type: "info",
stage: "stats_connecting",
message: `Connecting to ${currentHostConfig.username}@${currentHostConfig.ip}:${currentHostConfig.port}`,
});
const result = await startMetricsPolling(currentHostConfig.id);
if (cancelled) return;
@@ -459,10 +464,10 @@ function ServerStatsInner({
if (data) {
setMetrics(data);
setServerStatus("online");
if (!hasExistingMetrics) {
setIsLoadingMetrics(false);
logServerActivity();
setTimeout(() => clearLogs(), 1000);
}
}
@@ -567,17 +572,24 @@ function ServerStatsInner({
const handleRefresh = async () => {
if (!currentHostConfig?.id) return;
if (hasConnectionError) {
setHasConnectionError(false);
clearLogs();
return;
}
try {
setIsRefreshing(true);
const res = await getServerStatusById(currentHostConfig.id);
setServerStatus(res?.status === "online" ? "online" : "offline");
const data = await getServerMetricsById(currentHostConfig.id);
if (data) setMetrics(data);
setShowStatsUI(true);
if (data) {
setMetrics(data);
setShowStatsUI(true);
}
} catch {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} finally {
setIsRefreshing(false);
}
@@ -598,7 +610,7 @@ function ServerStatsInner({
}}
>
<div className="flex-1 overflow-y-auto overflow-x-hidden flex flex-col">
{!totpRequired && !isLoadingMetrics && (
{!totpRequired && !isLoadingMetrics && !hasConnectionError && (
<div className="mx-3 mt-3 flex items-center justify-between border border-border bg-card px-3 py-3 shrink-0">
<div className="flex items-center gap-3">
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
@@ -606,16 +618,6 @@ function ServerStatsInner({
</div>
<div>
<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 className="flex items-center gap-0">
@@ -735,16 +737,19 @@ function ServerStatsInner({
showStatsUI &&
!isLoadingMetrics &&
!metrics &&
serverStatus === "offline" && (
serverStatus === "offline" &&
!hasConnectionError && (
<div className="flex-1 flex items-center justify-center py-20">
<div className="text-center opacity-40">
<Server className="size-16 mx-auto mb-4" />
<p className="text-xl font-bold uppercase tracking-widest">
{t("serverStats.serverOffline")}
</p>
<p className="text-sm font-semibold">
{t("serverStats.cannotFetchMetrics")}
</p>
<div className="text-center">
<div className="opacity-40">
<Server className="size-16 mx-auto mb-4" />
<p className="text-xl font-bold uppercase tracking-widest">
{t("serverStats.serverOffline")}
</p>
<p className="text-sm font-semibold">
{t("serverStats.cannotFetchMetrics")}
</p>
</div>
</div>
</div>
)}
@@ -777,7 +782,7 @@ function ServerStatsInner({
/>
<ConnectionLog
isConnecting={isLoadingMetrics}
isConnected={serverStatus === "online"}
isConnected={serverStatus === "online" && !hasConnectionError}
hasConnectionError={hasConnectionError}
position={hasConnectionError ? "top" : "bottom"}
/>
@@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { ServerStats } from "@/features/server-stats/ServerStats.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
@@ -7,6 +8,7 @@ interface ServerStatsAppProps {
}
const ServerStatsApp: React.FC<ServerStatsAppProps> = ({ hostId }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, loading) => {
@@ -15,7 +17,9 @@ const ServerStatsApp: React.FC<ServerStatsAppProps> = ({ hostId }) => {
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">Loading host...</p>
<p className="text-muted-foreground">
{t("hosts.loadingHost")}
</p>
</div>
</div>
);
@@ -25,7 +29,7 @@ const ServerStatsApp: React.FC<ServerStatsAppProps> = ({ hostId }) => {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-red-500 mb-4">Host not found</p>
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
</div>
</div>
);
@@ -21,36 +21,40 @@ function Sparkline({
current ?? 0,
].slice(-20);
if (points.length < 2) return null;
const w = 300;
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 fill = `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z`;
const hasData = points.length >= 2;
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 (
<div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden">
<svg
className="absolute inset-0 h-full w-full"
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
>
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
<path
d={d}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-accent-brand/60"
/>
</svg>
{hasData && (
<svg
className="absolute inset-0 h-full w-full"
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
>
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
<path
d={d}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-accent-brand/60"
/>
</svg>
)}
</div>
);
}
@@ -20,36 +20,40 @@ function Sparkline({
current ?? 0,
].slice(-20);
if (points.length < 2) return null;
const w = 300;
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 fill = `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z`;
const hasData = points.length >= 2;
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 (
<div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden">
<svg
className="absolute inset-0 h-full w-full"
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
>
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
<path
d={d}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-accent-brand/60"
/>
</svg>
{hasData && (
<svg
className="absolute inset-0 h-full w-full"
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
>
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
<path
d={d}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-accent-brand/60"
/>
</svg>
)}
</div>
);
}
@@ -132,7 +132,7 @@ export function FirewallWidget({ metrics }: FirewallWidgetProps) {
</span>
)}
{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) => (
<ChainSection key={chain.name} chain={chain} />
))}
@@ -46,40 +46,42 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
{t("serverStats.noRecentLoginData")}
</span>
) : (
allLogins.map((login, i) => (
<div
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">
{login.status === "failed" ? (
<UserX className="size-3 text-destructive" />
) : (
<UserCheck className="size-3 text-accent-brand" />
)}
<span
className={`text-xs font-bold ${login.status === "failed" ? "text-destructive" : ""}`}
>
{login.user}
<div className="flex flex-col gap-2 overflow-y-auto max-h-[300px]">
{allLogins.map((login, i) => (
<div
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">
{login.status === "failed" ? (
<UserX className="size-3 text-destructive" />
) : (
<UserCheck className="size-3 text-accent-brand" />
)}
<span
className={`text-xs font-bold ${login.status === "failed" ? "text-destructive" : ""}`}
>
{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>
</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>
</div>
</div>
))
))}
</div>
)}
</div>
</SectionCard>
@@ -20,36 +20,40 @@ function Sparkline({
current ?? 0,
].slice(-20);
if (points.length < 2) return null;
const w = 300;
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 fill = `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z`;
const hasData = points.length >= 2;
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 (
<div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden">
<svg
className="absolute inset-0 h-full w-full"
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
>
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
<path
d={d}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-accent-brand/60"
/>
</svg>
{hasData && (
<svg
className="absolute inset-0 h-full w-full"
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
>
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
<path
d={d}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-accent-brand/60"
/>
</svg>
)}
</div>
);
}
@@ -38,34 +38,36 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
</span>
</div>
) : (
interfaces.map((iface, i) => (
<div
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={`size-1.5 rounded-full ${iface.state === "UP" ? "bg-accent-brand" : "bg-muted-foreground"}`}
/>
<span className="text-sm font-bold font-mono">
{iface.name}
<div className="flex flex-col gap-2 overflow-y-auto max-h-[260px]">
{interfaces.map((iface, i) => (
<div
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={`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>
</div>
<span className="text-[10px] font-semibold px-1.5 py-px border border-border text-muted-foreground uppercase">
{iface.state}
</span>
</div>
<span className="text-[10px] font-semibold px-1.5 py-px border border-border text-muted-foreground uppercase">
{iface.state}
</span>
<div className="flex justify-between text-[10px] font-mono text-muted-foreground">
<span>{iface.ip}</span>
{(iface.rx || iface.tx) && (
<span>
{iface.rx ?? "—"} / {iface.tx ?? "—"}
</span>
)}
</div>
</div>
<div className="flex justify-between text-[10px] font-mono text-muted-foreground">
<span>{iface.ip}</span>
{(iface.rx || iface.tx) && (
<span>
{iface.rx ?? "—"} / {iface.tx ?? "—"}
</span>
)}
</div>
</div>
))
))}
</div>
)}
</div>
</SectionCard>
@@ -14,15 +14,17 @@ function PortRow({ port }: { port: ListeningPort }) {
addr === "0.0.0.0" || addr === "*" || addr === "::" ? "*" : addr;
return (
<div 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-accent-brand font-bold">{port.localPort}</span>
<span className="text-muted-foreground">
<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 truncate">
{port.localPort}
</span>
<span className="text-muted-foreground truncate">
{port.protocol.toUpperCase()}
</span>
<span className="font-semibold truncate">
{port.process ?? (port.pid ? `PID:${port.pid}` : "—")}
</span>
<span className="text-right text-muted-foreground">
<span className="text-right text-muted-foreground truncate">
{formatAddress(port.localAddress)}
</span>
</div>
@@ -41,7 +43,7 @@ export function PortsWidget({ metrics }: PortsWidgetProps) {
title={t("serverStats.ports.title")}
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">
<span>{t("serverStats.ports.port")}</span>
<span>{t("serverStats.ports.protocol")}</span>
@@ -53,12 +55,14 @@ export function PortsWidget({ metrics }: PortsWidgetProps) {
{t("serverStats.ports.noData")}
</span>
) : (
ports.map((port, i) => (
<PortRow
key={`${port.protocol}-${port.localPort}-${i}`}
port={port}
/>
))
<div className="overflow-y-auto max-h-[300px]">
{ports.map((port, i) => (
<PortRow
key={`${port.protocol}-${port.localPort}-${i}`}
port={port}
/>
))}
</div>
)}
</div>
</SectionCard>
@@ -45,19 +45,21 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
<span className="text-xs">{t("serverStats.noProcessesFound")}</span>
</div>
) : (
topProcesses.map((proc, i) => (
<div
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>{proc.mem}%</span>
<span className="truncate font-semibold" title={proc.command}>
{proc.command.split("/").pop()}
</span>
</div>
))
<div className="overflow-y-auto max-h-[280px]">
{topProcesses.map((proc, i) => (
<div
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>{proc.mem}%</span>
<span className="truncate font-semibold" title={proc.command}>
{proc.command.split("/").pop()}
</span>
</div>
))}
</div>
)}
</div>
</SectionCard>
+164 -84
View File
@@ -53,6 +53,46 @@ import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
import { toast } from "sonner";
import { Button } from "@/components/button";
// Background/foreground per UI theme for "Termix Default" — must match index.css
const TERMIX_DEFAULT_COLORS: Record<
string,
{ background: string; foreground: string }
> = {
dark: { background: "#0c0d0b", foreground: "#fafafa" },
light: { background: "#ffffff", foreground: "#111210" },
dracula: { background: "#282a36", foreground: "#f8f8f2" },
catppuccin: { background: "#1e1e2e", foreground: "#cdd6f4" },
nord: { background: "#2e3440", foreground: "#eceff4" },
solarized: { background: "#002b36", foreground: "#839496" },
"tokyo-night": { background: "#1a1b26", foreground: "#a9b1d6" },
"one-dark": { background: "#282c34", foreground: "#abb2bf" },
gruvbox: { background: "#282828", foreground: "#ebdbb2" },
};
function resolveTermixThemeColors(activeTheme: string, appTheme: string) {
if (activeTheme !== "termix") {
return (
TERMINAL_THEMES[activeTheme]?.colors || TERMINAL_THEMES.termixDark.colors
);
}
let resolvedUiTheme = appTheme;
if (appTheme === "system") {
resolvedUiTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
const uiColors =
TERMIX_DEFAULT_COLORS[resolvedUiTheme] ?? TERMIX_DEFAULT_COLORS.dark;
const base = TERMINAL_THEMES.termixDark.colors;
return {
...base,
background: uiColors.background,
foreground: uiColors.foreground,
cursor: uiColors.foreground,
cursorAccent: uiColors.background,
};
}
interface HostConfig {
id?: number;
instanceId?: string;
@@ -130,27 +170,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
DEFAULT_TERMINAL_CONFIG.theme,
};
const isDarkMode =
appTheme === "dark" ||
appTheme === "dracula" ||
appTheme === "gentlemansChoice" ||
appTheme === "midnightEspresso" ||
appTheme === "catppuccinMocha" ||
(appTheme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
let themeColors;
const activeTheme = previewTheme || config.theme;
if (activeTheme === "termix") {
themeColors = isDarkMode
? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors;
} else {
themeColors =
TERMINAL_THEMES[activeTheme]?.colors ||
TERMINAL_THEMES.termixDark.colors;
}
const themeColors = resolveTermixThemeColors(activeTheme, appTheme);
const backgroundColor = themeColors.background;
const fitAddonRef = useRef<FitAddon | null>(null);
const webSocketRef = useRef<WebSocket | null>(null);
@@ -220,6 +241,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}>;
} | null>(null);
const tmuxSessionNameRef = useRef<string | null>(null);
const [isTmuxAttached, setIsTmuxAttached] = useState(false);
const tmuxCopyModeHintShownRef = useRef(false);
const isVisibleRef = useRef<boolean>(false);
@@ -688,9 +710,28 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
},
fit: () => {
fitAddonRef.current?.fit();
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
hardRefresh();
if (!fitAddonRef.current || !terminal || isFittingRef.current) return;
isFittingRef.current = true;
try {
fitAddonRef.current.fit();
if (terminal.cols > 0 && terminal.rows > 0) {
const lastSize = lastFittedSizeRef.current;
if (
!lastSize ||
lastSize.cols !== terminal.cols ||
lastSize.rows !== terminal.rows
) {
scheduleNotify(terminal.cols, terminal.rows);
lastFittedSizeRef.current = {
cols: terminal.cols,
rows: terminal.rows,
};
}
}
setIsFitted(true);
} finally {
isFittingRef.current = false;
}
},
sendInput: (data: string) => {
if (webSocketRef.current?.readyState === 1) {
@@ -849,6 +890,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
if (isEmbeddedMode()) {
baseWsUrl = "ws://127.0.0.1:30002";
const storedJwt = localStorage.getItem("jwt");
if (storedJwt) {
baseWsUrl += `?token=${encodeURIComponent(storedJwt)}`;
}
} else if (!configuredUrl) {
console.error("No configured server URL available for Electron SSH");
setIsConnected(false);
@@ -864,6 +909,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
baseWsUrl = `${wsProtocol}${wsHost}/ssh/websocket/`;
const storedJwt = localStorage.getItem("jwt");
if (storedJwt) {
baseWsUrl += `?token=${encodeURIComponent(storedJwt)}`;
}
}
} else {
baseWsUrl = `${getBasePath()}/ssh/websocket/`;
@@ -935,7 +984,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
currentHostConfigRef.current = hostConfig;
const persistenceEnabled =
localStorage.getItem("enableTerminalSessionPersistence") === "true";
localStorage.getItem("enableTerminalSessionPersistence") !== "false";
const tabId = hostConfig.instanceId
? `${hostConfig.id}_${hostConfig.instanceId}`
: `${hostConfig.id}_${Date.now()}`;
@@ -1206,7 +1255,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
shouldNotReconnectRef.current = true;
setIsConnected(false);
setIsConnecting(false);
if (wasConnectedRef.current) {
if (msg.graceful) {
wasConnectedRef.current = false;
if (onClose) onClose();
} else if (wasConnectedRef.current) {
wasConnectedRef.current = false;
setShowDisconnectedOverlay(true);
} else if (!connectionErrorRef.current) {
@@ -1433,8 +1485,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
} else if (msg.type === "sessionCreated") {
sessionIdRef.current = msg.sessionId;
const persistenceEnabled =
localStorage.getItem("enableTerminalSessionPersistence") ===
"true";
localStorage.getItem("enableTerminalSessionPersistence") !==
"false";
if (persistenceEnabled && hostConfig.instanceId) {
const tabId = `${hostConfig.id}_${hostConfig.instanceId}`;
localStorage.setItem(`termix_session_${tabId}`, msg.sessionId);
@@ -1511,6 +1563,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const sessionName =
typeof msg.sessionName === "string" ? msg.sessionName : "";
tmuxSessionNameRef.current = sessionName || "(active)";
setIsTmuxAttached(true);
addLog({
type: "info",
stage: "connection",
@@ -1534,6 +1587,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
stage: "connection",
message: t("terminal.tmuxUnavailable"),
});
} else if (msg.type === "tmux_detached") {
tmuxSessionNameRef.current = null;
setIsTmuxAttached(false);
toast.info(t("terminal.tmuxDetached"), { duration: 3000 });
} else if (msg.type === "connection_log") {
if (msg.data) {
addLog({
@@ -1559,6 +1616,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
setIsConnected(false);
isConnectingRef.current = false;
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
if (pongTimeoutRef.current) {
clearTimeout(pongTimeoutRef.current);
pongTimeoutRef.current = null;
@@ -1649,6 +1711,11 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
setIsConnecting(false);
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
if (totpTimeoutRef.current) {
clearTimeout(totpTimeoutRef.current);
totpTimeoutRef.current = null;
@@ -1786,18 +1853,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
...hostConfig.terminalConfig,
};
let themeColors;
const activeTheme = previewTheme || config.theme;
if (activeTheme === "termix") {
themeColors = isDarkMode
? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors;
} else {
themeColors =
TERMINAL_THEMES[activeTheme]?.colors ||
TERMINAL_THEMES.termixDark.colors;
}
const themeColors = resolveTermixThemeColors(activeTheme, appTheme);
const fontConfig = TERMINAL_FONTS.find(
(f) => f.value === config.fontFamily,
@@ -1811,7 +1868,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
terminal.options.fontSize = config.fontSize;
terminal.options.fontFamily = fontFamily;
terminal.options.rightClickSelectsWord = config.rightClickSelectsWord;
terminal.options.fastScrollModifier = config.fastScrollModifier;
terminal.options.fastScrollSensitivity = config.fastScrollSensitivity;
terminal.options.minimumContrastRatio = config.minimumContrastRatio;
terminal.options.letterSpacing = config.letterSpacing;
@@ -1854,13 +1910,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
// Refresh terminal to apply new theme colors to existing buffer content
hardRefresh();
}, [
terminal,
hostConfig.terminalConfig,
previewTheme,
isDarkMode,
isFitted,
]);
}, [terminal, hostConfig.terminalConfig, previewTheme, appTheme, isFitted]);
useEffect(() => {
if (!terminal || !xtermRef.current) return;
@@ -1875,18 +1925,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
);
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
let themeColors;
const activeTheme = previewTheme || config.theme;
if (activeTheme === "termix") {
themeColors = isDarkMode
? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors;
} else {
themeColors =
TERMINAL_THEMES[activeTheme]?.colors ||
TERMINAL_THEMES.termixDark.colors;
}
const themeColors = resolveTermixThemeColors(activeTheme, appTheme);
// Set initial options before opening the terminal
terminal.options = {
@@ -1897,11 +1937,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
fontFamily,
allowTransparency: true, // MUST be set before open()
convertEol: false,
windowsMode: false,
macOptionIsMeta: false,
macOptionClickForcesSelection: false,
rightClickSelectsWord: config.rightClickSelectsWord,
fastScrollModifier: config.fastScrollModifier,
fastScrollSensitivity: config.fastScrollSensitivity,
allowProposedApi: true,
minimumContrastRatio: config.minimumContrastRatio,
@@ -1938,7 +1976,13 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
const clipboardProvider = new RobustClipboardProvider();
const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider);
const unicode11Addon = new Unicode11Addon();
const webLinksAddon = new WebLinksAddon();
const webLinksAddon = new WebLinksAddon((_event, uri) => {
const url =
uri.startsWith("http://") || uri.startsWith("https://")
? uri
: `https://${uri}`;
window.open(url, "_blank");
});
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
@@ -1950,15 +1994,35 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
terminal.open(xtermRef.current);
terminal.attachCustomWheelEventHandler((ev) => {
const cfg = {
...DEFAULT_TERMINAL_CONFIG,
...hostConfig.terminalConfig,
};
const mod = cfg.fastScrollModifier;
const modHeld =
(mod === "alt" && ev.altKey) ||
(mod === "ctrl" && ev.ctrlKey) ||
(mod === "shift" && ev.shiftKey);
if (modHeld) {
const lines = Math.round(
(Math.abs(ev.deltaY) / 100) * (cfg.fastScrollSensitivity ?? 5),
);
terminal.scrollLines(ev.deltaY > 0 ? lines : -lines);
return false;
}
return true;
});
fitAddonRef.current?.fit();
if (terminal.cols < 10 || terminal.rows < 3) {
// Double-rAF ensures layout is fully settled (fonts, flexbox, etc.) before
// committing the fitted size, preventing the "terminal too short" glitch.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
fitAddonRef.current?.fit();
setIsFitted(true);
});
} else {
setIsFitted(true);
}
});
const element = xtermRef.current;
const handleContextMenu = (e: MouseEvent) => {
@@ -2101,7 +2165,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
const persistenceEnabled =
localStorage.getItem("enableTerminalSessionPersistence") === "true";
localStorage.getItem("enableTerminalSessionPersistence") !==
"false";
if (
!persistenceEnabled &&
sessionIdRef.current &&
@@ -2410,32 +2475,28 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
return;
}
if (terminal.cols < 10 || terminal.rows < 3) {
setIsConnecting(true);
fitAddonRef.current?.fit();
requestAnimationFrame(() => {
requestAnimationFrame(() => {
fitAddonRef.current?.fit();
if (terminal.cols > 0 && terminal.rows > 0) {
setIsConnecting(true);
fitAddonRef.current?.fit();
scheduleNotify(terminal.cols, terminal.rows);
connectToHost(terminal.cols, terminal.rows);
}
});
return;
}
setIsConnecting(true);
fitAddonRef.current?.fit();
requestAnimationFrame(() => {
fitAddonRef.current?.fit();
if (terminal.cols > 0 && terminal.rows > 0) {
scheduleNotify(terminal.cols, terminal.rows);
connectToHost(terminal.cols, terminal.rows);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [terminal, hostConfig.id, isVisible, isConnected, isConnecting]);
useEffect(() => {
if (!terminal || !fitAddonRef.current || !isVisible) return;
if (!terminal || !fitAddonRef.current) return;
if (!isVisible) {
lastFittedSizeRef.current = null;
lastSentSizeRef.current = null;
return;
}
const fitTimeoutId = setTimeout(() => {
if (!isFittingRef.current && terminal.cols > 0 && terminal.rows > 0) {
@@ -2444,7 +2505,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
requestAnimationFrame(() => terminal.focus());
}
}
}, 0);
}, 50);
return () => clearTimeout(fitTimeoutId);
}, [terminal, isVisible, splitScreen, isConnecting]);
@@ -2470,6 +2531,22 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}}
/>
{isTmuxAttached && isConnected && (
<button
onClick={() => {
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
webSocketRef.current.send(
JSON.stringify({ type: "tmux_detach" }),
);
}
}}
title={t("terminal.tmuxDetach")}
className="absolute top-2 right-2 z-[110] px-2 py-1 text-xs rounded bg-black/60 text-white/70 hover:text-white hover:bg-black/80 transition-colors"
>
tmux:detach
</button>
)}
<SimpleLoader
visible={isConnecting && !isConnectionLogExpanded}
message={t("terminal.connecting")}
@@ -2478,9 +2555,12 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
{showDisconnectedOverlay && !isConnecting && (
<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 }}
>
<p className="text-sm text-muted-foreground">
{t("terminal.connectionLost")}
</p>
<div className="flex gap-2">
<Button
onClick={() => {
@@ -2502,7 +2582,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
{t("terminal.reconnect")}
</Button>
{onClose && (
<Button variant="secondary" onClick={onClose}>
<Button variant="outline" onClick={onClose}>
{t("terminal.closeTab")}
</Button>
)}
@@ -2513,7 +2593,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
<ConnectionLog
isConnecting={isConnecting}
isConnected={isConnected}
hasConnectionError={hasConnectionError}
hasConnectionError={hasConnectionError && !showDisconnectedOverlay}
position={hasConnectionError ? "top" : "bottom"}
/>
+6 -2
View File
@@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Terminal } from "@/features/terminal/Terminal.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
@@ -7,6 +8,7 @@ interface TerminalAppProps {
}
const TerminalApp: React.FC<TerminalAppProps> = ({ hostId }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, loading) => {
@@ -15,7 +17,9 @@ const TerminalApp: React.FC<TerminalAppProps> = ({ hostId }) => {
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">Loading host...</p>
<p className="text-muted-foreground">
{t("hosts.loadingHost")}
</p>
</div>
</div>
);
@@ -25,7 +29,7 @@ const TerminalApp: React.FC<TerminalAppProps> = ({ hostId }) => {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-red-500 mb-4">Host not found</p>
<p className="text-red-500 mb-4">{t("hosts.hostNotFound")}</p>
</div>
</div>
);
+55 -56
View File
@@ -1,5 +1,5 @@
import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/lib/terminal-themes.ts";
import { useTheme } from "@/components/theme-provider.tsx";
import { useTheme } from "@/components/theme-provider";
import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/lib/terminal-themes";
interface TerminalPreviewProps {
theme: string;
@@ -18,7 +18,7 @@ export function TerminalPreview({
cursorStyle = "bar",
cursorBlink = true,
letterSpacing = 0,
lineHeight = 1.2,
lineHeight = 1.0,
}: TerminalPreviewProps) {
const { theme: appTheme } = useTheme();
@@ -31,80 +31,79 @@ export function TerminalPreview({
: "termixLight"
: theme;
const colors = TERMINAL_THEMES[resolvedTheme]?.colors;
const fontFallback =
TERMINAL_FONTS.find((f) => f.value === fontFamily)?.fallback ||
TERMINAL_FONTS[0].fallback;
return (
<div className="border border-input rounded-md overflow-hidden">
<div className="border border-input overflow-hidden">
<div
className="p-4 font-mono text-sm"
className="p-3 font-mono"
style={{
fontSize: `${fontSize}px`,
fontFamily:
TERMINAL_FONTS.find((f) => f.value === fontFamily)?.fallback ||
TERMINAL_FONTS[0].fallback,
fontFamily: fontFallback,
letterSpacing: `${letterSpacing}px`,
lineHeight,
background:
TERMINAL_THEMES[resolvedTheme]?.colors.background ||
"var(--bg-base)",
color:
TERMINAL_THEMES[resolvedTheme]?.colors.foreground ||
"var(--foreground)",
background: colors?.background || "var(--bg-base)",
color: colors?.foreground || "var(--foreground)",
}}
>
<div>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.green }}>
user@termix
<span style={{ color: colors?.green }}>deploy@web-01</span>
<span style={{ color: colors?.brightBlack }}>:</span>
<span style={{ color: colors?.blue }}>~</span>
<span style={{ color: colors?.brightBlack }}>$</span>
<span> ls -la</span>
</div>
<div style={{ color: colors?.brightBlack }}>total 48</div>
<div>
<span style={{ color: colors?.cyan }}>drwxr-xr-x</span>
<span style={{ color: colors?.brightBlack }}>
{" "}
5 deploy deploy 4096 May 1 09:12{" "}
</span>
<span>:</span>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>
~
</span>
<span>$ ls -la</span>
<span style={{ color: colors?.blue }}>.</span>
</div>
<div>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>
drwxr-xr-x
</span>
<span> 5 user </span>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.cyan }}>
docs
<span style={{ color: colors?.cyan }}>drwxr-xr-x</span>
<span style={{ color: colors?.brightBlack }}>
{" "}
3 root root 4096 Apr 15 18:44{" "}
</span>
<span style={{ color: colors?.blue }}>..</span>
</div>
<div>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.green }}>
-rwxr-xr-x
</span>
<span> 1 user </span>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.green }}>
script.sh
<span style={{ color: colors?.cyan }}>-rw-r--r--</span>
<span style={{ color: colors?.brightBlack }}>
{" "}
1 deploy deploy 220 Apr 15 18:44{" "}
</span>
<span>.bash_logout</span>
</div>
<div>
<span>-rw-r--r--</span>
<span> 1 user </span>
<span>README.md</span>
<span style={{ color: colors?.cyan }}>-rwxr-xr-x</span>
<span style={{ color: colors?.brightBlack }}>
{" "}
1 deploy deploy 8192 May 1 08:55{" "}
</span>
<span style={{ color: colors?.green }}>deploy.sh</span>
</div>
<div>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.green }}>
user@termix
</span>
<span>:</span>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>
~
</span>
<span>$ </span>
<div className="flex items-center gap-0.5 mt-0.5">
<span style={{ color: colors?.green }}>deploy@web-01</span>
<span style={{ color: colors?.brightBlack }}>:</span>
<span style={{ color: colors?.blue }}>~</span>
<span style={{ color: colors?.brightBlack }}>$</span>
<span> </span>
<span
className="inline-block"
style={{
width: cursorStyle === "block" ? "0.6em" : "0.1em",
height:
cursorStyle === "underline"
? "0.15em"
: cursorStyle === "bar"
? `${fontSize}px`
: `${fontSize}px`,
background:
TERMINAL_THEMES[resolvedTheme]?.colors.cursor || "#f7f7f7",
animation: cursorBlink ? "blink 1s step-end infinite" : "none",
width: cursorStyle === "block" ? "0.6em" : "0.12em",
height: cursorStyle === "underline" ? "0.12em" : `${fontSize}px`,
background: colors?.cursor || colors?.foreground || "#f7f7f7",
animation: cursorBlink
? "termPreviewBlink 1s step-end infinite"
: "none",
verticalAlign:
cursorStyle === "underline" ? "bottom" : "text-bottom",
}}
@@ -112,7 +111,7 @@ export function TerminalPreview({
</div>
</div>
<style>{`
@keyframes blink {
@keyframes termPreviewBlink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
-248
View File
@@ -1,248 +0,0 @@
import React, { useState, useEffect, useCallback } from "react";
import { TunnelViewer } from "@/features/tunnel/TunnelViewer.tsx";
import {
getSSHHosts,
subscribeTunnelStatuses,
connectTunnel,
disconnectTunnel,
cancelTunnel,
logActivity,
} from "@/main-axios.ts";
import type {
SSHHost,
TunnelConnection,
TunnelStatus,
SSHTunnelProps,
} from "@/types/index";
export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
const [tunnelStatuses, setTunnelStatuses] = useState<
Record<string, TunnelStatus>
>({});
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>(
{},
);
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
const haveTunnelConnectionsChanged = (
a: TunnelConnection[] = [],
b: TunnelConnection[] = [],
): boolean => {
if (a.length !== b.length) return true;
for (let i = 0; i < a.length; i++) {
const x = a[i];
const y = b[i];
if (
x.sourcePort !== y.sourcePort ||
x.endpointPort !== y.endpointPort ||
x.endpointHost !== y.endpointHost ||
x.maxRetries !== y.maxRetries ||
x.retryInterval !== y.retryInterval ||
x.autoStart !== y.autoStart
) {
return true;
}
}
return false;
};
const fetchHosts = useCallback(async () => {
const hostsData = await getSSHHosts();
setAllHosts(hostsData);
const nextVisible = filterHostKey
? hostsData.filter((h) => {
const key =
h.name && h.name.trim() !== "" ? h.name : `${h.username}@${h.ip}`;
return key === filterHostKey;
})
: hostsData;
const prev = prevVisibleHostRef.current;
const curr = nextVisible[0] ?? null;
let changed = false;
if (!prev && curr) changed = true;
else if (prev && !curr) changed = true;
else if (prev && curr) {
if (
prev.id !== curr.id ||
prev.name !== curr.name ||
prev.ip !== curr.ip ||
prev.port !== curr.port ||
prev.username !== curr.username ||
haveTunnelConnectionsChanged(
prev.tunnelConnections,
curr.tunnelConnections,
)
) {
changed = true;
}
}
if (changed) {
setVisibleHosts(nextVisible);
prevVisibleHostRef.current = curr;
}
}, [filterHostKey]);
const logTunnelActivity = async (host: SSHHost) => {
if (!host?.id || activityLoggedRef.current || activityLoggingRef.current) {
return;
}
activityLoggingRef.current = true;
activityLoggedRef.current = true;
try {
const hostName = host.name || `${host.username}@${host.ip}`;
await logActivity("tunnel", host.id, hostName);
} catch (err) {
console.warn("Failed to log tunnel activity:", err);
activityLoggedRef.current = false;
} finally {
activityLoggingRef.current = false;
}
};
useEffect(() => {
fetchHosts();
const interval = setInterval(fetchHosts, 5000);
const handleHostsChanged = () => {
fetchHosts();
};
window.addEventListener(
"ssh-hosts:changed",
handleHostsChanged as EventListener,
);
return () => {
clearInterval(interval);
window.removeEventListener(
"ssh-hosts:changed",
handleHostsChanged as EventListener,
);
};
}, [fetchHosts]);
useEffect(() => {
return subscribeTunnelStatuses(setTunnelStatuses, () => {
// The view remains usable if the stream reconnects or is unavailable.
});
}, []);
useEffect(() => {
if (visibleHosts.length > 0 && visibleHosts[0]) {
logTunnelActivity(visibleHosts[0]);
}
}, [visibleHosts.length > 0 ? visibleHosts[0]?.id : null]);
const handleTunnelAction = async (
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => {
const tunnel = host.tunnelConnections[tunnelIndex];
const tunnelName = `${host.id}::${tunnelIndex}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
setTunnelActions((prev) => ({ ...prev, [tunnelName]: true }));
try {
if (action === "connect") {
const endpointHost = allHosts.find(
(h) =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost,
);
const tunnelConfig = {
name: tunnelName,
scope: tunnel.scope || "s2s",
mode: tunnel.mode || tunnel.tunnelType || "remote",
bindHost: tunnel.bindHost,
targetHost: tunnel.targetHost,
tunnelType:
tunnel.tunnelType ||
(tunnel.mode === "local" || tunnel.mode === "remote"
? tunnel.mode
: "remote"),
sourceHostId: host.id,
tunnelIndex: tunnelIndex,
hostName: host.name || `${host.username}@${host.ip}`,
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
sourcePassword:
host.authType === "password" ? host.password : undefined,
sourceAuthMethod: host.authType,
sourceSSHKey: host.authType === "key" ? host.key : undefined,
sourceKeyPassword:
host.authType === "key" ? host.keyPassword : undefined,
sourceKeyType: host.authType === "key" ? host.keyType : undefined,
sourceCredentialId: host.credentialId,
sourceUserId: host.userId,
endpointHost: tunnel.endpointHost,
endpointIP: endpointHost?.ip,
endpointSSHPort: endpointHost?.port,
endpointUsername: endpointHost?.username,
endpointPassword:
endpointHost?.authType === "password"
? endpointHost.password
: undefined,
endpointAuthMethod: endpointHost?.authType,
endpointSSHKey:
endpointHost?.authType === "key" ? endpointHost.key : undefined,
endpointKeyPassword:
endpointHost?.authType === "key"
? endpointHost.keyPassword
: undefined,
endpointKeyType:
endpointHost?.authType === "key" ? endpointHost.keyType : undefined,
endpointCredentialId: endpointHost?.credentialId,
endpointUserId: endpointHost?.userId,
sourcePort: tunnel.sourcePort,
endpointPort: tunnel.endpointPort,
maxRetries: tunnel.maxRetries,
retryInterval: tunnel.retryInterval * 1000,
autoStart: tunnel.autoStart,
isPinned: host.pin,
useSocks5: host.useSocks5,
socks5Host: host.socks5Host,
socks5Port: host.socks5Port,
socks5Username: host.socks5Username,
socks5Password: host.socks5Password,
socks5ProxyChain: host.socks5ProxyChain,
};
await connectTunnel(tunnelConfig);
} else if (action === "disconnect") {
await disconnectTunnel(tunnelName);
} else if (action === "cancel") {
await cancelTunnel(tunnelName);
}
} catch (error) {
console.error("Tunnel action failed:", {
action,
tunnelName,
hostId: host.id,
tunnelIndex,
error: error instanceof Error ? error.message : String(error),
fullError: error,
});
} finally {
setTunnelActions((prev) => ({ ...prev, [tunnelName]: false }));
}
};
return (
<TunnelViewer
hosts={visibleHosts}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={handleTunnelAction}
/>
);
}
+42 -14
View File
@@ -1,22 +1,55 @@
import React from "react";
import { TunnelManager } from "@/features/tunnel/TunnelManager.tsx";
import { useTranslation } from "react-i18next";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { TunnelTab } from "@/features/tunnel/TunnelTab.tsx";
import type { Host } from "@/types/ui-types";
import type { SSHHost } from "@/types";
interface TunnelAppProps {
hostId?: string;
}
function sshHostToMinimalHost(h: SSHHost): Host {
return {
id: String(h.id),
name: h.name,
ip: h.ip,
port: h.port,
username: h.username,
folder: h.folder ?? "",
online: false,
cpu: null,
ram: null,
lastAccess: "",
tags: h.tags ?? [],
pin: h.pin ?? false,
authType: h.authType,
enableTerminal: h.enableTerminal ?? false,
enableTunnel: h.enableTunnel ?? false,
enableFileManager: h.enableFileManager ?? false,
enableDocker: h.enableDocker ?? false,
enableSsh: h.enableSsh ?? true,
enableRdp: h.enableRdp ?? false,
enableVnc: h.enableVnc ?? false,
enableTelnet: h.enableTelnet ?? false,
sshPort: h.sshPort ?? h.port,
rdpPort: h.rdpPort ?? 3389,
vncPort: h.vncPort ?? 5900,
telnetPort: h.telnetPort ?? 23,
serverTunnels: [],
quickActions: [],
};
}
const TunnelApp: React.FC<TunnelAppProps> = ({ hostId }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, loading) => {
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
<p className="text-muted-foreground">Loading host...</p>
</div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto" />
</div>
);
}
@@ -24,20 +57,15 @@ const TunnelApp: React.FC<TunnelAppProps> = ({ hostId }) => {
if (!hostConfig) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-red-500 mb-4">Host not found</p>
</div>
<p className="text-muted-foreground">{t("hosts.hostNotFound")}</p>
</div>
);
}
return (
<TunnelManager
hostConfig={hostConfig}
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
isVisible={true}
isTopbarOpen={false}
embedded={true}
<TunnelTab
label={hostConfig.name}
host={sshHostToMinimalHost(hostConfig)}
/>
);
}}
+14 -14
View File
@@ -88,28 +88,28 @@ export function TunnelInlineControls({
const statusClass =
kind === "connected"
? "text-green-600 dark:text-green-400 bg-green-500/10 border-green-500/20"
? "text-accent-brand border-accent-brand/40 bg-accent-brand/10"
: kind === "connecting"
? "text-blue-600 dark:text-blue-400 bg-blue-500/10 border-blue-500/20"
? "text-blue-400 border-blue-400/40 bg-blue-400/10"
: kind === "error"
? "text-red-600 dark:text-red-400 bg-red-500/10 border-red-500/20"
: "text-muted-foreground bg-muted/30 border-border";
? "text-destructive border-destructive/40 bg-destructive/10"
: "text-muted-foreground border-border bg-muted/30";
const statusIcon =
kind === "connected" ? (
<Wifi className="h-3 w-3" />
<Wifi className="size-3" />
) : kind === "connecting" ? (
<Loader2 className="h-3 w-3 animate-spin" />
<Loader2 className="size-3 animate-spin" />
) : kind === "error" ? (
<AlertCircle className="h-3 w-3" />
<AlertCircle className="size-3" />
) : (
<WifiOff className="h-3 w-3" />
<WifiOff className="size-3" />
);
return (
<div className="flex flex-wrap items-center justify-end gap-2">
<span
className={`inline-flex h-8 items-center gap-1.5 rounded-md border px-2 text-xs font-medium ${statusClass}`}
className={`inline-flex h-8 items-center gap-1.5 border px-2 text-[10px] font-bold uppercase tracking-wide ${statusClass}`}
title={title}
>
{statusIcon}
@@ -123,7 +123,7 @@ export function TunnelInlineControls({
disabled
className="h-8 px-3 text-xs text-muted-foreground border-border"
>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
<Loader2 className="size-3 mr-1 animate-spin" />
{isDisconnected ? t("tunnels.start") : t("tunnels.stop")}
</Button>
) : isDisconnected ? (
@@ -134,9 +134,9 @@ export function TunnelInlineControls({
onClick={onStart}
disabled={startDisabled}
title={startDisabled ? startDisabledReason : undefined}
className="h-8 px-3 text-xs text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50"
className="h-8 px-3 text-xs text-accent-brand border-accent-brand/40 hover:bg-accent-brand/10 hover:text-accent-brand"
>
<Play className="h-3 w-3 mr-1" />
<Play className="size-3 mr-1" />
{t("tunnels.start")}
</Button>
) : (
@@ -145,9 +145,9 @@ export function TunnelInlineControls({
size="sm"
variant="outline"
onClick={onStop}
className="h-8 px-3 text-xs text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50"
className="h-8 px-3 text-xs text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive"
>
<Square className="h-3 w-3 mr-1" />
<Square className="size-3 mr-1" />
{t("tunnels.stop")}
</Button>
)}
-169
View File
@@ -1,169 +0,0 @@
import React from "react";
import { Separator } from "@/components/separator.tsx";
import { Button } from "@/components/button.tsx";
import { Tunnel } from "@/features/tunnel/Tunnel.tsx";
import { useTranslation } from "react-i18next";
import { getSSHHosts } from "@/main-axios.ts";
import { useTabsSafe } from "@/shell/TabContext.tsx";
interface HostConfig {
id: number;
name: string;
ip: string;
username: string;
folder?: string;
enableFileManager?: boolean;
tunnelConnections?: unknown[];
[key: string]: unknown;
}
interface TunnelManagerProps {
hostConfig?: HostConfig;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
}
export function TunnelManager({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false,
}: TunnelManagerProps): React.ReactElement {
const { t } = useTranslation();
const { tabs, addTab, setCurrentTab, updateTab } = useTabsSafe();
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const isElectron =
typeof window !== "undefined" && window.electronAPI?.isElectron === true;
const openC2SPresets = React.useCallback(() => {
const profileTab = tabs.find((tab) => tab.type === "user_profile");
if (profileTab) {
updateTab(profileTab.id, {
initialTab: "c2s-tunnels",
_updateTimestamp: Date.now(),
});
setCurrentTab(profileTab.id);
return;
}
const id = addTab({
type: "user_profile",
title: t("profile.title"),
initialTab: "c2s-tunnels",
});
setCurrentTab(id);
}, [addTab, setCurrentTab, t, tabs, updateTab]);
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setCurrentHostConfig(hostConfig);
}
}, [hostConfig?.id]);
React.useEffect(() => {
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
try {
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
// Silently handle error
}
}
};
fetchLatestHostConfig();
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
// Silently handle error
}
}
};
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
return () =>
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
}, [hostConfig?.id]);
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = 8;
const bottomMarginPx = 8;
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
: {
opacity: isVisible ? 1 : 0,
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
};
const containerClass = embedded
? "h-full w-full text-foreground overflow-hidden bg-transparent"
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
</div>
{isElectron && (
<Button size="sm" variant="outline" onClick={openC2SPresets}>
{t("tunnels.manageClientTunnels")}
</Button>
)}
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0 p-1">
{currentHostConfig?.tunnelConnections &&
currentHostConfig.tunnelConnections.length > 0 ? (
<div className="rounded-lg h-full overflow-hidden flex flex-col min-h-0">
<Tunnel
filterHostKey={
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
}
/>
</div>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-foreground-subtle text-lg">
{t("tunnels.noTunnelsConfigured")}
</p>
<p className="text-foreground-subtle text-sm mt-2">
{t("tunnels.configureTunnelsInHostSettings")}
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}
+19 -13
View File
@@ -46,26 +46,32 @@ export function TunnelModeSelector({
];
return (
<div className="grid gap-3 lg:grid-cols-3">
<div className="grid gap-2 lg:grid-cols-3">
{options.map((option) => (
<label
<button
key={option.value}
className="flex items-start gap-3 rounded-md border bg-card p-3 cursor-pointer"
type="button"
onClick={() => onChange(option.value)}
className={`flex items-start gap-2.5 border p-3 text-left transition-colors ${
mode === option.value
? "border-accent-brand bg-accent-brand/5 text-foreground"
: "border-border bg-muted/20 text-muted-foreground hover:border-border/80 hover:bg-muted/30"
}`}
>
<input
type="radio"
value={option.value}
checked={mode === option.value}
onChange={() => onChange(option.value)}
className="mt-0.5 w-4 h-4 text-primary border-input focus:ring-ring"
<div
className={`mt-0.5 size-3.5 shrink-0 rounded-full border-2 ${
mode === option.value
? "border-accent-brand bg-accent-brand"
: "border-muted-foreground/40"
}`}
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{option.label}</span>
<span className="text-xs text-muted-foreground">
<div className="flex flex-col gap-0.5">
<span className="text-xs font-semibold">{option.label}</span>
<span className="text-[10px] text-muted-foreground leading-tight">
{option.description}
</span>
</div>
</label>
</button>
))}
</div>
);
-532
View File
@@ -1,532 +0,0 @@
import React from "react";
import { Button } from "@/components/button.tsx";
import { Card } from "@/components/card.tsx";
import { Separator } from "@/components/separator.tsx";
import { useTranslation } from "react-i18next";
import {
Loader2,
Pin,
Network,
Tag,
Play,
Square,
AlertCircle,
Clock,
Wifi,
WifiOff,
X,
} from "lucide-react";
import { Badge } from "@/components/badge.tsx";
import type { TunnelStatus, SSHTunnelObjectProps } from "@/types/index";
export function TunnelObject({
host,
tunnelIndex,
tunnelStatuses,
tunnelActions,
onTunnelAction,
compact = false,
bare = false,
}: SSHTunnelObjectProps): React.ReactElement {
const { t } = useTranslation();
const getTunnelStatus = (idx: number): TunnelStatus | undefined => {
const tunnel = host.tunnelConnections[idx];
const tunnelName = `${host.id}::${idx}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
return tunnelStatuses[tunnelName];
};
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
if (!status)
return {
icon: <WifiOff className="h-4 w-4" />,
text: t("tunnels.unknown"),
color: "text-muted-foreground",
bgColor: "bg-muted/50",
borderColor: "border-border",
};
const statusValue = status.status || "DISCONNECTED";
switch (statusValue.toUpperCase()) {
case "CONNECTED":
return {
icon: <Wifi className="h-4 w-4" />,
text: t("tunnels.connected"),
color: "text-green-600 dark:text-green-400",
bgColor: "bg-green-500/10 dark:bg-green-400/10",
borderColor: "border-green-500/20 dark:border-green-400/20",
};
case "CONNECTING":
return {
icon: <Loader2 className="h-4 w-4 animate-spin" />,
text: t("tunnels.connecting"),
color: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-500/10 dark:bg-blue-400/10",
borderColor: "border-blue-500/20 dark:border-blue-400/20",
};
case "DISCONNECTING":
return {
icon: <Loader2 className="h-4 w-4 animate-spin" />,
text: t("tunnels.disconnecting"),
color: "text-orange-600 dark:text-orange-400",
bgColor: "bg-orange-500/10 dark:bg-orange-400/10",
borderColor: "border-orange-500/20 dark:border-orange-400/20",
};
case "DISCONNECTED":
return {
icon: <WifiOff className="h-4 w-4" />,
text: t("tunnels.disconnected"),
color: "text-muted-foreground",
bgColor: "bg-muted/30",
borderColor: "border-border",
};
case "WAITING":
return {
icon: <Clock className="h-4 w-4" />,
color: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-500/10 dark:bg-blue-400/10",
borderColor: "border-blue-500/20 dark:border-blue-400/20",
};
case "ERROR":
case "FAILED":
return {
icon: <AlertCircle className="h-4 w-4" />,
text: status.reason || t("tunnels.error"),
color: "text-red-600 dark:text-red-400",
bgColor: "bg-red-500/10 dark:bg-red-400/10",
borderColor: "border-red-500/20 dark:border-red-400/20",
};
default:
return {
icon: <WifiOff className="h-4 w-4" />,
text: t("tunnels.unknown"),
color: "text-muted-foreground",
bgColor: "bg-muted/30",
borderColor: "border-border",
};
}
};
if (bare) {
return (
<div className="w-full min-w-0">
<div className="space-y-3">
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="space-y-3">
{(tunnelIndex !== undefined
? [tunnelIndex]
: host.tunnelConnections.map((_, idx) => idx)
).map((idx) => {
const tunnel = host.tunnelConnections[idx];
const status = getTunnelStatus(idx);
const statusDisplay = getTunnelStatusDisplay(status);
const tunnelName = `${host.id}::${idx}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
const isActionLoading = tunnelActions[tunnelName];
const statusValue =
status?.status?.toUpperCase() || "DISCONNECTED";
const isConnected = statusValue === "CONNECTED";
const isConnecting = statusValue === "CONNECTING";
const isDisconnecting = statusValue === "DISCONNECTING";
const isRetrying = statusValue === "RETRYING";
const isWaiting = statusValue === "WAITING";
return (
<div
key={idx}
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
<span
className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}
>
{statusDisplay.icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium break-words">
{t("tunnels.port")} {tunnel.sourcePort} {" "}
{tunnel.endpointHost}:{tunnel.endpointPort}
</div>
<div
className={`text-xs ${statusDisplay.color} font-medium`}
>
{statusDisplay.text}
</div>
</div>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
{!isActionLoading ? (
<div className="flex flex-col gap-1">
{isConnected ? (
<>
<Button
size="sm"
variant="outline"
onClick={() =>
onTunnelAction("disconnect", host, idx)
}
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
>
<Square className="h-3 w-3 mr-1" />
{t("tunnels.stop")}
</Button>
</>
) : isRetrying || isWaiting ? (
<Button
size="sm"
variant="outline"
onClick={() =>
onTunnelAction("cancel", host, idx)
}
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
>
<X className="h-3 w-3 mr-1" />
{t("tunnels.cancel")}
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() =>
onTunnelAction("connect", host, idx)
}
disabled={isConnecting || isDisconnecting}
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
>
<Play className="h-3 w-3 mr-1" />
{t("tunnels.start")}
</Button>
)}
</div>
) : (
<Button
size="sm"
variant="outline"
disabled
className="h-7 px-2 text-muted-foreground border-border text-xs"
>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
{isConnected
? t("tunnels.disconnecting")
: isRetrying || isWaiting
? t("tunnels.canceling")
: t("tunnels.connecting")}
</Button>
)}
</div>
</div>
{(statusValue === "ERROR" || statusValue === "FAILED") &&
status?.reason && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
<div className="font-medium mb-1">
{t("tunnels.error")}:
</div>
{status.reason}
{status.reason &&
status.reason.includes("Max retries exhausted") && (
<>
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
{t("tunnels.checkDockerLogs")}{" "}
<a
href="https://discord.com/invite/jVQGdvHDrf"
target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
{t("tunnels.discord")}
</a>{" "}
{t("tunnels.orCreate")}{" "}
<a
href="https://github.com/Termix-SSH/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
{t("tunnels.githubIssue")}
</a>{" "}
{t("tunnels.forHelp")}.
</div>
</>
)}
</div>
)}
{(statusValue === "RETRYING" ||
statusValue === "WAITING") &&
status?.retryCount &&
status?.maxRetries && (
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
<div className="font-medium mb-1">
{statusValue === "WAITING"
? t("tunnels.waitingForRetry")
: t("tunnels.retryingConnection")}
</div>
<div>
{t("tunnels.attempt", {
current: status.retryCount,
max: status.maxRetries,
})}
{status.nextRetryIn && (
<span>
{" "}
{" "}
{t("tunnels.nextRetryIn", {
seconds: status.nextRetryIn,
})}
</span>
)}
</div>
</div>
)}
</div>
);
})}
</div>
) : (
<div className="text-center py-4 text-muted-foreground">
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{t("tunnels.noTunnelConnections")}</p>
</div>
)}
</div>
</div>
);
}
return (
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
<div className="p-4">
{!compact && (
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
{host.pin && (
<Pin className="h-4 w-4 text-yellow-500 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-card-foreground truncate">
{host.name || `${host.username}@${host.ip}`}
</h3>
<p className="text-xs text-muted-foreground truncate">
{host.ip}:{host.port} {host.username}
</p>
</div>
</div>
</div>
)}
{!compact && host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{host.tags.slice(0, 3).map((tag, index) => (
<Badge
key={index}
variant="secondary"
className="text-xs px-1 py-0"
>
<Tag className="h-2 w-2 mr-0.5" />
{tag}
</Badge>
))}
{host.tags.length > 3 && (
<Badge variant="outline" className="text-xs px-1 py-0">
+{host.tags.length - 3}
</Badge>
)}
</div>
)}
{!compact && <Separator className="mb-3" />}
<div className="space-y-3">
{!compact && (
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
<Network className="h-4 w-4" />
{t("tunnels.tunnelConnections")} (
{tunnelIndex !== undefined ? 1 : host.tunnelConnections.length})
</h4>
)}
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="space-y-3">
{(tunnelIndex !== undefined
? [tunnelIndex]
: host.tunnelConnections.map((_, idx) => idx)
).map((idx) => {
const tunnel = host.tunnelConnections[idx];
const status = getTunnelStatus(idx);
const statusDisplay = getTunnelStatusDisplay(status);
const tunnelName = `${host.id}::${idx}::${host.name || `${host.username}@${host.ip}`}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
const isActionLoading = tunnelActions[tunnelName];
const statusValue =
status?.status?.toUpperCase() || "DISCONNECTED";
const isConnected = statusValue === "CONNECTED";
const isConnecting = statusValue === "CONNECTING";
const isDisconnecting = statusValue === "DISCONNECTING";
const isRetrying = statusValue === "RETRYING";
const isWaiting = statusValue === "WAITING";
return (
<div
key={idx}
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
<span
className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}
>
{statusDisplay.icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium break-words">
{t("tunnels.port")} {tunnel.sourcePort} {" "}
{tunnel.endpointHost}:{tunnel.endpointPort}
</div>
<div
className={`text-xs ${statusDisplay.color} font-medium`}
>
{statusDisplay.text}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{!isActionLoading && (
<div className="flex flex-col gap-1">
{isConnected ? (
<>
<Button
size="sm"
variant="outline"
onClick={() =>
onTunnelAction("disconnect", host, idx)
}
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
>
<Square className="h-3 w-3 mr-1" />
{t("tunnels.stop")}
</Button>
</>
) : isRetrying || isWaiting ? (
<Button
size="sm"
variant="outline"
onClick={() =>
onTunnelAction("cancel", host, idx)
}
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
>
<X className="h-3 w-3 mr-1" />
{t("tunnels.cancel")}
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() =>
onTunnelAction("connect", host, idx)
}
disabled={isConnecting || isDisconnecting}
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
>
<Play className="h-3 w-3 mr-1" />
{t("tunnels.start")}
</Button>
)}
</div>
)}
{isActionLoading && (
<Button
size="sm"
variant="outline"
disabled
className="h-7 px-2 text-muted-foreground border-border text-xs"
>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
{isConnected
? t("tunnels.disconnecting")
: isRetrying || isWaiting
? t("tunnels.canceling")
: t("tunnels.connecting")}
</Button>
)}
</div>
</div>
{(statusValue === "ERROR" || statusValue === "FAILED") &&
status?.reason && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
<div className="font-medium mb-1">
{t("tunnels.error")}:
</div>
{status.reason}
{status.reason &&
status.reason.includes("Max retries exhausted") && (
<>
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
{t("tunnels.checkDockerLogs")}{" "}
<a
href="https://discord.com/invite/jVQGdvHDrf"
target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
{t("tunnels.discord")}
</a>{" "}
{t("tunnels.orCreate")}{" "}
<a
href="https://github.com/Termix-SSH/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
{t("tunnels.githubIssue")}
</a>{" "}
{t("tunnels.forHelp")}.
</div>
</>
)}
</div>
)}
{(statusValue === "RETRYING" ||
statusValue === "WAITING") &&
status?.retryCount &&
status?.maxRetries && (
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
<div className="font-medium mb-1">
{statusValue === "WAITING"
? t("tunnels.waitingForRetry")
: t("tunnels.retryingConnection")}
</div>
<div>
{t("tunnels.attempt", {
current: status.retryCount,
max: status.maxRetries,
})}
{status.nextRetryIn && (
<span>
{" "}
{" "}
{t("tunnels.nextRetryIn", {
seconds: status.nextRetryIn,
})}
</span>
)}
</div>
</div>
)}
</div>
);
})}
</div>
) : (
<div className="text-center py-4 text-muted-foreground">
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{t("tunnels.noTunnelConnections")}</p>
</div>
)}
</div>
</div>
</Card>
);
}
+2 -19
View File
@@ -1,26 +1,15 @@
import React, { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/button";
import { Card } from "@/components/card";
import { Input } from "@/components/input";
import { Separator } from "@/components/separator";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog";
import {
AlertCircle,
Clock,
Network,
Play,
Plus,
RefreshCw,
Settings,
Square,
Trash2,
Wifi,
WifiOff,
} from "lucide-react";
@@ -407,7 +396,7 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
const connectedCount = tunnels.filter((t, i) => {
if (!sshHost) return false;
const name = tunnelName(sshHost, i, t);
return tunnelStatuses[name]?.connected;
return tunnelStatuses[name]?.status === "connected";
}).length;
return (
@@ -428,12 +417,6 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
</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>
{tunnels.length > 0 ? (
-64
View File
@@ -1,64 +0,0 @@
import React from "react";
import { TunnelObject } from "./TunnelObject.tsx";
import { useTranslation } from "react-i18next";
import type { SSHHost, TunnelStatus } from "@/types/index";
interface SSHTunnelViewerProps {
hosts: SSHHost[];
tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>;
onTunnelAction: (
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => Promise<unknown>;
}
export function TunnelViewer({
hosts = [],
tunnelStatuses = {},
tunnelActions = {},
onTunnelAction,
}: SSHTunnelViewerProps): React.ReactElement {
const { t } = useTranslation();
const activeHost: SSHHost | undefined =
Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
if (
!activeHost ||
!activeHost.tunnelConnections ||
activeHost.tunnelConnections.length === 0
) {
return (
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
<h3 className="text-lg font-semibold text-foreground mb-2">
{t("tunnels.noSshTunnels")}
</h3>
<p className="text-muted-foreground max-w-md">
{t("tunnels.createFirstTunnelMessage")}
</p>
</div>
);
}
return (
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
<div className="min-h-0 flex-1 overflow-auto thin-scrollbar pr-1">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{activeHost.tunnelConnections.map((t, idx) => (
<TunnelObject
key={`tunnel-${activeHost.id}-${idx}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
host={activeHost}
tunnelIndex={idx}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={onTunnelAction}
compact
bare
/>
))}
</div>
</div>
</div>
);
}