fix: General bug fixes/small feature improvements
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user