mirror of
https://github.com/YuzuZensai/Termix.git
synced 2026-11-12 19:26:37 +00:00
* Guacd, Docker-Compose, RDP (#475) * fix select edit host but not update view (#438) * fix: Checksum issue with chocolatey * fix: Remove homebrew old stuff * Add Korean translation (#439) Co-authored-by: 송준우 <2484@coreit.co.kr> * feat: Automate flatpak * fix: Add imagemagik to electron builder to resolve build error * fix: Build error with runtime repo flag * fix: Flatpak runtime error and install freedesktop ver warning * fix: Flatpak runtime error and install freedesktop ver warning * feat: Re-add homebrew cask and move scripts to backend * fix: No sandbox flag issue * fix: Change name for electron macos cask output * fix: Sandbox error with Linux * fix: Remove comming soon for app stores in readme * Adding Comment at the end of the public_key on the host on deploy (#440) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * -Add New Interface for Credential DB -Add Credential Name as a comment into the server authorized_key file --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Sudo auto fill password (#441) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Feature Sudo password auto-fill; * Fix locale json shema; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Added Italian Language; (#445) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Added Italian Language; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Auto collapse snippet folders (#448) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * feat: Add collapsable snippets (customizable in user profile) * Translations (#447) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Added Italian Language; * Fix translations; Removed duplicate keys, synchronised other languages using English as the source, translated added keys, fixed inaccurate translations. --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Remove PTY-level keepalive (#449) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Remove PTY-level keepalive to prevent unwanted terminal output; use SSH-level keepalive instead --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add Guacamole support for RDP, VNC, and Telnet connections - Implemented WebSocket support for Guacamole in Nginx configuration. - Added REST API endpoints for generating connection tokens and checking guacd status. - Created Guacamole server using guacamole-lite for handling connections. - Developed frontend components for testing RDP/VNC connections and displaying the remote session. - Updated package dependencies to include guacamole-common-js and guacamole-lite. - Enhanced logging for Guacamole operations. * feat: enhance Guacamole support with RDP and VNC connection settings and UI updates * feat: Seperate server stats and tunnel management (improved both UI's) then started initial docker implementation * fix: finalize adding docker to db * fix: merge syntax errors * feat: implement mouse coordinate adjustment based on scale factor in GuacamoleDisplay * feat: add TypeScript definitions for guacamole-common-js module * feat: enhance Mouse.State constructor to accept optional parameters and object destructuring * feat: Add support for RDP and VNC connections in SSH host management - Introduced connectionType field to differentiate between SSH, RDP, VNC, and Telnet in host data structures. - Updated backend routes to handle RDP/VNC specific fields: domain, security, and ignoreCert. - Enhanced the HostManagerEditor to include RDP/VNC specific settings and authentication options. - Implemented token retrieval for RDP/VNC connections using Guacamole API. - Updated UI components to reflect connection type changes and provide appropriate connection buttons. - Removed the GuacamoleTestDialog component as its functionality is integrated into the HostManagerEditor. - Adjusted the TopNavbar and Host components to accommodate new connection types and their respective actions. * feat: Enhance Guacamole integration with extended configuration options - Added detailed Guacamole configuration interface for RDP/VNC/Telnet connections, including display, audio, performance, and session settings. - Implemented logging for token requests and received options for better debugging. - Updated HostManagerEditor to support new Guacamole configuration fields with validation and default values. - Integrated Guacamole configuration parsing in HostManagerViewer and Host components. - Enhanced API requests to include extended Guacamole configuration parameters in the token request. - Refactored code to convert camelCase configuration keys to kebab-case for compatibility with Guacamole API. * feat: merge guacd into 2.0.0 and improve UI for host manager and made general bug fixes --------- Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: junu <bigdwarf_@naver.com> Co-authored-by: 송준우 <2484@coreit.co.kr> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: rename api routes and files * feat: improve guacd ui/backend * feat: improve guacd ui/backend * fix: state persistance issues causing refresh * feat: improge guacd connections, fixed telnet not opening, and improved general guacd integration * feat: continue improving integration also with bug fixes * Merge 2.0.0 with 2.0.0 that includes bug fixes (#620) * Guacd, Docker-Compose, RDP (#475) * fix select edit host but not update view (#438) * fix: Checksum issue with chocolatey * fix: Remove homebrew old stuff * Add Korean translation (#439) Co-authored-by: 송준우 <2484@coreit.co.kr> * feat: Automate flatpak * fix: Add imagemagik to electron builder to resolve build error * fix: Build error with runtime repo flag * fix: Flatpak runtime error and install freedesktop ver warning * fix: Flatpak runtime error and install freedesktop ver warning * feat: Re-add homebrew cask and move scripts to backend * fix: No sandbox flag issue * fix: Change name for electron macos cask output * fix: Sandbox error with Linux * fix: Remove comming soon for app stores in readme * Adding Comment at the end of the public_key on the host on deploy (#440) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * -Add New Interface for Credential DB -Add Credential Name as a comment into the server authorized_key file --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Sudo auto fill password (#441) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Feature Sudo password auto-fill; * Fix locale json shema; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Added Italian Language; (#445) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Added Italian Language; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Auto collapse snippet folders (#448) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * feat: Add collapsable snippets (customizable in user profile) * Translations (#447) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Added Italian Language; * Fix translations; Removed duplicate keys, synchronised other languages using English as the source, translated added keys, fixed inaccurate translations. --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Remove PTY-level keepalive (#449) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Remove PTY-level keepalive to prevent unwanted terminal output; use SSH-level keepalive instead --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: add Guacamole support for RDP, VNC, and Telnet connections - Implemented WebSocket support for Guacamole in Nginx configuration. - Added REST API endpoints for generating connection tokens and checking guacd status. - Created Guacamole server using guacamole-lite for handling connections. - Developed frontend components for testing RDP/VNC connections and displaying the remote session. - Updated package dependencies to include guacamole-common-js and guacamole-lite. - Enhanced logging for Guacamole operations. * feat: enhance Guacamole support with RDP and VNC connection settings and UI updates * feat: Seperate server stats and tunnel management (improved both UI's) then started initial docker implementation * fix: finalize adding docker to db * fix: merge syntax errors * feat: implement mouse coordinate adjustment based on scale factor in GuacamoleDisplay * feat: add TypeScript definitions for guacamole-common-js module * feat: enhance Mouse.State constructor to accept optional parameters and object destructuring * feat: Add support for RDP and VNC connections in SSH host management - Introduced connectionType field to differentiate between SSH, RDP, VNC, and Telnet in host data structures. - Updated backend routes to handle RDP/VNC specific fields: domain, security, and ignoreCert. - Enhanced the HostManagerEditor to include RDP/VNC specific settings and authentication options. - Implemented token retrieval for RDP/VNC connections using Guacamole API. - Updated UI components to reflect connection type changes and provide appropriate connection buttons. - Removed the GuacamoleTestDialog component as its functionality is integrated into the HostManagerEditor. - Adjusted the TopNavbar and Host components to accommodate new connection types and their respective actions. * feat: Enhance Guacamole integration with extended configuration options - Added detailed Guacamole configuration interface for RDP/VNC/Telnet connections, including display, audio, performance, and session settings. - Implemented logging for token requests and received options for better debugging. - Updated HostManagerEditor to support new Guacamole configuration fields with validation and default values. - Integrated Guacamole configuration parsing in HostManagerViewer and Host components. - Enhanced API requests to include extended Guacamole configuration parameters in the token request. - Refactored code to convert camelCase configuration keys to kebab-case for compatibility with Guacamole API. * feat: merge guacd into 2.0.0 and improve UI for host manager and made general bug fixes --------- Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: junu <bigdwarf_@naver.com> Co-authored-by: 송준우 <2484@coreit.co.kr> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: rename api routes and files * feat: improve guacd ui/backend * feat: improve guacd ui/backend * fix: state persistance issues causing refresh * feat: improge guacd connections, fixed telnet not opening, and improved general guacd integration * feat: continue improving integration also with bug fixes --------- Co-authored-by: Wesley Reid <starhound@lostsouls.org> Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com> Co-authored-by: junu <bigdwarf_@naver.com> Co-authored-by: 송준우 <2484@coreit.co.kr> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> * feat: allow customizing guacd backened url * fix: ssh route mistmatching and guacamole url not changing * chore: increment ver * feat: change default to work with default compose, added splits creen support, updated readmes * fix: linux app not starting due to better sqlite isuses, improved copy/paste system so no context menu, added oidc remember me toggle, improved OS detection for sessions, flatpak invalid key, and sharing hosts with other users errors * fix: global settings not setting * chore: update compose * feat: improve the global status input * chore: cleanup files * chore: update export/improt with new host fields * fix: file manager and docker not loading properly --------- Co-authored-by: Wesley Reid <starhound@lostsouls.org> Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com> Co-authored-by: junu <bigdwarf_@naver.com> Co-authored-by: 송준우 <2484@coreit.co.kr> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
642 lines
21 KiB
TypeScript
642 lines
21 KiB
TypeScript
import React, { useState } from "react";
|
|
import {
|
|
ChevronUp,
|
|
User2,
|
|
HardDrive,
|
|
Menu,
|
|
ChevronRight,
|
|
RotateCcw,
|
|
} from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { isElectron, logoutUser } from "@/ui/main-axios.ts";
|
|
|
|
import {
|
|
Sidebar,
|
|
SidebarContent,
|
|
SidebarFooter,
|
|
SidebarGroup,
|
|
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 { Input } from "@/components/ui/input.tsx";
|
|
import { Button } from "@/components/ui/button.tsx";
|
|
import { FolderCard } from "@/ui/desktop/navigation/hosts/FolderCard.tsx";
|
|
import { getSSHHosts, getSSHFolders } from "@/ui/main-axios.ts";
|
|
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
|
import type { SSHFolder, SSHHost } from "@/types/index.ts";
|
|
|
|
interface SidebarProps {
|
|
disabled?: boolean;
|
|
isAdmin?: boolean;
|
|
username?: string | null;
|
|
children?: React.ReactNode;
|
|
onLogout?: () => void;
|
|
}
|
|
|
|
async function handleLogout() {
|
|
try {
|
|
await logoutUser();
|
|
|
|
if (isElectron()) {
|
|
localStorage.removeItem("jwt");
|
|
}
|
|
|
|
window.location.reload();
|
|
} catch (error) {
|
|
console.error("Logout failed:", error);
|
|
window.location.reload();
|
|
}
|
|
}
|
|
|
|
export function LeftSidebar({
|
|
disabled,
|
|
isAdmin,
|
|
username,
|
|
children,
|
|
onLogout,
|
|
}: SidebarProps): React.ReactElement {
|
|
const { t } = useTranslation();
|
|
|
|
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(() => {
|
|
const saved = localStorage.getItem("leftSidebarOpen");
|
|
return saved !== null ? JSON.parse(saved) : true;
|
|
});
|
|
|
|
const {
|
|
tabs: tabList,
|
|
addTab,
|
|
setCurrentTab,
|
|
allSplitScreenTab,
|
|
updateHostConfig,
|
|
} = useTabs() as {
|
|
tabs: Array<{ id: number; type: string; [key: string]: unknown }>;
|
|
addTab: (tab: { type: string; [key: string]: unknown }) => number;
|
|
setCurrentTab: (id: number) => void;
|
|
allSplitScreenTab: number[];
|
|
updateHostConfig: (id: number, config: unknown) => void;
|
|
};
|
|
const isSplitScreenActive =
|
|
Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
|
|
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
|
const openSshManagerTab = () => {
|
|
if (isSplitScreenActive) return;
|
|
if (sshManagerTab) {
|
|
setCurrentTab(sshManagerTab.id);
|
|
return;
|
|
}
|
|
const id = addTab({ type: "ssh_manager", title: t("nav.hostManager") });
|
|
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" });
|
|
setCurrentTab(id);
|
|
};
|
|
const userProfileTab = tabList.find((t) => t.type === "user_profile");
|
|
const openUserProfileTab = () => {
|
|
if (isSplitScreenActive) return;
|
|
if (userProfileTab) {
|
|
setCurrentTab(userProfileTab.id);
|
|
return;
|
|
}
|
|
const id = addTab({ type: "user_profile" });
|
|
setCurrentTab(id);
|
|
};
|
|
|
|
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
|
const [hostsLoading] = useState(false);
|
|
const [hostsError, setHostsError] = useState<string | null>(null);
|
|
const prevHostsRef = React.useRef<SSHHost[]>([]);
|
|
const [search, setSearch] = useState("");
|
|
const [debouncedSearch, setDebouncedSearch] = useState("");
|
|
const [folderMetadata, setFolderMetadata] = useState<Map<string, SSHFolder>>(
|
|
new Map(),
|
|
);
|
|
|
|
const fetchFolderMetadata = React.useCallback(async () => {
|
|
try {
|
|
const folders = await getSSHFolders();
|
|
const metadataMap = new Map<string, SSHFolder>();
|
|
folders.forEach((folder) => {
|
|
metadataMap.set(folder.name, folder);
|
|
});
|
|
setFolderMetadata(metadataMap);
|
|
} catch (error) {
|
|
console.error("Failed to fetch folder metadata:", error);
|
|
}
|
|
}, []);
|
|
|
|
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 ||
|
|
newHost.enableTunnel !== existingHost.enableTunnel ||
|
|
newHost.enableFileManager !== existingHost.enableFileManager ||
|
|
newHost.authType !== existingHost.authType ||
|
|
newHost.password !== existingHost.password ||
|
|
newHost.key !== existingHost.key ||
|
|
newHost.keyPassword !== existingHost.keyPassword ||
|
|
newHost.keyType !== existingHost.keyType ||
|
|
newHost.defaultPath !== existingHost.defaultPath ||
|
|
JSON.stringify(newHost.tags) !==
|
|
JSON.stringify(existingHost.tags) ||
|
|
JSON.stringify(newHost.tunnelConnections) !==
|
|
JSON.stringify(existingHost.tunnelConnections)
|
|
) {
|
|
hasChanges = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (hasChanges) {
|
|
setTimeout(() => {
|
|
setHosts(newHosts);
|
|
prevHostsRef.current = newHosts;
|
|
}, 50);
|
|
}
|
|
} catch {
|
|
setHostsError(t("leftSidebar.failedToLoadHosts"));
|
|
}
|
|
}, [t]);
|
|
|
|
const fetchHostsRef = React.useRef(fetchHosts);
|
|
const fetchFolderMetadataRef = React.useRef(fetchFolderMetadata);
|
|
|
|
React.useEffect(() => {
|
|
fetchHostsRef.current = fetchHosts;
|
|
fetchFolderMetadataRef.current = fetchFolderMetadata;
|
|
});
|
|
|
|
React.useEffect(() => {
|
|
fetchHostsRef.current();
|
|
fetchFolderMetadataRef.current();
|
|
const interval = setInterval(() => {
|
|
fetchHostsRef.current();
|
|
fetchFolderMetadataRef.current();
|
|
}, 300000);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
const handleHostsChanged = () => {
|
|
fetchHostsRef.current();
|
|
fetchFolderMetadataRef.current();
|
|
};
|
|
const handleCredentialsChanged = () => {
|
|
fetchHostsRef.current();
|
|
};
|
|
const handleFoldersChanged = () => {
|
|
fetchFolderMetadataRef.current();
|
|
};
|
|
window.addEventListener(
|
|
"ssh-hosts:changed",
|
|
handleHostsChanged as EventListener,
|
|
);
|
|
window.addEventListener(
|
|
"credentials:changed",
|
|
handleCredentialsChanged as EventListener,
|
|
);
|
|
window.addEventListener(
|
|
"folders:changed",
|
|
handleFoldersChanged as EventListener,
|
|
);
|
|
return () => {
|
|
window.removeEventListener(
|
|
"ssh-hosts:changed",
|
|
handleHostsChanged as EventListener,
|
|
);
|
|
window.removeEventListener(
|
|
"credentials:changed",
|
|
handleCredentialsChanged as EventListener,
|
|
);
|
|
window.removeEventListener(
|
|
"folders:changed",
|
|
handleFoldersChanged as EventListener,
|
|
);
|
|
};
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
|
return () => clearTimeout(handler);
|
|
}, [search]);
|
|
|
|
React.useEffect(() => {
|
|
localStorage.setItem("leftSidebarOpen", JSON.stringify(isSidebarOpen));
|
|
}, [isSidebarOpen]);
|
|
|
|
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
|
|
const saved = localStorage.getItem("leftSidebarWidth");
|
|
const defaultWidth = 250;
|
|
const savedWidth = saved !== null ? parseInt(saved, 10) : defaultWidth;
|
|
const minWidth = Math.min(200, Math.floor(window.innerWidth * 0.15));
|
|
const maxWidth = Math.floor(window.innerWidth * 0.3);
|
|
return Math.min(savedWidth, Math.max(minWidth, maxWidth));
|
|
});
|
|
|
|
const [isResizing, setIsResizing] = useState(false);
|
|
const startXRef = React.useRef<number | null>(null);
|
|
const startWidthRef = React.useRef<number>(sidebarWidth);
|
|
|
|
React.useEffect(() => {
|
|
localStorage.setItem("leftSidebarWidth", String(sidebarWidth));
|
|
}, [sidebarWidth]);
|
|
|
|
React.useEffect(() => {
|
|
const handleResize = () => {
|
|
const minWidth = Math.min(200, Math.floor(window.innerWidth * 0.15));
|
|
const maxWidth = Math.floor(window.innerWidth * 0.3);
|
|
if (sidebarWidth > maxWidth) {
|
|
setSidebarWidth(Math.max(minWidth, maxWidth));
|
|
} else if (sidebarWidth < minWidth) {
|
|
setSidebarWidth(minWidth);
|
|
}
|
|
};
|
|
window.addEventListener("resize", handleResize);
|
|
return () => window.removeEventListener("resize", handleResize);
|
|
}, [sidebarWidth]);
|
|
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setIsResizing(true);
|
|
startXRef.current = e.clientX;
|
|
startWidthRef.current = sidebarWidth;
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
if (!isResizing) return;
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
if (startXRef.current == null) return;
|
|
const dx = e.clientX - startXRef.current;
|
|
const newWidth = Math.round(startWidthRef.current + dx);
|
|
const minWidth = Math.min(200, Math.floor(window.innerWidth * 0.15));
|
|
const maxWidth = Math.round(window.innerWidth * 0.3);
|
|
if (newWidth >= minWidth && newWidth <= maxWidth) {
|
|
setSidebarWidth(newWidth);
|
|
} else if (newWidth < minWidth) {
|
|
setSidebarWidth(minWidth);
|
|
} else if (newWidth > maxWidth) {
|
|
setSidebarWidth(maxWidth);
|
|
}
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setIsResizing(false);
|
|
startXRef.current = null;
|
|
};
|
|
|
|
document.addEventListener("mousemove", handleMouseMove);
|
|
document.addEventListener("mouseup", handleMouseUp);
|
|
document.body.style.cursor = "col-resize";
|
|
document.body.style.userSelect = "none";
|
|
|
|
return () => {
|
|
document.removeEventListener("mousemove", handleMouseMove);
|
|
document.removeEventListener("mouseup", handleMouseUp);
|
|
document.body.style.cursor = "";
|
|
document.body.style.userSelect = "";
|
|
};
|
|
}, [isResizing]);
|
|
|
|
const filteredHosts = React.useMemo(() => {
|
|
if (!debouncedSearch.trim()) return hosts;
|
|
const searchQuery = debouncedSearch.trim().toLowerCase();
|
|
|
|
return hosts.filter((h) => {
|
|
const fieldMatches: Record<string, string> = {};
|
|
let remainingQuery = searchQuery;
|
|
|
|
const fieldPattern = /(\w+):([^\s]+)/g;
|
|
let match;
|
|
while ((match = fieldPattern.exec(searchQuery)) !== null) {
|
|
const [fullMatch, field, value] = match;
|
|
fieldMatches[field] = value;
|
|
remainingQuery = remainingQuery.replace(fullMatch, "").trim();
|
|
}
|
|
|
|
for (const [field, value] of Object.entries(fieldMatches)) {
|
|
switch (field) {
|
|
case "tag":
|
|
case "tags": {
|
|
const tags = Array.isArray(h.tags) ? h.tags : [];
|
|
const hasMatchingTag = tags.some((tag) =>
|
|
tag.toLowerCase().includes(value),
|
|
);
|
|
if (!hasMatchingTag) return false;
|
|
break;
|
|
}
|
|
case "name":
|
|
if (!(h.name || "").toLowerCase().includes(value)) return false;
|
|
break;
|
|
case "user":
|
|
case "username":
|
|
if (!h.username.toLowerCase().includes(value)) return false;
|
|
break;
|
|
case "ip":
|
|
case "host":
|
|
if (!h.ip.toLowerCase().includes(value)) return false;
|
|
break;
|
|
case "port":
|
|
if (!String(h.port).includes(value)) return false;
|
|
break;
|
|
case "folder":
|
|
if (!(h.folder || "").toLowerCase().includes(value)) return false;
|
|
break;
|
|
case "auth":
|
|
case "authtype":
|
|
if (!h.authType.toLowerCase().includes(value)) return false;
|
|
break;
|
|
case "path":
|
|
if (!(h.defaultPath || "").toLowerCase().includes(value))
|
|
return false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (remainingQuery) {
|
|
const searchableText = [
|
|
h.name || "",
|
|
h.username,
|
|
h.ip,
|
|
h.folder || "",
|
|
...(h.tags || []),
|
|
h.authType,
|
|
h.defaultPath || "",
|
|
]
|
|
.join(" ")
|
|
.toLowerCase();
|
|
if (!searchableText.includes(remainingQuery)) return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}, [hosts, debouncedSearch]);
|
|
|
|
const hostsByFolder = React.useMemo(() => {
|
|
const map: Record<string, SSHHost[]> = {};
|
|
filteredHosts.forEach((h) => {
|
|
const folder =
|
|
h.folder && h.folder.trim() ? h.folder : t("leftSidebar.noFolder");
|
|
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 === t("leftSidebar.noFolder")) return -1;
|
|
if (b === t("leftSidebar.noFolder")) 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];
|
|
}, []);
|
|
|
|
return (
|
|
<div className="h-screen w-screen overflow-hidden">
|
|
<SidebarProvider
|
|
open={isSidebarOpen}
|
|
style={
|
|
{ "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties
|
|
}
|
|
>
|
|
<div className="flex h-screen w-screen overflow-hidden">
|
|
<Sidebar variant="floating">
|
|
<SidebarHeader>
|
|
<SidebarGroupLabel className="text-lg font-bold text-foreground">
|
|
{t("common.appName")}
|
|
<div className="absolute right-5 flex gap-1">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setSidebarWidth(250)}
|
|
className="w-[28px] h-[28px]"
|
|
title={t("common.resetSidebarWidth")}
|
|
>
|
|
<RotateCcw className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
|
className="w-[28px] h-[28px]"
|
|
title={t("common.toggleSidebar")}
|
|
>
|
|
<Menu className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</SidebarGroupLabel>
|
|
</SidebarHeader>
|
|
<Separator className="p-0.25" />
|
|
<SidebarContent>
|
|
<SidebarGroup className="!m-0 !p-0 !-mb-2">
|
|
<Button
|
|
className="m-2 flex flex-row font-semibold border-2 !border-edge"
|
|
variant="outline"
|
|
onClick={openSshManagerTab}
|
|
disabled={isSplitScreenActive}
|
|
title={
|
|
isSplitScreenActive
|
|
? t("interface.disabledDuringSplitScreen")
|
|
: undefined
|
|
}
|
|
>
|
|
<HardDrive strokeWidth="2.5" />
|
|
{t("nav.hostManager")}
|
|
</Button>
|
|
</SidebarGroup>
|
|
<Separator className="p-0.25" />
|
|
<SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
|
|
<div className="!bg-field rounded-lg">
|
|
<Input
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder={t("placeholders.searchHostsAny")}
|
|
className="w-full h-8 text-sm border-2 !bg-field border-edge rounded-md"
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
|
|
{hostsError && (
|
|
<div className="!bg-field rounded-lg">
|
|
<div className="w-full h-8 text-sm border-2 !bg-field border-edge rounded-md px-3 py-1.5 flex items-center text-red-500">
|
|
{t("leftSidebar.failedToLoadHosts")}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{hostsLoading && (
|
|
<div className="px-4 pb-2">
|
|
<div className="text-xs text-muted-foreground text-center">
|
|
{t("hosts.loadingHosts")}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{sortedFolders.map((folder, idx) => {
|
|
const metadata = folderMetadata.get(folder);
|
|
return (
|
|
<FolderCard
|
|
key={`folder-${folder}`}
|
|
folderName={folder}
|
|
hosts={getSortedHosts(hostsByFolder[folder])}
|
|
isFirst={idx === 0}
|
|
isLast={idx === sortedFolders.length - 1}
|
|
folderColor={metadata?.color}
|
|
folderIcon={metadata?.icon}
|
|
/>
|
|
);
|
|
})}
|
|
</SidebarGroup>
|
|
</SidebarContent>
|
|
<Separator className="p-0.25 mt-1 mb-1" />
|
|
<SidebarFooter>
|
|
<SidebarMenu>
|
|
<SidebarMenuItem>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<SidebarMenuButton
|
|
className="data-[state=open]:opacity-90 w-full"
|
|
disabled={disabled}
|
|
>
|
|
<User2 /> {username ? username : t("common.logout")}
|
|
<ChevronUp className="ml-auto" />
|
|
</SidebarMenuButton>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
side="top"
|
|
align="start"
|
|
sideOffset={6}
|
|
className="min-w-[var(--radix-popper-anchor-width)] bg-sidebar-accent text-sidebar-accent-foreground border border-border rounded-md shadow-2xl p-1"
|
|
>
|
|
<DropdownMenuItem
|
|
className="rounded px-2 py-1.5 hover:bg-surface-hover hover:text-accent-foreground focus:bg-surface-hover focus:text-accent-foreground cursor-pointer focus:outline-none"
|
|
onClick={() => {
|
|
openUserProfileTab();
|
|
}}
|
|
>
|
|
<span>{t("profile.title")}</span>
|
|
</DropdownMenuItem>
|
|
{isAdmin && (
|
|
<DropdownMenuItem
|
|
className="rounded px-2 py-1.5 hover:bg-surface-hover hover:text-accent-foreground focus:bg-surface-hover focus:text-accent-foreground cursor-pointer focus:outline-none"
|
|
onClick={() => {
|
|
if (isAdmin) openAdminTab();
|
|
}}
|
|
>
|
|
<span>{t("admin.title")}</span>
|
|
</DropdownMenuItem>
|
|
)}
|
|
<DropdownMenuItem
|
|
className="rounded px-2 py-1.5 hover:bg-surface-hover hover:text-accent-foreground focus:bg-surface-hover focus:text-accent-foreground cursor-pointer focus:outline-none"
|
|
onClick={onLogout || handleLogout}
|
|
>
|
|
<span>{t("common.logout")}</span>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</SidebarMenuItem>
|
|
</SidebarMenu>
|
|
</SidebarFooter>
|
|
{isSidebarOpen && (
|
|
<div
|
|
className="absolute top-0 h-full cursor-col-resize z-[60]"
|
|
onMouseDown={handleMouseDown}
|
|
style={{
|
|
right: "-4px",
|
|
width: "8px",
|
|
backgroundColor: isResizing
|
|
? "var(--bg-interact)"
|
|
: "transparent",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!isResizing) {
|
|
e.currentTarget.style.backgroundColor =
|
|
"var(--border-hover)";
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!isResizing) {
|
|
e.currentTarget.style.backgroundColor = "transparent";
|
|
}
|
|
}}
|
|
title={t("common.dragToResizeSidebar")}
|
|
/>
|
|
)}
|
|
</Sidebar>
|
|
|
|
<SidebarInset>{children}</SidebarInset>
|
|
</div>
|
|
</SidebarProvider>
|
|
|
|
{!isSidebarOpen && (
|
|
<div
|
|
onClick={() => setIsSidebarOpen(true)}
|
|
className="fixed top-0 left-0 w-[10px] h-full cursor-pointer flex items-center justify-center rounded-tr-md rounded-br-md"
|
|
style={{
|
|
zIndex: 9999,
|
|
backgroundColor: "var(--bg-base)",
|
|
border: "2px solid var(--border-base)",
|
|
borderLeft: "none",
|
|
}}
|
|
>
|
|
<ChevronRight size={10} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|