feat: initial ui redesign from demo

This commit is contained in:
LukeGus
2026-05-11 01:23:11 -05:00
parent eaa758effe
commit 33dcde0827
349 changed files with 23984 additions and 31634 deletions
+142
View File
@@ -0,0 +1,142 @@
import React, { useEffect, useState } from "react";
import { TabProvider } from "@/shell/TabContext.tsx";
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
import { SidebarProvider } from "@/components/sidebar.tsx";
import { getSSHHosts, getUserInfo } from "@/main-axios.ts";
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";
interface FullScreenAppWrapperProps {
hostId?: string;
children: (hostConfig: SSHHost | null, loading: boolean) => React.ReactNode;
}
export const FullScreenAppWrapper: React.FC<FullScreenAppWrapperProps> = ({
hostId,
children,
}) => {
const [hostConfig, setHostConfig] = useState<SSHHost | null>(null);
const [loading, setLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [authLoading, setAuthLoading] = useState(true);
const [, setIsAdmin] = useState(false);
useEffect(() => {
const handleSessionExpired = () => {
setIsAuthenticated(false);
setIsAdmin(false);
setHostConfig(null);
};
dbHealthMonitor.on("session-expired", handleSessionExpired);
return () => dbHealthMonitor.off("session-expired", handleSessionExpired);
}, []);
useEffect(() => {
const checkAuth = async () => {
try {
const userInfo = await getUserInfo();
if (userInfo) {
setIsAuthenticated(true);
}
} catch {
setIsAuthenticated(false);
} finally {
setAuthLoading(false);
}
};
checkAuth();
}, []);
useEffect(() => {
const fetchHost = async () => {
if (!hostId || !isAuthenticated) {
setLoading(false);
return;
}
try {
const hosts = await getSSHHosts();
const host = hosts.find((h) => h.id === parseInt(hostId, 10));
if (host) {
setHostConfig(host);
}
} catch (error) {
console.error("Failed to fetch host:", error);
} finally {
setLoading(false);
}
};
if (!authLoading && isAuthenticated) {
fetchHost();
}
}, [hostId, isAuthenticated, authLoading]);
const handleAuthSuccess = () => {
setIsAuthenticated(true);
window.location.reload();
};
if (authLoading) {
return (
<div
className="w-full h-screen overflow-hidden flex items-center justify-center"
style={{ backgroundColor: "#18181b" }}
>
<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>
</div>
);
}
if (!isAuthenticated) {
return (
<SidebarProvider>
<TabProvider>
<CommandHistoryProvider>
<div
className="w-full h-screen overflow-hidden flex items-center justify-center"
style={{ backgroundColor: "#18181b" }}
>
<Dashboard
isAuthenticated={false}
authLoading={authLoading}
onAuthSuccess={handleAuthSuccess}
isTopbarOpen={false}
onSelectView={() => {}}
/>
<Toaster
position="bottom-right"
richColors={false}
closeButton
duration={5000}
offset={20}
/>
</div>
</CommandHistoryProvider>
</TabProvider>
</SidebarProvider>
);
}
return (
<SidebarProvider>
<TabProvider>
<CommandHistoryProvider>
<div
className="w-full h-screen overflow-hidden"
style={{ backgroundColor: "#18181b" }}
>
{children(hostConfig, loading)}
</div>
</CommandHistoryProvider>
</TabProvider>
</SidebarProvider>
);
};
+49
View File
@@ -0,0 +1,49 @@
import React from "react";
import { DockerManager } from "@/features/docker/DockerManager.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
interface DockerAppProps {
hostId?: string;
}
const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
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>
);
}
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>
</div>
);
}
return (
<DockerManager
hostConfig={hostConfig}
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
isVisible={true}
isTopbarOpen={false}
embedded={true}
onClose={() => {}}
/>
);
}}
</FullScreenAppWrapper>
);
};
export default DockerApp;
+800
View File
@@ -0,0 +1,800 @@
import React from "react";
import { Separator } from "@/components/separator.tsx";
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 { useTranslation } from "react-i18next";
import type { SSHHost, DockerContainer, DockerValidation } from "@/types";
import {
connectDockerSession,
disconnectDockerSession,
listDockerContainers,
validateDockerAvailability,
keepaliveDockerSession,
verifyDockerTOTP,
verifyDockerWarpgate,
logActivity,
getSSHHosts,
} from "@/main-axios.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { ContainerList } from "./components/ContainerList.tsx";
import { ContainerDetail } from "./components/ContainerDetail.tsx";
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx";
import { useTabsSafe } from "@/shell/TabContext.tsx";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
import type { LogEntry } from "@/types/connection-log.ts";
interface DockerManagerProps {
hostConfig?: SSHHost;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
onClose?: () => void;
}
type ConnectionLogInput = Omit<LogEntry, "id" | "timestamp">;
interface DockerConnectionError {
message?: string;
connectionLogs?: ConnectionLogInput[];
}
function DockerManagerInner({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false,
onClose,
}: DockerManagerProps): React.ReactElement {
const { t } = useTranslation();
const {
addLog,
setLogs,
clearLogs,
isExpanded: isConnectionLogExpanded,
} = useConnectionLog();
const { currentTab, removeTab } = useTabsSafe();
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [sessionId, setSessionId] = React.useState<string | null>(null);
const [containers, setContainers] = React.useState<DockerContainer[]>([]);
const [selectedContainer, setSelectedContainer] = React.useState<
string | null
>(null);
const [isConnecting, setIsConnecting] = React.useState(false);
const [dockerValidation, setDockerValidation] =
React.useState<DockerValidation | null>(null);
const [isValidating, setIsValidating] = React.useState(false);
const [viewMode, setViewMode] = React.useState<"list" | "detail">("list");
const [isLoadingContainers, setIsLoadingContainers] = React.useState(false);
const [totpRequired, setTotpRequired] = React.useState(false);
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
const [warpgateRequired, setWarpgateRequired] = React.useState(false);
const [warpgateSessionId, setWarpgateSessionId] = React.useState<
string | null
>(null);
const [warpgateUrl, setWarpgateUrl] = React.useState<string>("");
const [warpgateSecurityKey, setWarpgateSecurityKey] =
React.useState<string>("");
const [showAuthDialog, setShowAuthDialog] = React.useState(false);
const [authReason, setAuthReason] = React.useState<
"no_keyboard" | "auth_failed" | "timeout"
>("no_keyboard");
const [hasConnectionError, setHasConnectionError] = React.useState(false);
const [search, setSearch] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
const logDockerActivity = async () => {
if (
!currentHostConfig?.id ||
activityLoggedRef.current ||
activityLoggingRef.current
) {
return;
}
activityLoggingRef.current = true;
activityLoggedRef.current = true;
try {
const hostName =
currentHostConfig.name ||
`${currentHostConfig.username}@${currentHostConfig.ip}`;
await logActivity("docker", currentHostConfig.id, hostName);
} catch (err) {
console.warn("Failed to log docker activity:", err);
activityLoggedRef.current = false;
} finally {
activityLoggingRef.current = false;
}
};
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setCurrentHostConfig(hostConfig);
setContainers([]);
setSelectedContainer(null);
setSessionId(null);
setDockerValidation(null);
setViewMode("list");
}
}, [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 initializingRef = React.useRef(false);
React.useEffect(() => {
const initSession = async () => {
if (!currentHostConfig?.id || !currentHostConfig.enableDocker) {
return;
}
if (initializingRef.current) return;
initializingRef.current = true;
if (sessionId) {
initializingRef.current = false;
return;
}
setIsConnecting(true);
setHasConnectionError(false);
clearLogs();
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
const result = await connectDockerSession(sid, currentHostConfig.id, {
useSocks5: currentHostConfig.useSocks5,
socks5Host: currentHostConfig.socks5Host,
socks5Port: currentHostConfig.socks5Port,
socks5Username: currentHostConfig.socks5Username,
socks5Password: currentHostConfig.socks5Password,
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
});
if (result?.requires_warpgate) {
setWarpgateRequired(true);
setWarpgateSessionId(sid);
setWarpgateUrl(result.url || "");
setWarpgateSecurityKey(result.securityKey || "");
setIsConnecting(false);
return;
}
if (result?.requires_totp) {
setTotpRequired(true);
setTotpSessionId(sid);
setTotpPrompt(result.prompt || t("docker.verificationCodePrompt"));
setIsConnecting(false);
return;
}
if (result?.status === "auth_required") {
setShowAuthDialog(true);
setAuthReason(
result.reason === "no_keyboard" ? "no_keyboard" : "auth_failed",
);
setIsConnecting(false);
return;
}
setSessionId(sid);
setIsValidating(true);
const validation = await validateDockerAvailability(sid);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
setHasConnectionError(true);
addLog({
type: "error",
stage: "validation",
message: validation.error || t("docker.error"),
details: validation.code
? `Error code: ${validation.code}`
: undefined,
});
} else {
logDockerActivity();
setTimeout(() => clearLogs(), 1000);
}
} catch (error) {
const dockerError = error as DockerConnectionError;
setIsConnecting(false);
setIsValidating(false);
setHasConnectionError(true);
if (Array.isArray(dockerError.connectionLogs)) {
setLogs(dockerError.connectionLogs);
} else {
addLog({
type: "error",
stage: "connection",
message: dockerError.message || t("docker.connectionFailed"),
});
}
} finally {
setIsConnecting(false);
}
};
initSession();
return () => {
initializingRef.current = false;
if (sessionId) {
disconnectDockerSession(sessionId).catch(() => {
// Silently handle disconnect errors
});
}
};
}, [currentHostConfig?.id, currentHostConfig?.enableDocker]);
React.useEffect(() => {
if (!sessionId || !isVisible) return;
const keepalive = setInterval(
() => {
keepaliveDockerSession(sessionId).catch(() => {
// Silently handle keepalive errors
});
},
10 * 60 * 1000,
);
return () => clearInterval(keepalive);
}, [sessionId, isVisible]);
const refreshContainers = React.useCallback(async () => {
if (!sessionId) return;
try {
const data = await listDockerContainers(sessionId, true);
setContainers(data);
} catch {
// Silently handle polling errors
}
}, [sessionId]);
React.useEffect(() => {
if (!sessionId || !isVisible || !dockerValidation?.available) return;
let cancelled = false;
const pollContainers = async () => {
try {
setIsLoadingContainers(true);
const data = await listDockerContainers(sessionId, true);
if (!cancelled) {
setContainers(data);
}
} catch {
// Silently handle polling errors
} finally {
if (!cancelled) {
setIsLoadingContainers(false);
}
}
};
pollContainers();
const interval = setInterval(pollContainers, 5000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [sessionId, isVisible, dockerValidation?.available]);
const handleBack = React.useCallback(() => {
setViewMode("list");
setSelectedContainer(null);
}, []);
const handleTotpSubmit = async (code: string) => {
if (!totpSessionId || !code) return;
try {
setIsConnecting(true);
const result = await verifyDockerTOTP(totpSessionId, code);
if (result?.status === "success") {
setTotpRequired(false);
setTotpPrompt("");
setSessionId(totpSessionId);
setTotpSessionId(null);
setIsValidating(true);
const validation = await validateDockerAvailability(totpSessionId);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
setHasConnectionError(true);
addLog({
type: "error",
stage: "validation",
message: validation.error || t("docker.error"),
details: validation.code
? `Error code: ${validation.code}`
: undefined,
});
} else {
logDockerActivity();
}
}
} catch (error) {
console.error("TOTP verification failed:", error);
setHasConnectionError(true);
addLog({
type: "error",
stage: "auth",
message: t("docker.totpVerificationFailed"),
});
} finally {
setIsConnecting(false);
}
};
const handleTotpCancel = () => {
setTotpRequired(false);
setTotpSessionId(null);
setTotpPrompt("");
setIsConnecting(false);
if (currentTab !== null) {
removeTab(currentTab);
}
};
const handleWarpgateContinue = async () => {
if (!warpgateSessionId) return;
try {
setIsConnecting(true);
const result = await verifyDockerWarpgate(warpgateSessionId);
if (result?.status === "success") {
setWarpgateRequired(false);
setWarpgateUrl("");
setWarpgateSecurityKey("");
setSessionId(warpgateSessionId);
setWarpgateSessionId(null);
setIsValidating(true);
const validation = await validateDockerAvailability(warpgateSessionId);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
setHasConnectionError(true);
addLog({
type: "error",
stage: "validation",
message: validation.error || t("docker.error"),
details: validation.code
? `Error code: ${validation.code}`
: undefined,
});
} else {
logDockerActivity();
}
}
} catch (error) {
console.error("Warpgate verification failed:", error);
setHasConnectionError(true);
addLog({
type: "error",
stage: "auth",
message: t("docker.warpgateVerificationFailed"),
});
} finally {
setIsConnecting(false);
}
};
const handleWarpgateCancel = () => {
setWarpgateRequired(false);
setWarpgateSessionId(null);
setWarpgateUrl("");
setWarpgateSecurityKey("");
setIsConnecting(false);
if (currentTab !== null) {
removeTab(currentTab);
}
};
const handleWarpgateOpenUrl = () => {
if (warpgateUrl) {
window.open(warpgateUrl, "_blank", "noopener,noreferrer");
}
};
const handleAuthSubmit = async (credentials: {
password?: string;
sshKey?: string;
keyPassword?: string;
}) => {
if (!currentHostConfig?.id) return;
setShowAuthDialog(false);
setIsConnecting(true);
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
try {
const result = await connectDockerSession(sid, currentHostConfig.id, {
userProvidedPassword: credentials.password,
userProvidedSshKey: credentials.sshKey,
userProvidedKeyPassword: credentials.keyPassword,
useSocks5: currentHostConfig.useSocks5,
socks5Host: currentHostConfig.socks5Host,
socks5Port: currentHostConfig.socks5Port,
socks5Username: currentHostConfig.socks5Username,
socks5Password: currentHostConfig.socks5Password,
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
});
if (result?.requires_warpgate) {
setWarpgateRequired(true);
setWarpgateSessionId(sid);
setWarpgateUrl(result.url || "");
setWarpgateSecurityKey(result.securityKey || "N/A");
setIsConnecting(false);
return;
}
if (result?.requires_totp) {
setTotpRequired(true);
setTotpSessionId(sid);
setTotpPrompt(result.prompt || t("docker.verificationCodePrompt"));
setIsConnecting(false);
return;
}
if (result?.status === "auth_required") {
setShowAuthDialog(true);
setAuthReason("auth_failed");
setIsConnecting(false);
return;
}
setSessionId(sid);
setIsValidating(true);
const validation = await validateDockerAvailability(sid);
setDockerValidation(validation);
setIsValidating(false);
if (!validation.available) {
setHasConnectionError(true);
} else {
logDockerActivity();
}
} catch (error) {
setIsConnecting(false);
setIsValidating(false);
setHasConnectionError(true);
addLog({
type: "error",
stage: "connection",
message: error?.message || t("docker.connectionFailed"),
});
} finally {
setIsConnecting(false);
}
};
const handleAuthCancel = () => {
setShowAuthDialog(false);
setIsConnecting(false);
onClose?.();
};
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";
if (!currentHostConfig?.enableDocker) {
return (
<div style={wrapperStyle} className={`${containerClass} relative`}>
<div className="h-full w-full flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex-1 overflow-y-auto px-3 py-3 flex flex-col gap-3">
<Card className="flex-row items-center justify-between px-3 py-3 shrink-0 gap-0">
<div className="flex items-center gap-3">
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
<Box className="size-5 text-accent-brand" />
</div>
<div>
<h1 className="text-2xl font-bold">{title}</h1>
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{t("docker.manager")}
</span>
</div>
</div>
</Card>
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{t("docker.notEnabled")}</AlertDescription>
</Alert>
</div>
</div>
</div>
);
}
if (isConnecting || isValidating) {
return (
<div style={wrapperStyle} className={`${containerClass} relative`}>
<div className="h-full w-full flex flex-col">
<div className="flex-1 overflow-hidden min-h-0 relative">
<SimpleLoader
visible={!isConnectionLogExpanded}
message={
isValidating ? t("docker.validating") : t("docker.connecting")
}
/>
</div>
</div>
<ConnectionLog
isConnecting={isConnecting}
isConnected={!!sessionId && !!dockerValidation?.available}
hasConnectionError={hasConnectionError}
position={hasConnectionError ? "top" : "bottom"}
/>
</div>
);
}
if (dockerValidation && !dockerValidation.available) {
return (
<div style={wrapperStyle} className={`${containerClass} relative`}>
<ConnectionLog
isConnecting={isConnecting}
isConnected={!!sessionId && !!dockerValidation?.available}
hasConnectionError={
hasConnectionError ||
(!!dockerValidation && !dockerValidation.available)
}
position={
hasConnectionError ||
(!!dockerValidation && !dockerValidation.available)
? "top"
: "bottom"
}
/>
</div>
);
}
return (
<div style={wrapperStyle} className={`${containerClass} relative`}>
<div
className="h-full w-full flex flex-col flex-1 min-h-0 overflow-hidden"
style={{
visibility:
(hasConnectionError ||
(!!dockerValidation && !dockerValidation.available)) &&
isConnectionLogExpanded
? "hidden"
: "visible",
}}
>
{viewMode === "detail" &&
sessionId &&
selectedContainer &&
currentHostConfig ? (
<ContainerDetail
sessionId={sessionId}
containerId={selectedContainer}
containers={containers}
hostConfig={currentHostConfig}
onBack={handleBack}
/>
) : (
<div className="flex-1 overflow-y-auto px-3 py-3 flex flex-col gap-3">
<Card className="flex-row items-center justify-between px-3 py-3 shrink-0 gap-0">
<div className="flex items-center gap-3">
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
<Box className="size-5 text-accent-brand" />
</div>
<div>
<h1 className="text-2xl font-bold">{title}</h1>
<div className="flex items-center gap-2">
<span className="size-2 rounded-full bg-accent-brand" />
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{dockerValidation?.version
? t("docker.version", {
version: dockerValidation.version,
})
: t("docker.manager")}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative w-56">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input
placeholder={t("docker.searchPlaceholder")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 h-8"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="h-8 px-2 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
>
<option value="all">{t("docker.allStatuses")}</option>
<option value="running">{t("docker.stateRunning")}</option>
<option value="paused">{t("docker.statePaused")}</option>
<option value="exited">{t("docker.stateExited")}</option>
<option value="restarting">
{t("docker.stateRestarting")}
</option>
</select>
<Separator orientation="vertical" className="h-8 mx-1" />
<Button
variant="ghost"
size="icon"
onClick={refreshContainers}
disabled={isLoadingContainers}
>
<RefreshCw
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>
{sessionId ? (
isLoadingContainers && containers.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full opacity-40 py-20">
<RefreshCw className="size-8 animate-spin mb-4" />
<span className="text-sm font-semibold">
{t("docker.loadingContainers")}
</span>
</div>
) : (
<ContainerList
containers={containers}
sessionId={sessionId}
onSelectContainer={(id) => {
setSelectedContainer(id);
setViewMode("detail");
}}
selectedContainerId={selectedContainer}
onRefresh={refreshContainers}
search={search}
statusFilter={statusFilter}
/>
)
) : null}
</div>
)}
</div>
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel}
/>
<WarpgateDialog
isOpen={warpgateRequired}
url={warpgateUrl}
securityKey={warpgateSecurityKey}
onContinue={handleWarpgateContinue}
onCancel={handleWarpgateCancel}
onOpenUrl={handleWarpgateOpenUrl}
/>
{currentHostConfig && (
<SSHAuthDialog
isOpen={showAuthDialog}
reason={authReason}
onSubmit={handleAuthSubmit}
onCancel={handleAuthCancel}
hostInfo={{
ip: currentHostConfig.ip,
port: currentHostConfig.port,
username: currentHostConfig.username,
name: currentHostConfig.name,
}}
/>
)}
<SimpleLoader
visible={isConnecting && !isConnectionLogExpanded}
message={t("docker.connecting")}
/>
<ConnectionLog
isConnecting={isConnecting}
isConnected={!!sessionId && !!dockerValidation?.available}
hasConnectionError={
hasConnectionError ||
(!!dockerValidation && !dockerValidation.available)
}
position={
hasConnectionError ||
(!!dockerValidation && !dockerValidation.available)
? "top"
: "bottom"
}
/>
</div>
);
}
export function DockerManager(props: DockerManagerProps): React.ReactElement {
return (
<ConnectionLogProvider>
<DockerManagerInner {...props} />
</ConnectionLogProvider>
);
}
@@ -0,0 +1,537 @@
import React from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { RobustClipboardProvider } from "@/lib/clipboard-provider";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { Button } from "@/components/button.tsx";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/select.tsx";
import { Card, CardContent } from "@/components/card.tsx";
import { getBasePath } from "@/lib/base-path";
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
import { toast } from "sonner";
import type { SSHHost } from "@/types";
import { isElectron } from "@/main-axios.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { useTranslation } from "react-i18next";
interface ConsoleTerminalProps {
containerId: string;
containerName: string;
containerState: string;
hostConfig: SSHHost;
}
export function ConsoleTerminal({
containerId,
containerName,
containerState,
hostConfig,
}: ConsoleTerminalProps): React.ReactElement {
const { t } = useTranslation();
const { instance: terminal, ref: xtermRef } = useXTerm();
const [isConnected, setIsConnected] = React.useState(false);
const [isConnecting, setIsConnecting] = React.useState(false);
const [selectedShell, setSelectedShell] = React.useState<string>("bash");
const wsRef = React.useRef<WebSocket | null>(null);
const fitAddonRef = React.useRef<FitAddon | null>(null);
const pingIntervalRef = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
if (!terminal) return;
const fitAddon = new FitAddon();
const clipboardProvider = new RobustClipboardProvider();
const clipboardAddon = new ClipboardAddon(undefined, clipboardProvider);
const webLinksAddon = new WebLinksAddon();
fitAddonRef.current = fitAddon;
terminal.loadAddon(fitAddon);
terminal.loadAddon(clipboardAddon);
terminal.loadAddon(webLinksAddon);
terminal.options.cursorBlink = true;
terminal.options.fontSize = 14;
terminal.options.fontFamily = "monospace";
const readTextFromClipboard = async (): Promise<string> => {
if (window.electronClipboard) {
return window.electronClipboard.readText();
}
return navigator.clipboard.readText();
};
const writeTextToClipboard = async (text: string): Promise<void> => {
if (window.electronClipboard) {
await window.electronClipboard.writeText(text);
return;
}
await navigator.clipboard.writeText(text);
};
terminal.attachCustomKeyEventHandler((e: KeyboardEvent): boolean => {
if (e.type !== "keydown") return true;
if (
((e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) ||
(e.metaKey && !e.shiftKey && !e.ctrlKey && !e.altKey)) &&
e.key.toLowerCase() === "v"
) {
e.preventDefault();
e.stopPropagation();
readTextFromClipboard()
.then((text) => {
if (text) terminal.paste(text);
})
.catch(() => {
toast.error(t("terminal.clipboardReadFailed"));
});
return false;
}
if (
e.ctrlKey &&
!e.shiftKey &&
!e.altKey &&
!e.metaKey &&
e.key.toLowerCase() === "c" &&
terminal.hasSelection()
) {
e.preventDefault();
e.stopPropagation();
const selection = terminal.getSelection();
if (selection) {
writeTextToClipboard(selection).catch(() => {
toast.error(t("terminal.clipboardWriteFailed"));
});
terminal.clearSelection();
}
return false;
}
if (
e.ctrlKey &&
!e.shiftKey &&
!e.altKey &&
!e.metaKey &&
e.key === "Insert" &&
terminal.hasSelection()
) {
e.preventDefault();
e.stopPropagation();
const selection = terminal.getSelection();
if (selection) {
writeTextToClipboard(selection).catch(() => {
toast.error(t("terminal.clipboardWriteFailed"));
});
}
return false;
}
if (
e.shiftKey &&
!e.ctrlKey &&
!e.altKey &&
!e.metaKey &&
e.key === "Insert"
) {
e.preventDefault();
e.stopPropagation();
readTextFromClipboard()
.then((text) => {
if (text) terminal.paste(text);
})
.catch(() => {
toast.error(t("terminal.clipboardReadFailed"));
});
return false;
}
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)",
};
setTimeout(() => {
fitAddon.fit();
}, 100);
const resizeHandler = () => {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const { rows, cols } = terminal;
wsRef.current.send(
JSON.stringify({
type: "resize",
data: { rows, cols },
}),
);
}
}
};
window.addEventListener("resize", resizeHandler);
return () => {
window.removeEventListener("resize", resizeHandler);
clipboardProvider.dispose();
if (wsRef.current) {
try {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch {
// Best-effort disconnect during cleanup.
}
wsRef.current.close();
wsRef.current = null;
}
terminal.dispose();
};
}, [terminal, t]);
const disconnect = React.useCallback(() => {
if (wsRef.current) {
try {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch {
// Best-effort disconnect.
}
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
if (terminal) {
try {
terminal.clear();
} catch {
// Terminal clear can fail after disposal.
}
}
}, [terminal]);
const connect = React.useCallback(() => {
if (!terminal || containerState !== "running") {
toast.error(t("docker.containerMustBeRunning"));
return;
}
setIsConnecting(true);
try {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
}
const isElectronApp = isElectron();
const isDev =
!isElectronApp &&
process.env.NODE_ENV === "development" &&
(window.location.port === "3000" ||
window.location.port === "5173" ||
window.location.port === "");
const baseWsUrl = isDev
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30009`
: isElectronApp
? (() => {
const baseUrl =
(window as { configuredServerUrl?: string })
.configuredServerUrl || "http://127.0.0.1:30001";
const wsProtocol = baseUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = baseUrl.replace(/^https?:\/\//, "");
return `${wsProtocol}${wsHost}/docker/console/`;
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}${getBasePath()}/docker/console/`;
const ws = new WebSocket(baseWsUrl);
ws.onopen = () => {
const cols = terminal.cols || 80;
const rows = terminal.rows || 24;
ws.send(
JSON.stringify({
type: "connect",
data: {
hostConfig,
containerId,
shell: selectedShell,
cols,
rows,
},
}),
);
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
switch (msg.type) {
case "output":
terminal.write(msg.data);
break;
case "connected":
setIsConnected(true);
setIsConnecting(false);
if (msg.data?.shellChanged) {
toast.warning(
`Shell "${msg.data.requestedShell}" not available. Using "${msg.data.shell}" instead.`,
);
} else {
toast.success(t("docker.connectedTo", { containerName }));
}
setTimeout(() => {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
}
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "resize",
data: { rows: terminal.rows, cols: terminal.cols },
}),
);
}
}, 100);
break;
case "disconnected":
setIsConnected(false);
setIsConnecting(false);
terminal.write(
`\r\n\x1b[1;33m${msg.message || t("docker.disconnected")}\x1b[0m\r\n`,
);
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
break;
case "error":
setIsConnecting(false);
toast.error(msg.message || t("docker.consoleError"));
terminal.write(
`\r\n\x1b[1;31m${t("docker.errorMessage", { message: msg.message })}\x1b[0m\r\n`,
);
break;
}
} catch (error) {
console.error("Failed to parse WebSocket message:", error);
}
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
setIsConnecting(false);
setIsConnected(false);
toast.error(t("docker.failedToConnect"));
};
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
}
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }));
}
}, 30000);
ws.onclose = () => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
setIsConnected(false);
setIsConnecting(false);
if (wsRef.current === ws) {
wsRef.current = null;
}
};
wsRef.current = ws;
terminal.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: "input",
data,
}),
);
}
});
} catch (error) {
setIsConnecting(false);
toast.error(
`Failed to connect: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}, [
terminal,
containerState,
hostConfig,
containerId,
selectedShell,
containerName,
t,
]);
React.useEffect(() => {
return () => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
if (wsRef.current) {
try {
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
} catch {
// Best-effort disconnect during cleanup.
}
wsRef.current.close();
wsRef.current = null;
}
setIsConnected(false);
};
}, []);
if (containerState !== "running") {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
<p className="text-muted-foreground text-lg">
{t("docker.containerNotRunning")}
</p>
<p className="text-muted-foreground text-sm">
{t("docker.startContainerToAccess")}
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full gap-3">
<Card className="py-3">
<CardContent className="px-3">
<div className="flex flex-col sm:flex-row gap-2 items-center sm:items-center">
<div className="flex items-center gap-2 flex-1">
<TerminalIcon className="h-5 w-5" />
<span className="text-base font-medium">
{t("docker.console")}
</span>
</div>
<Select
value={selectedShell}
onValueChange={setSelectedShell}
disabled={isConnected}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder={t("docker.selectShell")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="bash">{t("docker.bash")}</SelectItem>
<SelectItem value="sh">{t("docker.sh")}</SelectItem>
<SelectItem value="ash">{t("docker.ash")}</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2 sm:gap-2">
{!isConnected ? (
<Button
onClick={connect}
disabled={isConnecting}
className="min-w-[120px]"
>
{isConnecting ? (
<>
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
{t("docker.connecting")}
</>
) : (
<>
<Power className="h-4 w-4 mr-2" />
{t("docker.connect")}
</>
)}
</Button>
) : (
<Button
onClick={disconnect}
variant="destructive"
className="min-w-[120px]"
>
<PowerOff className="h-4 w-4 mr-2" />
{t("docker.disconnect")}
</Button>
)}
</div>
</div>
</CardContent>
</Card>
<Card className="flex-1 overflow-hidden pt-1 pb-0">
<CardContent className="p-0 h-full relative">
<div
ref={xtermRef}
className="h-full w-full"
style={{ display: isConnected ? "block" : "none" }}
/>
{!isConnected && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center space-y-2">
<TerminalIcon className="h-12 w-12 text-muted-foreground/50 mx-auto" />
<p className="text-muted-foreground">
{t("docker.notConnected")}
</p>
<p className="text-muted-foreground text-sm">
{t("docker.clickToConnect")}
</p>
</div>
</div>
)}
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<SimpleLoader size="lg" />
<p className="text-muted-foreground mt-4">
{t("docker.connectingTo", { containerName })}
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,311 @@
import React from "react";
import { Card } from "@/components/card.tsx";
import { Button } from "@/components/button.tsx";
import {
Box,
Play,
Square,
RotateCw,
Pause,
PlayCircle,
Trash2,
RefreshCw,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type { DockerContainer } from "@/types";
import {
startDockerContainer,
stopDockerContainer,
restartDockerContainer,
pauseDockerContainer,
unpauseDockerContainer,
removeDockerContainer,
} from "@/main-axios.ts";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
interface ContainerCardProps {
container: DockerContainer;
sessionId: string;
onSelect?: () => void;
isSelected?: boolean;
onRefresh?: () => void;
}
export function DockerBadge({ state }: { state: DockerContainer["state"] }) {
let colorClass = "border-border text-muted-foreground";
if (state === "running")
colorClass = "border-accent-brand/40 text-accent-brand bg-accent-brand/10";
if (state === "paused")
colorClass = "border-yellow-500/40 text-yellow-500 bg-yellow-500/10";
if (state === "exited")
colorClass = "border-destructive/40 text-destructive bg-destructive/5";
if (state === "restarting")
colorClass = "border-blue-400/40 text-blue-400 bg-blue-400/10";
return (
<span
className={`text-[10px] font-bold px-1.5 py-0.5 border uppercase tracking-wider ${colorClass}`}
>
{state}
</span>
);
}
export function ContainerCard({
container,
sessionId,
onSelect,
isSelected = false,
onRefresh,
}: ContainerCardProps): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [isStarting, setIsStarting] = React.useState(false);
const [isStopping, setIsStopping] = React.useState(false);
const [isRestarting, setIsRestarting] = React.useState(false);
const [isPausing, setIsPausing] = React.useState(false);
const [isRemoving, setIsRemoving] = React.useState(false);
const isLoading =
isStarting || isStopping || isRestarting || isPausing || isRemoving;
const containerName = container.name.startsWith("/")
? container.name.slice(1)
: container.name;
const portsList = (container.ports ?? "")
.split(",")
.map((p) => p.trim())
.filter(Boolean);
const handleStart = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsStarting(true);
try {
await startDockerContainer(sessionId, container.id);
toast.success(t("docker.containerStarted", { name: containerName }));
onRefresh?.();
} catch (err) {
toast.error(
t("docker.failedToStartContainer", {
error: err instanceof Error ? err.message : "Unknown error",
}),
);
} finally {
setIsStarting(false);
}
};
const handleStop = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsStopping(true);
try {
await stopDockerContainer(sessionId, container.id);
toast.success(t("docker.containerStopped", { name: containerName }));
onRefresh?.();
} catch (err) {
toast.error(
t("docker.failedToStopContainer", {
error: err instanceof Error ? err.message : "Unknown error",
}),
);
} finally {
setIsStopping(false);
}
};
const handleRestart = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsRestarting(true);
try {
await restartDockerContainer(sessionId, container.id);
toast.success(t("docker.containerRestarted", { name: containerName }));
onRefresh?.();
} catch (err) {
toast.error(
t("docker.failedToRestartContainer", {
error: err instanceof Error ? err.message : "Unknown error",
}),
);
} finally {
setIsRestarting(false);
}
};
const handlePause = async (e: React.MouseEvent) => {
e.stopPropagation();
setIsPausing(true);
try {
if (container.state === "paused") {
await unpauseDockerContainer(sessionId, container.id);
toast.success(t("docker.containerUnpaused", { name: containerName }));
} else {
await pauseDockerContainer(sessionId, container.id);
toast.success(t("docker.containerPaused", { name: containerName }));
}
onRefresh?.();
} catch (err) {
toast.error(
t("docker.failedToTogglePauseContainer", {
action: container.state === "paused" ? "unpause" : "pause",
error: err instanceof Error ? err.message : "Unknown error",
}),
);
} finally {
setIsPausing(false);
}
};
const handleRemove = async (e: React.MouseEvent) => {
e.stopPropagation();
confirmWithToast(
t("docker.confirmRemoveContainer", { name: containerName }) +
(container.state === "running"
? " " + t("docker.runningContainerWarning")
: ""),
async () => {
setIsRemoving(true);
try {
await removeDockerContainer(
sessionId,
container.id,
container.state === "running",
);
toast.success(t("docker.containerRemoved", { name: containerName }));
onRefresh?.();
} catch (err) {
toast.error(
t("docker.failedToRemoveContainer", {
error: err instanceof Error ? err.message : "Unknown error",
}),
);
} finally {
setIsRemoving(false);
}
},
t("common.remove"),
t("common.cancel"),
);
};
return (
<Card
className={`flex flex-col overflow-hidden p-0 gap-0 group hover:border-accent-brand/40 transition-colors cursor-pointer ${isSelected ? "border-accent-brand/60 bg-accent-brand/5" : ""}`}
onClick={onSelect}
>
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-muted/10">
<div className="flex items-center gap-2 min-w-0">
<Box
className={`size-3.5 ${container.state === "running" ? "text-accent-brand" : "text-muted-foreground"}`}
/>
<span className="text-sm font-bold truncate">{containerName}</span>
</div>
<DockerBadge state={container.state} />
</div>
<div className="px-4 py-3 flex flex-col gap-2">
<div className="flex flex-col gap-0.5">
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">
{t("docker.image")}
</span>
<span className="text-xs font-mono truncate text-foreground/80">
{container.image}
</span>
</div>
<div className="flex flex-col gap-0.5 mt-1">
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">
{t("docker.ports")}
</span>
<div className="flex flex-wrap gap-1 mt-0.5">
{portsList.length > 0 ? (
portsList.slice(0, 3).map((p) => (
<span
key={p}
className="text-[10px] font-mono px-1 border border-border bg-muted/30"
>
{p}
</span>
))
) : (
<span className="text-[10px] text-muted-foreground italic">
{t("docker.noPorts")}
</span>
)}
</div>
</div>
</div>
<div className="px-4 py-2 border-t border-border bg-muted/5 flex items-center justify-between opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[10px] text-muted-foreground italic">
{container.id.substring(0, 12)}
</span>
<div className="flex items-center gap-1">
{container.state !== "running" && (
<Button
variant="ghost"
size="icon-xs"
className="text-accent-brand"
disabled={isLoading}
onClick={handleStart}
>
{isStarting ? (
<RefreshCw className="size-3 animate-spin" />
) : (
<Play className="size-3" />
)}
</Button>
)}
{container.state === "running" && (
<Button
variant="ghost"
size="icon-xs"
className="text-destructive"
disabled={isLoading}
onClick={handleStop}
>
{isStopping ? (
<RefreshCw className="size-3 animate-spin" />
) : (
<Square className="size-3" />
)}
</Button>
)}
{(container.state === "running" || container.state === "paused") && (
<Button
variant="ghost"
size="icon-xs"
disabled={isLoading}
onClick={handlePause}
>
{isPausing ? (
<RefreshCw className="size-3 animate-spin" />
) : container.state === "paused" ? (
<PlayCircle className="size-3" />
) : (
<Pause className="size-3" />
)}
</Button>
)}
<Button
variant="ghost"
size="icon-xs"
disabled={isLoading || container.state === "exited"}
onClick={handleRestart}
>
{isRestarting ? (
<RefreshCw className="size-3 animate-spin" />
) : (
<RotateCw className="size-3" />
)}
</Button>
<Button
variant="ghost"
size="icon-xs"
className="text-destructive"
disabled={isLoading}
onClick={handleRemove}
>
<Trash2 className="size-3" />
</Button>
</div>
</div>
</Card>
);
}
@@ -0,0 +1,161 @@
import React from "react";
import { Button } from "@/components/button.tsx";
import { Card } from "@/components/card.tsx";
import { Separator } from "@/components/separator.tsx";
import {
Activity,
ArrowLeft,
Box,
Info,
List,
Settings,
Terminal,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { DockerContainer, SSHHost } from "@/types";
import { LogViewer } from "./LogViewer.tsx";
import { ContainerStats } from "./ContainerStats.tsx";
import { ConsoleTerminal } from "./ConsoleTerminal.tsx";
import { DockerBadge } from "./ContainerCard.tsx";
interface ContainerDetailProps {
sessionId: string;
containerId: string;
containers: DockerContainer[];
hostConfig: SSHHost;
onBack: () => void;
}
type DetailTab = "logs" | "stats" | "console";
export function ContainerDetail({
sessionId,
containerId,
containers,
hostConfig,
onBack,
}: ContainerDetailProps): React.ReactElement {
const { t } = useTranslation();
const [activeTab, setActiveTab] = React.useState<DetailTab>("logs");
const container = containers.find((c) => c.id === containerId);
const containerName = container
? container.name.startsWith("/")
? container.name.slice(1)
: container.name
: "";
if (!container) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center flex flex-col gap-3">
<p className="text-muted-foreground">
{t("docker.containerNotFound")}
</p>
<Button onClick={onBack} variant="outline" size="sm">
<ArrowLeft className="size-4 mr-2" />
{t("docker.backToList")}
</Button>
</div>
</div>
);
}
const tabs: { id: DetailTab; label: string; icon: React.ReactNode }[] = [
{
id: "logs",
label: t("docker.logs"),
icon: <List className="size-3.5" />,
},
{
id: "stats",
label: t("docker.stats"),
icon: <Activity className="size-3.5" />,
},
{
id: "console",
label: t("docker.consoleTab"),
icon: <Terminal className="size-3.5" />,
},
];
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex flex-col flex-1 min-h-0 overflow-y-auto px-3 py-3 gap-3">
<Card className="flex-row items-center justify-between px-3 py-3 shrink-0 gap-0">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="size-8 text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-4" />
</Button>
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
<Box className="size-5 text-accent-brand" />
</div>
<div>
<h1 className="text-2xl font-bold">{containerName}</h1>
<span className="text-xs text-muted-foreground font-mono">
{container.image}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<DockerBadge state={container.state} />
<Separator orientation="vertical" className="h-8 mx-2" />
<Button variant="ghost" size="icon">
<Settings className="size-4 text-accent-brand" />
</Button>
</div>
</Card>
<div className="flex flex-col flex-1 min-h-0 gap-3">
<div className="flex gap-1 border-b border-border shrink-0">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 text-sm font-semibold border-b-2 transition-colors ${
activeTab === tab.id
? "border-b-accent-brand text-foreground bg-accent-brand/5"
: "border-b-transparent text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
<div className="flex-1 min-h-0 flex flex-col">
{activeTab === "logs" && (
<LogViewer
sessionId={sessionId}
containerId={containerId}
containerName={containerName}
/>
)}
{activeTab === "stats" && (
<ContainerStats
sessionId={sessionId}
containerId={containerId}
containerName={containerName}
containerState={container.state}
/>
)}
{activeTab === "console" && (
<ConsoleTerminal
containerId={containerId}
containerName={containerName}
containerState={container.state}
hostConfig={hostConfig}
/>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,83 @@
import React from "react";
import { Box } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { DockerContainer } from "@/types";
import { ContainerCard } from "./ContainerCard.tsx";
interface ContainerListProps {
containers: DockerContainer[];
sessionId: string;
onSelectContainer: (containerId: string) => void;
selectedContainerId?: string | null;
onRefresh?: () => void;
search?: string;
statusFilter?: string;
}
export function ContainerList({
containers,
sessionId,
onSelectContainer,
selectedContainerId = null,
onRefresh,
search = "",
statusFilter = "all",
}: ContainerListProps): React.ReactElement {
const { t } = useTranslation();
const filtered = React.useMemo(() => {
return containers.filter((c) => {
const name = c.name.startsWith("/") ? c.name.slice(1) : c.name;
const matchesSearch =
name.toLowerCase().includes(search.toLowerCase()) ||
c.image.toLowerCase().includes(search.toLowerCase()) ||
c.id.toLowerCase().includes(search.toLowerCase());
return (
matchesSearch && (statusFilter === "all" || c.state === statusFilter)
);
});
}, [containers, search, statusFilter]);
if (containers.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
<Box className="size-16 mb-4" />
<span className="text-xl font-bold uppercase tracking-widest">
{t("docker.noContainersFound")}
</span>
<span className="text-xs font-semibold">
{t("docker.noContainersFoundHint")}
</span>
</div>
);
}
if (filtered.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
<Box className="size-16 mb-4" />
<span className="text-xl font-bold uppercase tracking-widest">
{t("docker.noContainersMatchFilters")}
</span>
<span className="text-xs font-semibold">
{t("docker.noContainersMatchFiltersHint")}
</span>
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{filtered.map((container) => (
<ContainerCard
key={container.id}
container={container}
sessionId={sessionId}
onSelect={() => onSelectContainer(container.id)}
isSelected={selectedContainerId === container.id}
onRefresh={onRefresh}
/>
))}
</div>
);
}
@@ -0,0 +1,240 @@
import React from "react";
import {
Activity,
Cpu,
HardDrive,
Info,
MemoryStick,
Network,
RefreshCw,
} from "lucide-react";
import type { DockerStats } from "@/types";
import { getContainerStats } from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
import { SectionCard } from "@/components/section-card";
import { DockerBadge } from "./ContainerCard.tsx";
interface ContainerStatsProps {
sessionId: string;
containerId: string;
containerName: string;
containerState: string;
}
export function ContainerStats({
sessionId,
containerId,
containerName,
containerState,
}: ContainerStatsProps): React.ReactElement {
const { t } = useTranslation();
const [stats, setStats] = React.useState<DockerStats | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const fetchStats = React.useCallback(async () => {
if (containerState !== "running") return;
setIsLoading(true);
setError(null);
try {
const data = await getContainerStats(sessionId, containerId);
setStats(data);
} catch (err) {
setError(
err instanceof Error ? err.message : t("docker.failedToFetchStats"),
);
} finally {
setIsLoading(false);
}
}, [sessionId, containerId, containerState]);
React.useEffect(() => {
fetchStats();
const interval = setInterval(fetchStats, 2000);
return () => clearInterval(interval);
}, [fetchStats]);
if (containerState !== "running") {
return (
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
<Activity className="size-16 mb-4" />
<span className="text-xl font-bold uppercase tracking-widest">
{t("docker.containerNotRunning")}
</span>
<span className="text-xs font-semibold">
{t("docker.startContainerToViewStats")}
</span>
</div>
);
}
if (isLoading && !stats) {
return (
<div className="flex flex-col items-center justify-center h-full opacity-40 py-20">
<RefreshCw className="size-8 animate-spin mb-4" />
<span className="text-sm font-semibold">
{t("docker.loadingStats")}
</span>
</div>
);
}
if (error) {
return (
<div className="flex flex-col items-center justify-center h-full opacity-40 py-20">
<Activity className="size-8 mb-4" />
<span className="text-sm font-semibold text-destructive">
{t("docker.errorLoadingStats")}
</span>
<span className="text-xs text-muted-foreground mt-1">{error}</span>
</div>
);
}
if (!stats) {
return (
<div className="flex flex-col items-center justify-center h-full opacity-20 py-20">
<span className="text-sm font-semibold">
{t("docker.noStatsAvailable")}
</span>
</div>
);
}
const cpuPercent = parseFloat(stats.cpu) || 0;
const memPercent = parseFloat(stats.memoryPercent) || 0;
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<SectionCard
title={t("docker.cpuUsage")}
icon={<Cpu className="size-3.5" />}
>
<div className="flex flex-col gap-3 py-2">
<div className="flex items-end justify-between">
<span className="text-3xl font-bold text-accent-brand">
{stats.cpu}
</span>
<span className="text-[10px] text-muted-foreground uppercase font-bold">
{t("docker.current")}
</span>
</div>
<div className="h-1.5 bg-muted w-full overflow-hidden">
<div
className="h-full bg-accent-brand transition-all duration-500"
style={{ width: `${Math.min(cpuPercent, 100)}%` }}
/>
</div>
</div>
</SectionCard>
<SectionCard
title={t("docker.memoryUsage")}
icon={<MemoryStick className="size-3.5" />}
>
<div className="flex flex-col gap-3 py-2">
<div className="flex items-end justify-between">
<span className="text-3xl font-bold text-accent-brand">
{stats.memoryPercent}
</span>
<span className="text-[10px] text-muted-foreground uppercase font-bold">
{stats.memoryUsed} / {stats.memoryLimit}
</span>
</div>
<div className="h-1.5 bg-muted w-full overflow-hidden">
<div
className="h-full bg-accent-brand transition-all duration-500"
style={{ width: `${Math.min(memPercent, 100)}%` }}
/>
</div>
</div>
</SectionCard>
<SectionCard
title={t("docker.networkIo")}
icon={<Network className="size-3.5" />}
>
<div className="flex flex-col gap-1 py-1">
<div className="flex justify-between items-center py-1">
<span className="text-xs text-muted-foreground font-semibold">
{t("docker.input")}
</span>
<span className="text-sm font-mono font-bold">
{stats.netInput}
</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-xs text-muted-foreground font-semibold">
{t("docker.output")}
</span>
<span className="text-sm font-mono font-bold text-accent-brand">
{stats.netOutput}
</span>
</div>
</div>
</SectionCard>
<SectionCard
title={t("docker.blockIo")}
icon={<HardDrive className="size-3.5" />}
>
<div className="flex flex-col gap-1 py-1">
<div className="flex justify-between items-center py-1">
<span className="text-xs text-muted-foreground font-semibold">
{t("docker.read")}
</span>
<span className="text-sm font-mono font-bold">
{stats.blockRead}
</span>
</div>
<div className="flex justify-between items-center py-1">
<span className="text-xs text-muted-foreground font-semibold">
{t("docker.write")}
</span>
<span className="text-sm font-mono font-bold text-accent-brand">
{stats.blockWrite}
</span>
</div>
{stats.pids && (
<div className="flex justify-between items-center py-1">
<span className="text-xs text-muted-foreground font-semibold">
{t("docker.pids")}
</span>
<span className="text-sm font-mono font-bold">{stats.pids}</span>
</div>
)}
</div>
</SectionCard>
<SectionCard
title={t("docker.containerInformation")}
icon={<Info className="size-3.5" />}
>
<div className="flex flex-col gap-1.5 py-1">
<div className="flex justify-between items-center text-xs py-1 border-b border-border/50">
<span className="text-muted-foreground font-semibold">
{t("docker.name")}
</span>
<span className="font-mono">{containerName}</span>
</div>
<div className="flex justify-between items-center text-xs py-1 border-b border-border/50">
<span className="text-muted-foreground font-semibold">
{t("docker.id")}
</span>
<span className="font-mono">{containerId.substring(0, 12)}</span>
</div>
<div className="flex justify-between items-center text-xs py-1">
<span className="text-muted-foreground font-semibold">
{t("docker.state")}
</span>
<DockerBadge
state={
containerState as import("@/types").DockerContainer["state"]
}
/>
</div>
</div>
</SectionCard>
</div>
);
}
@@ -0,0 +1,233 @@
import React from "react";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { Separator } from "@/components/separator.tsx";
import { Download, RefreshCw, Search, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type { DockerLogOptions } from "@/types";
import { getContainerLogs, downloadContainerLogs } from "@/main-axios.ts";
interface LogViewerProps {
sessionId: string;
containerId: string;
containerName: string;
}
function AdminToggle({
on,
onToggle,
label,
}: {
on: boolean;
onToggle: () => void;
label: string;
}) {
return (
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground uppercase font-bold">
{label}
</span>
<button
type="button"
onClick={onToggle}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${on ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
>
<span
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${on ? "translate-x-4" : "translate-x-0.5"}`}
/>
</button>
</div>
);
}
function getLogColor(line: string): string {
if (line.includes(" WARN") || line.includes(" warn"))
return "text-yellow-400/90";
if (line.includes(" ERROR") || line.includes(" error"))
return "text-destructive";
if (line.includes(" DEBUG") || line.includes(" debug"))
return "text-muted-foreground/60";
return "text-foreground/90";
}
export function LogViewer({
sessionId,
containerId,
containerName,
}: LogViewerProps): React.ReactElement {
const { t } = useTranslation();
const [rawLogs, setRawLogs] = React.useState<string[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [isDownloading, setIsDownloading] = React.useState(false);
const [tailLines, setTailLines] = React.useState("100");
const [showTimestamps, setShowTimestamps] = React.useState(false);
const [autoRefresh, setAutoRefresh] = React.useState(false);
const [logSearch, setLogSearch] = React.useState("");
const logsEndRef = React.useRef<HTMLDivElement>(null);
const fetchLogs = React.useCallback(async () => {
setIsLoading(true);
try {
const options: DockerLogOptions = {
tail: tailLines === "all" ? undefined : parseInt(tailLines, 10),
timestamps: showTimestamps,
};
const data = await getContainerLogs(sessionId, containerId, options);
setRawLogs(data.logs.split("\n").filter(Boolean));
} catch (error) {
toast.error(
`Failed to fetch logs: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setIsLoading(false);
}
}, [sessionId, containerId, tailLines, showTimestamps]);
React.useEffect(() => {
fetchLogs();
}, [fetchLogs]);
React.useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(fetchLogs, 3000);
return () => clearInterval(interval);
}, [autoRefresh, fetchLogs]);
React.useEffect(() => {
if (autoRefresh && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [rawLogs, autoRefresh]);
const handleDownload = async () => {
setIsDownloading(true);
try {
const blob = await downloadContainerLogs(sessionId, containerId, {
timestamps: showTimestamps,
});
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${containerName.replace(/[^a-z0-9]/gi, "_")}_logs.txt`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
toast.success(t("docker.logsDownloaded"));
} catch (error) {
toast.error(
`Failed to download logs: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setIsDownloading(false);
}
};
const filteredLogs = logSearch
? rawLogs.filter((l) => l.toLowerCase().includes(logSearch.toLowerCase()))
: rawLogs;
return (
<div className="flex flex-col flex-1 min-h-0 gap-3">
<div className="flex items-center justify-between bg-card border border-border px-3 py-2 gap-3 flex-wrap">
<div className="flex items-center gap-3 shrink-0 flex-wrap">
<AdminToggle
on={autoRefresh}
onToggle={() => setAutoRefresh(!autoRefresh)}
label={t("docker.autoRefresh")}
/>
<Separator orientation="vertical" className="h-4" />
<AdminToggle
on={showTimestamps}
onToggle={() => setShowTimestamps(!showTimestamps)}
label={t("docker.timestamps")}
/>
<Separator orientation="vertical" className="h-4" />
<select
value={tailLines}
onChange={(e) => setTailLines(e.target.value)}
className="h-7 px-2 text-[10px] bg-background border border-border text-foreground outline-none uppercase font-bold"
>
<option value="50">{t("docker.last50")}</option>
<option value="100">{t("docker.last100")}</option>
<option value="500">{t("docker.last500")}</option>
<option value="1000">{t("docker.last1000")}</option>
<option value="all">{t("docker.allLogs")}</option>
</select>
<Separator orientation="vertical" className="h-4" />
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-widest">
{filteredLogs.length}/{rawLogs.length} {t("docker.lines")}
</span>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-64">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3 text-muted-foreground" />
<Input
placeholder={t("docker.filterLogs")}
value={logSearch}
onChange={(e) => setLogSearch(e.target.value)}
className="pl-7 h-7 text-xs bg-background border-border rounded-none"
/>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1.5"
onClick={fetchLogs}
disabled={isLoading}
>
<RefreshCw
className={`size-3 ${isLoading ? "animate-spin" : ""}`}
/>
{t("docker.refresh")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1.5"
onClick={handleDownload}
disabled={isDownloading}
>
<Download className="size-3" />
{t("docker.download")}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1.5"
onClick={() => setRawLogs([])}
>
<Trash2 className="size-3" />
{t("docker.clear")}
</Button>
</div>
</div>
<div className="flex-1 bg-[#111210] border border-border p-3 overflow-auto font-mono text-xs leading-relaxed scrollbar-thin min-h-0">
{filteredLogs.length > 0 ? (
filteredLogs.map((line, i) => {
const tsEnd = line.indexOf(" ", 1);
const maybeTs = tsEnd > 10 ? line.substring(0, tsEnd) : null;
const rest = maybeTs ? line.substring(tsEnd) : line;
return (
<div key={i} className="whitespace-pre-wrap break-all">
{maybeTs && (
<span className="text-accent-brand/50">{maybeTs}</span>
)}
<span className={getLogColor(rest)}>{rest}</span>
</div>
);
})
) : (
<span className="text-muted-foreground italic">
{logSearch
? t("docker.noLogsMatching", { query: logSearch })
: t("docker.noLogsAvailable")}
</span>
)}
<div ref={logsEndRef} />
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,46 @@
import React from "react";
import { FileManager } from "@/features/file-manager/FileManager.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
interface FileManagerAppProps {
hostId?: string;
}
const FileManagerApp: React.FC<FileManagerAppProps> = ({ hostId }) => {
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>
);
}
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>
</div>
);
}
return (
<FileManager
embedded={true}
initialHost={hostConfig}
onClose={() => {}}
/>
);
}}
</FullScreenAppWrapper>
);
};
export default FileManagerApp;
@@ -0,0 +1,585 @@
import React, { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils.ts";
import {
Download,
Edit3,
Copy,
Scissors,
Trash2,
Info,
Upload,
FolderPlus,
FilePlus,
RefreshCw,
Clipboard,
Eye,
Terminal,
Play,
Star,
Bookmark,
FileArchive,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
executable?: boolean;
}
interface ContextMenuProps {
x: number;
y: number;
files: FileItem[];
isVisible: boolean;
onClose: () => void;
onDownload?: (files: FileItem[]) => void;
onRename?: (file: FileItem) => void;
onCopy?: (files: FileItem[]) => void;
onCut?: (files: FileItem[]) => void;
onDelete?: (files: FileItem[]) => void;
onProperties?: (file: FileItem) => void;
onUpload?: () => void;
onNewFolder?: () => void;
onNewFile?: () => void;
onRefresh?: () => void;
onPaste?: () => void;
onPreview?: (file: FileItem) => void;
hasClipboard?: boolean;
onDragToDesktop?: () => void;
onOpenTerminal?: (path: string) => void;
onRunExecutable?: (file: FileItem) => void;
onPinFile?: (file: FileItem) => void;
onUnpinFile?: (file: FileItem) => void;
onAddShortcut?: (path: string) => void;
isPinned?: (file: FileItem) => boolean;
currentPath?: string;
onExtractArchive?: (file: FileItem) => void;
onCompress?: (files: FileItem[]) => void;
onCopyPath?: (files: FileItem[]) => void;
}
interface MenuItem {
icon: React.ReactNode;
label: string;
action: () => void;
shortcut?: string;
separator?: boolean;
disabled?: boolean;
danger?: boolean;
}
const VIEWPORT_PADDING = 10;
function getClampedMenuPosition(
x: number,
y: number,
menuWidth: number,
menuHeight: number,
) {
const maxX = Math.max(
VIEWPORT_PADDING,
window.innerWidth - menuWidth - VIEWPORT_PADDING,
);
const maxY = Math.max(
VIEWPORT_PADDING,
window.innerHeight - menuHeight - VIEWPORT_PADDING,
);
return {
x: Math.min(Math.max(VIEWPORT_PADDING, x), maxX),
y: Math.min(Math.max(VIEWPORT_PADDING, y), maxY),
};
}
export function FileManagerContextMenu({
x,
y,
files,
isVisible,
onClose,
onDownload,
onRename,
onCopy,
onCut,
onDelete,
onProperties,
onUpload,
onNewFolder,
onNewFile,
onRefresh,
onPaste,
onPreview,
hasClipboard = false,
onDragToDesktop,
onOpenTerminal,
onRunExecutable,
onPinFile,
onUnpinFile,
onAddShortcut,
isPinned,
currentPath,
onExtractArchive,
onCompress,
onCopyPath,
}: ContextMenuProps) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
const [menuPosition, setMenuPosition] = useState({ x, y });
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
if (!isVisible) {
setIsMounted(false);
return;
}
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(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
const menuElement = document.querySelector("[data-context-menu]");
if (!menuElement?.contains(target)) {
onClose();
}
};
const handleRightClick = (event: MouseEvent) => {
event.preventDefault();
onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
};
const handleBlur = () => {
onClose();
};
const handleScroll = () => {
onClose();
};
document.addEventListener("mousedown", handleClickOutside, true);
document.addEventListener("contextmenu", handleRightClick);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("blur", handleBlur);
window.addEventListener("scroll", handleScroll, true);
cleanupFn = () => {
document.removeEventListener("mousedown", handleClickOutside, true);
document.removeEventListener("contextmenu", handleRightClick);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("blur", handleBlur);
window.removeEventListener("scroll", handleScroll, true);
};
}, 50);
return () => {
clearTimeout(timeoutId);
if (cleanupFn) {
cleanupFn();
}
};
}, [isVisible, x, y, onClose]);
const isFileContext = files.length > 0;
const isSingleFile = files.length === 1;
const isMultipleFiles = files.length > 1;
const hasFiles = files.some((f) => f.type === "file");
const hasExecutableFiles = files.some(
(f) => f.type === "file" && f.executable,
);
const menuItems: MenuItem[] = [];
if (isFileContext) {
if (onOpenTerminal) {
const targetPath = isSingleFile
? files[0].type === "directory"
? files[0].path
: files[0].path.substring(0, files[0].path.lastIndexOf("/"))
: files[0].path.substring(0, files[0].path.lastIndexOf("/"));
menuItems.push({
icon: <Terminal className="size-3.5" />,
label:
files[0].type === "directory"
? t("fileManager.openTerminalInFolder")
: t("fileManager.openTerminalInFileLocation"),
action: () => onOpenTerminal(targetPath),
shortcut: "Ctrl+Shift+T",
});
}
if (isSingleFile && hasExecutableFiles && onRunExecutable) {
menuItems.push({
icon: <Play className="size-3.5" />,
label: t("fileManager.run"),
action: () => onRunExecutable(files[0]),
shortcut: "Enter",
});
}
if (
onOpenTerminal ||
(isSingleFile && hasExecutableFiles && onRunExecutable)
) {
menuItems.push({ separator: true } as MenuItem);
}
if (hasFiles && onPreview) {
menuItems.push({
icon: <Eye className="size-3.5" />,
label: t("fileManager.preview"),
action: () => onPreview(files[0]),
disabled: !isSingleFile || files[0].type !== "file",
});
}
if (hasFiles && onDownload) {
menuItems.push({
icon: <Download className="size-3.5" />,
label: isMultipleFiles
? t("fileManager.downloadFiles", { count: files.length })
: t("fileManager.downloadFile"),
action: () => onDownload(files),
shortcut: "Ctrl+D",
});
}
if (isSingleFile && files[0].type === "file" && onExtractArchive) {
const fileName = files[0].name.toLowerCase();
const isArchive =
fileName.endsWith(".zip") ||
fileName.endsWith(".tar") ||
fileName.endsWith(".tar.gz") ||
fileName.endsWith(".tgz") ||
fileName.endsWith(".tar.bz2") ||
fileName.endsWith(".tbz2") ||
fileName.endsWith(".tar.xz") ||
fileName.endsWith(".gz") ||
fileName.endsWith(".bz2") ||
fileName.endsWith(".xz") ||
fileName.endsWith(".7z") ||
fileName.endsWith(".rar");
if (isArchive) {
menuItems.push({
icon: <FileArchive className="size-3.5" />,
label: t("fileManager.extractArchive"),
action: () => onExtractArchive(files[0]),
shortcut: "Ctrl+E",
});
}
}
if (isFileContext && onCompress) {
menuItems.push({
icon: <FileArchive className="size-3.5" />,
label: isMultipleFiles
? t("fileManager.compressFiles")
: t("fileManager.compressFile"),
action: () => onCompress(files),
shortcut: "Ctrl+Shift+C",
});
}
if (isSingleFile && files[0].type === "file") {
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
if (isCurrentlyPinned && onUnpinFile) {
menuItems.push({
icon: (
<Star className="size-3.5 fill-accent-brand text-accent-brand" />
),
label: t("fileManager.unpinFile"),
action: () => onUnpinFile(files[0]),
});
} else if (!isCurrentlyPinned && onPinFile) {
menuItems.push({
icon: <Star className="size-3.5" />,
label: t("fileManager.pinFile"),
action: () => onPinFile(files[0]),
});
}
}
if (isSingleFile && files[0].type === "directory" && onAddShortcut) {
menuItems.push({
icon: <Bookmark className="size-3.5" />,
label: t("fileManager.addToShortcuts"),
action: () => onAddShortcut(files[0].path),
});
}
if (
(hasFiles && (onPreview || onDragToDesktop)) ||
(isSingleFile &&
files[0].type === "file" &&
(onPinFile || onUnpinFile)) ||
(isSingleFile && files[0].type === "directory" && onAddShortcut)
) {
menuItems.push({ separator: true } as MenuItem);
}
if (isSingleFile && onRename) {
menuItems.push({
icon: <Edit3 className="size-3.5" />,
label: t("fileManager.rename"),
action: () => onRename(files[0]),
shortcut: "F6",
});
}
if (onCopy) {
menuItems.push({
icon: <Copy className="size-3.5" />,
label: isMultipleFiles
? t("fileManager.copyFiles", { count: files.length })
: t("fileManager.copy"),
action: () => onCopy(files),
shortcut: "Ctrl+C",
});
}
if (onCut) {
menuItems.push({
icon: <Scissors className="size-3.5" />,
label: isMultipleFiles
? t("fileManager.cutFiles", { count: files.length })
: t("fileManager.cut"),
action: () => onCut(files),
shortcut: "Ctrl+X",
});
}
if (onCopyPath) {
menuItems.push({
icon: <Clipboard className="size-3.5" />,
label: isMultipleFiles
? t("fileManager.copyPaths")
: t("fileManager.copyPath"),
action: () => onCopyPath(files),
shortcut: "Ctrl+Shift+P",
});
}
if ((isSingleFile && onRename) || onCopy || onCut || onCopyPath) {
menuItems.push({ separator: true } as MenuItem);
}
if (isSingleFile && onProperties) {
menuItems.push({
icon: <Info className="size-3.5" />,
label: t("fileManager.properties"),
action: () => onProperties(files[0]),
});
}
if ((isSingleFile && onProperties) || onDelete) {
menuItems.push({ separator: true } as MenuItem);
}
if (onDelete) {
menuItems.push({
icon: <Trash2 className="size-3.5" />,
label: isMultipleFiles
? t("fileManager.deleteFiles", { count: files.length })
: t("fileManager.delete"),
action: () => onDelete(files),
shortcut: "Delete",
danger: true,
});
}
} else {
if (onOpenTerminal && currentPath) {
menuItems.push({
icon: <Terminal className="size-3.5" />,
label: t("fileManager.openTerminalHere"),
action: () => onOpenTerminal(currentPath),
shortcut: "Ctrl+Shift+T",
});
}
if (onUpload) {
menuItems.push({
icon: <Upload className="size-3.5" />,
label: t("fileManager.uploadFile"),
action: onUpload,
shortcut: "Ctrl+U",
});
}
if ((onOpenTerminal && currentPath) || onUpload) {
menuItems.push({ separator: true } as MenuItem);
}
if (onNewFolder) {
menuItems.push({
icon: <FolderPlus className="size-3.5" />,
label: t("fileManager.newFolder"),
action: onNewFolder,
shortcut: "Ctrl+Shift+N",
});
}
if (onNewFile) {
menuItems.push({
icon: <FilePlus className="size-3.5" />,
label: t("fileManager.newFile"),
action: onNewFile,
shortcut: "Ctrl+N",
});
}
if (onNewFolder || onNewFile) {
menuItems.push({ separator: true } as MenuItem);
}
if (onRefresh) {
menuItems.push({
icon: <RefreshCw className="size-3.5" />,
label: t("fileManager.refresh"),
action: onRefresh,
shortcut: "Ctrl+Y",
});
}
if (hasClipboard && onPaste) {
menuItems.push({
icon: <Clipboard className="size-3.5" />,
label: t("fileManager.paste"),
action: onPaste,
shortcut: "Ctrl+V",
});
}
}
const filteredMenuItems = menuItems.filter((item, index) => {
if (!item.separator) return true;
const prevItem = index > 0 ? menuItems[index - 1] : null;
const nextItem = index < menuItems.length - 1 ? menuItems[index + 1] : null;
if (prevItem?.separator || nextItem?.separator) {
return false;
}
return true;
});
const finalMenuItems = filteredMenuItems.filter((item, index) => {
if (!item.separator) return true;
return index > 0 && index < filteredMenuItems.length - 1;
});
const renderShortcut = (shortcut: string) => {
const keys = shortcut.split("+");
if (keys.length === 1) {
return <Kbd>{keys[0]}</Kbd>;
}
return (
<Kbd>
{keys.map((key, index) => (
<>
<KbdKey key={`key-${index}`}>{key}</KbdKey>
{index < keys.length - 1 && <KbdSeparator key={`sep-${index}`} />}
</>
))}
</Kbd>
);
};
if (!isVisible && !isMounted) return null;
return (
<>
<div
className={cn(
"fixed inset-0 z-[99990] transition-opacity duration-150",
!isMounted && "opacity-0",
)}
/>
<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",
)}
style={{
left: menuPosition.x,
top: menuPosition.y,
maxHeight: `calc(100vh - ${VIEWPORT_PADDING * 2}px)`,
}}
>
{finalMenuItems.map((item, index) => {
if (item.separator) {
return (
<div
key={`separator-${index}`}
className="border-t border-border"
/>
);
}
return (
<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",
item.disabled &&
"opacity-50 cursor-not-allowed hover:bg-transparent hover:text-current",
item.danger &&
"text-destructive hover:bg-destructive/10 hover:text-destructive",
)}
onClick={() => {
if (!item.disabled) {
item.action();
onClose();
}
}}
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>
{item.shortcut && (
<div className="ml-2 flex-shrink-0">
{renderShortcut(item.shortcut)}
</div>
)}
</button>
);
})}
</div>
</>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,759 @@
import React, {
useState,
useEffect,
useRef,
useCallback,
useMemo,
} from "react";
import { cn } from "@/lib/utils.ts";
import { Star, Clock, Bookmark, File, Folder } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SSHHost } from "@/types";
import {
getRecentFiles,
getPinnedFiles,
getFolderShortcuts,
listSSHFiles,
removeRecentFile,
removePinnedFile,
removeFolderShortcut,
} from "@/main-axios.ts";
import { toast } from "sonner";
import FolderTree from "@/components/folder.tsx";
// ─── Interfaces ────────────────────────────────────────────────────────────────
interface RecentFileData {
id: number;
name: string;
path: string;
lastOpened?: string;
[key: string]: unknown;
}
interface PinnedFileData {
id: number;
name: string;
path: string;
[key: string]: unknown;
}
interface ShortcutData {
id: number;
name: string;
path: string;
[key: string]: unknown;
}
interface DirectoryItemData {
name: string;
path: string;
type: string;
[key: string]: unknown;
}
export interface SidebarItem {
id: string;
name: string;
path: string;
type: "recent" | "pinned" | "shortcut" | "folder";
lastAccessed?: string;
isExpanded?: boolean;
children?: SidebarItem[];
}
interface FileManagerSidebarProps {
currentHost: SSHHost;
currentPath: string;
onPathChange: (path: string) => void;
onFileOpen?: (file: SidebarItem) => void;
sshSessionId?: string;
refreshTrigger?: number;
diskInfo?: { usedHuman: string; totalHuman: string; percent: number };
}
// ─── Component ─────────────────────────────────────────────────────────────────
export function FileManagerSidebar({
currentHost,
currentPath,
onPathChange,
onFileOpen,
sshSessionId,
refreshTrigger,
diskInfo,
}: FileManagerSidebarProps) {
const { t } = useTranslation();
// ── Quick access state (API-backed) ──────────────────────────────────────────
const [recentItems, setRecentItems] = useState<SidebarItem[]>([]);
const [pinnedItems, setPinnedItems] = useState<SidebarItem[]>([]);
const [shortcuts, setShortcuts] = useState<SidebarItem[]>([]);
// ── Directory tree state ──────────────────────────────────────────────────────
const [directoryTree, setDirectoryTree] = useState<SidebarItem[]>([]);
/**
* Tracks which folder paths have already been lazy-loaded so we don't
* re-fetch on every re-selection / collapse-reopen.
*/
const loadedFoldersRef = useRef<Set<string>>(new Set(["/"]));
// ── Context menu state ────────────────────────────────────────────────────────
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
isVisible: boolean;
item: SidebarItem | null;
}>({
x: 0,
y: 0,
isVisible: false,
item: null,
});
// ─── Effects ──────────────────────────────────────────────────────────────────
useEffect(() => {
loadQuickAccessData();
}, [currentHost, refreshTrigger]);
useEffect(() => {
if (sshSessionId) {
loadedFoldersRef.current = new Set(["/"]);
loadDirectoryTree();
}
}, [sshSessionId]);
// When currentPath changes externally (grid navigation), ensure the parent
// directory is loaded in the tree so the selection highlight can appear.
useEffect(() => {
if (!sshSessionId || currentPath === "/") return;
const parentPath =
currentPath.substring(0, currentPath.lastIndexOf("/")) || "/";
const findByPath = (items: SidebarItem[]): SidebarItem | null => {
for (const item of items) {
if (item.path === parentPath) return item;
if (item.children) {
const found = findByPath(item.children);
if (found) return found;
}
}
return null;
};
const parent = findByPath(directoryTree);
if (parent && !loadedFoldersRef.current.has(parent.path)) {
loadedFoldersRef.current.add(parent.path);
loadSubdirectory(parent.id, parent.path);
}
}, [currentPath, sshSessionId]);
// ─── API: Quick access ────────────────────────────────────────────────────────
const loadQuickAccessData = async () => {
if (!currentHost?.id) return;
try {
const recentData = await getRecentFiles(currentHost.id);
const recentItems = (recentData as RecentFileData[])
.slice(0, 5)
.map((item: RecentFileData) => ({
id: `recent-${item.id}`,
name: item.name,
path: item.path,
type: "recent" as const,
lastAccessed: item.lastOpened,
}));
setRecentItems(recentItems);
const pinnedData = await getPinnedFiles(currentHost.id);
const pinnedItems = (pinnedData as PinnedFileData[]).map(
(item: PinnedFileData) => ({
id: `pinned-${item.id}`,
name: item.name,
path: item.path,
type: "pinned" as const,
}),
);
setPinnedItems(pinnedItems);
const shortcutData = await getFolderShortcuts(currentHost.id);
const shortcutItems = (shortcutData as ShortcutData[]).map(
(item: ShortcutData) => ({
id: `shortcut-${item.id}`,
name: item.name,
path: item.path,
type: "shortcut" as const,
}),
);
setShortcuts(shortcutItems);
} catch (error) {
console.error("Failed to load quick access data:", error);
setRecentItems([]);
setPinnedItems([]);
setShortcuts([]);
}
};
// ─── API: Directory tree ──────────────────────────────────────────────────────
const loadDirectoryTree = async (attempt = 0) => {
if (!sshSessionId) return;
try {
const response = await listSSHFiles(sshSessionId, "/");
const rootFiles = (response.files || []) as DirectoryItemData[];
const rootFolders = rootFiles.filter(
(item: DirectoryItemData) => item.type === "directory",
);
const rootTreeItems = rootFolders.map((folder: DirectoryItemData) => ({
id: `folder-${folder.name}`,
name: folder.name,
path: folder.path,
type: "folder" as const,
isExpanded: false,
children: [],
}));
setDirectoryTree([
{
id: "root",
name: "/",
path: "/",
type: "folder" as const,
isExpanded: true,
children: rootTreeItems,
},
]);
} catch (error: unknown) {
const status =
(error as { status?: number })?.status ||
(error as { response?: { status?: number } })?.response?.status;
if (status === 409 && attempt < 3) {
// Another request was already listing "/" — retry after a short delay
setTimeout(() => loadDirectoryTree(attempt + 1), 600);
return;
}
console.error("Failed to load directory tree:", error);
setDirectoryTree([
{
id: "root",
name: "/",
path: "/",
type: "folder" as const,
isExpanded: false,
children: [],
},
]);
}
};
/**
* Lazily fetches subdirectory contents and patches them into the tree state.
* Called the first time a folder is expanded via FolderTree's onSelect.
*/
const loadSubdirectory = useCallback(
async (folderId: string, folderPath: string) => {
if (!sshSessionId) return;
try {
const subResponse = await listSSHFiles(sshSessionId, folderPath);
const subFiles = (subResponse.files || []) as DirectoryItemData[];
const subFolders = subFiles.filter(
(item: DirectoryItemData) => item.type === "directory",
);
const subTreeItems = subFolders.map((folder: DirectoryItemData) => ({
id: `folder-${folder.path.replace(/\//g, "-")}`,
name: folder.name,
path: folder.path,
type: "folder" as const,
isExpanded: false,
children: [],
}));
setDirectoryTree((prevTree) => {
const updateChildren = (items: SidebarItem[]): SidebarItem[] =>
items.map((item) => {
if (item.id === folderId) {
return { ...item, children: subTreeItems };
}
if (item.children) {
return { ...item, children: updateChildren(item.children) };
}
return item;
});
return updateChildren(prevTree);
});
} catch (error: unknown) {
const status =
(error as { status?: number })?.status ||
(error as { response?: { status?: number } })?.response?.status;
if (status === 409) {
// Another request was listing this path — retry after the lock clears
setTimeout(() => loadSubdirectory(folderId, folderPath), 600);
return;
}
console.error("Failed to load subdirectory:", error);
}
},
[sshSessionId],
);
// ─── Quick-access mutation handlers ──────────────────────────────────────────
const handleRemoveRecentFile = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removeRecentFile(currentHost.id, item.path);
loadQuickAccessData();
toast.success(
t("fileManager.removedFromRecentFiles", { name: item.name }),
);
} catch (error) {
console.error("Failed to remove recent file:", error);
toast.error(t("fileManager.removeFailed"));
}
};
const handleUnpinFile = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removePinnedFile(currentHost.id, item.path);
loadQuickAccessData();
toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name }));
} catch (error) {
console.error("Failed to unpin file:", error);
toast.error(t("fileManager.unpinFailed"));
}
};
const handleRemoveShortcut = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removeFolderShortcut(currentHost.id, item.path);
loadQuickAccessData();
toast.success(t("fileManager.removedShortcut", { name: item.name }));
} catch (error) {
console.error("Failed to remove shortcut:", error);
toast.error(t("fileManager.removeShortcutFailed"));
}
};
const handleClearAllRecent = async () => {
if (!currentHost?.id || recentItems.length === 0) return;
try {
await Promise.all(
recentItems.map((item) => removeRecentFile(currentHost.id, item.path)),
);
loadQuickAccessData();
toast.success(t("fileManager.clearedAllRecentFiles"));
} catch (error) {
console.error("Failed to clear recent files:", error);
toast.error(t("fileManager.clearFailed"));
}
};
// ─── Quick-access item click ──────────────────────────────────────────────────
const handleQuickAccessClick = (item: SidebarItem) => {
if (item.type === "recent" || item.type === "pinned") {
if (onFileOpen) {
onFileOpen(item);
} else {
const directory =
item.path.substring(0, item.path.lastIndexOf("/")) || "/";
onPathChange(directory);
}
} else if (item.type === "shortcut") {
onPathChange(item.path);
}
};
// ─── FolderTree directory selection (onSelect callback) ──────────────────────
/**
* Called by FolderTree whenever the user selects (clicks) a tree item.
* We navigate to the folder and lazily load children on first visit.
*/
const handleDirectorySelect = useCallback(
async (id: string) => {
// Walk the tree to find the item by id
const findItem = (items: SidebarItem[]): SidebarItem | null => {
for (const item of items) {
if (item.id === id) return item;
if (item.children) {
const found = findItem(item.children);
if (found) return found;
}
}
return null;
};
const item = findItem(directoryTree);
if (!item) return;
// Navigate to path
onPathChange(item.path);
// Lazy-load children the first time this folder is expanded
if (
sshSessionId &&
item.path !== "/" &&
!loadedFoldersRef.current.has(item.path)
) {
loadedFoldersRef.current.add(item.path);
await loadSubdirectory(id, item.path);
}
},
[directoryTree, onPathChange, sshSessionId, loadSubdirectory],
);
// ─── Context menu ─────────────────────────────────────────────────────────────
const handleContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, isVisible: true, item });
};
const closeContextMenu = () => {
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
};
useEffect(() => {
if (!contextMenu.isVisible) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
const menuElement = document.querySelector("[data-sidebar-context-menu]");
if (!menuElement?.contains(target)) closeContextMenu();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeContextMenu();
};
const timeoutId = setTimeout(() => {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
}, 50);
return () => {
clearTimeout(timeoutId);
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [contextMenu.isVisible]);
// ─── Derive selected tree node + ancestors from currentPath ──────────────────
const { selectedTreeId, ancestorIds } = useMemo(() => {
if (currentPath === "/")
return { selectedTreeId: "root", ancestorIds: new Set<string>() };
const ancestors: string[] = [];
const findByPath = (
items: SidebarItem[],
path: string[],
): string | null => {
for (const item of items) {
if (item.path === currentPath) {
ancestors.push(...path, "root");
return item.id;
}
if (item.children) {
const found = findByPath(item.children, [...path, item.id]);
if (found) return found;
}
}
return null;
};
const id = findByPath(directoryTree, []);
return { selectedTreeId: id, ancestorIds: new Set(ancestors) };
}, [currentPath, directoryTree]);
// ─── Render helpers ───────────────────────────────────────────────────────────
/**
* Recursively renders directory tree items using FolderTree.Item + Content.
*
* FolderTree.Item detects "has children" via React.Children.count > 0.
* By always wrapping children in <FolderTree.Content> (even when the
* children array is empty), every directory shows the expand chevron.
* FolderTree.Content internally shows nothing when its own children are
* absent, so an unloaded folder simply expands to an empty state while
* the async fetch fills it in.
*/
const renderFolderTreeItem = (item: SidebarItem): React.ReactNode => (
<FolderTree.Item key={item.id} id={item.id} label={item.name}>
<FolderTree.Content>
{item.children?.map((child) => renderFolderTreeItem(child))}
</FolderTree.Content>
</FolderTree.Item>
);
const renderQuickAccessItem = (item: SidebarItem, icon: React.ReactNode) => {
const dirPath =
item.type === "shortcut"
? item.path
: item.path.substring(0, item.path.lastIndexOf("/")) || "/";
const isActive = currentPath === dirPath;
return (
<button
key={item.id}
className={cn(
"w-full flex items-center gap-2.5 px-3 py-1.5 text-[11px] font-bold uppercase tracking-wider transition-colors text-left border-l-2",
isActive
? "bg-accent-brand/10 text-accent-brand border-accent-brand"
: "text-muted-foreground hover:text-foreground hover:bg-muted border-transparent",
)}
onClick={() => handleQuickAccessClick(item)}
onContextMenu={(e) => handleContextMenu(e, item)}
title={item.path}
>
<div className="shrink-0">{icon}</div>
<span className="flex-1 truncate">{item.name}</span>
</button>
);
};
const renderSection = (
title: string,
items: SidebarItem[],
renderItem: (item: SidebarItem) => React.ReactNode,
) => {
if (items.length === 0) return null;
return (
<div>
<div className="px-3 py-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{title}
</span>
</div>
{items.map((item) => renderItem(item))}
</div>
);
};
const hasQuickAccessItems =
recentItems.length > 0 || pinnedItems.length > 0 || shortcuts.length > 0;
// ─── Render ───────────────────────────────────────────────────────────────────
const storageUsedPct = diskInfo?.percent ?? null;
return (
<>
<div className="h-full flex flex-col bg-card border-r border-border overflow-hidden">
<div className="flex-1 overflow-y-auto thin-scrollbar">
{/* ── Recent files ──────────────────────────────────────── */}
{renderSection(t("fileManager.recent"), recentItems, (item) =>
renderQuickAccessItem(
item,
<File
className={cn(
"size-3.5 shrink-0",
currentPath ===
(item.path.substring(0, item.path.lastIndexOf("/")) || "/")
? "text-accent-brand"
: "text-muted-foreground/60",
)}
/>,
),
)}
{/* ── Pinned files ───────────────────────────────────────── */}
{renderSection(t("fileManager.pinned"), pinnedItems, (item) =>
renderQuickAccessItem(
item,
<Star
className={cn(
"size-3.5 shrink-0",
currentPath ===
(item.path.substring(0, item.path.lastIndexOf("/")) || "/")
? "text-accent-brand fill-accent-brand"
: "text-muted-foreground/60",
)}
/>,
),
)}
{/* ── Folder shortcuts ───────────────────────────────────── */}
{renderSection(t("fileManager.folderShortcuts"), shortcuts, (item) =>
renderQuickAccessItem(
item,
<Folder
className={cn(
"size-3.5 shrink-0",
currentPath === item.path
? "text-accent-brand"
: "text-muted-foreground/60",
)}
/>,
),
)}
{/* ── Directory tree ─────────────────────────────────────── */}
<div
className={cn(
hasQuickAccessItems && "border-t border-border mt-1 pt-1",
)}
>
<div className="px-3 py-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("fileManager.directories")}
</span>
</div>
<div className="px-1">
<FolderTree.Root
id="sidebar-directory-tree"
defaultExpanded={["root"]}
selectedId={selectedTreeId}
expandedIds={ancestorIds}
onSelect={(id) => handleDirectorySelect(id)}
className="bg-transparent border-0 rounded-none shadow-none"
>
{directoryTree.map((item) => renderFolderTreeItem(item))}
</FolderTree.Root>
</div>
</div>
{/* ── Storage — mobile only (inside scroll) ──────────────── */}
{diskInfo && storageUsedPct !== null && (
<div className="md:hidden">
<div className="border-t border-border mx-0 my-2" />
<div className="px-3 py-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("fileManager.storage")}
</span>
</div>
<div className="px-3 pb-3 flex flex-col gap-2">
<div className="flex items-center justify-between text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<span>{t("fileManager.disk")}</span>
<span className="text-accent-brand">
{storageUsedPct}% {t("fileManager.used")}
</span>
</div>
<div className="h-1.5 bg-muted rounded-none overflow-hidden border border-border">
<div
className="h-full bg-accent-brand"
style={{ width: `${storageUsedPct}%` }}
/>
</div>
<span className="text-[10px] font-bold text-muted-foreground/60 tracking-tight">
{diskInfo.usedHuman} {t("fileManager.of")}{" "}
{diskInfo.totalHuman} {t("fileManager.used").toLowerCase()}
</span>
</div>
</div>
)}
</div>
{/* ── Storage — desktop only (bottom of sidebar) ──────────── */}
{diskInfo && storageUsedPct !== null && (
<div className="hidden md:flex flex-col p-3 gap-2 border-t border-border shrink-0">
<div className="flex items-center justify-between text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
<span>{t("fileManager.storage")}</span>
<span className="text-accent-brand">
{storageUsedPct}% {t("fileManager.used")}
</span>
</div>
<div className="h-1.5 bg-muted rounded-none overflow-hidden border border-border">
<div
className="h-full bg-accent-brand"
style={{ width: `${storageUsedPct}%` }}
/>
</div>
<span className="text-[10px] font-bold text-muted-foreground/60 tracking-tight">
{diskInfo.usedHuman} {t("fileManager.of")} {diskInfo.totalHuman}{" "}
{t("fileManager.used").toLowerCase()}
</span>
</div>
)}
</div>
{/* ── Context menu ─────────────────────────────────────────────── */}
{contextMenu.isVisible && contextMenu.item && (
<>
<div className="fixed inset-0 z-40" />
<div
data-sidebar-context-menu
className="fixed bg-card border border-border rounded-none shadow-xl min-w-[180px] z-50 overflow-hidden"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
{contextMenu.item.type === "recent" && (
<>
<button
className="w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center gap-2.5 hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={() => {
handleRemoveRecentFile(contextMenu.item!);
closeContextMenu();
}}
>
<Clock className="size-3.5 shrink-0" />
<span className="flex-1">
{t("fileManager.removeFromRecentFiles")}
</span>
</button>
{recentItems.length > 1 && (
<>
<div className="border-t border-border" />
<button
className="w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center gap-2.5 text-destructive hover:bg-destructive/10 transition-colors"
onClick={() => {
handleClearAllRecent();
closeContextMenu();
}}
>
<Clock className="size-3.5 shrink-0" />
<span className="flex-1">
{t("fileManager.clearAllRecentFiles")}
</span>
</button>
</>
)}
</>
)}
{contextMenu.item.type === "pinned" && (
<button
className="w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center gap-2.5 hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={() => {
handleUnpinFile(contextMenu.item!);
closeContextMenu();
}}
>
<Star className="size-3.5 shrink-0" />
<span className="flex-1">{t("fileManager.unpinFile")}</span>
</button>
)}
{contextMenu.item.type === "shortcut" && (
<button
className="w-full px-3 h-9 text-left text-[10px] font-bold uppercase tracking-widest flex items-center gap-2.5 hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={() => {
handleRemoveShortcut(contextMenu.item!);
closeContextMenu();
}}
>
<Bookmark className="size-3.5 shrink-0" />
<span className="flex-1">
{t("fileManager.removeShortcut")}
</span>
</button>
)}
</div>
</>
)}
</>
);
}
@@ -0,0 +1,98 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/dialog.tsx";
import { Button } from "@/components/button.tsx";
import { PasswordInput } from "@/components/password-input.tsx";
import { Shield } from "lucide-react";
import { useTranslation } from "react-i18next";
interface SudoPasswordDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (password: string) => void;
}
export function SudoPasswordDialog({
open,
onOpenChange,
onSubmit,
}: SudoPasswordDialogProps) {
const { t } = useTranslation();
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!open) {
setPassword("");
setLoading(false);
}
}, [open]);
const handleSubmit = async (e?: React.FormEvent) => {
if (e) {
e.preventDefault();
}
if (!password.trim()) {
return;
}
setLoading(true);
onSubmit(password);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-md rounded-none border-border bg-card">
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<Shield className="size-4 text-accent-brand" />
{t("fileManager.sudoPasswordRequired")}
</DialogTitle>
<DialogDescription className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">
{t("fileManager.enterSudoPassword")}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("fileManager.sudoPassword")}
className="rounded-none bg-muted/50 border-border text-xs focus:ring-1 focus:ring-accent-brand/50"
autoFocus
disabled={loading}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={loading}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={!password.trim() || loading}
variant="outline"
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{loading ? t("common.loading") : t("common.confirm")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,108 @@
import React from "react";
import AudioPlayer from "react-h5-audio-player";
import "react-h5-audio-player/lib/styles.css";
import { Music } from "lucide-react";
import { cn } from "@/lib/utils.ts";
import { useTranslation } from "react-i18next";
interface FileItem {
name: string;
size?: number;
}
interface AudioPreviewProps {
file: FileItem;
content: string;
color: string;
onMediaDimensionsChange?: (dimensions: {
width: number;
height: number;
}) => void;
}
function formatFileSize(bytes?: number, t?: (key: string) => string): string {
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
}
function getAudioMimeType(filename: string): string {
const ext = filename.split(".").pop()?.toLowerCase() || "";
switch (ext) {
case "mp3":
return "audio/mpeg";
case "wav":
return "audio/wav";
case "flac":
return "audio/flac";
case "ogg":
return "audio/ogg";
case "aac":
return "audio/aac";
case "m4a":
return "audio/mp4";
default:
return "audio/mpeg";
}
}
export function AudioPreview({
file,
content,
color,
onMediaDimensionsChange,
}: AudioPreviewProps) {
const { t } = useTranslation();
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const audioUrl = `data:${getAudioMimeType(file.name)};base64,${content}`;
return (
<div className="p-6 flex items-center justify-center h-full">
<div className="w-full max-w-2xl">
<div className="space-y-4">
<div className="flex justify-center">
<div
className={cn(
"w-32 h-32 rounded-lg bg-gradient-to-br from-pink-100 to-purple-100 flex items-center justify-center shadow-lg",
color,
)}
>
<Music className="w-16 h-16 text-pink-600" />
</div>
</div>
<div className="text-center">
<h3 className="font-semibold text-foreground text-lg mb-1">
{file.name.replace(/\.[^/.]+$/, "")}
</h3>
<p className="text-sm text-muted-foreground">
{ext.toUpperCase()} {formatFileSize(file.size, t)}
</p>
</div>
<div className="rounded-lg overflow-hidden">
<AudioPlayer
src={audioUrl}
onLoadedMetadata={() => {
onMediaDimensionsChange?.({
width: 600,
height: 400,
});
}}
onError={(e) => {
console.error("Audio playback error:", e);
}}
showJumpControls={false}
showSkipControls={false}
showDownloadProgress={true}
customAdditionalControls={[]}
customVolumeControls={[]}
/>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,160 @@
import React, { forwardRef, useImperativeHandle, useMemo, useRef } from "react";
import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark";
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
import { EditorView, keymap } from "@codemirror/view";
import { searchKeymap, search, openSearchPanel } from "@codemirror/search";
import {
defaultKeymap,
history,
historyKeymap,
toggleComment,
} from "@codemirror/commands";
import { autocompletion, completionKeymap } from "@codemirror/autocomplete";
export interface CodeEditorHandle {
openSearchPanel: () => void;
}
interface CodeEditorProps {
fileName: string;
value: string;
placeholder: string;
onChange: (value: string) => void;
onFocus: () => void;
onBlur: () => void;
}
function getLanguageExtension(filename: string) {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const baseName = filename.toLowerCase();
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
return loadLanguage(baseName);
}
const langMap: Record<string, string> = {
js: "javascript",
jsx: "jsx",
ts: "typescript",
tsx: "tsx",
py: "python",
java: "java",
cpp: "cpp",
c: "c",
cs: "csharp",
php: "php",
rb: "ruby",
go: "go",
rs: "rust",
html: "html",
css: "css",
scss: "sass",
less: "less",
json: "json",
xml: "xml",
yaml: "yaml",
yml: "yaml",
toml: "toml",
sql: "sql",
sh: "shell",
bash: "shell",
zsh: "shell",
vue: "vue",
svelte: "svelte",
md: "markdown",
conf: "shell",
ini: "properties",
};
const language = langMap[ext];
return language ? loadLanguage(language) : null;
}
export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(
function CodeEditor(
{ fileName, value, placeholder, onChange, onFocus, onBlur },
ref,
) {
const editorRef = useRef<{ view?: EditorView } | null>(null);
const extensions = useMemo(() => {
const languageExtension = getLanguageExtension(fileName);
return [
...(languageExtension ? [languageExtension] : []),
history(),
search(),
autocompletion(),
keymap.of([
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...completionKeymap,
{
key: "Mod-/",
run: toggleComment,
preventDefault: true,
},
{
key: "Mod-h",
run: () => false,
preventDefault: true,
},
]),
EditorView.theme({
"&": {
height: "100%",
},
".cm-scroller": {
overflow: "auto",
scrollbarWidth: "thin",
scrollbarColor: "var(--scrollbar-thumb) var(--scrollbar-track)",
},
".cm-editor": {
height: "100%",
},
}),
];
}, [fileName]);
useImperativeHandle(
ref,
() => ({
openSearchPanel: () => {
const view = editorRef.current?.view;
if (view) {
openSearchPanel(view);
}
},
}),
[],
);
return (
<CodeMirror
ref={editorRef}
value={value}
onChange={onChange}
onFocus={onFocus}
onBlur={onBlur}
extensions={extensions}
theme={oneDark}
placeholder={placeholder}
className="h-full"
basicSetup={{
lineNumbers: true,
foldGutter: true,
dropCursor: false,
allowMultipleSelections: false,
indentOnInput: true,
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: false,
scrollPastEnd: false,
}}
/>
);
},
);
@@ -0,0 +1,170 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/dialog.tsx";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { Label } from "@/components/label.tsx";
import { Package } from "lucide-react";
import { useTranslation } from "react-i18next";
interface CompressDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
fileNames: string[];
onCompress: (archiveName: string, format: string) => void;
}
export function CompressDialog({
open,
onOpenChange,
fileNames,
onCompress,
}: CompressDialogProps) {
const { t } = useTranslation();
const [archiveName, setArchiveName] = useState("");
const [format, setFormat] = useState("zip");
useEffect(() => {
if (open && fileNames.length > 0) {
if (fileNames.length === 1) {
const baseName = fileNames[0].replace(/\.[^/.]+$/, "");
setArchiveName(baseName);
} else {
setArchiveName("archive");
}
}
}, [open, fileNames]);
const handleCompress = () => {
if (!archiveName.trim()) return;
let finalName = archiveName.trim();
const extensions: Record<string, string> = {
zip: ".zip",
"tar.gz": ".tar.gz",
"tar.bz2": ".tar.bz2",
"tar.xz": ".tar.xz",
tar: ".tar",
"7z": ".7z",
};
const expectedExtension = extensions[format];
if (expectedExtension && !finalName.endsWith(expectedExtension)) {
finalName += expectedExtension;
}
onCompress(finalName, format);
onOpenChange(false);
};
const formats = ["zip", "tar.gz", "tar.bz2", "tar.xz", "tar", "7z"] as const;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-md rounded-none border-border bg-card">
<DialogHeader>
<DialogTitle className="text-xs font-bold uppercase tracking-widest flex items-center gap-2">
<Package className="size-4 text-accent-brand" />
{t("fileManager.compressFiles")}
</DialogTitle>
<DialogDescription className="text-[10px] font-bold uppercase tracking-tight text-muted-foreground">
{t("fileManager.compressFilesDesc", { count: fileNames.length })}
</DialogDescription>
</DialogHeader>
<div className="py-4 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label
className="text-xs font-bold uppercase tracking-widest text-muted-foreground"
htmlFor="archiveName"
>
{t("fileManager.archiveName")}
</Label>
<Input
id="archiveName"
value={archiveName}
onChange={(e) => setArchiveName(e.target.value)}
placeholder={t("fileManager.enterArchiveName")}
className="rounded-none bg-muted/50 border-border text-xs"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleCompress();
}
}}
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("fileManager.compressionFormat")}
</Label>
<div className="grid grid-cols-3 gap-1">
{formats.map((f) => (
<button
key={f}
onClick={() => setFormat(f)}
className={`py-2 text-xs font-bold uppercase tracking-widest border transition-colors ${
format === f
? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand"
: "border-border text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
.{f}
</button>
))}
</div>
</div>
<div className="rounded-none bg-muted/10 border border-border p-3">
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-2">
{t("fileManager.selectedFiles")}:
</p>
<ul className="text-xs space-y-1">
{fileNames.slice(0, 5).map((name, index) => (
<li
key={index}
className="truncate text-foreground font-medium"
>
{name}
</li>
))}
{fileNames.length > 5 && (
<li className="text-muted-foreground">
{t("fileManager.andMoreFiles", {
count: fileNames.length - 5,
})}
</li>
)}
</ul>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
onClick={handleCompress}
disabled={!archiveName.trim()}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
>
<Package className="size-3.5 mr-1" />
{t("fileManager.compress")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,347 @@
import React, { useState, useEffect } from "react";
import { DiffEditor } from "@monaco-editor/react";
import { Button } from "@/components/button.tsx";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
Download,
RefreshCw,
Eye,
EyeOff,
ArrowLeftRight,
FileText,
} from "lucide-react";
import {
readSSHFile,
downloadSSHFile,
getSSHStatus,
connectSSH,
} from "@/main-axios.ts";
import type { FileItem, SSHHost } from "@/types";
interface DiffViewerProps {
file1: FileItem;
file2: FileItem;
sshSessionId: string;
sshHost: SSHHost;
onDownload1?: () => void;
onDownload2?: () => void;
}
export function DiffViewer({
file1,
file2,
sshSessionId,
sshHost,
}: DiffViewerProps) {
const { t } = useTranslation();
const [content1, setContent1] = useState<string>("");
const [content2, setContent2] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [diffMode, setDiffMode] = useState<"side-by-side" | "inline">(
"side-by-side",
);
const [showLineNumbers, setShowLineNumbers] = useState(true);
const ensureSSHConnection = async () => {
try {
const status = await getSSHStatus(sshSessionId);
if (!status.connected) {
await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
port: sshHost.port,
username: sshHost.username,
password: sshHost.password,
sshKey: sshHost.key,
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId,
});
}
} catch {
await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
port: sshHost.port,
username: sshHost.username,
password: sshHost.password,
sshKey: sshHost.key,
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId,
});
}
};
const loadFileContents = async () => {
if (file1.type !== "file" || file2.type !== "file") {
setError(t("fileManager.canOnlyCompareFiles"));
return;
}
try {
setIsLoading(true);
setError(null);
await ensureSSHConnection();
const [response1, response2] = await Promise.all([
readSSHFile(sshSessionId, file1.path),
readSSHFile(sshSessionId, file2.path),
]);
setContent1(response1.content || "");
setContent2(response2.content || "");
} catch (error: unknown) {
console.error("Failed to load files for diff:", error);
const err = error as {
message?: string;
response?: { data?: { tooLarge?: boolean; error?: string } };
};
const errorData = err?.response?.data;
if (errorData?.tooLarge) {
setError(t("fileManager.fileTooLarge", { error: errorData.error }));
} else if (
err.message?.includes("connection") ||
err.message?.includes("established")
) {
setError(
t("fileManager.sshConnectionFailed", {
name: sshHost.name,
ip: sshHost.ip,
port: sshHost.port,
}),
);
} else {
setError(
t("fileManager.loadFileFailed", {
error:
err.message || errorData?.error || t("fileManager.unknownError"),
}),
);
}
} finally {
setIsLoading(false);
}
};
const handleDownloadFile = async (file: FileItem) => {
try {
await ensureSSHConnection();
const response = await downloadSSHFile(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.downloadFileSuccess", { name: file.name }),
);
}
} catch (error: unknown) {
console.error("Failed to download file:", error);
const err = error as { message?: string };
toast.error(
t("fileManager.downloadFileFailed") +
": " +
(err.message || t("fileManager.unknownError")),
);
}
};
const getFileLanguage = (fileName: string): string => {
const ext = fileName.split(".").pop()?.toLowerCase();
const languageMap: Record<string, string> = {
js: "javascript",
jsx: "javascript",
ts: "typescript",
tsx: "typescript",
py: "python",
java: "java",
c: "c",
cpp: "cpp",
cs: "csharp",
php: "php",
rb: "ruby",
go: "go",
rs: "rust",
html: "html",
css: "css",
scss: "scss",
less: "less",
json: "json",
xml: "xml",
yaml: "yaml",
yml: "yaml",
md: "markdown",
sql: "sql",
sh: "shell",
bash: "shell",
ps1: "powershell",
dockerfile: "dockerfile",
};
return languageMap[ext || ""] || "plaintext";
};
useEffect(() => {
loadFileContents();
}, [file1, file2, sshSessionId]);
if (isLoading) {
return (
<div className="h-full flex items-center justify-center bg-card">
<div className="text-center">
<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">
{t("fileManager.loadingFileComparison")}
</p>
</div>
</div>
);
}
if (error) {
return (
<div className="h-full flex items-center justify-center bg-card">
<div className="text-center max-w-md">
<FileText className="w-16 h-16 mx-auto mb-4 text-red-500 opacity-50" />
<p className="text-red-500 mb-4">{error}</p>
<Button onClick={loadFileContents} variant="outline">
<RefreshCw className="w-4 h-4 mr-2" />
{t("fileManager.reload")}
</Button>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-card">
<div className="flex-shrink-0 border-b border-border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm">
<span className="text-muted-foreground">
{t("fileManager.compare")}:
</span>
<span className="font-medium text-green-400 mx-2">
{file1.name}
</span>
<ArrowLeftRight className="w-4 h-4 inline mx-1" />
<span className="font-medium text-blue-400">{file2.name}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
setDiffMode(
diffMode === "side-by-side" ? "inline" : "side-by-side",
)
}
>
{diffMode === "side-by-side"
? t("fileManager.sideBySide")
: t("fileManager.inline")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowLineNumbers(!showLineNumbers)}
>
{showLineNumbers ? (
<Eye className="w-4 h-4" />
) : (
<EyeOff className="w-4 h-4" />
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadFile(file1)}
title={t("fileManager.downloadFile", { name: file1.name })}
>
<Download className="w-4 h-4 mr-1" />
{file1.name}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadFile(file2)}
title={t("fileManager.downloadFile", { name: file2.name })}
>
<Download className="w-4 h-4 mr-1" />
{file2.name}
</Button>
<Button variant="outline" size="sm" onClick={loadFileContents}>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<div className="flex-1">
<DiffEditor
original={content1}
modified={content2}
language={getFileLanguage(file1.name)}
theme="vs-dark"
options={{
renderSideBySide: diffMode === "side-by-side",
lineNumbers: showLineNumbers ? "on" : "off",
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 13,
wordWrap: "off",
automaticLayout: true,
readOnly: true,
originalEditable: false,
scrollbar: {
vertical: "visible",
horizontal: "visible",
},
diffWordWrap: "off",
ignoreTrimWhitespace: false,
}}
loading={
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">
{t("fileManager.initializingEditor")}
</p>
</div>
</div>
}
/>
</div>
</div>
);
}
@@ -0,0 +1,75 @@
import React from "react";
import { DraggableWindow } from "./DraggableWindow.tsx";
import { DiffViewer } from "./DiffViewer.tsx";
import { useWindowManager } from "./WindowManager.tsx";
import { useTranslation } from "react-i18next";
import type { FileItem, SSHHost } from "@/types/index";
interface DiffWindowProps {
windowId: string;
file1: FileItem;
file2: FileItem;
sshSessionId: string;
sshHost: SSHHost;
initialX?: number;
initialY?: number;
}
export function DiffWindow({
windowId,
file1,
file2,
sshSessionId,
sshHost,
initialX = 150,
initialY = 100,
}: DiffWindowProps) {
const { t } = useTranslation();
const { closeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
const currentWindow = windows.find((w) => w.id === windowId);
const handleClose = () => {
closeWindow(windowId);
};
const handleMaximize = () => {
maximizeWindow(windowId);
};
const handleFocus = () => {
focusWindow(windowId);
};
if (!currentWindow) {
return null;
}
return (
<DraggableWindow
title={t("fileManager.fileComparison", {
file1: file1.name,
file2: file2.name,
})}
initialX={initialX}
initialY={initialY}
initialWidth={1200}
initialHeight={700}
minWidth={800}
minHeight={500}
onClose={handleClose}
onMaximize={handleMaximize}
onFocus={handleFocus}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
>
<DiffViewer
file1={file1}
file2={file2}
sshSessionId={sshSessionId}
sshHost={sshHost}
/>
</DraggableWindow>
);
}
@@ -0,0 +1,384 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils.ts";
import { Minus, X, Maximize2, Minimize2 } from "lucide-react";
import { useTranslation } from "react-i18next";
interface DraggableWindowProps {
title: string;
children: React.ReactNode;
initialX?: number;
initialY?: number;
initialWidth?: number;
initialHeight?: number;
minWidth?: number;
minHeight?: number;
onClose: () => void;
onMinimize?: () => void;
onMaximize?: () => void;
onResize?: () => void;
isMaximized?: boolean;
zIndex?: number;
onFocus?: () => void;
targetSize?: { width: number; height: number };
}
export function DraggableWindow({
title,
children,
initialX = 100,
initialY = 100,
initialWidth = 600,
initialHeight = 400,
minWidth = 300,
minHeight = 200,
onClose,
onMinimize,
onMaximize,
onResize,
isMaximized = false,
zIndex = 1000,
onFocus,
targetSize,
}: DraggableWindowProps) {
const { t } = useTranslation();
const [position, setPosition] = useState({ x: initialX, y: initialY });
const [size, setSize] = useState({
width: initialWidth,
height: initialHeight,
});
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [resizeDirection, setResizeDirection] = useState<string>("");
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 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);
let newWidth = Math.min(targetSize.width + 50, maxWidth);
let newHeight = Math.min(targetSize.height + 150, maxHeight);
if (newWidth > maxWidth || newHeight > maxHeight) {
const widthRatio = maxWidth / newWidth;
const heightRatio = maxHeight / newHeight;
const scale = Math.min(widthRatio, heightRatio);
newWidth = Math.floor(newWidth * scale);
newHeight = Math.floor(newHeight * scale);
}
newWidth = Math.max(newWidth, minWidth);
newHeight = Math.max(newHeight, minHeight);
setSize({ width: newWidth, height: newHeight });
setPosition({
x: Math.max(0, (window.innerWidth - newWidth) / 2),
y: Math.max(0, (window.innerHeight - newHeight) / 2),
});
}
}, [targetSize, isMaximized, minWidth, minHeight]);
const handleWindowClick = useCallback(() => {
onFocus?.();
}, [onFocus]);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (isMaximized) return;
e.preventDefault();
setIsDragging(true);
setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: position.x, y: position.y });
onFocus?.();
},
[isMaximized, position, onFocus],
);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (isDragging && !isMaximized) {
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
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));
setPosition({
x: constrainedX,
y: constrainedY,
});
}
if (isResizing && !isMaximized) {
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
let newWidth = sizeStart.width;
let newHeight = sizeStart.height;
let newX = windowStart.x;
let newY = windowStart.y;
if (resizeDirection.includes("right")) {
newWidth = Math.max(minWidth, sizeStart.width + deltaX);
}
if (resizeDirection.includes("left")) {
const widthChange = -deltaX;
newWidth = Math.max(minWidth, sizeStart.width + widthChange);
if (newWidth > minWidth || widthChange > 0) {
newX = windowStart.x - (newWidth - sizeStart.width);
} else {
newX = windowStart.x - (minWidth - sizeStart.width);
}
}
if (resizeDirection.includes("bottom")) {
newHeight = Math.max(minHeight, sizeStart.height + deltaY);
}
if (resizeDirection.includes("top")) {
const heightChange = -deltaY;
newHeight = Math.max(minHeight, sizeStart.height + heightChange);
if (newHeight > minHeight || heightChange > 0) {
newY = windowStart.y - (newHeight - sizeStart.height);
} else {
newY = windowStart.y - (minHeight - sizeStart.height);
}
}
newX = Math.max(0, Math.min(window.innerWidth - newWidth, newX));
newY = Math.max(0, Math.min(window.innerHeight - newHeight, newY));
setSize({ width: newWidth, height: newHeight });
setPosition({ x: newX, y: newY });
if (onResize) {
onResize();
}
}
},
[
isDragging,
isResizing,
isMaximized,
dragStart,
windowStart,
sizeStart,
size,
position,
minWidth,
minHeight,
resizeDirection,
onResize,
],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
setIsResizing(false);
setResizeDirection("");
}, []);
const handleResizeStart = useCallback(
(e: React.MouseEvent, direction: string) => {
if (isMaximized) return;
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
setResizeDirection(direction);
setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: position.x, y: position.y });
setSizeStart({ width: size.width, height: size.height });
onFocus?.();
},
[isMaximized, position, size, onFocus],
);
useEffect(() => {
if (isDragging || isResizing) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "none";
document.body.style.cursor = isDragging ? "grabbing" : "resizing";
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "";
document.body.style.cursor = "";
};
}
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
const handleTitleDoubleClick = useCallback(() => {
onMaximize?.();
}, [onMaximize]);
return (
<div
ref={windowRef}
className={cn(
"absolute bg-[#0d0e0c] border border-border rounded-none shadow-2xl",
"select-none overflow-hidden flex flex-col",
isMaximized ? "inset-0" : "",
)}
style={{
left: isMaximized ? 0 : position.x,
top: isMaximized ? 0 : position.y,
width: isMaximized ? "100%" : size.width,
height: isMaximized ? "100%" : size.height,
zIndex,
}}
onClick={handleWindowClick}
>
<div
ref={titleBarRef}
className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border shrink-0 cursor-grab active:cursor-grabbing"
onMouseDown={handleMouseDown}
onDoubleClick={handleTitleDoubleClick}
>
<div className="flex items-center gap-3 flex-1 overflow-hidden">
<span className="text-[10px] font-bold uppercase tracking-[0.2em] text-accent-brand shrink-0">
{t("fileManager.editor")}
</span>
<div className="h-4 w-px bg-border/50 shrink-0" />
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground truncate">
{title}
</span>
</div>
<div className="flex items-center gap-0.5">
{onMinimize && (
<button
className="size-6 flex items-center justify-center rounded-none hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={(e) => {
e.stopPropagation();
onMinimize();
}}
title={t("common.minimize")}
>
<Minus className="size-3.5" />
</button>
)}
{onMaximize && (
<button
className="size-6 flex items-center justify-center rounded-none hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={(e) => {
e.stopPropagation();
onMaximize();
}}
title={isMaximized ? t("common.restore") : t("common.maximize")}
>
{isMaximized ? (
<Minimize2 className="size-3.5" />
) : (
<Maximize2 className="size-3.5" />
)}
</button>
)}
<button
className="size-6 flex items-center justify-center rounded-none hover:bg-accent-brand/10 hover:text-accent-brand text-muted-foreground transition-colors"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
title={t("common.close")}
>
<X className="size-3.5" />
</button>
</div>
</div>
<div className="flex-1 overflow-hidden">{children}</div>
{!isMaximized && (
<>
<div
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize"
onMouseDown={(e) => handleResizeStart(e, "top")}
/>
<div
className="absolute bottom-0 left-0 right-0 h-1 cursor-s-resize"
onMouseDown={(e) => handleResizeStart(e, "bottom")}
/>
<div
className="absolute top-0 bottom-0 left-0 w-1 cursor-w-resize"
onMouseDown={(e) => handleResizeStart(e, "left")}
/>
<div
className="absolute top-0 bottom-0 right-0 w-1 cursor-e-resize"
onMouseDown={(e) => handleResizeStart(e, "right")}
/>
<div
className="absolute top-0 left-0 w-2 h-2 cursor-nw-resize"
onMouseDown={(e) => handleResizeStart(e, "top-left")}
/>
<div
className="absolute top-0 right-0 w-2 h-2 cursor-ne-resize"
onMouseDown={(e) => handleResizeStart(e, "top-right")}
/>
<div
className="absolute bottom-0 left-0 w-2 h-2 cursor-sw-resize"
onMouseDown={(e) => handleResizeStart(e, "bottom-left")}
/>
<div
className="absolute bottom-0 right-0 w-2 h-2 cursor-se-resize"
onMouseDown={(e) => handleResizeStart(e, "bottom-right")}
/>
</>
)}
</div>
);
}
@@ -0,0 +1,917 @@
import React, { Suspense, lazy, useState, useEffect, useRef } from "react";
import { cn } from "@/lib/utils.ts";
import { useTranslation } from "react-i18next";
import {
FileText,
Image as ImageIcon,
Film,
Music,
File as FileIcon,
Code,
AlertCircle,
Download,
Eye,
Edit,
Save,
RotateCcw,
Keyboard,
Search,
} from "lucide-react";
import {
SiJavascript,
SiTypescript,
SiPython,
SiCplusplus,
SiC,
SiDotnet,
SiPhp,
SiRuby,
SiGo,
SiRust,
SiHtml5,
SiSass,
SiVuedotjs,
SiSvelte,
SiMarkdown,
SiGnubash,
SiMysql,
SiDocker,
} from "react-icons/si";
import { Button } from "@/components/button.tsx";
import type { CodeEditorHandle } from "./CodeEditor.tsx";
const CodeEditor = lazy(() =>
import("./CodeEditor.tsx").then((module) => ({
default: module.CodeEditor,
})),
);
const ImagePreview = lazy(() =>
import("./ImagePreview.tsx").then((module) => ({
default: module.ImagePreview,
})),
);
const MarkdownRenderer = lazy(() =>
import("./MarkdownRenderer.tsx").then((module) => ({
default: module.MarkdownRenderer,
})),
);
const PdfPreview = lazy(() =>
import("./PdfPreview.tsx").then((module) => ({
default: module.PdfPreview,
})),
);
const AudioPreview = lazy(() =>
import("./AudioPreview.tsx").then((module) => ({
default: module.AudioPreview,
})),
);
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
}
interface FileViewerProps {
file: FileItem;
content?: string;
savedContent?: string;
isLoading?: boolean;
isEditable?: boolean;
onContentChange?: (content: string) => void;
onSave?: (content: string) => void;
onRevert?: () => void;
onDownload?: () => void;
onMediaDimensionsChange?: (dimensions: {
width: number;
height: number;
}) => void;
}
function getLanguageIcon(filename: string): React.ReactNode {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const baseName = filename.toLowerCase();
if (["dockerfile"].includes(baseName)) {
return <SiDocker className="w-6 h-6 text-blue-400" />;
}
if (["makefile", "rakefile", "gemfile"].includes(baseName)) {
return <SiRuby className="w-6 h-6 text-red-500" />;
}
const iconMap: Record<string, React.ReactNode> = {
js: <SiJavascript className="w-6 h-6 text-yellow-400" />,
jsx: <SiJavascript className="w-6 h-6 text-yellow-400" />,
ts: <SiTypescript className="w-6 h-6 text-blue-500" />,
tsx: <SiTypescript className="w-6 h-6 text-blue-500" />,
py: <SiPython className="w-6 h-6 text-blue-400" />,
java: <Code className="w-6 h-6 text-red-500" />,
cpp: <SiCplusplus className="w-6 h-6 text-blue-600" />,
c: <SiC className="w-6 h-6 text-blue-700" />,
cs: <SiDotnet className="w-6 h-6 text-purple-600" />,
php: <SiPhp className="w-6 h-6 text-indigo-500" />,
rb: <SiRuby className="w-6 h-6 text-red-500" />,
go: <SiGo className="w-6 h-6 text-cyan-500" />,
rs: <SiRust className="w-6 h-6 text-orange-600" />,
html: <SiHtml5 className="w-6 h-6 text-orange-500" />,
css: <Code className="w-6 h-6 text-blue-500" />,
scss: <SiSass className="w-6 h-6 text-pink-500" />,
sass: <SiSass className="w-6 h-6 text-pink-500" />,
less: <Code className="w-6 h-6 text-blue-600" />,
json: <Code className="w-6 h-6 text-yellow-500" />,
xml: <Code className="w-6 h-6 text-orange-500" />,
yaml: <Code className="w-6 h-6 text-red-400" />,
yml: <Code className="w-6 h-6 text-red-400" />,
toml: <Code className="w-6 h-6 text-orange-400" />,
sql: <SiMysql className="w-6 h-6 text-blue-500" />,
sh: <SiGnubash className="w-6 h-6 text-foreground" />,
bash: <SiGnubash className="w-6 h-6 text-foreground" />,
zsh: <Code className="w-6 h-6 text-foreground" />,
vue: <SiVuedotjs className="w-6 h-6 text-green-500" />,
svelte: <SiSvelte className="w-6 h-6 text-orange-500" />,
md: <SiMarkdown className="w-6 h-6 text-muted-foreground" />,
conf: <Code className="w-6 h-6 text-muted-foreground" />,
ini: <Code className="w-6 h-6 text-muted-foreground" />,
};
return iconMap[ext] || <Code className="w-6 h-6 text-yellow-500" />;
}
function getFileType(filename: string): {
type: string;
icon: React.ReactNode;
color: string;
} {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp"];
const videoExts = ["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm"];
const audioExts = ["mp3", "wav", "flac", "ogg", "aac", "m4a"];
const textExts = ["txt", "readme"];
const markdownExts = ["md", "markdown", "mdown", "mkdn", "mdx"];
const pdfExts = ["pdf"];
const codeExts = [
"js",
"ts",
"jsx",
"tsx",
"py",
"java",
"cpp",
"c",
"cs",
"php",
"rb",
"go",
"rs",
"html",
"css",
"scss",
"less",
"json",
"xml",
"yaml",
"yml",
"toml",
"ini",
"conf",
"sh",
"bash",
"zsh",
"sql",
"vue",
"svelte",
];
if (imageExts.includes(ext)) {
return {
type: "image",
icon: <ImageIcon className="w-6 h-6" />,
color: "text-green-500",
};
} else if (videoExts.includes(ext)) {
return {
type: "video",
icon: <Film className="w-6 h-6" />,
color: "text-purple-500",
};
} else if (audioExts.includes(ext)) {
return {
type: "audio",
icon: <Music className="w-6 h-6" />,
color: "text-pink-500",
};
} else if (markdownExts.includes(ext)) {
return {
type: "markdown",
icon: <FileText className="w-6 h-6" />,
color: "text-blue-600",
};
} else if (pdfExts.includes(ext)) {
return {
type: "pdf",
icon: <FileText className="w-6 h-6" />,
color: "text-red-600",
};
} else if (textExts.includes(ext)) {
return {
type: "text",
icon: <FileText className="w-6 h-6" />,
color: "text-blue-500",
};
} else if (codeExts.includes(ext)) {
return {
type: "code",
icon: getLanguageIcon(filename),
color: "text-yellow-500",
};
} else {
return {
type: "unknown",
icon: <FileIcon className="w-6 h-6" />,
color: "text-foreground-subtle",
};
}
}
function formatFileSize(bytes?: number, t?: (key: string) => string): string {
if (!bytes) return t ? t("fileManager.unknownSize") : "Unknown size";
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
}
function PreviewFallback({ label }: { label: string }) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<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">{label}</p>
</div>
</div>
);
}
export function FileViewer({
file,
content = "",
savedContent = "",
isLoading = false,
isEditable = false,
onContentChange,
onSave,
onRevert,
onDownload,
onMediaDimensionsChange,
}: FileViewerProps) {
const { t } = useTranslation();
const [editedContent, setEditedContent] = useState(content);
const [, setOriginalContent] = useState(savedContent || content);
const [hasChanges, setHasChanges] = useState(false);
const [showLargeFileWarning, setShowLargeFileWarning] = useState(false);
const [forceShowAsText, setForceShowAsText] = useState(false);
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false);
const [editorFocused, setEditorFocused] = useState(false);
const [markdownEditMode, setMarkdownEditMode] = useState(false);
const editorRef = useRef<CodeEditorHandle | null>(null);
const fileTypeInfo = getFileType(file.name);
const WARNING_SIZE = 50 * 1024 * 1024;
const MAX_SIZE = Number.MAX_SAFE_INTEGER;
const shouldShowAsText =
fileTypeInfo.type === "text" ||
fileTypeInfo.type === "code" ||
(fileTypeInfo.type === "unknown" &&
(forceShowAsText || !file.size || file.size <= WARNING_SIZE));
const isLargeFile = file.size && file.size > WARNING_SIZE;
const isTooLarge = file.size && file.size > MAX_SIZE;
useEffect(() => {
setEditedContent(content);
if (savedContent) {
setOriginalContent(savedContent);
}
setHasChanges(content !== savedContent);
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
setShowLargeFileWarning(true);
} else {
setShowLargeFileWarning(false);
}
}, [
content,
savedContent,
fileTypeInfo.type,
isLargeFile,
forceShowAsText,
file.name,
]);
const handleContentChange = (newContent: string) => {
setEditedContent(newContent);
setHasChanges(newContent !== savedContent);
onContentChange?.(newContent);
};
const handleSave = () => {
onSave?.(editedContent);
};
const handleRevert = () => {
if (onRevert) {
onRevert();
} else {
setEditedContent(savedContent);
setHasChanges(false);
}
};
useEffect(() => {
if (!editorFocused || !isEditable) return;
const handleKeyDown = (e: KeyboardEvent) => {
const isCtrl = e.ctrlKey || e.metaKey;
if (isCtrl && e.key.toLowerCase() === "s") {
e.preventDefault();
e.stopPropagation();
handleSave();
}
};
document.addEventListener("keydown", handleKeyDown, true);
return () => {
document.removeEventListener("keydown", handleKeyDown, true);
};
}, [editorFocused, isEditable, handleSave]);
const handleConfirmOpenAsText = () => {
setForceShowAsText(true);
setShowLargeFileWarning(false);
};
const handleCancelOpenAsText = () => {
setShowLargeFileWarning(false);
};
if (isLoading) {
return (
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
<p className="text-sm text-muted-foreground">Loading file...</p>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-background">
<div className="flex-shrink-0 bg-card border-b border-border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={cn("p-2 rounded-lg bg-muted", fileTypeInfo.color)}>
{fileTypeInfo.icon}
</div>
<div>
<h3 className="font-medium text-foreground">{file.name}</h3>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>{formatFileSize(file.size, t)}</span>
{file.modified && (
<span>
{t("fileManager.modified")}: {file.modified}
</span>
)}
<span
className={cn(
"px-2 py-1 rounded-full text-xs",
fileTypeInfo.color,
"bg-muted",
)}
>
{fileTypeInfo.type.toUpperCase()}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{isEditable && (
<Button
variant="ghost"
size="sm"
onClick={() => editorRef.current?.openSearchPanel()}
className="flex items-center gap-2"
title={t("fileManager.searchInFile")}
>
<Search className="w-4 h-4" />
</Button>
)}
{isEditable && (
<Button
variant="ghost"
size="sm"
onClick={() => setShowKeyboardShortcuts(!showKeyboardShortcuts)}
className="flex items-center gap-2"
title={t("fileManager.showKeyboardShortcuts")}
>
<Keyboard className="w-4 h-4" />
</Button>
)}
{hasChanges && (
<>
<Button
variant="outline"
size="sm"
onClick={handleRevert}
className="flex items-center gap-2"
>
<RotateCcw className="w-4 h-4" />
Revert
</Button>
<Button
variant="default"
size="sm"
onClick={handleSave}
className="flex items-center gap-2"
>
<Save className="w-4 h-4" />
Save
</Button>
</>
)}
{onDownload && (
<Button
variant="outline"
size="sm"
onClick={onDownload}
className="flex items-center gap-2"
>
<Download className="w-4 h-4" />
{t("fileManager.download")}
</Button>
)}
</div>
</div>
</div>
{showKeyboardShortcuts && isEditable && (
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold">
{t("fileManager.keyboardShortcuts")}
</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setShowKeyboardShortcuts(false)}
className="h-6 w-6 p-0"
>
×
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-xs">
<div className="space-y-2">
<h4 className="font-medium text-muted-foreground">
{t("fileManager.searchAndReplace")}
</h4>
<div className="space-y-1">
<div className="flex justify-between">
<span>{t("fileManager.search")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+F
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.replace")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+H
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.findNext")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
F3
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.findPrevious")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Shift+F3
</KbdKey>
</Kbd>
</div>
</div>
</div>
<div className="space-y-2">
<h4 className="font-medium text-muted-foreground">
{t("fileManager.editing")}
</h4>
<div className="space-y-1">
<div className="flex justify-between">
<span>{t("fileManager.save")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+S
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.selectAll")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+A
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.undo")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+Z
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.redo")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+Y
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.toggleComment")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+/
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.autoComplete")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Ctrl+Space
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.moveLineUp")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Alt+
</KbdKey>
</Kbd>
</div>
<div className="flex justify-between">
<span>{t("fileManager.moveLineDown")}</span>
<Kbd className="px-2 py-1 bg-background rounded text-xs">
<KbdKey className="px-2 py-1 bg-background rounded text-xs">
Alt+
</KbdKey>
</Kbd>
</div>
</div>
</div>
</div>
</div>
)}
<div className="flex-1 overflow-hidden">
{showLargeFileWarning && (
<div className="h-full flex items-center justify-center bg-background">
<div className="bg-card border border-destructive/30 rounded-lg p-6 max-w-md mx-4 shadow-lg">
<div className="flex items-start gap-3 mb-4">
<AlertCircle className="w-6 h-6 text-destructive flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-foreground mb-2">
{t("fileManager.largeFileWarning")}
</h3>
<p className="text-sm text-muted-foreground mb-3">
{t("fileManager.largeFileWarningDesc", {
size: formatFileSize(file.size, t),
})}
</p>
{isTooLarge ? (
<div className="bg-destructive/10 border border-destructive/30 rounded p-3 mb-4">
<p className="text-sm text-destructive font-medium">
File is too large (&gt; 10MB) and cannot be opened as
text for security reasons.
</p>
</div>
) : (
<p className="text-sm text-muted-foreground mb-4">
Do you want to continue opening this file as text? This
may slow down your browser.
</p>
)}
</div>
</div>
<div className="flex gap-3">
{!isTooLarge && (
<Button
variant="default"
size="sm"
onClick={handleConfirmOpenAsText}
className="flex items-center gap-2"
>
<FileText className="w-4 h-4" />
Open as Text
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={onDownload}
className="flex items-center gap-2"
>
<Download className="w-4 h-4" />
{t("fileManager.downloadInstead")}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleCancelOpenAsText}
>
Cancel
</Button>
</div>
</div>
</div>
)}
{fileTypeInfo.type === "image" && !showLargeFileWarning && (
<Suspense fallback={<PreviewFallback label="Loading image..." />}>
<ImagePreview
content={content}
fileName={file.name}
onDownload={onDownload}
onMediaDimensionsChange={onMediaDimensionsChange}
/>
</Suspense>
)}
{shouldShowAsText && !showLargeFileWarning && (
<div className="h-full flex flex-col">
{isEditable ? (
<Suspense
fallback={<PreviewFallback label="Loading editor..." />}
>
<CodeEditor
ref={editorRef}
fileName={file.name}
value={editedContent}
onChange={handleContentChange}
onFocus={() => setEditorFocused(true)}
onBlur={() => setEditorFocused(false)}
placeholder={t("fileManager.startTyping")}
/>
</Suspense>
) : (
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto thin-scrollbar bg-background text-foreground">
{editedContent || content || t("fileManager.fileIsEmpty")}
</div>
)}
</div>
)}
{fileTypeInfo.type === "video" && !showLargeFileWarning && (
<div className="p-6 flex items-center justify-center h-full">
<div className="w-full max-w-4xl">
{(() => {
const ext = file.name.split(".").pop()?.toLowerCase() || "";
const mimeType = (() => {
switch (ext) {
case "mp4":
return "video/mp4";
case "webm":
return "video/webm";
case "mkv":
return "video/x-matroska";
case "avi":
return "video/x-msvideo";
case "mov":
return "video/quicktime";
case "wmv":
return "video/x-ms-wmv";
case "flv":
return "video/x-flv";
default:
return "video/mp4";
}
})();
const videoUrl = `data:${mimeType};base64,${content}`;
return (
<div className="relative">
<video
controls
className="w-full rounded-lg shadow-sm"
style={{
maxHeight: "calc(100vh - 200px)",
backgroundColor: "#000",
}}
preload="metadata"
onError={(e) => {
console.error(
"Video playback error:",
e.currentTarget.error,
);
}}
onLoadedMetadata={(e) => {
const video = e.currentTarget;
if (
onMediaDimensionsChange &&
video.videoWidth &&
video.videoHeight
) {
onMediaDimensionsChange({
width: video.videoWidth,
height: video.videoHeight,
});
}
}}
>
<source src={videoUrl} type={mimeType} />
<div className="text-center text-muted-foreground p-4">
<AlertCircle className="w-8 h-8 mx-auto mb-2" />
<p>
Your browser does not support video playback for this
format.
</p>
{onDownload && (
<Button
variant="outline"
onClick={onDownload}
className="mt-2 flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
Download to play externally
</Button>
)}
</div>
</video>
</div>
);
})()}
</div>
</div>
)}
{fileTypeInfo.type === "markdown" && !showLargeFileWarning && (
<div className="h-full flex flex-col">
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
variant={markdownEditMode ? "default" : "outline"}
size="sm"
onClick={() => setMarkdownEditMode(true)}
>
<Edit className="w-4 h-4 mr-1" />
{t("fileManager.edit")}
</Button>
<Button
variant={!markdownEditMode ? "default" : "outline"}
size="sm"
onClick={() => setMarkdownEditMode(false)}
>
<Eye className="w-4 h-4 mr-1" />
{t("fileManager.preview")}
</Button>
</div>
<div className="flex items-center gap-2"></div>
</div>
</div>
<div className="flex-1 flex overflow-hidden">
{markdownEditMode ? (
<>
<div className="flex-1 border-r border-border">
<div className="h-full p-4 bg-background">
<textarea
value={editedContent}
onChange={(e) => {
setEditedContent(e.target.value);
onContentChange?.(e.target.value);
}}
className="w-full h-full resize-none border-0 bg-transparent text-foreground font-mono text-sm leading-relaxed focus:outline-none focus:ring-0"
placeholder={t("fileManager.startWritingMarkdown")}
/>
</div>
</div>
<div className="flex-1 overflow-auto thin-scrollbar bg-muted/10">
<div className="p-4">
<Suspense
fallback={
<PreviewFallback label="Loading preview..." />
}
>
<MarkdownRenderer
compact
content={editedContent || "Nothing to preview yet..."}
/>
</Suspense>
</div>
</div>
</>
) : (
<div className="flex-1 overflow-auto thin-scrollbar p-6">
<div className="max-w-4xl mx-auto">
<Suspense
fallback={<PreviewFallback label="Loading preview..." />}
>
<MarkdownRenderer content={editedContent} />
</Suspense>
</div>
</div>
)}
</div>
</div>
)}
{fileTypeInfo.type === "pdf" && !showLargeFileWarning && (
<Suspense
fallback={<PreviewFallback label="Loading PDF viewer..." />}
>
<PdfPreview
content={content}
onDownload={onDownload}
onMediaDimensionsChange={onMediaDimensionsChange}
/>
</Suspense>
)}
{fileTypeInfo.type === "audio" && !showLargeFileWarning && (
<Suspense
fallback={<PreviewFallback label="Loading audio player..." />}
>
<AudioPreview
file={file}
content={content}
color={fileTypeInfo.color}
onMediaDimensionsChange={onMediaDimensionsChange}
/>
</Suspense>
)}
{fileTypeInfo.type === "unknown" &&
!shouldShowAsText &&
!showLargeFileWarning && (
<div className="h-full flex items-center justify-center">
<div className="text-center text-muted-foreground">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">
Cannot preview this file type
</h3>
<p className="text-sm mb-4">
This file type is not supported for preview. You can download
it to view in an external application.
</p>
{onDownload && (
<Button
variant="outline"
onClick={onDownload}
className="flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
{t("fileManager.downloadFile")}
</Button>
)}
</div>
</div>
)}
</div>
<div className="flex-shrink-0 bg-muted/50 border-t border-border px-4 py-2 text-xs text-muted-foreground">
<div className="flex justify-between items-center">
<span>{file.path}</span>
{hasChanges && (
<span className="text-orange-600 font-medium">
Unsaved changes
</span>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,452 @@
import React, { useState, useEffect, useRef } from "react";
import { DraggableWindow } from "./DraggableWindow.tsx";
import { FileViewer } from "./FileViewer.tsx";
import { useWindowManager } from "./WindowManager.tsx";
import {
downloadSSHFile,
readSSHFile,
writeSSHFile,
getSSHStatus,
connectSSH,
} from "@/main-axios.ts";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
authType: "password" | "key";
credentialId?: number;
userId?: number;
}
interface FileWindowProps {
windowId: string;
file: FileItem;
sshSessionId: string;
sshHost: SSHHost;
initialX?: number;
initialY?: number;
onFileNotFound?: (file: FileItem) => void;
}
function isDisplayableText(str: string): boolean {
let printable = 0;
for (let i = 0; i < Math.min(str.length, 1000); i++) {
const code = str.charCodeAt(i);
if (
(code >= 32 && code <= 126) ||
code === 9 ||
code === 10 ||
code === 13
) {
printable++;
}
}
return printable / Math.min(str.length, 1000) > 0.85;
}
export function FileWindow({
windowId,
file,
sshSessionId,
sshHost,
initialX = 100,
initialY = 100,
onFileNotFound,
}: FileWindowProps) {
const { closeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
const { t } = useTranslation();
const [content, setContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [isEditable, setIsEditable] = useState(false);
const [pendingContent, setPendingContent] = useState<string>("");
const [mediaDimensions, setMediaDimensions] = useState<
{ width: number; height: number } | undefined
>();
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const currentWindow = windows.find((w) => w.id === windowId);
const ensureSSHConnection = async () => {
try {
const status = await getSSHStatus(sshSessionId);
if (!status.connected) {
await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
port: sshHost.port,
username: sshHost.username,
password: sshHost.password,
sshKey: sshHost.key,
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId,
});
}
} catch (error) {
console.error("SSH connection check/reconnect failed:", error);
throw error;
}
};
useEffect(() => {
const loadFileContent = async () => {
if (file.type !== "file") return;
try {
setIsLoading(true);
await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path);
let fileContent = response.content || "";
if (response.encoding === "base64") {
try {
const decoded = atob(fileContent);
if (isDisplayableText(decoded)) {
fileContent = decoded;
}
} catch (err) {
console.error("Failed to decode base64 content:", err);
}
}
setContent(fileContent);
setPendingContent(fileContent);
if (!file.size) {
const contentSize = new Blob([fileContent]).size;
file.size = contentSize;
}
const mediaExtensions = [
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"svg",
"webp",
"tiff",
"ico",
"mp3",
"wav",
"ogg",
"aac",
"flac",
"m4a",
"wma",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"mkv",
"webm",
"m4v",
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"xz",
"exe",
"dll",
"so",
"dylib",
"bin",
"iso",
];
const extension = file.name.split(".").pop()?.toLowerCase();
setIsEditable(!mediaExtensions.includes(extension || ""));
} catch (error: unknown) {
console.error("Failed to load file:", error);
const err = error as {
message?: string;
isFileNotFound?: boolean;
response?: {
status?: number;
data?: {
tooLarge?: boolean;
error?: string;
fileNotFound?: boolean;
};
};
};
const errorData = err?.response?.data;
if (errorData?.tooLarge) {
toast.error(`File too large: ${errorData.error}`, {
duration: 10000,
});
} else if (
err.message?.includes("connection") ||
err.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
const errorMessage =
errorData?.error || err.message || "Unknown error";
const isFileNotFound =
err.isFileNotFound ||
errorData?.fileNotFound ||
err.response?.status === 404 ||
errorMessage.includes("File not found") ||
errorMessage.includes("No such file or directory") ||
errorMessage.includes("cannot access") ||
errorMessage.includes("not found") ||
errorMessage.includes("Resource not found");
if (isFileNotFound && onFileNotFound) {
onFileNotFound(file);
toast.error(
t("fileManager.fileNotFoundAndRemoved", { name: file.name }),
);
closeWindow(windowId);
return;
} else {
toast.error(
t("fileManager.failedToLoadFile", {
error: errorMessage.includes("Server error occurred")
? t("fileManager.serverErrorOccurred")
: errorMessage,
}),
);
}
}
} finally {
setIsLoading(false);
}
};
loadFileContent();
}, [file, sshSessionId, sshHost]);
const handleRevert = async () => {
const loadFileContent = async () => {
if (file.type !== "file") return;
try {
setIsLoading(true);
await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path);
const fileContent = response.content || "";
setContent(fileContent);
setPendingContent("");
if (!file.size) {
const contentSize = new Blob([fileContent]).size;
file.size = contentSize;
}
} catch (error: unknown) {
console.error("Failed to load file content:", error);
const err = error as { message?: string };
toast.error(
`${t("fileManager.failedToLoadFile")}: ${err.message || t("fileManager.unknownError")}`,
);
} finally {
setIsLoading(false);
}
};
loadFileContent();
};
const handleSave = async (newContent: string) => {
try {
setIsLoading(true);
await ensureSSHConnection();
await writeSSHFile(sshSessionId, file.path, newContent);
setContent(newContent);
setPendingContent("");
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
toast.success(t("fileManager.fileSavedSuccessfully"));
} catch (error: unknown) {
console.error("Failed to save file:", error);
const err = error as { message?: string };
if (
err.message?.includes("connection") ||
err.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
toast.error(
`${t("fileManager.failedToSaveFile")}: ${err.message || t("fileManager.unknownError")}`,
);
}
} finally {
setIsLoading(false);
}
};
const handleContentChange = (newContent: string) => {
setPendingContent(newContent);
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
if (newContent !== content) {
autoSaveTimerRef.current = setTimeout(async () => {
try {
await handleSave(newContent);
toast.success(t("fileManager.fileAutoSaved"));
} catch (error) {
console.error("Auto-save failed:", error);
toast.error(t("fileManager.autoSaveFailed"));
}
}, 60000);
}
};
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
};
}, []);
const handleDownload = async () => {
try {
await ensureSSHConnection();
const response = await downloadSSHFile(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"));
}
} catch (error: unknown) {
console.error("Failed to download file:", error);
const err = error as { message?: string };
if (
err.message?.includes("connection") ||
err.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
toast.error(
`Failed to download file: ${err.message || "Unknown error"}`,
);
}
}
};
const handleClose = () => {
closeWindow(windowId);
};
const handleMaximize = () => {
maximizeWindow(windowId);
};
const handleFocus = () => {
focusWindow(windowId);
};
const handleMediaDimensionsChange = (dimensions: {
width: number;
height: number;
}) => {
setMediaDimensions(dimensions);
};
if (!currentWindow) {
return null;
}
return (
<DraggableWindow
title={file.name}
initialX={initialX}
initialY={initialY}
initialWidth={800}
initialHeight={600}
minWidth={400}
minHeight={300}
onClose={handleClose}
onMaximize={handleMaximize}
onFocus={handleFocus}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
targetSize={mediaDimensions}
>
<FileViewer
file={file}
content={pendingContent || content}
savedContent={content}
isLoading={isLoading}
onRevert={handleRevert}
isEditable={isEditable}
onContentChange={handleContentChange}
onSave={(newContent) => handleSave(newContent)}
onDownload={handleDownload}
onMediaDimensionsChange={handleMediaDimensionsChange}
/>
</DraggableWindow>
);
}
@@ -0,0 +1,112 @@
import React, { useState } from "react";
import { PhotoProvider, PhotoView } from "react-photo-view";
import "react-photo-view/dist/react-photo-view.css";
import { AlertCircle, Download } from "lucide-react";
import { Button } from "@/components/button.tsx";
import { useTranslation } from "react-i18next";
interface ImagePreviewProps {
content: string;
fileName: string;
onDownload?: () => void;
onMediaDimensionsChange?: (dimensions: {
width: number;
height: number;
}) => void;
}
function getImageDataUrl(content: string, fileName: string): string {
const ext = fileName.split(".").pop()?.toLowerCase() || "";
const mimeTypes: Record<string, string> = {
svg: "image/svg+xml",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
ico: "image/x-icon",
tiff: "image/tiff",
tif: "image/tiff",
};
const mimeType = mimeTypes[ext] || "image/png";
return `data:${mimeType};base64,${content}`;
}
export function ImagePreview({
content,
fileName,
onDownload,
onMediaDimensionsChange,
}: ImagePreviewProps) {
const { t } = useTranslation();
const [imageLoadError, setImageLoadError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const imageUrl = getImageDataUrl(content, fileName);
return (
<div className="p-6 flex items-center justify-center h-full relative">
{imageLoadError ? (
<div className="text-center text-muted-foreground">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">
{t("fileManager.imageLoadError")}
</h3>
<p className="text-sm mb-4">{fileName}</p>
{onDownload && (
<Button
variant="outline"
onClick={onDownload}
className="flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
{t("fileManager.download")}
</Button>
)}
</div>
) : (
<PhotoProvider maskOpacity={0.7}>
<PhotoView src={imageUrl}>
<img
src={imageUrl}
alt={fileName}
className="max-w-full max-h-full object-contain rounded-lg shadow-sm cursor-pointer hover:shadow-lg transition-shadow"
style={{ maxHeight: "calc(100vh - 200px)" }}
onLoad={(e) => {
setImageLoading(false);
setImageLoadError(false);
const img = e.currentTarget;
if (
onMediaDimensionsChange &&
img.naturalWidth &&
img.naturalHeight
) {
onMediaDimensionsChange({
width: img.naturalWidth,
height: img.naturalHeight,
});
}
}}
onError={() => {
setImageLoading(false);
setImageLoadError(true);
}}
/>
</PhotoView>
</PhotoProvider>
)}
{imageLoading && !imageLoadError && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80">
<div className="text-center">
<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 image...</p>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,119 @@
import React from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark as syntaxTheme } from "react-syntax-highlighter/dist/esm/styles/prism";
interface MarkdownRendererProps {
content: string;
compact?: boolean;
}
export function MarkdownRenderer({
content,
compact = false,
}: MarkdownRendererProps) {
const h1Class = compact
? "text-2xl font-bold mb-4 mt-6 text-foreground border-b border-border pb-2"
: "text-3xl font-bold mb-6 mt-8 text-foreground border-b border-border pb-2";
const h2Class = compact
? "text-xl font-semibold mb-3 mt-5 text-foreground border-b border-border pb-1"
: "text-2xl font-semibold mb-4 mt-6 text-foreground border-b border-border pb-1";
const h3Class = compact
? "text-lg font-semibold mb-2 mt-4 text-foreground"
: "text-xl font-semibold mb-3 mt-4 text-foreground";
const h4Class = compact
? "text-base font-semibold mb-2 mt-3 text-foreground"
: "text-lg font-semibold mb-2 mt-3 text-foreground";
const pClass = compact
? "mb-3 text-foreground leading-relaxed"
: "mb-4 text-foreground leading-relaxed";
const listClass = compact
? "mb-3 ml-4 text-foreground"
: "mb-4 ml-6 text-foreground";
const quoteClass = compact
? "border-l-4 border-blue-500 pl-3 mb-3 italic text-muted-foreground bg-muted/30 py-1"
: "border-l-4 border-blue-500 pl-4 mb-4 italic text-muted-foreground bg-muted/30 py-2";
const tableWrapClass = compact
? "mb-3 overflow-x-auto thin-scrollbar"
: "mb-4 overflow-x-auto thin-scrollbar";
const tableClass = compact
? "min-w-full border border-border rounded-lg text-sm"
: "min-w-full border border-border rounded-lg";
const thClass = compact
? "px-3 py-2 text-left font-semibold text-foreground"
: "px-4 py-2 text-left font-semibold text-foreground";
const tdClass = compact
? "px-3 py-2 text-foreground"
: "px-4 py-2 text-foreground";
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code({ inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
return !inline && match ? (
<SyntaxHighlighter
style={syntaxTheme}
language={match[1]}
PreTag="div"
className="rounded-lg"
{...props}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
) : (
<code
className="bg-muted px-1 py-0.5 rounded text-sm font-mono"
{...props}
>
{children}
</code>
);
},
h1: ({ children }) => <h1 className={h1Class}>{children}</h1>,
h2: ({ children }) => <h2 className={h2Class}>{children}</h2>,
h3: ({ children }) => <h3 className={h3Class}>{children}</h3>,
h4: ({ children }) => <h4 className={h4Class}>{children}</h4>,
p: ({ children }) => <p className={pClass}>{children}</p>,
ul: ({ children }) => (
<ul className={`${listClass} list-disc`}>{children}</ul>
),
ol: ({ children }) => (
<ol className={`${listClass} list-decimal`}>{children}</ol>
),
li: ({ children }) => (
<li className="mb-1 text-foreground">{children}</li>
),
blockquote: ({ children }) => (
<blockquote className={quoteClass}>{children}</blockquote>
),
table: ({ children }) => (
<div className={tableWrapClass}>
<table className={tableClass}>{children}</table>
</div>
),
thead: ({ children }) => <thead className="bg-muted">{children}</thead>,
tbody: ({ children }) => <tbody>{children}</tbody>,
tr: ({ children }) => (
<tr className="border-b border-border">{children}</tr>
),
th: ({ children }) => <th className={thClass}>{children}</th>,
td: ({ children }) => <td className={tdClass}>{children}</td>,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline"
>
{children}
</a>
),
}}
>
{content}
</ReactMarkdown>
);
}
@@ -0,0 +1,147 @@
import React, { useState } from "react";
import { Document, Page, pdfjs } from "react-pdf";
import { AlertCircle, Download } from "lucide-react";
import { Button } from "@/components/button.tsx";
import { useTranslation } from "react-i18next";
pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.js";
interface PdfPreviewProps {
content: string;
onDownload?: () => void;
onMediaDimensionsChange?: (dimensions: {
width: number;
height: number;
}) => void;
}
export function PdfPreview({
content,
onDownload,
onMediaDimensionsChange,
}: PdfPreviewProps) {
const { t } = useTranslation();
const [numPages, setNumPages] = useState<number | null>(null);
const [pageNumber, setPageNumber] = useState(1);
const [pdfScale, setPdfScale] = useState(1.2);
const [pdfError, setPdfError] = useState(false);
return (
<div className="h-full flex flex-col bg-background">
<div className="flex-shrink-0 bg-muted/30 border-b border-border p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageNumber(Math.max(1, pageNumber - 1))}
disabled={pageNumber <= 1}
>
{t("fileManager.previous")}
</Button>
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border">
{t("fileManager.pageXOfY", {
current: pageNumber,
total: numPages || 0,
})}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
setPageNumber(Math.min(numPages || 1, pageNumber + 1))
}
disabled={!numPages || pageNumber >= numPages}
>
{t("fileManager.next")}
</Button>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPdfScale(Math.max(0.5, pdfScale - 0.2))}
>
{t("fileManager.zoomOut")}
</Button>
<span className="text-sm text-foreground px-3 py-1 bg-background rounded border min-w-[80px] text-center">
{Math.round(pdfScale * 100)}%
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPdfScale(Math.min(3.0, pdfScale + 0.2))}
>
{t("fileManager.zoomIn")}
</Button>
</div>
</div>
</div>
</div>
<div className="flex-1 overflow-auto thin-scrollbar p-6 bg-surface">
<div className="flex justify-center">
{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>
{onDownload && (
<Button
variant="outline"
onClick={onDownload}
className="flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
{t("fileManager.download")}
</Button>
)}
</div>
) : (
<Document
file={`data:application/pdf;base64,${content}`}
onLoadSuccess={({ numPages }) => {
setNumPages(numPages);
setPdfError(false);
onMediaDimensionsChange?.({
width: 800,
height: 600,
});
}}
onLoadError={(error) => {
console.error("PDF load error:", error);
setPdfError(true);
}}
loading={
<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...
</p>
</div>
}
>
<Page
pageNumber={pageNumber}
scale={pdfScale}
className="shadow-lg"
loading={
<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...
</p>
</div>
}
/>
</Document>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,260 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/dialog.tsx";
import { Button } from "@/components/button.tsx";
import { Input } from "@/components/input.tsx";
import { useTranslation } from "react-i18next";
import { Lock } from "lucide-react";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
permissions?: string;
owner?: string;
group?: string;
}
interface PermissionsDialogProps {
file: FileItem | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onSave: (file: FileItem, permissions: string) => Promise<void>;
}
const parsePermissions = (
perms: string,
): { owner: number; group: number; other: number } => {
if (!perms) {
return { owner: 0, group: 0, other: 0 };
}
if (/^\d{3,4}$/.test(perms)) {
const numStr = perms.slice(-3);
return {
owner: parseInt(numStr[0] || "0", 10),
group: parseInt(numStr[1] || "0", 10),
other: parseInt(numStr[2] || "0", 10),
};
}
const cleanPerms = perms.replace(/^-/, "").substring(0, 9);
const calcBits = (str: string): number => {
let value = 0;
if (str[0] === "r") value += 4;
if (str[1] === "w") value += 2;
if (str[2] === "x") value += 1;
return value;
};
return {
owner: calcBits(cleanPerms.substring(0, 3)),
group: calcBits(cleanPerms.substring(3, 6)),
other: calcBits(cleanPerms.substring(6, 9)),
};
};
const toNumeric = (owner: number, group: number, other: number): string => {
return `${owner}${group}${other}`;
};
export function PermissionsDialog({
file,
open,
onOpenChange,
onSave,
}: PermissionsDialogProps) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const initialPerms = parsePermissions(file?.permissions || "644");
const [ownerRead, setOwnerRead] = useState((initialPerms.owner & 4) !== 0);
const [ownerWrite, setOwnerWrite] = useState((initialPerms.owner & 2) !== 0);
const [ownerExecute, setOwnerExecute] = useState(
(initialPerms.owner & 1) !== 0,
);
const [groupRead, setGroupRead] = useState((initialPerms.group & 4) !== 0);
const [groupWrite, setGroupWrite] = useState((initialPerms.group & 2) !== 0);
const [groupExecute, setGroupExecute] = useState(
(initialPerms.group & 1) !== 0,
);
const [otherRead, setOtherRead] = useState((initialPerms.other & 4) !== 0);
const [otherWrite, setOtherWrite] = useState((initialPerms.other & 2) !== 0);
const [otherExecute, setOtherExecute] = useState(
(initialPerms.other & 1) !== 0,
);
useEffect(() => {
if (file) {
const perms = parsePermissions(file.permissions || "644");
setOwnerRead((perms.owner & 4) !== 0);
setOwnerWrite((perms.owner & 2) !== 0);
setOwnerExecute((perms.owner & 1) !== 0);
setGroupRead((perms.group & 4) !== 0);
setGroupWrite((perms.group & 2) !== 0);
setGroupExecute((perms.group & 1) !== 0);
setOtherRead((perms.other & 4) !== 0);
setOtherWrite((perms.other & 2) !== 0);
setOtherExecute((perms.other & 1) !== 0);
}
}, [file]);
const calculateOctal = (): string => {
const owner =
(ownerRead ? 4 : 0) + (ownerWrite ? 2 : 0) + (ownerExecute ? 1 : 0);
const group =
(groupRead ? 4 : 0) + (groupWrite ? 2 : 0) + (groupExecute ? 1 : 0);
const other =
(otherRead ? 4 : 0) + (otherWrite ? 2 : 0) + (otherExecute ? 1 : 0);
return toNumeric(owner, group, other);
};
const handleSave = async () => {
if (!file) return;
setLoading(true);
try {
const permissions = calculateOctal();
await onSave(file, permissions);
onOpenChange(false);
} catch (error) {
console.error("Failed to update permissions:", error);
} finally {
setLoading(false);
}
};
if (!file) return null;
const octal = calculateOctal();
const rows = [
{
label: `${t("fileManager.owner")}${file.owner ? ` (${file.owner})` : ""}`,
read: ownerRead,
setRead: setOwnerRead,
write: ownerWrite,
setWrite: setOwnerWrite,
execute: ownerExecute,
setExecute: setOwnerExecute,
},
{
label: `${t("fileManager.group")}${file.group ? ` (${file.group})` : ""}`,
read: groupRead,
setRead: setGroupRead,
write: groupWrite,
setWrite: setGroupWrite,
execute: groupExecute,
setExecute: setGroupExecute,
},
{
label: t("fileManager.others"),
read: otherRead,
setRead: setOtherRead,
write: otherWrite,
setWrite: setOtherWrite,
execute: otherExecute,
setExecute: setOtherExecute,
},
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[calc(100vw-2rem)] sm:max-w-md 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">
{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>
))}
{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">
{row.label}
</div>
{[
{ val: row.read, set: row.setRead },
{ val: row.write, set: row.setWrite },
{ val: row.execute, set: row.setExecute },
].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"
>
<input
type="checkbox"
checked={perm.val}
onChange={(e) => perm.set(e.target.checked)}
className="accent-[var(--accent-brand)] size-3.5 cursor-pointer"
/>
</div>
))}
</React.Fragment>
))}
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{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 || "—"}
</span>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => onOpenChange(false)}
disabled={loading}
className="rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{t("common.cancel")}
</Button>
<Button
variant="outline"
onClick={handleSave}
disabled={loading}
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none text-[10px] font-bold uppercase tracking-widest"
>
{loading ? t("common.saving") : t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,124 @@
import React from "react";
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;
}
interface TerminalWindowProps {
windowId: string;
hostConfig: SSHHost;
initialPath?: string;
initialX?: number;
initialY?: number;
executeCommand?: string;
}
export function TerminalWindow({
windowId,
hostConfig,
initialPath,
initialX = 200,
initialY = 150,
executeCommand,
}: TerminalWindowProps) {
const { t } = useTranslation();
const { closeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
const terminalRef = React.useRef<{ fit?: () => void } | null>(null);
const resizeTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
return () => {
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
};
}, []);
const currentWindow = windows.find((w) => w.id === windowId);
if (!currentWindow) {
return null;
}
const handleClose = () => {
closeWindow(windowId);
};
const handleMaximize = () => {
maximizeWindow(windowId);
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = setTimeout(() => {
if (terminalRef.current?.fit) {
terminalRef.current.fit();
}
}, 150);
};
const handleFocus = () => {
focusWindow(windowId);
};
const handleResize = () => {
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = setTimeout(() => {
if (terminalRef.current?.fit) {
terminalRef.current.fit();
}
}, 100);
};
const terminalTitle = executeCommand
? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand })
: initialPath
? t("terminal.terminalWithPath", {
host: hostConfig.name,
path: initialPath,
})
: 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}
onClose={handleClose}
/>
</DraggableWindow>
);
}
@@ -0,0 +1,138 @@
import React, { useState, useCallback, useRef } from "react";
export interface WindowInstance {
id: string;
title: string;
component: React.ReactNode | ((windowId: string) => React.ReactNode);
x: number;
y: number;
width: number;
height: number;
isMaximized: boolean;
isMinimized: boolean;
zIndex: number;
}
interface WindowManagerProps {
children?: React.ReactNode;
}
interface WindowManagerContextType {
windows: WindowInstance[];
openWindow: (window: Omit<WindowInstance, "id" | "zIndex">) => string;
closeWindow: (id: string) => void;
minimizeWindow: (id: string) => void;
maximizeWindow: (id: string) => void;
focusWindow: (id: string) => void;
updateWindow: (id: string, updates: Partial<WindowInstance>) => void;
}
const WindowManagerContext =
React.createContext<WindowManagerContextType | null>(null);
export function WindowManager({ children }: WindowManagerProps) {
const [windows, setWindows] = useState<WindowInstance[]>([]);
const nextZIndex = useRef(1000);
const windowCounter = useRef(0);
const openWindow = useCallback(
(windowData: Omit<WindowInstance, "id" | "zIndex">) => {
const id = `window-${++windowCounter.current}`;
const zIndex = ++nextZIndex.current;
const offset = (windows.length % 5) * 20;
let adjustedX = windowData.x + offset;
let adjustedY = windowData.y + offset;
const maxX = Math.max(0, window.innerWidth - windowData.width - 20);
const maxY = Math.max(0, window.innerHeight - windowData.height - 20);
adjustedX = Math.max(20, Math.min(adjustedX, maxX));
adjustedY = Math.max(20, Math.min(adjustedY, maxY));
const newWindow: WindowInstance = {
...windowData,
id,
zIndex,
x: adjustedX,
y: adjustedY,
};
setWindows((prev) => [...prev, newWindow]);
return id;
},
[windows.length],
);
const closeWindow = useCallback((id: string) => {
setWindows((prev) => prev.filter((w) => w.id !== id));
}, []);
const minimizeWindow = useCallback((id: string) => {
setWindows((prev) =>
prev.map((w) =>
w.id === id ? { ...w, isMinimized: !w.isMinimized } : w,
),
);
}, []);
const maximizeWindow = useCallback((id: string) => {
setWindows((prev) =>
prev.map((w) =>
w.id === id ? { ...w, isMaximized: !w.isMaximized } : w,
),
);
}, []);
const focusWindow = useCallback((id: string) => {
setWindows((prev) => {
const targetWindow = prev.find((w) => w.id === id);
if (!targetWindow) return prev;
const newZIndex = ++nextZIndex.current;
return prev.map((w) => (w.id === id ? { ...w, zIndex: newZIndex } : w));
});
}, []);
const updateWindow = useCallback(
(id: string, updates: Partial<WindowInstance>) => {
setWindows((prev) =>
prev.map((w) => (w.id === id ? { ...w, ...updates } : w)),
);
},
[],
);
const contextValue: WindowManagerContextType = {
windows,
openWindow,
closeWindow,
minimizeWindow,
maximizeWindow,
focusWindow,
updateWindow,
};
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>
</WindowManagerContext.Provider>
);
}
export function useWindowManager() {
const context = React.useContext(WindowManagerContext);
if (!context) {
throw new Error("useWindowManager must be used within a WindowManager");
}
return context;
}
@@ -0,0 +1,161 @@
import { useState, useCallback } from "react";
interface DragAndDropState {
isDragging: boolean;
dragCounter: number;
draggedFiles: File[];
}
interface UseDragAndDropProps {
onFilesDropped: (files: FileList) => void;
onError?: (error: string) => void;
maxFileSize?: number;
allowedTypes?: string[];
}
export function useDragAndDrop({
onFilesDropped,
onError,
maxFileSize = 5120,
allowedTypes = [],
}: UseDragAndDropProps) {
const [state, setState] = useState<DragAndDropState>({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
const validateFiles = useCallback(
(files: FileList): string | null => {
const maxSizeBytes = maxFileSize * 1024 * 1024;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.size > maxSizeBytes) {
return `File "${file.name}" is too large. Maximum size is ${maxFileSize}MB.`;
}
if (allowedTypes.length > 0) {
const fileExt = file.name.split(".").pop()?.toLowerCase();
const mimeType = file.type.toLowerCase();
const isAllowed = allowedTypes.some((type) => {
if (type.startsWith(".")) {
return fileExt === type.slice(1);
}
if (type.includes("/")) {
return (
mimeType === type || mimeType.startsWith(type.replace("*", ""))
);
}
switch (type) {
case "image":
return mimeType.startsWith("image/");
case "video":
return mimeType.startsWith("video/");
case "audio":
return mimeType.startsWith("audio/");
case "text":
return mimeType.startsWith("text/");
default:
return false;
}
});
if (!isAllowed) {
return `File type "${file.type || "unknown"}" is not allowed.`;
}
}
}
return null;
},
[maxFileSize, allowedTypes],
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState((prev) => ({
...prev,
dragCounter: prev.dragCounter + 1,
}));
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setState((prev) => ({
...prev,
isDragging: true,
}));
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState((prev) => {
const newCounter = prev.dragCounter - 1;
return {
...prev,
dragCounter: newCounter,
isDragging: newCounter > 0,
};
});
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
const files = e.dataTransfer.files;
if (files.length === 0) {
return;
}
const validationError = validateFiles(files);
if (validationError) {
onError?.(validationError);
return;
}
onFilesDropped(files);
},
[validateFiles, onFilesDropped, onError],
);
const resetDragState = useCallback(() => {
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
}, []);
return {
isDragging: state.isDragging,
dragHandlers: {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop,
},
resetDragState,
};
}
@@ -0,0 +1,286 @@
import { useState, useCallback } from "react";
import { toast } from "sonner";
import { downloadSSHFile } from "@/main-axios";
import type { FileItem, SSHHost } from "@/types/index.js";
interface DragToDesktopState {
isDragging: boolean;
isDownloading: boolean;
progress: number;
error: string | null;
}
interface UseDragToDesktopProps {
sshSessionId: string;
sshHost: SSHHost;
}
interface DragToDesktopOptions {
enableToast?: boolean;
onSuccess?: () => void;
onError?: (error: string) => void;
}
export function useDragToDesktop({ sshSessionId }: UseDragToDesktopProps) {
const [state, setState] = useState<DragToDesktopState>({
isDragging: false,
isDownloading: false,
progress: 0,
error: null,
});
const isElectron = () => {
return (
typeof window !== "undefined" &&
window.electronAPI &&
window.electronAPI.isElectron
);
};
const dragFileToDesktop = useCallback(
async (file: FileItem, options: DragToDesktopOptions = {}) => {
const { enableToast = true, onSuccess, onError } = options;
if (!isElectron()) {
const error =
"Drag to desktop feature is only available in desktop application";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
if (file.type !== "file") {
const error = "Only files can be dragged to desktop";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
try {
setState((prev) => ({
...prev,
isDownloading: true,
progress: 0,
error: null,
}));
const response = await downloadSSHFile(sshSessionId, file.path);
if (!response?.content) {
throw new Error("Unable to get file content");
}
setState((prev) => ({ ...prev, progress: 50 }));
const tempResult = await window.electronAPI.createTempFile({
fileName: file.name,
content: response.content,
encoding: "base64",
});
if (!tempResult.success) {
throw new Error(
tempResult.error || "Failed to create temporary file",
);
}
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
const dragResult = await window.electronAPI.startDragToDesktop({
tempId: tempResult.tempId,
fileName: file.name,
});
if (!dragResult.success) {
throw new Error(dragResult.error || "Failed to start dragging");
}
setState((prev) => ({ ...prev, progress: 100 }));
if (enableToast) {
toast.success(`Dragging ${file.name} to desktop`);
}
onSuccess?.();
setTimeout(async () => {
await window.electronAPI.cleanupTempFile(tempResult.tempId);
setState((prev) => ({
...prev,
isDragging: false,
isDownloading: false,
progress: 0,
}));
}, 10000);
return true;
} catch (error: unknown) {
console.error("Failed to drag to desktop:", error);
const err = error as { message?: string };
const errorMessage = err.message || "Drag failed";
setState((prev) => ({
...prev,
isDownloading: false,
isDragging: false,
progress: 0,
error: errorMessage,
}));
if (enableToast) {
toast.error(`Drag failed: ${errorMessage}`);
}
onError?.(errorMessage);
return false;
}
},
[sshSessionId],
);
const dragFilesToDesktop = useCallback(
async (files: FileItem[], options: DragToDesktopOptions = {}) => {
const { enableToast = true, onSuccess, onError } = options;
if (!isElectron()) {
const error =
"Drag to desktop feature is only available in desktop application";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
const fileList = files.filter((f) => f.type === "file");
if (fileList.length === 0) {
const error = "No files available for dragging";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
if (fileList.length === 1) {
return dragFileToDesktop(fileList[0], options);
}
try {
setState((prev) => ({
...prev,
isDownloading: true,
progress: 0,
error: null,
}));
const downloadPromises = fileList.map((file) =>
downloadSSHFile(sshSessionId, file.path),
);
const responses = await Promise.all(downloadPromises);
setState((prev) => ({ ...prev, progress: 40 }));
const folderName = `Files_${Date.now()}`;
const filesData = fileList.map((file, index) => ({
relativePath: file.name,
content: responses[index]?.content || "",
encoding: "base64",
}));
const tempResult = await window.electronAPI.createTempFolder({
folderName,
files: filesData,
});
if (!tempResult.success) {
throw new Error(
tempResult.error || "Failed to create temporary folder",
);
}
setState((prev) => ({ ...prev, progress: 80, isDragging: true }));
const dragResult = await window.electronAPI.startDragToDesktop({
tempId: tempResult.tempId,
fileName: folderName,
});
if (!dragResult.success) {
throw new Error(dragResult.error || "Failed to start dragging");
}
setState((prev) => ({ ...prev, progress: 100 }));
if (enableToast) {
toast.success(`Dragging ${fileList.length} files to desktop`);
}
onSuccess?.();
setTimeout(async () => {
await window.electronAPI.cleanupTempFile(tempResult.tempId);
setState((prev) => ({
...prev,
isDragging: false,
isDownloading: false,
progress: 0,
}));
}, 15000);
return true;
} catch (error: unknown) {
console.error("Failed to batch drag to desktop:", error);
const err = error as { message?: string };
const errorMessage = err.message || "Batch drag failed";
setState((prev) => ({
...prev,
isDownloading: false,
isDragging: false,
progress: 0,
error: errorMessage,
}));
if (enableToast) {
toast.error(`Batch drag failed: ${errorMessage}`);
}
onError?.(errorMessage);
return false;
}
},
[sshSessionId, dragFileToDesktop],
);
const dragFolderToDesktop = useCallback(
async (folder: FileItem, options: DragToDesktopOptions = {}) => {
const { enableToast = true, onError } = options;
if (!isElectron()) {
const error =
"Drag to desktop feature is only available in desktop application";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
if (folder.type !== "directory") {
const error = "Only folder types can be dragged";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
if (enableToast) {
toast.info("Folder drag functionality is under development...");
}
return false;
},
[],
);
return {
...state,
isElectron: isElectron(),
dragFileToDesktop,
dragFilesToDesktop,
dragFolderToDesktop,
};
}
@@ -0,0 +1,299 @@
import { useState, useCallback, useRef } from "react";
import { toast } from "sonner";
import { downloadSSHFile } from "@/main-axios";
import type { FileItem, SSHHost } from "@/types/index.js";
interface DragToSystemState {
isDragging: boolean;
isDownloading: boolean;
progress: number;
error: string | null;
}
interface UseDragToSystemProps {
sshSessionId: string;
sshHost: SSHHost;
}
interface DragToSystemOptions {
enableToast?: boolean;
onSuccess?: () => void;
onError?: (error: string) => void;
}
export function useDragToSystemDesktop({ sshSessionId }: UseDragToSystemProps) {
const [state, setState] = useState<DragToSystemState>({
isDragging: false,
isDownloading: false,
progress: 0,
error: null,
});
const dragDataRef = useRef<{
files: FileItem[];
options: DragToSystemOptions;
} | null>(null);
const saveLastDirectory = async (fileHandle: {
getParent?: () => Promise<unknown>;
}) => {
try {
if ("indexedDB" in window && fileHandle.getParent) {
const dirHandle = await fileHandle.getParent();
const request = indexedDB.open("termix-dirs", 1);
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction(["directories"], "readwrite");
const store = transaction.objectStore("directories");
store.put({ handle: dirHandle }, "lastSaveDir");
};
}
} catch (error) {
console.error("Drag operation failed:", error);
}
};
const isFileSystemAPISupported = () => {
return "showSaveFilePicker" in window;
};
const isDraggedOutsideWindow = (e: DragEvent) => {
const margin = 50;
return (
e.clientX < margin ||
e.clientX > window.innerWidth - margin ||
e.clientY < margin ||
e.clientY > window.innerHeight - margin
);
};
const createFileBlob = async (file: FileItem): Promise<Blob> => {
const response = await downloadSSHFile(sshSessionId, file.path);
if (!response?.content) {
throw new Error(`Unable to get content for file ${file.name}`);
}
const binaryString = atob(response.content);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new Blob([bytes]);
};
const createZipBlob = async (files: FileItem[]): Promise<Blob> => {
const JSZip = (await import("jszip")).default;
const zip = new JSZip();
for (const file of files) {
const blob = await createFileBlob(file);
zip.file(file.name, blob);
}
return await zip.generateAsync({ type: "blob" });
};
const fallbackDownload = (blob: Blob, fileName: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleDragToSystem = useCallback(
async (files: FileItem[], options: DragToSystemOptions = {}) => {
const { enableToast = true, onSuccess, onError } = options;
if (files.length === 0) {
const error = "No files available for dragging";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
const fileList = files.filter((f) => f.type === "file");
if (fileList.length === 0) {
const error = "Only files can be dragged to desktop";
if (enableToast) toast.error(error);
onError?.(error);
return false;
}
try {
setState((prev) => ({
...prev,
isDownloading: true,
progress: 0,
error: null,
}));
const fileName =
fileList.length === 1 ? fileList[0].name : `files_${Date.now()}.zip`;
let fileHandle: {
createWritable?: () => Promise<{
write: (data: Blob) => Promise<void>;
close: () => Promise<void>;
}>;
getParent?: () => Promise<unknown>;
} | null = null;
if (isFileSystemAPISupported()) {
try {
fileHandle = await (
window as Window & {
showSaveFilePicker?: (options: {
suggestedName: string;
startIn: string;
types: Array<{
description: string;
accept: Record<string, string[]>;
}>;
}) => Promise<{
createWritable?: () => Promise<{
write: (data: Blob) => Promise<void>;
close: () => Promise<void>;
}>;
getParent?: () => Promise<unknown>;
}>;
}
).showSaveFilePicker!({
suggestedName: fileName,
startIn: "desktop",
types: [
{
description: "Files",
accept: {
"*/*": [
".txt",
".jpg",
".png",
".pdf",
".zip",
".tar",
".gz",
],
},
},
],
});
} catch (error: unknown) {
const err = error as { name?: string };
if (err.name === "AbortError") {
setState((prev) => ({
...prev,
isDownloading: false,
progress: 0,
}));
return false;
}
throw error;
}
}
let blob: Blob;
if (fileList.length === 1) {
blob = await createFileBlob(fileList[0]);
setState((prev) => ({ ...prev, progress: 70 }));
} else {
blob = await createZipBlob(fileList);
setState((prev) => ({ ...prev, progress: 70 }));
}
setState((prev) => ({ ...prev, progress: 90 }));
if (fileHandle) {
await saveLastDirectory(fileHandle);
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
} else {
fallbackDownload(blob, fileName);
if (enableToast) {
toast.info(
"Due to browser limitations, file will be downloaded to default download directory",
);
}
}
setState((prev) => ({ ...prev, progress: 100 }));
if (enableToast) {
toast.success(
fileList.length === 1
? `${fileName} saved to specified location`
: `${fileList.length} files packaged and saved`,
);
}
onSuccess?.();
setTimeout(() => {
setState((prev) => ({ ...prev, isDownloading: false, progress: 0 }));
}, 1000);
return true;
} catch (error: unknown) {
const err = error as { message?: string };
const errorMessage = err.message || "Save failed";
setState((prev) => ({
...prev,
isDownloading: false,
progress: 0,
error: errorMessage,
}));
if (enableToast) {
toast.error(`Save failed: ${errorMessage}`);
}
onError?.(errorMessage);
return false;
}
},
[sshSessionId],
);
const startDragToSystem = useCallback(
(files: FileItem[], options: DragToSystemOptions = {}) => {
dragDataRef.current = { files, options };
setState((prev) => ({ ...prev, isDragging: true, error: null }));
},
[],
);
const handleDragEnd = useCallback(
(e: DragEvent) => {
if (!dragDataRef.current) return;
const { files, options } = dragDataRef.current;
if (isDraggedOutsideWindow(e)) {
handleDragToSystem(files, options);
}
dragDataRef.current = null;
setState((prev) => ({ ...prev, isDragging: false }));
},
[handleDragToSystem],
);
const cancelDragToSystem = useCallback(() => {
dragDataRef.current = null;
setState((prev) => ({ ...prev, isDragging: false, error: null }));
}, []);
return {
...state,
isFileSystemAPISupported: isFileSystemAPISupported(),
startDragToSystem,
handleDragEnd,
cancelDragToSystem,
handleDragToSystem,
};
}
@@ -0,0 +1,92 @@
import { useState, useCallback } from "react";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
}
export function useFileSelection() {
const [selectedFiles, setSelectedFiles] = useState<FileItem[]>([]);
const selectFile = useCallback((file: FileItem, multiSelect = false) => {
if (multiSelect) {
setSelectedFiles((prev) => {
const isSelected = prev.some((f) => f.path === file.path);
if (isSelected) {
return prev.filter((f) => f.path !== file.path);
} else {
return [...prev, file];
}
});
} else {
setSelectedFiles([file]);
}
}, []);
const selectRange = useCallback(
(files: FileItem[], startFile: FileItem, endFile: FileItem) => {
const startIndex = files.findIndex((f) => f.path === startFile.path);
const endIndex = files.findIndex((f) => f.path === endFile.path);
if (startIndex !== -1 && endIndex !== -1) {
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const rangeFiles = files.slice(start, end + 1);
setSelectedFiles(rangeFiles);
}
},
[],
);
const selectAll = useCallback((files: FileItem[]) => {
setSelectedFiles([...files]);
}, []);
const clearSelection = useCallback(() => {
setSelectedFiles([]);
}, []);
const toggleSelection = useCallback((file: FileItem) => {
setSelectedFiles((prev) => {
const isSelected = prev.some((f) => f.path === file.path);
if (isSelected) {
return prev.filter((f) => f.path !== file.path);
} else {
return [...prev, file];
}
});
}, []);
const isSelected = useCallback(
(file: FileItem) => {
return selectedFiles.some((f) => f.path === file.path);
},
[selectedFiles],
);
const getSelectedCount = useCallback(() => {
return selectedFiles.length;
}, [selectedFiles]);
const setSelection = useCallback((files: FileItem[]) => {
setSelectedFiles(files);
}, []);
return {
selectedFiles,
selectFile,
selectRange,
selectAll,
clearSelection,
toggleSelection,
isSelected,
getSelectedCount,
setSelection,
};
}
+105
View File
@@ -0,0 +1,105 @@
import React, { useState, useEffect } from "react";
import { GuacamoleDisplay } from "@/features/guacamole/GuacamoleDisplay.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
import { getGuacamoleTokenFromHost } from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
import { AlertCircle, RefreshCw } from "lucide-react";
import type { SSHHost } from "@/types";
interface GuacamoleAppProps {
hostId?: string;
}
const GuacamoleApp: React.FC<GuacamoleAppProps> = ({ hostId }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={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>
);
}
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">
{t("guacamole.hostNotFound")}
</span>
</div>
);
}
return (
<GuacamoleAppInner
hostId={parseInt(hostId!, 10)}
hostConfig={hostConfig}
/>
);
}}
</FullScreenAppWrapper>
);
};
interface GuacamoleAppInnerProps {
hostId: number;
hostConfig: Pick<SSHHost, "connectionType">;
}
const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
hostId,
hostConfig,
}) => {
const { t } = useTranslation();
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getGuacamoleTokenFromHost(hostId)
.then((result) => setToken(result.token))
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
}, [hostId]);
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>
);
}
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(),
})}
</span>
</div>
);
}
const protocol = hostConfig.connectionType as "rdp" | "vnc" | "telnet";
return (
<div className="relative w-full h-full">
<GuacamoleDisplay
connectionConfig={{ token, protocol, type: protocol }}
isVisible={true}
/>
</div>
);
};
export default GuacamoleApp;
@@ -0,0 +1,559 @@
import {
useEffect,
useRef,
useState,
useImperativeHandle,
forwardRef,
useCallback,
} from "react";
import Guacamole from "guacamole-common-js";
import { useTranslation } from "react-i18next";
import { getGuacamoleToken, isElectron, isEmbeddedMode } from "@/main-axios.ts";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
export type GuacamoleConnectionType = "rdp" | "vnc" | "telnet";
export interface GuacamoleConnectionConfig {
token?: string;
protocol?: GuacamoleConnectionType;
type?: GuacamoleConnectionType;
hostname?: string;
port?: number;
username?: string;
password?: string;
domain?: string;
width?: number;
height?: number;
dpi?: number;
[key: string]: unknown;
}
export interface GuacamoleDisplayHandle {
disconnect: () => void;
sendKey: (keysym: number, pressed: boolean) => void;
sendMouse: (x: number, y: number, buttonMask: number) => void;
setClipboard: (data: string) => void;
}
interface GuacamoleDisplayProps {
connectionConfig: GuacamoleConnectionConfig;
isVisible: boolean;
onConnect?: () => void;
onDisconnect?: () => void;
onError?: (error: string) => void;
}
const isDev = import.meta.env.DEV;
export const GuacamoleDisplay = forwardRef<
GuacamoleDisplayHandle,
GuacamoleDisplayProps
>(function GuacamoleDisplay(
{ connectionConfig, isVisible, onConnect, onDisconnect, onError },
ref,
) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const displayRef = useRef<HTMLDivElement>(null);
const displayElementRef = useRef<HTMLElement | null>(null);
const clientRef = useRef<Guacamole.Client | null>(null);
const keyboardRef = useRef<Guacamole.Keyboard | null>(null);
const scaleRef = useRef<number>(1);
const resizeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const hasKeyboardFocusRef = useRef(false);
const windowFocusedRef = useRef(
typeof document === "undefined" ? true : document.hasFocus(),
);
const [isReady, setIsReady] = useState(false);
useImperativeHandle(ref, () => ({
disconnect: () => {
if (clientRef.current) {
clientRef.current.disconnect();
}
},
sendKey: (keysym: number, pressed: boolean) => {
if (clientRef.current) {
clientRef.current.sendKeyEvent(pressed ? 1 : 0, keysym);
}
},
sendMouse: (x: number, y: number, buttonMask: number) => {
if (clientRef.current) {
clientRef.current.sendMouseState(
new Guacamole.Mouse.State({
x,
y,
left: !!(buttonMask & 1),
middle: !!(buttonMask & 2),
right: !!(buttonMask & 4),
}),
);
}
},
setClipboard: (data: string) => {
if (clientRef.current) {
const stream = clientRef.current.createClipboardStream("text/plain");
const writer = new Guacamole.StringWriter(stream);
writer.sendText(data);
writer.sendEnd();
}
},
}));
const getWebSocketUrl = useCallback(
async (
containerWidth: number,
containerHeight: number,
): Promise<string | null> => {
try {
let token: string;
const protocol = connectionConfig.protocol ?? connectionConfig.type;
if (connectionConfig.token) {
token = connectionConfig.token;
} else {
const data = await getGuacamoleToken({
protocol: protocol ?? "rdp",
hostname: String(connectionConfig.hostname ?? ""),
port: connectionConfig.port,
username: connectionConfig.username,
password: connectionConfig.password,
domain: connectionConfig.domain,
security:
typeof connectionConfig.security === "string"
? connectionConfig.security
: undefined,
ignoreCert:
typeof connectionConfig.ignoreCert === "boolean"
? connectionConfig.ignoreCert
: undefined,
guacamoleConfig: connectionConfig.guacamoleConfig as Parameters<
typeof getGuacamoleToken
>[0]["guacamoleConfig"],
});
token = data.token;
}
const width = connectionConfig.width ?? containerWidth ?? 1280;
const height = connectionConfig.height ?? containerHeight ?? 720;
const wsBase = isDev
? `ws://localhost:30008`
: isElectron()
? (() => {
const configuredUrl = (
window as { configuredServerUrl?: string }
).configuredServerUrl;
// Embedded mode or no configured remote server: connect directly
// to the local guacamole websocket service.
if (isEmbeddedMode() || !configuredUrl) {
return "ws://127.0.0.1:30008";
}
const wsProtocol = configuredUrl.startsWith("https://")
? "wss://"
: "ws://";
const wsHost = configuredUrl
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
return `${wsProtocol}${wsHost}/guacamole/websocket/`;
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/guacamole/websocket/`;
const params = new URLSearchParams({
token,
width: String(width),
height: String(height),
});
return `${wsBase}?${params.toString()}`;
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Unknown error";
onError?.(errorMessage);
return null;
}
},
[connectionConfig, onError],
);
const refreshKeyboardHandlers = useCallback(() => {
const keyboard = keyboardRef.current;
const client = clientRef.current;
const displayElement = displayElementRef.current;
if (!keyboard) return;
const documentVisible =
typeof document === "undefined" || document.visibilityState === "visible";
const displayIsFocused =
!!displayElement &&
typeof document !== "undefined" &&
document.activeElement === displayElement;
const shouldCaptureInput =
!!client &&
!!displayElement &&
isVisible &&
documentVisible &&
windowFocusedRef.current &&
(hasKeyboardFocusRef.current || displayIsFocused);
if (!shouldCaptureInput) {
keyboard.onkeydown = null;
keyboard.onkeyup = null;
keyboard.reset();
return;
}
keyboard.onkeydown = (keysym: number) => {
if (!clientRef.current) return;
if (!isVisible || !windowFocusedRef.current) return;
const activeDisplay = displayElementRef.current;
const stillFocused =
!!activeDisplay &&
typeof document !== "undefined" &&
document.activeElement === activeDisplay;
if (!hasKeyboardFocusRef.current && !stillFocused) return;
clientRef.current.sendKeyEvent(1, keysym);
};
keyboard.onkeyup = (keysym: number) => {
if (!clientRef.current) return;
if (!isVisible || !windowFocusedRef.current) return;
const activeDisplay = displayElementRef.current;
const stillFocused =
!!activeDisplay &&
typeof document !== "undefined" &&
document.activeElement === activeDisplay;
if (!hasKeyboardFocusRef.current && !stillFocused) return;
clientRef.current.sendKeyEvent(0, keysym);
};
}, [isVisible]);
const rescaleDisplay = useCallback((immediate: boolean = false) => {
if (!clientRef.current || !containerRef.current) return;
const performRescale = () => {
if (!clientRef.current || !containerRef.current) return;
const display = clientRef.current.getDisplay();
const cWidth = containerRef.current.clientWidth;
const cHeight = containerRef.current.clientHeight;
const displayWidth = display.getWidth();
const displayHeight = display.getHeight();
if (displayWidth > 0 && displayHeight > 0 && cWidth > 0 && cHeight > 0) {
const scale = Math.min(cWidth / displayWidth, cHeight / displayHeight);
scaleRef.current = scale;
display.scale(scale);
}
};
if (immediate) {
performRescale();
} else {
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = setTimeout(performRescale, 200);
}
}, []);
const connect = useCallback(async () => {
if (isConnectingRef.current) return;
isConnectingRef.current = true;
setIsReady(false);
let containerWidth = containerRef.current?.clientWidth || 0;
let containerHeight = containerRef.current?.clientHeight || 0;
if (containerWidth < 100 || containerHeight < 100) {
containerWidth = 1280;
containerHeight = 720;
}
const wsUrl = await getWebSocketUrl(containerWidth, containerHeight);
if (!wsUrl) {
isConnectingRef.current = false;
return;
}
const tunnel = new Guacamole.WebSocketTunnel(wsUrl);
const client = new Guacamole.Client(tunnel);
clientRef.current = client;
const display = client.getDisplay();
const displayElement = display.getElement();
displayElementRef.current = displayElement;
if (displayRef.current) {
displayRef.current.innerHTML = "";
displayRef.current.appendChild(displayElement);
}
displayElement.setAttribute("tabindex", "0");
displayElement.style.outline = "none";
display.onresize = () => {
rescaleDisplay(true);
setIsReady(true);
};
const mouse = new Guacamole.Mouse(displayElement);
const sendMouseState = (state: Guacamole.Mouse.State) => {
displayElement.focus({ preventScroll: true });
const scale = scaleRef.current;
const adjustedX = Math.round(state.x / scale);
const adjustedY = Math.round(state.y / scale);
const adjustedState = new Guacamole.Mouse.State(
adjustedX,
adjustedY,
state.left,
state.middle,
state.right,
state.up,
state.down,
) as Guacamole.Mouse.State;
client.sendMouseState(adjustedState);
};
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState;
const keyboard = new Guacamole.Keyboard(displayElement);
keyboardRef.current = keyboard;
const handleDisplayFocus = () => {
hasKeyboardFocusRef.current = true;
refreshKeyboardHandlers();
};
const handleDisplayBlur = () => {
hasKeyboardFocusRef.current = false;
refreshKeyboardHandlers();
};
displayElement.addEventListener("focus", handleDisplayFocus);
displayElement.addEventListener("blur", handleDisplayBlur);
displayElement.addEventListener("mousedown", handleDisplayFocus);
refreshKeyboardHandlers();
client.onstatechange = (state: number) => {
switch (state) {
case 0:
break;
case 1:
break;
case 2:
break;
case 3:
setIsReady(true);
onConnect?.();
break;
case 4:
break;
case 5:
setIsReady(false);
hasKeyboardFocusRef.current = false;
refreshKeyboardHandlers();
onDisconnect?.();
break;
}
};
client.onerror = (error: Guacamole.Status) => {
const errorMessage = error.message || "Connection error";
setIsReady(false);
onError?.(errorMessage);
};
client.onclipboard = (stream: Guacamole.InputStream, mimetype: string) => {
if (mimetype === "text/plain") {
const reader = new Guacamole.StringReader(stream);
let data = "";
reader.ontext = (text: string) => {
data += text;
};
reader.onend = () => {
navigator.clipboard?.writeText?.(data).catch(() => {});
};
}
};
client.onaudio = (stream: Guacamole.InputStream, mimetype: string) => {
Guacamole.AudioPlayer.getInstance(stream, mimetype);
};
client.connect();
}, [
getWebSocketUrl,
onConnect,
onDisconnect,
onError,
refreshKeyboardHandlers,
rescaleDisplay,
]);
const hasInitiatedRef = useRef(false);
const isMountedRef = useRef(false);
const isConnectingRef = useRef(false);
useEffect(() => {
isMountedRef.current = true;
if (isVisible && !hasInitiatedRef.current) {
hasInitiatedRef.current = true;
requestAnimationFrame(() => {
if (isMountedRef.current) {
connect();
}
});
}
}, [isVisible, connect]);
useEffect(() => {
if (!isVisible) {
hasKeyboardFocusRef.current = false;
}
refreshKeyboardHandlers();
}, [isVisible, refreshKeyboardHandlers]);
useEffect(() => {
const handleWindowFocus = () => {
windowFocusedRef.current = true;
refreshKeyboardHandlers();
};
const handleWindowBlur = () => {
windowFocusedRef.current = false;
hasKeyboardFocusRef.current = false;
refreshKeyboardHandlers();
};
const handleVisibilityChange = () => {
windowFocusedRef.current =
document.visibilityState === "visible" && document.hasFocus();
if (document.visibilityState !== "visible") {
hasKeyboardFocusRef.current = false;
}
refreshKeyboardHandlers();
};
window.addEventListener("focus", handleWindowFocus);
window.addEventListener("blur", handleWindowBlur);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.removeEventListener("focus", handleWindowFocus);
window.removeEventListener("blur", handleWindowBlur);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [refreshKeyboardHandlers]);
useEffect(() => {
return () => {
isMountedRef.current = false;
hasInitiatedRef.current = false;
isConnectingRef.current = false;
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
if (clientRef.current) {
clientRef.current.disconnect();
clientRef.current = null;
}
displayElementRef.current = null;
};
}, []);
useEffect(() => {
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;
if (w > 0 && h > 0) clientRef.current.sendSize(w, h);
}
}, 200);
});
resizeObserver.observe(containerRef.current);
const initialTimeout = setTimeout(() => rescaleDisplay(true), 100);
return () => {
resizeObserver.disconnect();
clearTimeout(initialTimeout);
};
}, [rescaleDisplay]);
const syncClipboard = useCallback(() => {
const client = clientRef.current;
if (!client || !navigator.clipboard?.readText) return;
navigator.clipboard
.readText()
.then((text) => {
if (text) {
const stream = client.createClipboardStream("text/plain");
const writer = new Guacamole.StringWriter(stream);
writer.sendText(text);
writer.sendEnd();
}
})
.catch(() => {});
}, []);
useEffect(() => {
if (isVisible && isReady) {
syncClipboard();
}
}, [isVisible, isReady, syncClipboard]);
useEffect(() => {
const container = containerRef.current;
if (!container || !isReady) return;
const handleFocus = () => syncClipboard();
container.addEventListener("mouseenter", handleFocus);
return () => {
container.removeEventListener("mouseenter", handleFocus);
};
}, [isReady, syncClipboard]);
const connectingMessage = t("guacamole.connecting", {
type: (
connectionConfig.protocol ||
connectionConfig.type ||
"remote"
).toUpperCase(),
});
return (
<div
ref={containerRef}
className="absolute inset-0 overflow-hidden"
style={{ backgroundColor: "var(--bg-base)" }}
>
<div
ref={displayRef}
className="relative w-full h-full flex items-center justify-center"
style={{
cursor: isReady ? "none" : "default",
visibility: isReady ? "visible" : "hidden",
}}
/>
<SimpleLoader visible={!isReady} message={connectingMessage} />
</div>
);
});
@@ -0,0 +1,794 @@
import React from "react";
import { Separator } from "@/components/separator.tsx";
import { Button } from "@/components/button.tsx";
import {
getServerStatusById,
getServerMetricsById,
startMetricsPolling,
stopMetricsPolling,
submitMetricsTOTP,
executeSnippet,
logActivity,
sendMetricsHeartbeat,
getSSHHosts,
type ServerMetrics,
} from "@/main-axios.ts";
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
import { useTabsSafe } from "@/shell/TabContext.tsx";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
type WidgetType,
type StatsConfig,
DEFAULT_STATS_CONFIG,
} from "@/types/stats-widgets.ts";
import {
CpuWidget,
MemoryWidget,
DiskWidget,
NetworkWidget,
UptimeWidget,
ProcessesWidget,
SystemWidget,
LoginStatsWidget,
PortsWidget,
FirewallWidget,
} from "./widgets";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
import { RefreshCw, Server } from "lucide-react";
import {
ConnectionLogProvider,
useConnectionLog,
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
import type { LogEntry } from "@/types/connection-log.ts";
interface QuickAction {
name: string;
snippetId: number;
}
type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
type ConnectionLogError = Error & {
connectionLogs?: ConnectionLogPayload[];
};
interface HostConfig {
id: number;
name: string;
ip: string;
username: string;
folder?: string;
enableFileManager?: boolean;
tunnelConnections?: unknown[];
quickActions?: QuickAction[];
statsConfig?: string | StatsConfig;
[key: string]: unknown;
}
interface ServerProps {
hostConfig?: HostConfig;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
}
function ServerStatsInner({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false,
}: ServerProps): React.ReactElement {
const { t } = useTranslation();
const {
addLog,
clearLogs,
isExpanded: isConnectionLogExpanded,
} = useConnectionLog();
const { currentTab, removeTab } = useTabsSafe();
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
"offline",
);
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
const [metricsHistory, setMetricsHistory] = React.useState<ServerMetrics[]>(
[],
);
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [showStatsUI, setShowStatsUI] = React.useState(true);
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
new Set(),
);
const [totpRequired, setTotpRequired] = React.useState(false);
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
const [totpVerified, setTotpVerified] = React.useState(false);
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
null,
);
const [hasConnectionError, setHasConnectionError] = React.useState(false);
const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
const statsConfig = React.useMemo((): StatsConfig => {
if (!currentHostConfig?.statsConfig) {
return DEFAULT_STATS_CONFIG;
}
try {
const parsed =
typeof currentHostConfig.statsConfig === "string"
? JSON.parse(currentHostConfig.statsConfig)
: currentHostConfig.statsConfig;
return { ...DEFAULT_STATS_CONFIG, ...parsed };
} catch (error) {
console.error("Failed to parse statsConfig:", error);
return DEFAULT_STATS_CONFIG;
}
}, [currentHostConfig?.statsConfig]);
const enabledWidgets = statsConfig.enabledWidgets;
const statusCheckEnabled = statsConfig.statusCheckEnabled !== false;
const metricsEnabled = statsConfig.metricsEnabled !== false;
React.useEffect(() => {
const handleVisibilityChange = () => {
setIsPageVisible(!document.hidden);
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () =>
document.removeEventListener("visibilitychange", handleVisibilityChange);
}, []);
const isActuallyVisible = isVisible && isPageVisible;
React.useEffect(() => {
if (!viewerSessionId || !isActuallyVisible) return;
const heartbeatInterval = setInterval(async () => {
try {
await sendMetricsHeartbeat(viewerSessionId);
} catch (error) {
console.error("Failed to send heartbeat:", error);
}
}, 30000);
return () => clearInterval(heartbeatInterval);
}, [viewerSessionId, isActuallyVisible]);
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setServerStatus("offline");
setMetrics(null);
setMetricsHistory([]);
setShowStatsUI(true);
}
setCurrentHostConfig(hostConfig);
}, [hostConfig?.id]);
const logServerActivity = async () => {
if (
!currentHostConfig?.id ||
activityLoggedRef.current ||
activityLoggingRef.current
) {
return;
}
activityLoggingRef.current = true;
activityLoggedRef.current = true;
try {
const hostName =
currentHostConfig.name ||
`${currentHostConfig.username}@${currentHostConfig.ip}`;
await logActivity("server_stats", currentHostConfig.id, hostName);
} catch (err) {
console.warn("Failed to log server stats activity:", err);
activityLoggedRef.current = false;
} finally {
activityLoggingRef.current = false;
}
};
const handleTOTPSubmit = async (totpCode: string) => {
if (!totpSessionId || !currentHostConfig) return;
try {
const result = await submitMetricsTOTP(totpSessionId, totpCode);
if (result.success) {
setTotpRequired(false);
setTotpSessionId(null);
setShowStatsUI(true);
setTotpVerified(true);
if (result.viewerSessionId) {
setViewerSessionId(result.viewerSessionId);
}
} else {
toast.error(t("serverStats.totpFailed"));
}
} catch (error) {
toast.error(t("serverStats.totpFailed"));
console.error("TOTP verification failed:", error);
}
};
const handleTOTPCancel = async () => {
setTotpRequired(false);
if (currentHostConfig?.id) {
try {
await stopMetricsPolling(currentHostConfig.id);
} catch (error) {
console.error("Failed to stop metrics polling:", error);
}
}
if (currentTab !== null) {
removeTab(currentTab);
}
};
const renderWidget = (widgetType: WidgetType) => {
switch (widgetType) {
case "cpu":
return <CpuWidget metrics={metrics} metricsHistory={metricsHistory} />;
case "memory":
return (
<MemoryWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "disk":
return <DiskWidget metrics={metrics} metricsHistory={metricsHistory} />;
case "network":
return (
<NetworkWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "uptime":
return (
<UptimeWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "processes":
return (
<ProcessesWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "system":
return (
<SystemWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "login_stats":
return (
<LoginStatsWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "ports":
return (
<PortsWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "firewall":
return (
<FirewallWidget metrics={metrics} metricsHistory={metricsHistory} />
);
default:
return null;
}
};
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 {
toast.error(t("serverStats.failedToFetchHostConfig"));
}
}
};
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 {
toast.error(t("serverStats.failedToFetchHostConfig"));
}
}
};
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
return () =>
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
}, [hostConfig?.id]);
React.useEffect(() => {
if (!statusCheckEnabled || !currentHostConfig?.id) {
setServerStatus("offline");
return;
}
let cancelled = false;
const fetchStatus = async () => {
try {
const res = await getServerStatusById(currentHostConfig?.id);
if (!cancelled) {
setServerStatus(res?.status === "online" ? "online" : "offline");
}
} catch (error: unknown) {
if (!cancelled) {
const err = error as {
response?: { status?: number };
};
if (err?.response?.status === 503) {
setServerStatus("offline");
} else if (err?.response?.status === 504) {
setServerStatus("offline");
} else if (err?.response?.status === 404) {
setServerStatus("offline");
} else {
setServerStatus("offline");
}
}
}
};
fetchStatus();
const intervalId = window.setInterval(
fetchStatus,
statsConfig.statusCheckInterval * 1000,
);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [
currentHostConfig?.id,
statusCheckEnabled,
statsConfig.statusCheckInterval,
]);
React.useEffect(() => {
if (!metricsEnabled || !currentHostConfig?.id) {
return;
}
let cancelled = false;
let pollingIntervalId: number | undefined;
if (isActuallyVisible && !metrics) {
setIsLoadingMetrics(true);
setShowStatsUI(true);
} else if (!isActuallyVisible) {
setIsLoadingMetrics(false);
}
const startMetrics = async () => {
if (cancelled) return;
if (currentHostConfig.authType === "none") {
toast.error(t("serverStats.noneAuthNotSupported"));
setIsLoadingMetrics(false);
if (currentTab !== null) {
removeTab(currentTab);
}
return;
}
const hasExistingMetrics = metrics !== null;
if (!hasExistingMetrics) {
setIsLoadingMetrics(true);
}
setShowStatsUI(true);
setHasConnectionError(false);
clearLogs();
try {
if (!totpVerified) {
const result = await startMetricsPolling(currentHostConfig.id);
if (cancelled) return;
if (result?.connectionLogs) {
result.connectionLogs.forEach((log) => {
addLog({
type: log.type,
stage: log.stage,
message: log.message,
details: log.details,
});
});
}
if (result.requires_totp) {
setTotpRequired(true);
setTotpSessionId(result.sessionId || null);
setTotpPrompt(result.prompt || "Verification code");
setIsLoadingMetrics(false);
return;
}
if (result.viewerSessionId) {
setViewerSessionId(result.viewerSessionId);
}
}
let retryCount = 0;
let data = null;
const maxRetries = 15;
const retryDelay = 2000;
while (retryCount < maxRetries && !cancelled) {
try {
data = await getServerMetricsById(currentHostConfig.id);
break;
} catch (error: unknown) {
retryCount++;
if (retryCount === 1) {
const initialDelay = totpVerified ? 3000 : 5000;
await new Promise((resolve) => setTimeout(resolve, initialDelay));
} else if (retryCount < maxRetries && !cancelled) {
await new Promise((resolve) => setTimeout(resolve, retryDelay));
} else {
throw error;
}
}
}
if (cancelled) return;
if (data) {
setMetrics(data);
if (!hasExistingMetrics) {
setIsLoadingMetrics(false);
logServerActivity();
setTimeout(() => clearLogs(), 1000);
}
}
pollingIntervalId = window.setInterval(async () => {
if (cancelled) return;
try {
const data = await getServerMetricsById(currentHostConfig.id);
if (!cancelled && data) {
setMetrics(data);
setMetricsHistory((prev) => {
const newHistory = [...prev, data];
return newHistory.slice(-20);
});
}
} catch (error) {
if (!cancelled) {
console.error("Failed to fetch metrics:", error);
}
}
}, statsConfig.metricsInterval * 1000);
} catch (error: unknown) {
if (!cancelled) {
const logError = error as ConnectionLogError;
console.error("Failed to start metrics polling:", error);
setIsLoadingMetrics(false);
setHasConnectionError(true);
if (logError.connectionLogs) {
logError.connectionLogs.forEach((log) => {
addLog({
type: log.type,
stage: log.stage,
message: log.message,
details: log.details,
});
});
} else {
addLog({
type: "error",
stage: "connection",
message:
error instanceof Error
? error.message
: t("serverStats.connectionFailed"),
});
}
}
}
};
const stopMetrics = async () => {
if (pollingIntervalId) {
window.clearInterval(pollingIntervalId);
pollingIntervalId = undefined;
}
if (currentHostConfig?.id) {
try {
await stopMetricsPolling(
currentHostConfig.id,
viewerSessionId || undefined,
);
} catch (error) {
console.error("Failed to stop metrics polling:", error);
}
}
};
const debounceTimeout = setTimeout(() => {
if (isActuallyVisible) {
if (!hasConnectionError) {
startMetrics();
}
} else {
stopMetrics();
}
}, 500);
return () => {
cancelled = true;
clearTimeout(debounceTimeout);
if (pollingIntervalId) window.clearInterval(pollingIntervalId);
if (currentHostConfig?.id) {
stopMetricsPolling(currentHostConfig.id).catch(() => {});
}
};
}, [
currentHostConfig?.id,
isActuallyVisible,
metricsEnabled,
statsConfig.metricsInterval,
totpVerified,
hasConnectionError,
]);
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
: {
opacity: isVisible ? 1 : 0,
margin: isTopbarOpen ? "74px 17px 8px 8px" : "16px 17px 8px 8px",
height: isTopbarOpen ? "calc(100vh - 82px)" : "calc(100vh - 24px)",
};
const handleRefresh = async () => {
if (!currentHostConfig?.id) 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);
} catch {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} finally {
setIsRefreshing(false);
}
};
return (
<div
style={wrapperStyle}
className="relative overflow-hidden flex flex-col"
>
<div
className="flex flex-col flex-1 min-h-0 overflow-hidden"
style={{
visibility:
hasConnectionError && isConnectionLogExpanded
? "hidden"
: "visible",
}}
>
<div className="flex-1 overflow-y-auto overflow-x-hidden flex flex-col">
{!totpRequired && !isLoadingMetrics && (
<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">
<Server className="size-5 text-accent-brand" />
</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">
{currentHostConfig?.quickActions &&
currentHostConfig.quickActions.length > 0 && (
<>
<div className="flex flex-wrap gap-2 mr-3">
{currentHostConfig.quickActions.map((action, index) => {
const isExecuting = executingActions.has(
action.snippetId,
);
return (
<Button
key={index}
variant="outline"
size="sm"
className="h-8 text-xs font-semibold"
disabled={isExecuting}
onClick={async () => {
if (!currentHostConfig) return;
setExecutingActions((prev) =>
new Set(prev).add(action.snippetId),
);
toast.loading(
t("serverStats.executingQuickAction", {
name: action.name,
}),
{ id: `quick-action-${action.snippetId}` },
);
try {
const result = await executeSnippet(
action.snippetId,
currentHostConfig.id,
);
if (result.success) {
toast.success(
t("serverStats.quickActionSuccess", {
name: action.name,
}),
{
id: `quick-action-${action.snippetId}`,
description: result.output?.substring(
0,
200,
),
duration: 5000,
},
);
} else {
toast.error(
t("serverStats.quickActionFailed", {
name: action.name,
}),
{
id: `quick-action-${action.snippetId}`,
description:
result.error || result.output,
duration: 5000,
},
);
}
} catch (error) {
toast.error(
t("serverStats.quickActionError", {
name: action.name,
}),
{
id: `quick-action-${action.snippetId}`,
description:
error instanceof Error
? error.message
: "Unknown error",
duration: 5000,
},
);
} finally {
setExecutingActions((prev) => {
const next = new Set(prev);
next.delete(action.snippetId);
return next;
});
}
}}
>
{isExecuting ? (
<>
<RefreshCw className="size-3 animate-spin mr-1" />
{action.name}
</>
) : (
action.name
)}
</Button>
);
})}
</div>
<Separator orientation="vertical" className="h-8 mx-3" />
</>
)}
<Button
variant="outline"
size="default"
onClick={handleRefresh}
disabled={isRefreshing}
className="gap-2 font-semibold"
>
<RefreshCw
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("serverStats.refresh")}
</Button>
</div>
</div>
)}
{metricsEnabled &&
showStatsUI &&
!isLoadingMetrics &&
!metrics &&
serverStatus === "offline" && (
<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>
</div>
)}
{metricsEnabled && showStatsUI && !isLoadingMetrics && metrics && (
<div className="px-3 pt-3 pb-3 columns-1 md:columns-2 lg:columns-3 gap-3">
{enabledWidgets.map((widgetType) => (
<div key={widgetType} className="break-inside-avoid mb-3">
{renderWidget(widgetType)}
</div>
))}
</div>
)}
</div>
{metricsEnabled && (
<SimpleLoader
visible={isLoadingMetrics && !metrics && !isConnectionLogExpanded}
message={t("serverStats.connecting")}
/>
)}
</div>
<TOTPDialog
isOpen={totpRequired}
prompt={totpPrompt}
onSubmit={handleTOTPSubmit}
onCancel={handleTOTPCancel}
backgroundColor="var(--bg-canvas)"
/>
<ConnectionLog
isConnecting={isLoadingMetrics}
isConnected={serverStatus === "online"}
hasConnectionError={hasConnectionError}
position={hasConnectionError ? "top" : "bottom"}
/>
</div>
);
}
export function ServerStats(props: ServerProps): React.ReactElement {
return (
<ConnectionLogProvider>
<ServerStatsInner {...props} />
</ConnectionLogProvider>
);
}
@@ -0,0 +1,48 @@
import React from "react";
import { ServerStats } from "@/features/server-stats/ServerStats.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
interface ServerStatsAppProps {
hostId?: string;
}
const ServerStatsApp: React.FC<ServerStatsAppProps> = ({ hostId }) => {
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>
);
}
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>
</div>
);
}
return (
<ServerStats
hostConfig={hostConfig}
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
isVisible={true}
isTopbarOpen={false}
embedded={true}
/>
);
}}
</FullScreenAppWrapper>
);
};
export default ServerStatsApp;
@@ -0,0 +1,103 @@
import React from "react";
import { Cpu } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import { SectionCard } from "@/components/section-card";
interface CpuWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
function Sparkline({
history,
current,
}: {
history: ServerMetrics[];
current: number | null;
}) {
const points = [
...history.map((m) => m?.cpu?.percent ?? 0),
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`;
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>
</div>
);
}
export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
const { t } = useTranslation();
const percent = metrics?.cpu?.percent ?? null;
const cores = metrics?.cpu?.cores ?? null;
const load = metrics?.cpu?.load ?? null;
return (
<SectionCard
title={t("serverStats.cpuUsage")}
icon={<Cpu className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-2">
<div className="flex items-end justify-between">
<div className="flex flex-col">
<span className="text-xl md:text-3xl font-bold text-accent-brand">
{percent !== null ? `${percent.toFixed(1)}%` : "N/A"}
</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
{cores !== null
? t("serverStats.cpuCores", { count: cores })
: t("serverStats.naCpus")}
</span>
</div>
{load && (
<div className="text-right">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
{t("serverStats.loadAvg")}
</span>
<div className="text-xs font-mono">
{load[0].toFixed(2)}&nbsp;&nbsp;{load[1].toFixed(2)}&nbsp;&nbsp;
{load[2].toFixed(2)}
</div>
</div>
)}
</div>
<div className="h-2 bg-muted w-full overflow-hidden">
<div
className="h-full bg-accent-brand transition-all duration-500"
style={{ width: `${percent ?? 0}%` }}
/>
</div>
<Sparkline history={metricsHistory} current={percent} />
</div>
</SectionCard>
);
}
@@ -0,0 +1,98 @@
import { HardDrive } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import { SectionCard } from "@/components/section-card";
interface DiskWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
function Sparkline({
history,
current,
}: {
history: ServerMetrics[];
current: number | null;
}) {
const points = [
...history.map((m) => m?.disk?.percent ?? 0),
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`;
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>
</div>
);
}
export function DiskWidget({ metrics, metricsHistory }: DiskWidgetProps) {
const { t } = useTranslation();
const percent = metrics?.disk?.percent ?? null;
const usedHuman = metrics?.disk?.usedHuman ?? null;
const totalHuman = metrics?.disk?.totalHuman ?? null;
const availableHuman = metrics?.disk?.availableHuman ?? null;
return (
<SectionCard
title={t("serverStats.diskUsage")}
icon={<HardDrive className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-2">
<div className="flex items-end justify-between">
<div className="flex flex-col">
<span className="text-xl md:text-3xl font-bold text-accent-brand">
{percent !== null ? `${percent}%` : "N/A"}
</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
{usedHuman && totalHuman ? `${usedHuman} / ${totalHuman}` : "N/A"}
</span>
</div>
</div>
<div className="h-2 bg-muted w-full overflow-hidden">
<div
className="h-full bg-accent-brand transition-all duration-500"
style={{ width: `${percent ?? 0}%` }}
/>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground font-semibold">
{t("serverStats.available")}
</span>
<span className="font-mono">{availableHuman ?? "N/A"}</span>
</div>
</div>
<Sparkline history={metricsHistory} current={percent} />
</div>
</SectionCard>
);
}
@@ -0,0 +1,148 @@
import React from "react";
import { Shield, ShieldOff, ShieldCheck, ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import type {
FirewallMetrics,
FirewallChain,
FirewallRule,
} from "@/types/stats-widgets";
import { SectionCard } from "@/components/section-card";
interface FirewallWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
function RuleRow({ rule }: { rule: FirewallRule }) {
const { t } = useTranslation();
const targetClass =
rule.target.toUpperCase() === "ACCEPT"
? "text-accent-brand"
: rule.target.toUpperCase() === "DROP"
? "text-destructive"
: rule.target.toUpperCase() === "REJECT"
? "text-yellow-500"
: "text-muted-foreground";
const src =
rule.interface ??
rule.state ??
(rule.source === "0.0.0.0/0"
? t("serverStats.firewall.anywhere")
: rule.source);
return (
<div className="grid grid-cols-4 gap-2 text-xs font-mono py-1 border-b border-border/50 last:border-0">
<span className={`font-bold ${targetClass}`}>{rule.target}</span>
<span className="text-muted-foreground">
{rule.protocol.toUpperCase()}
</span>
<span>{rule.dport ?? "—"}</span>
<span className="truncate text-muted-foreground" title={src}>
{src}
</span>
</div>
);
}
function ChainSection({ chain }: { chain: FirewallChain }) {
const { t } = useTranslation();
const [open, setOpen] = React.useState(true);
const policyClass =
chain.policy.toUpperCase() === "ACCEPT"
? "text-accent-brand"
: chain.policy.toUpperCase() === "DROP"
? "text-destructive"
: "text-yellow-500";
return (
<div>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-2 w-full py-1.5 hover:bg-muted/30 text-left"
>
<ChevronDown
className={`size-3 text-muted-foreground transition-transform ${open ? "" : "-rotate-90"}`}
/>
<span className="text-xs font-bold">{chain.name}</span>
<span className="text-[10px] text-muted-foreground">
({t("serverStats.firewall.policy")}:{" "}
<span className={policyClass}>{chain.policy}</span>)
</span>
<span className="text-[10px] text-muted-foreground ml-auto">
{chain.rules.length} {t("serverStats.firewall.rules")}
</span>
</button>
{open && chain.rules.length > 0 && (
<div className="ml-5">
<div className="grid grid-cols-4 gap-2 text-[10px] text-muted-foreground font-bold uppercase pb-1 border-b border-border">
<span>{t("serverStats.firewall.action")}</span>
<span>{t("serverStats.firewall.protocol")}</span>
<span>{t("serverStats.firewall.port")}</span>
<span>{t("serverStats.firewall.source")}</span>
</div>
{chain.rules.map((rule, i) => (
<RuleRow key={i} rule={rule} />
))}
</div>
)}
</div>
);
}
export function FirewallWidget({ metrics }: FirewallWidgetProps) {
const { t } = useTranslation();
const firewall = (metrics as ServerMetrics & { firewall?: FirewallMetrics })
?.firewall;
const statusIcon =
!firewall || firewall.type === "none" ? (
<ShieldOff className="size-3.5 text-muted-foreground" />
) : firewall.status === "active" ? (
<ShieldCheck className="size-3.5 text-accent-brand" />
) : (
<Shield className="size-3.5 text-yellow-500" />
);
const statusBadge =
firewall?.status === "active" ? (
<span className="flex items-center gap-1.5 px-2 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-[10px] font-bold">
<ShieldCheck className="size-3" /> ACTIVE
</span>
) : (
<span className="flex items-center gap-1.5 px-2 py-0.5 border border-border text-muted-foreground text-[10px] font-bold">
{t("serverStats.firewall.inactive").toUpperCase()}
</span>
);
return (
<SectionCard title={t("serverStats.firewall.title")} icon={statusIcon}>
<div className="flex flex-col gap-3 py-1">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">
{t("serverStats.firewall.title")}
</span>
{statusBadge}
</div>
{firewall?.type && firewall.type !== "none" && (
<span className="text-[10px] text-muted-foreground uppercase font-bold">
{firewall.type}
</span>
)}
{firewall && firewall.chains.length > 0 ? (
<div className="flex flex-col gap-1">
{firewall.chains.map((chain) => (
<ChainSection key={chain.name} chain={chain} />
))}
</div>
) : (
<span className="text-xs text-muted-foreground">
{t("serverStats.firewall.noData")}
</span>
)}
</div>
</SectionCard>
);
}
@@ -0,0 +1,87 @@
import { UserCheck, UserX } from "lucide-react";
import { useTranslation } from "react-i18next";
import { SectionCard } from "@/components/section-card";
interface LoginRecord {
user: string;
ip: string;
time: string;
status: "success" | "failed";
}
interface LoginStatsMetrics {
recentLogins: LoginRecord[];
failedLogins: LoginRecord[];
totalLogins: number;
uniqueIPs: number;
}
interface LoginStatsWidgetProps {
metrics: { login_stats?: LoginStatsMetrics } | null;
metricsHistory: unknown[];
}
export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
const { t } = useTranslation();
const loginStats = metrics?.login_stats;
const recentLogins = loginStats?.recentLogins ?? [];
const failedLogins = loginStats?.failedLogins ?? [];
const allLogins = [
...recentLogins.map((l) => ({ ...l, status: "success" as const })),
...failedLogins.map((l) => ({ ...l, status: "failed" as const })),
]
.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())
.slice(0, 6);
return (
<SectionCard
title={t("serverStats.loginStats")}
icon={<UserCheck className="size-3.5" />}
>
<div className="flex flex-col gap-2 py-1">
{allLogins.length === 0 ? (
<span className="text-xs text-muted-foreground italic py-2">
{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}
</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>
</SectionCard>
);
}
@@ -0,0 +1,109 @@
import { MemoryStick } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import { SectionCard } from "@/components/section-card";
interface MemoryWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
function Sparkline({
history,
current,
}: {
history: ServerMetrics[];
current: number | null;
}) {
const points = [
...history.map((m) => m?.memory?.percent ?? 0),
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`;
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>
</div>
);
}
export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
const { t } = useTranslation();
const percent = metrics?.memory?.percent ?? null;
const usedGiB = metrics?.memory?.usedGiB ?? null;
const totalGiB = metrics?.memory?.totalGiB ?? null;
return (
<SectionCard
title={t("serverStats.memoryUsage")}
icon={<MemoryStick className="size-3.5" />}
>
<div className="flex flex-col gap-4 py-2">
<div className="flex items-end justify-between">
<div className="flex flex-col">
<span className="text-xl md:text-3xl font-bold text-accent-brand">
{percent !== null ? `${percent.toFixed(1)}%` : "N/A"}
</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
{usedGiB !== null && totalGiB !== null
? `${usedGiB.toFixed(1)} / ${totalGiB.toFixed(1)} GiB`
: "N/A"}
</span>
</div>
</div>
<div className="h-2 bg-muted w-full overflow-hidden">
<div
className="h-full bg-accent-brand transition-all duration-500"
style={{ width: `${percent ?? 0}%` }}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="flex flex-col p-2 bg-muted/30 border border-border">
<span className="text-[10px] text-muted-foreground uppercase font-bold">
{t("serverStats.swap")}
</span>
<span className="text-xs font-semibold">N/A</span>
</div>
<div className="flex flex-col p-2 bg-muted/30 border border-border">
<span className="text-[10px] text-muted-foreground uppercase font-bold">
{t("serverStats.free")}
</span>
<span className="text-xs font-semibold">
{usedGiB !== null && totalGiB !== null
? `${(totalGiB - usedGiB).toFixed(1)} GiB`
: "N/A"}
</span>
</div>
</div>
<Sparkline history={metricsHistory} current={percent} />
</div>
</SectionCard>
);
}
@@ -0,0 +1,73 @@
import { Network, WifiOff } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import { SectionCard } from "@/components/section-card";
interface NetworkWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
export function NetworkWidget({ metrics }: NetworkWidgetProps) {
const { t } = useTranslation();
const metricsWithNetwork = metrics as ServerMetrics & {
network?: {
interfaces?: Array<{
name: string;
state: string;
ip: string;
rx?: string;
tx?: string;
}>;
};
};
const interfaces = metricsWithNetwork?.network?.interfaces ?? [];
return (
<SectionCard
title={t("serverStats.networkInterfaces")}
icon={<Network className="size-3.5" />}
>
<div className="flex flex-col gap-2 py-1">
{interfaces.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-muted-foreground gap-2">
<WifiOff className="size-6 opacity-40" />
<span className="text-xs">
{t("serverStats.noInterfacesFound")}
</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}
</span>
</div>
<span className="text-[10px] font-semibold px-1.5 py-px border border-border text-muted-foreground uppercase">
{iface.state}
</span>
</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>
</SectionCard>
);
}
@@ -0,0 +1,66 @@
import { Unplug } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import type { PortsMetrics, ListeningPort } from "@/types/stats-widgets";
import { SectionCard } from "@/components/section-card";
interface PortsWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
function PortRow({ port }: { port: ListeningPort }) {
const formatAddress = (addr: string) =>
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">
{port.protocol.toUpperCase()}
</span>
<span className="font-semibold truncate">
{port.process ?? (port.pid ? `PID:${port.pid}` : "—")}
</span>
<span className="text-right text-muted-foreground">
{formatAddress(port.localAddress)}
</span>
</div>
);
}
export function PortsWidget({ metrics }: PortsWidgetProps) {
const { t } = useTranslation();
const portsData = (metrics as ServerMetrics & { ports?: PortsMetrics })
?.ports;
const ports = portsData?.ports ?? [];
return (
<SectionCard
title={t("serverStats.ports.title")}
icon={<Unplug className="size-3.5" />}
>
<div className="flex flex-col gap-1.5 py-1">
<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>
<span>{t("serverStats.ports.process")}</span>
<span className="text-right">{t("serverStats.ports.address")}</span>
</div>
{ports.length === 0 ? (
<span className="text-xs text-muted-foreground italic py-2">
{t("serverStats.ports.noData")}
</span>
) : (
ports.map((port, i) => (
<PortRow
key={`${port.protocol}-${port.localPort}-${i}`}
port={port}
/>
))
)}
</div>
</SectionCard>
);
}
@@ -0,0 +1,65 @@
import { List, Activity } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import { SectionCard } from "@/components/section-card";
interface ProcessesWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
const { t } = useTranslation();
const metricsWithProcesses = metrics as ServerMetrics & {
processes?: {
total?: number;
running?: number;
top?: Array<{
pid: number;
cpu: number;
mem: number;
command: string;
user: string;
}>;
};
};
const processes = metricsWithProcesses?.processes;
const topProcesses = processes?.top ?? [];
return (
<SectionCard
title={t("serverStats.processes")}
icon={<List className="size-3.5" />}
>
<div className="flex flex-col gap-1.5 py-1">
<div className="grid grid-cols-4 text-[10px] text-muted-foreground font-bold uppercase tracking-wider pb-1 border-b border-border min-w-0">
<span>PID</span>
<span>CPU</span>
<span>MEM</span>
<span>CMD</span>
</div>
{topProcesses.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-muted-foreground gap-2">
<Activity className="size-6 opacity-40" />
<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>
</SectionCard>
);
}
@@ -0,0 +1,51 @@
import { Server } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import { SectionCard } from "@/components/section-card";
interface SystemWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
export function SystemWidget({ metrics }: SystemWidgetProps) {
const { t } = useTranslation();
const metricsWithSystem = metrics as ServerMetrics & {
system?: { hostname?: string; os?: string; kernel?: string; arch?: string };
uptime?: { formatted?: string };
};
const system = metricsWithSystem?.system;
const uptime = metricsWithSystem?.uptime;
const rows = [
{ label: t("serverStats.hostname"), value: system?.hostname },
{ label: t("serverStats.operatingSystem"), value: system?.os },
{ label: t("serverStats.kernel"), value: system?.kernel },
{ label: t("serverStats.architecture"), value: system?.arch },
{ label: t("serverStats.uptime"), value: uptime?.formatted },
].filter((r) => r.value);
return (
<SectionCard
title={t("serverStats.systemInfo")}
icon={<Server className="size-3.5" />}
>
<div className="grid grid-cols-1 gap-y-3 py-2">
{rows.map(({ label, value }) => (
<div key={label} className="flex flex-col">
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
{label}
</span>
<span className="text-sm font-mono font-semibold truncate">
{value}
</span>
</div>
))}
{rows.length === 0 && (
<span className="text-xs text-muted-foreground">N/A</span>
)}
</div>
</SectionCard>
);
}
@@ -0,0 +1,37 @@
import { Clock } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/main-axios.ts";
import { SectionCard } from "@/components/section-card";
interface UptimeWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
export function UptimeWidget({ metrics }: UptimeWidgetProps) {
const { t } = useTranslation();
const metricsWithUptime = metrics as ServerMetrics & {
uptime?: { formatted?: string; seconds?: number };
};
const uptime = metricsWithUptime?.uptime;
return (
<SectionCard
title={t("serverStats.uptime")}
icon={<Clock className="size-3.5" />}
>
<div className="flex flex-col gap-3 py-2">
<span className="text-xl md:text-3xl font-bold text-accent-brand">
{uptime?.formatted ?? "N/A"}
</span>
{uptime?.seconds && (
<span className="text-xs text-muted-foreground font-mono">
{Math.floor(uptime.seconds).toLocaleString()}{" "}
{t("serverStats.seconds")}
</span>
)}
</div>
</SectionCard>
);
}
@@ -0,0 +1,10 @@
export { CpuWidget } from "./CpuWidget.tsx";
export { MemoryWidget } from "./MemoryWidget.tsx";
export { DiskWidget } from "./DiskWidget.tsx";
export { NetworkWidget } from "./NetworkWidget.tsx";
export { UptimeWidget } from "./UptimeWidget.tsx";
export { ProcessesWidget } from "./ProcessesWidget.tsx";
export { SystemWidget } from "./SystemWidget.tsx";
export { LoginStatsWidget } from "./LoginStatsWidget.tsx";
export { PortsWidget } from "./PortsWidget.tsx";
export { FirewallWidget } from "./FirewallWidget.tsx";
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
import React from "react";
import { Terminal } from "@/features/terminal/Terminal.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
interface TerminalAppProps {
hostId?: string;
}
const TerminalApp: React.FC<TerminalAppProps> = ({ hostId }) => {
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>
);
}
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>
</div>
);
}
return (
<Terminal
hostConfig={hostConfig}
isVisible={true}
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
showTitle={false}
splitScreen={false}
onClose={() => {}}
/>
);
}}
</FullScreenAppWrapper>
);
};
export default TerminalApp;
@@ -0,0 +1,122 @@
import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/lib/terminal-themes.ts";
import { useTheme } from "@/components/theme-provider.tsx";
interface TerminalPreviewProps {
theme: string;
fontSize?: number;
fontFamily?: string;
cursorStyle?: "block" | "underline" | "bar";
cursorBlink?: boolean;
letterSpacing?: number;
lineHeight?: number;
}
export function TerminalPreview({
theme = "termix",
fontSize = 14,
fontFamily = "Caskaydia Cove Nerd Font Mono",
cursorStyle = "bar",
cursorBlink = true,
letterSpacing = 0,
lineHeight = 1.2,
}: TerminalPreviewProps) {
const { theme: appTheme } = useTheme();
const resolvedTheme =
theme === "termix"
? appTheme === "dark" ||
(appTheme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
? "termixDark"
: "termixLight"
: theme;
return (
<div className="border border-input rounded-md overflow-hidden">
<div
className="p-4 font-mono text-sm"
style={{
fontSize: `${fontSize}px`,
fontFamily:
TERMINAL_FONTS.find((f) => f.value === fontFamily)?.fallback ||
TERMINAL_FONTS[0].fallback,
letterSpacing: `${letterSpacing}px`,
lineHeight,
background:
TERMINAL_THEMES[resolvedTheme]?.colors.background ||
"var(--bg-base)",
color:
TERMINAL_THEMES[resolvedTheme]?.colors.foreground ||
"var(--foreground)",
}}
>
<div>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.green }}>
user@termix
</span>
<span>:</span>
<span style={{ color: TERMINAL_THEMES[resolvedTheme]?.colors.blue }}>
~
</span>
<span>$ ls -la</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>
</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>
</div>
<div>
<span>-rw-r--r--</span>
<span> 1 user </span>
<span>README.md</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>
<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",
verticalAlign:
cursorStyle === "underline" ? "bottom" : "text-bottom",
}}
/>
</div>
</div>
<style>{`
@keyframes blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
`}</style>
</div>
);
}
@@ -0,0 +1,73 @@
import React, { useEffect, useRef } from "react";
import { cn } from "@/lib/utils.ts";
interface CommandAutocompleteProps {
suggestions: string[];
selectedIndex: number;
onSelect: (command: string) => void;
position: { top: number; left: number };
visible: boolean;
}
export function CommandAutocomplete({
suggestions,
selectedIndex,
onSelect,
position,
visible,
}: CommandAutocompleteProps) {
const containerRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (selectedRef.current && containerRef.current) {
selectedRef.current.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}, [selectedIndex]);
if (!visible || suggestions.length === 0) {
return null;
}
const footerHeight = 32;
const maxSuggestionsHeight = 240 - footerHeight;
return (
<div
ref={containerRef}
className="fixed z-[9999] bg-canvas border border-edge rounded-md shadow-lg min-w-[200px] max-w-[600px] flex flex-col"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
maxHeight: "240px",
}}
>
<div
className="overflow-y-auto thin-scrollbar"
style={{ maxHeight: `${maxSuggestionsHeight}px` }}
>
{suggestions.map((suggestion, index) => (
<div
key={index}
ref={index === selectedIndex ? selectedRef : null}
className={cn(
"px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors",
"hover:bg-hover",
index === selectedIndex && "bg-surface text-muted-foreground",
)}
onClick={() => onSelect(suggestion)}
onMouseEnter={() => {}}
>
{suggestion}
</div>
))}
</div>
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-edge bg-canvas/50 shrink-0">
Tab/Enter to complete to navigate Esc to close
</div>
</div>
);
}
@@ -0,0 +1,85 @@
import React, {
createContext,
useContext,
useState,
useCallback,
ReactNode,
} from "react";
interface CommandHistoryContextType {
commandHistory: string[];
isLoading: boolean;
setCommandHistory: (history: string[]) => void;
setIsLoading: (loading: boolean) => void;
onSelectCommand?: (command: string) => void;
setOnSelectCommand: (callback: (command: string) => void) => void;
onDeleteCommand?: (command: string) => void;
setOnDeleteCommand: (callback: (command: string) => void) => void;
openCommandHistory: () => void;
setOpenCommandHistory: (callback: () => void) => void;
}
const CommandHistoryContext = createContext<
CommandHistoryContextType | undefined
>(undefined);
export function CommandHistoryProvider({ children }: { children: ReactNode }) {
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [onSelectCommand, setOnSelectCommand] = useState<
((command: string) => void) | undefined
>(undefined);
const [onDeleteCommand, setOnDeleteCommand] = useState<
((command: string) => void) | undefined
>(undefined);
const [openCommandHistory, setOpenCommandHistory] = useState<
(() => void) | undefined
>(() => () => {});
const handleSetOnSelectCommand = useCallback(
(callback: (command: string) => void) => {
setOnSelectCommand(() => callback);
},
[],
);
const handleSetOnDeleteCommand = useCallback(
(callback: (command: string) => void) => {
setOnDeleteCommand(() => callback);
},
[],
);
const handleSetOpenCommandHistory = useCallback((callback: () => void) => {
setOpenCommandHistory(() => callback);
}, []);
return (
<CommandHistoryContext.Provider
value={{
commandHistory,
isLoading,
setCommandHistory,
setIsLoading,
onSelectCommand,
setOnSelectCommand: handleSetOnSelectCommand,
onDeleteCommand,
setOnDeleteCommand: handleSetOnDeleteCommand,
openCommandHistory: openCommandHistory || (() => {}),
setOpenCommandHistory: handleSetOpenCommandHistory,
}}
>
{children}
</CommandHistoryContext.Provider>
);
}
export function useCommandHistory() {
const context = useContext(CommandHistoryContext);
if (context === undefined) {
throw new Error(
"useCommandHistory must be used within a CommandHistoryProvider",
);
}
return context;
}
@@ -0,0 +1,126 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { getCommandHistory, saveCommandToHistory } from "@/main-axios.ts";
interface UseCommandHistoryOptions {
hostId?: number;
enabled?: boolean;
}
interface CommandHistoryResult {
suggestions: string[];
getSuggestions: (input: string) => string[];
saveCommand: (command: string) => Promise<void>;
clearSuggestions: () => void;
isLoading: boolean;
}
export function useCommandHistory({
hostId,
enabled = true,
}: UseCommandHistoryOptions): CommandHistoryResult {
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [suggestions, setSuggestions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const historyCache = useRef<Map<number, string[]>>(new Map());
useEffect(() => {
if (!enabled || !hostId) {
setCommandHistory([]);
setSuggestions([]);
return;
}
const cached = historyCache.current.get(hostId);
if (cached) {
setCommandHistory(cached);
return;
}
const fetchHistory = async () => {
setIsLoading(true);
try {
const history = await getCommandHistory(hostId);
setCommandHistory(history);
historyCache.current.set(hostId, history);
} catch (error) {
console.error("Failed to fetch command history:", error);
setCommandHistory([]);
} finally {
setIsLoading(false);
}
};
fetchHistory();
}, [hostId, enabled]);
const getSuggestions = useCallback(
(input: string): string[] => {
if (!input || input.trim().length === 0) {
return [];
}
const trimmedInput = input.trim();
const matches = commandHistory.filter((cmd) =>
cmd.startsWith(trimmedInput),
);
const filtered = matches
.filter((cmd) => cmd !== trimmedInput)
.slice(0, 10);
setSuggestions(filtered);
return filtered;
},
[commandHistory],
);
const saveCommand = useCallback(
async (command: string) => {
if (!enabled || !hostId || !command || command.trim().length === 0) {
return;
}
const trimmedCommand = command.trim();
if (commandHistory.length > 0 && commandHistory[0] === trimmedCommand) {
return;
}
try {
await saveCommandToHistory(hostId, trimmedCommand);
setCommandHistory((prev) => {
const newHistory = [
trimmedCommand,
...prev.filter((c) => c !== trimmedCommand),
];
const limited = newHistory.slice(0, 500);
historyCache.current.set(hostId, limited);
return limited;
});
} catch (error) {
console.error("Failed to save command to history:", error);
setCommandHistory((prev) => {
const newHistory = [
trimmedCommand,
...prev.filter((c) => c !== trimmedCommand),
];
return newHistory.slice(0, 500);
});
}
},
[enabled, hostId, commandHistory],
);
const clearSuggestions = useCallback(() => {
setSuggestions([]);
}, []);
return {
suggestions,
getSuggestions,
saveCommand,
clearSuggestions,
isLoading,
};
}
@@ -0,0 +1,130 @@
import { useRef, useCallback } from "react";
import { saveCommandToHistory } from "@/main-axios.ts";
const SENSITIVE_PATTERNS = [
/\bpassw(or)?d\b/i,
/\bsecret\b/i,
/\btoken\b/i,
/\bapi.?key\b/i,
/\bPASS(WORD)?=/i,
/\bAWS_SECRET/i,
/\bmysql\b.*-p/i,
/\bsudo\s+-S\b/,
/\bhtpasswd\b/i,
/\bsshpass\b/i,
/\bcurl\b.*-u\s/i,
/\bexport\b.*(?:PASSWORD|SECRET|TOKEN|KEY)=/i,
];
interface UseCommandTrackerOptions {
hostId?: number;
enabled?: boolean;
onCommandExecuted?: (command: string) => void;
}
interface CommandTrackerResult {
trackInput: (data: string) => void;
getCurrentCommand: () => string;
clearCurrentCommand: () => void;
updateCurrentCommand: (command: string) => void;
}
export function useCommandTracker({
hostId,
enabled = true,
onCommandExecuted,
}: UseCommandTrackerOptions): CommandTrackerResult {
const currentCommandRef = useRef<string>("");
const isInEscapeSequenceRef = useRef<boolean>(false);
const trackInput = useCallback(
(data: string) => {
if (!enabled || !hostId) {
return;
}
for (let i = 0; i < data.length; i++) {
const char = data[i];
const charCode = char.charCodeAt(0);
if (charCode === 27) {
isInEscapeSequenceRef.current = true;
continue;
}
if (isInEscapeSequenceRef.current) {
if (
(charCode >= 65 && charCode <= 90) ||
(charCode >= 97 && charCode <= 122) ||
charCode === 126
) {
isInEscapeSequenceRef.current = false;
}
continue;
}
if (charCode === 13 || charCode === 10) {
const command = currentCommandRef.current.trim();
if (command.length > 0) {
const isSensitive = SENSITIVE_PATTERNS.some((p) => p.test(command));
if (!isSensitive) {
saveCommandToHistory(hostId, command).catch((error) => {
console.error("Failed to save command to history:", error);
});
}
if (onCommandExecuted) {
onCommandExecuted(command);
}
}
currentCommandRef.current = "";
continue;
}
if (charCode === 8 || charCode === 127) {
if (currentCommandRef.current.length > 0) {
currentCommandRef.current = currentCommandRef.current.slice(0, -1);
}
continue;
}
if (charCode === 3 || charCode === 4) {
currentCommandRef.current = "";
continue;
}
if (charCode === 21) {
currentCommandRef.current = "";
continue;
}
if (charCode >= 32 && charCode <= 126) {
currentCommandRef.current += char;
}
}
},
[enabled, hostId, onCommandExecuted],
);
const getCurrentCommand = useCallback(() => {
return currentCommandRef.current;
}, []);
const clearCurrentCommand = useCallback(() => {
currentCommandRef.current = "";
}, []);
const updateCurrentCommand = useCallback((command: string) => {
currentCommandRef.current = command;
}, []);
return {
trackInput,
getCurrentCommand,
clearCurrentCommand,
updateCurrentCommand,
};
}
+248
View File
@@ -0,0 +1,248 @@
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}
/>
);
}
+48
View File
@@ -0,0 +1,48 @@
import React from "react";
import { TunnelManager } from "@/features/tunnel/TunnelManager.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
interface TunnelAppProps {
hostId?: string;
}
const TunnelApp: React.FC<TunnelAppProps> = ({ hostId }) => {
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>
);
}
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>
</div>
);
}
return (
<TunnelManager
hostConfig={hostConfig}
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
isVisible={true}
isTopbarOpen={false}
embedded={true}
/>
);
}}
</FullScreenAppWrapper>
);
};
export default TunnelApp;
@@ -0,0 +1,156 @@
import { Button } from "@/components/button.tsx";
import type { TunnelStatus } from "@/types/index.js";
import {
AlertCircle,
Loader2,
Play,
Square,
Wifi,
WifiOff,
} from "lucide-react";
import { useTranslation } from "react-i18next";
type TunnelInlineControlsProps = {
status?: TunnelStatus;
loading?: boolean;
onStart?: () => void;
onStop?: () => void;
startDisabled?: boolean;
startDisabledReason?: string;
};
function getStatusKind(status?: TunnelStatus) {
const value = status?.status?.toUpperCase() || "DISCONNECTED";
if (value === "CONNECTED") return "connected";
if (value === "ERROR" || value === "FAILED") return "error";
if (
value === "CONNECTING" ||
value === "DISCONNECTING" ||
value === "RETRYING" ||
value === "WAITING"
) {
return "connecting";
}
return "disconnected";
}
function getStatusTitle(
status: TunnelStatus | undefined,
statusText: string,
t: ReturnType<typeof useTranslation>["t"],
) {
if (!status) return statusText;
const details = [];
if (status.reason) details.push(status.reason);
if (status.retryCount && status.maxRetries) {
details.push(
t("tunnels.attempt", {
current: status.retryCount,
max: status.maxRetries,
}),
);
}
if (status.nextRetryIn) {
details.push(
t("tunnels.nextRetryIn", {
seconds: status.nextRetryIn,
}),
);
}
if (status.errorType && !status.reason) details.push(status.errorType);
return details.length > 0 ? details.join("\n") : statusText;
}
export function TunnelInlineControls({
status,
loading = false,
onStart,
onStop,
startDisabled,
startDisabledReason,
}: TunnelInlineControlsProps) {
const { t } = useTranslation();
const kind = getStatusKind(status);
const isDisconnected = kind === "disconnected";
const statusText =
kind === "connected"
? t("tunnels.connected")
: kind === "connecting"
? t("tunnels.connecting")
: kind === "error"
? t("tunnels.error")
: t("tunnels.disconnected");
const title = getStatusTitle(status, statusText, t);
const statusClass =
kind === "connected"
? "text-green-600 dark:text-green-400 bg-green-500/10 border-green-500/20"
: kind === "connecting"
? "text-blue-600 dark:text-blue-400 bg-blue-500/10 border-blue-500/20"
: 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";
const statusIcon =
kind === "connected" ? (
<Wifi className="h-3 w-3" />
) : kind === "connecting" ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : kind === "error" ? (
<AlertCircle className="h-3 w-3" />
) : (
<WifiOff className="h-3 w-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}`}
title={title}
>
{statusIcon}
{statusText}
</span>
{loading ? (
<Button
type="button"
size="sm"
variant="outline"
disabled
className="h-8 px-3 text-xs text-muted-foreground border-border"
>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
{isDisconnected ? t("tunnels.start") : t("tunnels.stop")}
</Button>
) : isDisconnected ? (
<Button
type="button"
size="sm"
variant="outline"
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"
>
<Play className="h-3 w-3 mr-1" />
{t("tunnels.start")}
</Button>
) : (
<Button
type="button"
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"
>
<Square className="h-3 w-3 mr-1" />
{t("tunnels.stop")}
</Button>
)}
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
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>
);
}
@@ -0,0 +1,72 @@
import { useTranslation } from "react-i18next";
import type { TunnelMode } from "@/types/index.js";
type TunnelModeSelectorProps = {
mode: TunnelMode;
scope: "client" | "server";
onChange: (mode: TunnelMode) => void;
};
export function TunnelModeSelector({
mode,
scope,
onChange,
}: TunnelModeSelectorProps) {
const { t } = useTranslation();
const options: Array<{
value: TunnelMode;
label: string;
description: string;
}> = [
{
value: "local",
label: t("tunnels.typeLocal"),
description:
scope === "client"
? t("tunnels.typeClientLocalDesc")
: t("tunnels.typeServerLocalDesc"),
},
{
value: "remote",
label: t("tunnels.typeRemote"),
description:
scope === "client"
? t("tunnels.typeClientRemoteDesc")
: t("tunnels.typeServerRemoteDesc"),
},
{
value: "dynamic",
label: t("tunnels.typeDynamic"),
description:
scope === "client"
? t("tunnels.typeClientDynamicDesc")
: t("tunnels.typeDynamicDesc"),
},
];
return (
<div className="grid gap-3 lg:grid-cols-3">
{options.map((option) => (
<label
key={option.value}
className="flex items-start gap-3 rounded-md border bg-card p-3 cursor-pointer"
>
<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="flex flex-col">
<span className="text-sm font-medium">{option.label}</span>
<span className="text-xs text-muted-foreground">
{option.description}
</span>
</div>
</label>
))}
</div>
);
}
+532
View File
@@ -0,0 +1,532 @@
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>
);
}
+473
View File
@@ -0,0 +1,473 @@
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";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
getSSHHosts,
subscribeTunnelStatuses,
connectTunnel,
disconnectTunnel,
cancelTunnel,
logActivity,
} from "@/main-axios";
import type { Host as DemoHost } from "@/types/ui-types";
import type { SSHHost, TunnelConnection, TunnelStatus } from "@/types";
function tunnelName(
host: SSHHost,
index: number,
tunnel: TunnelConnection,
): string {
const hostKey = host.name || `${host.username}@${host.ip}`;
return `${host.id}::${index}::${hostKey}::${tunnel.sourcePort}::${tunnel.endpointHost}::${tunnel.endpointPort}`;
}
function statusLabel(status: TunnelStatus | undefined): string {
if (!status) return "DISCONNECTED";
const s = status.status?.toUpperCase();
if (s === "CONNECTED") return "CONNECTED";
if (s === "CONNECTING" || s === "VERIFYING") return "CONNECTING";
if (s === "FAILED") return "ERROR";
if (s === "RETRYING" || s === "WAITING") return "WAITING";
if (s === "DISCONNECTING") return "DISCONNECTED";
return "DISCONNECTED";
}
function TunnelCard({
host,
index,
tunnel,
status,
isActing,
onAction,
}: {
host: SSHHost;
index: number;
tunnel: TunnelConnection;
status: TunnelStatus | undefined;
isActing: boolean;
onAction: (action: "connect" | "disconnect" | "cancel") => void;
}) {
const { t } = useTranslation();
const [showSettings, setShowSettings] = useState(false);
const label = statusLabel(status);
const isConnected = label === "CONNECTED";
const isConnecting = label === "CONNECTING";
const isError = label === "ERROR";
const isWaiting = label === "WAITING";
let statusColor = "text-muted-foreground border-border bg-muted/30";
if (isConnected)
statusColor = "text-accent-brand border-accent-brand/40 bg-accent-brand/10";
if (isConnecting)
statusColor = "text-blue-400 border-blue-400/40 bg-blue-400/10";
if (isError)
statusColor = "text-destructive border-destructive/40 bg-destructive/10";
if (isWaiting)
statusColor = "text-yellow-500 border-yellow-500/40 bg-yellow-500/10";
const mode = tunnel.mode ?? (tunnel.tunnelType as string) ?? "local";
const destination =
mode === "dynamic"
? "SOCKS5 Proxy"
: `${tunnel.endpointHost ?? ""}:${tunnel.endpointPort}`;
return (
<Card className="flex flex-col overflow-hidden p-0 gap-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border bg-muted/10">
<div className="flex items-center gap-2">
<Network className="size-3.5 text-muted-foreground" />
<span className="text-xs font-bold uppercase tracking-widest text-muted-foreground">
{t("tunnels.port")} {tunnel.sourcePort}
</span>
</div>
<div
className={`flex items-center gap-1.5 px-2 py-0.5 border text-[10px] font-bold ${statusColor}`}
>
{isConnecting || isActing ? (
<RefreshCw className="size-3 animate-spin" />
) : isConnected ? (
<Wifi className="size-3" />
) : isError ? (
<AlertCircle className="size-3" />
) : isWaiting ? (
<Clock className="size-3" />
) : (
<WifiOff className="size-3" />
)}
{isActing ? t("tunnels.working") : label}
</div>
</div>
<div className="px-4 py-4 flex flex-col gap-3">
<div className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground uppercase font-bold tracking-wider">
{t("tunnels.destination")}
</span>
<span
className="text-sm font-mono font-semibold truncate"
title={destination}
>
{destination}
</span>
<div className="flex items-center gap-1.5 mt-1">
<span className="text-[10px] font-semibold px-1.5 py-px border border-border text-muted-foreground uppercase">
{mode}
</span>
<span className="text-[10px] text-muted-foreground">
localhost:{tunnel.sourcePort}
</span>
</div>
</div>
{isError && status?.reason && (
<div className="flex items-start gap-2 p-2 bg-destructive/5 border border-destructive/20 text-destructive text-[10px]">
<AlertCircle className="size-3 mt-0.5 shrink-0" />
<span>{status.reason}</span>
</div>
)}
{showSettings && (
<div className="border border-border bg-muted/20 p-3 flex flex-col gap-2 text-xs">
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.host")}
</span>
<span className="font-mono">
{host.name || `${host.username}@${host.ip}`}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.mode")}
</span>
<span className="uppercase font-bold">{mode}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.localPort")}
</span>
<span className="font-mono">{tunnel.sourcePort}</span>
</div>
{mode !== "dynamic" && (
<>
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.remoteHost")}
</span>
<span className="font-mono">{tunnel.endpointHost}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.remotePort")}
</span>
<span className="font-mono">{tunnel.endpointPort}</span>
</div>
</>
)}
<div className="flex justify-between items-center">
<span className="text-muted-foreground font-semibold">
{t("tunnels.maxRetries")}
</span>
<span className="font-mono">{tunnel.maxRetries}</span>
</div>
</div>
)}
<div className="flex gap-2 mt-1">
{isConnected ? (
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-destructive border-destructive/40 hover:bg-destructive/10 hover:text-destructive gap-1.5"
disabled={isActing}
onClick={() => onAction("disconnect")}
>
<Square className="size-3" />
{t("tunnels.stop")}
</Button>
) : isConnecting || isWaiting ? (
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-yellow-500 border-yellow-500/40 hover:bg-yellow-500/10 hover:text-yellow-500 gap-1.5"
disabled={isActing}
onClick={() => onAction("cancel")}
>
<Square className="size-3" />
{t("tunnels.cancel")}
</Button>
) : (
<Button
variant="outline"
size="sm"
className="flex-1 h-8 text-accent-brand border-accent-brand/40 hover:bg-accent-brand/10 hover:text-accent-brand gap-1.5"
disabled={isActing}
onClick={() => onAction("connect")}
>
{isActing ? (
<RefreshCw className="size-3 animate-spin" />
) : (
<Play className="size-3" />
)}
{t("tunnels.start")}
</Button>
)}
<Button
variant={showSettings ? "secondary" : "ghost"}
size="icon"
className={`h-8 w-8 ${showSettings ? "bg-accent-brand/10 text-accent-brand" : "text-muted-foreground hover:text-foreground"}`}
onClick={() => setShowSettings((s) => !s)}
>
<Settings className="size-3.5" />
</Button>
</div>
</div>
</Card>
);
}
export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
const { t } = useTranslation();
const [sshHost, setSshHost] = useState<SSHHost | null>(null);
const [tunnelStatuses, setTunnelStatuses] = useState<
Record<string, TunnelStatus>
>({});
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>(
{},
);
const activityLoggedRef = React.useRef(false);
const fetchHost = useCallback(async () => {
if (!host) return;
try {
const hosts = await getSSHHosts();
const found = hosts.find(
(h) => String(h.id) === host.id || h.name === host.name,
);
if (found) setSshHost(found);
} catch {
/* ignore */
}
}, [host?.id]);
useEffect(() => {
fetchHost();
const interval = setInterval(fetchHost, 5000);
window.addEventListener("ssh-hosts:changed", fetchHost);
return () => {
clearInterval(interval);
window.removeEventListener("ssh-hosts:changed", fetchHost);
};
}, [fetchHost]);
useEffect(() => {
return subscribeTunnelStatuses(setTunnelStatuses, () => {});
}, []);
useEffect(() => {
if (!sshHost || activityLoggedRef.current) return;
activityLoggedRef.current = true;
const name = sshHost.name || `${sshHost.username}@${sshHost.ip}`;
logActivity("tunnel", sshHost.id, name).catch(() => {
activityLoggedRef.current = false;
});
}, [sshHost?.id]);
const handleAction = async (
action: "connect" | "disconnect" | "cancel",
index: number,
) => {
if (!sshHost) return;
const tunnel = sshHost.tunnelConnections[index];
if (!tunnel) return;
const name = tunnelName(sshHost, index, tunnel);
setTunnelActions((prev) => ({ ...prev, [name]: true }));
try {
if (action === "connect") {
const allHosts = await getSSHHosts();
const endpointSsh = allHosts.find(
(h) =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost,
);
await connectTunnel({
name,
scope: tunnel.scope ?? "s2s",
mode:
tunnel.mode ??
(tunnel.tunnelType as "local" | "remote" | "dynamic") ??
"remote",
tunnelType:
tunnel.tunnelType ??
(tunnel.mode === "local" || tunnel.mode === "remote"
? tunnel.mode
: "remote"),
bindHost: tunnel.bindHost,
targetHost: tunnel.targetHost,
sourceHostId: sshHost.id,
tunnelIndex: index,
hostName: sshHost.name || `${sshHost.username}@${sshHost.ip}`,
sourceIP: sshHost.ip,
sourceSSHPort: sshHost.port,
sourceUsername: sshHost.username,
sourcePassword:
sshHost.authType === "password" ? sshHost.password : undefined,
sourceAuthMethod: sshHost.authType,
sourceSSHKey: sshHost.authType === "key" ? sshHost.key : undefined,
sourceKeyPassword:
sshHost.authType === "key" ? sshHost.keyPassword : undefined,
sourceKeyType:
sshHost.authType === "key" ? sshHost.keyType : undefined,
sourceCredentialId: sshHost.credentialId,
endpointHost: tunnel.endpointHost ?? "",
endpointIP: endpointSsh?.ip ?? tunnel.endpointHost ?? "",
endpointSSHPort: endpointSsh?.port ?? 22,
endpointUsername: endpointSsh?.username ?? "",
endpointPassword:
endpointSsh?.authType === "password"
? endpointSsh.password
: undefined,
endpointAuthMethod: endpointSsh?.authType ?? "password",
endpointSSHKey:
endpointSsh?.authType === "key" ? endpointSsh.key : undefined,
endpointKeyPassword:
endpointSsh?.authType === "key"
? endpointSsh.keyPassword
: undefined,
endpointKeyType:
endpointSsh?.authType === "key" ? endpointSsh.keyType : undefined,
endpointCredentialId: endpointSsh?.credentialId,
sourcePort: tunnel.sourcePort,
endpointPort: tunnel.endpointPort,
maxRetries: tunnel.maxRetries,
retryInterval: tunnel.retryInterval * 1000,
autoStart: tunnel.autoStart,
isPinned: sshHost.pin,
useSocks5: sshHost.useSocks5,
socks5Host: sshHost.socks5Host,
socks5Port: sshHost.socks5Port,
socks5Username: sshHost.socks5Username,
socks5Password: sshHost.socks5Password,
});
toast.success(t("tunnels.clientTunnelStarted"));
} else if (action === "disconnect") {
await disconnectTunnel(name);
toast.info(t("tunnels.clientTunnelStopped"));
} else {
await cancelTunnel(name);
toast.info(t("tunnels.canceling"));
}
} catch (err) {
toast.error(t("tunnels.manualControlError"));
console.error("Tunnel action failed:", err);
} finally {
setTunnelActions((prev) => ({ ...prev, [name]: false }));
}
};
if (!host) {
return (
<div className="flex flex-col items-center justify-center flex-1 gap-3 p-6 text-center">
<div className="size-10 rounded-full bg-muted/40 flex items-center justify-center">
<Network className="size-5 text-muted-foreground/30" />
</div>
<span className="text-sm font-semibold text-muted-foreground/60">
{t("tunnels.noHostSelected")}
</span>
</div>
);
}
const tunnels = sshHost?.tunnelConnections ?? [];
const connectedCount = tunnels.filter((t, i) => {
if (!sshHost) return false;
const name = tunnelName(sshHost, i, t);
return tunnelStatuses[name]?.connected;
}).length;
return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden">
<div className="flex-1 overflow-y-auto px-3 py-3 flex flex-col gap-3">
<Card className="flex-row items-center justify-between px-3 py-3 shrink-0 gap-0">
<div className="flex items-center gap-3">
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
<Network className="size-5 text-accent-brand" />
</div>
<div>
<h1 className="text-2xl font-bold">{host.name}</h1>
<div className="flex items-center gap-2">
<span className="size-2 rounded-full bg-accent-brand" />
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
{connectedCount}/{tunnels.length} {t("tunnels.active")}
</span>
</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 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{tunnels.map((tunnel, index) => {
if (!sshHost) return null;
const name = tunnelName(sshHost, index, tunnel);
return (
<TunnelCard
key={name}
host={sshHost}
index={index}
tunnel={tunnel}
status={tunnelStatuses[name]}
isActing={tunnelActions[name] ?? false}
onAction={(action) => handleAction(action, index)}
/>
);
})}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full gap-4 py-20">
<div className="opacity-10 flex flex-col items-center gap-4">
<Network className="size-16" />
<span className="text-xl font-bold uppercase tracking-widest">
{t("tunnels.noSshTunnels")}
</span>
</div>
<p className="text-sm text-muted-foreground text-center max-w-sm">
{t("tunnels.createFirstTunnelMessage")}
</p>
</div>
)}
</div>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
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>
);
}
@@ -0,0 +1,62 @@
import type { TunnelMode } from "@/types/index.js";
type Translate = (
key: string,
options?: Record<string, string | number>,
) => string;
export function getTunnelTypeForMode(mode: TunnelMode): "local" | "remote" {
return mode === "remote" ? "remote" : "local";
}
export function getTunnelPortLabels(
scope: "client" | "server",
mode: TunnelMode,
t: Translate,
) {
if (scope === "client") {
return {
sourcePortLabel:
mode === "remote" ? t("tunnels.remotePort") : t("tunnels.localPort"),
endpointPortLabel:
mode === "remote" ? t("tunnels.localPort") : t("tunnels.remotePort"),
};
}
return {
sourcePortLabel: t("tunnels.currentHostPort"),
endpointPortLabel: t("tunnels.endpointPort"),
};
}
export function getTunnelModeDescription(
scope: "client" | "server",
mode: TunnelMode,
ports: {
sourcePort: string | number;
endpointPort: string | number;
},
t: Translate,
) {
if (scope === "client") {
if (mode === "dynamic") {
return t("tunnels.forwardDescriptionClientDynamic", {
sourcePort: ports.sourcePort,
});
}
if (mode === "local") {
return t("tunnels.forwardDescriptionClientLocal", ports);
}
return t("tunnels.forwardDescriptionClientRemote", ports);
}
if (mode === "dynamic") {
return t("tunnels.forwardDescriptionServerDynamic", {
sourcePort: ports.sourcePort,
});
}
if (mode === "local") {
return t("tunnels.forwardDescriptionServerLocal", ports);
}
return t("tunnels.forwardDescriptionServerRemote", ports);
}