Format code

This commit is contained in:
LukeGus
2025-08-18 00:13:21 -05:00
parent fa64e98ef9
commit c1d06028c3
31 changed files with 1791 additions and 1780 deletions

View File

@@ -1,21 +1,21 @@
import React, { useEffect, useRef, useState } from "react";
import React, {useEffect, useRef, useState} from "react";
import {TerminalComponent} from "@/ui/apps/Terminal/TerminalComponent.tsx";
import {Server as ServerView} from "@/ui/apps/Server/Server.tsx";
import {FileManager} from "@/ui/apps/File Manager/FileManager.tsx";
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
import {ResizablePanelGroup, ResizablePanel, ResizableHandle} from '@/components/ui/resizable.tsx';
import * as ResizablePrimitive from "react-resizable-panels";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import {useSidebar} from "@/components/ui/sidebar.tsx";
import {LucideRefreshCcw, LucideRefreshCw, RefreshCcw, RefreshCcwDot} from "lucide-react";
import { Button } from "@/components/ui/button.tsx";
import {Button} from "@/components/ui/button.tsx";
interface TerminalViewProps {
isTopbarOpen?: boolean;
}
export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.ReactElement {
export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactElement {
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' || tab.type === 'file_manager');
@@ -51,7 +51,6 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
});
};
// Coalesce layout → measure → fit callbacks
const layoutScheduleRef = useRef<number | null>(null);
const scheduleMeasureAndFit = () => {
if (layoutScheduleRef.current) cancelAnimationFrame(layoutScheduleRef.current);
@@ -63,7 +62,6 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
});
};
// Hide terminals until layout → rects → fit applied to prevent first-frame wrapping
const hideThenFit = () => {
setReady(false);
requestAnimationFrame(() => {
@@ -77,13 +75,10 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
useEffect(() => {
hideThenFit();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentTab, terminalTabs.length, allSplitScreenTab.join(',')]);
// When split layout toggles on/off, topbar toggles, or sidebar state changes → measure+fit
useEffect(() => {
scheduleMeasureAndFit();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]);
useEffect(() => {
@@ -93,14 +88,15 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
}) : null;
if (containerRef.current && roContainer) roContainer.observe(containerRef.current);
return () => roContainer?.disconnect();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const onWinResize = () => { updatePanelRects(); fitActiveAndNotify(); };
const onWinResize = () => {
updatePanelRects();
fitActiveAndNotify();
};
window.addEventListener('resize', onWinResize);
return () => window.removeEventListener('resize', onWinResize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const HEADER_H = 28;
@@ -109,24 +105,34 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
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[];
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
if (allSplitScreenTab.length === 0 && mainTab) {
styles[mainTab.id] = { position:'absolute', top:2, left:2, right:2, bottom:2, zIndex: 20, display: 'block', pointerEvents:'auto', opacity: ready ? 1 : 0 };
styles[mainTab.id] = {
position: 'absolute',
top: 2,
left: 2,
right: 2,
bottom: 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',
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',
pointerEvents: 'auto',
opacity: ready ? 1 : 0,
};
}
@@ -134,22 +140,21 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
}
return (
<div style={{position:'absolute', inset:0, zIndex:1}}>
{terminalTabs.map((t:any) => {
<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 isVisible = hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
// Visible style from computed positions; otherwise keep mounted but hidden and non-interactive
const finalStyle: React.CSSProperties = hasStyle
? {...styles[t.id], overflow:'hidden'}
? {...styles[t.id], overflow: 'hidden'}
: {
position:'absolute', inset:0, visibility:'hidden', pointerEvents:'none', zIndex:0,
} as React.CSSProperties;
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" style={{background:'#18181b'}}>
<div className="absolute inset-0 rounded-md" style={{background: '#18181b'}}>
{t.type === 'terminal' ? (
<TerminalComponent
ref={t.terminalRef}
@@ -157,7 +162,7 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
isVisible={effectiveVisible}
title={t.title}
showTitle={false}
splitScreen={allSplitScreenTab.length>0}
splitScreen={allSplitScreenTab.length > 0}
/>
) : t.type === 'server' ? (
<ServerView
@@ -181,7 +186,7 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
);
};
const ResetButton = ({ onClick }: { onClick: () => void }) => (
const ResetButton = ({onClick}: { onClick: () => void }) => (
<Button
type="button"
variant="ghost"
@@ -189,7 +194,7 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
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" />
<RefreshCcw className="h-4 w-4"/>
</Button>
);
@@ -201,28 +206,75 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
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[];
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;
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[];
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 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'}}>
<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} />
<ResetButton onClick={handleReset}/>
</div>
</div>
</ResizablePanel>
@@ -231,32 +283,101 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
);
}
if (layoutTabs.length === 3) {
const [a,b,c] = layoutTabs as any[];
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 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'}}>
<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} />
<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>
<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>
@@ -264,40 +385,133 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
);
}
if (layoutTabs.length === 4) {
const [a,b,c,d] = layoutTabs as any[];
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 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'}}>
<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} />
<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>
<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>
<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>
@@ -318,8 +532,8 @@ export function AppView({ isTopbarOpen = true }: TerminalViewProps): React.React
ref={containerRef}
className="border-2 border-[#303032] rounded-lg overflow-hidden overflow-x-hidden"
style={{
position:'relative',
background:'#18181b',
position: 'relative',
background: '#18181b',
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react";
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";
@@ -35,7 +35,7 @@ interface FolderCardProps {
isLast: boolean;
}
export function FolderCard({ folderName, hosts, isFirst, isLast }: FolderCardProps): React.ReactElement {
export function FolderCard({folderName, hosts, isFirst, isLast}: FolderCardProps): React.ReactElement {
const [isExpanded, setIsExpanded] = useState(true);
const toggleExpanded = () => {
@@ -43,7 +43,8 @@ export function FolderCard({ folderName, hosts, isFirst, isLast }: FolderCardPro
};
return (
<div className="bg-[#0e0e10] border-2 border-[#303032] rounded-lg overflow-hidden" style={{padding: '0', margin: '0'}}>
<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">
@@ -65,10 +66,10 @@ export function FolderCard({ folderName, hosts, isFirst, isLast }: FolderCardPro
<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} />
<Host host={host}/>
{index < hosts.length - 1 && (
<div className="relative -mx-2">
<Separator className="p-0.25 absolute inset-x-0" />
<Separator className="p-0.25 absolute inset-x-0"/>
</div>
)}
</React.Fragment>

View File

@@ -1,10 +1,10 @@
import React, { useEffect, useState } from "react";
import React, {useEffect, useState} from "react";
import {Status, StatusIndicator} from "@/components/ui/shadcn-io/status";
import {Button} from "@/components/ui/button.tsx";
import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Server, Terminal} from "lucide-react";
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
import { getServerStatusById } from "@/ui/main-axios.ts";
import {getServerStatusById} from "@/ui/main-axios.ts";
interface SSHHost {
id: number;
@@ -33,12 +33,12 @@ interface HostProps {
host: SSHHost;
}
export function Host({ host }: HostProps): React.ReactElement {
const { addTab } = useTabs();
export function Host({host}: HostProps): React.ReactElement {
const {addTab} = useTabs();
const [serverStatus, setServerStatus] = useState<'online' | 'offline'>('offline');
const tags = Array.isArray(host.tags) ? host.tags : [];
const hasTags = tags.length > 0;
const title = host.name?.trim() ? host.name : `${host.username}@${host.ip}:${host.port}`;
useEffect(() => {
@@ -66,13 +66,13 @@ export function Host({ host }: HostProps): React.ReactElement {
}, [host.id]);
const handleTerminalClick = () => {
addTab({ type: 'terminal', title, hostConfig: host });
addTab({type: 'terminal', title, hostConfig: host});
};
const handleServerClick = () => {
addTab({ type: 'server', title, hostConfig: host });
addTab({type: 'server', title, hostConfig: host});
};
return (
<div>
<div className="flex items-center gap-2">
@@ -87,8 +87,8 @@ export function Host({ host }: HostProps): React.ReactElement {
<Server/>
</Button>
{host.enableTerminal && (
<Button
variant="outline"
<Button
variant="outline"
className="!px-2 border-1 border-[#303032]"
onClick={handleTerminalClick}
>

View File

@@ -49,7 +49,7 @@ import axios from "axios";
import {Card} from "@/components/ui/card.tsx";
import {FolderCard} from "@/ui/Navigation/Hosts/FolderCard.tsx";
import {getSSHHosts} from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/Navigation/Tabs/TabContext.tsx";
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
interface SSHHost {
id: number;
@@ -102,29 +102,14 @@ const API = axios.create({
});
export function LeftSidebar({
onSelectView,
getView,
disabled,
isAdmin,
username,
children,
}: SidebarProps): React.ReactElement {
onSelectView,
getView,
disabled,
isAdmin,
username,
children,
}: SidebarProps): React.ReactElement {
const [adminSheetOpen, setAdminSheetOpen] = React.useState(false);
const [allowRegistration, setAllowRegistration] = React.useState(true);
const [regLoading, setRegLoading] = React.useState(false);
const [oidcConfig, setOidcConfig] = React.useState({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
});
const [oidcLoading, setOidcLoading] = React.useState(false);
const [oidcError, setOidcError] = React.useState<string | null>(null);
const [oidcSuccess, setOidcSuccess] = React.useState<string | null>(null);
const [deleteAccountOpen, setDeleteAccountOpen] = React.useState(false);
const [deletePassword, setDeletePassword] = React.useState("");
@@ -138,21 +123,21 @@ export function LeftSidebar({
is_admin: boolean;
is_oidc: boolean;
}>>([]);
const [usersLoading, setUsersLoading] = React.useState(false);
const [newAdminUsername, setNewAdminUsername] = React.useState("");
const [usersLoading, setUsersLoading] = React.useState(false);
const [makeAdminLoading, setMakeAdminLoading] = React.useState(false);
const [makeAdminError, setMakeAdminError] = React.useState<string | null>(null);
const [makeAdminSuccess, setMakeAdminSuccess] = React.useState<string | null>(null);
const [oidcConfig, setOidcConfig] = React.useState<any>(null);
const [isSidebarOpen, setIsSidebarOpen] = useState<boolean>(true);
// Tabs context for opening SSH Manager tab
const { tabs: tabList, addTab, setCurrentTab, allSplitScreenTab } = useTabs() as any;
const {tabs: tabList, addTab, setCurrentTab, allSplitScreenTab} = useTabs() as any;
const isSplitScreenActive = Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0;
const sshManagerTab = tabList.find((t) => t.type === 'ssh_manager');
const openSshManagerTab = () => {
if (sshManagerTab || isSplitScreenActive) return;
const id = addTab({ type: 'ssh_manager', title: 'SSH Manager' } as any);
const id = addTab({type: 'ssh_manager', title: 'SSH Manager'} as any);
setCurrentTab(id);
};
const adminTab = tabList.find((t) => t.type === 'admin');
@@ -162,11 +147,10 @@ export function LeftSidebar({
setCurrentTab(adminTab.id);
return;
}
const id = addTab({ type: 'admin', title: 'Admin' } as any);
const id = addTab({type: 'admin', title: 'Admin'} as any);
setCurrentTab(id);
};
// SSH Hosts state management
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [hostsLoading, setHostsLoading] = useState(false);
const [hostsError, setHostsError] = useState<string | null>(null);
@@ -202,23 +186,16 @@ export function LeftSidebar({
}
}, [isAdmin]);
// SSH Hosts data fetching
const fetchHosts = React.useCallback(async () => {
try {
const newHosts = await getSSHHosts();
// Show all hosts in sidebar, regardless of terminal setting
// Terminal visibility is handled in the UI components
const prevHosts = prevHostsRef.current;
// Create a stable map of existing hosts by ID for comparison
const existingHostsMap = new Map(prevHosts.map(h => [h.id, h]));
const newHostsMap = new Map(newHosts.map(h => [h.id, h]));
// Check if there are any meaningful changes
let hasChanges = false;
// Check for new hosts, removed hosts, or changed hosts
if (newHosts.length !== prevHosts.length) {
hasChanges = true;
} else {
@@ -228,8 +205,7 @@ export function LeftSidebar({
hasChanges = true;
break;
}
// Only check fields that affect the display
if (
newHost.name !== existingHost.name ||
newHost.folder !== existingHost.folder ||
@@ -245,9 +221,8 @@ export function LeftSidebar({
}
}
}
if (hasChanges) {
// Use a small delay to batch updates and reduce jittering
setTimeout(() => {
setHosts(newHosts);
prevHostsRef.current = newHosts;
@@ -264,7 +239,6 @@ export function LeftSidebar({
return () => clearInterval(interval);
}, [fetchHosts]);
// Immediate refresh when SSH hosts are changed elsewhere in the app
React.useEffect(() => {
const handleHostsChanged = () => {
fetchHosts();
@@ -273,13 +247,11 @@ export function LeftSidebar({
return () => window.removeEventListener('ssh-hosts:changed', handleHostsChanged as EventListener);
}, [fetchHosts]);
// Search debouncing
React.useEffect(() => {
const handler = setTimeout(() => setDebouncedSearch(search), 200);
return () => clearTimeout(handler);
}, [search]);
// Filter and organize hosts with stable references
const filteredHosts = React.useMemo(() => {
if (!debouncedSearch.trim()) return hosts;
const q = debouncedSearch.trim().toLowerCase();
@@ -323,68 +295,6 @@ export function LeftSidebar({
return [...pinned, ...rest];
}, []);
const handleToggle = async (checked: boolean) => {
if (!isAdmin) {
return;
}
setRegLoading(true);
const jwt = getCookie("jwt");
try {
await API.patch(
"/registration-allowed",
{allowed: checked},
{headers: {Authorization: `Bearer ${jwt}`}}
);
setAllowRegistration(checked);
} catch (e) {
} finally {
setRegLoading(false);
}
};
const handleOIDCConfigSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!isAdmin) {
return;
}
setOidcLoading(true);
setOidcError(null);
setOidcSuccess(null);
const requiredFields = ['client_id', 'client_secret', 'issuer_url', 'authorization_url', 'token_url'];
const missingFields = requiredFields.filter(field => !oidcConfig[field as keyof typeof oidcConfig]);
if (missingFields.length > 0) {
setOidcError(`Missing required fields: ${missingFields.join(', ')}`);
setOidcLoading(false);
return;
}
const jwt = getCookie("jwt");
try {
await API.post(
"/oidc-config",
oidcConfig,
{headers: {Authorization: `Bearer ${jwt}`}}
);
setOidcSuccess("OIDC configuration updated successfully!");
} catch (err: any) {
setOidcError(err?.response?.data?.error || "Failed to update OIDC configuration");
} finally {
setOidcLoading(false);
}
};
const handleOIDCConfigChange = (field: string, value: string) => {
setOidcConfig(prev => ({
...prev,
[field]: value
}));
};
const handleDeleteAccount = async (e: React.FormEvent) => {
e.preventDefault();
setDeleteLoading(true);
@@ -416,7 +326,7 @@ export function LeftSidebar({
if (!jwt || !isAdmin) {
return;
}
setUsersLoading(true);
try {
const response = await API.get("/list", {
@@ -427,7 +337,6 @@ export function LeftSidebar({
const adminUsers = response.data.users.filter((user: any) => user.is_admin);
setAdminCount(adminUsers.length);
} catch (err: any) {
console.error("Failed to fetch users:", err);
} finally {
setUsersLoading(false);
}
@@ -439,7 +348,7 @@ export function LeftSidebar({
if (!jwt || !isAdmin) {
return;
}
try {
const response = await API.get("/list", {
headers: {Authorization: `Bearer ${jwt}`}
@@ -447,7 +356,6 @@ export function LeftSidebar({
const adminUsers = response.data.users.filter((user: any) => user.is_admin);
setAdminCount(adminUsers.length);
} catch (err: any) {
console.error("Failed to fetch admin count:", err);
}
};
@@ -494,7 +402,6 @@ export function LeftSidebar({
);
fetchUsers();
} catch (err: any) {
console.error("Failed to remove admin status:", err);
}
};
@@ -513,7 +420,6 @@ export function LeftSidebar({
});
fetchUsers();
} catch (err: any) {
console.error("Failed to delete user:", err);
}
};
@@ -536,14 +442,15 @@ export function LeftSidebar({
<Separator className="p-0.25"/>
<SidebarContent>
<SidebarGroup className="!m-0 !p-0 !-mb-2">
<Button className="m-2 flex flex-row font-semibold" variant="outline" onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive} title={sshManagerTab ? 'SSH Manager already open' : isSplitScreenActive ? 'Disabled during split screen' : undefined}>
<Button className="m-2 flex flex-row font-semibold" variant="outline"
onClick={openSshManagerTab} disabled={!!sshManagerTab || isSplitScreenActive}
title={sshManagerTab ? 'SSH Manager already open' : isSplitScreenActive ? 'Disabled during split screen' : undefined}>
<HardDrive strokeWidth="2.5"/>
Host Manager
</Button>
</SidebarGroup>
<Separator className="p-0.25"/>
<SidebarGroup className="flex flex-col gap-y-2 !-mt-2">
{/* Search Input */}
<div className="bg-[#131316] rounded-lg">
<Input
value={search}
@@ -553,17 +460,16 @@ export function LeftSidebar({
autoComplete="off"
/>
</div>
{/* Error Display */}
{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">
<div
className="text-xs text-red-500 bg-red-500/10 rounded-lg px-2 py-1 border w-full">
{hostsError}
</div>
</div>
)}
{/* Loading State */}
{hostsLoading && (
<div className="px-4 pb-2">
<div className="text-xs text-muted-foreground text-center">
@@ -572,7 +478,6 @@ export function LeftSidebar({
</div>
)}
{/* Hosts by Folder */}
{sortedFolders.map((folder, idx) => (
<FolderCard
key={`folder-${folder}-${hostsByFolder[folder]?.length || 0}`}
@@ -608,7 +513,7 @@ export function LeftSidebar({
{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"
onSelect={() => {
onClick={() => {
if (isAdmin) openAdminTab();
}}>
<span>Admin Settings</span>
@@ -616,12 +521,12 @@ export function LeftSidebar({
)}
<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"
onSelect={handleLogout}>
onClick={handleLogout}>
<span>Sign out</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"
onSelect={() => setDeleteAccountOpen(true)}
onClick={() => setDeleteAccountOpen(true)}
disabled={isAdmin && adminCount <= 1}
>
<span
@@ -635,383 +540,74 @@ export function LeftSidebar({
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>
{/* Admin Settings Sheet */}
{isAdmin && (
<Sheet modal={false} open={adminSheetOpen && isAdmin} onOpenChange={(open) => {
if (open && !isAdmin) return;
setAdminSheetOpen(open);
}}>
<SheetContent side="left" className="w-[700px] max-h-screen overflow-y-auto">
<SheetHeader className="px-6 pb-4">
<SheetTitle>Admin Settings</SheetTitle>
</SheetHeader>
<div className="px-6">
<Tabs defaultValue="registration" className="w-full">
<TabsList className="grid w-full grid-cols-4 mb-6">
<TabsTrigger value="registration" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
Reg
</TabsTrigger>
<TabsTrigger value="oidc" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
OIDC
</TabsTrigger>
<TabsTrigger value="users" className="flex items-center gap-2">
<Users className="h-4 w-4"/>
Users
</TabsTrigger>
<TabsTrigger value="admins" className="flex items-center gap-2">
<Shield className="h-4 w-4"/>
Admins
</TabsTrigger>
</TabsList>
{/* Registration Settings Tab */}
<TabsContent value="registration" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">User Registration</h3>
<label className="flex items-center gap-2">
<Checkbox checked={allowRegistration} onCheckedChange={handleToggle}
disabled={regLoading}/>
Allow new account registration
</label>
</div>
</TabsContent>
</Sidebar>
<SidebarInset>
{children}
</SidebarInset>
</SidebarProvider>
{/* OIDC Configuration Tab */}
<TabsContent value="oidc" className="space-y-6">
<div className="space-y-4">
<h3 className="text-lg font-semibold">External Authentication
(OIDC)</h3>
<p className="text-sm text-muted-foreground">
Configure external identity provider for OIDC/OAuth2 authentication.
Users will see an "External" login option once configured.
</p>
{!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>
)}
{oidcError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{oidcError}</AlertDescription>
</Alert>
)}
{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">Delete Account</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="Close Delete Account"
>
<span className="text-lg font-bold leading-none">×</span>
</Button>
</div>
<form onSubmit={handleOIDCConfigSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="client_id">Client ID</Label>
<Input
id="client_id"
value={oidcConfig.client_id}
onChange={(e) => handleOIDCConfigChange('client_id', e.target.value)}
placeholder="your-client-id"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="client_secret">Client Secret</Label>
<Input
id="client_secret"
type="password"
value={oidcConfig.client_secret}
onChange={(e) => handleOIDCConfigChange('client_secret', e.target.value)}
placeholder="your-client-secret"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="authorization_url">Authorization URL</Label>
<Input
id="authorization_url"
value={oidcConfig.authorization_url}
onChange={(e) => handleOIDCConfigChange('authorization_url', e.target.value)}
placeholder="https://your-provider.com/application/o/authorize/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="issuer_url">Issuer URL</Label>
<Input
id="issuer_url"
value={oidcConfig.issuer_url}
onChange={(e) => handleOIDCConfigChange('issuer_url', e.target.value)}
placeholder="https://your-provider.com/application/o/termix/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="token_url">Token URL</Label>
<Input
id="token_url"
value={oidcConfig.token_url}
onChange={(e) => handleOIDCConfigChange('token_url', e.target.value)}
placeholder="https://your-provider.com/application/o/token/"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="identifier_path">User Identifier Path</Label>
<Input
id="identifier_path"
value={oidcConfig.identifier_path}
onChange={(e) => handleOIDCConfigChange('identifier_path', e.target.value)}
placeholder="sub"
required
/>
<p className="text-xs text-muted-foreground">
JSON path to extract user ID from JWT (e.g., "sub", "email",
"preferred_username")
</p>
</div>
<div className="space-y-2">
<Label htmlFor="name_path">Display Name Path</Label>
<Input
id="name_path"
value={oidcConfig.name_path}
onChange={(e) => handleOIDCConfigChange('name_path', e.target.value)}
placeholder="name"
required
/>
<p className="text-xs text-muted-foreground">
JSON path to extract display name from JWT (e.g., "name",
"preferred_username")
</p>
</div>
<div className="space-y-2">
<Label htmlFor="scopes">Scopes</Label>
<Input
id="scopes"
value={oidcConfig.scopes}
onChange={(e) => handleOIDCConfigChange('scopes', (e.target as HTMLInputElement).value)}
placeholder="openid email profile"
required
/>
<p className="text-xs text-muted-foreground">
Space-separated list of OAuth2 scopes to request
</p>
</div>
<div className="flex gap-2 pt-2">
<Button
type="submit"
className="flex-1"
disabled={oidcLoading}
>
{oidcLoading ? "Saving..." : "Save Configuration"}
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
setOidcConfig({
client_id: '',
client_secret: '',
issuer_url: '',
authorization_url: '',
token_url: '',
identifier_path: 'sub',
name_path: 'name',
scopes: 'openid email profile'
});
}}
>
Reset
</Button>
</div>
{oidcSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{oidcSuccess}</AlertDescription>
</Alert>
)}
</form>
</div>
</TabsContent>
{/* Users Management Tab */}
<TabsContent value="users" className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">User Management</h3>
<Button
onClick={fetchUsers}
disabled={usersLoading}
variant="outline"
size="sm"
>
{usersLoading ? "Loading..." : "Refresh"}
</Button>
</div>
{usersLoading ? (
<div className="text-center py-8 text-muted-foreground">
Loading users...
</div>
) : (
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Username</TableHead>
<TableHead className="px-4">Type</TableHead>
<TableHead className="px-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="px-4 font-medium">
{user.username}
{user.is_admin && (
<span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
Admin
</span>
)}
</TableCell>
<TableCell className="px-4">
{user.is_oidc ? "External" : "Local"}
</TableCell>
<TableCell className="px-4">
<Button
variant="ghost"
size="sm"
onClick={() => deleteUser(user.username)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}
>
<Trash2 className="h-4 w-4"/>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</TabsContent>
{/* Admins Management Tab */}
<TabsContent value="admins" className="space-y-6">
<div className="space-y-6">
<h3 className="text-lg font-semibold">Admin Management</h3>
{/* Add New Admin Form */}
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
<h4 className="font-medium">Make User Admin</h4>
<form onSubmit={makeUserAdmin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-admin-username">Username</Label>
<div className="flex gap-2">
<Input
id="new-admin-username"
value={newAdminUsername}
onChange={(e) => setNewAdminUsername(e.target.value)}
placeholder="Enter username to make admin"
required
/>
<Button
type="submit"
disabled={makeAdminLoading || !newAdminUsername.trim()}
>
{makeAdminLoading ? "Adding..." : "Make Admin"}
</Button>
</div>
</div>
{makeAdminError && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{makeAdminError}</AlertDescription>
</Alert>
)}
{makeAdminSuccess && (
<Alert>
<AlertTitle>Success</AlertTitle>
<AlertDescription>{makeAdminSuccess}</AlertDescription>
</Alert>
)}
</form>
</div>
{/* Current Admins Table */}
<div className="space-y-4">
<h4 className="font-medium">Current Admins</h4>
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Username</TableHead>
<TableHead className="px-4">Type</TableHead>
<TableHead className="px-4">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.filter(user => user.is_admin).map((admin) => (
<TableRow key={admin.id}>
<TableCell className="px-4 font-medium">
{admin.username}
<span
className="ml-2 inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-muted/50 text-muted-foreground border border-border">
Admin
</span>
</TableCell>
<TableCell className="px-4">
{admin.is_oidc ? "External" : "Local"}
</TableCell>
<TableCell className="px-4">
<Button
variant="ghost"
size="sm"
onClick={() => removeAdminStatus(admin.username)}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
disabled={admin.username === username}
>
<Shield className="h-4 w-4"/>
Remove Admin
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
<SheetFooter className="px-6 pt-6 pb-6">
<Separator className="p-0.25 mt-2 mb-2"/>
<SheetClose asChild>
<Button variant="outline">Close</Button>
</SheetClose>
</SheetFooter>
</SheetContent>
</Sheet>
)}
{/* Delete Account Confirmation Sheet */}
<Sheet modal={false} open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
<SheetContent side="left" className="w-[400px]">
<SheetHeader className="pb-0">
<SheetTitle>Delete Account</SheetTitle>
<SheetDescription>
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<div className="text-sm text-gray-300">
This action cannot be undone. This will permanently delete your account and all
associated data.
</SheetDescription>
</SheetHeader>
<div className="pb-4 px-4 flex flex-col gap-4">
</div>
<Alert variant="destructive">
<AlertTitle>Warning</AlertTitle>
<AlertDescription>
@@ -1076,19 +672,18 @@ export function LeftSidebar({
</div>
</form>
</div>
</SheetContent>
</Sheet>
</Sidebar>
<SidebarInset>
{children}
</SidebarInset>
</SidebarProvider>
</div>
</div>
{!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
className="flex-1"
onClick={() => {
setDeleteAccountOpen(false);
setDeletePassword("");
setDeleteError(null);
}}
style={{cursor: 'pointer'}}
/>
</div>
)}
</div>

View File

@@ -1,7 +1,14 @@
import React from "react";
import {ButtonGroup} from "@/components/ui/button-group.tsx";
import {Button} from "@/components/ui/button.tsx";
import {Home, SeparatorVertical, X, Terminal as TerminalIcon, Server as ServerIcon, Folder as FolderIcon} from "lucide-react";
import {
Home,
SeparatorVertical,
X,
Terminal as TerminalIcon,
Server as ServerIcon,
Folder as FolderIcon
} from "lucide-react";
interface TabProps {
tabType: string;
@@ -17,7 +24,19 @@ interface TabProps {
disableClose?: boolean;
}
export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, canSplit = false, canClose = false, disableActivate = false, disableSplit = false, disableClose = false}: TabProps): React.ReactElement {
export function Tab({
tabType,
title,
isActive,
onActivate,
onClose,
onSplit,
canSplit = false,
canClose = false,
disableActivate = false,
disableSplit = false,
disableClose = false
}: TabProps): React.ReactElement {
if (tabType === "home") {
return (
<Button
@@ -42,7 +61,8 @@ export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, can
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"/>}
{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 ? 'Server' : isFileManager ? 'file_manager' : 'Terminal')}
</Button>
{canSplit && (
@@ -53,7 +73,7 @@ export function Tab({tabType, title, isActive, onActivate, onClose, onSplit, can
disabled={disableSplit}
title={disableSplit ? 'Cannot split this tab' : 'Split'}
>
<SeparatorVertical className="w-[28px] h-[28px]" />
<SeparatorVertical className="w-[28px] h-[28px]"/>
</Button>
)}
{canClose && (

View File

@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useRef, type ReactNode } from 'react';
import React, {createContext, useContext, useState, useRef, type ReactNode} from 'react';
export interface Tab {
id: number;
@@ -33,9 +33,9 @@ interface TabProviderProps {
children: ReactNode;
}
export function TabProvider({ children }: TabProviderProps) {
export function TabProvider({children}: TabProviderProps) {
const [tabs, setTabs] = useState<Tab[]>([
{ id: 1, type: 'home', title: 'Home' }
{id: 1, type: 'home', title: 'Home'}
]);
const [currentTab, setCurrentTab] = useState<number>(1);
const [allSplitScreenTab, setAllSplitScreenTab] = useState<number[]>([]);
@@ -44,7 +44,6 @@ export function TabProvider({ children }: TabProviderProps) {
function computeUniqueTitle(tabType: Tab['type'], desiredTitle: string | undefined): string {
const defaultTitle = tabType === 'server' ? 'Server' : (tabType === 'file_manager' ? 'File Manager' : 'Terminal');
const baseTitle = (desiredTitle || defaultTitle).trim();
// Extract base name without trailing " (n)"
const match = baseTitle.match(/^(.*) \((\d+)\)$/);
const root = match ? match[1] : baseTitle;
@@ -64,7 +63,6 @@ export function TabProvider({ children }: TabProviderProps) {
});
if (!rootUsed) return root;
// Start at (2) for the second instance
let n = 2;
while (usedNumbers.has(n)) n += 1;
return `${root} (${n})`;
@@ -74,8 +72,8 @@ export function TabProvider({ children }: TabProviderProps) {
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,
const newTab: Tab = {
...tabData,
id,
title: effectiveTitle,
terminalRef: tabData.type === 'terminal' ? React.createRef<any>() : undefined
@@ -92,10 +90,10 @@ export function TabProvider({ children }: TabProviderProps) {
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);

View File

@@ -24,7 +24,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const {tabs, currentTab, setCurrentTab, setSplitScreenTab, removeTab, allSplitScreenTab} = useTabs() as any;
const leftPosition = state === "collapsed" ? "26px" : "264px";
// SSH Tools state
const [toolsSheetOpen, setToolsSheetOpen] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
@@ -47,7 +46,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const handleStartRecording = () => {
setIsRecording(true);
// Focus on the input when recording starts
setTimeout(() => {
const input = document.getElementById('ssh-tools-input') as HTMLInputElement;
if (input) input.focus();
@@ -60,7 +58,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// Don't handle input change for special keys - let onKeyDown handle them
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -69,9 +66,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const value = e.currentTarget.value;
let commandToSend = '';
// Handle special keys and control sequences
if (e.ctrlKey || e.metaKey) {
// Control sequences
if (e.key === 'c') {
commandToSend = '\x03'; // Ctrl+C (SIGINT)
e.preventDefault();
@@ -177,7 +172,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
e.preventDefault();
}
// Send the command to all selected terminals
if (commandToSend) {
selectedTabIds.forEach(tabId => {
const tab = tabs.find((t: any) => t.id === tabId);
@@ -190,8 +184,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (selectedTabIds.length === 0) return;
// Handle regular character input
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
const char = e.key;
selectedTabIds.forEach(tabId => {
@@ -209,7 +202,6 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const currentTabIsSshManager = currentTabObj?.type === 'ssh_manager';
const currentTabIsAdmin = currentTabObj?.type === 'admin';
// Get terminal tabs for selection
const terminalTabs = tabs.filter((tab: any) => tab.type === 'terminal');
function getCookie(name: string) {
@@ -237,7 +229,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
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">
<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);
@@ -246,9 +239,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
const isFileManager = tab.type === 'file_manager';
const isSshManager = tab.type === 'ssh_manager';
const isAdmin = tab.type === 'admin';
// Split availability
const isSplittable = isTerminal || isServer || isFileManager;
// Disable split entirely when on Home or SSH Manager
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);
@@ -296,13 +287,12 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
<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} />
<ChevronDown size={10}/>
</div>
)}
{/* Custom SSH Tools Overlay */}
{toolsSheetOpen && (
<div
<div
className="fixed inset-0 z-[999999] flex justify-end"
style={{
position: 'fixed',
@@ -316,13 +306,13 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
transform: 'translateZ(0)'
}}
>
<div
<div
className="flex-1"
onClick={() => setToolsSheetOpen(false)}
style={{ cursor: 'pointer' }}
style={{cursor: 'pointer'}}
/>
<div
<div
className="w-[400px] h-full bg-[#18181b] border-l-2 border-[#303032] flex flex-col shadow-2xl"
style={{
backgroundColor: '#18181b',
@@ -346,7 +336,7 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
<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">
@@ -374,11 +364,12 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
</Button>
)}
</div>
{isRecording && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Select terminals:</label>
<label className="text-sm font-medium text-white">Select
terminals:</label>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2">
{terminalTabs.map(tab => (
<Button
@@ -387,8 +378,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
variant="outline"
size="sm"
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
selectedTabIds.includes(tab.id)
? 'bg-blue-600 text-white border-blue-700 hover:bg-blue-700'
selectedTabIds.includes(tab.id)
? 'bg-blue-600 text-white border-blue-700 hover:bg-blue-700'
: 'bg-transparent text-gray-300 border-gray-500 hover:bg-gray-700'
}`}
onClick={() => handleTabToggle(tab.id)}
@@ -398,9 +389,10 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
))}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">Type commands (all keys supported):</label>
<label className="text-sm font-medium text-white">Type commands (all
keys supported):</label>
<Input
id="ssh-tools-input"
placeholder="Type here"
@@ -411,7 +403,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
readOnly
/>
<p className="text-xs text-muted-foreground">
Commands will be sent to {selectedTabIds.length} selected terminal(s).
Commands will be sent to {selectedTabIds.length} selected
terminal(s).
</p>
</div>
</>
@@ -426,8 +419,8 @@ export function TopNavbar({isTopbarOpen, setIsTopbarOpen}: TopNavbarProps): Reac
</h1>
<div className="flex items-center space-x-2">
<Checkbox
id="enable-copy-paste"
<Checkbox
id="enable-copy-paste"
onCheckedChange={updateRightClickCopyPaste}
defaultChecked={getCookie("rightClickCopyPaste") === "true"}
/>