fix: General bug fixes/small feature improvements

This commit is contained in:
LukeGus
2025-11-10 16:23:46 -06:00
parent 966758ecf8
commit 7e8105a938
31 changed files with 1774 additions and 634 deletions

View File

@@ -8,6 +8,7 @@ import {
useTabs,
} from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
import { CommandHistoryProvider } from "@/ui/desktop/contexts/CommandHistoryContext.tsx";
import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx";
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
import { Toaster } from "@/components/ui/sonner.tsx";
@@ -37,11 +38,16 @@ function AppContent() {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.code === "ShiftLeft") {
if (event.repeat) {
return;
}
const now = Date.now();
if (now - lastShiftPressTime.current < 300) {
setIsCommandPaletteOpen((isOpen) => !isOpen);
lastShiftPressTime.current = 0; // Reset on double press
} else {
lastShiftPressTime.current = now;
}
lastShiftPressTime.current = now;
}
if (event.key === "Escape") {
setIsCommandPaletteOpen(false);
@@ -314,7 +320,7 @@ function AppContent() {
"subtitleFade 1.6s cubic-bezier(0.4, 0, 0.2, 1) forwards",
}}
>
SSH TERMINAL MANAGER
SSH SERVER MANAGER
</div>
</div>
</div>
@@ -421,7 +427,9 @@ function AppContent() {
function DesktopApp() {
return (
<TabProvider>
<AppContent />
<CommandHistoryProvider>
<AppContent />
</CommandHistoryProvider>
</TabProvider>
);
}

View File

@@ -1072,109 +1072,113 @@ export function AdminSettings({
</div>
) : (
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Device</TableHead>
<TableHead className="px-4">User</TableHead>
<TableHead className="px-4">Created</TableHead>
<TableHead className="px-4">Last Active</TableHead>
<TableHead className="px-4">Expires</TableHead>
<TableHead className="px-4">
{t("admin.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session) => {
const DeviceIcon =
session.deviceType === "desktop"
? Monitor
: session.deviceType === "mobile"
? Smartphone
: Globe;
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="px-4">Device</TableHead>
<TableHead className="px-4">User</TableHead>
<TableHead className="px-4">Created</TableHead>
<TableHead className="px-4">Last Active</TableHead>
<TableHead className="px-4">Expires</TableHead>
<TableHead className="px-4">
{t("admin.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sessions.map((session) => {
const DeviceIcon =
session.deviceType === "desktop"
? Monitor
: session.deviceType === "mobile"
? Smartphone
: Globe;
const createdDate = new Date(session.createdAt);
const lastActiveDate = new Date(session.lastActiveAt);
const expiresDate = new Date(session.expiresAt);
const createdDate = new Date(session.createdAt);
const lastActiveDate = new Date(
session.lastActiveAt,
);
const expiresDate = new Date(session.expiresAt);
const formatDate = (date: Date) =>
date.toLocaleDateString() +
" " +
date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
const formatDate = (date: Date) =>
date.toLocaleDateString() +
" " +
date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
return (
<TableRow
key={session.id}
className={
session.isRevoked ? "opacity-50" : undefined
}
>
<TableCell className="px-4">
<div className="flex items-center gap-2">
<DeviceIcon className="h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium text-sm">
{session.deviceInfo}
</span>
{session.isRevoked && (
<span className="text-xs text-red-600">
Revoked
return (
<TableRow
key={session.id}
className={
session.isRevoked ? "opacity-50" : undefined
}
>
<TableCell className="px-4">
<div className="flex items-center gap-2">
<DeviceIcon className="h-4 w-4" />
<div className="flex flex-col">
<span className="font-medium text-sm">
{session.deviceInfo}
</span>
)}
{session.isRevoked && (
<span className="text-xs text-red-600">
Revoked
</span>
)}
</div>
</div>
</div>
</TableCell>
<TableCell className="px-4">
{session.username || session.userId}
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(createdDate)}
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(lastActiveDate)}
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(expiresDate)}
</TableCell>
<TableCell className="px-4">
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRevokeSession(session.id)
}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={session.isRevoked}
>
<Trash2 className="h-4 w-4" />
</Button>
{session.username && (
</TableCell>
<TableCell className="px-4">
{session.username || session.userId}
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(createdDate)}
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(lastActiveDate)}
</TableCell>
<TableCell className="px-4 text-sm text-muted-foreground">
{formatDate(expiresDate)}
</TableCell>
<TableCell className="px-4">
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRevokeAllUserSessions(
session.userId,
)
handleRevokeSession(session.id)
}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50 text-xs"
title="Revoke all sessions for this user"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={session.isRevoked}
>
Revoke All
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{session.username && (
<Button
variant="ghost"
size="sm"
onClick={() =>
handleRevokeAllUserSessions(
session.userId,
)
}
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50 text-xs"
title="Revoke all sessions for this user"
>
Revoke All
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
)}
</div>
@@ -1185,8 +1189,8 @@ export function AdminSettings({
<h3 className="text-lg font-semibold">
{t("admin.adminManagement")}
</h3>
<div className="space-y-4 p-6 border rounded-md bg-muted/50">
<h4 className="font-medium">{t("admin.makeUserAdmin")}</h4>
<div className="space-y-4 p-4 border rounded-md bg-dark-bg-panel">
<h4 className="font-semibold">{t("admin.makeUserAdmin")}</h4>
<form onSubmit={handleMakeUserAdmin} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-admin-username">
@@ -1279,32 +1283,17 @@ export function AdminSettings({
<TabsContent value="security" className="space-y-6">
<div className="space-y-4">
<div className="flex items-center gap-3">
<Database className="h-5 w-5" />
<h3 className="text-lg font-semibold">
{t("admin.databaseSecurity")}
</h3>
</div>
<div className="p-4 border rounded bg-card">
<div className="flex items-center gap-2">
<Lock className="h-4 w-4 text-green-500" />
<div>
<div className="text-sm font-medium">
{t("admin.encryptionStatus")}
</div>
<div className="text-xs text-green-500">
{t("admin.encryptionEnabled")}
</div>
</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="p-4 border rounded bg-card">
<div className="p-4 border rounded-lg bg-dark-bg-panel">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Download className="h-4 w-4 text-blue-500" />
<h4 className="font-medium">{t("admin.export")}</h4>
<h4 className="font-semibold">{t("admin.export")}</h4>
</div>
<p className="text-xs text-muted-foreground">
{t("admin.exportDescription")}
@@ -1351,11 +1340,11 @@ export function AdminSettings({
</div>
</div>
<div className="p-4 border rounded bg-card">
<div className="p-4 border rounded-lg bg-dark-bg-panel">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Upload className="h-4 w-4 text-green-500" />
<h4 className="font-medium">{t("admin.import")}</h4>
<h4 className="font-semibold">{t("admin.import")}</h4>
</div>
<p className="text-xs text-muted-foreground">
{t("admin.importDescription")}

View File

@@ -369,6 +369,18 @@ export function FileManagerContextMenu({
menuItems.push({ separator: true } as MenuItem);
}
if (isSingleFile && onProperties) {
menuItems.push({
icon: <Info className="w-4 h-4" />,
label: t("fileManager.properties"),
action: () => onProperties(files[0]),
});
}
if ((isSingleFile && onProperties) || onDelete) {
menuItems.push({ separator: true } as MenuItem);
}
if (onDelete) {
menuItems.push({
icon: <Trash2 className="w-4 h-4" />,
@@ -380,18 +392,6 @@ export function FileManagerContextMenu({
danger: true,
});
}
if (onDelete) {
menuItems.push({ separator: true } as MenuItem);
}
if (isSingleFile && onProperties) {
menuItems.push({
icon: <Info className="w-4 h-4" />,
label: t("fileManager.properties"),
action: () => onProperties(files[0]),
});
}
} else {
if (onOpenTerminal && currentPath) {
menuItems.push({

View File

@@ -73,17 +73,22 @@ export function CompressDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
<DialogHeader>
<DialogTitle>{t("fileManager.compressFiles")}</DialogTitle>
<DialogDescription>
<DialogDescription className="text-muted-foreground">
{t("fileManager.compressFilesDesc", { count: fileNames.length })}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="archiveName">{t("fileManager.archiveName")}</Label>
<div className="space-y-6 py-4">
<div className="space-y-3">
<Label
className="text-base font-semibold text-foreground"
htmlFor="archiveName"
>
{t("fileManager.archiveName")}
</Label>
<Input
id="archiveName"
value={archiveName}
@@ -98,8 +103,13 @@ export function CompressDialog({
/>
</div>
<div className="grid gap-2">
<Label htmlFor="format">{t("fileManager.compressionFormat")}</Label>
<div className="space-y-3">
<Label
className="text-base font-semibold text-foreground"
htmlFor="format"
>
{t("fileManager.compressionFormat")}
</Label>
<Select value={format} onValueChange={setFormat}>
<SelectTrigger id="format">
<SelectValue />
@@ -115,18 +125,18 @@ export function CompressDialog({
</Select>
</div>
<div className="rounded-md bg-muted p-3">
<p className="text-sm text-muted-foreground mb-2">
<div className="rounded-md bg-dark-hover/50 border border-dark-border p-3">
<p className="text-sm text-gray-400 mb-2">
{t("fileManager.selectedFiles")}:
</p>
<ul className="text-sm space-y-1">
{fileNames.slice(0, 5).map((name, index) => (
<li key={index} className="truncate">
<li key={index} className="truncate text-foreground">
{name}
</li>
))}
{fileNames.length > 5 && (
<li className="text-muted-foreground italic">
<li className="text-gray-400 italic">
{t("fileManager.andMoreFiles", {
count: fileNames.length - 5,
})}

View File

@@ -1,6 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button.tsx";
import {
@@ -49,6 +50,18 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command.tsx";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover.tsx";
import { Slider } from "@/components/ui/slider.tsx";
import {
Accordion,
@@ -66,7 +79,7 @@ import {
} from "@/constants/terminal-themes";
import { TerminalPreview } from "@/ui/desktop/apps/terminal/TerminalPreview.tsx";
import type { TerminalConfig } from "@/types";
import { Plus, X } from "lucide-react";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
interface SSHHost {
id: number;
@@ -94,6 +107,9 @@ interface SSHHost {
retryInterval: number;
autoStart: boolean;
}>;
jumpHosts?: Array<{
hostId: number;
}>;
statsConfig?: StatsConfig;
terminalConfig?: TerminalConfig;
createdAt: string;
@@ -113,6 +129,7 @@ export function HostManagerEditor({
const { t } = useTranslation();
const [folders, setFolders] = useState<string[]>([]);
const [sshConfigurations, setSshConfigurations] = useState<string[]>([]);
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [credentials, setCredentials] = useState<
Array<{ id: number; username: string; authType: string }>
>([]);
@@ -146,6 +163,7 @@ export function HostManagerEditor({
getCredentials(),
getSnippets(),
]);
setHosts(hostsData);
setCredentials(credentialsData);
setSnippets(Array.isArray(snippetsData) ? snippetsData : []);
@@ -327,6 +345,13 @@ export function HostManagerEditor({
})
.optional(),
forceKeyboardInteractive: z.boolean().optional(),
jumpHosts: z
.array(
z.object({
hostId: z.number().min(1),
}),
)
.default([]),
})
.superRefine((data, ctx) => {
if (data.authType === "none") {
@@ -414,6 +439,7 @@ export function HostManagerEditor({
enableFileManager: true,
defaultPath: "/",
tunnelConnections: [],
jumpHosts: [],
statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false,
@@ -433,7 +459,7 @@ export function HostManagerEditor({
}
}
}
}, [authTab, credentials, form]);
}, [authTab, credentials, form.getValues, form.setValue]);
useEffect(() => {
if (editingHost) {
@@ -477,7 +503,7 @@ export function HostManagerEditor({
port: cleanedHost.port || 22,
username: cleanedHost.username || "",
folder: cleanedHost.folder || "",
tags: cleanedHost.tags || [],
tags: Array.isArray(cleanedHost.tags) ? cleanedHost.tags : [],
pin: Boolean(cleanedHost.pin),
authType: defaultAuthType as "password" | "key" | "credential" | "none",
credentialId: null,
@@ -492,9 +518,22 @@ export function HostManagerEditor({
enableTunnel: Boolean(cleanedHost.enableTunnel),
enableFileManager: Boolean(cleanedHost.enableFileManager),
defaultPath: cleanedHost.defaultPath || "/",
tunnelConnections: cleanedHost.tunnelConnections || [],
tunnelConnections: Array.isArray(cleanedHost.tunnelConnections)
? cleanedHost.tunnelConnections
: [],
jumpHosts: Array.isArray(cleanedHost.jumpHosts)
? cleanedHost.jumpHosts
: [],
statsConfig: parsedStatsConfig,
terminalConfig: cleanedHost.terminalConfig || DEFAULT_TERMINAL_CONFIG,
terminalConfig: {
...DEFAULT_TERMINAL_CONFIG,
...(cleanedHost.terminalConfig || {}),
environmentVariables: Array.isArray(
cleanedHost.terminalConfig?.environmentVariables,
)
? cleanedHost.terminalConfig.environmentVariables
: [],
},
forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive),
};
@@ -542,6 +581,7 @@ export function HostManagerEditor({
enableFileManager: true,
defaultPath: "/",
tunnelConnections: [],
jumpHosts: [],
statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false,
@@ -601,6 +641,7 @@ export function HostManagerEditor({
enableFileManager: Boolean(data.enableFileManager),
defaultPath: data.defaultPath || "/",
tunnelConnections: data.tunnelConnections || [],
jumpHosts: data.jumpHosts || [],
statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG,
terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: Boolean(data.forceKeyboardInteractive),
@@ -1387,6 +1428,147 @@ export function HostManagerEditor({
</FormItem>
)}
/>
<Separator className="my-6" />
<FormLabel className="mb-3 font-bold">
{t("hosts.jumpHosts")}
</FormLabel>
<Alert className="mt-2 mb-4">
<AlertDescription>
{t("hosts.jumpHostsDescription")}
</AlertDescription>
</Alert>
<FormField
control={form.control}
name="jumpHosts"
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>{t("hosts.jumpHostChain")}</FormLabel>
<FormControl>
<div className="space-y-3">
{field.value.map((jumpHost, index) => {
const selectedHost = hosts.find(
(h) => h.id === jumpHost.hostId,
);
const [open, setOpen] = React.useState(false);
return (
<div
key={index}
className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30"
>
<div className="flex items-center gap-2 flex-1">
<span className="text-sm font-medium text-muted-foreground">
{index + 1}.
</span>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="flex-1 justify-between"
>
{selectedHost
? `${selectedHost.name || `${selectedHost.username}@${selectedHost.ip}`}`
: t("hosts.selectServer")}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput
placeholder={t(
"hosts.searchServers",
)}
/>
<CommandEmpty>
{t("hosts.noServerFound")}
</CommandEmpty>
<CommandGroup className="max-h-[300px] overflow-y-auto">
{hosts
.filter(
(h) =>
!editingHost ||
h.id !== editingHost.id,
)
.map((host) => (
<CommandItem
key={host.id}
value={`${host.name} ${host.ip} ${host.username}`}
onSelect={() => {
const newJumpHosts = [
...field.value,
];
newJumpHosts[index] = {
hostId: host.id,
};
field.onChange(
newJumpHosts,
);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
jumpHost.hostId ===
host.id
? "opacity-100"
: "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">
{host.name ||
`${host.username}@${host.ip}`}
</span>
<span className="text-xs text-muted-foreground">
{host.username}@{host.ip}:
{host.port}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => {
const newJumpHosts = field.value.filter(
(_, i) => i !== index,
);
field.onChange(newJumpHosts);
}}
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
field.onChange([...field.value, { hostId: 0 }]);
}}
>
<Plus className="h-4 w-4 mr-2" />
{t("hosts.addJumpHost")}
</Button>
</div>
</FormControl>
<FormDescription>
{t("hosts.jumpHostsOrder")}
</FormDescription>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="terminal" className="space-y-1">
<FormField
@@ -1417,7 +1599,9 @@ export function HostManagerEditor({
</h1>
<Accordion type="multiple" className="w-full">
<AccordionItem value="appearance">
<AccordionTrigger>{t("hosts.appearance")}</AccordionTrigger>
<AccordionTrigger>
{t("hosts.appearance")}
</AccordionTrigger>
<AccordionContent className="space-y-4 pt-4">
<div className="space-y-2">
<label className="text-sm font-medium">
@@ -1452,7 +1636,9 @@ export function HostManagerEditor({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectTheme")} />
<SelectValue
placeholder={t("hosts.selectTheme")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -1484,7 +1670,9 @@ export function HostManagerEditor({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectFont")} />
<SelectValue
placeholder={t("hosts.selectFont")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -1510,7 +1698,11 @@ export function HostManagerEditor({
name="terminalConfig.fontSize"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.fontSizeValue", { value: field.value })}</FormLabel>
<FormLabel>
{t("hosts.fontSizeValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={8}
@@ -1535,7 +1727,9 @@ export function HostManagerEditor({
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.letterSpacingValue", { value: field.value })}
{t("hosts.letterSpacingValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
@@ -1560,7 +1754,11 @@ export function HostManagerEditor({
name="terminalConfig.lineHeight"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.lineHeightValue", { value: field.value })}</FormLabel>
<FormLabel>
{t("hosts.lineHeightValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
min={1}
@@ -1591,15 +1789,21 @@ export function HostManagerEditor({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectCursorStyle")} />
<SelectValue
placeholder={t("hosts.selectCursorStyle")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="block">{t("hosts.cursorStyleBlock")}</SelectItem>
<SelectItem value="block">
{t("hosts.cursorStyleBlock")}
</SelectItem>
<SelectItem value="underline">
{t("hosts.cursorStyleUnderline")}
</SelectItem>
<SelectItem value="bar">{t("hosts.cursorStyleBar")}</SelectItem>
<SelectItem value="bar">
{t("hosts.cursorStyleBar")}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
@@ -1641,7 +1845,9 @@ export function HostManagerEditor({
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.scrollbackBufferValue", { value: field.value })}
{t("hosts.scrollbackBufferValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
@@ -1673,14 +1879,24 @@ export function HostManagerEditor({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectBellStyle")} />
<SelectValue
placeholder={t("hosts.selectBellStyle")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">{t("hosts.bellStyleNone")}</SelectItem>
<SelectItem value="sound">{t("hosts.bellStyleSound")}</SelectItem>
<SelectItem value="visual">{t("hosts.bellStyleVisual")}</SelectItem>
<SelectItem value="both">{t("hosts.bellStyleBoth")}</SelectItem>
<SelectItem value="none">
{t("hosts.bellStyleNone")}
</SelectItem>
<SelectItem value="sound">
{t("hosts.bellStyleSound")}
</SelectItem>
<SelectItem value="visual">
{t("hosts.bellStyleVisual")}
</SelectItem>
<SelectItem value="both">
{t("hosts.bellStyleBoth")}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
@@ -1696,7 +1912,9 @@ export function HostManagerEditor({
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>{t("hosts.rightClickSelectsWord")}</FormLabel>
<FormLabel>
{t("hosts.rightClickSelectsWord")}
</FormLabel>
<FormDescription>
{t("hosts.rightClickSelectsWordDesc")}
</FormDescription>
@@ -1716,20 +1934,30 @@ export function HostManagerEditor({
name="terminalConfig.fastScrollModifier"
render={({ field }) => (
<FormItem>
<FormLabel>{t("hosts.fastScrollModifier")}</FormLabel>
<FormLabel>
{t("hosts.fastScrollModifier")}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectModifier")} />
<SelectValue
placeholder={t("hosts.selectModifier")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="alt">{t("hosts.modifierAlt")}</SelectItem>
<SelectItem value="ctrl">{t("hosts.modifierCtrl")}</SelectItem>
<SelectItem value="shift">{t("hosts.modifierShift")}</SelectItem>
<SelectItem value="alt">
{t("hosts.modifierAlt")}
</SelectItem>
<SelectItem value="ctrl">
{t("hosts.modifierCtrl")}
</SelectItem>
<SelectItem value="shift">
{t("hosts.modifierShift")}
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
@@ -1745,7 +1973,9 @@ export function HostManagerEditor({
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.fastScrollSensitivityValue", { value: field.value })}
{t("hosts.fastScrollSensitivityValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
@@ -1771,7 +2001,9 @@ export function HostManagerEditor({
render={({ field }) => (
<FormItem>
<FormLabel>
{t("hosts.minimumContrastRatioValue", { value: field.value })}
{t("hosts.minimumContrastRatioValue", {
value: field.value,
})}
</FormLabel>
<FormControl>
<Slider
@@ -1802,7 +2034,9 @@ export function HostManagerEditor({
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<FormLabel>{t("hosts.sshAgentForwarding")}</FormLabel>
<FormLabel>
{t("hosts.sshAgentForwarding")}
</FormLabel>
<FormDescription>
{t("hosts.sshAgentForwardingDesc")}
</FormDescription>
@@ -1829,7 +2063,11 @@ export function HostManagerEditor({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectBackspaceMode")} />
<SelectValue
placeholder={t(
"hosts.selectBackspaceMode",
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -1865,7 +2103,9 @@ export function HostManagerEditor({
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t("hosts.selectSnippet")} />
<SelectValue
placeholder={t("hosts.selectSnippet")}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
@@ -1882,7 +2122,9 @@ export function HostManagerEditor({
/>
</div>
<div className="max-h-[200px] overflow-y-auto">
<SelectItem value="none">{t("hosts.snippetNone")}</SelectItem>
<SelectItem value="none">
{t("hosts.snippetNone")}
</SelectItem>
{snippets
.filter((snippet) =>
snippet.name

View File

@@ -105,14 +105,18 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
fetchFolderMetadata();
};
const handleFoldersRefresh = () => {
fetchFolderMetadata();
};
window.addEventListener("hosts:refresh", handleHostsRefresh);
window.addEventListener("ssh-hosts:changed", handleHostsRefresh);
window.addEventListener("folders:changed", handleHostsRefresh);
window.addEventListener("folders:changed", handleFoldersRefresh);
return () => {
window.removeEventListener("hosts:refresh", handleHostsRefresh);
window.removeEventListener("ssh-hosts:changed", handleHostsRefresh);
window.removeEventListener("folders:changed", handleHostsRefresh);
window.removeEventListener("folders:changed", handleFoldersRefresh);
};
}, []);

View File

@@ -1,238 +0,0 @@
import React, { useState, useEffect, useRef } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { Search, Clock, X, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
interface CommandHistoryDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
commands: string[];
onSelectCommand: (command: string) => void;
onDeleteCommand?: (command: string) => void;
isLoading?: boolean;
}
export function CommandHistoryDialog({
open,
onOpenChange,
commands,
onSelectCommand,
onDeleteCommand,
isLoading = false,
}: CommandHistoryDialogProps) {
const [searchQuery, setSearchQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const selectedRef = useRef<HTMLDivElement>(null);
// Filter commands based on search query
const filteredCommands = searchQuery
? commands.filter((cmd) =>
cmd.toLowerCase().includes(searchQuery.toLowerCase()),
)
: commands;
// Reset state when dialog opens/closes
useEffect(() => {
if (open) {
setSearchQuery("");
setSelectedIndex(0);
// Focus search input
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [open]);
// Scroll selected item into view
useEffect(() => {
if (selectedRef.current && listRef.current) {
selectedRef.current.scrollIntoView({
block: "nearest",
behavior: "smooth",
});
}
}, [selectedIndex]);
// Handle keyboard navigation
const handleKeyDown = (e: React.KeyboardEvent) => {
if (filteredCommands.length === 0) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((prev) =>
prev < filteredCommands.length - 1 ? prev + 1 : prev,
);
break;
case "ArrowUp":
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0));
break;
case "Enter":
e.preventDefault();
if (filteredCommands[selectedIndex]) {
onSelectCommand(filteredCommands[selectedIndex]);
onOpenChange(false);
}
break;
case "Escape":
e.preventDefault();
onOpenChange(false);
break;
}
};
const handleSelect = (command: string) => {
onSelectCommand(command);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px] p-0 gap-0">
<DialogHeader className="px-6 pt-6 pb-4">
<DialogTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Command History
</DialogTitle>
</DialogHeader>
<div className="px-6 pb-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={inputRef}
placeholder="Search commands... (↑↓ to navigate, Enter to select)"
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setSelectedIndex(0);
}}
onKeyDown={handleKeyDown}
className="pl-10 pr-10"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
onClick={() => {
setSearchQuery("");
inputRef.current?.focus();
}}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
<ScrollArea ref={listRef} className="h-[400px] px-6 pb-6">
{isLoading ? (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
Loading history...
</div>
</div>
) : filteredCommands.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
{searchQuery ? (
<>
<Search className="h-12 w-12 mb-2 opacity-20" />
<p>No commands found matching "{searchQuery}"</p>
</>
) : (
<>
<Clock className="h-12 w-12 mb-2 opacity-20" />
<p>No command history yet</p>
<p className="text-sm">
Execute commands to build your history
</p>
</>
)}
</div>
) : (
<div className="space-y-1">
{filteredCommands.map((command, index) => (
<div
key={index}
ref={index === selectedIndex ? selectedRef : null}
className={cn(
"px-4 py-2.5 rounded-md transition-colors group",
"font-mono text-sm flex items-center justify-between gap-2",
"hover:bg-accent",
index === selectedIndex &&
"bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/50",
)}
onMouseEnter={() => setSelectedIndex(index)}
>
<span
className="flex-1 cursor-pointer"
onClick={() => handleSelect(command)}
>
{command}
</span>
{onDeleteCommand && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
onDeleteCommand(command);
}}
title="Delete command"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
))}
</div>
)}
</ScrollArea>
<div className="px-6 py-3 border-t border-border bg-muted/30">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-4">
<span>
<kbd className="px-1.5 py-0.5 bg-background border border-border rounded">
</kbd>{" "}
Navigate
</span>
<span>
<kbd className="px-1.5 py-0.5 bg-background border border-border rounded">
Enter
</kbd>{" "}
Select
</span>
<span>
<kbd className="px-1.5 py-0.5 bg-background border border-border rounded">
Esc
</kbd>{" "}
Close
</span>
</div>
<span>
{filteredCommands.length} command
{filteredCommands.length !== 1 ? "s" : ""}
</span>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -28,8 +28,8 @@ import {
} from "@/constants/terminal-themes";
import type { TerminalConfig } from "@/types";
import { useCommandTracker } from "@/ui/hooks/useCommandTracker";
import { useCommandHistory } from "@/ui/hooks/useCommandHistory";
import { CommandHistoryDialog } from "./CommandHistoryDialog";
import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory";
import { useCommandHistory } from "@/ui/desktop/contexts/CommandHistoryContext.tsx";
import { CommandAutocomplete } from "./CommandAutocomplete";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
@@ -91,6 +91,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const { t } = useTranslation();
const { instance: terminal, ref: xtermRef } = useXTerm();
const commandHistoryContext = useCommandHistory();
const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig };
const themeColors =
@@ -183,20 +184,24 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
useEffect(() => {
if (showHistoryDialog && hostConfig.id) {
setIsLoadingHistory(true);
commandHistoryContext.setIsLoading(true);
import("@/ui/main-axios.ts")
.then((module) => module.getCommandHistory(hostConfig.id!))
.then((history) => {
setCommandHistory(history);
commandHistoryContext.setCommandHistory(history);
})
.catch((error) => {
console.error("Failed to load command history:", error);
setCommandHistory([]);
commandHistoryContext.setCommandHistory([]);
})
.finally(() => {
setIsLoadingHistory(false);
commandHistoryContext.setIsLoading(false);
});
}
}, [showHistoryDialog, hostConfig.id]);
}, [showHistoryDialog, hostConfig.id, commandHistoryContext]);
// Load command history for autocomplete on mount (Stage 3)
useEffect(() => {
@@ -898,6 +903,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
[terminal],
);
// Register handlers with context
useEffect(() => {
commandHistoryContext.setOnSelectCommand(handleSelectCommand);
}, [handleSelectCommand, commandHistoryContext]);
// Handle autocomplete selection (mouse click)
const handleAutocompleteSelect = useCallback(
(selectedCommand: string) => {
@@ -944,7 +954,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
await deleteCommandFromHistory(hostConfig.id, command);
// Update local state
setCommandHistory((prev) => prev.filter((cmd) => cmd !== command));
setCommandHistory((prev) => {
const newHistory = prev.filter((cmd) => cmd !== command);
commandHistoryContext.setCommandHistory(newHistory);
return newHistory;
});
// Update autocomplete history
autocompleteHistory.current = autocompleteHistory.current.filter(
@@ -956,9 +970,14 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
console.error("Failed to delete command from history:", error);
}
},
[hostConfig.id],
[hostConfig.id, commandHistoryContext],
);
// Register delete handler with context
useEffect(() => {
commandHistoryContext.setOnDeleteCommand(handleDeleteCommand);
}, [handleDeleteCommand, commandHistoryContext]);
useEffect(() => {
if (!terminal || !xtermRef.current) return;
@@ -1074,6 +1093,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
e.preventDefault();
e.stopPropagation();
setShowHistoryDialog(true);
// Also trigger the sidebar to open
if (commandHistoryContext.openCommandHistory) {
commandHistoryContext.openCommandHistory();
}
return false;
}
@@ -1476,15 +1499,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
backgroundColor={backgroundColor}
/>
<CommandHistoryDialog
open={showHistoryDialog}
onOpenChange={setShowHistoryDialog}
commands={commandHistory}
onSelectCommand={handleSelectCommand}
onDeleteCommand={handleDeleteCommand}
isLoading={isLoadingHistory}
/>
<CommandAutocomplete
visible={showAutocomplete}
suggestions={autocompleteSuggestions}

View File

@@ -23,7 +23,17 @@ import {
SidebarProvider,
SidebarGroupLabel,
} from "@/components/ui/sidebar.tsx";
import { Plus, Play, Edit, Trash2, Copy, X, RotateCcw } from "lucide-react";
import {
Plus,
Play,
Edit,
Trash2,
Copy,
X,
RotateCcw,
Search,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
@@ -56,6 +66,12 @@ interface SSHUtilitySidebarProps {
onSnippetExecute: (content: string) => void;
sidebarWidth: number;
setSidebarWidth: (width: number) => void;
commandHistory?: string[];
onSelectCommand?: (command: string) => void;
onDeleteCommand?: (command: string) => void;
isHistoryLoading?: boolean;
initialTab?: string;
onTabChange?: () => void;
}
export function SSHUtilitySidebar({
@@ -64,15 +80,39 @@ export function SSHUtilitySidebar({
onSnippetExecute,
sidebarWidth,
setSidebarWidth,
commandHistory = [],
onSelectCommand,
onDeleteCommand,
isHistoryLoading = false,
initialTab,
onTabChange,
}: SSHUtilitySidebarProps) {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const { tabs } = useTabs() as { tabs: TabData[] };
const [activeTab, setActiveTab] = useState("ssh-tools");
const [activeTab, setActiveTab] = useState(initialTab || "ssh-tools");
// Update active tab when initialTab changes
useEffect(() => {
if (initialTab && isOpen) {
setActiveTab(initialTab);
}
}, [initialTab, isOpen]);
// Call onTabChange when active tab changes
const handleTabChange = (tab: string) => {
setActiveTab(tab);
if (onTabChange) {
onTabChange();
}
};
// SSH Tools state
const [isRecording, setIsRecording] = useState(false);
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
const [rightClickCopyPaste, setRightClickCopyPaste] = useState<boolean>(
() => getCookie("rightClickCopyPaste") === "true",
);
// Snippets state
const [snippets, setSnippets] = useState<Snippet[]>([]);
@@ -92,6 +132,10 @@ export function SSHUtilitySidebar({
[],
);
// Command History state
const [searchQuery, setSearchQuery] = useState("");
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
// Resize state
const [isResizing, setIsResizing] = useState(false);
const startXRef = React.useRef<number | null>(null);
@@ -99,6 +143,13 @@ export function SSHUtilitySidebar({
const terminalTabs = tabs.filter((tab: TabData) => tab.type === "terminal");
// Filter command history based on search query
const filteredCommands = searchQuery
? commandHistory.filter((cmd) =>
cmd.toLowerCase().includes(searchQuery.toLowerCase()),
)
: commandHistory;
// Initialize CSS variable on mount and when sidebar width changes
useEffect(() => {
document.documentElement.style.setProperty(
@@ -327,6 +378,7 @@ export function SSHUtilitySidebar({
const updateRightClickCopyPaste = (checked: boolean) => {
setCookie("rightClickCopyPaste", checked.toString());
setRightClickCopyPaste(checked);
};
// Snippets handlers
@@ -441,6 +493,33 @@ export function SSHUtilitySidebar({
toast.success(t("snippets.copySuccess", { name: snippet.name }));
};
// Command History handlers
const handleCommandSelect = (command: string) => {
if (onSelectCommand) {
onSelectCommand(command);
}
};
const handleCommandDelete = (command: string) => {
if (onDeleteCommand) {
confirmWithToast(
t("commandHistory.deleteConfirmDescription", {
defaultValue: `Delete "${command}" from history?`,
command,
}),
() => {
onDeleteCommand(command);
toast.success(
t("commandHistory.deleteSuccess", {
defaultValue: "Command deleted from history",
}),
);
},
"destructive",
);
}
};
return (
<>
{isOpen && (
@@ -482,14 +561,17 @@ export function SSHUtilitySidebar({
</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">
<Tabs value={activeTab} onValueChange={handleTabChange}>
<TabsList className="w-full grid grid-cols-3 mb-4">
<TabsTrigger value="ssh-tools">
{t("sshTools.title")}
</TabsTrigger>
<TabsTrigger value="snippets">
{t("snippets.title")}
</TabsTrigger>
<TabsTrigger value="command-history">
{t("commandHistory.title", { defaultValue: "History" })}
</TabsTrigger>
</TabsList>
<TabsContent value="ssh-tools" className="space-y-4">
@@ -577,9 +659,7 @@ export function SSHUtilitySidebar({
<Checkbox
id="enable-copy-paste"
onCheckedChange={updateRightClickCopyPaste}
defaultChecked={
getCookie("rightClickCopyPaste") === "true"
}
checked={rightClickCopyPaste}
/>
<label
htmlFor="enable-copy-paste"
@@ -763,6 +843,129 @@ export function SSHUtilitySidebar({
</TooltipProvider>
)}
</TabsContent>
<TabsContent value="command-history" className="space-y-4">
<div className="space-y-2">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("commandHistory.searchPlaceholder", {
defaultValue: "Search commands...",
})}
value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value);
setSelectedCommandIndex(0);
}}
className="pl-10 pr-10"
/>
{searchQuery && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 transform -translate-y-1/2 h-7 w-7 p-0"
onClick={() => setSearchQuery("")}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-hidden">
{isHistoryLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse py-8">
<Loader2 className="animate-spin mr-2" size={16} />
<span>
{t("commandHistory.loading", {
defaultValue: "Loading history...",
})}
</span>
</div>
) : filteredCommands.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
{searchQuery ? (
<>
<Search className="h-12 w-12 mb-2 opacity-20 mx-auto" />
<p className="mb-2 font-medium">
{t("commandHistory.noResults", {
defaultValue: "No commands found",
})}
</p>
<p className="text-sm">
{t("commandHistory.noResultsHint", {
defaultValue: `No commands matching "${searchQuery}"`,
query: searchQuery,
})}
</p>
</>
) : (
<>
<p className="mb-2 font-medium">
{t("commandHistory.empty", {
defaultValue: "No command history yet",
})}
</p>
<p className="text-sm">
{t("commandHistory.emptyHint", {
defaultValue:
"Execute commands to build your history",
})}
</p>
</>
)}
</div>
) : (
<div className="space-y-2 overflow-y-auto max-h-[calc(100vh-300px)]">
{filteredCommands.map((command, index) => (
<div
key={index}
className="bg-dark-bg border-2 border-dark-border rounded-md px-3 py-2.5 hover:bg-dark-hover-alt hover:border-blue-400/50 transition-all duration-200 group"
>
<div className="flex items-center justify-between gap-2">
<span
className="flex-1 font-mono text-sm cursor-pointer text-white"
onClick={() => handleCommandSelect(command)}
>
{command}
</span>
{onDeleteCommand && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 hover:bg-red-500/20 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
handleCommandDelete(command);
}}
title={t("commandHistory.deleteTooltip", {
defaultValue: "Delete command",
})}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
))}
</div>
)}
</div>
<Separator />
<div className="text-xs text-muted-foreground">
<span>
{filteredCommands.length}{" "}
{t("commandHistory.commandCount", {
defaultValue:
filteredCommands.length !== 1
? "commands"
: "command",
})}
</span>
</div>
</TabsContent>
</Tabs>
</SidebarContent>
{isOpen && (

View File

@@ -847,7 +847,7 @@ export function Auth({
<div className="w-full h-full flex flex-col md:flex-row">
{/* Left Side - Brand Showcase */}
<div
className="hidden md:flex md:w-2/5 items-center justify-center relative"
className="hidden md:flex md:w-2/5 items-center justify-center relative border-r-2 border-bg-border-dark"
style={{
background: "#0e0e10",
backgroundImage: `repeating-linear-gradient(
@@ -871,21 +871,14 @@ export function Auth({
TERMIX
</div>
<div className="text-lg text-muted-foreground tracking-widest font-light">
{t("auth.tagline") || "SSH TERMINAL MANAGER"}
</div>
<div className="mt-8 text-sm text-muted-foreground/80 max-w-md">
{t("auth.description") ||
"Secure, powerful, and intuitive SSH connection management"}
{t("auth.tagline")}
</div>
</div>
</div>
{/* Right Side - Auth Form */}
<div className="flex-1 flex items-center justify-center p-6 md:p-12 bg-background overflow-y-auto">
<div
className="w-full max-w-md backdrop-blur-sm bg-card/50 rounded-2xl p-8 shadow-xl border-2 border-dark-border animate-in fade-in slide-in-from-bottom-4 duration-500"
style={{ maxHeight: "calc(100vh - 3rem)" }}
>
<div className="flex-1 flex p-6 md:p-12 bg-background overflow-y-auto">
<div className="m-auto w-full max-w-md backdrop-blur-sm bg-card/50 rounded-2xl p-8 shadow-xl border-2 border-dark-border animate-in fade-in slide-in-from-bottom-4 duration-500 flex flex-col">
{isInElectronWebView() && !webviewAuthSuccess && (
<Alert className="mb-4 border-blue-500 bg-blue-500/10">
<Monitor className="h-4 w-4" />

View File

@@ -25,6 +25,11 @@ export function ElectronLoginForm({
const [currentUrl, setCurrentUrl] = useState(serverUrl);
const hasLoadedOnce = useRef(false);
useEffect(() => {
// Clear any existing token to prevent login loops with expired tokens
localStorage.removeItem("jwt");
}, []);
useEffect(() => {
const handleMessage = async (event: MessageEvent) => {
try {

View File

@@ -0,0 +1,85 @@
import React, {
createContext,
useContext,
useState,
useCallback,
ReactNode,
} from "react";
interface CommandHistoryContextType {
commandHistory: string[];
isLoading: boolean;
setCommandHistory: (history: string[]) => void;
setIsLoading: (loading: boolean) => void;
onSelectCommand?: (command: string) => void;
setOnSelectCommand: (callback: (command: string) => void) => void;
onDeleteCommand?: (command: string) => void;
setOnDeleteCommand: (callback: (command: string) => void) => void;
openCommandHistory: () => void;
setOpenCommandHistory: (callback: () => void) => void;
}
const CommandHistoryContext = createContext<
CommandHistoryContextType | undefined
>(undefined);
export function CommandHistoryProvider({ children }: { children: ReactNode }) {
const [commandHistory, setCommandHistory] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [onSelectCommand, setOnSelectCommand] = useState<
((command: string) => void) | undefined
>(undefined);
const [onDeleteCommand, setOnDeleteCommand] = useState<
((command: string) => void) | undefined
>(undefined);
const [openCommandHistory, setOpenCommandHistory] = useState<
(() => void) | undefined
>(() => () => {});
const handleSetOnSelectCommand = useCallback(
(callback: (command: string) => void) => {
setOnSelectCommand(() => callback);
},
[],
);
const handleSetOnDeleteCommand = useCallback(
(callback: (command: string) => void) => {
setOnDeleteCommand(() => callback);
},
[],
);
const handleSetOpenCommandHistory = useCallback((callback: () => void) => {
setOpenCommandHistory(() => callback);
}, []);
return (
<CommandHistoryContext.Provider
value={{
commandHistory,
isLoading,
setCommandHistory,
setIsLoading,
onSelectCommand,
setOnSelectCommand: handleSetOnSelectCommand,
onDeleteCommand,
setOnDeleteCommand: handleSetOnDeleteCommand,
openCommandHistory: openCommandHistory || (() => {}),
setOpenCommandHistory: handleSetOpenCommandHistory,
}}
>
{children}
</CommandHistoryContext.Provider>
);
}
export function useCommandHistory() {
const context = useContext(CommandHistoryContext);
if (context === undefined) {
throw new Error(
"useCommandHistory must be used within a CommandHistoryProvider",
);
}
return context;
}

View File

@@ -247,6 +247,9 @@ export function LeftSidebar({
const handleCredentialsChanged = () => {
fetchHosts();
};
const handleFoldersChanged = () => {
fetchFolderMetadata();
};
window.addEventListener(
"ssh-hosts:changed",
handleHostsChanged as EventListener,
@@ -255,6 +258,10 @@ export function LeftSidebar({
"credentials:changed",
handleCredentialsChanged as EventListener,
);
window.addEventListener(
"folders:changed",
handleFoldersChanged as EventListener,
);
return () => {
window.removeEventListener(
"ssh-hosts:changed",
@@ -264,6 +271,10 @@ export function LeftSidebar({
"credentials:changed",
handleCredentialsChanged as EventListener,
);
window.removeEventListener(
"folders:changed",
handleFoldersChanged as EventListener,
);
};
}, [fetchHosts, fetchFolderMetadata]);

View File

@@ -8,6 +8,7 @@ import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { useTranslation } from "react-i18next";
import { TabDropdown } from "@/ui/desktop/navigation/tabs/TabDropdown.tsx";
import { SSHUtilitySidebar } from "@/ui/desktop/apps/tools/SSHUtilitySidebar.tsx";
import { useCommandHistory } from "@/ui/desktop/contexts/CommandHistoryContext.tsx";
interface TabData {
id: number;
@@ -55,11 +56,13 @@ export function TopNavbar({
const leftPosition =
state === "collapsed" ? "26px" : "calc(var(--sidebar-width) + 8px)";
const { t } = useTranslation();
const commandHistory = useCommandHistory();
const [toolsSidebarOpen, setToolsSidebarOpen] = useState(false);
const [commandHistoryTabActive, setCommandHistoryTabActive] = useState(false);
const [rightSidebarWidth, setRightSidebarWidth] = useState<number>(() => {
const saved = localStorage.getItem("rightSidebarWidth");
return saved !== null ? parseInt(saved, 10) : 400;
return saved !== null ? parseInt(saved, 10) : 350;
});
React.useEffect(() => {
@@ -72,6 +75,14 @@ export function TopNavbar({
}
}, [toolsSidebarOpen, rightSidebarWidth, onRightSidebarStateChange]);
// Register function to open command history sidebar
React.useEffect(() => {
commandHistory.setOpenCommandHistory(() => {
setToolsSidebarOpen(true);
setCommandHistoryTabActive(true);
});
}, [commandHistory]);
const rightPosition = toolsSidebarOpen
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
: "17px";
@@ -359,8 +370,7 @@ export function TopNavbar({
tab.type === "user_profile") &&
isSplitScreenActive);
const isHome = tab.type === "home";
const disableClose =
(isSplitScreenActive && isActive) || isHome;
const disableClose = (isSplitScreenActive && isActive) || isHome;
const isDraggingThisTab = dragState.draggedIndex === index;
const isTheDraggedTab = tab.id === dragState.draggedId;
@@ -536,6 +546,12 @@ export function TopNavbar({
onSnippetExecute={handleSnippetExecute}
sidebarWidth={rightSidebarWidth}
setSidebarWidth={setRightSidebarWidth}
commandHistory={commandHistory.commandHistory}
onSelectCommand={commandHistory.onSelectCommand}
onDeleteCommand={commandHistory.onDeleteCommand}
isHistoryLoading={commandHistory.isLoading}
initialTab={commandHistoryTabActive ? "command-history" : undefined}
onTabChange={() => setCommandHistoryTabActive(false)}
/>
</div>
);