@@ -720,6 +720,5 @@ export {
SidebarRail,
SidebarSeparator,
SidebarTrigger,
- // eslint-disable-next-line react-refresh/only-export-components
useSidebar,
}
diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx
new file mode 100644
index 00000000..cd62aff2
--- /dev/null
+++ b/src/components/ui/sonner.tsx
@@ -0,0 +1,23 @@
+import { useTheme } from "next-themes"
+import { Toaster as Sonner, ToasterProps } from "sonner"
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme()
+
+ return (
+
+ )
+}
+
+export { Toaster }
diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx
new file mode 100644
index 00000000..5513a5cd
--- /dev/null
+++ b/src/components/ui/table.tsx
@@ -0,0 +1,114 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Table({ className, ...props }: React.ComponentProps<"table">) {
+ return (
+
+ )
+}
+
+function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
+ return (
+
+ )
+}
+
+function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
+ return (
+
+ )
+}
+
+function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
+ return (
+
tr]:last:border-b-0",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<"td">) {
+ return (
+ [role=checkbox]]:translate-y-[2px]",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<"caption">) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
index 4ee26b38..750c93cd 100644
--- a/src/components/ui/tooltip.tsx
+++ b/src/components/ui/tooltip.tsx
@@ -52,7 +52,6 @@ function TooltipContent({
{...props}
>
{children}
-
)
diff --git a/src/index.css b/src/index.css
index df635134..89185ec7 100644
--- a/src/index.css
+++ b/src/index.css
@@ -130,4 +130,48 @@
body {
@apply bg-background text-foreground;
}
+}
+
+.thin-scrollbar {
+ scrollbar-width: thin;
+ scrollbar-color: #303032 transparent;
+}
+
+.thin-scrollbar::-webkit-scrollbar {
+ height: 6px;
+ width: 6px;
+}
+
+.thin-scrollbar::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.thin-scrollbar::-webkit-scrollbar-thumb {
+ background-color: #303032;
+ border-radius: 9999px;
+ border: 2px solid transparent;
+ background-clip: content-box;
+}
+
+.thin-scrollbar::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+.thin-scrollbar::-webkit-scrollbar-track {
+ background: #18181b;
+}
+
+.thin-scrollbar::-webkit-scrollbar-thumb {
+ background: #434345;
+ border-radius: 3px;
+}
+
+.thin-scrollbar::-webkit-scrollbar-thumb:hover {
+ background: #5a5a5d;
+}
+
+.thin-scrollbar {
+ scrollbar-width: thin;
+ scrollbar-color: #434345 #18181b;
}
\ No newline at end of file
diff --git a/src/ui/Admin/AdminSettings.tsx b/src/ui/Admin/AdminSettings.tsx
new file mode 100644
index 00000000..bece9c0a
--- /dev/null
+++ b/src/ui/Admin/AdminSettings.tsx
@@ -0,0 +1,450 @@
+import React from "react";
+import {useSidebar} from "@/components/ui/sidebar";
+import {Separator} from "@/components/ui/separator.tsx";
+import {Button} from "@/components/ui/button.tsx";
+import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
+import {Checkbox} from "@/components/ui/checkbox.tsx";
+import {Input} from "@/components/ui/input.tsx";
+import {Label} from "@/components/ui/label.tsx";
+import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table.tsx";
+import {Shield, Trash2, Users} from "lucide-react";
+import {
+ getOIDCConfig,
+ getRegistrationAllowed,
+ getUserList,
+ updateRegistrationAllowed,
+ updateOIDCConfig,
+ makeUserAdmin,
+ removeAdminStatus,
+ deleteUser
+} from "@/ui/main-axios.ts";
+
+function getCookie(name: string) {
+ return document.cookie.split('; ').reduce((r, v) => {
+ const parts = v.split('=');
+ return parts[0] === name ? decodeURIComponent(parts[1]) : r;
+ }, "");
+}
+
+interface AdminSettingsProps {
+ isTopbarOpen?: boolean;
+}
+
+export function AdminSettings({isTopbarOpen = true}: AdminSettingsProps): React.ReactElement {
+ const {state: sidebarState} = useSidebar();
+
+ const [allowRegistration, setAllowRegistration] = React.useState(true);
+ const [regLoading, setRegLoading] = React.useState(false);
+
+ const [oidcConfig, setOidcConfig] = React.useState({
+ client_id: '',
+ client_secret: '',
+ issuer_url: '',
+ authorization_url: '',
+ token_url: '',
+ identifier_path: 'sub',
+ name_path: 'name',
+ scopes: 'openid email profile'
+ });
+ const [oidcLoading, setOidcLoading] = React.useState(false);
+ const [oidcError, setOidcError] = React.useState(null);
+ const [oidcSuccess, setOidcSuccess] = React.useState(null);
+
+ const [users, setUsers] = React.useState>([]);
+ const [usersLoading, setUsersLoading] = React.useState(false);
+ const [newAdminUsername, setNewAdminUsername] = React.useState("");
+ const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
+ const [makeAdminError, setMakeAdminError] = React.useState(null);
+ const [makeAdminSuccess, setMakeAdminSuccess] = React.useState(null);
+
+ React.useEffect(() => {
+ const jwt = getCookie("jwt");
+ if (!jwt) return;
+ getOIDCConfig()
+ .then(res => {
+ if (res) setOidcConfig(res);
+ })
+ .catch(() => {
+ });
+ fetchUsers();
+ }, []);
+
+ React.useEffect(() => {
+ getRegistrationAllowed()
+ .then(res => {
+ if (typeof res?.allowed === 'boolean') {
+ setAllowRegistration(res.allowed);
+ }
+ })
+ .catch(() => {
+ });
+ }, []);
+
+ const fetchUsers = async () => {
+ const jwt = getCookie("jwt");
+ if (!jwt) return;
+ setUsersLoading(true);
+ try {
+ const response = await getUserList();
+ setUsers(response.users);
+ } finally {
+ setUsersLoading(false);
+ }
+ };
+
+ const handleToggleRegistration = async (checked: boolean) => {
+ setRegLoading(true);
+ const jwt = getCookie("jwt");
+ try {
+ await updateRegistrationAllowed(checked);
+ setAllowRegistration(checked);
+ } finally {
+ setRegLoading(false);
+ }
+ };
+
+ const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setOidcLoading(true);
+ setOidcError(null);
+ setOidcSuccess(null);
+
+ const required = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
+ const missing = required.filter(f => !oidcConfig[f as keyof typeof oidcConfig]);
+ if (missing.length > 0) {
+ setOidcError(`Missing required fields: ${missing.join(', ')}`);
+ setOidcLoading(false);
+ return;
+ }
+
+ const jwt = getCookie("jwt");
+ try {
+ await updateOIDCConfig(oidcConfig);
+ setOidcSuccess("OIDC configuration updated successfully!");
+ } catch (err: any) {
+ setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
+ } finally {
+ setOidcLoading(false);
+ }
+ };
+
+ const handleOIDCConfigChange = (field: string, value: string) => {
+ setOidcConfig(prev => ({...prev, [field]: value}));
+ };
+
+ const makeUserAdmin = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!newAdminUsername.trim()) return;
+ setMakeAdminLoading(true);
+ setMakeAdminError(null);
+ setMakeAdminSuccess(null);
+ const jwt = getCookie("jwt");
+ try {
+ await makeUserAdmin(newAdminUsername.trim());
+ setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
+ setNewAdminUsername("");
+ fetchUsers();
+ } catch (err: any) {
+ setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
+ } finally {
+ setMakeAdminLoading(false);
+ }
+ };
+
+ const removeAdminStatus = async (username: string) => {
+ if (!confirm(`Remove admin status from ${username}?`)) return;
+ const jwt = getCookie("jwt");
+ try {
+ await removeAdminStatus(username);
+ fetchUsers();
+ } catch {
+ }
+ };
+
+ const deleteUser = async (username: string) => {
+ if (!confirm(`Delete user ${username}? This cannot be undone.`)) return;
+ const jwt = getCookie("jwt");
+ try {
+ await deleteUser(username);
+ fetchUsers();
+ } catch {
+ }
+ };
+
+ const topMarginPx = isTopbarOpen ? 74 : 26;
+ const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
+ const bottomMarginPx = 8;
+ const wrapperStyle: React.CSSProperties = {
+ marginLeft: leftMarginPx,
+ marginRight: 17,
+ marginTop: topMarginPx,
+ marginBottom: bottomMarginPx,
+ height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`
+ };
+
+ return (
+
+
+
+
Admin Settings
+
+
+
+
+
+
+
+
+ General
+
+
+
+ OIDC
+
+
+
+ Users
+
+
+
+ Admins
+
+
+
+
+
+
User Registration
+
+
+ Allow new account registration
+
+
+
+
+
+
+
External Authentication (OIDC)
+
Configure external identity provider for
+ OIDC/OAuth2 authentication.
+
+ {oidcError && (
+
+ Error
+ {oidcError}
+
+ )}
+
+
+
+
+
+
+
+
+
User Management
+ {usersLoading ? "Loading..." : "Refresh"}
+
+ {usersLoading ? (
+
Loading users...
+ ) : (
+
+
+
+
+ Username
+ Type
+ Actions
+
+
+
+ {users.map((user) => (
+
+
+ {user.username}
+ {user.is_admin && (
+ Admin
+ )}
+
+ {user.is_oidc ? "External" : "Local"}
+
+ deleteUser(user.username)}
+ className="text-red-600 hover:text-red-700 hover:bg-red-50"
+ disabled={user.is_admin}>
+
+
+
+
+ ))}
+
+
+
+ )}
+
+
+
+
+
+
Admin Management
+
+
+
+
Current Admins
+
+
+
+
+ Username
+ Type
+ Actions
+
+
+
+ {users.filter(u => u.is_admin).map((admin) => (
+
+
+ {admin.username}
+ Admin
+
+ {admin.is_oidc ? "External" : "Local"}
+
+ removeAdminStatus(admin.username)}
+ className="text-orange-600 hover:text-orange-700 hover:bg-orange-50">
+
+ Remove Admin
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default AdminSettings;
\ No newline at end of file
diff --git a/src/ui/Homepage/Homepage.tsx b/src/ui/Homepage/Homepage.tsx
new file mode 100644
index 00000000..b6fbe9ae
--- /dev/null
+++ b/src/ui/Homepage/Homepage.tsx
@@ -0,0 +1,158 @@
+import React, {useEffect, useState} from "react";
+import {HomepageAuth} from "@/ui/Homepage/HomepageAuth.tsx";
+import {HomepageUpdateLog} from "@/ui/Homepage/HompageUpdateLog.tsx";
+import {HomepageAlertManager} from "@/ui/Homepage/HomepageAlertManager.tsx";
+import {Button} from "@/components/ui/button.tsx";
+import { getUserInfo, getDatabaseHealth } from "@/ui/main-axios.ts";
+
+interface HomepageProps {
+ onSelectView: (view: string) => void;
+ isAuthenticated: boolean;
+ authLoading: boolean;
+ onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => void;
+ isTopbarOpen?: boolean;
+}
+
+function getCookie(name: string) {
+ return document.cookie.split('; ').reduce((r, v) => {
+ const parts = v.split('=');
+ return parts[0] === name ? decodeURIComponent(parts[1]) : r;
+ }, "");
+}
+
+function setCookie(name: string, value: string, days = 7) {
+ const expires = new Date(Date.now() + days * 864e5).toUTCString();
+ document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
+}
+
+export function Homepage({
+ onSelectView,
+ isAuthenticated,
+ authLoading,
+ onAuthSuccess,
+ isTopbarOpen = true
+ }: HomepageProps): React.ReactElement {
+ const [loggedIn, setLoggedIn] = useState(isAuthenticated);
+ const [isAdmin, setIsAdmin] = useState(false);
+ const [username, setUsername] = useState(null);
+ const [userId, setUserId] = useState(null);
+ const [dbError, setDbError] = useState(null);
+
+ useEffect(() => {
+ setLoggedIn(isAuthenticated);
+ }, [isAuthenticated]);
+
+ useEffect(() => {
+ if (isAuthenticated) {
+ const jwt = getCookie("jwt");
+ if (jwt) {
+ Promise.all([
+ getUserInfo(),
+ getDatabaseHealth()
+ ])
+ .then(([meRes]) => {
+ setIsAdmin(!!meRes.is_admin);
+ setUsername(meRes.username || null);
+ setUserId(meRes.userId || null);
+ setDbError(null);
+ })
+ .catch((err) => {
+ setIsAdmin(false);
+ setUsername(null);
+ setUserId(null);
+ if (err?.response?.data?.error?.includes("Database")) {
+ setDbError("Could not connect to the database. Please try again later.");
+ } else {
+ setDbError(null);
+ }
+ });
+ }
+ }
+ }, [isAuthenticated]);
+
+ return (
+
+ {!loggedIn ? (
+
+
+
+ ) : (
+
+
+
+
+
Logged in!
+
+ You are logged in! Use the sidebar to access all available tools. To get started,
+ create an SSH Host in the SSH Manager tab. Once created, you can connect to that
+ host using the other apps in the sidebar.
+
+
+
+
+
window.open('https://github.com/LukeGus/Termix', '_blank')}
+ >
+ GitHub
+
+
+
window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
+ >
+ Feedback
+
+
+
window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
+ >
+ Discord
+
+
+
window.open('https://github.com/sponsors/LukeGus', '_blank')}
+ >
+ Donate
+
+
+
+
+
+
+
+ )}
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/ui/Homepage/HomepageAlertCard.tsx b/src/ui/Homepage/HomepageAlertCard.tsx
new file mode 100644
index 00000000..d2f34722
--- /dev/null
+++ b/src/ui/Homepage/HomepageAlertCard.tsx
@@ -0,0 +1,150 @@
+import React from "react";
+import {Card, CardContent, CardFooter, CardHeader, CardTitle} from "@/components/ui/card.tsx";
+import {Button} from "@/components/ui/button.tsx";
+import {Badge} from "@/components/ui/badge.tsx";
+import {X, ExternalLink, AlertTriangle, Info, CheckCircle, AlertCircle} from "lucide-react";
+
+interface TermixAlert {
+ id: string;
+ title: string;
+ message: string;
+ expiresAt: string;
+ priority?: 'low' | 'medium' | 'high' | 'critical';
+ type?: 'info' | 'warning' | 'error' | 'success';
+ actionUrl?: string;
+ actionText?: string;
+}
+
+interface AlertCardProps {
+ alert: TermixAlert;
+ onDismiss: (alertId: string) => void;
+ onClose: () => void;
+}
+
+const getAlertIcon = (type?: string) => {
+ switch (type) {
+ case 'warning':
+ return ;
+ case 'error':
+ return ;
+ case 'success':
+ return ;
+ case 'info':
+ default:
+ return ;
+ }
+};
+
+const getPriorityBadgeVariant = (priority?: string) => {
+ switch (priority) {
+ case 'critical':
+ return 'destructive';
+ case 'high':
+ return 'destructive';
+ case 'medium':
+ return 'secondary';
+ case 'low':
+ default:
+ return 'outline';
+ }
+};
+
+const getTypeBadgeVariant = (type?: string) => {
+ switch (type) {
+ case 'warning':
+ return 'secondary';
+ case 'error':
+ return 'destructive';
+ case 'success':
+ return 'default';
+ case 'info':
+ default:
+ return 'outline';
+ }
+};
+
+export function HomepageAlertCard({alert, onDismiss, onClose}: AlertCardProps): React.ReactElement {
+ if (!alert) {
+ return null;
+ }
+
+ const handleDismiss = () => {
+ onDismiss(alert.id);
+ onClose();
+ };
+
+ const formatExpiryDate = (expiryString: string) => {
+ const expiryDate = new Date(expiryString);
+ const now = new Date();
+ const diffTime = expiryDate.getTime() - now.getTime();
+ const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
+
+ if (diffDays < 0) return 'Expired';
+ if (diffDays === 0) return 'Expires today';
+ if (diffDays === 1) return 'Expires tomorrow';
+ return `Expires in ${diffDays} days`;
+ };
+
+ return (
+
+
+
+
+ {getAlertIcon(alert.type)}
+
+ {alert.title}
+
+
+
+
+
+
+
+ {alert.priority && (
+
+ {alert.priority.toUpperCase()}
+
+ )}
+ {alert.type && (
+
+ {alert.type}
+
+ )}
+
+ {formatExpiryDate(alert.expiresAt)}
+
+
+
+
+
+ {alert.message}
+
+
+
+
+
+ Dismiss
+
+ {alert.actionUrl && alert.actionText && (
+ window.open(alert.actionUrl, '_blank', 'noopener,noreferrer')}
+ className="gap-2"
+ >
+ {alert.actionText}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/ui/Homepage/HomepageAlertManager.tsx b/src/ui/Homepage/HomepageAlertManager.tsx
new file mode 100644
index 00000000..4aa8dd70
--- /dev/null
+++ b/src/ui/Homepage/HomepageAlertManager.tsx
@@ -0,0 +1,177 @@
+import React, {useEffect, useState} from "react";
+import {HomepageAlertCard} from "./HomepageAlertCard.tsx";
+import {Button} from "@/components/ui/button.tsx";
+import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
+
+interface TermixAlert {
+ id: string;
+ title: string;
+ message: string;
+ expiresAt: string;
+ priority?: 'low' | 'medium' | 'high' | 'critical';
+ type?: 'info' | 'warning' | 'error' | 'success';
+ actionUrl?: string;
+ actionText?: string;
+}
+
+interface AlertManagerProps {
+ userId: string | null;
+ loggedIn: boolean;
+}
+
+export function HomepageAlertManager({userId, loggedIn}: AlertManagerProps): React.ReactElement {
+ const [alerts, setAlerts] = useState([]);
+ const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (loggedIn && userId) {
+ fetchUserAlerts();
+ }
+ }, [loggedIn, userId]);
+
+ const fetchUserAlerts = async () => {
+ if (!userId) return;
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const response = await getUserAlerts(userId);
+
+ const userAlerts = response.alerts || [];
+
+ const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
+ const priorityOrder = {critical: 4, high: 3, medium: 2, low: 1};
+ const aPriority = priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
+ const bPriority = priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
+
+ if (aPriority !== bPriority) {
+ return bPriority - aPriority;
+ }
+
+ return new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime();
+ });
+
+ setAlerts(sortedAlerts);
+ setCurrentAlertIndex(0);
+ } catch (err) {
+ setError('Failed to load alerts');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleDismissAlert = async (alertId: string) => {
+ if (!userId) return;
+
+ try {
+ await dismissAlert(userId, alertId);
+
+ setAlerts(prev => {
+ const newAlerts = prev.filter(alert => alert.id !== alertId);
+ return newAlerts;
+ });
+
+ setCurrentAlertIndex(prevIndex => {
+ const newAlertsLength = alerts.length - 1;
+ if (newAlertsLength === 0) return 0;
+ if (prevIndex >= newAlertsLength) return Math.max(0, newAlertsLength - 1);
+ return prevIndex;
+ });
+ } catch (err) {
+ setError('Failed to dismiss alert');
+ }
+ };
+
+ const handleCloseCurrentAlert = () => {
+ if (alerts.length === 0) return;
+
+ if (currentAlertIndex < alerts.length - 1) {
+ setCurrentAlertIndex(currentAlertIndex + 1);
+ } else {
+ setAlerts([]);
+ setCurrentAlertIndex(0);
+ }
+ };
+
+ const handlePreviousAlert = () => {
+ if (currentAlertIndex > 0) {
+ setCurrentAlertIndex(currentAlertIndex - 1);
+ }
+ };
+
+ const handleNextAlert = () => {
+ if (currentAlertIndex < alerts.length - 1) {
+ setCurrentAlertIndex(currentAlertIndex + 1);
+ }
+ };
+
+ if (!loggedIn || !userId) {
+ return null;
+ }
+
+ if (alerts.length === 0) {
+ return null;
+ }
+
+ const currentAlert = alerts[currentAlertIndex];
+
+ if (!currentAlert) {
+ return null;
+ }
+
+ const priorityCounts = {critical: 0, high: 0, medium: 0, low: 0};
+ alerts.forEach(alert => {
+ const priority = alert.priority || 'low';
+ priorityCounts[priority as keyof typeof priorityCounts]++;
+ });
+ const hasMultipleAlerts = alerts.length > 1;
+
+ return (
+
+
+
+
+ {hasMultipleAlerts && (
+
+
+ Previous
+
+
+ {currentAlertIndex + 1} of {alerts.length}
+
+
+ Next
+
+
+ )}
+
+ {error && (
+
+ )}
+
+
+ );
+}
diff --git a/src/ui/Homepage/HomepageAuth.tsx b/src/ui/Homepage/HomepageAuth.tsx
new file mode 100644
index 00000000..8504e434
--- /dev/null
+++ b/src/ui/Homepage/HomepageAuth.tsx
@@ -0,0 +1,689 @@
+import React, {useState, useEffect} from "react";
+import {cn} from "../../lib/utils.ts";
+import {Button} from "../../components/ui/button.tsx";
+import {Input} from "../../components/ui/input.tsx";
+import {Label} from "../../components/ui/label.tsx";
+import {Alert, AlertTitle, AlertDescription} from "../../components/ui/alert.tsx";
+import {
+ registerUser,
+ loginUser,
+ getUserInfo,
+ getRegistrationAllowed,
+ getOIDCConfig,
+ getUserCount,
+ initiatePasswordReset,
+ verifyPasswordResetCode,
+ completePasswordReset,
+ getOIDCAuthorizeUrl
+} from "../main-axios.ts";
+
+function setCookie(name: string, value: string, days = 7) {
+ const expires = new Date(Date.now() + days * 864e5).toUTCString();
+ document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/`;
+}
+
+function getCookie(name: string) {
+ return document.cookie.split('; ').reduce((r, v) => {
+ const parts = v.split('=');
+ return parts[0] === name ? decodeURIComponent(parts[1]) : r;
+ }, "");
+}
+
+
+
+interface HomepageAuthProps extends React.ComponentProps<"div"> {
+ setLoggedIn: (loggedIn: boolean) => void;
+ setIsAdmin: (isAdmin: boolean) => void;
+ setUsername: (username: string | null) => void;
+ setUserId: (userId: string | null) => void;
+ loggedIn: boolean;
+ authLoading: boolean;
+ dbError: string | null;
+ setDbError: (error: string | null) => void;
+ onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => void;
+}
+
+export function HomepageAuth({
+ className,
+ setLoggedIn,
+ setIsAdmin,
+ setUsername,
+ setUserId,
+ loggedIn,
+ authLoading,
+ dbError,
+ setDbError,
+ onAuthSuccess,
+ ...props
+ }: HomepageAuthProps) {
+ const [tab, setTab] = useState<"login" | "signup" | "external" | "reset">("login");
+ const [localUsername, setLocalUsername] = useState("");
+ const [password, setPassword] = useState("");
+ const [signupConfirmPassword, setSignupConfirmPassword] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [oidcLoading, setOidcLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [internalLoggedIn, setInternalLoggedIn] = useState(false);
+ const [firstUser, setFirstUser] = useState(false);
+ const [registrationAllowed, setRegistrationAllowed] = useState(true);
+ const [oidcConfigured, setOidcConfigured] = useState(false);
+
+ const [resetStep, setResetStep] = useState<"initiate" | "verify" | "newPassword">("initiate");
+ const [resetCode, setResetCode] = useState("");
+ const [newPassword, setNewPassword] = useState("");
+ const [confirmPassword, setConfirmPassword] = useState("");
+ const [tempToken, setTempToken] = useState("");
+ const [resetLoading, setResetLoading] = useState(false);
+ const [resetSuccess, setResetSuccess] = useState(false);
+
+ useEffect(() => {
+ setInternalLoggedIn(loggedIn);
+ }, [loggedIn]);
+
+ useEffect(() => {
+ getRegistrationAllowed().then(res => {
+ setRegistrationAllowed(res.allowed);
+ });
+ }, []);
+
+ useEffect(() => {
+ getOIDCConfig().then((response) => {
+ if (response) {
+ setOidcConfigured(true);
+ } else {
+ setOidcConfigured(false);
+ }
+ }).catch((error) => {
+ if (error.response?.status === 404) {
+ setOidcConfigured(false);
+ } else {
+ setOidcConfigured(false);
+ }
+ });
+ }, []);
+
+ useEffect(() => {
+ getUserCount().then(res => {
+ if (res.count === 0) {
+ setFirstUser(true);
+ setTab("signup");
+ } else {
+ setFirstUser(false);
+ }
+ setDbError(null);
+ }).catch(() => {
+ setDbError("Could not connect to the database. Please try again later.");
+ });
+ }, [setDbError]);
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault();
+ setError(null);
+ setLoading(true);
+
+ if (!localUsername.trim()) {
+ setError("Username is required");
+ setLoading(false);
+ return;
+ }
+
+ try {
+ let res, meRes;
+ if (tab === "login") {
+ res = await loginUser(localUsername, password);
+ } else {
+ if (password !== signupConfirmPassword) {
+ setError("Passwords do not match");
+ setLoading(false);
+ return;
+ }
+ if (password.length < 6) {
+ setError("Password must be at least 6 characters long");
+ setLoading(false);
+ return;
+ }
+
+ await registerUser(localUsername, password);
+ res = await loginUser(localUsername, password);
+ }
+
+ if (!res || !res.token) {
+ throw new Error('No token received from login');
+ }
+
+ setCookie("jwt", res.token);
+ [meRes] = await Promise.all([
+ getUserInfo(),
+ ]);
+
+ setInternalLoggedIn(true);
+ setLoggedIn(true);
+ setIsAdmin(!!meRes.is_admin);
+ setUsername(meRes.username || null);
+ setUserId(meRes.userId || null);
+ setDbError(null);
+ onAuthSuccess({
+ isAdmin: !!meRes.is_admin,
+ username: meRes.username || null,
+ userId: meRes.userId || null
+ });
+ setInternalLoggedIn(true);
+ if (tab === "signup") {
+ setSignupConfirmPassword("");
+ }
+ } catch (err: any) {
+ setError(err?.response?.data?.error || err?.message || "Unknown error");
+ setInternalLoggedIn(false);
+ setLoggedIn(false);
+ setIsAdmin(false);
+ setUsername(null);
+ setUserId(null);
+ setCookie("jwt", "", -1);
+ if (err?.response?.data?.error?.includes("Database")) {
+ setDbError("Could not connect to the database. Please try again later.");
+ } else {
+ setDbError(null);
+ }
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function handleInitiatePasswordReset() {
+ setError(null);
+ setResetLoading(true);
+ try {
+ const result = await initiatePasswordReset(localUsername);
+ setResetStep("verify");
+ setError(null);
+ } catch (err: any) {
+ setError(err?.response?.data?.error || err?.message || "Failed to initiate password reset");
+ } finally {
+ setResetLoading(false);
+ }
+ }
+
+ async function handleVerifyResetCode() {
+ setError(null);
+ setResetLoading(true);
+ try {
+ const response = await verifyPasswordResetCode(localUsername, resetCode);
+ setTempToken(response.tempToken);
+ setResetStep("newPassword");
+ setError(null);
+ } catch (err: any) {
+ setError(err?.response?.data?.error || "Failed to verify reset code");
+ } finally {
+ setResetLoading(false);
+ }
+ }
+
+ async function handleCompletePasswordReset() {
+ setError(null);
+ setResetLoading(true);
+
+ if (newPassword !== confirmPassword) {
+ setError("Passwords do not match");
+ setResetLoading(false);
+ return;
+ }
+
+ if (newPassword.length < 6) {
+ setError("Password must be at least 6 characters long");
+ setResetLoading(false);
+ return;
+ }
+
+ try {
+ await completePasswordReset(localUsername, tempToken, newPassword);
+
+ setResetStep("initiate");
+ setResetCode("");
+ setNewPassword("");
+ setConfirmPassword("");
+ setTempToken("");
+ setError(null);
+
+ setResetSuccess(true);
+ } catch (err: any) {
+ setError(err?.response?.data?.error || "Failed to complete password reset");
+ } finally {
+ setResetLoading(false);
+ }
+ }
+
+ function resetPasswordState() {
+ setResetStep("initiate");
+ setResetCode("");
+ setNewPassword("");
+ setConfirmPassword("");
+ setTempToken("");
+ setError(null);
+ setResetSuccess(false);
+ setSignupConfirmPassword("");
+ }
+
+ function clearFormFields() {
+ setPassword("");
+ setSignupConfirmPassword("");
+ setError(null);
+ }
+
+ async function handleOIDCLogin() {
+ setError(null);
+ setOidcLoading(true);
+ try {
+ const authResponse = await getOIDCAuthorizeUrl();
+ const {auth_url: authUrl} = authResponse;
+
+ if (!authUrl || authUrl === 'undefined') {
+ throw new Error('Invalid authorization URL received from backend');
+ }
+
+ window.location.replace(authUrl);
+ } catch (err: any) {
+ setError(err?.response?.data?.error || err?.message || "Failed to start OIDC login");
+ setOidcLoading(false);
+ }
+ }
+
+ useEffect(() => {
+ const urlParams = new URLSearchParams(window.location.search);
+ const success = urlParams.get('success');
+ const token = urlParams.get('token');
+ const error = urlParams.get('error');
+
+ if (error) {
+ setError(`OIDC authentication failed: ${error}`);
+ setOidcLoading(false);
+ window.history.replaceState({}, document.title, window.location.pathname);
+ return;
+ }
+
+ if (success && token) {
+ setOidcLoading(true);
+ setError(null);
+
+ setCookie("jwt", token);
+ getUserInfo()
+ .then(meRes => {
+ setInternalLoggedIn(true);
+ setLoggedIn(true);
+ setIsAdmin(!!meRes.is_admin);
+ setUsername(meRes.username || null);
+ setUserId(meRes.id || null);
+ setDbError(null);
+ onAuthSuccess({
+ isAdmin: !!meRes.is_admin,
+ username: meRes.username || null,
+ userId: meRes.id || null
+ });
+ setInternalLoggedIn(true);
+ window.history.replaceState({}, document.title, window.location.pathname);
+ })
+ .catch(err => {
+ setError("Failed to get user info after OIDC login");
+ setInternalLoggedIn(false);
+ setLoggedIn(false);
+ setIsAdmin(false);
+ setUsername(null);
+ setUserId(null);
+ setCookie("jwt", "", -1);
+ window.history.replaceState({}, document.title, window.location.pathname);
+ })
+ .finally(() => {
+ setOidcLoading(false);
+ });
+ }
+ }, []);
+
+ const Spinner = (
+
+
+
+
+ );
+
+ return (
+
+ {dbError && (
+
+ Error
+ {dbError}
+
+ )}
+ {firstUser && !dbError && !internalLoggedIn && (
+
+ First User
+
+ You are the first user and will be made an admin. You can view admin settings in the sidebar
+ user dropdown. If you think this is a mistake, check the docker logs, or create a{" "}
+
+ GitHub issue
+ .
+
+
+ )}
+ {!registrationAllowed && !internalLoggedIn && (
+
+ Registration Disabled
+
+ New account registration is currently disabled by an admin. Please log in or contact an
+ administrator.
+
+
+ )}
+ {(!internalLoggedIn && (!authLoading || !getCookie("jwt"))) && (
+ <>
+
+ {
+ setTab("login");
+ if (tab === "reset") resetPasswordState();
+ if (tab === "signup") clearFormFields();
+ }}
+ aria-selected={tab === "login"}
+ disabled={loading || firstUser}
+ >
+ Login
+
+ {
+ setTab("signup");
+ if (tab === "reset") resetPasswordState();
+ if (tab === "login") clearFormFields();
+ }}
+ aria-selected={tab === "signup"}
+ disabled={loading || !registrationAllowed}
+ >
+ Sign Up
+
+ {oidcConfigured && (
+ {
+ setTab("external");
+ if (tab === "reset") resetPasswordState();
+ if (tab === "login" || tab === "signup") clearFormFields();
+ }}
+ aria-selected={tab === "external"}
+ disabled={oidcLoading}
+ >
+ External
+
+ )}
+
+
+
+ {tab === "login" ? "Login to your account" :
+ tab === "signup" ? "Create a new account" :
+ tab === "external" ? "Login with external provider" :
+ "Reset your password"}
+
+
+
+ {tab === "external" || tab === "reset" ? (
+
+ {tab === "external" && (
+ <>
+
+
Login using your configured external identity provider
+
+
+ {oidcLoading ? Spinner : "Login with External Provider"}
+
+ >
+ )}
+ {tab === "reset" && (
+ <>
+ {resetStep === "initiate" && (
+ <>
+
+
Enter your username to receive a password reset code. The code
+ will be logged in the docker container logs.
+
+
+
+ Username
+ setLocalUsername(e.target.value)}
+ disabled={resetLoading}
+ />
+
+
+ {resetLoading ? Spinner : "Send Reset Code"}
+
+
+ >
+ )}
+
+ {resetStep === "verify" && (
+ <>
+
+
Enter the 6-digit code from the docker container logs for
+ user: {localUsername}
+
+
+
+ Reset Code
+ setResetCode(e.target.value.replace(/\D/g, ''))}
+ disabled={resetLoading}
+ placeholder="000000"
+ />
+
+
+ {resetLoading ? Spinner : "Verify Code"}
+
+
{
+ setResetStep("initiate");
+ setResetCode("");
+ }}
+ >
+ Back
+
+
+ >
+ )}
+
+ {resetSuccess && (
+ <>
+
+ Success!
+
+ Your password has been successfully reset! You can now log in
+ with your new password.
+
+
+
{
+ setTab("login");
+ resetPasswordState();
+ }}
+ >
+ Go to Login
+
+ >
+ )}
+
+ {resetStep === "newPassword" && !resetSuccess && (
+ <>
+
+
Enter your new password for
+ user: {localUsername}
+
+
+
+ New Password
+ setNewPassword(e.target.value)}
+ disabled={resetLoading}
+ autoComplete="new-password"
+ />
+
+
+ Confirm Password
+ setConfirmPassword(e.target.value)}
+ disabled={resetLoading}
+ autoComplete="new-password"
+ />
+
+
+ {resetLoading ? Spinner : "Reset Password"}
+
+
{
+ setResetStep("verify");
+ setNewPassword("");
+ setConfirmPassword("");
+ }}
+ >
+ Back
+
+
+ >
+ )}
+ >
+ )}
+
+ ) : (
+
+ )}
+ >
+ )}
+ {error && (
+
+ Error
+ {error}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/src/apps/Homepage/HompageUpdateLog.tsx b/src/ui/Homepage/HompageUpdateLog.tsx
similarity index 70%
rename from src/apps/Homepage/HompageUpdateLog.tsx
rename to src/ui/Homepage/HompageUpdateLog.tsx
index 6a3e6fc5..c95f5bb0 100644
--- a/src/apps/Homepage/HompageUpdateLog.tsx
+++ b/src/ui/Homepage/HompageUpdateLog.tsx
@@ -2,7 +2,7 @@ import React, {useEffect, useState} from "react";
import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Separator} from "@/components/ui/separator.tsx";
-import axios from "axios";
+import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
interface HomepageUpdateLogProps extends React.ComponentProps<"div"> {
loggedIn: boolean;
@@ -50,12 +50,6 @@ interface VersionResponse {
cache_age?: number;
}
-const apiBase = import.meta.env.DEV ? "http://localhost:8081" : "";
-
-const API = axios.create({
- baseURL: apiBase,
-});
-
export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
const [releases, setReleases] = useState(null);
const [versionInfo, setVersionInfo] = useState(null);
@@ -66,12 +60,12 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
if (loggedIn) {
setLoading(true);
Promise.all([
- API.get('/releases/rss?per_page=100'),
- API.get('/version/')
+ getReleasesRSS(100),
+ getVersionInfo()
])
.then(([releasesRes, versionRes]) => {
- setReleases(releasesRes.data);
- setVersionInfo(versionRes.data);
+ setReleases(releasesRes);
+ setVersionInfo(versionRes);
setError(null);
})
.catch(err => {
@@ -94,70 +88,63 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
};
return (
-
+
-
Updates & Releases
+
Updates & Releases
-
+
{versionInfo && versionInfo.status === 'requires_update' && (
-
- Update Available
-
+
+ Update Available
+
A new version ({versionInfo.version}) is available.
- window.open("https://docs.termix.site/docs", '_blank')}
- >
- Update now
-
)}
{versionInfo && versionInfo.status === 'requires_update' && (
-
+
)}
-
+
{loading && (
)}
{error && (
-
- Error
- {error}
+
+ Error
+ {error}
)}
{releases?.items.map((release) => (
window.open(release.link, '_blank')}
>
-
+
{release.title}
{release.isPrerelease && (
+ className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
Pre-release
)}
-
+
{formatDescription(release.description)}
-
+
{new Date(release.pubDate).toLocaleDateString()}
{release.assets.length > 0 && (
<>
@@ -170,9 +157,9 @@ export function HomepageUpdateLog({loggedIn}: HomepageUpdateLogProps) {
))}
{releases && releases.items.length === 0 && !loading && (
-
- No Releases
-
+
+ No Releases
+
No releases found.
diff --git a/src/ui/Navigation/AppView.tsx b/src/ui/Navigation/AppView.tsx
new file mode 100644
index 00000000..51117c69
--- /dev/null
+++ b/src/ui/Navigation/AppView.tsx
@@ -0,0 +1,553 @@
+import React, {useEffect, useRef, useState} from "react";
+import {Terminal} from "@/ui/apps/Terminal/Terminal.tsx";
+import {Server as ServerView} from "@/ui/apps/Server/Server.tsx";
+import {FileManager} from "@/ui/apps/File Manager/FileManager.tsx";
+import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
+import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
+import * as ResizablePrimitive from "react-resizable-panels";
+import {useSidebar} from "@/components/ui/sidebar.tsx";
+import {LucideRefreshCcw, LucideRefreshCw, RefreshCcw, RefreshCcwDot} from "lucide-react";
+import {Button} from "@/components/ui/button.tsx";
+
+interface TerminalViewProps {
+ isTopbarOpen?: boolean;
+}
+
+export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactElement {
+ const {tabs, currentTab, allSplitScreenTab} = useTabs() as any;
+ const {state: sidebarState} = useSidebar();
+
+ const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal' || tab.type === 'server' || tab.type === 'file_manager');
+
+ const containerRef = useRef(null);
+ const panelRefs = useRef>({});
+ const [panelRects, setPanelRects] = useState>({});
+ const [ready, setReady] = useState(true);
+ const [resetKey, setResetKey] = useState(0);
+
+ const updatePanelRects = () => {
+ const next: Record = {};
+ Object.entries(panelRefs.current).forEach(([id, el]) => {
+ if (el) next[id] = el.getBoundingClientRect();
+ });
+ setPanelRects(next);
+ };
+
+ const fitActiveAndNotify = () => {
+ const visibleIds: number[] = [];
+ if (allSplitScreenTab.length === 0) {
+ if (currentTab) visibleIds.push(currentTab);
+ } else {
+ const splitIds = allSplitScreenTab as number[];
+ visibleIds.push(currentTab, ...splitIds.filter((i) => i !== currentTab));
+ }
+ terminalTabs.forEach((t: any) => {
+ if (visibleIds.includes(t.id)) {
+ const ref = t.terminalRef?.current;
+ if (ref?.fit) ref.fit();
+ if (ref?.notifyResize) ref.notifyResize();
+ if (ref?.refresh) ref.refresh();
+ }
+ });
+ };
+
+ const layoutScheduleRef = useRef(null);
+ const scheduleMeasureAndFit = () => {
+ if (layoutScheduleRef.current) cancelAnimationFrame(layoutScheduleRef.current);
+ layoutScheduleRef.current = requestAnimationFrame(() => {
+ updatePanelRects();
+ layoutScheduleRef.current = requestAnimationFrame(() => {
+ fitActiveAndNotify();
+ });
+ });
+ };
+
+ const hideThenFit = () => {
+ setReady(false);
+ requestAnimationFrame(() => {
+ updatePanelRects();
+ requestAnimationFrame(() => {
+ fitActiveAndNotify();
+ setReady(true);
+ });
+ });
+ };
+
+ useEffect(() => {
+ hideThenFit();
+ }, [currentTab, terminalTabs.length, allSplitScreenTab.join(',')]);
+
+ useEffect(() => {
+ scheduleMeasureAndFit();
+ }, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]);
+
+ useEffect(() => {
+ const roContainer = containerRef.current ? new ResizeObserver(() => {
+ updatePanelRects();
+ fitActiveAndNotify();
+ }) : null;
+ if (containerRef.current && roContainer) roContainer.observe(containerRef.current);
+ return () => roContainer?.disconnect();
+ }, []);
+
+ useEffect(() => {
+ const onWinResize = () => {
+ updatePanelRects();
+ fitActiveAndNotify();
+ };
+ window.addEventListener('resize', onWinResize);
+ return () => window.removeEventListener('resize', onWinResize);
+ }, []);
+
+ const HEADER_H = 28;
+
+ const renderTerminalsLayer = () => {
+ const styles: Record = {};
+ const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
+ const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
+ const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
+
+ if (allSplitScreenTab.length === 0 && mainTab) {
+ const isFileManagerTab = mainTab.type === 'file_manager';
+ styles[mainTab.id] = {
+ position: 'absolute',
+ top: isFileManagerTab ? 0 : 2,
+ left: isFileManagerTab ? 0 : 2,
+ right: isFileManagerTab ? 0 : 2,
+ bottom: isFileManagerTab ? 0 : 2,
+ zIndex: 20,
+ display: 'block',
+ pointerEvents: 'auto',
+ opacity: ready ? 1 : 0
+ };
+ } else {
+ layoutTabs.forEach((t: any) => {
+ const rect = panelRects[String(t.id)];
+ const parentRect = containerRef.current?.getBoundingClientRect();
+ if (rect && parentRect) {
+ styles[t.id] = {
+ position: 'absolute',
+ top: (rect.top - parentRect.top) + HEADER_H + 2,
+ left: (rect.left - parentRect.left) + 2,
+ width: rect.width - 4,
+ height: rect.height - HEADER_H - 4,
+ zIndex: 20,
+ display: 'block',
+ pointerEvents: 'auto',
+ opacity: ready ? 1 : 0,
+ };
+ }
+ });
+ }
+
+ return (
+
+ {terminalTabs.map((t: any) => {
+ const hasStyle = !!styles[t.id];
+ const isVisible = hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
+
+ const finalStyle: React.CSSProperties = hasStyle
+ ? {...styles[t.id], overflow: 'hidden'}
+ : {
+ position: 'absolute', inset: 0, visibility: 'hidden', pointerEvents: 'none', zIndex: 0,
+ } as React.CSSProperties;
+
+ const effectiveVisible = isVisible && ready;
+ return (
+
+
+ {t.type === 'terminal' ? (
+ 0}
+ />
+ ) : t.type === 'server' ? (
+
+ ) : (
+
+ )}
+
+
+ );
+ })}
+
+ );
+ };
+
+ const ResetButton = ({onClick}: { onClick: () => void }) => (
+
+
+
+ );
+
+ const handleReset = () => {
+ setResetKey((k) => k + 1);
+ requestAnimationFrame(() => scheduleMeasureAndFit());
+ };
+
+ const renderSplitOverlays = () => {
+ const splitTabs = terminalTabs.filter((tab: any) => allSplitScreenTab.includes(tab.id));
+ const mainTab = terminalTabs.find((tab: any) => tab.id === currentTab);
+ const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
+ if (allSplitScreenTab.length === 0) return null;
+
+ const handleStyle = {pointerEvents: 'auto', zIndex: 12, background: '#303032'} as React.CSSProperties;
+ const commonGroupProps = {onLayout: scheduleMeasureAndFit, onResize: scheduleMeasureAndFit} as any;
+
+ if (layoutTabs.length === 2) {
+ const [a, b] = layoutTabs as any[];
+ return (
+
+
+
+ {
+ panelRefs.current[String(a.id)] = el;
+ }} style={{
+ height: '100%',
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ background: 'transparent',
+ position: 'relative'
+ }}>
+
{a.title}
+
+
+
+
+ {
+ panelRefs.current[String(b.id)] = el;
+ }} style={{
+ height: '100%',
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ background: 'transparent',
+ position: 'relative'
+ }}>
+
+ {b.title}
+
+
+
+
+
+
+ );
+ }
+ if (layoutTabs.length === 3) {
+ const [a, b, c] = layoutTabs as any[];
+ return (
+
+
+
+
+
+ {
+ panelRefs.current[String(a.id)] = el;
+ }} style={{
+ height: '100%',
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ position: 'relative'
+ }}>
+
{a.title}
+
+
+
+
+ {
+ panelRefs.current[String(b.id)] = el;
+ }} style={{
+ height: '100%',
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ position: 'relative'
+ }}>
+
+ {b.title}
+
+
+
+
+
+
+
+
+ {
+ panelRefs.current[String(c.id)] = el;
+ }} style={{
+ height: '100%',
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ position: 'relative'
+ }}>
+
{c.title}
+
+
+
+
+ );
+ }
+ if (layoutTabs.length === 4) {
+ const [a, b, c, d] = layoutTabs as any[];
+ return (
+
+
+
+
+
+ {
+ panelRefs.current[String(a.id)] = el;
+ }} style={{
+ height: '100%',
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ position: 'relative'
+ }}>
+
{a.title}
+
+
+
+
+ {
+ panelRefs.current[String(b.id)] = el;
+ }} style={{
+ height: '100%',
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ position: 'relative'
+ }}>
+
+ {b.title}
+
+
+
+
+
+
+
+
+
+
+ {
+ panelRefs.current[String(c.id)] = el;
+ }} style={{
+ height: '100%',
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ position: 'relative'
+ }}>
+
{c.title}
+
+
+
+
+ {
+ panelRefs.current[String(d.id)] = el;
+ }} style={{
+ height: '100%',
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ position: 'relative'
+ }}>
+
{d.title}
+
+
+
+
+
+
+ );
+ }
+ return null;
+ };
+
+ const currentTabData = tabs.find((tab: any) => tab.id === currentTab);
+ const isFileManager = currentTabData?.type === 'file_manager';
+ const isSplitScreen = allSplitScreenTab.length > 0;
+
+ const topMarginPx = isTopbarOpen ? 74 : 26;
+ const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
+ const bottomMarginPx = 8;
+
+ return (
+
+ {renderTerminalsLayer()}
+ {renderSplitOverlays()}
+
+ );
+}
diff --git a/src/ui/Navigation/Hosts/FolderCard.tsx b/src/ui/Navigation/Hosts/FolderCard.tsx
new file mode 100644
index 00000000..b4c473cb
--- /dev/null
+++ b/src/ui/Navigation/Hosts/FolderCard.tsx
@@ -0,0 +1,81 @@
+import React, {useState} from "react";
+import {CardTitle} from "@/components/ui/card.tsx";
+import {ChevronDown, Folder} from "lucide-react";
+import {Button} from "@/components/ui/button.tsx";
+import {Host} from "@/ui/Navigation/Hosts/Host.tsx";
+import {Separator} from "@/components/ui/separator.tsx";
+
+interface SSHHost {
+ id: number;
+ name: string;
+ ip: string;
+ port: number;
+ username: string;
+ folder: string;
+ tags: string[];
+ pin: boolean;
+ authType: string;
+ password?: string;
+ key?: string;
+ keyPassword?: string;
+ keyType?: string;
+ enableTerminal: boolean;
+ enableTunnel: boolean;
+ enableFileManager: boolean;
+ defaultPath: string;
+ tunnelConnections: any[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+interface FolderCardProps {
+ folderName: string;
+ hosts: SSHHost[];
+ isFirst: boolean;
+ isLast: boolean;
+}
+
+export function FolderCard({folderName, hosts, isFirst, isLast}: FolderCardProps): React.ReactElement {
+ const [isExpanded, setIsExpanded] = useState(true);
+
+ const toggleExpanded = () => {
+ setIsExpanded(!isExpanded);
+ };
+
+ return (
+
+
+
+
+
+
+
+ {folderName}
+
+
+
+
+
+
+ {isExpanded && (
+
+ {hosts.map((host, index) => (
+
+
+ {index < hosts.length - 1 && (
+
+
+
+ )}
+
+ ))}
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/src/ui/Navigation/Hosts/Host.tsx b/src/ui/Navigation/Hosts/Host.tsx
new file mode 100644
index 00000000..9d393733
--- /dev/null
+++ b/src/ui/Navigation/Hosts/Host.tsx
@@ -0,0 +1,111 @@
+import React, {useEffect, useState} from "react";
+import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
+import {Button} from "@/components/ui/button.tsx";
+import {ButtonGroup} from "@/components/ui/button-group.tsx";
+import {Server, Terminal} from "lucide-react";
+import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
+import {getServerStatusById} from "@/ui/main-axios.ts";
+
+interface SSHHost {
+ id: number;
+ name: string;
+ ip: string;
+ port: number;
+ username: string;
+ folder: string;
+ tags: string[];
+ pin: boolean;
+ authType: string;
+ password?: string;
+ key?: string;
+ keyPassword?: string;
+ keyType?: string;
+ enableTerminal: boolean;
+ enableTunnel: boolean;
+ enableFileManager: boolean;
+ defaultPath: string;
+ tunnelConnections: any[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+interface HostProps {
+ host: SSHHost;
+}
+
+export function Host({host}: HostProps): React.ReactElement {
+ const {addTab} = useTabs();
+ const [serverStatus, setServerStatus] = useState<'online' | 'offline'>('offline');
+ const tags = Array.isArray(host.tags) ? host.tags : [];
+ const hasTags = tags.length > 0;
+
+ const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
+
+ useEffect(() => {
+ let cancelled = false;
+ let intervalId: number | undefined;
+
+ const fetchStatus = async () => {
+ try {
+ const res = await getServerStatusById(host.id);
+ if (!cancelled) {
+ setServerStatus(res?.status === 'online' ? 'online' : 'offline');
+ }
+ } catch {
+ if (!cancelled) setServerStatus('offline');
+ }
+ };
+
+ fetchStatus();
+ intervalId = window.setInterval(fetchStatus, 60_000);
+
+ return () => {
+ cancelled = true;
+ if (intervalId) window.clearInterval(intervalId);
+ };
+ }, [host.id]);
+
+ const handleTerminalClick = () => {
+ addTab({type: 'terminal', title, hostConfig: host});
+ };
+
+ const handleServerClick = () => {
+ addTab({type: 'server', title, hostConfig: host});
+ };
+
+ return (
+
+
+
+
+
+
+ {host.name || host.ip}
+
+
+
+
+
+ {host.enableTerminal && (
+
+
+
+ )}
+
+
+ {hasTags && (
+
+ {tags.map((tag: string) => (
+
+ ))}
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/src/ui/Navigation/LeftSidebar.tsx b/src/ui/Navigation/LeftSidebar.tsx
new file mode 100644
index 00000000..f60383a9
--- /dev/null
+++ b/src/ui/Navigation/LeftSidebar.tsx
@@ -0,0 +1,678 @@
+import React, {useState} from 'react';
+import {
+ Computer,
+ Server,
+ File,
+ Hammer, ChevronUp, User2, HardDrive, Trash2, Users, Shield, Settings, Menu, ChevronRight
+} from "lucide-react";
+
+import {
+ Sidebar,
+ SidebarContent, SidebarFooter,
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarMenu,
+ SidebarMenuButton,
+ SidebarMenuItem, SidebarProvider, SidebarInset, SidebarHeader,
+} from "@/components/ui/sidebar.tsx"
+
+import {
+ Separator,
+} from "@/components/ui/separator.tsx"
+import {DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger} from "@radix-ui/react-dropdown-menu";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+ SheetClose
+} from "@/components/ui/sheet";
+import {Checkbox} from "@/components/ui/checkbox.tsx";
+import {Input} from "@/components/ui/input.tsx";
+import {Label} from "@/components/ui/label.tsx";
+import {Button} from "@/components/ui/button.tsx";
+import {Alert, AlertTitle, AlertDescription} from "@/components/ui/alert.tsx";
+import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table.tsx";
+import {Card} from "@/components/ui/card.tsx";
+import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
+import {getSSHHosts} from "@/ui/main-axios.ts";
+import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
+import {
+ getOIDCConfig,
+ getUserList,
+ makeUserAdmin,
+ removeAdminStatus,
+ deleteUser,
+ deleteAccount
+} from "@/ui/main-axios.ts";
+
+interface SSHHost {
+ id: number;
+ name: string;
+ ip: string;
+ port: number;
+ username: string;
+ folder: string;
+ tags: string[];
+ pin: boolean;
+ authType: string;
+ password?: string;
+ key?: string;
+ keyPassword?: string;
+ keyType?: string;
+ enableTerminal: boolean;
+ enableTunnel: boolean;
+ enableFileManager: boolean;
+ defaultPath: string;
+ tunnelConnections: any[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+interface SidebarProps {
+ onSelectView: (view: string) => void;
+ getView?: () => string;
+ disabled?: boolean;
+ isAdmin?: boolean;
+ username?: string | null;
+ children?: React.ReactNode;
+}
+
+function handleLogout() {
+ document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
+ window.location.reload();
+}
+
+function getCookie(name: string) {
+ return document.cookie.split('; ').reduce((r, v) => {
+ const parts = v.split('=');
+ return parts[0] === name ? decodeURIComponent(parts[1]) : r;
+ }, "");
+}
+
+
+
+export function LeftSidebar({
+ onSelectView,
+ getView,
+ disabled,
+ isAdmin,
+ username,
+ children,
+ }: SidebarProps): React.ReactElement {
+ const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
+
+ const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
+ const [deletePassword, setDeletePassword] = React.useState("");
+ const [deleteLoading, setDeleteLoading] = React.useState(false);
+ const [deleteError, setDeleteError] = React.useState(null);
+ const [adminCount, setAdminCount] = React.useState(0);
+
+ const [users, setUsers] = React.useState>([]);
+ const [newAdminUsername, setNewAdminUsername] = React.useState("");
+ const [usersLoading, setUsersLoading] = React.useState(false);
+ const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
+ const [makeAdminError, setMakeAdminError] = React.useState(null);
+ const [makeAdminSuccess, setMakeAdminSuccess] = React.useState(null);
+ const [oidcConfig, setOidcConfig] = React.useState(null);
+
+ const [isSidebarOpen, setIsSidebarOpen] = useState(true);
+
+ const {tabs: tabList, addTab, setCurrentTab, allSplitScreenTab} = useTabs() as any;
+ const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
+ const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager');
+ const openSshManagerTab = () => {
+ if (sshManagerTab || isSplitScreenActive) return;
+ const id = addTab({type: 'ssh_manager', title: 'SSH Manager'} as any);
+ setCurrentTab(id);
+ };
+ const adminTab = tabList.find((t) => t.type === 'admin');
+ const openAdminTab = () => {
+ if (isSplitScreenActive) return;
+ if (adminTab) {
+ setCurrentTab(adminTab.id);
+ return;
+ }
+ const id = addTab({type: 'admin', title: 'Admin'} as any);
+ setCurrentTab(id);
+ };
+
+ const [hosts, setHosts] = useState([]);
+ const [hostsLoading, setHostsLoading] = useState(false);
+ const [hostsError, setHostsError] = useState(null);
+ const prevHostsRef = React.useRef([]);
+ const [search, setSearch] = useState("");
+ const [debouncedSearch, setDebouncedSearch] = useState("");
+
+ React.useEffect(() => {
+ if (adminSheetOpen) {
+ const jwt = getCookie("jwt");
+ if (jwt && isAdmin) {
+ getOIDCConfig().then(res => {
+ if (res) {
+ setOidcConfig(res);
+ }
+ }).catch((error) => {
+ });
+ fetchUsers();
+ }
+ } else {
+ const jwt = getCookie("jwt");
+ if (jwt && isAdmin) {
+ fetchAdminCount();
+ }
+ }
+ }, [adminSheetOpen, isAdmin]);
+
+ React.useEffect(() => {
+ if (!isAdmin) {
+ setAdminSheetOpen(false);
+ setUsers([]);
+ setAdminCount(0);
+ }
+ }, [isAdmin]);
+
+ const fetchHosts = React.useCallback(async () => {
+ try {
+ const newHosts = await getSSHHosts();
+ const prevHosts = prevHostsRef.current;
+
+ const existingHostsMap = new Map(prevHosts.map(h => [h.id, h]));
+ const newHostsMap = new Map(newHosts.map(h => [h.id, h]));
+
+ let hasChanges = false;
+
+ if (newHosts.length !== prevHosts.length) {
+ hasChanges = true;
+ } else {
+ for (const [id, newHost] of newHostsMap) {
+ const existingHost = existingHostsMap.get(id);
+ if (!existingHost) {
+ hasChanges = true;
+ break;
+ }
+
+ if (
+ newHost.name !== existingHost.name ||
+ newHost.folder !== existingHost.folder ||
+ newHost.ip !== existingHost.ip ||
+ newHost.port !== existingHost.port ||
+ newHost.username !== existingHost.username ||
+ newHost.pin !== existingHost.pin ||
+ newHost.enableTerminal !== existingHost.enableTerminal ||
+ JSON.stringify(newHost.tags) !== JSON.stringify(existingHost.tags)
+ ) {
+ hasChanges = true;
+ break;
+ }
+ }
+ }
+
+ if (hasChanges) {
+ setTimeout(() => {
+ setHosts(newHosts);
+ prevHostsRef.current = newHosts;
+ }, 50);
+ }
+ } catch (err: any) {
+ setHostsError('Failed to load hosts');
+ }
+ }, []);
+
+ React.useEffect(() => {
+ fetchHosts();
+ const interval = setInterval(fetchHosts, 300000); // 5 minutes instead of 10 seconds
+ return () => clearInterval(interval);
+ }, [fetchHosts]);
+
+ React.useEffect(() => {
+ const handleHostsChanged = () => {
+ fetchHosts();
+ };
+ window.addEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
+ return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
+ }, [fetchHosts]);
+
+ React.useEffect(() => {
+ const handler = setTimeout(() => setDebouncedSearch(search), 200);
+ return () => clearTimeout(handler);
+ }, [search]);
+
+ const filteredHosts = React.useMemo(() => {
+ if (!debouncedSearch.trim()) return hosts;
+ const q = debouncedSearch.trim().toLowerCase();
+ return hosts.filter(h => {
+ const searchableText = [
+ h.name || '',
+ h.username,
+ h.ip,
+ h.folder || '',
+ ...(h.tags || []),
+ h.authType,
+ h.defaultPath || ''
+ ].join(' ').toLowerCase();
+ return searchableText.includes(q);
+ });
+ }, [hosts, debouncedSearch]);
+
+ const hostsByFolder = React.useMemo(() => {
+ const map: Record = {};
+ filteredHosts.forEach(h => {
+ const folder = h.folder && h.folder.trim() ? h.folder : 'No Folder';
+ if (!map[folder]) map[folder] = [];
+ map[folder].push(h);
+ });
+ return map;
+ }, [filteredHosts]);
+
+ const sortedFolders = React.useMemo(() => {
+ const folders = Object.keys(hostsByFolder);
+ folders.sort((a, b) => {
+ if (a === 'No Folder') return -1;
+ if (b === 'No Folder') return 1;
+ return a.localeCompare(b);
+ });
+ return folders;
+ }, [hostsByFolder]);
+
+ const getSortedHosts = React.useCallback((arr: SSHHost[]) => {
+ const pinned = arr.filter(h => h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
+ const rest = arr.filter(h => !h.pin).sort((a, b) => (a.name || '').localeCompare(b.name || ''));
+ return [...pinned, ...rest];
+ }, []);
+
+ const handleDeleteAccount = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setDeleteLoading(true);
+ setDeleteError(null);
+
+ if (!deletePassword.trim()) {
+ setDeleteError("Password is required");
+ setDeleteLoading(false);
+ return;
+ }
+
+ const jwt = getCookie("jwt");
+ try {
+ await deleteAccount(deletePassword);
+
+ handleLogout();
+ } catch (err: any) {
+ setDeleteError(err?.response?.data?.error || "Failed to delete account");
+ setDeleteLoading(false);
+ }
+ };
+
+ const fetchUsers = async () => {
+ const jwt = getCookie("jwt");
+
+ if (!jwt || !isAdmin) {
+ return;
+ }
+
+ setUsersLoading(true);
+ try {
+ const response = await getUserList();
+ setUsers(response.users);
+
+ const adminUsers = response.users.filter((user: any) => user.is_admin);
+ setAdminCount(adminUsers.length);
+ } catch (err: any) {
+ } finally {
+ setUsersLoading(false);
+ }
+ };
+
+ const fetchAdminCount = async () => {
+ const jwt = getCookie("jwt");
+
+ if (!jwt || !isAdmin) {
+ return;
+ }
+
+ try {
+ const response = await getUserList();
+ const adminUsers = response.users.filter((user: any) => user.is_admin);
+ setAdminCount(adminUsers.length);
+ } catch (err: any) {
+ }
+ };
+
+ const makeUserAdmin = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!newAdminUsername.trim()) return;
+
+ if (!isAdmin) {
+ return;
+ }
+
+ setMakeAdminLoading(true);
+ setMakeAdminError(null);
+ setMakeAdminSuccess(null);
+
+ const jwt = getCookie("jwt");
+ try {
+ await makeUserAdmin(newAdminUsername.trim());
+ setMakeAdminSuccess(`User ${newAdminUsername} is now an admin`);
+ setNewAdminUsername("");
+ fetchUsers();
+ } catch (err: any) {
+ setMakeAdminError(err?.response?.data?.error || "Failed to make user admin");
+ } finally {
+ setMakeAdminLoading(false);
+ }
+ };
+
+ const removeAdminStatus = async (username: string) => {
+ if (!confirm(`Are you sure you want to remove admin status from ${username}?`)) return;
+
+ if (!isAdmin) {
+ return;
+ }
+
+ const jwt = getCookie("jwt");
+ try {
+ await removeAdminStatus(username);
+ fetchUsers();
+ } catch (err: any) {
+ }
+ };
+
+ const deleteUser = async (username: string) => {
+ if (!confirm(`Are you sure you want to delete user ${username}? This action cannot be undone.`)) return;
+
+ if (!isAdmin) {
+ return;
+ }
+
+ const jwt = getCookie("jwt");
+ try {
+ await deleteUser(username);
+ fetchUsers();
+ } catch (err: any) {
+ }
+ };
+
+ return (
+
+
+
+
+
+ Termix
+ setIsSidebarOpen(!isSidebarOpen)}
+ className="w-[28px] h-[28px] absolute right-5"
+ >
+
+
+
+
+
+
+
+
+
+ Host Manager
+
+
+
+
+
+ setSearch(e.target.value)}
+ placeholder="Search hosts by any info..."
+ className="w-full h-8 text-sm border-2 border-[#272728] rounded-lg"
+ autoComplete="off"
+ />
+
+
+ {hostsError && (
+
+ )}
+
+ {hostsLoading && (
+
+ )}
+
+ {sortedFolders.map((folder, idx) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+ {username ? username : 'Signed out'}
+
+
+
+
+ {isAdmin && (
+ {
+ if (isAdmin) openAdminTab();
+ }}>
+ Admin Settings
+
+ )}
+
+ Sign out
+
+ setDeleteAccountOpen(true)}
+ disabled={isAdmin && adminCount <= 1}
+ >
+
+ Delete Account
+ {isAdmin && adminCount <= 1 && " (Last Admin)"}
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+ {!isSidebarOpen && (
+
setIsSidebarOpen(true)}
+ className="absolute top-0 left-0 w-[10px] h-full bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-tr-md rounded-br-md">
+
+
+ )}
+
+ {deleteAccountOpen && (
+
+
e.stopPropagation()}
+ >
+
+
Delete Account
+ {
+ setDeleteAccountOpen(false);
+ setDeletePassword("");
+ setDeleteError(null);
+ }}
+ className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
+ title="Close Delete Account"
+ >
+ ×
+
+
+
+
+
+
+ This action cannot be undone. This will permanently delete your account and all
+ associated data.
+
+
+
+ Warning
+
+ Deleting your account will remove all your data including SSH hosts,
+ configurations, and settings.
+ This action is irreversible.
+
+
+
+ {deleteError && (
+
+ Error
+ {deleteError}
+
+ )}
+
+
+
+
+
+
+
{
+ setDeleteAccountOpen(false);
+ setDeletePassword("");
+ setDeleteError(null);
+ }}
+ style={{cursor: 'pointer'}}
+ />
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/src/ui/Navigation/Tabs/Tab.tsx b/src/ui/Navigation/Tabs/Tab.tsx
new file mode 100644
index 00000000..217aeb33
--- /dev/null
+++ b/src/ui/Navigation/Tabs/Tab.tsx
@@ -0,0 +1,140 @@
+import React from "react";
+import {ButtonGroup} from "@/components/ui/button-group.tsx";
+import {Button} from "@/components/ui/button.tsx";
+import {
+ Home,
+ SeparatorVertical,
+ X,
+ Terminal as TerminalIcon,
+ Server as ServerIcon,
+ Folder as FolderIcon
+} from "lucide-react";
+
+interface TabProps {
+ tabType: string;
+ title?: string;
+ isActive?: boolean;
+ onActivate?: () => void;
+ onClose?: () => void;
+ onSplit?: () => void;
+ canSplit?: boolean;
+ canClose?: boolean;
+ disableActivate?: boolean;
+ disableSplit?: boolean;
+ disableClose?: boolean;
+}
+
+export function Tab({
+ tabType,
+ title,
+ isActive,
+ onActivate,
+ onClose,
+ onSplit,
+ canSplit = false,
+ canClose = false,
+ disableActivate = false,
+ disableSplit = false,
+ disableClose = false
+ }: TabProps): React.ReactElement {
+ if (tabType === "home") {
+ return (
+
+
+
+ );
+ }
+
+ if (tabType === "terminal" || tabType === "server" || tabType === "file_manager") {
+ const isServer = tabType === 'server';
+ const isFileManager = tabType === 'file_manager';
+ return (
+
+
+ {isServer ? : isFileManager ?
+ : }
+ {title || (isServer ? 'Server' : isFileManager ? 'file_manager' : 'Terminal')}
+
+ {canSplit && (
+
+
+
+ )}
+ {canClose && (
+
+
+
+ )}
+
+ );
+ }
+
+ if (tabType === "ssh_manager") {
+ return (
+
+
+ {title || "SSH Manager"}
+
+
+
+
+
+ );
+ }
+
+ if (tabType === "admin") {
+ return (
+
+
+ {title || "Admin"}
+
+
+
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/src/ui/Navigation/Tabs/TabContext.tsx b/src/ui/Navigation/Tabs/TabContext.tsx
new file mode 100644
index 00000000..5d8104bc
--- /dev/null
+++ b/src/ui/Navigation/Tabs/TabContext.tsx
@@ -0,0 +1,133 @@
+import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
+
+export interface Tab {
+ id: number;
+ type: 'home' | 'terminal' | 'ssh_manager' | 'server' | 'admin' | 'file_manager';
+ title: string;
+ hostConfig?: any;
+ terminalRef?: React.RefObject
;
+}
+
+interface TabContextType {
+ tabs: Tab[];
+ currentTab: number | null;
+ allSplitScreenTab: number[];
+ addTab: (tab: Omit) => number;
+ removeTab: (tabId: number) => void;
+ setCurrentTab: (tabId: number) => void;
+ setSplitScreenTab: (tabId: number) => void;
+ getTab: (tabId: number) => Tab | undefined;
+}
+
+const TabContext = createContext(undefined);
+
+export function useTabs() {
+ const context = useContext(TabContext);
+ if (context === undefined) {
+ throw new Error('useTabs must be used within a TabProvider');
+ }
+ return context;
+}
+
+interface TabProviderProps {
+ children: ReactNode;
+}
+
+export function TabProvider({children}: TabProviderProps) {
+ const [tabs, setTabs] = useState([
+ {id: 1, type: 'home', title: 'Home'}
+ ]);
+ const [currentTab, setCurrentTab] = useState(1);
+ const [allSplitScreenTab, setAllSplitScreenTab] = useState([]);
+ const nextTabId = useRef(2);
+
+ function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string {
+ const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'file_manager' ? 'File Manager' : 'Terminal');
+ const baseTitle = (desiredTitle || defaultTitle).trim();
+ const match = baseTitle.match(/^(.*) \((\d+)\)$/);
+ const root = match ? match[1] : baseTitle;
+
+ const usedNumbers = new Set();
+ let rootUsed = false;
+ tabs.forEach(t => {
+ if (!t.title) return;
+ if (t.title === root) {
+ rootUsed = true;
+ return;
+ }
+ const m = t.title.match(new RegExp(`^${root.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")} \\((\\d+)\\)$`));
+ if (m) {
+ const n = parseInt(m[1], 10);
+ if (!isNaN(n)) usedNumbers.add(n);
+ }
+ });
+
+ if (!rootUsed) return root;
+ let n = 2;
+ while (usedNumbers.has(n)) n += 1;
+ return `${root} (${n})`;
+ }
+
+ const addTab = (tabData: Omit): number => {
+ const id = nextTabId.current++;
+ const needsUniqueTitle = tabData.type === 'terminal' || tabData.type === 'server' || tabData.type === 'file_manager';
+ const effectiveTitle = needsUniqueTitle ? computeUniqueTitle(tabData.type, tabData.title) : (tabData.title || '');
+ const newTab: Tab = {
+ ...tabData,
+ id,
+ title: effectiveTitle,
+ terminalRef: tabData.type === 'terminal' ? React.createRef() : undefined
+ };
+ setTabs(prev => [...prev, newTab]);
+ setCurrentTab(id);
+ setAllSplitScreenTab(prev => prev.filter(tid => tid !== id));
+ return id;
+ };
+
+ const removeTab = (tabId: number) => {
+ const tab = tabs.find(t => t.id === tabId);
+ if (tab && tab.terminalRef?.current && typeof tab.terminalRef.current.disconnect === "function") {
+ tab.terminalRef.current.disconnect();
+ }
+
+ setTabs(prev => prev.filter(tab => tab.id !== tabId));
+ setAllSplitScreenTab(prev => prev.filter(id => id !== tabId));
+
+ if (currentTab === tabId) {
+ const remainingTabs = tabs.filter(tab => tab.id !== tabId);
+ setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1);
+ }
+ };
+
+ const setSplitScreenTab = (tabId: number) => {
+ setAllSplitScreenTab(prev => {
+ if (prev.includes(tabId)) {
+ return prev.filter(id => id !== tabId);
+ } else if (prev.length < 3) {
+ return [...prev, tabId];
+ }
+ return prev;
+ });
+ };
+
+ const getTab = (tabId: number) => {
+ return tabs.find(tab => tab.id === tabId);
+ };
+
+ const value: TabContextType = {
+ tabs,
+ currentTab,
+ allSplitScreenTab,
+ addTab,
+ removeTab,
+ setCurrentTab,
+ setSplitScreenTab,
+ getTab,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/ui/Navigation/TopNavbar.tsx b/src/ui/Navigation/TopNavbar.tsx
new file mode 100644
index 00000000..d89f6cc2
--- /dev/null
+++ b/src/ui/Navigation/TopNavbar.tsx
@@ -0,0 +1,456 @@
+import React, {useState} from "react";
+import {useSidebar} from "@/components/ui/sidebar";
+import {Button} from "@/components/ui/button.tsx";
+import {ChevronDown, ChevronUpIcon, Hammer} from "lucide-react";
+import {Tab} from "@/ui/Navigation/Tabs/Tab.tsx";
+import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion.tsx";
+import {Input} from "@/components/ui/input.tsx";
+import {Checkbox} from "@/components/ui/checkbox.tsx";
+import {Separator} from "@/components/ui/separator.tsx";
+
+interface TopNavbarProps {
+ isTopbarOpen: boolean;
+ setIsTopbarOpen: (open: boolean) => void;
+}
+
+export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): React.ReactElement {
+ const {state} = useSidebar();
+ const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any;
+ const leftPosition = state === "collapsed" ? "26px" : "264px";
+
+ const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
+ const [isRecording, setIsRecording] = useState(false);
+ const [selectedTabIds, setSelectedTabIds] = useState([]);
+
+ const handleTabActivate = (tabId: number) => {
+ setCurrentTab(tabId);
+ };
+
+ const handleTabSplit = (tabId: number) => {
+ setSplitScreenTab(tabId);
+ };
+
+ const handleTabClose = (tabId: number) => {
+ removeTab(tabId);
+ };
+
+ const handleTabToggle = (tabId: number) => {
+ setSelectedTabIds(prev => prev.includes(tabId) ? prev.filter(id => id !== tabId) : [...prev, tabId]);
+ };
+
+ const handleStartRecording = () => {
+ setIsRecording(true);
+ setTimeout(() => {
+ const input = document.getElementById('ssh-tools-input') as HTMLInputElement;
+ if (input) input.focus();
+ }, 100);
+ };
+
+ const handleStopRecording = () => {
+ setIsRecording(false);
+ setSelectedTabIds([]);
+ };
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (selectedTabIds.length === 0) return;
+
+ const value = e.currentTarget.value;
+ let commandToSend = '';
+
+ if (e.ctrlKey || e.metaKey) {
+ if (e.key === 'c') {
+ commandToSend = '\x03'; // Ctrl+C (SIGINT)
+ e.preventDefault();
+ } else if (e.key === 'd') {
+ commandToSend = '\x04'; // Ctrl+D (EOF)
+ e.preventDefault();
+ } else if (e.key === 'l') {
+ commandToSend = '\x0c'; // Ctrl+L (clear screen)
+ e.preventDefault();
+ } else if (e.key === 'u') {
+ commandToSend = '\x15'; // Ctrl+U (clear line)
+ e.preventDefault();
+ } else if (e.key === 'k') {
+ commandToSend = '\x0b'; // Ctrl+K (clear from cursor to end)
+ e.preventDefault();
+ } else if (e.key === 'a') {
+ commandToSend = '\x01'; // Ctrl+A (move to beginning of line)
+ e.preventDefault();
+ } else if (e.key === 'e') {
+ commandToSend = '\x05'; // Ctrl+E (move to end of line)
+ e.preventDefault();
+ } else if (e.key === 'w') {
+ commandToSend = '\x17'; // Ctrl+W (delete word before cursor)
+ e.preventDefault();
+ }
+ } else if (e.key === 'Enter') {
+ commandToSend = '\n';
+ e.preventDefault();
+ } else if (e.key === 'Backspace') {
+ commandToSend = '\x08'; // Backspace
+ e.preventDefault();
+ } else if (e.key === 'Delete') {
+ commandToSend = '\x7f'; // Delete
+ e.preventDefault();
+ } else if (e.key === 'Tab') {
+ commandToSend = '\x09'; // Tab
+ e.preventDefault();
+ } else if (e.key === 'Escape') {
+ commandToSend = '\x1b'; // Escape
+ e.preventDefault();
+ } else if (e.key === 'ArrowUp') {
+ commandToSend = '\x1b[A'; // Up arrow
+ e.preventDefault();
+ } else if (e.key === 'ArrowDown') {
+ commandToSend = '\x1b[B'; // Down arrow
+ e.preventDefault();
+ } else if (e.key === 'ArrowLeft') {
+ commandToSend = '\x1b[D'; // Left arrow
+ e.preventDefault();
+ } else if (e.key === 'ArrowRight') {
+ commandToSend = '\x1b[C'; // Right arrow
+ e.preventDefault();
+ } else if (e.key === 'Home') {
+ commandToSend = '\x1b[H'; // Home
+ e.preventDefault();
+ } else if (e.key === 'End') {
+ commandToSend = '\x1b[F'; // End
+ e.preventDefault();
+ } else if (e.key === 'PageUp') {
+ commandToSend = '\x1b[5~'; // Page Up
+ e.preventDefault();
+ } else if (e.key === 'PageDown') {
+ commandToSend = '\x1b[6~'; // Page Down
+ e.preventDefault();
+ } else if (e.key === 'Insert') {
+ commandToSend = '\x1b[2~'; // Insert
+ e.preventDefault();
+ } else if (e.key === 'F1') {
+ commandToSend = '\x1bOP'; // F1
+ e.preventDefault();
+ } else if (e.key === 'F2') {
+ commandToSend = '\x1bOQ'; // F2
+ e.preventDefault();
+ } else if (e.key === 'F3') {
+ commandToSend = '\x1bOR'; // F3
+ e.preventDefault();
+ } else if (e.key === 'F4') {
+ commandToSend = '\x1bOS'; // F4
+ e.preventDefault();
+ } else if (e.key === 'F5') {
+ commandToSend = '\x1b[15~'; // F5
+ e.preventDefault();
+ } else if (e.key === 'F6') {
+ commandToSend = '\x1b[17~'; // F6
+ e.preventDefault();
+ } else if (e.key === 'F7') {
+ commandToSend = '\x1b[18~'; // F7
+ e.preventDefault();
+ } else if (e.key === 'F8') {
+ commandToSend = '\x1b[19~'; // F8
+ e.preventDefault();
+ } else if (e.key === 'F9') {
+ commandToSend = '\x1b[20~'; // F9
+ e.preventDefault();
+ } else if (e.key === 'F10') {
+ commandToSend = '\x1b[21~'; // F10
+ e.preventDefault();
+ } else if (e.key === 'F11') {
+ commandToSend = '\x1b[23~'; // F11
+ e.preventDefault();
+ } else if (e.key === 'F12') {
+ commandToSend = '\x1b[24~'; // F12
+ e.preventDefault();
+ }
+
+ if (commandToSend) {
+ selectedTabIds.forEach(tabId => {
+ const tab = tabs.find((t: any) => t.id === tabId);
+ if (tab?.terminalRef?.current?.sendInput) {
+ tab.terminalRef.current.sendInput(commandToSend);
+ }
+ });
+ }
+ };
+
+ const handleKeyPress = (e: React.KeyboardEvent) => {
+ if (selectedTabIds.length === 0) return;
+
+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
+ const char = e.key;
+ selectedTabIds.forEach(tabId => {
+ const tab = tabs.find((t: any) => t.id === tabId);
+ if (tab?.terminalRef?.current?.sendInput) {
+ tab.terminalRef.current.sendInput(char);
+ }
+ });
+ }
+ };
+
+ const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
+ const currentTabObj = tabs.find((t: any) => t.id === currentTab);
+ const currentTabIsHome = currentTabObj?.type === 'home';
+ const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
+ const currentTabIsAdmin = currentTabObj?.type === 'admin';
+
+ const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal');
+
+ function getCookie(name: string) {
+ return document.cookie.split('; ').reduce((r, v) => {
+ const parts = v.split('=');
+ return parts[0] === name ? decodeURIComponent(parts[1]) : r;
+ }, "");
+ }
+
+ const updateRightClickCopyPaste = (checked: boolean) => {
+ document.cookie = `rightClickCopyPaste=${checked}; expires=2147483647; path=/`;
+ }
+
+ return (
+
+
+
+ {tabs.map((tab: any) => {
+ const isActive = tab.id === currentTab;
+ const isSplit = Array.isArray(allSplitScreenTab) && allSplitScreenTab.includes(tab.id);
+ const isTerminal = tab.type === 'terminal';
+ const isServer = tab.type === 'server';
+ const isFileManager = tab.type === 'file_manager';
+ const isSshManager = tab.type === 'ssh_manager';
+ const isAdmin = tab.type === 'admin';
+ const isSplittable = isTerminal || isServer || isFileManager;
+ const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
+ const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin;
+ const disableActivate = isSplit || ((tab.type === 'home' || tab.type === 'ssh_manager' || tab.type === 'admin') && isSplitScreenActive);
+ const disableClose = (isSplitScreenActive && isActive) || isSplit;
+ return (
+ handleTabActivate(tab.id)}
+ onClose={isTerminal || isServer || isFileManager || isSshManager || isAdmin ? () => handleTabClose(tab.id) : undefined}
+ onSplit={isSplittable ? () => handleTabSplit(tab.id) : undefined}
+ canSplit={isSplittable}
+ canClose={isTerminal || isServer || isFileManager || isSshManager || isAdmin}
+ disableActivate={disableActivate}
+ disableSplit={disableSplit}
+ disableClose={disableClose}
+ />
+ );
+ })}
+
+
+
+ setToolsSheetOpen(true)}
+ >
+
+
+
+ setIsTopbarOpen(false)}
+ className="w-[30px] h-[30px]"
+ >
+
+
+
+
+
+ {!isTopbarOpen && (
+
setIsTopbarOpen(true)}
+ className="absolute top-0 left-0 w-full h-[10px] bg-[#18181b] cursor-pointer z-20 flex items-center justify-center rounded-bl-md rounded-br-md">
+
+
+ )}
+
+ {toolsSheetOpen && (
+
+
setToolsSheetOpen(false)}
+ style={{cursor: 'pointer'}}
+ />
+
+
e.stopPropagation()}
+ >
+
+
SSH Tools
+ setToolsSheetOpen(false)}
+ className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
+ title="Close SSH Tools"
+ >
+ ×
+
+
+
+
+
+
+ Key Recording
+
+
+
+
+
+ {!isRecording ? (
+
+ Start Key Recording
+
+ ) : (
+
+ Stop Key Recording
+
+ )}
+
+
+ {isRecording && (
+ <>
+
+
Select
+ terminals:
+
+ {terminalTabs.map(tab => (
+ handleTabToggle(tab.id)}
+ >
+ {tab.title}
+
+ ))}
+
+
+
+
+
Type commands (all
+ keys supported):
+
+
+ Commands will be sent to {selectedTabIds.length} selected
+ terminal(s).
+
+
+ >
+ )}
+
+
+
+
+
+
+ Settings
+
+
+
+
+
+ Enable right‑click copy/paste
+
+
+
+
+
+
+ Have ideas for what should come next for ssh tools? Share them on{" "}
+
+ GitHub
+
+ !
+
+
+
+
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/src/ui/apps/File Manager/FIleManagerTopNavbar.tsx b/src/ui/apps/File Manager/FIleManagerTopNavbar.tsx
new file mode 100644
index 00000000..84fb12c6
--- /dev/null
+++ b/src/ui/apps/File Manager/FIleManagerTopNavbar.tsx
@@ -0,0 +1,24 @@
+import React from "react";
+import { FileManagerTabList } from "./FileManagerTabList.tsx";
+
+interface FileManagerTopNavbarProps {
+ tabs: {id: string | number, title: string}[];
+ activeTab: string | number;
+ setActiveTab: (tab: string | number) => void;
+ closeTab: (tab: string | number) => void;
+ onHomeClick: () => void;
+}
+
+export function FIleManagerTopNavbar(props: FileManagerTopNavbarProps): React.ReactElement {
+ const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props;
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/apps/SSH/Config Editor/ConfigEditor.tsx b/src/ui/apps/File Manager/FileManager.tsx
similarity index 55%
rename from src/apps/SSH/Config Editor/ConfigEditor.tsx
rename to src/ui/apps/File Manager/FileManager.tsx
index 7251ba77..ddb397b9 100644
--- a/src/apps/SSH/Config Editor/ConfigEditor.tsx
+++ b/src/ui/apps/File Manager/FileManager.tsx
@@ -1,26 +1,30 @@
import React, {useState, useEffect, useRef} from "react";
-import {ConfigEditorSidebar} from "@/apps/SSH/Config Editor/ConfigEditorSidebar.tsx";
-import {ConfigTabList} from "@/apps/SSH/Config Editor/ConfigTabList.tsx";
-import {ConfigHomeView} from "@/apps/SSH/Config Editor/ConfigHomeView.tsx";
-import {ConfigCodeEditor} from "@/apps/SSH/Config Editor/ConfigCodeEditor.tsx";
+import {FileManagerLeftSidebar} from "@/ui/apps/File Manager/FileManagerLeftSidebar.tsx";
+import {FileManagerTabList} from "@/ui/apps/File Manager/FileManagerTabList.tsx";
+import {FileManagerHomeView} from "@/ui/apps/File Manager/FileManagerHomeView.tsx";
+import {FileManagerFileEditor} from "@/ui/apps/File Manager/FileManagerFileEditor.tsx";
+import {FileManagerOperations} from "@/ui/apps/File Manager/FileManagerOperations.tsx";
import {Button} from '@/components/ui/button.tsx';
-import {ConfigTopbar} from "@/apps/SSH/Config Editor/ConfigTopbar.tsx";
+import {FIleManagerTopNavbar} from "@/ui/apps/File Manager/FIleManagerTopNavbar.tsx";
import {cn} from '@/lib/utils.ts';
+import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
+import {Separator} from '@/components/ui/separator.tsx';
+import {toast} from 'sonner';
import {
- getConfigEditorRecent,
- getConfigEditorPinned,
- getConfigEditorShortcuts,
- addConfigEditorRecent,
- removeConfigEditorRecent,
- addConfigEditorPinned,
- removeConfigEditorPinned,
- addConfigEditorShortcut,
- removeConfigEditorShortcut,
+ getFileManagerRecent,
+ getFileManagerPinned,
+ getFileManagerShortcuts,
+ addFileManagerRecent,
+ removeFileManagerRecent,
+ addFileManagerPinned,
+ removeFileManagerPinned,
+ addFileManagerShortcut,
+ removeFileManagerShortcut,
readSSHFile,
writeSSHFile,
getSSHStatus,
connectSSH
-} from '@/apps/SSH/ssh-axios.ts';
+} from '@/ui/main-axios.ts';
interface Tab {
id: string | number;
@@ -31,8 +35,6 @@ interface Tab {
sshSessionId?: string;
filePath?: string;
loading?: boolean;
- error?: string;
- success?: string;
dirty?: boolean;
}
@@ -52,14 +54,18 @@ interface SSHHost {
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
- enableConfigEditor: boolean;
+ enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
-export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => void }): React.ReactElement {
+export function FileManager({onSelectView, embedded = false, initialHost = null}: {
+ onSelectView?: (view: string) => void,
+ embedded?: boolean,
+ initialHost?: SSHHost | null
+}): React.ReactElement {
const [tabs, setTabs] = useState
([]);
const [activeTab, setActiveTab] = useState('home');
const [recent, setRecent] = useState([]);
@@ -69,8 +75,28 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
const [currentHost, setCurrentHost] = useState(null);
const [isSaving, setIsSaving] = useState(false);
+ const [showOperations, setShowOperations] = useState(false);
+ const [currentPath, setCurrentPath] = useState('/');
+
+ const [deletingItem, setDeletingItem] = useState(null);
+
const sidebarRef = useRef(null);
+ useEffect(() => {
+ if (initialHost && (!currentHost || currentHost.id !== initialHost.id)) {
+ setCurrentHost(initialHost);
+ setTimeout(() => {
+ try {
+ const path = initialHost.defaultPath || '/';
+ if (sidebarRef.current && sidebarRef.current.openFolder) {
+ sidebarRef.current.openFolder(initialHost, path);
+ }
+ } catch (e) {
+ }
+ }, 0);
+ }
+ }, [initialHost]);
+
useEffect(() => {
if (currentHost) {
fetchHomeData();
@@ -102,16 +128,16 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
try {
const homeDataPromise = Promise.all([
- getConfigEditorRecent(currentHost.id),
- getConfigEditorPinned(currentHost.id),
- getConfigEditorShortcuts(currentHost.id),
+ getFileManagerRecent(currentHost.id),
+ getFileManagerPinned(currentHost.id),
+ getFileManagerShortcuts(currentHost.id),
]);
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Fetch home data timed out')), 15000)
);
- const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]);
+ const [recentRes, pinnedRes, shortcutsRes] = await Promise.race([homeDataPromise, timeoutPromise]) as [any, any, any];
const recentWithPinnedStatus = (recentRes || []).map(file => ({
...file,
@@ -181,7 +207,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
loading: false,
error: undefined
} : t));
- await addConfigEditorRecent({
+ await addFileManagerRecent({
name: file.name,
path: file.path,
isSSH: true,
@@ -191,7 +217,8 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
fetchHomeData();
} catch (err: any) {
const errorMessage = formatErrorMessage(err, 'Cannot read file');
- setTabs(tabs => tabs.map(t => t.id === tabId ? {...t, loading: false, error: errorMessage} : t));
+ toast.error(errorMessage);
+ setTabs(tabs => tabs.map(t => t.id === tabId ? {...t, loading: false} : t));
}
}
setActiveTab(tabId);
@@ -199,7 +226,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
const handleRemoveRecent = async (file: any) => {
try {
- await removeConfigEditorRecent({
+ await removeFileManagerRecent({
name: file.name,
path: file.path,
isSSH: true,
@@ -213,7 +240,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
const handlePinFile = async (file: any) => {
try {
- await addConfigEditorPinned({
+ await addFileManagerPinned({
name: file.name,
path: file.path,
isSSH: true,
@@ -230,7 +257,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
const handleUnpinFile = async (file: any) => {
try {
- await removeConfigEditorPinned({
+ await removeFileManagerPinned({
name: file.name,
path: file.path,
isSSH: true,
@@ -270,7 +297,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
const handleAddShortcut = async (folderPath: string) => {
try {
const name = folderPath.split('/').pop() || folderPath;
- await addConfigEditorShortcut({
+ await addFileManagerShortcut({
name,
path: folderPath,
isSSH: true,
@@ -284,7 +311,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
const handleRemoveShortcut = async (shortcut: any) => {
try {
- await removeConfigEditorShortcut({
+ await removeFileManagerShortcut({
name: shortcut.name,
path: shortcut.path,
isSSH: true,
@@ -345,7 +372,7 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
setTimeout(() => reject(new Error('SSH status check timed out')), 10000)
);
- const status = await Promise.race([statusPromise, statusTimeoutPromise]);
+ const status = await Promise.race([statusPromise, statusTimeoutPromise]) as { connected: boolean };
if (!status.connected) {
const connectPromise = connectSSH(tab.sshSessionId, {
@@ -375,18 +402,15 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
const result = await Promise.race([savePromise, timeoutPromise]);
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
...t,
- dirty: false,
- success: 'File saved successfully'
+ loading: false
} : t));
- setTimeout(() => {
- setTabs(tabs => tabs.map(t => t.id === tab.id ? {...t, success: undefined} : t));
- }, 3000);
+ toast.success('File saved successfully');
Promise.allSettled([
(async () => {
try {
- await addConfigEditorRecent({
+ await addFileManagerRecent({
name: tab.fileName,
path: tab.filePath,
isSSH: true,
@@ -412,38 +436,73 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
errorMessage = `Save operation timed out. The file may have been saved successfully, but the operation took too long to complete. Check the Docker logs for confirmation.`;
}
- setTabs(tabs => {
- const updatedTabs = tabs.map(t => t.id === tab.id ? {
- ...t,
- error: `Failed to save file: ${errorMessage}`
- } : t);
- return updatedTabs;
- });
-
- setTimeout(() => {
- setTabs(currentTabs => [...currentTabs]);
- }, 100);
+ toast.error(`Failed to save file: ${errorMessage}`);
+ setTabs(tabs => tabs.map(t => t.id === tab.id ? {
+ ...t,
+ loading: false
+ } : t));
} finally {
setIsSaving(false);
}
};
- const handleHostChange = (host: SSHHost | null) => {
- setCurrentHost(host);
- setTabs([]);
- setActiveTab('home');
+ const handleHostChange = (_host: SSHHost | null) => {
+ };
+
+ const handleOperationComplete = () => {
+ if (sidebarRef.current && sidebarRef.current.fetchFiles) {
+ sidebarRef.current.fetchFiles();
+ }
+ if (currentHost) {
+ fetchHomeData();
+ }
+ };
+
+ const handleSuccess = (message: string) => {
+ toast.success(message);
+ };
+
+ const handleError = (error: string) => {
+ toast.error(error);
+ };
+
+ const updateCurrentPath = (newPath: string) => {
+ setCurrentPath(newPath);
+ };
+
+ const handleDeleteFromSidebar = (item: any) => {
+ setDeletingItem(item);
+ };
+
+ const performDelete = async (item: any) => {
+ if (!currentHost?.id) return;
+
+ try {
+ const {deleteSSHItem} = await import('@/ui/main-axios.ts');
+ await deleteSSHItem(currentHost.id.toString(), item.path, item.type === 'directory');
+ toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} deleted successfully`);
+ setDeletingItem(null);
+ handleOperationComplete();
+ } catch (error: any) {
+ handleError(error?.response?.data?.error || 'Failed to delete item');
+ }
};
if (!currentHost) {
return (
-
-
-
+
+ {
+ })}
onOpenFile={handleOpenFile}
tabs={tabs}
ref={sidebarRef}
- onHostChange={handleHostChange}
+ host={initialHost as SSHHost}
+ onOperationComplete={handleOperationComplete}
+ onError={handleError}
+ onSuccess={handleSuccess}
+ onPathChange={updateCurrentPath}
/>
v
}
return (
-
-
-
+
+ {
+ })}
onOpenFile={handleOpenFile}
tabs={tabs}
ref={sidebarRef}
- onHostChange={handleHostChange}
+ host={currentHost as SSHHost}
+ onOperationComplete={handleOperationComplete}
+ onError={handleError}
+ onSuccess={handleSuccess}
+ onPathChange={updateCurrentPath}
+ onDeleteItem={handleDeleteFromSidebar}
/>
-
-
- {/* Tab list scrollable area */}
-
-
- ({id: t.id, title: t.title}))}
- activeTab={activeTab}
- setActiveTab={setActiveTab}
- closeTab={closeTab}
- onHomeClick={() => {
- setActiveTab('home');
- if (currentHost) {
- fetchHomeData();
- }
- }}
- />
-
+
+
+
+ ({id: t.id, title: t.title}))}
+ activeTab={activeTab}
+ setActiveTab={setActiveTab}
+ closeTab={closeTab}
+ onHomeClick={() => {
+ setActiveTab('home');
+ if (currentHost) {
+ fetchHomeData();
+ }
+ }}
+ />
+
+
+
setShowOperations(!showOperations)}
+ className={cn(
+ 'w-[30px] h-[30px]',
+ showOperations ? 'bg-[#2d2d30] border-[#434345]' : ''
+ )}
+ title="File Operations"
+ >
+
+
+
+
{
+ const tab = tabs.find(t => t.id === activeTab);
+ if (tab && !isSaving) handleSave(tab);
+ }}
+ disabled={activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving}
+ className={cn(
+ 'w-[30px] h-[30px]',
+ activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : ''
+ )}
+ >
+ {isSaving ? : }
+
- {/* Save button - always visible */}
-
t.id === activeTab)?.dirty || isSaving ? 'opacity-60 cursor-not-allowed' : 'hover:border-[#2d2d30]'
- )}
- disabled={activeTab === 'home' || !tabs.find(t => t.id === activeTab)?.dirty || isSaving}
- onClick={() => {
- const tab = tabs.find(t => t.id === activeTab);
- if (tab && !isSaving) handleSave(tab);
- }}
- type="button"
- style={{height: 36, alignSelf: 'center'}}
- >
- {isSaving ? 'Saving...' : 'Save'}
-
v
display: 'flex',
flexDirection: 'column'
}}>
- {activeTab === 'home' ? (
-
- ) : (
- (() => {
- const tab = tabs.find(t => t.id === activeTab);
- if (!tab) return null;
- return (
-
- {/* Error display */}
- {tab.error && (
-
-
-
- ⚠️
- {tab.error}
-
-
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
- ...t,
- error: undefined
- } : t))}
- className="text-red-400 hover:text-red-300 transition-colors"
- >
- ✕
-
+
+
+ {activeTab === 'home' ? (
+
+ ) : (
+ (() => {
+ const tab = tabs.find(t => t.id === activeTab);
+ if (!tab) return null;
+ return (
+
+
+ setTabContent(tab.id, content)}
+ />
- )}
- {/* Success display */}
- {tab.success && (
-
-
-
- ✓
- {tab.success}
-
-
setTabs(tabs => tabs.map(t => t.id === tab.id ? {
- ...t,
- success: undefined
- } : t))}
- className="text-green-400 hover:text-green-300 transition-colors"
- >
- ✕
-
-
-
- )}
-
- setTabContent(tab.id, content)}
- />
-
-
- );
- })()
- )}
+ );
+ })()
+ )}
+
+ {showOperations && (
+
+
+
+ )}
+
+
+ {deletingItem && (
+
+
+
+
+
+
+
+ Confirm Delete
+
+
+ Are you sure you want to delete {deletingItem.name} ?
+ {deletingItem.type === 'directory' && ' This will delete the folder and all its contents.'}
+
+
+ This action cannot be undone.
+
+
+ performDelete(deletingItem)}
+ className="flex-1"
+ >
+ Delete
+
+ setDeletingItem(null)}
+ className="flex-1"
+ >
+ Cancel
+
+
+
+
+
+ )}
);
}
\ No newline at end of file
diff --git a/src/apps/SSH/Config Editor/ConfigCodeEditor.tsx b/src/ui/apps/File Manager/FileManagerFileEditor.tsx
similarity index 98%
rename from src/apps/SSH/Config Editor/ConfigCodeEditor.tsx
rename to src/ui/apps/File Manager/FileManagerFileEditor.tsx
index c216bf16..bf16d2ff 100644
--- a/src/apps/SSH/Config Editor/ConfigCodeEditor.tsx
+++ b/src/ui/apps/File Manager/FileManagerFileEditor.tsx
@@ -5,13 +5,13 @@ import {hyperLink} from '@uiw/codemirror-extensions-hyper-link';
import {oneDark} from '@codemirror/theme-one-dark';
import {EditorView} from '@codemirror/view';
-interface ConfigCodeEditorProps {
+interface FileManagerCodeEditorProps {
content: string;
fileName: string;
onContentChange: (value: string) => void;
}
-export function ConfigCodeEditor({content, fileName, onContentChange}: ConfigCodeEditorProps) {
+export function FileManagerFileEditor({content, fileName, onContentChange}: FileManagerCodeEditorProps) {
function getLanguageName(filename: string): string {
if (!filename || typeof filename !== 'string') {
return 'text';
diff --git a/src/apps/SSH/Config Editor/ConfigHomeView.tsx b/src/ui/apps/File Manager/FileManagerHomeView.tsx
similarity index 83%
rename from src/apps/SSH/Config Editor/ConfigHomeView.tsx
rename to src/ui/apps/File Manager/FileManagerHomeView.tsx
index 74bf3429..ae75804e 100644
--- a/src/apps/SSH/Config Editor/ConfigHomeView.tsx
+++ b/src/ui/apps/File Manager/FileManagerHomeView.tsx
@@ -18,7 +18,7 @@ interface ShortcutItem {
path: string;
}
-interface ConfigHomeViewProps {
+interface FileManagerHomeViewProps {
recent: FileItem[];
pinned: FileItem[];
shortcuts: ShortcutItem[];
@@ -31,25 +31,25 @@ interface ConfigHomeViewProps {
onAddShortcut: (path: string) => void;
}
-export function ConfigHomeView({
- recent,
- pinned,
- shortcuts,
- onOpenFile,
- onRemoveRecent,
- onPinFile,
- onUnpinFile,
- onOpenShortcut,
- onRemoveShortcut,
- onAddShortcut
- }: ConfigHomeViewProps) {
+export function FileManagerHomeView({
+ recent,
+ pinned,
+ shortcuts,
+ onOpenFile,
+ onRemoveRecent,
+ onPinFile,
+ onUnpinFile,
+ onOpenShortcut,
+ onRemoveShortcut,
+ onAddShortcut
+ }: FileManagerHomeViewProps) {
const [tab, setTab] = useState<'recent' | 'pinned' | 'shortcuts'>('recent');
const [newShortcut, setNewShortcut] = useState('');
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => (
+ className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded hover:border-[#434345] transition-colors">
onOpenFile(file)}
@@ -92,7 +92,7 @@ export function ConfigHomeView({
const renderShortcutCard = (shortcut: ShortcutItem) => (
+ className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded hover:border-[#434345] transition-colors">
onOpenShortcut(shortcut)}
@@ -120,7 +120,7 @@ export function ConfigHomeView({
return (
setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
-
+
Recent
Pinned
Folder
@@ -128,7 +128,8 @@ export function ConfigHomeView({
-
+
{recent.length === 0 ? (
No recent files.
@@ -145,7 +146,8 @@ export function ConfigHomeView({
-
+
{pinned.length === 0 ? (
No pinned files.
@@ -162,12 +164,12 @@ export function ConfigHomeView({
-
+
setNewShortcut(e.target.value)}
- className="flex-1 bg-[#23232a] border-[#434345] text-white placeholder:text-muted-foreground"
+ className="flex-1 bg-[#23232a] border-2 border-[#303032] text-white placeholder:text-muted-foreground"
onKeyDown={(e) => {
if (e.key === 'Enter' && newShortcut.trim()) {
onAddShortcut(newShortcut.trim());
@@ -177,8 +179,8 @@ export function ConfigHomeView({
/>
{
if (newShortcut.trim()) {
onAddShortcut(newShortcut.trim());
@@ -190,7 +192,8 @@ export function ConfigHomeView({
Add
-
+
{shortcuts.length === 0 ? (
No shortcuts.
diff --git a/src/ui/apps/File Manager/FileManagerLeftSidebar.tsx b/src/ui/apps/File Manager/FileManagerLeftSidebar.tsx
new file mode 100644
index 00000000..eb754f4a
--- /dev/null
+++ b/src/ui/apps/File Manager/FileManagerLeftSidebar.tsx
@@ -0,0 +1,572 @@
+import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react';
+import {Separator} from '@/components/ui/separator.tsx';
+import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin, MoreVertical, Trash2, Edit3} from 'lucide-react';
+import {ScrollArea} from '@/components/ui/scroll-area.tsx';
+import {cn} from '@/lib/utils.ts';
+import {Input} from '@/components/ui/input.tsx';
+import {Button} from '@/components/ui/button.tsx';
+import {toast} from 'sonner';
+import {
+ listSSHFiles,
+ renameSSHItem,
+ deleteSSHItem,
+ getFileManagerRecent,
+ getFileManagerPinned,
+ addFileManagerPinned,
+ removeFileManagerPinned,
+ readSSHFile,
+ getSSHStatus,
+ connectSSH
+} from '@/ui/main-axios.ts';
+
+interface SSHHost {
+ id: number;
+ name: string;
+ ip: string;
+ port: number;
+ username: string;
+ folder: string;
+ tags: string[];
+ pin: boolean;
+ authType: string;
+ password?: string;
+ key?: string;
+ keyPassword?: string;
+ keyType?: string;
+ enableTerminal: boolean;
+ enableTunnel: boolean;
+ enableFileManager: boolean;
+ defaultPath: string;
+ tunnelConnections: any[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
+ {onSelectView, onOpenFile, tabs, host, onOperationComplete, onError, onSuccess, onPathChange, onDeleteItem}: {
+ onSelectView?: (view: string) => void;
+ onOpenFile: (file: any) => void;
+ tabs: any[];
+ host: SSHHost;
+ onOperationComplete?: () => void;
+ onError?: (error: string) => void;
+ onSuccess?: (message: string) => void;
+ onPathChange?: (path: string) => void;
+ onDeleteItem?: (item: any) => void;
+ },
+ ref
+) {
+ const [currentPath, setCurrentPath] = useState('/');
+ const [files, setFiles] = useState
([]);
+ const pathInputRef = useRef(null);
+
+ const [search, setSearch] = useState('');
+ const [debouncedSearch, setDebouncedSearch] = useState('');
+ const [fileSearch, setFileSearch] = useState('');
+ const [debouncedFileSearch, setDebouncedFileSearch] = useState('');
+ useEffect(() => {
+ const handler = setTimeout(() => setDebouncedSearch(search), 200);
+ return () => clearTimeout(handler);
+ }, [search]);
+ useEffect(() => {
+ const handler = setTimeout(() => setDebouncedSearch(fileSearch), 200);
+ return () => clearTimeout(handler);
+ }, [fileSearch]);
+
+ const [sshSessionId, setSshSessionId] = useState(null);
+ const [filesLoading, setFilesLoading] = useState(false);
+ const [searchQuery, setSearchQuery] = useState('');
+ const [connectingSSH, setConnectingSSH] = useState(false);
+ const [connectionCache, setConnectionCache] = useState>({});
+ const [fetchingFiles, setFetchingFiles] = useState(false);
+
+ const [contextMenu, setContextMenu] = useState<{
+ visible: boolean;
+ x: number;
+ y: number;
+ item: any;
+ }>({
+ visible: false,
+ x: 0,
+ y: 0,
+ item: null
+ });
+
+ const [renamingItem, setRenamingItem] = useState<{
+ item: any;
+ newName: string;
+ } | null>(null);
+
+ useEffect(() => {
+ const nextPath = host?.defaultPath || '/';
+ setCurrentPath(nextPath);
+ onPathChange?.(nextPath);
+ (async () => {
+ await connectToSSH(host);
+ })();
+ }, [host?.id]);
+
+ async function connectToSSH(server: SSHHost): Promise {
+ const sessionId = server.id.toString();
+
+ const cached = connectionCache[sessionId];
+ if (cached && Date.now() - cached.timestamp < 30000) {
+ setSshSessionId(cached.sessionId);
+ return cached.sessionId;
+ }
+
+ if (connectingSSH) {
+ return null;
+ }
+
+ setConnectingSSH(true);
+
+ try {
+ if (!server.password && !server.key) {
+ toast.error('No authentication credentials available for this SSH host');
+ return null;
+ }
+
+ const connectionConfig = {
+ ip: server.ip,
+ port: server.port,
+ username: server.username,
+ password: server.password,
+ sshKey: server.key,
+ keyPassword: server.keyPassword,
+ };
+
+ await connectSSH(sessionId, connectionConfig);
+
+ setSshSessionId(sessionId);
+
+ setConnectionCache(prev => ({
+ ...prev,
+ [sessionId]: {sessionId, timestamp: Date.now()}
+ }));
+
+ return sessionId;
+ } catch (err: any) {
+ toast.error(err?.response?.data?.error || 'Failed to connect to SSH');
+ setSshSessionId(null);
+ return null;
+ } finally {
+ setConnectingSSH(false);
+ }
+ }
+
+ async function fetchFiles() {
+ if (fetchingFiles) {
+ return;
+ }
+
+ setFetchingFiles(true);
+ setFiles([]);
+ setFilesLoading(true);
+
+ try {
+ let pinnedFiles: any[] = [];
+ try {
+ if (host) {
+ pinnedFiles = await getFileManagerPinned(host.id);
+ }
+ } catch (err) {
+ }
+
+ if (host && sshSessionId) {
+ let res: any[] = [];
+
+ try {
+ const status = await getSSHStatus(sshSessionId);
+ if (!status.connected) {
+ const newSessionId = await connectToSSH(host);
+ if (newSessionId) {
+ setSshSessionId(newSessionId);
+ res = await listSSHFiles(newSessionId, currentPath);
+ } else {
+ throw new Error('Failed to reconnect SSH session');
+ }
+ } else {
+ res = await listSSHFiles(sshSessionId, currentPath);
+ }
+ } catch (sessionErr) {
+ const newSessionId = await connectToSSH(host);
+ if (newSessionId) {
+ setSshSessionId(newSessionId);
+ res = await listSSHFiles(newSessionId, currentPath);
+ } else {
+ throw sessionErr;
+ }
+ }
+
+ const processedFiles = (res || []).map((f: any) => {
+ const filePath = currentPath + (currentPath.endsWith('/') ? '' : '/') + f.name;
+ const isPinned = pinnedFiles.some(pinned => pinned.path === filePath);
+ return {
+ ...f,
+ path: filePath,
+ isPinned,
+ isSSH: true,
+ sshSessionId: sshSessionId
+ };
+ });
+
+ setFiles(processedFiles);
+ }
+ } catch (err: any) {
+ setFiles([]);
+ toast.error(err?.response?.data?.error || err?.message || 'Failed to list files');
+ } finally {
+ setFilesLoading(false);
+ setFetchingFiles(false);
+ }
+ }
+
+ useEffect(() => {
+ if (host && sshSessionId && !connectingSSH && !fetchingFiles) {
+ const timeoutId = setTimeout(() => {
+ fetchFiles();
+ }, 100);
+ return () => clearTimeout(timeoutId);
+ }
+ }, [currentPath, host, sshSessionId]);
+
+ useImperativeHandle(ref, () => ({
+ openFolder: async (_server: SSHHost, path: string) => {
+ if (connectingSSH || fetchingFiles) {
+ return;
+ }
+
+ if (currentPath === path) {
+ setTimeout(() => fetchFiles(), 100);
+ return;
+ }
+
+ setFetchingFiles(false);
+ setFilesLoading(false);
+ setFiles([]);
+
+ setCurrentPath(path);
+ onPathChange?.(path);
+ if (!sshSessionId) {
+ const sessionId = await connectToSSH(host);
+ if (sessionId) setSshSessionId(sessionId);
+ }
+ },
+ fetchFiles: () => {
+ if (host && sshSessionId) {
+ fetchFiles();
+ }
+ },
+ getCurrentPath: () => currentPath
+ }));
+
+ useEffect(() => {
+ if (pathInputRef.current) {
+ pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
+ }
+ }, [currentPath]);
+
+ const filteredFiles = files.filter(file => {
+ const q = debouncedFileSearch.trim().toLowerCase();
+ if (!q) return true;
+ return file.name.toLowerCase().includes(q);
+ });
+
+ const handleContextMenu = (e: React.MouseEvent, item: any) => {
+ e.preventDefault();
+
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+
+ const menuWidth = 160;
+ const menuHeight = 80;
+
+ let x = e.clientX;
+ let y = e.clientY;
+
+ if (x + menuWidth > viewportWidth) {
+ x = e.clientX - menuWidth;
+ }
+
+ if (y + menuHeight > viewportHeight) {
+ y = e.clientY - menuHeight;
+ }
+
+ if (x < 0) {
+ x = 0;
+ }
+
+ if (y < 0) {
+ y = 0;
+ }
+
+ setContextMenu({
+ visible: true,
+ x,
+ y,
+ item
+ });
+ };
+
+ const closeContextMenu = () => {
+ setContextMenu({ visible: false, x: 0, y: 0, item: null });
+ };
+
+ const handleRename = async (item: any, newName: string) => {
+ if (!sshSessionId || !newName.trim() || newName === item.name) {
+ setRenamingItem(null);
+ return;
+ }
+
+ try {
+ await renameSSHItem(sshSessionId, item.path, newName.trim());
+ toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} renamed successfully`);
+ setRenamingItem(null);
+ if (onOperationComplete) {
+ onOperationComplete();
+ } else {
+ fetchFiles();
+ }
+ } catch (error: any) {
+ toast.error(error?.response?.data?.error || 'Failed to rename item');
+ }
+ };
+
+ const handleDelete = async (item: any) => {
+ if (!sshSessionId) return;
+
+ try {
+ await deleteSSHItem(sshSessionId, item.path, item.type === 'directory');
+ toast.success(`${item.type === 'directory' ? 'Folder' : 'File'} deleted successfully`);
+ if (onOperationComplete) {
+ onOperationComplete();
+ } else {
+ fetchFiles();
+ }
+ } catch (error: any) {
+ toast.error(error?.response?.data?.error || 'Failed to delete item');
+ }
+ };
+
+ const startRename = (item: any) => {
+ setRenamingItem({ item, newName: item.name });
+ closeContextMenu();
+ };
+
+ const startDelete = (item: any) => {
+ onDeleteItem?.(item);
+ closeContextMenu();
+ };
+
+ useEffect(() => {
+ const handleClickOutside = () => closeContextMenu();
+ document.addEventListener('click', handleClickOutside);
+ return () => document.removeEventListener('click', handleClickOutside);
+ }, []);
+
+ const handlePathChange = (newPath: string) => {
+ setCurrentPath(newPath);
+ onPathChange?.(newPath);
+ };
+
+ return (
+
+
+
+ {host && (
+
+
+
{
+ let path = currentPath;
+ if (path && path !== '/' && path !== '') {
+ if (path.endsWith('/')) path = path.slice(0, -1);
+ const lastSlash = path.lastIndexOf('/');
+ if (lastSlash > 0) {
+ handlePathChange(path.slice(0, lastSlash));
+ } else {
+ handlePathChange('/');
+ }
+ } else {
+ handlePathChange('/');
+ }
+ }}
+ >
+
+
+
handlePathChange(e.target.value)}
+ className="flex-1 bg-[#18181b] border-2 border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]"
+ />
+
+
+ setFileSearch(e.target.value)}
+ />
+
+
+
+
+ {connectingSSH || filesLoading ? (
+
Loading...
+ ) : filteredFiles.length === 0 ? (
+
No files or folders found.
+ ) : (
+
+ {filteredFiles.map((item: any) => {
+ const isOpen = (tabs || []).some((t: any) => t.id === item.path);
+ const isRenaming = renamingItem?.item?.path === item.path;
+ const isDeleting = false;
+
+ return (
+
!isOpen && handleContextMenu(e, item)}
+ >
+ {isRenaming ? (
+
+ {item.type === 'directory' ?
+ :
+ }
+ setRenamingItem(prev => prev ? {...prev, newName: e.target.value} : null)}
+ className="flex-1 h-6 text-sm bg-[#23232a] border border-[#434345] text-white"
+ autoFocus
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ handleRename(item, renamingItem.newName);
+ } else if (e.key === 'Escape') {
+ setRenamingItem(null);
+ }
+ }}
+ onBlur={() => handleRename(item, renamingItem.newName)}
+ />
+
+ ) : (
+ <>
+
!isOpen && (item.type === 'directory' ? handlePathChange(item.path) : onOpenFile({
+ name: item.name,
+ path: item.path,
+ isSSH: item.isSSH,
+ sshSessionId: item.sshSessionId
+ }))}
+ >
+ {item.type === 'directory' ?
+ :
+ }
+ {item.name}
+
+
+ {item.type === 'file' && (
+
{
+ e.stopPropagation();
+ try {
+ if (item.isPinned) {
+ await removeFileManagerPinned({
+ name: item.name,
+ path: item.path,
+ hostId: host?.id,
+ isSSH: true,
+ sshSessionId: host?.id.toString()
+ });
+ setFiles(files.map(f =>
+ f.path === item.path ? { ...f, isPinned: false } : f
+ ));
+ } else {
+ await addFileManagerPinned({
+ name: item.name,
+ path: item.path,
+ hostId: host?.id,
+ isSSH: true,
+ sshSessionId: host?.id.toString()
+ });
+ setFiles(files.map(f =>
+ f.path === item.path ? { ...f, isPinned: true } : f
+ ));
+ }
+ } catch (err) {
+ }
+ }}
+ >
+
+
+ )}
+ {!isOpen && (
+
{
+ e.stopPropagation();
+ handleContextMenu(e, item);
+ }}
+ >
+
+
+ )}
+
+ >
+ )}
+
+ );
+ })}
+
+ )}
+
+
+
+
+ )}
+
+
+
+ {contextMenu.visible && contextMenu.item && (
+
+ startRename(contextMenu.item)}
+ >
+
+ Rename
+
+ startDelete(contextMenu.item)}
+ >
+
+ Delete
+
+
+ )}
+
+ );
+});
+
+export {FileManagerLeftSidebar};
\ No newline at end of file
diff --git a/src/apps/SSH/Config Editor/ConfigFileSidebarViewer.tsx b/src/ui/apps/File Manager/FileManagerLeftSidebarFileViewer.tsx
similarity index 50%
rename from src/apps/SSH/Config Editor/ConfigFileSidebarViewer.tsx
rename to src/ui/apps/File Manager/FileManagerLeftSidebarFileViewer.tsx
index 99d3e7d8..59bac696 100644
--- a/src/apps/SSH/Config Editor/ConfigFileSidebarViewer.tsx
+++ b/src/ui/apps/File Manager/FileManagerLeftSidebarFileViewer.tsx
@@ -20,7 +20,7 @@ interface FileItem {
isStarred?: boolean;
}
-interface ConfigFileSidebarViewerProps {
+interface FileManagerLeftSidebarVileViewerProps {
sshConnections: SSHConnection[];
onAddSSH: () => void;
onConnectSSH: (conn: SSHConnection) => void;
@@ -41,70 +41,28 @@ interface ConfigFileSidebarViewerProps {
currentSSH?: SSHConnection;
}
-export function ConfigFileSidebarViewer({
- sshConnections,
- onAddSSH,
- onConnectSSH,
- onEditSSH,
- onDeleteSSH,
- onPinSSH,
- currentPath,
- files,
- onOpenFile,
- onOpenFolder,
- onStarFile,
- onDeleteFile,
- isLoading,
- error,
- isSSHMode,
- onSwitchToLocal,
- onSwitchToSSH,
- currentSSH,
- }: ConfigFileSidebarViewerProps) {
+export function FileManagerLeftSidebarFileViewer({
+ sshConnections,
+ onAddSSH,
+ onConnectSSH,
+ onEditSSH,
+ onDeleteSSH,
+ onPinSSH,
+ currentPath,
+ files,
+ onOpenFile,
+ onOpenFolder,
+ onStarFile,
+ onDeleteFile,
+ isLoading,
+ error,
+ isSSHMode,
+ onSwitchToLocal,
+ onSwitchToSSH,
+ currentSSH,
+ }: FileManagerLeftSidebarVileViewerProps) {
return (
- {/* SSH Connections */}
-
-
-
SSH Connections
-
-
-
-
-
-
- Local Files
-
- {sshConnections.map((conn) => (
-
-
onSwitchToSSH(conn)}
- >
-
- {conn.name || conn.ip}
- {conn.isPinned && }
-
-
onPinSSH(conn)}>
-
-
-
onEditSSH(conn)}>
-
-
-
onDeleteSSH(conn)}>
-
-
-
- ))}
-
-
- {/* File/Folder Viewer */}
{files.map((item) => (
+ className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border-2 border-[#303032] rounded">
item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}>
{item.type === 'directory' ?
:
diff --git a/src/ui/apps/File Manager/FileManagerOperations.tsx b/src/ui/apps/File Manager/FileManagerOperations.tsx
new file mode 100644
index 00000000..6a974d91
--- /dev/null
+++ b/src/ui/apps/File Manager/FileManagerOperations.tsx
@@ -0,0 +1,624 @@
+import React, {useState, useRef, useEffect} from 'react';
+import {Button} from '@/components/ui/button.tsx';
+import {Input} from '@/components/ui/input.tsx';
+import {Card} from '@/components/ui/card.tsx';
+import {Separator} from '@/components/ui/separator.tsx';
+import {
+ Upload,
+ FilePlus,
+ FolderPlus,
+ Trash2,
+ Edit3,
+ X,
+ Check,
+ AlertCircle,
+ FileText,
+ Folder
+} from 'lucide-react';
+import {cn} from '@/lib/utils.ts';
+
+interface FileManagerOperationsProps {
+ currentPath: string;
+ sshSessionId: string | null;
+ onOperationComplete: () => void;
+ onError: (error: string) => void;
+ onSuccess: (message: string) => void;
+}
+
+export function FileManagerOperations({
+ currentPath,
+ sshSessionId,
+ onOperationComplete,
+ onError,
+ onSuccess
+ }: FileManagerOperationsProps) {
+ const [showUpload, setShowUpload] = useState(false);
+ const [showCreateFile, setShowCreateFile] = useState(false);
+ const [showCreateFolder, setShowCreateFolder] = useState(false);
+ const [showDelete, setShowDelete] = useState(false);
+ const [showRename, setShowRename] = useState(false);
+
+ const [uploadFile, setUploadFile] = useState
(null);
+ const [newFileName, setNewFileName] = useState('');
+ const [newFolderName, setNewFolderName] = useState('');
+ const [deletePath, setDeletePath] = useState('');
+ const [deleteIsDirectory, setDeleteIsDirectory] = useState(false);
+ const [renamePath, setRenamePath] = useState('');
+ const [renameIsDirectory, setRenameIsDirectory] = useState(false);
+ const [newName, setNewName] = useState('');
+
+ const [isLoading, setIsLoading] = useState(false);
+ const [showTextLabels, setShowTextLabels] = useState(true);
+ const fileInputRef = useRef(null);
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ const checkContainerWidth = () => {
+ if (containerRef.current) {
+ const width = containerRef.current.offsetWidth;
+ setShowTextLabels(width > 240);
+ }
+ };
+
+ checkContainerWidth();
+
+ const resizeObserver = new ResizeObserver(checkContainerWidth);
+ if (containerRef.current) {
+ resizeObserver.observe(containerRef.current);
+ }
+
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }, []);
+
+ const handleFileUpload = async () => {
+ if (!uploadFile || !sshSessionId) return;
+
+ setIsLoading(true);
+ try {
+ const content = await uploadFile.text();
+ const {uploadSSHFile} = await import('@/ui/main-axios.ts');
+
+ await uploadSSHFile(sshSessionId, currentPath, uploadFile.name, content);
+ onSuccess(`File "${uploadFile.name}" uploaded successfully`);
+ setShowUpload(false);
+ setUploadFile(null);
+ onOperationComplete();
+ } catch (error: any) {
+ onError(error?.response?.data?.error || 'Failed to upload file');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleCreateFile = async () => {
+ if (!newFileName.trim() || !sshSessionId) return;
+
+ setIsLoading(true);
+ try {
+ const {createSSHFile} = await import('@/ui/main-axios.ts');
+
+ await createSSHFile(sshSessionId, currentPath, newFileName.trim());
+ onSuccess(`File "${newFileName.trim()}" created successfully`);
+ setShowCreateFile(false);
+ setNewFileName('');
+ onOperationComplete();
+ } catch (error: any) {
+ onError(error?.response?.data?.error || 'Failed to create file');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleCreateFolder = async () => {
+ if (!newFolderName.trim() || !sshSessionId) return;
+
+ setIsLoading(true);
+ try {
+ const {createSSHFolder} = await import('@/ui/main-axios.ts');
+
+ await createSSHFolder(sshSessionId, currentPath, newFolderName.trim());
+ onSuccess(`Folder "${newFolderName.trim()}" created successfully`);
+ setShowCreateFolder(false);
+ setNewFolderName('');
+ onOperationComplete();
+ } catch (error: any) {
+ onError(error?.response?.data?.error || 'Failed to create folder');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleDelete = async () => {
+ if (!deletePath || !sshSessionId) return;
+
+ setIsLoading(true);
+ try {
+ const {deleteSSHItem} = await import('@/ui/main-axios.ts');
+
+ await deleteSSHItem(sshSessionId, deletePath, deleteIsDirectory);
+ onSuccess(`${deleteIsDirectory ? 'Folder' : 'File'} deleted successfully`);
+ setShowDelete(false);
+ setDeletePath('');
+ setDeleteIsDirectory(false);
+ onOperationComplete();
+ } catch (error: any) {
+ onError(error?.response?.data?.error || 'Failed to delete item');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleRename = async () => {
+ if (!renamePath || !newName.trim() || !sshSessionId) return;
+
+ setIsLoading(true);
+ try {
+ const {renameSSHItem} = await import('@/ui/main-axios.ts');
+
+ await renameSSHItem(sshSessionId, renamePath, newName.trim());
+ onSuccess(`${renameIsDirectory ? 'Folder' : 'File'} renamed successfully`);
+ setShowRename(false);
+ setRenamePath('');
+ setRenameIsDirectory(false);
+ setNewName('');
+ onOperationComplete();
+ } catch (error: any) {
+ onError(error?.response?.data?.error || 'Failed to rename item');
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const openFileDialog = () => {
+ fileInputRef.current?.click();
+ };
+
+ const handleFileSelect = (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (file) {
+ setUploadFile(file);
+ }
+ };
+
+ const resetStates = () => {
+ setShowUpload(false);
+ setShowCreateFile(false);
+ setShowCreateFolder(false);
+ setShowDelete(false);
+ setShowRename(false);
+ setUploadFile(null);
+ setNewFileName('');
+ setNewFolderName('');
+ setDeletePath('');
+ setDeleteIsDirectory(false);
+ setRenamePath('');
+ setRenameIsDirectory(false);
+ setNewName('');
+ };
+
+ if (!sshSessionId) {
+ return (
+
+
+
Connect to SSH to use file operations
+
+ );
+ }
+
+ return (
+
+
+ setShowUpload(true)}
+ className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
+ title="Upload File"
+ >
+
+ {showTextLabels && Upload File }
+
+ setShowCreateFile(true)}
+ className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
+ title="New File"
+ >
+
+ {showTextLabels && New File }
+
+ setShowCreateFolder(true)}
+ className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
+ title="New Folder"
+ >
+
+ {showTextLabels && New Folder }
+
+ setShowRename(true)}
+ className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
+ title="Rename"
+ >
+
+ {showTextLabels && Rename }
+
+ setShowDelete(true)}
+ className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2"
+ title="Delete Item"
+ >
+
+ {showTextLabels && Delete Item }
+
+
+
+
+
+
+
+ Current Path:
+ {currentPath}
+
+
+
+
+
+
+ {showUpload && (
+
+
+
+
+
+ Upload File
+
+
+ Max: 100MB (JSON) / 200MB (Binary)
+
+
+
setShowUpload(false)}
+ className="h-8 w-8 p-0 flex-shrink-0 ml-2"
+ >
+
+
+
+
+
+
+ {uploadFile ? (
+
+
+
{uploadFile.name}
+
+ {(uploadFile.size / 1024).toFixed(2)} KB
+
+
setUploadFile(null)}
+ className="w-full text-sm h-8"
+ >
+ Remove File
+
+
+ ) : (
+
+
+
Click to select a file
+
+ Choose File
+
+
+ )}
+
+
+
+
+
+
+ {isLoading ? 'Uploading...' : 'Upload File'}
+
+ setShowUpload(false)}
+ disabled={isLoading}
+ className="w-full text-sm h-9"
+ >
+ Cancel
+
+
+
+
+ )}
+
+ {showCreateFile && (
+
+
+
+
+
+ Create New File
+
+
+
setShowCreateFile(false)}
+ className="h-8 w-8 p-0 flex-shrink-0 ml-2"
+ >
+
+
+
+
+
+
+
+ File Name
+
+ setNewFileName(e.target.value)}
+ placeholder="Enter file name (e.g., example.txt)"
+ className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
+ onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()}
+ />
+
+
+
+
+ {isLoading ? 'Creating...' : 'Create File'}
+
+ setShowCreateFile(false)}
+ disabled={isLoading}
+ className="w-full text-sm h-9"
+ >
+ Cancel
+
+
+
+
+ )}
+
+ {showCreateFolder && (
+
+
+
+
+
+ Create New Folder
+
+
+
setShowCreateFolder(false)}
+ className="h-8 w-8 p-0 flex-shrink-0 ml-2"
+ >
+
+
+
+
+
+
+
+ Folder Name
+
+ setNewFolderName(e.target.value)}
+ placeholder="Enter folder name"
+ className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
+ onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()}
+ />
+
+
+
+
+ {isLoading ? 'Creating...' : 'Create Folder'}
+
+ setShowCreateFolder(false)}
+ disabled={isLoading}
+ className="w-full text-sm h-9"
+ >
+ Cancel
+
+
+
+
+ )}
+
+ {showDelete && (
+
+
+
+
+
+ Delete Item
+
+
+
setShowDelete(false)}
+ className="h-8 w-8 p-0 flex-shrink-0 ml-2"
+ >
+
+
+
+
+
+
+
+
+
Warning: This action cannot be undone
+
+
+
+
+
+ Item Path
+
+ setDeletePath(e.target.value)}
+ placeholder="Enter full path to item"
+ className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
+ />
+
+
+
+ setDeleteIsDirectory(e.target.checked)}
+ className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
+ />
+
+ This is a directory (will delete recursively)
+
+
+
+
+
+ {isLoading ? 'Deleting...' : 'Delete Item'}
+
+ setShowDelete(false)}
+ disabled={isLoading}
+ className="w-full text-sm h-9"
+ >
+ Cancel
+
+
+
+
+ )}
+
+ {showRename && (
+
+
+
+
+
+ Rename Item
+
+
+
setShowRename(false)}
+ className="h-8 w-8 p-0 flex-shrink-0 ml-2"
+ >
+
+
+
+
+
+
+
+ Current Path
+
+ setRenamePath(e.target.value)}
+ placeholder="Enter current path to item"
+ className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
+ />
+
+
+
+
+ New Name
+
+ setNewName(e.target.value)}
+ placeholder="Enter new name"
+ className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
+ onKeyDown={(e) => e.key === 'Enter' && handleRename()}
+ />
+
+
+
+ setRenameIsDirectory(e.target.checked)}
+ className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
+ />
+
+ This is a directory
+
+
+
+
+
+ {isLoading ? 'Renaming...' : 'Rename Item'}
+
+ setShowRename(false)}
+ disabled={isLoading}
+ className="w-full text-sm h-9"
+ >
+ Cancel
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/ui/apps/File Manager/FileManagerTabList.tsx b/src/ui/apps/File Manager/FileManagerTabList.tsx
new file mode 100644
index 00000000..e46a7e22
--- /dev/null
+++ b/src/ui/apps/File Manager/FileManagerTabList.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import {Button} from '@/components/ui/button.tsx';
+import {X, Home} from 'lucide-react';
+
+interface FileManagerTab {
+ id: string | number;
+ title: string;
+}
+
+interface FileManagerTabList {
+ tabs: FileManagerTab[];
+ activeTab: string | number;
+ setActiveTab: (tab: string | number) => void;
+ closeTab: (tab: string | number) => void;
+ onHomeClick: () => void;
+}
+
+export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onHomeClick}: FileManagerTabList) {
+ return (
+
+
+
+
+ {tabs.map((tab) => {
+ const isActive = tab.id === activeTab;
+ return (
+
+ setActiveTab(tab.id)}
+ variant="outline"
+ className={`h-8 rounded-r-none !px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
+ >
+ {tab.title}
+
+
+ closeTab(tab.id)}
+ variant="outline"
+ className="h-8 rounded-l-none p-0 !w-9 border-1 border-[#303032]"
+ >
+
+
+
+ );
+ })}
+
+ );
+}
\ No newline at end of file
diff --git a/src/apps/SSH/Manager/SSHManager.tsx b/src/ui/apps/Host Manager/HostManager.tsx
similarity index 61%
rename from src/apps/SSH/Manager/SSHManager.tsx
rename to src/ui/apps/Host Manager/HostManager.tsx
index 90c11750..c372d5c8 100644
--- a/src/apps/SSH/Manager/SSHManager.tsx
+++ b/src/ui/apps/Host Manager/HostManager.tsx
@@ -1,12 +1,13 @@
import React, {useState} from "react";
-import {SSHManagerSidebar} from "@/apps/SSH/Manager/SSHManagerSidebar.tsx";
-import {SSHManagerHostViewer} from "@/apps/SSH/Manager/SSHManagerHostViewer.tsx"
+import {HostManagerHostViewer} from "@/ui/apps/Host Manager/HostManagerHostViewer.tsx"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx";
import {Separator} from "@/components/ui/separator.tsx";
-import {SSHManagerHostEditor} from "@/apps/SSH/Manager/SSHManagerHostEditor.tsx";
+import {HostManagerHostEditor} from "@/ui/apps/Host Manager/HostManagerHostEditor.tsx";
+import {useSidebar} from "@/components/ui/sidebar.tsx";
-interface ConfigEditorProps {
+interface HostManagerProps {
onSelectView: (view: string) => void;
+ isTopbarOpen?: boolean;
}
interface SSHHost {
@@ -25,16 +26,17 @@ interface SSHHost {
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
- enableConfigEditor: boolean;
+ enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
updatedAt: string;
}
-export function SSHManager({onSelectView}: ConfigEditorProps): React.ReactElement {
+export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): React.ReactElement {
const [activeTab, setActiveTab] = useState("host_viewer");
const [editingHost, setEditingHost] = useState(null);
+ const {state: sidebarState} = useSidebar();
const handleEditHost = (host: SSHHost) => {
setEditingHost(host);
@@ -53,33 +55,39 @@ export function SSHManager({onSelectView}: ConfigEditorProps): React.ReactElemen
}
};
+ const topMarginPx = isTopbarOpen ? 74 : 26;
+ const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
+ const bottomMarginPx = 8;
+
return (
-
-
-
-
-
+
+ className="bg-[#18181b] text-white p-4 pt-0 rounded-lg border-2 border-[#303032] flex flex-col min-h-0 overflow-hidden"
+ style={{
+ marginLeft: leftMarginPx,
+ marginRight: 17,
+ marginTop: topMarginPx,
+ marginBottom: bottomMarginPx,
+ height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`
+ }}
+ >
-
+
Host Viewer
{editingHost ? "Edit Host" : "Add Host"}
-
-
+
+
-
+
-
diff --git a/src/apps/SSH/Manager/SSHManagerHostEditor.tsx b/src/ui/apps/Host Manager/HostManagerHostEditor.tsx
similarity index 96%
rename from src/apps/SSH/Manager/SSHManagerHostEditor.tsx
rename to src/ui/apps/Host Manager/HostManagerHostEditor.tsx
index 71c82290..7dc84072 100644
--- a/src/apps/SSH/Manager/SSHManagerHostEditor.tsx
+++ b/src/ui/apps/Host Manager/HostManagerHostEditor.tsx
@@ -19,7 +19,7 @@ import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs.tsx
import React, {useEffect, useRef, useState} from "react";
import {Switch} from "@/components/ui/switch.tsx";
import {Alert, AlertDescription} from "@/components/ui/alert.tsx";
-import {createSSHHost, updateSSHHost, getSSHHosts} from '@/apps/SSH/ssh-axios';
+import {createSSHHost, updateSSHHost, getSSHHosts} from '@/ui/main-axios.ts';
interface SSHHost {
id: number;
@@ -37,7 +37,7 @@ interface SSHHost {
keyType?: string;
enableTerminal: boolean;
enableTunnel: boolean;
- enableConfigEditor: boolean;
+ enableFileManager: boolean;
defaultPath: string;
tunnelConnections: any[];
createdAt: string;
@@ -49,7 +49,7 @@ interface SSHManagerHostEditorProps {
onFormSubmit?: () => void;
}
-export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) {
+export function HostManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHostEditorProps) {
const [hosts, setHosts] = useState
([]);
const [folders, setFolders] = useState([]);
const [sshConfigurations, setSshConfigurations] = useState([]);
@@ -120,7 +120,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
retryInterval: z.coerce.number().min(1).max(3600).default(10),
autoStart: z.boolean().default(false),
})).default([]),
- enableConfigEditor: z.boolean().default(true),
+ enableFileManager: z.boolean().default(true),
defaultPath: z.string().optional(),
}).superRefine((data, ctx) => {
if (data.authType === 'password') {
@@ -178,7 +178,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
keyType: "auto",
enableTerminal: editingHost?.enableTerminal !== false,
enableTunnel: editingHost?.enableTunnel !== false,
- enableConfigEditor: editingHost?.enableConfigEditor !== false,
+ enableFileManager: editingHost?.enableFileManager !== false,
defaultPath: editingHost?.defaultPath || "/",
tunnelConnections: editingHost?.tunnelConnections || [],
}
@@ -205,7 +205,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
keyType: (editingHost.keyType as any) || "auto",
enableTerminal: editingHost.enableTerminal !== false,
enableTunnel: editingHost.enableTunnel !== false,
- enableConfigEditor: editingHost.enableConfigEditor !== false,
+ enableFileManager: editingHost.enableFileManager !== false,
defaultPath: editingHost.defaultPath || "/",
tunnelConnections: editingHost.tunnelConnections || [],
});
@@ -227,7 +227,7 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
keyType: "auto",
enableTerminal: true,
enableTunnel: true,
- enableConfigEditor: true,
+ enableFileManager: true,
defaultPath: "/",
tunnelConnections: [],
});
@@ -251,6 +251,8 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost
if (onFormSubmit) {
onFormSubmit();
}
+
+ window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
} catch (error) {
alert('Failed to save host. Please try again.');
}
@@ -388,15 +390,15 @@ export function SSHManagerHostEditor({editingHost, onFormSubmit}: SSHManagerHost