Remove old terminal
This commit is contained in:
@@ -1,440 +0,0 @@
|
|||||||
import React, {useState} from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CornerDownLeft,
|
|
||||||
Hammer, Pin, Menu
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Button
|
|
||||||
} from "@/components/ui/button.tsx"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuItem, SidebarProvider,
|
|
||||||
} from "@/components/ui/sidebar.tsx"
|
|
||||||
|
|
||||||
import {
|
|
||||||
Separator,
|
|
||||||
} from "@/components/ui/separator.tsx"
|
|
||||||
import {
|
|
||||||
Sheet,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetTitle,
|
|
||||||
SheetTrigger
|
|
||||||
} from "@/components/ui/sheet.tsx";
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from "@/components/ui/accordion.tsx";
|
|
||||||
import {ScrollArea} from "@/components/ui/scroll-area.tsx";
|
|
||||||
import {Input} from "@/components/ui/input.tsx";
|
|
||||||
import {getSSHHosts} from "@/apps/SSH/ssh-axios";
|
|
||||||
import {Checkbox} from "@/components/ui/checkbox.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;
|
|
||||||
enableConfigEditor: boolean;
|
|
||||||
defaultPath: string;
|
|
||||||
tunnelConnections: any[];
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SidebarProps {
|
|
||||||
onSelectView: (view: string) => void;
|
|
||||||
onHostConnect: (hostConfig: any) => void;
|
|
||||||
allTabs: { id: number; title: string; terminalRef: React.RefObject<any> }[];
|
|
||||||
runCommandOnTabs: (tabIds: number[], command: string) => void;
|
|
||||||
onCloseSidebar?: () => void;
|
|
||||||
onAddHostSubmit?: (data: any) => void;
|
|
||||||
open?: boolean;
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TerminalSidebar({
|
|
||||||
onSelectView,
|
|
||||||
onHostConnect,
|
|
||||||
allTabs,
|
|
||||||
runCommandOnTabs,
|
|
||||||
onCloseSidebar,
|
|
||||||
open,
|
|
||||||
onOpenChange
|
|
||||||
}: SidebarProps): React.ReactElement {
|
|
||||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
|
||||||
const [hostsLoading, setHostsLoading] = useState(false);
|
|
||||||
const [hostsError, setHostsError] = useState<string | null>(null);
|
|
||||||
const prevHostsRef = React.useRef<SSHHost[]>([]);
|
|
||||||
|
|
||||||
const fetchHosts = React.useCallback(async () => {
|
|
||||||
setHostsLoading(true);
|
|
||||||
setHostsError(null);
|
|
||||||
try {
|
|
||||||
const newHosts = await getSSHHosts();
|
|
||||||
const terminalHosts = newHosts.filter(host => host.enableTerminal);
|
|
||||||
|
|
||||||
const prevHosts = prevHostsRef.current;
|
|
||||||
const isSame =
|
|
||||||
terminalHosts.length === prevHosts.length &&
|
|
||||||
terminalHosts.every((h: SSHHost, i: number) => {
|
|
||||||
const prev = prevHosts[i];
|
|
||||||
if (!prev) return false;
|
|
||||||
return (
|
|
||||||
h.id === prev.id &&
|
|
||||||
h.name === prev.name &&
|
|
||||||
h.folder === prev.folder &&
|
|
||||||
h.ip === prev.ip &&
|
|
||||||
h.port === prev.port &&
|
|
||||||
h.username === prev.username &&
|
|
||||||
h.password === prev.password &&
|
|
||||||
h.authType === prev.authType &&
|
|
||||||
h.key === prev.key &&
|
|
||||||
h.pin === prev.pin &&
|
|
||||||
JSON.stringify(h.tags) === JSON.stringify(prev.tags)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
if (!isSame) {
|
|
||||||
setHosts(terminalHosts);
|
|
||||||
prevHostsRef.current = terminalHosts;
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
setHostsError('Failed to load hosts');
|
|
||||||
} finally {
|
|
||||||
setHostsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
fetchHosts();
|
|
||||||
const interval = setInterval(fetchHosts, 10000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [fetchHosts]);
|
|
||||||
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
|
||||||
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 : 'No Folder';
|
|
||||||
if (!map[folder]) map[folder] = [];
|
|
||||||
map[folder].push(h);
|
|
||||||
});
|
|
||||||
return map;
|
|
||||||
}, [filteredHosts]);
|
|
||||||
|
|
||||||
const sortedFolders = React.useMemo(() => {
|
|
||||||
const folders = Object.keys(hostsByFolder);
|
|
||||||
folders.sort((a, b) => {
|
|
||||||
if (a === 'No Folder') return -1;
|
|
||||||
if (b === 'No Folder') return 1;
|
|
||||||
return a.localeCompare(b);
|
|
||||||
});
|
|
||||||
return folders;
|
|
||||||
}, [hostsByFolder]);
|
|
||||||
|
|
||||||
const getSortedHosts = (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 [toolsSheetOpen, setToolsSheetOpen] = useState(false);
|
|
||||||
const [toolsCommand, setToolsCommand] = useState("");
|
|
||||||
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
|
||||||
|
|
||||||
const handleTabToggle = (tabId: number) => {
|
|
||||||
setSelectedTabIds(prev => prev.includes(tabId) ? prev.filter(id => id !== tabId) : [...prev, tabId]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRunCommand = () => {
|
|
||||||
if (selectedTabIds.length && toolsCommand.trim()) {
|
|
||||||
let cmd = toolsCommand;
|
|
||||||
if (!cmd.endsWith("\n")) cmd += "\n";
|
|
||||||
runCommandOnTabs(selectedTabIds, cmd);
|
|
||||||
setToolsCommand("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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) => {
|
|
||||||
document.cookie = `rightClickCopyPaste=${checked}; expires=2147483647; path=/`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarProvider open={open} onOpenChange={onOpenChange}>
|
|
||||||
<Sidebar className="h-full flex flex-col overflow-hidden">
|
|
||||||
<SidebarContent className="flex flex-col flex-grow h-full overflow-hidden">
|
|
||||||
<SidebarGroup className="flex flex-col flex-grow h-full overflow-hidden">
|
|
||||||
<SidebarGroupLabel
|
|
||||||
className="text-lg font-bold text-white flex items-center justify-between gap-2 w-full">
|
|
||||||
<span>Termix / Terminal</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onCloseSidebar?.()}
|
|
||||||
title="Hide sidebar"
|
|
||||||
style={{
|
|
||||||
height: 28,
|
|
||||||
width: 28,
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
background: 'hsl(240 5% 9%)',
|
|
||||||
color: 'hsl(240 5% 64.9%)',
|
|
||||||
border: '1px solid hsl(240 3.7% 15.9%)',
|
|
||||||
borderRadius: 6,
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Menu className="h-4 w-4"/>
|
|
||||||
</button>
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
<Separator className="p-0.25 mt-1 mb-1"/>
|
|
||||||
<SidebarGroupContent className="flex flex-col flex-grow h-full overflow-hidden">
|
|
||||||
<SidebarMenu className="flex flex-col flex-grow h-full overflow-hidden">
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<SidebarMenuItem key="Main" className="flex flex-col flex-grow overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="w-full flex-grow rounded-md bg-[#09090b] border border-[#434345] overflow-hidden p-0 m-0 relative flex flex-col min-h-0">
|
|
||||||
<div className="w-full px-2 pt-2 pb-2 bg-[#09090b] z-10">
|
|
||||||
<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-background border border-border rounded"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{display: 'flex', justifyContent: 'center'}}>
|
|
||||||
<Separator className="w-full h-px bg-[#434345] my-2"
|
|
||||||
style={{maxWidth: 213, margin: '0 auto'}}/>
|
|
||||||
</div>
|
|
||||||
{hostsError && (
|
|
||||||
<div className="px-2 py-1 mt-2">
|
|
||||||
<div
|
|
||||||
className="text-xs text-red-500 bg-red-500/10 rounded px-2 py-1 border border-red-500/20">{hostsError}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
<ScrollArea className="w-full h-full">
|
|
||||||
<Accordion key={`host-accordion-${sortedFolders.length}`}
|
|
||||||
type="multiple" className="w-full"
|
|
||||||
defaultValue={sortedFolders.length > 0 ? sortedFolders : undefined}>
|
|
||||||
{sortedFolders.map((folder, idx) => (
|
|
||||||
<React.Fragment key={folder}>
|
|
||||||
<AccordionItem value={folder}
|
|
||||||
className={idx === 0 ? "mt-0 !border-b-transparent" : "mt-2 !border-b-transparent"}>
|
|
||||||
<AccordionTrigger
|
|
||||||
className="text-base font-semibold rounded-t-none px-3 py-2"
|
|
||||||
style={{marginTop: idx === 0 ? 0 : undefined}}>{folder}</AccordionTrigger>
|
|
||||||
<AccordionContent
|
|
||||||
className="flex flex-col gap-1 px-3 pb-2 pt-1">
|
|
||||||
{getSortedHosts(hostsByFolder[folder]).map(host => (
|
|
||||||
<div key={host.id}
|
|
||||||
className="w-full overflow-hidden">
|
|
||||||
<HostMenuItem
|
|
||||||
host={host}
|
|
||||||
onHostConnect={onHostConnect}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</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>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
<div className="bg-sidebar">
|
|
||||||
<Sheet open={toolsSheetOpen} onOpenChange={setToolsSheetOpen}>
|
|
||||||
<SheetTrigger asChild>
|
|
||||||
<Button
|
|
||||||
className="w-full h-8 mt-2"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setToolsSheetOpen(true)}
|
|
||||||
>
|
|
||||||
<Hammer className="mr-2 h-4 w-4"/>
|
|
||||||
Tools
|
|
||||||
</Button>
|
|
||||||
</SheetTrigger>
|
|
||||||
<SheetContent side="left"
|
|
||||||
className="w-[256px] fixed top-0 left-0 h-full z-[100] flex flex-col">
|
|
||||||
<SheetHeader className="pb-0.5">
|
|
||||||
<SheetTitle>Tools</SheetTitle>
|
|
||||||
</SheetHeader>
|
|
||||||
<div className="flex-1 overflow-y-auto px-2 pt-2">
|
|
||||||
<Accordion type="single" collapsible defaultValue="multiwindow">
|
|
||||||
<AccordionItem value="multiwindow">
|
|
||||||
<AccordionTrigger className="text-base font-semibold">Run multiwindow
|
|
||||||
commands</AccordionTrigger>
|
|
||||||
<AccordionContent>
|
|
||||||
<textarea
|
|
||||||
className="w-full min-h-[120px] max-h-48 rounded-md border border-input text-foreground p-2 text-sm font-mono resize-vertical focus:outline-none focus:ring-0"
|
|
||||||
placeholder="Enter command(s) to run on selected tabs..."
|
|
||||||
value={toolsCommand}
|
|
||||||
onChange={e => setToolsCommand(e.target.value)}
|
|
||||||
style={{
|
|
||||||
fontFamily: 'monospace',
|
|
||||||
marginBottom: 8,
|
|
||||||
background: '#141416'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-wrap gap-2 mb-2">
|
|
||||||
{allTabs.map(tab => (
|
|
||||||
<Button
|
|
||||||
key={tab.id}
|
|
||||||
type="button"
|
|
||||||
variant={selectedTabIds.includes(tab.id) ? "secondary" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className="rounded-full px-3 py-1 text-xs flex items-center gap-1"
|
|
||||||
onClick={() => handleTabToggle(tab.id)}
|
|
||||||
>
|
|
||||||
{tab.title}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleRunCommand}
|
|
||||||
disabled={!toolsCommand.trim() || !selectedTabIds.length}
|
|
||||||
>
|
|
||||||
Run Command
|
|
||||||
</Button>
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<Separator className="p-0.25"/>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 mt-5">
|
|
||||||
<Checkbox id="enable-copy-paste" onCheckedChange={updateRightClickCopyPaste}
|
|
||||||
defaultChecked={getCookie("rightClickCopyPaste") === "true"}/>
|
|
||||||
<label
|
|
||||||
htmlFor="enable-paste"
|
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
>
|
|
||||||
Enable right‑click copy/paste
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
</div>
|
|
||||||
</SidebarGroup>
|
|
||||||
</SidebarContent>
|
|
||||||
</Sidebar>
|
|
||||||
</SidebarProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const HostMenuItem = React.memo(function HostMenuItem({host, onHostConnect}: {
|
|
||||||
host: SSHHost;
|
|
||||||
onHostConnect: (hostConfig: any) => void
|
|
||||||
}) {
|
|
||||||
const tags = Array.isArray(host.tags) ? host.tags : [];
|
|
||||||
const hasTags = tags.length > 0;
|
|
||||||
return (
|
|
||||||
<div className="relative group flex flex-col mb-1 w-full overflow-hidden">
|
|
||||||
<div className={`flex flex-col w-full rounded overflow-hidden border border-[#434345] bg-[#18181b] h-full`}>
|
|
||||||
<div className="flex w-full h-10">
|
|
||||||
<div
|
|
||||||
className="flex items-center h-full px-2 w-full hover:bg-muted transition-colors cursor-pointer"
|
|
||||||
onClick={() => onHostConnect(host)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center w-full">
|
|
||||||
{host.pin &&
|
|
||||||
<Pin className="h-4.5 mr-1 w-4.5 mt-0.5 text-yellow-500 flex-shrink-0"/>
|
|
||||||
}
|
|
||||||
<span className="font-medium truncate">{host.name || host.ip}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{hasTags && (
|
|
||||||
<div
|
|
||||||
className="border-t border-border bg-[#18181b] flex items-center gap-1 px-2 py-2 overflow-x-auto overflow-y-hidden scrollbar-thin scrollbar-thumb-muted-foreground/30 scrollbar-track-transparent"
|
|
||||||
style={{height: 30}}>
|
|
||||||
{tags.map((tag: string) => (
|
|
||||||
<span key={tag}
|
|
||||||
className="bg-muted-foreground/10 text-xs rounded-full px-2 py-0.5 text-muted-foreground whitespace-nowrap border border-border flex-shrink-0 hover:bg-muted transition-colors">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user