feat: Replace the old ssh tools system with a new dedicated sidebar

This commit is contained in:
LukeGus
2025-11-09 21:47:12 -06:00
parent 4399e11574
commit 45176bc735
13 changed files with 1060 additions and 978 deletions

View File

@@ -111,7 +111,6 @@ class SystemCrypto {
} else { } else {
} }
} catch (fileError) { } catch (fileError) {
// OK: .env file not found or unreadable, will generate new database key
databaseLogger.debug( databaseLogger.debug(
".env file not accessible, will generate new database key", ".env file not accessible, will generate new database key",
{ {

View File

@@ -371,6 +371,8 @@ export interface HostManagerProps {
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
initialTab?: string; initialTab?: string;
hostConfig?: SSHHost; hostConfig?: SSHHost;
rightSidebarOpen?: boolean;
rightSidebarWidth?: number;
} }
export interface SSHManagerHostEditorProps { export interface SSHManagerHostEditorProps {

View File

@@ -24,9 +24,13 @@ function AppContent() {
return saved !== null ? JSON.parse(saved) : true; return saved !== null ? JSON.parse(saved) : true;
}); });
const [isTransitioning, setIsTransitioning] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false);
const [transitionPhase, setTransitionPhase] = useState<'idle' | 'fadeOut' | 'fadeIn'>('idle'); const [transitionPhase, setTransitionPhase] = useState<
"idle" | "fadeOut" | "fadeIn"
>("idle");
const { currentTab, tabs } = useTabs(); const { currentTab, tabs } = useTabs();
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
const lastShiftPressTime = useRef(0); const lastShiftPressTime = useRef(0);
@@ -101,17 +105,17 @@ function AppContent() {
userId: string | null; userId: string | null;
}) => { }) => {
setIsTransitioning(true); setIsTransitioning(true);
setTransitionPhase('fadeOut'); setTransitionPhase("fadeOut");
setTimeout(() => { setTimeout(() => {
setIsAuthenticated(true); setIsAuthenticated(true);
setIsAdmin(authData.isAdmin); setIsAdmin(authData.isAdmin);
setUsername(authData.username); setUsername(authData.username);
setTransitionPhase('fadeIn'); setTransitionPhase("fadeIn");
setTimeout(() => { setTimeout(() => {
setIsTransitioning(false); setIsTransitioning(false);
setTransitionPhase('idle'); setTransitionPhase("idle");
}, 800); }, 800);
}, 1200); }, 1200);
}, },
@@ -120,7 +124,7 @@ function AppContent() {
const handleLogout = useCallback(async () => { const handleLogout = useCallback(async () => {
setIsTransitioning(true); setIsTransitioning(true);
setTransitionPhase('fadeOut'); setTransitionPhase("fadeOut");
setTimeout(async () => { setTimeout(async () => {
try { try {
@@ -168,17 +172,21 @@ function AppContent() {
{isAuthenticated && ( {isAuthenticated && (
<LeftSidebar <LeftSidebar
onSelectView={handleSelectView} onSelectView={handleSelectView}
disabled={!isAuthenticated || authLoading} disabled={!isAuthenticated || authLoading}
isAdmin={isAdmin} isAdmin={isAdmin}
username={username} username={username}
onLogout={handleLogout} onLogout={handleLogout}
> >
<div <div
className="h-screen w-full visible pointer-events-auto static overflow-hidden" className="h-screen w-full visible pointer-events-auto static overflow-hidden"
style={{ display: showTerminalView ? "block" : "none" }} style={{ display: showTerminalView ? "block" : "none" }}
> >
<AppView isTopbarOpen={isTopbarOpen} /> <AppView
isTopbarOpen={isTopbarOpen}
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth}
/>
</div> </div>
{showHome && ( {showHome && (
@@ -189,6 +197,8 @@ function AppContent() {
authLoading={authLoading} authLoading={authLoading}
onAuthSuccess={handleAuthSuccess} onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen} isTopbarOpen={isTopbarOpen}
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth}
/> />
</div> </div>
)} )}
@@ -200,19 +210,29 @@ function AppContent() {
isTopbarOpen={isTopbarOpen} isTopbarOpen={isTopbarOpen}
initialTab={currentTabData?.initialTab} initialTab={currentTabData?.initialTab}
hostConfig={currentTabData?.hostConfig} hostConfig={currentTabData?.hostConfig}
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth}
/> />
</div> </div>
)} )}
{showAdmin && ( {showAdmin && (
<div className="h-screen w-full visible pointer-events-auto static overflow-hidden"> <div className="h-screen w-full visible pointer-events-auto static overflow-hidden">
<AdminSettings isTopbarOpen={isTopbarOpen} /> <AdminSettings
isTopbarOpen={isTopbarOpen}
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth}
/>
</div> </div>
)} )}
{showProfile && ( {showProfile && (
<div className="h-screen w-full visible pointer-events-auto static overflow-auto"> <div className="h-screen w-full visible pointer-events-auto static overflow-auto">
<UserProfile isTopbarOpen={isTopbarOpen} /> <UserProfile
isTopbarOpen={isTopbarOpen}
rightSidebarOpen={rightSidebarOpen}
rightSidebarWidth={rightSidebarWidth}
/>
</div> </div>
)} )}
@@ -220,6 +240,10 @@ function AppContent() {
isTopbarOpen={isTopbarOpen} isTopbarOpen={isTopbarOpen}
setIsTopbarOpen={setIsTopbarOpen} setIsTopbarOpen={setIsTopbarOpen}
onOpenCommandPalette={() => setIsCommandPaletteOpen(true)} onOpenCommandPalette={() => setIsCommandPaletteOpen(true)}
onRightSidebarStateChange={(isOpen, width) => {
setRightSidebarOpen(isOpen);
setRightSidebarWidth(width);
}}
/> />
</LeftSidebar> </LeftSidebar>
)} )}
@@ -227,51 +251,69 @@ function AppContent() {
{isTransitioning && ( {isTransitioning && (
<div <div
className={`fixed inset-0 bg-background z-[20000] transition-opacity duration-700 ${ className={`fixed inset-0 bg-background z-[20000] transition-opacity duration-700 ${
transitionPhase === 'fadeOut' ? 'opacity-100' : 'opacity-0' transitionPhase === "fadeOut" ? "opacity-100" : "opacity-0"
}`} }`}
> >
{transitionPhase === 'fadeOut' && ( {transitionPhase === "fadeOut" && (
<> <>
<div className="absolute inset-0 flex items-center justify-center overflow-hidden"> <div className="absolute inset-0 flex items-center justify-center overflow-hidden">
<div className="absolute w-0 h-0 bg-primary/10 rounded-full" <div
style={{ className="absolute w-0 h-0 bg-primary/10 rounded-full"
animation: 'ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards', style={{
animationDelay: '0ms' animation:
}} "ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards",
animationDelay: "0ms",
}}
/> />
<div className="absolute w-0 h-0 bg-primary/7 rounded-full" <div
style={{ className="absolute w-0 h-0 bg-primary/7 rounded-full"
animation: 'ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards', style={{
animationDelay: '200ms' animation:
}} "ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards",
animationDelay: "200ms",
}}
/> />
<div className="absolute w-0 h-0 bg-primary/5 rounded-full" <div
style={{ className="absolute w-0 h-0 bg-primary/5 rounded-full"
animation: 'ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards', style={{
animationDelay: '400ms' animation:
}} "ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards",
animationDelay: "400ms",
}}
/> />
<div className="absolute w-0 h-0 bg-primary/3 rounded-full" <div
style={{ className="absolute w-0 h-0 bg-primary/3 rounded-full"
animation: 'ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards', style={{
animationDelay: '600ms' animation:
}} "ripple 2.5s cubic-bezier(0.4, 0, 0.2, 1) forwards",
animationDelay: "600ms",
}}
/> />
<div className="relative z-10 text-center" <div
style={{ className="relative z-10 text-center"
animation: 'logoFade 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards' style={{
}}> animation:
<div className="text-7xl font-bold tracking-wider" "logoFade 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards",
style={{ }}
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', >
animation: 'logoGlow 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards' <div
}}> className="text-7xl font-bold tracking-wider"
style={{
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
animation:
"logoGlow 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards",
}}
>
TERMIX TERMIX
</div> </div>
<div className="text-sm text-muted-foreground mt-3 tracking-widest" <div
style={{ className="text-sm text-muted-foreground mt-3 tracking-widest"
animation: 'subtitleFade 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards' style={{
}}> animation:
"subtitleFade 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards",
}}
>
SSH TERMINAL MANAGER SSH TERMINAL MANAGER
</div> </div>
</div> </div>

View File

@@ -59,10 +59,14 @@ import {
interface AdminSettingsProps { interface AdminSettingsProps {
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
rightSidebarOpen?: boolean;
rightSidebarWidth?: number;
} }
export function AdminSettings({ export function AdminSettings({
isTopbarOpen = true, isTopbarOpen = true,
rightSidebarOpen = false,
rightSidebarWidth = 400,
}: AdminSettingsProps): React.ReactElement { }: AdminSettingsProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const { confirmWithToast } = useConfirmation(); const { confirmWithToast } = useConfirmation();
@@ -637,7 +641,7 @@ export function AdminSettings({
const bottomMarginPx = 8; const bottomMarginPx = 8;
const wrapperStyle: React.CSSProperties = { const wrapperStyle: React.CSSProperties = {
marginLeft: leftMarginPx, marginLeft: leftMarginPx,
marginRight: 17, marginRight: rightSidebarOpen ? rightSidebarWidth + 17 : 17,
marginTop: topMarginPx, marginTop: topMarginPx,
marginBottom: bottomMarginPx, marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`, height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,

View File

@@ -50,6 +50,8 @@ interface DashboardProps {
userId: string | null; userId: string | null;
}) => void; }) => void;
isTopbarOpen: boolean; isTopbarOpen: boolean;
rightSidebarOpen?: boolean;
rightSidebarWidth?: number;
} }
export function Dashboard({ export function Dashboard({
@@ -58,6 +60,8 @@ export function Dashboard({
onAuthSuccess, onAuthSuccess,
isTopbarOpen, isTopbarOpen,
onSelectView, onSelectView,
rightSidebarOpen = false,
rightSidebarWidth = 400,
}: DashboardProps): React.ReactElement { }: DashboardProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const [loggedIn, setLoggedIn] = useState(isAuthenticated); const [loggedIn, setLoggedIn] = useState(isAuthenticated);
@@ -97,6 +101,7 @@ export function Dashboard({
const topMarginPx = isTopbarOpen ? 74 : 26; const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8; const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
const rightMarginPx = rightSidebarOpen ? rightSidebarWidth + 17 : 17;
const bottomMarginPx = 8; const bottomMarginPx = 8;
useEffect(() => { useEffect(() => {
@@ -336,7 +341,7 @@ export function Dashboard({
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden flex" className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden flex"
style={{ style={{
marginLeft: leftMarginPx, marginLeft: leftMarginPx,
marginRight: 17, marginRight: rightMarginPx,
marginTop: topMarginPx, marginTop: topMarginPx,
marginBottom: bottomMarginPx, marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`, height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,

View File

@@ -18,6 +18,8 @@ export function HostManager({
isTopbarOpen, isTopbarOpen,
initialTab = "host_viewer", initialTab = "host_viewer",
hostConfig, hostConfig,
rightSidebarOpen = false,
rightSidebarWidth = 400,
}: HostManagerProps): React.ReactElement { }: HostManagerProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(initialTab); const [activeTab, setActiveTab] = useState(initialTab);
@@ -90,7 +92,7 @@ export function HostManager({
className="bg-dark-bg text-white p-4 pt-0 rounded-lg border-2 border-dark-border flex flex-col min-h-0 overflow-hidden" className="bg-dark-bg text-white p-4 pt-0 rounded-lg border-2 border-dark-border flex flex-col min-h-0 overflow-hidden"
style={{ style={{
marginLeft: leftMarginPx, marginLeft: leftMarginPx,
marginRight: 17, marginRight: rightSidebarOpen ? rightSidebarWidth + 17 : 17,
marginTop: topMarginPx, marginTop: topMarginPx,
marginBottom: bottomMarginPx, marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`, height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,

View File

@@ -1,480 +0,0 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Plus, Play, Edit, Trash2, Copy, X } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getSnippets,
createSnippet,
updateSnippet,
deleteSnippet,
} from "@/ui/main-axios";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import type { Snippet, SnippetData } from "../../../../types/index.js";
interface TabData {
id: number;
type: string;
title: string;
terminalRef?: {
current?: {
sendInput?: (data: string) => void;
};
};
[key: string]: unknown;
}
interface SnippetsSidebarProps {
isOpen: boolean;
onClose: () => void;
onExecute: (content: string) => void;
}
export function SnippetsSidebar({
isOpen,
onClose,
onExecute,
}: SnippetsSidebarProps) {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const { tabs } = useTabs() as { tabs: TabData[] };
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [loading, setLoading] = useState(true);
const [showDialog, setShowDialog] = useState(false);
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null);
const [formData, setFormData] = useState<SnippetData>({
name: "",
content: "",
description: "",
});
const [formErrors, setFormErrors] = useState({
name: false,
content: false,
});
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
useEffect(() => {
if (isOpen) {
fetchSnippets();
}
}, [isOpen]);
const fetchSnippets = async () => {
try {
setLoading(true);
const data = await getSnippets();
setSnippets(Array.isArray(data) ? data : []);
} catch {
toast.error(t("snippets.failedToFetch"));
setSnippets([]);
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingSnippet(null);
setFormData({ name: "", content: "", description: "" });
setFormErrors({ name: false, content: false });
setShowDialog(true);
};
const handleEdit = (snippet: Snippet) => {
setEditingSnippet(snippet);
setFormData({
name: snippet.name,
content: snippet.content,
description: snippet.description || "",
});
setFormErrors({ name: false, content: false });
setShowDialog(true);
};
const handleDelete = (snippet: Snippet) => {
confirmWithToast(
t("snippets.deleteConfirmDescription", { name: snippet.name }),
async () => {
try {
await deleteSnippet(snippet.id);
toast.success(t("snippets.deleteSuccess"));
fetchSnippets();
} catch {
toast.error(t("snippets.deleteFailed"));
}
},
"destructive",
);
};
const handleSubmit = async () => {
const errors = {
name: !formData.name.trim(),
content: !formData.content.trim(),
};
setFormErrors(errors);
if (errors.name || errors.content) {
return;
}
try {
if (editingSnippet) {
await updateSnippet(editingSnippet.id, formData);
toast.success(t("snippets.updateSuccess"));
} else {
await createSnippet(formData);
toast.success(t("snippets.createSuccess"));
}
setShowDialog(false);
fetchSnippets();
} catch {
toast.error(
editingSnippet
? t("snippets.updateFailed")
: t("snippets.createFailed"),
);
}
};
const handleTabToggle = (tabId: number) => {
setSelectedTabIds((prev) =>
prev.includes(tabId)
? prev.filter((id) => id !== tabId)
: [...prev, tabId],
);
};
const handleExecute = (snippet: Snippet) => {
if (selectedTabIds.length > 0) {
selectedTabIds.forEach((tabId) => {
const tab = tabs.find((t: TabData) => t.id === tabId);
if (tab?.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(snippet.content + "\n");
}
});
toast.success(
t("snippets.executeSuccess", {
name: snippet.name,
count: selectedTabIds.length,
}),
);
} else {
onExecute(snippet.content);
toast.success(t("snippets.executeSuccess", { name: snippet.name }));
}
};
const handleCopy = (snippet: Snippet) => {
navigator.clipboard.writeText(snippet.content);
toast.success(t("snippets.copySuccess", { name: snippet.name }));
};
const terminalTabs = tabs.filter((tab: TabData) => tab.type === "terminal");
if (!isOpen) return null;
return (
<>
<div
className="fixed top-0 left-0 right-0 bottom-0 z-[999999] flex justify-end pointer-events-auto isolate"
style={{
transform: "translateZ(0)",
}}
>
<div className="flex-1 cursor-pointer" onClick={onClose} />
<div
className="w-[400px] h-full bg-dark-bg border-l-2 border-dark-border flex flex-col shadow-2xl relative isolate z-[999999]"
style={{
boxShadow: "-4px 0 20px rgba(0, 0, 0, 0.5)",
transform: "translateZ(0)",
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-dark-border">
<h2 className="text-lg font-semibold text-white">
{t("snippets.title")}
</h2>
<Button
variant="outline"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title={t("common.close")}
>
<X />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
{terminalTabs.length > 0 && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("snippets.selectTerminals", {
defaultValue: "Select Terminals (optional)",
})}
</label>
<p className="text-xs text-muted-foreground">
{selectedTabIds.length > 0
? t("snippets.executeOnSelected", {
defaultValue: `Execute on ${selectedTabIds.length} selected terminal(s)`,
count: selectedTabIds.length,
})
: t("snippets.executeOnCurrent", {
defaultValue:
"Execute on current terminal (click to select multiple)",
})}
</p>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
{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>
<Separator className="my-4" />
</>
)}
<Button
onClick={handleCreate}
className="w-full"
variant="outline"
>
<Plus className="w-4 h-4 mr-2" />
{t("snippets.new")}
</Button>
{loading ? (
<div className="text-center text-muted-foreground py-8">
<p>{t("common.loading")}</p>
</div>
) : snippets.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<p className="mb-2 font-medium">{t("snippets.empty")}</p>
<p className="text-sm">{t("snippets.emptyHint")}</p>
</div>
) : (
<TooltipProvider>
<div className="space-y-3">
{snippets.map((snippet) => (
<div
key={snippet.id}
className="bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group"
>
<div className="mb-2">
<h3 className="text-sm font-medium text-white mb-1">
{snippet.name}
</h3>
{snippet.description && (
<p className="text-xs text-muted-foreground">
{snippet.description}
</p>
)}
</div>
<div className="bg-muted/30 rounded p-2 mb-3">
<code className="text-xs font-mono break-all line-clamp-2 text-muted-foreground">
{snippet.content}
</code>
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="default"
className="flex-1"
onClick={() => handleExecute(snippet)}
>
<Play className="w-3 h-3 mr-1" />
{t("snippets.run")}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.runTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() => handleCopy(snippet)}
>
<Copy className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.copyTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(snippet)}
>
<Edit className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.editTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() => handleDelete(snippet)}
className="hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.deleteTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
))}
</div>
</TooltipProvider>
)}
</div>
</div>
</div>
</div>
{showDialog && (
<div
className="fixed inset-0 flex items-center justify-center z-[9999999] bg-black/50 backdrop-blur-sm"
onClick={() => setShowDialog(false)}
>
<div
className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-6">
<h2 className="text-xl font-semibold text-white">
{editingSnippet ? t("snippets.edit") : t("snippets.create")}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{editingSnippet
? t("snippets.editDescription")
: t("snippets.createDescription")}
</p>
</div>
<div className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center gap-1">
{t("snippets.name")}
<span className="text-destructive">*</span>
</label>
<Input
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder={t("snippets.namePlaceholder")}
className={`${formErrors.name ? "border-destructive focus-visible:ring-destructive" : ""}`}
autoFocus
/>
{formErrors.name && (
<p className="text-xs text-destructive mt-1">
{t("snippets.nameRequired")}
</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("snippets.description")}
<span className="text-muted-foreground ml-1">
({t("common.optional")})
</span>
</label>
<Input
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder={t("snippets.descriptionPlaceholder")}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center gap-1">
{t("snippets.content")}
<span className="text-destructive">*</span>
</label>
<Textarea
value={formData.content}
onChange={(e) =>
setFormData({ ...formData, content: e.target.value })
}
placeholder={t("snippets.contentPlaceholder")}
className={`font-mono text-sm ${formErrors.content ? "border-destructive focus-visible:ring-destructive" : ""}`}
rows={10}
/>
{formErrors.content && (
<p className="text-xs text-destructive mt-1">
{t("snippets.contentRequired")}
</p>
)}
</div>
</div>
<Separator className="my-6" />
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setShowDialog(false)}
className="flex-1"
>
{t("common.cancel")}
</Button>
<Button onClick={handleSubmit} className="flex-1">
{editingSnippet ? t("snippets.edit") : t("snippets.create")}
</Button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -1,351 +0,0 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button.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";
import { getCookie, setCookie } from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { X } from "lucide-react";
interface TabData {
id: number;
type: string;
title: string;
terminalRef?: {
current?: {
sendInput?: (data: string) => void;
};
};
[key: string]: unknown;
}
interface SshToolsSidebarProps {
isOpen: boolean;
onClose: () => void;
}
export function SSHToolsSidebar({
isOpen,
onClose,
}: SshToolsSidebarProps): React.ReactElement | null {
const { t } = useTranslation();
const { tabs } = useTabs() as { tabs: TabData[] };
const [isRecording, setIsRecording] = useState(false);
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
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 handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (selectedTabIds.length === 0) return;
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: TabData) => 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: TabData) => t.id === tabId);
if (tab?.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(char);
}
});
}
};
const updateRightClickCopyPaste = (checked: boolean) => {
setCookie("rightClickCopyPaste", checked.toString());
};
const terminalTabs = tabs.filter((tab: TabData) => tab.type === "terminal");
if (!isOpen) return null;
return (
<div
className="fixed top-0 left-0 right-0 bottom-0 z-[999999] flex justify-end pointer-events-auto isolate"
style={{
transform: "translateZ(0)",
}}
>
<div className="flex-1 cursor-pointer" onClick={onClose} />
<div
className="w-[400px] h-full bg-dark-bg border-l-2 border-dark-border flex flex-col shadow-2xl relative isolate z-[999999]"
style={{
boxShadow: "-4px 0 20px rgba(0, 0, 0, 0.5)",
transform: "translateZ(0)",
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-dark-border">
<h2 className="text-lg font-semibold text-white">
{t("sshTools.title")}
</h2>
<Button
variant="outline"
size="sm"
onClick={onClose}
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">
<X />
</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/Termix-SSH/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
GitHub
</a>
!
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,901 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Textarea } from "@/components/ui/textarea.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { Checkbox } from "@/components/ui/checkbox.tsx";
import {
Tabs,
TabsList,
TabsTrigger,
TabsContent,
} from "@/components/ui/tabs.tsx";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip.tsx";
import {
Sidebar,
SidebarContent,
SidebarHeader,
SidebarProvider,
SidebarGroupLabel,
} from "@/components/ui/sidebar.tsx";
import {
Plus,
Play,
Edit,
Trash2,
Copy,
X,
RotateCcw,
ChevronRight,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getSnippets,
createSnippet,
updateSnippet,
deleteSnippet,
getCookie,
setCookie,
} from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import type { Snippet, SnippetData } from "../../../../types";
interface TabData {
id: number;
type: string;
title: string;
terminalRef?: {
current?: {
sendInput?: (data: string) => void;
};
};
[key: string]: unknown;
}
interface SSHUtilitySidebarProps {
isOpen: boolean;
onClose: () => void;
onSnippetExecute: (content: string) => void;
sidebarWidth: number;
setSidebarWidth: (width: number) => void;
}
export function SSHUtilitySidebar({
isOpen,
onClose,
onSnippetExecute,
sidebarWidth,
setSidebarWidth,
}: SSHUtilitySidebarProps) {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const { tabs } = useTabs() as { tabs: TabData[] };
const [activeTab, setActiveTab] = useState("ssh-tools");
// SSH Tools state
const [isRecording, setIsRecording] = useState(false);
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
// Snippets state
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [loading, setLoading] = useState(true);
const [showDialog, setShowDialog] = useState(false);
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null);
const [formData, setFormData] = useState<SnippetData>({
name: "",
content: "",
description: "",
});
const [formErrors, setFormErrors] = useState({
name: false,
content: false,
});
const [selectedSnippetTabIds, setSelectedSnippetTabIds] = useState<number[]>(
[],
);
// Resize state
const [isResizing, setIsResizing] = useState(false);
const startXRef = React.useRef<number | null>(null);
const startWidthRef = React.useRef<number>(sidebarWidth);
const terminalTabs = tabs.filter((tab: TabData) => tab.type === "terminal");
useEffect(() => {
if (isOpen && activeTab === "snippets") {
fetchSnippets();
}
}, [isOpen, activeTab]);
// Resize handlers
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
startXRef.current = e.clientX;
startWidthRef.current = sidebarWidth;
};
React.useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (startXRef.current == null) return;
const dx = startXRef.current - e.clientX; // Reversed because we're on the right
const newWidth = Math.round(startWidthRef.current + dx);
const minWidth = 300;
const maxWidth = Math.round(window.innerWidth * 0.5);
if (newWidth >= minWidth && newWidth <= maxWidth) {
setSidebarWidth(newWidth);
} else if (newWidth < minWidth) {
setSidebarWidth(minWidth);
} else if (newWidth > maxWidth) {
setSidebarWidth(maxWidth);
}
};
const handleMouseUp = () => {
setIsResizing(false);
startXRef.current = null;
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [isResizing, sidebarWidth, setSidebarWidth]);
// SSH Tools handlers
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 handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (selectedTabIds.length === 0) return;
let commandToSend = "";
if (e.ctrlKey || e.metaKey) {
if (e.key === "c") {
commandToSend = "\x03";
e.preventDefault();
} else if (e.key === "d") {
commandToSend = "\x04";
e.preventDefault();
} else if (e.key === "l") {
commandToSend = "\x0c";
e.preventDefault();
} else if (e.key === "u") {
commandToSend = "\x15";
e.preventDefault();
} else if (e.key === "k") {
commandToSend = "\x0b";
e.preventDefault();
} else if (e.key === "a") {
commandToSend = "\x01";
e.preventDefault();
} else if (e.key === "e") {
commandToSend = "\x05";
e.preventDefault();
} else if (e.key === "w") {
commandToSend = "\x17";
e.preventDefault();
}
} else if (e.key === "Enter") {
commandToSend = "\n";
e.preventDefault();
} else if (e.key === "Backspace") {
commandToSend = "\x08";
e.preventDefault();
} else if (e.key === "Delete") {
commandToSend = "\x7f";
e.preventDefault();
} else if (e.key === "Tab") {
commandToSend = "\x09";
e.preventDefault();
} else if (e.key === "Escape") {
commandToSend = "\x1b";
e.preventDefault();
} else if (e.key === "ArrowUp") {
commandToSend = "\x1b[A";
e.preventDefault();
} else if (e.key === "ArrowDown") {
commandToSend = "\x1b[B";
e.preventDefault();
} else if (e.key === "ArrowLeft") {
commandToSend = "\x1b[D";
e.preventDefault();
} else if (e.key === "ArrowRight") {
commandToSend = "\x1b[C";
e.preventDefault();
} else if (e.key === "Home") {
commandToSend = "\x1b[H";
e.preventDefault();
} else if (e.key === "End") {
commandToSend = "\x1b[F";
e.preventDefault();
} else if (e.key === "PageUp") {
commandToSend = "\x1b[5~";
e.preventDefault();
} else if (e.key === "PageDown") {
commandToSend = "\x1b[6~";
e.preventDefault();
} else if (e.key === "Insert") {
commandToSend = "\x1b[2~";
e.preventDefault();
} else if (e.key === "F1") {
commandToSend = "\x1bOP";
e.preventDefault();
} else if (e.key === "F2") {
commandToSend = "\x1bOQ";
e.preventDefault();
} else if (e.key === "F3") {
commandToSend = "\x1bOR";
e.preventDefault();
} else if (e.key === "F4") {
commandToSend = "\x1bOS";
e.preventDefault();
} else if (e.key === "F5") {
commandToSend = "\x1b[15~";
e.preventDefault();
} else if (e.key === "F6") {
commandToSend = "\x1b[17~";
e.preventDefault();
} else if (e.key === "F7") {
commandToSend = "\x1b[18~";
e.preventDefault();
} else if (e.key === "F8") {
commandToSend = "\x1b[19~";
e.preventDefault();
} else if (e.key === "F9") {
commandToSend = "\x1b[20~";
e.preventDefault();
} else if (e.key === "F10") {
commandToSend = "\x1b[21~";
e.preventDefault();
} else if (e.key === "F11") {
commandToSend = "\x1b[23~";
e.preventDefault();
} else if (e.key === "F12") {
commandToSend = "\x1b[24~";
e.preventDefault();
}
if (commandToSend) {
selectedTabIds.forEach((tabId) => {
const tab = tabs.find((t: TabData) => 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: TabData) => t.id === tabId);
if (tab?.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(char);
}
});
}
};
const updateRightClickCopyPaste = (checked: boolean) => {
setCookie("rightClickCopyPaste", checked.toString());
};
// Snippets handlers
const fetchSnippets = async () => {
try {
setLoading(true);
const data = await getSnippets();
setSnippets(Array.isArray(data) ? data : []);
} catch {
toast.error(t("snippets.failedToFetch"));
setSnippets([]);
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingSnippet(null);
setFormData({ name: "", content: "", description: "" });
setFormErrors({ name: false, content: false });
setShowDialog(true);
};
const handleEdit = (snippet: Snippet) => {
setEditingSnippet(snippet);
setFormData({
name: snippet.name,
content: snippet.content,
description: snippet.description || "",
});
setFormErrors({ name: false, content: false });
setShowDialog(true);
};
const handleDelete = (snippet: Snippet) => {
confirmWithToast(
t("snippets.deleteConfirmDescription", { name: snippet.name }),
async () => {
try {
await deleteSnippet(snippet.id);
toast.success(t("snippets.deleteSuccess"));
fetchSnippets();
} catch {
toast.error(t("snippets.deleteFailed"));
}
},
"destructive",
);
};
const handleSubmit = async () => {
const errors = {
name: !formData.name.trim(),
content: !formData.content.trim(),
};
setFormErrors(errors);
if (errors.name || errors.content) {
return;
}
try {
if (editingSnippet) {
await updateSnippet(editingSnippet.id, formData);
toast.success(t("snippets.updateSuccess"));
} else {
await createSnippet(formData);
toast.success(t("snippets.createSuccess"));
}
setShowDialog(false);
fetchSnippets();
} catch {
toast.error(
editingSnippet
? t("snippets.updateFailed")
: t("snippets.createFailed"),
);
}
};
const handleSnippetTabToggle = (tabId: number) => {
setSelectedSnippetTabIds((prev) =>
prev.includes(tabId)
? prev.filter((id) => id !== tabId)
: [...prev, tabId],
);
};
const handleExecute = (snippet: Snippet) => {
if (selectedSnippetTabIds.length > 0) {
selectedSnippetTabIds.forEach((tabId) => {
const tab = tabs.find((t: TabData) => t.id === tabId);
if (tab?.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(snippet.content + "\n");
}
});
toast.success(
t("snippets.executeSuccess", {
name: snippet.name,
count: selectedSnippetTabIds.length,
}),
);
} else {
onSnippetExecute(snippet.content);
toast.success(t("snippets.executeSuccess", { name: snippet.name }));
}
};
const handleCopy = (snippet: Snippet) => {
navigator.clipboard.writeText(snippet.content);
toast.success(t("snippets.copySuccess", { name: snippet.name }));
};
if (!isOpen) return null;
return (
<>
<div className="min-h-svh">
<SidebarProvider
open={isOpen}
style={
{ "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties
}
>
<div className="flex h-screen w-full justify-end">
<Sidebar variant="floating" side="right">
<SidebarHeader>
<SidebarGroupLabel className="text-lg font-bold text-white">
{t("nav.tools")}
<div className="absolute right-5 flex gap-1">
<Button
variant="outline"
onClick={() => setSidebarWidth(400)}
className="w-[28px] h-[28px]"
title="Reset sidebar width"
>
<RotateCcw className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={onClose}
className="w-[28px] h-[28px]"
title={t("common.close")}
>
<X className="h-4 w-4" />
</Button>
</div>
</SidebarGroupLabel>
</SidebarHeader>
<Separator className="p-0.25" />
<SidebarContent className="p-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full grid grid-cols-2 mb-4">
<TabsTrigger value="ssh-tools">
{t("sshTools.title")}
</TabsTrigger>
<TabsTrigger value="snippets">
{t("snippets.title")}
</TabsTrigger>
</TabsList>
<TabsContent value="ssh-tools" className="space-y-4">
<h3 className="font-semibold text-white">
{t("sshTools.keyRecording")}
</h3>
<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">
{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"
disabled={selectedTabIds.length === 0}
readOnly
/>
<p className="text-xs text-muted-foreground">
{t("sshTools.commandsWillBeSent", {
count: selectedTabIds.length,
})}
</p>
</div>
</>
)}
</div>
<Separator />
<h3 className="font-semibold text-white">
{t("sshTools.settings")}
</h3>
<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 text-white cursor-pointer"
>
{t("sshTools.enableRightClickCopyPaste")}
</label>
</div>
<Separator />
<p className="text-sm text-gray-500">
{t("sshTools.shareIdeas")}{" "}
<a
href="https://github.com/Termix-SSH/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
GitHub
</a>
!
</p>
</TabsContent>
<TabsContent value="snippets" className="space-y-4">
{terminalTabs.length > 0 && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("snippets.selectTerminals", {
defaultValue: "Select Terminals (optional)",
})}
</label>
<p className="text-xs text-muted-foreground">
{selectedSnippetTabIds.length > 0
? t("snippets.executeOnSelected", {
defaultValue: `Execute on ${selectedSnippetTabIds.length} selected terminal(s)`,
count: selectedSnippetTabIds.length,
})
: t("snippets.executeOnCurrent", {
defaultValue:
"Execute on current terminal (click to select multiple)",
})}
</p>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
{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 ${
selectedSnippetTabIds.includes(tab.id)
? "text-white bg-gray-700"
: "text-gray-500"
}`}
onClick={() => handleSnippetTabToggle(tab.id)}
>
{tab.title}
</Button>
))}
</div>
</div>
<Separator />
</>
)}
<Button
onClick={handleCreate}
className="w-full"
variant="outline"
>
<Plus className="w-4 h-4 mr-2" />
{t("snippets.new")}
</Button>
{loading ? (
<div className="text-center text-muted-foreground py-8">
<p>{t("common.loading")}</p>
</div>
) : snippets.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<p className="mb-2 font-medium">
{t("snippets.empty")}
</p>
<p className="text-sm">{t("snippets.emptyHint")}</p>
</div>
) : (
<TooltipProvider>
<div className="space-y-3">
{snippets.map((snippet) => (
<div
key={snippet.id}
className="bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group"
>
<div className="mb-2">
<h3 className="text-sm font-medium text-white mb-1">
{snippet.name}
</h3>
{snippet.description && (
<p className="text-xs text-muted-foreground">
{snippet.description}
</p>
)}
</div>
<div className="bg-muted/30 rounded p-2 mb-3">
<code className="text-xs font-mono break-all line-clamp-2 text-muted-foreground">
{snippet.content}
</code>
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="default"
className="flex-1"
onClick={() => handleExecute(snippet)}
>
<Play className="w-3 h-3 mr-1" />
{t("snippets.run")}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.runTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() => handleCopy(snippet)}
>
<Copy className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.copyTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(snippet)}
>
<Edit className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.editTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() => handleDelete(snippet)}
className="hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.deleteTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
))}
</div>
</TooltipProvider>
)}
</TabsContent>
</Tabs>
</SidebarContent>
{isOpen && (
<div
className="absolute top-0 h-full cursor-col-resize z-[60]"
onMouseDown={handleMouseDown}
style={{
left: "-8px",
width: "18px",
backgroundColor: isResizing
? "var(--dark-active)"
: "transparent",
}}
onMouseEnter={(e) => {
if (!isResizing) {
e.currentTarget.style.backgroundColor =
"var(--dark-border-hover)";
}
}}
onMouseLeave={(e) => {
if (!isResizing) {
e.currentTarget.style.backgroundColor = "transparent";
}
}}
title="Drag to resize sidebar"
/>
)}
</Sidebar>
</div>
</SidebarProvider>
{!isOpen && (
<div
onClick={onClose}
className="fixed top-0 right-0 w-[10px] h-full cursor-pointer flex items-center justify-center rounded-tl-md rounded-bl-md"
style={{
zIndex: 9999,
backgroundColor: "#18181b",
border: "2px solid #27272a",
borderRight: "none",
}}
>
<ChevronRight size={10} />
</div>
)}
</div>
{showDialog && (
<div
className="fixed inset-0 flex items-center justify-center z-[9999999] bg-black/50 backdrop-blur-sm"
onClick={() => setShowDialog(false)}
>
<div
className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-6">
<h2 className="text-xl font-semibold text-white">
{editingSnippet ? t("snippets.edit") : t("snippets.create")}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{editingSnippet
? t("snippets.editDescription")
: t("snippets.createDescription")}
</p>
</div>
<div className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center gap-1">
{t("snippets.name")}
<span className="text-destructive">*</span>
</label>
<Input
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder={t("snippets.namePlaceholder")}
className={`${formErrors.name ? "border-destructive focus-visible:ring-destructive" : ""}`}
autoFocus
/>
{formErrors.name && (
<p className="text-xs text-destructive mt-1">
{t("snippets.nameRequired")}
</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("snippets.description")}
<span className="text-muted-foreground ml-1">
({t("common.optional")})
</span>
</label>
<Input
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder={t("snippets.descriptionPlaceholder")}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center gap-1">
{t("snippets.content")}
<span className="text-destructive">*</span>
</label>
<Textarea
value={formData.content}
onChange={(e) =>
setFormData({ ...formData, content: e.target.value })
}
placeholder={t("snippets.contentPlaceholder")}
className={`font-mono text-sm ${formErrors.content ? "border-destructive focus-visible:ring-destructive" : ""}`}
rows={10}
/>
{formErrors.content && (
<p className="text-xs text-destructive mt-1">
{t("snippets.contentRequired")}
</p>
)}
</div>
</div>
<Separator className="my-6" />
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setShowDialog(false)}
className="flex-1"
>
{t("common.cancel")}
</Button>
<Button onClick={handleSubmit} className="flex-1">
{editingSnippet ? t("snippets.edit") : t("snippets.create")}
</Button>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -1,69 +0,0 @@
import React from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Hammer, Wrench, FileText, Command } from "lucide-react";
import { useTranslation } from "react-i18next";
interface ToolsMenuProps {
onOpenSshTools: () => void;
onOpenSnippets: () => void;
onOpenCommandPalette: () => void;
}
export function ToolsMenu({
onOpenSshTools,
onOpenSnippets,
onOpenCommandPalette,
}: ToolsMenuProps): React.ReactElement {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-[30px] h-[30px] border-dark-border"
title={t("nav.tools")}
>
<Hammer className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-70 bg-dark-bg border-dark-border text-white"
>
<DropdownMenuItem
onClick={onOpenSshTools}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<Wrench className="h-4 w-4" />
<span className="flex-1">{t("sshTools.title")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={onOpenSnippets}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<FileText className="h-4 w-4" />
<span className="flex-1">{t("snippets.title")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={onOpenCommandPalette}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<Command className="h-4 w-4" />
<div className="flex items-center justify-between flex-1">
<span>Command Palette</span>
<kbd className="ml-2 px-1.5 py-0.5 text-xs font-semibold bg-dark-bg-darker border border-dark-border rounded">
LShift LShift
</kbd>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -35,10 +35,14 @@ interface TabData {
interface TerminalViewProps { interface TerminalViewProps {
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
rightSidebarOpen?: boolean;
rightSidebarWidth?: number;
} }
export function AppView({ export function AppView({
isTopbarOpen = true, isTopbarOpen = true,
rightSidebarOpen = false,
rightSidebarWidth = 400,
}: TerminalViewProps): React.ReactElement { }: TerminalViewProps): React.ReactElement {
const { tabs, currentTab, allSplitScreenTab, removeTab } = useTabs() as { const { tabs, currentTab, allSplitScreenTab, removeTab } = useTabs() as {
tabs: TabData[]; tabs: TabData[];
@@ -648,7 +652,7 @@ export function AppView({
style={{ style={{
background: containerBackground, background: containerBackground,
marginLeft: leftMarginPx, marginLeft: leftMarginPx,
marginRight: 17, marginRight: rightSidebarOpen ? rightSidebarWidth + 17 : 17,
marginTop: topMarginPx, marginTop: topMarginPx,
marginBottom: bottomMarginPx, marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`, height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,

View File

@@ -2,14 +2,12 @@ import React, { useState } from "react";
import { flushSync } from "react-dom"; import { flushSync } from "react-dom";
import { useSidebar } from "@/components/ui/sidebar.tsx"; import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { ChevronDown, ChevronUpIcon } from "lucide-react"; import { ChevronDown, ChevronUpIcon, Hammer } from "lucide-react";
import { Tab } from "@/ui/desktop/navigation/tabs/Tab.tsx"; import { Tab } from "@/ui/desktop/navigation/tabs/Tab.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.tsx"; import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.tsx";
import { SnippetsSidebar } from "@/ui/desktop/apps/terminal/SnippetsSidebar.tsx"; import { SSHUtilitySidebar } from "@/ui/desktop/apps/tools/SSHUtilitySidebar.tsx";
import { SSHToolsSidebar } from "@/ui/desktop/apps/tools/SSHToolsSidebar.tsx";
import { ToolsMenu } from "@/ui/desktop/apps/tools/ToolsMenu.tsx";
interface TabData { interface TabData {
id: number; id: number;
@@ -27,12 +25,14 @@ interface TopNavbarProps {
isTopbarOpen: boolean; isTopbarOpen: boolean;
setIsTopbarOpen: (open: boolean) => void; setIsTopbarOpen: (open: boolean) => void;
onOpenCommandPalette: () => void; onOpenCommandPalette: () => void;
onRightSidebarStateChange?: (isOpen: boolean, width: number) => void;
} }
export function TopNavbar({ export function TopNavbar({
isTopbarOpen, isTopbarOpen,
setIsTopbarOpen, setIsTopbarOpen,
onOpenCommandPalette, onOpenCommandPalette,
onRightSidebarStateChange,
}: TopNavbarProps): React.ReactElement { }: TopNavbarProps): React.ReactElement {
const { state } = useSidebar(); const { state } = useSidebar();
const { const {
@@ -56,8 +56,25 @@ export function TopNavbar({
state === "collapsed" ? "26px" : "calc(var(--sidebar-width) + 8px)"; state === "collapsed" ? "26px" : "calc(var(--sidebar-width) + 8px)";
const { t } = useTranslation(); const { t } = useTranslation();
const [toolsSheetOpen, setToolsSheetOpen] = useState(false); const [toolsSidebarOpen, setToolsSidebarOpen] = useState(false);
const [snippetsSidebarOpen, setSnippetsSidebarOpen] = useState(false); const [rightSidebarWidth, setRightSidebarWidth] = useState<number>(() => {
const saved = localStorage.getItem("rightSidebarWidth");
return saved !== null ? parseInt(saved, 10) : 400;
});
React.useEffect(() => {
localStorage.setItem("rightSidebarWidth", String(rightSidebarWidth));
}, [rightSidebarWidth]);
React.useEffect(() => {
if (onRightSidebarStateChange) {
onRightSidebarStateChange(toolsSidebarOpen, rightSidebarWidth);
}
}, [toolsSidebarOpen, rightSidebarWidth, onRightSidebarStateChange]);
const rightPosition = toolsSidebarOpen
? `${rightSidebarWidth + 17}px`
: "17px";
const [justDroppedTabId, setJustDroppedTabId] = useState<number | null>(null); const [justDroppedTabId, setJustDroppedTabId] = useState<number | null>(null);
const [isInDropAnimation, setIsInDropAnimation] = useState(false); const [isInDropAnimation, setIsInDropAnimation] = useState(false);
const [dragState, setDragState] = useState<{ const [dragState, setDragState] = useState<{
@@ -302,7 +319,7 @@ export function TopNavbar({
style={{ style={{
top: isTopbarOpen ? "0.5rem" : "-3rem", top: isTopbarOpen ? "0.5rem" : "-3rem",
left: leftPosition, left: leftPosition,
right: "17px", right: rightPosition,
backgroundColor: "#18181b", backgroundColor: "#18181b",
}} }}
> >
@@ -475,11 +492,14 @@ export function TopNavbar({
<div className="flex items-center justify-center gap-2 flex-1 px-2"> <div className="flex items-center justify-center gap-2 flex-1 px-2">
<TabDropdown /> <TabDropdown />
<ToolsMenu <Button
onOpenSshTools={() => setToolsSheetOpen(true)} variant="outline"
onOpenSnippets={() => setSnippetsSidebarOpen(true)} onClick={() => setToolsSidebarOpen(!toolsSidebarOpen)}
onOpenCommandPalette={onOpenCommandPalette} className="w-[30px] h-[30px] border-dark-border"
/> title={t("nav.tools")}
>
<Hammer className="h-4 w-4" />
</Button>
<Button <Button
variant="outline" variant="outline"
@@ -497,7 +517,7 @@ export function TopNavbar({
className="fixed top-0 cursor-pointer flex items-center justify-center rounded-bl-md rounded-br-md" className="fixed top-0 cursor-pointer flex items-center justify-center rounded-bl-md rounded-br-md"
style={{ style={{
left: leftPosition, left: leftPosition,
right: "17px", right: rightPosition,
height: "10px", height: "10px",
zIndex: 9999, zIndex: 9999,
backgroundColor: "#18181b", backgroundColor: "#18181b",
@@ -509,15 +529,12 @@ export function TopNavbar({
</div> </div>
)} )}
<SSHToolsSidebar <SSHUtilitySidebar
isOpen={toolsSheetOpen} isOpen={toolsSidebarOpen}
onClose={() => setToolsSheetOpen(false)} onClose={() => setToolsSidebarOpen(false)}
/> onSnippetExecute={handleSnippetExecute}
sidebarWidth={rightSidebarWidth}
<SnippetsSidebar setSidebarWidth={setRightSidebarWidth}
isOpen={snippetsSidebarOpen}
onClose={() => setSnippetsSidebarOpen(false)}
onExecute={handleSnippetExecute}
/> />
</div> </div>
); );

View File

@@ -26,6 +26,8 @@ import { useSidebar } from "@/components/ui/sidebar.tsx";
interface UserProfileProps { interface UserProfileProps {
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
rightSidebarOpen?: boolean;
rightSidebarWidth?: number;
} }
async function handleLogout() { async function handleLogout() {
@@ -68,7 +70,11 @@ async function handleLogout() {
} }
} }
export function UserProfile({ isTopbarOpen = true }: UserProfileProps) { export function UserProfile({
isTopbarOpen = true,
rightSidebarOpen = false,
rightSidebarWidth = 400,
}: UserProfileProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { state: sidebarState } = useSidebar(); const { state: sidebarState } = useSidebar();
const [userInfo, setUserInfo] = useState<{ const [userInfo, setUserInfo] = useState<{
@@ -156,7 +162,7 @@ export function UserProfile({ isTopbarOpen = true }: UserProfileProps) {
const bottomMarginPx = 8; const bottomMarginPx = 8;
const wrapperStyle: React.CSSProperties = { const wrapperStyle: React.CSSProperties = {
marginLeft: leftMarginPx, marginLeft: leftMarginPx,
marginRight: 17, marginRight: rightSidebarOpen ? rightSidebarWidth + 17 : 17,
marginTop: topMarginPx, marginTop: topMarginPx,
marginBottom: bottomMarginPx, marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`, height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,