Added config editor to tab system, (W.I.P)

This commit is contained in:
LukeGus
2025-08-17 02:33:50 -05:00
parent 981705e81d
commit 22162e5b9b
11 changed files with 236 additions and 401 deletions

View File

@@ -88,7 +88,7 @@ function AppContent() {
// Determine what to show based on current tab // Determine what to show based on current tab
const currentTabData = tabs.find(tab => tab.id === currentTab); const currentTabData = tabs.find(tab => tab.id === currentTab);
const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server'; const showTerminalView = currentTabData?.type === 'terminal' || currentTabData?.type === 'server' || currentTabData?.type === 'config';
const showHome = currentTabData?.type === 'home'; const showHome = currentTabData?.type === 'home';
const showSshManager = currentTabData?.type === 'ssh_manager'; const showSshManager = currentTabData?.type === 'ssh_manager';
const showAdmin = currentTabData?.type === 'admin'; const showAdmin = currentTabData?.type === 'admin';

View File

@@ -2,7 +2,7 @@ import React, { createContext, useContext, useState, useRef, type ReactNode } fr
export interface Tab { export interface Tab {
id: number; id: number;
type: 'home' | 'terminal' | 'ssh_manager' | 'server' | 'admin'; type: 'home' | 'terminal' | 'ssh_manager' | 'server' | 'admin' | 'config';
title: string; title: string;
hostConfig?: any; hostConfig?: any;
terminalRef?: React.RefObject<any>; terminalRef?: React.RefObject<any>;
@@ -42,7 +42,7 @@ export function TabProvider({ children }: TabProviderProps) {
const nextTabId = useRef(2); const nextTabId = useRef(2);
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string { function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string {
const defaultTitle = tabType === 'server' ? 'Server' : 'Terminal'; const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'config' ? 'Config' : 'Terminal');
const baseTitle = (desiredTitle || defaultTitle).trim(); const baseTitle = (desiredTitle || defaultTitle).trim();
// Extract base name without trailing " (n)" // Extract base name without trailing " (n)"
const match = baseTitle.match(/^(.*) \((\d+)\)$/); const match = baseTitle.match(/^(.*) \((\d+)\)$/);
@@ -72,7 +72,7 @@ export function TabProvider({ children }: TabProviderProps) {
const addTab = (tabData: Omit<Tab, 'id'>): number => { const addTab = (tabData: Omit<Tab, 'id'>): number => {
const id = nextTabId.current++; const id = nextTabId.current++;
const needsUniqueTitle = tabData.type === 'terminal' || tabData.type === 'server'; const needsUniqueTitle = tabData.type === 'terminal' || tabData.type === 'server' || tabData.type === 'config';
const effectiveTitle = needsUniqueTitle ? computeUniqueTitle(tabData.type, tabData.title) : (tabData.title || ''); const effectiveTitle = needsUniqueTitle ? computeUniqueTitle(tabData.type, tabData.title) : (tabData.title || '');
const newTab: Tab = { const newTab: Tab = {
...tabData, ...tabData,

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import {TerminalComponent} from "../SSH/Terminal/TerminalComponent.tsx"; import {TerminalComponent} from "../SSH/Terminal/TerminalComponent.tsx";
import {Server as ServerView} from "@/ui/SSH/Server/Server.tsx"; import {Server as ServerView} from "@/ui/SSH/Server/Server.tsx";
import {ConfigEditor} from "@/ui/SSH/Config Editor/ConfigEditor.tsx";
import {useTabs} from "@/contexts/TabContext.tsx"; import {useTabs} from "@/contexts/TabContext.tsx";
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx'; import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
import * as ResizablePrimitive from "react-resizable-panels"; import * as ResizablePrimitive from "react-resizable-panels";
@@ -16,7 +17,7 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
const {tabs, currentTab, allSplitScreenTab} = useTabs() as any; const {tabs, currentTab, allSplitScreenTab} = useTabs() as any;
const { state: sidebarState } = useSidebar(); const { state: sidebarState } = useSidebar();
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal' || tab.type === 'server'); const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal' || tab.type === 'server' || tab.type === 'config');
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const panelRefs = useRef<Record<string, HTMLDivElement | null>>({}); const panelRefs = useRef<Record<string, HTMLDivElement | null>>({});
@@ -158,7 +159,7 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
showTitle={false} showTitle={false}
splitScreen={allSplitScreenTab.length>0} splitScreen={allSplitScreenTab.length>0}
/> />
) : ( ) : t.type === 'server' ? (
<ServerView <ServerView
hostConfig={t.hostConfig} hostConfig={t.hostConfig}
title={t.title} title={t.title}
@@ -166,6 +167,11 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
isTopbarOpen={isTopbarOpen} isTopbarOpen={isTopbarOpen}
embedded embedded
/> />
) : (
<ConfigEditor
embedded
initialHost={t.hostConfig}
/>
)} )}
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import {ButtonGroup} from "@/components/ui/button-group.tsx"; import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Button} from "@/components/ui/button.tsx"; import {Button} from "@/components/ui/button.tsx";
import {Home, SeparatorVertical, X, Terminal as TerminalIcon, Server as ServerIcon} from "lucide-react"; import {Home, SeparatorVertical, X, Terminal as TerminalIcon, Server as ServerIcon, Folder as FolderIcon} from "lucide-react";
interface TabProps { interface TabProps {
tabType: string; tabType: string;
@@ -31,8 +31,9 @@ export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, can
); );
} }
if (tabType === "terminal" || tabType === "server") { if (tabType === "terminal" || tabType === "server" || tabType === "config") {
const isServer = tabType === 'server'; const isServer = tabType === 'server';
const isConfig = tabType === 'config';
return ( return (
<ButtonGroup> <ButtonGroup>
<Button <Button
@@ -41,8 +42,8 @@ export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, can
onClick={onActivate} onClick={onActivate}
disabled={disableActivate} disabled={disableActivate}
> >
{isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>} {isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isConfig ? <FolderIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>}
{title || (isServer ? 'Server' : 'Terminal')} {title || (isServer ? 'Server' : isConfig ? 'Config' : 'Terminal')}
</Button> </Button>
{canSplit && ( {canSplit && (
<Button <Button

View File

@@ -53,10 +53,11 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const isSplit = Array.isArray(allSplitScreenTab) && allSplitScreenTab.includes(tab.id); const isSplit = Array.isArray(allSplitScreenTab) && allSplitScreenTab.includes(tab.id);
const isTerminal = tab.type === 'terminal'; const isTerminal = tab.type === 'terminal';
const isServer = tab.type === 'server'; const isServer = tab.type === 'server';
const isConfig = tab.type === 'config';
const isSshManager = tab.type === 'ssh_manager'; const isSshManager = tab.type === 'ssh_manager';
const isAdmin = tab.type === 'admin'; const isAdmin = tab.type === 'admin';
// Split availability // Split availability
const isSplittable = isTerminal || isServer; const isSplittable = isTerminal || isServer || isConfig;
// Disable split entirely when on Home or SSH Manager // Disable split entirely when on Home or SSH Manager
const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit); const isSplitButtonDisabled = (isActive && !isSplitScreenActive) || ((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin; const disableSplit = !isSplittable || isSplitButtonDisabled || isActive || currentTabIsHome || currentTabIsSshManager || currentTabIsAdmin;
@@ -69,10 +70,10 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
title={tab.title} title={tab.title}
isActive={isActive} isActive={isActive}
onActivate={() => handleTabActivate(tab.id)} onActivate={() => handleTabActivate(tab.id)}
onClose={isTerminal || isServer || isSshManager || isAdmin ? () => handleTabClose(tab.id) : undefined} onClose={isTerminal || isServer || isConfig || isSshManager || isAdmin ? () => handleTabClose(tab.id) : undefined}
onSplit={isSplittable ? () => handleTabSplit(tab.id) : undefined} onSplit={isSplittable ? () => handleTabSplit(tab.id) : undefined}
canSplit={isSplittable} canSplit={isSplittable}
canClose={isTerminal || isServer || isSshManager || isAdmin} canClose={isTerminal || isServer || isConfig || isSshManager || isAdmin}
disableActivate={disableActivate} disableActivate={disableActivate}
disableSplit={disableSplit} disableSplit={disableSplit}
disableClose={disableClose} disableClose={disableClose}

View File

@@ -59,7 +59,7 @@ interface SSHHost {
updatedAt: string; updatedAt: string;
} }
export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => void }): React.ReactElement { export function ConfigEditor({onSelectView, embedded = false, initialHost = null}: { onSelectView?: (view: string) => void, embedded?: boolean, initialHost?: SSHHost | null }): React.ReactElement {
const [tabs, setTabs] = useState<Tab[]>([]); const [tabs, setTabs] = useState<Tab[]>([]);
const [activeTab, setActiveTab] = useState<string | number>('home'); const [activeTab, setActiveTab] = useState<string | number>('home');
const [recent, setRecent] = useState<any[]>([]); const [recent, setRecent] = useState<any[]>([]);
@@ -71,6 +71,22 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
const sidebarRef = useRef<any>(null); const sidebarRef = useRef<any>(null);
useEffect(() => {
if (initialHost && (!currentHost || currentHost.id !== initialHost.id)) {
setCurrentHost(initialHost);
// Defer to ensure sidebar is mounted
setTimeout(() => {
try {
const path = initialHost.defaultPath || '/';
if (sidebarRef.current && sidebarRef.current.openFolder) {
sidebarRef.current.openFolder(initialHost, path);
}
} catch (e) {
}
}, 0);
}
}, [initialHost]);
useEffect(() => { useEffect(() => {
if (currentHost) { if (currentHost) {
fetchHomeData(); fetchHomeData();
@@ -428,22 +444,19 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
} }
}; };
const handleHostChange = (host: SSHHost | null) => { // Host is locked; no external host change from UI
setCurrentHost(host); const handleHostChange = (_host: SSHHost | null) => {};
setTabs([]);
setActiveTab('home');
};
if (!currentHost) { if (!currentHost) {
return ( return (
<div style={{position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden'}}> <div style={{position: 'relative', width: '100%', height: '100%', overflow: 'hidden'}}>
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20}}> <div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
<ConfigEditorSidebar <ConfigEditorSidebar
onSelectView={onSelectView} onSelectView={onSelectView || (() => {})}
onOpenFile={handleOpenFile} onOpenFile={handleOpenFile}
tabs={tabs} tabs={tabs}
ref={sidebarRef} ref={sidebarRef}
onHostChange={handleHostChange} host={initialHost as SSHHost}
/> />
</div> </div>
<div style={{ <div style={{
@@ -467,23 +480,23 @@ export function ConfigEditor({onSelectView}: { onSelectView: (view: string) => v
} }
return ( return (
<div style={{position: 'relative', width: '100vw', height: '100vh', overflow: 'hidden'}}> <div style={{position: 'relative', width: '100%', height: '100%', overflow: 'hidden'}}>
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100vh', zIndex: 20}}> <div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
<ConfigEditorSidebar <ConfigEditorSidebar
onSelectView={onSelectView} onSelectView={onSelectView || (() => {})}
onOpenFile={handleOpenFile} onOpenFile={handleOpenFile}
tabs={tabs} tabs={tabs}
ref={sidebarRef} ref={sidebarRef}
onHostChange={handleHostChange} host={currentHost as SSHHost}
/> />
</div> </div>
<div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 44, zIndex: 30}}> <div style={{position: 'absolute', top: 0, left: 256, right: 0, height: 44, zIndex: 30}}>
<div className="flex items-center w-full bg-[#18181b] border-b border-[#222224] h-11 relative px-4" <div className="flex items-center w-full bg-[#18181b] border-b-2 border-[#303032] h-11 relative px-4"
style={{height: 44}}> style={{height: 44}}>
{/* Tab list scrollable area */} {/* Tab list scrollable area */}
<div className="flex-1 min-w-0 h-full flex items-center"> <div className="flex-1 min-w-0 h-full flex items-center">
<div <div
className="h-9 w-full bg-[#09090b] border border-[#23232a] rounded-md flex items-center overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent" className="h-9 w-full bg-[#09090b] border-2 border-[#303032] rounded-md flex items-center overflow-x-auto scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent"
style={{minWidth: 0}}> style={{minWidth: 0}}>
<ConfigTopbar <ConfigTopbar
tabs={tabs.map(t => ({id: t.id, title: t.title}))} tabs={tabs.map(t => ({id: t.id, title: t.title}))}

View File

@@ -1,21 +1,11 @@
import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react'; import React, {useEffect, useState, useRef, forwardRef, useImperativeHandle} from 'react';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel, SidebarMenu, SidebarMenuItem,
SidebarProvider
} from '@/components/ui/sidebar.tsx';
import {Separator} from '@/components/ui/separator.tsx'; import {Separator} from '@/components/ui/separator.tsx';
import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin} from 'lucide-react'; import {CornerDownLeft, Folder, File, Server, ArrowUp, Pin} from 'lucide-react';
import {ScrollArea} from '@/components/ui/scroll-area.tsx'; import {ScrollArea} from '@/components/ui/scroll-area.tsx';
import {cn} from '@/lib/utils.ts'; import {cn} from '@/lib/utils.ts';
import {Input} from '@/components/ui/input.tsx'; import {Input} from '@/components/ui/input.tsx';
import {Button} from '@/components/ui/button.tsx'; import {Button} from '@/components/ui/button.tsx';
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from '@/components/ui/accordion.tsx';
import { import {
getSSHHosts,
listSSHFiles, listSSHFiles,
connectSSH, connectSSH,
getSSHStatus, getSSHStatus,
@@ -48,19 +38,14 @@ interface SSHHost {
} }
const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar( const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
{onSelectView, onOpenFile, tabs, onHostChange}: { {onSelectView, onOpenFile, tabs, host}: {
onSelectView: (view: string) => void; onSelectView?: (view: string) => void;
onOpenFile: (file: any) => void; onOpenFile: (file: any) => void;
tabs: any[]; tabs: any[];
onHostChange?: (host: SSHHost | null) => void; host: SSHHost;
}, },
ref ref
) { ) {
const [sshConnections, setSSHConnections] = useState<SSHHost[]>([]);
const [loadingSSH, setLoadingSSH] = useState(false);
const [errorSSH, setErrorSSH] = useState<string | undefined>(undefined);
const [view, setView] = useState<'servers' | 'files'>('servers');
const [activeServer, setActiveServer] = useState<SSHHost | null>(null);
const [currentPath, setCurrentPath] = useState('/'); const [currentPath, setCurrentPath] = useState('/');
const [files, setFiles] = useState<any[]>([]); const [files, setFiles] = useState<any[]>([]);
const pathInputRef = useRef<HTMLInputElement>(null); const pathInputRef = useRef<HTMLInputElement>(null);
@@ -89,27 +74,14 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
const [fetchingFiles, setFetchingFiles] = useState(false); const [fetchingFiles, setFetchingFiles] = useState(false);
useEffect(() => { useEffect(() => {
fetchSSH(); // when host changes, set path and connect
}, []); const nextPath = host?.defaultPath || '/';
setCurrentPath(nextPath);
async function fetchSSH() { (async () => {
setLoadingSSH(true); await connectToSSH(host);
setErrorSSH(undefined); })();
try { // eslint-disable-next-line react-hooks/exhaustive-deps
const hosts = await getSSHHosts(); }, [host?.id]);
const configEditorHosts = hosts.filter(host => host.enableConfigEditor);
if (configEditorHosts.length > 0) {
const firstHost = configEditorHosts[0];
}
setSSHConnections(configEditorHosts);
} catch (err: any) {
setErrorSSH('Failed to load SSH connections');
} finally {
setLoadingSSH(false);
}
}
async function connectToSSH(server: SSHHost): Promise<string | null> { async function connectToSSH(server: SSHHost): Promise<string | null> {
const sessionId = server.id.toString(); const sessionId = server.id.toString();
@@ -173,19 +145,19 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
try { try {
let pinnedFiles: any[] = []; let pinnedFiles: any[] = [];
try { try {
if (activeServer) { if (host) {
pinnedFiles = await getConfigEditorPinned(activeServer.id); pinnedFiles = await getConfigEditorPinned(host.id);
} }
} catch (err) { } catch (err) {
} }
if (activeServer && sshSessionId) { if (host && sshSessionId) {
let res: any[] = []; let res: any[] = [];
try { try {
const status = await getSSHStatus(sshSessionId); const status = await getSSHStatus(sshSessionId);
if (!status.connected) { if (!status.connected) {
const newSessionId = await connectToSSH(activeServer); const newSessionId = await connectToSSH(host);
if (newSessionId) { if (newSessionId) {
setSshSessionId(newSessionId); setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath); res = await listSSHFiles(newSessionId, currentPath);
@@ -196,7 +168,7 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
res = await listSSHFiles(sshSessionId, currentPath); res = await listSSHFiles(sshSessionId, currentPath);
} }
} catch (sessionErr) { } catch (sessionErr) {
const newSessionId = await connectToSSH(activeServer); const newSessionId = await connectToSSH(host);
if (newSessionId) { if (newSessionId) {
setSshSessionId(newSessionId); setSshSessionId(newSessionId);
res = await listSSHFiles(newSessionId, currentPath); res = await listSSHFiles(newSessionId, currentPath);
@@ -229,48 +201,21 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
} }
useEffect(() => { useEffect(() => {
if (view === 'files' && activeServer && sshSessionId && !connectingSSH && !fetchingFiles) { if (host && sshSessionId && !connectingSSH && !fetchingFiles) {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
fetchFiles(); fetchFiles();
}, 100); }, 100);
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
} }
}, [currentPath, view, activeServer, sshSessionId]); }, [currentPath, host, sshSessionId]);
async function handleSelectServer(server: SSHHost) {
if (connectingSSH) {
return;
}
setFetchingFiles(false);
setFilesLoading(false);
setFilesError(null);
setFiles([]);
setActiveServer(server);
setCurrentPath(server.defaultPath || '/');
setView('files');
const sessionId = await connectToSSH(server);
if (sessionId) {
setSshSessionId(sessionId);
if (onHostChange) {
onHostChange(server);
}
} else {
w
setView('servers');
setActiveServer(null);
}
}
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
openFolder: async (server: SSHHost, path: string) => { openFolder: async (_server: SSHHost, path: string) => {
if (connectingSSH || fetchingFiles) { if (connectingSSH || fetchingFiles) {
return; return;
} }
if (activeServer?.id === server.id && currentPath === path) { if (currentPath === path) {
setTimeout(() => fetchFiles(), 100); setTimeout(() => fetchFiles(), 100);
return; return;
} }
@@ -280,29 +225,14 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
setFilesError(null); setFilesError(null);
setFiles([]); setFiles([]);
setActiveServer(server);
setCurrentPath(path); setCurrentPath(path);
setView('files'); if (!sshSessionId) {
const sessionId = await connectToSSH(host);
if (!sshSessionId || activeServer?.id !== server.id) { if (sessionId) setSshSessionId(sessionId);
const sessionId = await connectToSSH(server);
if (sessionId) {
setSshSessionId(sessionId);
if (onHostChange && activeServer?.id !== server.id) {
onHostChange(server);
}
} else {
setView('servers');
setActiveServer(null);
}
} else {
if (onHostChange && activeServer?.id !== server.id) {
onHostChange(server);
}
} }
}, },
fetchFiles: () => { fetchFiles: () => {
if (activeServer && sshSessionId) { if (host && sshSessionId) {
fetchFiles(); fetchFiles();
} }
} }
@@ -314,30 +244,6 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
} }
}, [currentPath]); }, [currentPath]);
const sshByFolder: Record<string, SSHHost[]> = {};
sshConnections.forEach(conn => {
const folder = conn.folder && conn.folder.trim() ? conn.folder : 'No Folder';
if (!sshByFolder[folder]) sshByFolder[folder] = [];
sshByFolder[folder].push(conn);
});
const sortedFolders = Object.keys(sshByFolder);
if (sortedFolders.includes('No Folder')) {
sortedFolders.splice(sortedFolders.indexOf('No Folder'), 1);
sortedFolders.unshift('No Folder');
}
const filteredSshByFolder: Record<string, SSHHost[]> = {};
Object.entries(sshByFolder).forEach(([folder, hosts]) => {
filteredSshByFolder[folder] = hosts.filter(conn => {
const q = debouncedSearch.trim().toLowerCase();
if (!q) return true;
return (conn.name || '').toLowerCase().includes(q) || (conn.ip || '').toLowerCase().includes(q) ||
(conn.username || '').toLowerCase().includes(q) || (conn.folder || '').toLowerCase().includes(q) ||
(conn.tags || []).join(' ').toLowerCase().includes(q);
});
});
const filteredFiles = files.filter(file => { const filteredFiles = files.filter(file => {
const q = debouncedFileSearch.trim().toLowerCase(); const q = debouncedFileSearch.trim().toLowerCase();
if (!q) return true; if (!q) return true;
@@ -345,107 +251,16 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
}); });
return ( return (
<SidebarProvider> <div className="flex flex-col h-full w-[256px]" style={{maxWidth: 256}}>
<Sidebar style={{height: '100vh', maxHeight: '100vh', overflow: 'hidden'}}> <div className="flex flex-col flex-grow min-h-0">
<SidebarContent style={{height: '100vh', maxHeight: '100vh', overflow: 'hidden'}}> <div className="flex-1 w-full h-full flex flex-col bg-[#09090b] border-r-2 border-[#303032] overflow-hidden p-0 relative min-h-0">
<SidebarGroup className="flex flex-col flex-grow h-full overflow-hidden"> {host && (
<SidebarGroupLabel className="text-lg font-bold text-white flex items-center gap-2">
Termix / Config
</SidebarGroupLabel>
<Separator className="p-0.25 mt-1 mb-1"/>
<SidebarGroupContent className="flex flex-col flex-grow min-h-0">
<SidebarMenu>
<SidebarMenuItem key={"Homepage"}>
<Button className="w-full mt-2 mb-2 h-8" onClick={() => onSelectView("homepage")}
variant="outline">
<CornerDownLeft/>
Return
</Button>
<Separator className="p-0.25 mt-1 mb-1"/>
</SidebarMenuItem>
</SidebarMenu>
<div
className="flex-1 w-full flex flex-col rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 relative min-h-0 mt-1">
{view === 'servers' && (
<>
<div
className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10 border-b border-[#23232a]">
<Input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search hosts by name, username, IP, folder, tags..."
className="w-full h-8 text-sm bg-[#18181b] border border-[#23232a] text-white placeholder:text-muted-foreground rounded"
autoComplete="off"
/>
</div>
<ScrollArea className="flex-1 w-full h-full"
style={{height: '100%', maxHeight: '100%'}}>
<div className="flex flex-col h-full">
<div
className="w-full flex-grow overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
<div style={{display: 'flex', justifyContent: 'center'}}>
<Separator className="w-full h-px bg-[#434345] my-2"
style={{maxWidth: 213, margin: '0 auto'}}/>
</div>
<div className="mx-auto" style={{maxWidth: '213px', width: '100%'}}>
<div className="flex-1 min-h-0">
<Accordion type="multiple" className="w-full"
value={sortedFolders}>
{sortedFolders.map((folder, idx) => (
<React.Fragment key={folder}>
<AccordionItem value={folder}
className="mt-0 w-full !border-b-transparent">
<AccordionTrigger
className="text-base font-semibold rounded-t-none py-2 w-full">{folder}</AccordionTrigger>
<AccordionContent
className="flex flex-col gap-1 pb-2 pt-1 w-full">
{filteredSshByFolder[folder].map(conn => (
<Button
key={conn.id}
variant="outline"
className="w-full h-10 px-2 bg-[#18181b] border border-[#434345] hover:bg-[#2d2d30] transition-colors text-left justify-start"
onClick={() => handleSelectServer(conn)}
>
<div
className="flex items-center w-full">
{conn.pin && <Pin
className="w-0.5 h-0.5 text-yellow-400 mr-1 flex-shrink-0"/>}
<span
className="font-medium truncate">{conn.name || conn.ip}</span>
</div>
</Button>
))}
</AccordionContent>
</AccordionItem>
{idx < sortedFolders.length - 1 && (
<div style={{
display: 'flex',
justifyContent: 'center'
}}>
<Separator
className="h-px bg-[#434345] my-1"
style={{width: 213}}/>
</div>
)}
</React.Fragment>
))}
</Accordion>
</div>
</div>
</div>
</div>
</ScrollArea>
</>
)}
{view === 'files' && activeServer && (
<div className="flex flex-col h-full w-full" style={{maxWidth: 260}}> <div className="flex flex-col h-full w-full" style={{maxWidth: 260}}>
<div <div className="flex items-center gap-2 px-2 py-2 border-b-2 border-[#303032] bg-[#18181b] z-20" style={{maxWidth: 260}}>
className="flex items-center gap-2 px-2 py-2 border-b border-[#23232a] bg-[#18181b] z-20"
style={{maxWidth: 260}}>
<Button <Button
size="icon" size="icon"
variant="outline" variant="outline"
className="h-8 w-8 bg-[#18181b] border border-[#23232a] rounded-md hover:bg-[#2d2d30] focus:outline-none focus:ring-2 focus:ring-ring" className="h-8 w-8 bg-[#18181b] border-2 border-[#303032] rounded-md hover:bg-[#2d2d30] focus:outline-none focus:ring-2 focus:ring-ring"
onClick={() => { onClick={() => {
let path = currentPath; let path = currentPath;
if (path && path !== '/' && path !== '') { if (path && path !== '/' && path !== '') {
@@ -457,10 +272,7 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
setCurrentPath('/'); setCurrentPath('/');
} }
} else { } else {
setView('servers'); setCurrentPath('/');
if (onHostChange) {
onHostChange(null);
}
} }
}} }}
> >
@@ -471,7 +283,7 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
className="flex-1 bg-[#18181b] border border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]" className="flex-1 bg-[#18181b] border border-[#434345] text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-[#5a5a5d]"
/> />
</div> </div>
<div className="px-2 py-2 border-b border-[#23232a] bg-[#18181b]"> <div className="px-2 py-2 border-b-1 border-[#303032] bg-[#18181b]">
<Input <Input
placeholder="Search files and folders..." placeholder="Search files and folders..."
className="w-full h-7 text-sm bg-[#23232a] border border-[#434345] text-white placeholder:text-muted-foreground rounded" className="w-full h-7 text-sm bg-[#23232a] border border-[#434345] text-white placeholder:text-muted-foreground rounded"
@@ -480,7 +292,7 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
onChange={e => setFileSearch(e.target.value)} onChange={e => setFileSearch(e.target.value)}
/> />
</div> </div>
<div className="flex-1 w-full h-full bg-[#09090b] border-t border-[#23232a]"> <div className="flex-1 w-full h-full bg-[#09090b] border-t-1 border-[#303032]">
<ScrollArea className="w-full h-full bg-[#09090b]" style={{ <ScrollArea className="w-full h-full bg-[#09090b]" style={{
height: '100%', height: '100%',
maxHeight: '100%', maxHeight: '100%',
@@ -494,8 +306,7 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
) : filesError ? ( ) : filesError ? (
<div className="text-xs text-red-500">{filesError}</div> <div className="text-xs text-red-500">{filesError}</div>
) : filteredFiles.length === 0 ? ( ) : filteredFiles.length === 0 ? (
<div className="text-xs text-muted-foreground">No files or <div className="text-xs text-muted-foreground">No files or folders found.</div>
folders found.</div>
) : ( ) : (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{filteredFiles.map((item: any) => { {filteredFiles.map((item: any) => {
@@ -504,7 +315,7 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
<div <div
key={item.path} key={item.path}
className={cn( className={cn(
"flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded group max-w-full", "flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded group max-w-full",
isOpen && "opacity-60 cursor-not-allowed pointer-events-none" isOpen && "opacity-60 cursor-not-allowed pointer-events-none"
)} )}
style={{maxWidth: 220, marginBottom: 8}} style={{maxWidth: 220, marginBottom: 8}}
@@ -519,17 +330,13 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
}))} }))}
> >
{item.type === 'directory' ? {item.type === 'directory' ?
<Folder <Folder className="w-4 h-4 text-blue-400"/> :
className="w-4 h-4 text-blue-400"/> : <File className="w-4 h-4 text-muted-foreground"/>}
<File <span className="text-sm text-white truncate max-w-[120px]">{item.name}</span>
className="w-4 h-4 text-muted-foreground"/>}
<span
className="text-sm text-white truncate max-w-[120px]">{item.name}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{item.type === 'file' && ( {item.type === 'file' && (
<Button size="icon" variant="ghost" <Button size="icon" variant="ghost" className="h-7 w-7"
className="h-7 w-7"
disabled={isOpen} disabled={isOpen}
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -538,29 +345,23 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
await removeConfigEditorPinned({ await removeConfigEditorPinned({
name: item.name, name: item.name,
path: item.path, path: item.path,
hostId: activeServer?.id, hostId: host?.id,
isSSH: true, isSSH: true,
sshSessionId: activeServer?.id.toString() sshSessionId: host?.id.toString()
}); });
setFiles(files.map(f => setFiles(files.map(f =>
f.path === item.path ? { f.path === item.path ? { ...f, isPinned: false } : f
...f,
isPinned: false
} : f
)); ));
} else { } else {
await addConfigEditorPinned({ await addConfigEditorPinned({
name: item.name, name: item.name,
path: item.path, path: item.path,
hostId: activeServer?.id, hostId: host?.id,
isSSH: true, isSSH: true,
sshSessionId: activeServer?.id.toString() sshSessionId: host?.id.toString()
}); });
setFiles(files.map(f => setFiles(files.map(f =>
f.path === item.path ? { f.path === item.path ? { ...f, isPinned: true } : f
...f,
isPinned: true
} : f
)); ));
} }
} catch (err) { } catch (err) {
@@ -568,8 +369,7 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
} }
}} }}
> >
<Pin <Pin className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
className={`w-1 h-1 ${item.isPinned ? 'text-yellow-400 fill-current' : 'text-muted-foreground'}`}/>
</Button> </Button>
)} )}
</div> </div>
@@ -584,11 +384,8 @@ const ConfigEditorSidebar = forwardRef(function ConfigEditorSidebar(
</div> </div>
)} )}
</div> </div>
</SidebarGroupContent> </div>
</SidebarGroup> </div>
</SidebarContent>
</Sidebar>
</SidebarProvider>
); );
}); });
export {ConfigEditorSidebar}; export {ConfigEditorSidebar};

View File

@@ -64,7 +64,7 @@ export function ConfigFileSidebarViewer({
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* SSH Connections */} {/* SSH Connections */}
<div className="p-2 bg-[#18181b] border-b border-[#23232a]"> <div className="p-2 bg-[#18181b] border-b-2 border-[#303032]">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground font-semibold">SSH Connections</span> <span className="text-xs text-muted-foreground font-semibold">SSH Connections</span>
<Button size="icon" variant="outline" onClick={onAddSSH} className="ml-2 h-7 w-7"> <Button size="icon" variant="outline" onClick={onAddSSH} className="ml-2 h-7 w-7">
@@ -119,7 +119,7 @@ export function ConfigFileSidebarViewer({
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{files.map((item) => ( {files.map((item) => (
<Card key={item.path} <Card key={item.path}
className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border border-[#23232a] rounded"> className="flex items-center gap-2 px-2 py-1 bg-[#18181b] border-2 border-[#303032] rounded">
<div className="flex items-center gap-2 flex-1 cursor-pointer" <div className="flex items-center gap-2 flex-1 cursor-pointer"
onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}> onClick={() => item.type === 'directory' ? onOpenFolder(item) : onOpenFile(item)}>
{item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400"/> : {item.type === 'directory' ? <Folder className="w-4 h-4 text-blue-400"/> :

View File

@@ -49,7 +49,7 @@ export function ConfigHomeView({
const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => ( const renderFileCard = (file: FileItem, onRemove: () => void, onPin?: () => void, isPinned = false) => (
<div key={file.path} <div key={file.path}
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors"> className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded hover:border-[#434345] transition-colors">
<div <div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0" className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenFile(file)} onClick={() => onOpenFile(file)}
@@ -92,7 +92,7 @@ export function ConfigHomeView({
const renderShortcutCard = (shortcut: ShortcutItem) => ( const renderShortcutCard = (shortcut: ShortcutItem) => (
<div key={shortcut.path} <div key={shortcut.path}
className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border border-[#23232a] rounded hover:border-[#434345] transition-colors"> className="flex items-center gap-2 px-3 py-2 bg-[#18181b] border-2 border-[#303032] rounded hover:border-[#434345] transition-colors">
<div <div
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0" className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
onClick={() => onOpenShortcut(shortcut)} onClick={() => onOpenShortcut(shortcut)}
@@ -120,7 +120,7 @@ export function ConfigHomeView({
return ( return (
<div className="p-4 flex flex-col gap-4 h-full bg-[#09090b]"> <div className="p-4 flex flex-col gap-4 h-full bg-[#09090b]">
<Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full"> <Tabs value={tab} onValueChange={v => setTab(v as 'recent' | 'pinned' | 'shortcuts')} className="w-full">
<TabsList className="mb-4 bg-[#18181b] border border-[#23232a]"> <TabsList className="mb-4 bg-[#18181b] border-2 border-[#303032]">
<TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">Recent</TabsTrigger> <TabsTrigger value="recent" className="data-[state=active]:bg-[#23232a]">Recent</TabsTrigger>
<TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">Pinned</TabsTrigger> <TabsTrigger value="pinned" className="data-[state=active]:bg-[#23232a]">Pinned</TabsTrigger>
<TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">Folder <TabsTrigger value="shortcuts" className="data-[state=active]:bg-[#23232a]">Folder
@@ -162,12 +162,12 @@ export function ConfigHomeView({
</TabsContent> </TabsContent>
<TabsContent value="shortcuts" className="mt-0"> <TabsContent value="shortcuts" className="mt-0">
<div className="flex items-center gap-3 mb-4 p-3 bg-[#18181b] border border-[#23232a] rounded-lg"> <div className="flex items-center gap-3 mb-4 p-3 bg-[#18181b] border-2 border-[#303032] rounded-lg">
<Input <Input
placeholder="Enter folder path" placeholder="Enter folder path"
value={newShortcut} value={newShortcut}
onChange={e => setNewShortcut(e.target.value)} onChange={e => 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) => { onKeyDown={(e) => {
if (e.key === 'Enter' && newShortcut.trim()) { if (e.key === 'Enter' && newShortcut.trim()) {
onAddShortcut(newShortcut.trim()); onAddShortcut(newShortcut.trim());
@@ -178,7 +178,7 @@ export function ConfigHomeView({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="h-8 px-2 bg-[#23232a] border-[#434345] hover:bg-[#2d2d30] rounded-md" className="h-8 px-2 bg-[#23232a] border-2 border-[#303032] hover:bg-[#2d2d30] rounded-md"
onClick={() => { onClick={() => {
if (newShortcut.trim()) { if (newShortcut.trim()) {
onAddShortcut(newShortcut.trim()); onAddShortcut(newShortcut.trim());

View File

@@ -21,7 +21,7 @@ export function ConfigTabList({tabs, activeTab, setActiveTab, closeTab, onHomeCl
<Button <Button
onClick={onHomeClick} onClick={onHomeClick}
variant="outline" variant="outline"
className={`h-7 mr-[0.5rem] rounded-md flex items-center ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`} className={`h-7 mr-[0.5rem] rounded-md flex items-center ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-2 !border-[#303032] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
> >
<Home className="w-4 h-4"/> <Home className="w-4 h-4"/>
</Button> </Button>
@@ -36,7 +36,7 @@ export function ConfigTabList({tabs, activeTab, setActiveTab, closeTab, onHomeCl
<Button <Button
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
variant="outline" variant="outline"
className={`h-7 rounded-r-none ${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' : ''}`} className={`h-7 rounded-r-none ${isActive ? '!bg-[#1d1d1f] !text-white !border-2 !border-[#303032] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
> >
{tab.title} {tab.title}
</Button> </Button>

View File

@@ -7,6 +7,7 @@ import { Progress } from "@/components/ui/progress"
import {Cpu, HardDrive, MemoryStick} from "lucide-react"; import {Cpu, HardDrive, MemoryStick} from "lucide-react";
import {SSHTunnel} from "@/ui/SSH/Tunnel/SSHTunnel.tsx"; import {SSHTunnel} from "@/ui/SSH/Tunnel/SSHTunnel.tsx";
import { getServerStatusById, getServerMetricsById, ServerMetrics } from "@/ui/SSH/ssh-axios"; import { getServerStatusById, getServerMetricsById, ServerMetrics } from "@/ui/SSH/ssh-axios";
import { useTabs } from "@/contexts/TabContext";
interface ServerProps { interface ServerProps {
hostConfig?: any; hostConfig?: any;
@@ -18,6 +19,7 @@ interface ServerProps {
export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = true, embedded = false }: ServerProps): React.ReactElement { export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = true, embedded = false }: ServerProps): React.ReactElement {
const { state: sidebarState } = useSidebar(); const { state: sidebarState } = useSidebar();
const { addTab } = useTabs() as any;
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline'); const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null); const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
@@ -95,7 +97,22 @@ export function Server({ hostConfig, title, isVisible = true, isTopbarOpen = tru
</Status> </Status>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<Button variant="outline">File Manager</Button> <Button
variant="outline"
onClick={() => {
if (!hostConfig) return;
const titleBase = hostConfig?.name && hostConfig.name.trim() !== ''
? hostConfig.name.trim()
: `${hostConfig.username}@${hostConfig.ip}`;
addTab({
type: 'config',
title: titleBase,
hostConfig: hostConfig,
});
}}
>
File Manager
</Button>
</div> </div>
</div> </div>
<Separator className="p-0.25 w-full"/> <Separator className="p-0.25 w-full"/>