Update file naming and structure for mobile support
This commit is contained in:
553
src/ui/Desktop/Navigation/AppView.tsx
Normal file
553
src/ui/Desktop/Navigation/AppView.tsx
Normal file
@@ -0,0 +1,553 @@
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import {Terminal} from "@/ui/Desktop/Apps/Terminal/Terminal.tsx";
|
||||
import {Server as ServerView} from "@/ui/Desktop/Apps/Server/Server.tsx";
|
||||
import {FileManager} from "@/ui/Desktop/Apps/File Manager/FileManager.tsx";
|
||||
import {useTabs} from "@/ui/Desktop/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<HTMLDivElement | null>(null);
|
||||
const panelRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
const [panelRects, setPanelRects] = useState<Record<string, DOMRect | null>>({});
|
||||
const [ready, setReady] = useState<boolean>(true);
|
||||
const [resetKey, setResetKey] = useState<number>(0);
|
||||
|
||||
const updatePanelRects = () => {
|
||||
const next: Record<string, DOMRect | null> = {};
|
||||
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<number | null>(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<number, React.CSSProperties> = {};
|
||||
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 (
|
||||
<div style={{position: 'absolute', inset: 0, zIndex: 1}}>
|
||||
{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 (
|
||||
<div key={t.id} style={finalStyle}>
|
||||
<div className="absolute inset-0 rounded-md bg-[#18181b]">
|
||||
{t.type === 'terminal' ? (
|
||||
<Terminal
|
||||
ref={t.terminalRef}
|
||||
hostConfig={t.hostConfig}
|
||||
isVisible={effectiveVisible}
|
||||
title={t.title}
|
||||
showTitle={false}
|
||||
splitScreen={allSplitScreenTab.length > 0}
|
||||
/>
|
||||
) : t.type === 'server' ? (
|
||||
<ServerView
|
||||
hostConfig={t.hostConfig}
|
||||
title={t.title}
|
||||
isVisible={effectiveVisible}
|
||||
isTopbarOpen={isTopbarOpen}
|
||||
embedded
|
||||
/>
|
||||
) : (
|
||||
<FileManager
|
||||
embedded
|
||||
initialHost={t.hostConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResetButton = ({onClick}: { onClick: () => void }) => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onClick}
|
||||
aria-label="Reset split sizes"
|
||||
className="absolute top-0 right-0 h-[28px] w-[28px] !rounded-none border-l-1 border-b-1 border-[#222224] bg-[#1b1b1e] hover:bg-[#232327] text-white flex items-center justify-center p-0"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4"/>
|
||||
</Button>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
|
||||
<ResizablePrimitive.PanelGroup key={resetKey} direction="horizontal"
|
||||
className="h-full w-full" {...commonGroupProps}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${a.id}`} order={1}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(a.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1b1b1e',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: HEADER_H,
|
||||
lineHeight: `${HEADER_H}px`,
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
position: 'relative'
|
||||
}}>{a.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={handleStyle}/>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${b.id}`} order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(b.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'transparent',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1b1b1e',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: HEADER_H,
|
||||
lineHeight: `${HEADER_H}px`,
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
position: 'relative'
|
||||
}}>
|
||||
{b.title}
|
||||
<ResetButton onClick={handleReset}/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePrimitive.PanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (layoutTabs.length === 3) {
|
||||
const [a, b, c] = layoutTabs as any[];
|
||||
return (
|
||||
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
|
||||
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
|
||||
id="main-vertical" {...commonGroupProps}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id="top-panel" order={1}>
|
||||
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
|
||||
className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${a.id}`} order={1}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(a.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1b1b1e',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: HEADER_H,
|
||||
lineHeight: `${HEADER_H}px`,
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
position: 'relative'
|
||||
}}>{a.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={handleStyle}/>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id={`panel-${b.id}`} order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(b.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1b1b1e',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: HEADER_H,
|
||||
lineHeight: `${HEADER_H}px`,
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
position: 'relative'
|
||||
}}>
|
||||
{b.title}
|
||||
<ResetButton onClick={handleReset}/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={handleStyle}/>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id="bottom-panel" order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(c.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1b1b1e',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: HEADER_H,
|
||||
lineHeight: `${HEADER_H}px`,
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
position: 'relative'
|
||||
}}>{c.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePrimitive.PanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (layoutTabs.length === 4) {
|
||||
const [a, b, c, d] = layoutTabs as any[];
|
||||
return (
|
||||
<div style={{position: 'absolute', inset: 0, zIndex: 10, pointerEvents: 'none'}}>
|
||||
<ResizablePrimitive.PanelGroup key={resetKey} direction="vertical" className="h-full w-full"
|
||||
id="main-vertical" {...commonGroupProps}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h-full w-full"
|
||||
id="top-panel" order={1}>
|
||||
<ResizablePanelGroup key={`top-${resetKey}`} direction="horizontal"
|
||||
className="h-full w-full" id="top-horizontal" {...commonGroupProps}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||
id={`panel-${a.id}`} order={1}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(a.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1b1b1e',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: HEADER_H,
|
||||
lineHeight: `${HEADER_H}px`,
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
position: 'relative'
|
||||
}}>{a.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={handleStyle}/>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||
id={`panel-${b.id}`} order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(b.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1b1b1e',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: HEADER_H,
|
||||
lineHeight: `${HEADER_H}px`,
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
position: 'relative'
|
||||
}}>
|
||||
{b.title}
|
||||
<ResetButton onClick={handleReset}/>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={handleStyle}/>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||
id="bottom-panel" order={2}>
|
||||
<ResizablePanelGroup key={`bottom-${resetKey}`} direction="horizontal"
|
||||
className="h-full w-full" id="bottom-horizontal" {...commonGroupProps}>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||
id={`panel-${c.id}`} order={1}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(c.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1b1b1e',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: HEADER_H,
|
||||
lineHeight: `${HEADER_H}px`,
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
position: 'relative'
|
||||
}}>{c.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle style={handleStyle}/>
|
||||
<ResizablePanel defaultSize={50} minSize={20} className="!overflow-hidden h_full w_full"
|
||||
id={`panel-${d.id}`} order={2}>
|
||||
<div ref={el => {
|
||||
panelRefs.current[String(d.id)] = el;
|
||||
}} style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1b1b1e',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
height: HEADER_H,
|
||||
lineHeight: `${HEADER_H}px`,
|
||||
padding: '0 10px',
|
||||
borderBottom: '1px solid #222224',
|
||||
letterSpacing: 1,
|
||||
margin: 0,
|
||||
pointerEvents: 'auto',
|
||||
zIndex: 11,
|
||||
position: 'relative'
|
||||
}}>{d.title}</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
</ResizablePrimitive.PanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="border-2 border-[#303032] rounded-lg overflow-hidden overflow-x-hidden"
|
||||
style={{
|
||||
position: 'relative',
|
||||
background: (isFileManager && !isSplitScreen) ? '#09090b' : '#18181b',
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
}}
|
||||
>
|
||||
{renderTerminalsLayer()}
|
||||
{renderSplitOverlays()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
src/ui/Desktop/Navigation/Hosts/FolderCard.tsx
Normal file
81
src/ui/Desktop/Navigation/Hosts/FolderCard.tsx
Normal file
@@ -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/Desktop/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 (
|
||||
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden"
|
||||
style={{padding: '0', margin: '0'}}>
|
||||
<div className={`px-4 py-3 relative ${isExpanded ? 'border-b-2' : ''} bg-[#131316]`}>
|
||||
<div className="flex gap-2 pr-10">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<Folder size={16} strokeWidth={3}/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="mb-0 leading-tight break-words text-md">{folderName}</CardTitle>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[28px] h-[28px] absolute right-4 top-1/2 -translate-y-1/2 flex-shrink-0"
|
||||
onClick={toggleExpanded}
|
||||
>
|
||||
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? '' : 'rotate-180'}`}/>
|
||||
</Button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="flex flex-col p-2 gap-y-3">
|
||||
{hosts.map((host, index) => (
|
||||
<React.Fragment key={`${folderName}-host-${host.id}-${host.name || host.ip}`}>
|
||||
<Host host={host}/>
|
||||
{index < hosts.length - 1 && (
|
||||
<div className="relative -mx-2">
|
||||
<Separator className="p-0.25 absolute inset-x-0"/>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
112
src/ui/Desktop/Navigation/Hosts/Host.tsx
Normal file
112
src/ui/Desktop/Navigation/Hosts/Host.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
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/Desktop/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' | 'degraded'>('degraded');
|
||||
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 intervalId: number | undefined;
|
||||
let cancelled = false;
|
||||
|
||||
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, 10000);
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Status status={serverStatus} className="!bg-transparent !p-0.75 flex-shrink-0">
|
||||
<StatusIndicator/>
|
||||
</Status>
|
||||
<p className="font-semibold flex-1 min-w-0 break-words text-sm">
|
||||
{host.name || host.ip}
|
||||
</p>
|
||||
<ButtonGroup className="flex-shrink-0">
|
||||
<Button variant="outline" className="!px-2 border-1 border-[#303032]" onClick={handleServerClick}>
|
||||
<Server/>
|
||||
</Button>
|
||||
{host.enableTerminal && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-[#303032]"
|
||||
onClick={handleTerminalClick}
|
||||
>
|
||||
<Terminal/>
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
{hasTags && (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
{tags.map((tag: string) => (
|
||||
<div key={tag} className="bg-[#18181b] border-1 border-[#303032] pl-2 pr-2 rounded-[10px]">
|
||||
<p className="text-sm">{tag}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
539
src/ui/Desktop/Navigation/LeftSidebar.tsx
Normal file
539
src/ui/Desktop/Navigation/LeftSidebar.tsx
Normal file
@@ -0,0 +1,539 @@
|
||||
import React, {useState} from 'react';
|
||||
import {
|
||||
Computer,
|
||||
Server,
|
||||
File,
|
||||
Hammer, ChevronUp, User2, HardDrive, Trash2, Users, Shield, Settings, Menu, ChevronRight
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
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.tsx";
|
||||
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/Desktop/Navigation/Hosts/FolderCard.tsx";
|
||||
import {getSSHHosts} from "@/ui/main-axios.ts";
|
||||
import {useTabs} from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import { 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 { t } = useTranslation();
|
||||
|
||||
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
|
||||
const [deletePassword, setDeletePassword] = React.useState("");
|
||||
const [deleteLoading, setDeleteLoading] = React.useState(false);
|
||||
const [deleteError, setDeleteError] = React.useState<string | null>(null);
|
||||
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(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'} 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'} as any);
|
||||
setCurrentTab(id);
|
||||
};
|
||||
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [hostsLoading, setHostsLoading] = useState(false);
|
||||
const [hostsError, setHostsError] = useState<string | null>(null);
|
||||
const prevHostsRef = React.useRef<SSHHost[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
|
||||
|
||||
|
||||
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(t('leftSidebar.failedToLoadHosts'));
|
||||
}
|
||||
}, []);
|
||||
|
||||
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<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];
|
||||
}, []);
|
||||
|
||||
const handleDeleteAccount = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setDeleteLoading(true);
|
||||
setDeleteError(null);
|
||||
|
||||
if (!deletePassword.trim()) {
|
||||
setDeleteError(t('leftSidebar.passwordRequired'));
|
||||
setDeleteLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const jwt = getCookie("jwt");
|
||||
try {
|
||||
await deleteAccount(deletePassword);
|
||||
|
||||
handleLogout();
|
||||
} catch (err: any) {
|
||||
setDeleteError(err?.response?.data?.error || t('leftSidebar.failedToDeleteAccount'));
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-svh">
|
||||
<SidebarProvider open={isSidebarOpen}>
|
||||
<Sidebar variant="floating" className="">
|
||||
<SidebarHeader>
|
||||
<SidebarGroupLabel className="text-lg font-bold text-white">
|
||||
Termix
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
|
||||
className="w-[28px] h-[28px] absolute right-5"
|
||||
title={t('common.toggleSidebar')}
|
||||
>
|
||||
<Menu className="h-4 w-4"/>
|
||||
</Button>
|
||||
</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-[#303032]" variant="outline"
|
||||
onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive}
|
||||
title={sshManagerTab ? t('interface.sshManagerAlreadyOpen') : 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-[#222225] 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-[#222225] border-[#303032] rounded-md"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hostsError && (
|
||||
<div className="px-1">
|
||||
<div
|
||||
className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
|
||||
{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) => (
|
||||
<FolderCard
|
||||
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
|
||||
folderName={folder}
|
||||
hosts={getSortedHosts(hostsByFolder[folder])}
|
||||
isFirst={idx === 0}
|
||||
isLast={idx === sortedFolders.length - 1}
|
||||
/>
|
||||
))}
|
||||
</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"
|
||||
style={{width: '100%'}}
|
||||
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-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onClick={() => {
|
||||
if (isSplitScreenActive) return;
|
||||
const profileTab = tabList.find((t: any) => t.type === 'profile');
|
||||
if (profileTab) {
|
||||
setCurrentTab(profileTab.id);
|
||||
return;
|
||||
}
|
||||
const id = addTab({type: 'profile', title: t('profile.title')} as any);
|
||||
setCurrentTab(id);
|
||||
}}>
|
||||
<span>{t('profile.title')}</span>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 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-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onClick={handleLogout}>
|
||||
<span>{t('common.logout')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="rounded px-2 py-1.5 hover:bg-white/15 hover:text-accent-foreground focus:bg-white/20 focus:text-accent-foreground cursor-pointer focus:outline-none"
|
||||
onClick={() => setDeleteAccountOpen(true)}
|
||||
>
|
||||
<span className="text-red-400">
|
||||
{t('leftSidebar.deleteAccount')}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
|
||||
</Sidebar>
|
||||
<SidebarInset>
|
||||
{children}
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
|
||||
{!isSidebarOpen && (
|
||||
<div
|
||||
onClick={() => 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">
|
||||
<ChevronRight size={10}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteAccountOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[999999] flex"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 999999,
|
||||
pointerEvents: 'auto',
|
||||
isolation: 'isolate',
|
||||
transform: 'translateZ(0)',
|
||||
willChange: 'z-index'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-[400px] h-full bg-[#18181b] border-r-2 border-[#303032] flex flex-col shadow-2xl"
|
||||
style={{
|
||||
backgroundColor: '#18181b',
|
||||
boxShadow: '4px 0 20px rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 9999999,
|
||||
position: 'relative',
|
||||
isolation: 'isolate',
|
||||
transform: 'translateZ(0)'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#303032]">
|
||||
<h2 className="text-lg font-semibold text-white">{t('leftSidebar.deleteAccount')}</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
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={t('leftSidebar.closeDeleteAccount')}
|
||||
>
|
||||
<span className="text-lg font-bold leading-none">×</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-300">
|
||||
{t('leftSidebar.deleteAccountWarning')}
|
||||
</div>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('common.warning')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t('leftSidebar.deleteAccountWarningDetails')}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{deleteError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>{t('common.error')}</AlertTitle>
|
||||
<AlertDescription>{deleteError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleDeleteAccount} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="delete-password">{t('leftSidebar.confirmPassword')}</Label>
|
||||
<Input
|
||||
id="delete-password"
|
||||
type="password"
|
||||
value={deletePassword}
|
||||
onChange={(e) => setDeletePassword(e.target.value)}
|
||||
placeholder={t('placeholders.confirmPassword')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={deleteLoading || !deletePassword.trim()}
|
||||
>
|
||||
{deleteLoading ? t('leftSidebar.deleting') : t('leftSidebar.deleteAccount')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteAccountOpen(false);
|
||||
setDeletePassword("");
|
||||
setDeleteError(null);
|
||||
}}
|
||||
>
|
||||
{t('leftSidebar.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setDeleteAccountOpen(false);
|
||||
setDeletePassword("");
|
||||
setDeleteError(null);
|
||||
}}
|
||||
style={{cursor: 'pointer'}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
142
src/ui/Desktop/Navigation/Tabs/Tab.tsx
Normal file
142
src/ui/Desktop/Navigation/Tabs/Tab.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React from "react";
|
||||
import {ButtonGroup} from "@/components/ui/button-group.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {useTranslation} from 'react-i18next';
|
||||
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 {
|
||||
const {t} = useTranslation();
|
||||
if (tabType === "home") {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
|
||||
onClick={onActivate}
|
||||
disabled={disableActivate}
|
||||
>
|
||||
<Home/>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (tabType === "terminal" || tabType === "server" || tabType === "file_manager") {
|
||||
const isServer = tabType === 'server';
|
||||
const isFileManager = tabType === 'file_manager';
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
|
||||
onClick={onActivate}
|
||||
disabled={disableActivate}
|
||||
>
|
||||
{isServer ? <ServerIcon className="mr-1 h-4 w-4"/> : isFileManager ?
|
||||
<FolderIcon className="mr-1 h-4 w-4"/> : <TerminalIcon className="mr-1 h-4 w-4"/>}
|
||||
{title || (isServer ? t('nav.serverStats') : isFileManager ? t('nav.fileManager') : t('nav.terminal'))}
|
||||
</Button>
|
||||
{canSplit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-[#303032]"
|
||||
onClick={onSplit}
|
||||
disabled={disableSplit}
|
||||
title={disableSplit ? t('nav.cannotSplitTab') : t('nav.splitScreen')}
|
||||
>
|
||||
<SeparatorVertical className="w-[28px] h-[28px]"/>
|
||||
</Button>
|
||||
)}
|
||||
{canClose && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-[#303032]"
|
||||
onClick={onClose}
|
||||
disabled={disableClose}
|
||||
>
|
||||
<X/>
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (tabType === "ssh_manager") {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
|
||||
onClick={onActivate}
|
||||
disabled={disableActivate}
|
||||
>
|
||||
{title || t('nav.sshManager')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-[#303032]"
|
||||
onClick={onClose}
|
||||
disabled={disableClose}
|
||||
>
|
||||
<X/>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (tabType === "admin") {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={`!px-2 border-1 border-[#303032] ${isActive ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30]' : ''}`}
|
||||
onClick={onActivate}
|
||||
disabled={disableActivate}
|
||||
>
|
||||
{title || t('nav.admin')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 border-1 border-[#303032]"
|
||||
onClick={onClose}
|
||||
disabled={disableClose}
|
||||
>
|
||||
<X/>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
135
src/ui/Desktop/Navigation/Tabs/TabContext.tsx
Normal file
135
src/ui/Desktop/Navigation/Tabs/TabContext.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
export interface Tab {
|
||||
id: number;
|
||||
type: 'home' | 'terminal' | 'ssh_manager' | 'server' | 'admin' | 'file_manager';
|
||||
title: string;
|
||||
hostConfig?: any;
|
||||
terminalRef?: React.RefObject<any>;
|
||||
}
|
||||
|
||||
interface TabContextType {
|
||||
tabs: Tab[];
|
||||
currentTab: number | null;
|
||||
allSplitScreenTab: number[];
|
||||
addTab: (tab: Omit<Tab, 'id'>) => number;
|
||||
removeTab: (tabId: number) => void;
|
||||
setCurrentTab: (tabId: number) => void;
|
||||
setSplitScreenTab: (tabId: number) => void;
|
||||
getTab: (tabId: number) => Tab | undefined;
|
||||
}
|
||||
|
||||
const TabContext = createContext<TabContextType | undefined>(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 {t} = useTranslation();
|
||||
const [tabs, setTabs] = useState<Tab[]>([
|
||||
{id: 1, type: 'home', title: t('nav.home')}
|
||||
]);
|
||||
const [currentTab, setCurrentTab] = useState<number>(1);
|
||||
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
|
||||
const nextTabId = useRef(2);
|
||||
|
||||
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string {
|
||||
const defaultTitle = tabType === 'server' ? t('nav.serverStats') : (tabType === 'file_manager' ? t('nav.fileManager') : t('nav.terminal'));
|
||||
const baseTitle = (desiredTitle || defaultTitle).trim();
|
||||
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
|
||||
const root = match ? match[1] : baseTitle;
|
||||
|
||||
const usedNumbers = new Set<number>();
|
||||
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<Tab, 'id'>): 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<any>() : 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 (
|
||||
<TabContext.Provider value={value}>
|
||||
{children}
|
||||
</TabContext.Provider>
|
||||
);
|
||||
}
|
||||
455
src/ui/Desktop/Navigation/TopNavbar.tsx
Normal file
455
src/ui/Desktop/Navigation/TopNavbar.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
import React, {useState} from "react";
|
||||
import {useSidebar} from "@/components/ui/sidebar.tsx";
|
||||
import {Button} from "@/components/ui/button.tsx";
|
||||
import {ChevronDown, ChevronUpIcon, Hammer} from "lucide-react";
|
||||
import {Tab} from "@/ui/Desktop/Navigation/Tabs/Tab.tsx";
|
||||
import {useTabs} from "@/ui/Desktop/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";
|
||||
import {useTranslation} from "react-i18next";
|
||||
|
||||
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 {t} = useTranslation();
|
||||
|
||||
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
||||
|
||||
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<HTMLInputElement>) => {
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div>
|
||||
<div
|
||||
className="fixed z-10 h-[50px] bg-[#18181b] border-2 border-[#303032] rounded-lg transition-all duration-200 ease-linear flex flex-row"
|
||||
style={{
|
||||
top: isTopbarOpen ? "0.5rem" : "-3rem",
|
||||
left: leftPosition,
|
||||
right: "17px",
|
||||
position: "fixed",
|
||||
transform: "none",
|
||||
margin: "0",
|
||||
padding: "0"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-full p-1 pr-2 border-r-2 border-[#303032] w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
|
||||
{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 (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
tabType={tab.type}
|
||||
title={tab.title}
|
||||
isActive={isActive}
|
||||
onActivate={() => 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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-2 flex-1 px-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[30px] h-[30px]"
|
||||
title={t('nav.tools')}
|
||||
onClick={() => setToolsSheetOpen(true)}
|
||||
>
|
||||
<Hammer className="h-4 w-4"/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsTopbarOpen(false)}
|
||||
className="w-[30px] h-[30px]"
|
||||
>
|
||||
<ChevronUpIcon/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isTopbarOpen && (
|
||||
<div
|
||||
onClick={() => 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">
|
||||
<ChevronDown size={10}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{toolsSheetOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-[999999] flex justify-end"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 999999,
|
||||
pointerEvents: 'auto',
|
||||
isolation: 'isolate',
|
||||
transform: 'translateZ(0)'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex-1"
|
||||
onClick={() => setToolsSheetOpen(false)}
|
||||
style={{cursor: 'pointer'}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="w-[400px] h-full bg-[#18181b] border-l-2 border-[#303032] flex flex-col shadow-2xl"
|
||||
style={{
|
||||
backgroundColor: '#18181b',
|
||||
boxShadow: '-4px 0 20px rgba(0, 0, 0, 0.5)',
|
||||
zIndex: 999999,
|
||||
position: 'relative',
|
||||
isolation: 'isolate',
|
||||
transform: 'translateZ(0)'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#303032]">
|
||||
<h2 className="text-lg font-semibold text-white">{t('sshTools.title')}</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setToolsSheetOpen(false)}
|
||||
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
|
||||
title={t('sshTools.closeTools')}
|
||||
>
|
||||
<span className="text-lg font-bold leading-none">×</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
<h1 className="font-semibold">
|
||||
{t('sshTools.keyRecording')}
|
||||
</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
{!isRecording ? (
|
||||
<Button
|
||||
onClick={handleStartRecording}
|
||||
className="flex-1"
|
||||
variant="outline"
|
||||
>
|
||||
{t('sshTools.startKeyRecording')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleStopRecording}
|
||||
className="flex-1"
|
||||
variant="destructive"
|
||||
>
|
||||
{t('sshTools.stopKeyRecording')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isRecording && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">{t('sshTools.selectTerminals')}</label>
|
||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2">
|
||||
{terminalTabs.map(tab => (
|
||||
<Button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
|
||||
selectedTabIds.includes(tab.id)
|
||||
? 'text-white bg-gray-700'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
onClick={() => handleTabToggle(tab.id)}
|
||||
>
|
||||
{tab.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">{t('sshTools.typeCommands')}</label>
|
||||
<Input
|
||||
id="ssh-tools-input"
|
||||
placeholder={t('placeholders.typeHere')}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyPress={handleKeyPress}
|
||||
className="font-mono mt-2"
|
||||
disabled={selectedTabIds.length === 0}
|
||||
readOnly
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('sshTools.commandsWillBeSent', { count: selectedTabIds.length })}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4"/>
|
||||
|
||||
<h1 className="font-semibold">
|
||||
{t('sshTools.settings')}
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enable-copy-paste"
|
||||
onCheckedChange={updateRightClickCopyPaste}
|
||||
defaultChecked={getCookie("rightClickCopyPaste") === "true"}
|
||||
/>
|
||||
<label
|
||||
htmlFor="enable-copy-paste"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-white"
|
||||
>
|
||||
{t('sshTools.enableRightClickCopyPaste')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Separator className="my-4"/>
|
||||
|
||||
<p className="pt-2 pb-2 text-sm text-gray-500">
|
||||
{t('sshTools.shareIdeas')}{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user