feat: General UI improvements and translation updates
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||
import {
|
||||
ChartLine,
|
||||
Clock,
|
||||
@@ -61,7 +62,7 @@ export function Dashboard({
|
||||
isTopbarOpen,
|
||||
onSelectView,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
rightSidebarWidth = 300,
|
||||
}: DashboardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||
@@ -357,6 +358,11 @@ export function Dashboard({
|
||||
{t("dashboard.title")}
|
||||
</div>
|
||||
<div className="flex flex-row gap-3">
|
||||
<div className="flex flex-col items-center gap-4 justify-center mr-5">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Press <Kbd>LShift</Kbd> twice to open the command palette
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="font-semibold"
|
||||
variant="outline"
|
||||
|
||||
@@ -19,7 +19,7 @@ export function HostManager({
|
||||
initialTab = "host_viewer",
|
||||
hostConfig,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
rightSidebarWidth = 300,
|
||||
}: HostManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
|
||||
@@ -81,6 +81,94 @@ import { TerminalPreview } from "@/ui/desktop/apps/terminal/TerminalPreview.tsx"
|
||||
import type { TerminalConfig } from "@/types";
|
||||
import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
|
||||
|
||||
interface JumpHostItemProps {
|
||||
jumpHost: { hostId: number };
|
||||
index: number;
|
||||
hosts: SSHHost[];
|
||||
editingHost?: SSHHost | null;
|
||||
onUpdate: (hostId: number) => void;
|
||||
onRemove: () => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
function JumpHostItem({
|
||||
jumpHost,
|
||||
index,
|
||||
hosts,
|
||||
editingHost,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
t,
|
||||
}: JumpHostItemProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const selectedHost = hosts.find((h) => h.id === jumpHost.hostId);
|
||||
|
||||
return (
|
||||
<div 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={() => {
|
||||
onUpdate(host.id);
|
||||
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={onRemove}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -722,8 +810,13 @@ export function HostManagerEditor({
|
||||
|
||||
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||
|
||||
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
|
||||
refreshServerPolling();
|
||||
// Notify the stats server to start/update polling for this specific host
|
||||
if (savedHost?.id) {
|
||||
const { notifyHostCreatedOrUpdated } = await import(
|
||||
"@/ui/main-axios.ts"
|
||||
);
|
||||
notifyHostCreatedOrUpdated(savedHost.id);
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("hosts.failedToSaveHost"));
|
||||
} finally {
|
||||
@@ -1406,169 +1499,102 @@ export function HostManagerEditor({
|
||||
</Alert>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="forceKeyboardInteractive"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 mt-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>
|
||||
{t("hosts.forceKeyboardInteractive")}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.forceKeyboardInteractiveDesc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</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);
|
||||
<Accordion type="multiple" className="w-full">
|
||||
<AccordionItem value="advanced-auth">
|
||||
<AccordionTrigger>
|
||||
{t("hosts.advancedAuthSettings")}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="forceKeyboardInteractive"
|
||||
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.forceKeyboardInteractive")}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.forceKeyboardInteractiveDesc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
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>
|
||||
<AccordionItem value="jump-hosts">
|
||||
<AccordionTrigger>
|
||||
{t("hosts.jumpHosts")}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t("hosts.jumpHostsDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="jumpHosts"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.jumpHostChain")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-3">
|
||||
{field.value.map((jumpHost, index) => (
|
||||
<JumpHostItem
|
||||
key={index}
|
||||
jumpHost={jumpHost}
|
||||
index={index}
|
||||
hosts={hosts}
|
||||
editingHost={editingHost}
|
||||
onUpdate={(hostId) => {
|
||||
const newJumpHosts = [...field.value];
|
||||
newJumpHosts[index] = { hostId };
|
||||
field.onChange(newJumpHosts);
|
||||
}}
|
||||
onRemove={() => {
|
||||
const newJumpHosts = field.value.filter(
|
||||
(_, i) => i !== index,
|
||||
);
|
||||
field.onChange(newJumpHosts);
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newJumpHosts = field.value.filter(
|
||||
(_, i) => i !== index,
|
||||
);
|
||||
field.onChange(newJumpHosts);
|
||||
field.onChange([
|
||||
...field.value,
|
||||
{ hostId: 0 },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("hosts.addJumpHost")}
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.jumpHostsOrder")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</TabsContent>
|
||||
<TabsContent value="terminal" className="space-y-1">
|
||||
<FormField
|
||||
|
||||
@@ -103,8 +103,15 @@ export function Server({
|
||||
const metricsEnabled = statsConfig.metricsEnabled !== false;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
// Reset state when switching to a different host
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setMetricsHistory([]);
|
||||
setShowStatsUI(true);
|
||||
}
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig]);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
const renderWidget = (widgetType: WidgetType) => {
|
||||
switch (widgetType) {
|
||||
|
||||
@@ -29,8 +29,8 @@ import {
|
||||
import type { TerminalConfig } from "@/types";
|
||||
import { useCommandTracker } from "@/ui/hooks/useCommandTracker";
|
||||
import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory";
|
||||
import { useCommandHistory } from "@/ui/desktop/contexts/CommandHistoryContext.tsx";
|
||||
import { CommandAutocomplete } from "./CommandAutocomplete";
|
||||
import { useCommandHistory } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
|
||||
import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
interface HostConfig {
|
||||
@@ -1421,14 +1421,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !isReady || !fitAddonRef.current || !terminal) {
|
||||
if (!isVisible && isFitted) {
|
||||
setIsFitted(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFitted(false);
|
||||
|
||||
// Don't set isFitted to false - keep terminal visible during resize
|
||||
let rafId1: number;
|
||||
let rafId2: number;
|
||||
|
||||
@@ -1467,8 +1463,10 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
ref={xtermRef}
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
visibility:
|
||||
isReady && !isConnecting && isFitted ? "visible" : "hidden",
|
||||
opacity: isReady && !isConnecting && isFitted ? 1 : 0,
|
||||
transition: "opacity 100ms ease-in-out",
|
||||
pointerEvents:
|
||||
isReady && !isConnecting && isFitted ? "auto" : "none",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (terminal && !splitScreen) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
|
||||
interface CommandAutocompleteProps {
|
||||
suggestions: string[];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
RotateCcw,
|
||||
Search,
|
||||
Loader2,
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -44,6 +45,8 @@ import {
|
||||
deleteSnippet,
|
||||
getCookie,
|
||||
setCookie,
|
||||
getCommandHistory,
|
||||
deleteCommandFromHistory,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import type { Snippet, SnippetData } from "../../../../types";
|
||||
@@ -57,6 +60,10 @@ interface TabData {
|
||||
sendInput?: (data: string) => void;
|
||||
};
|
||||
};
|
||||
hostConfig?: {
|
||||
id: number;
|
||||
};
|
||||
isActive?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -66,30 +73,25 @@ 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({
|
||||
export function SSHToolsSidebar({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSnippetExecute,
|
||||
sidebarWidth,
|
||||
setSidebarWidth,
|
||||
commandHistory = [],
|
||||
onSelectCommand,
|
||||
onDeleteCommand,
|
||||
isHistoryLoading = false,
|
||||
initialTab,
|
||||
onTabChange,
|
||||
}: SSHUtilitySidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const { tabs } = useTabs() as { tabs: TabData[] };
|
||||
const { tabs, currentTab } = useTabs() as {
|
||||
tabs: TabData[];
|
||||
currentTab: number | null;
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState(initialTab || "ssh-tools");
|
||||
|
||||
// Update active tab when initialTab changes
|
||||
@@ -133,8 +135,9 @@ export function SSHUtilitySidebar({
|
||||
);
|
||||
|
||||
// Command History state
|
||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
|
||||
|
||||
// Resize state
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
@@ -142,6 +145,31 @@ export function SSHUtilitySidebar({
|
||||
const startWidthRef = React.useRef<number>(sidebarWidth);
|
||||
|
||||
const terminalTabs = tabs.filter((tab: TabData) => tab.type === "terminal");
|
||||
const activeUiTab = tabs.find((tab) => tab.id === currentTab);
|
||||
const activeTerminal =
|
||||
activeUiTab?.type === "terminal" ? activeUiTab : undefined;
|
||||
const activeTerminalHostId = activeTerminal?.hostConfig?.id;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && activeTab === "command-history") {
|
||||
if (activeTerminalHostId) {
|
||||
setIsHistoryLoading(true);
|
||||
getCommandHistory(activeTerminalHostId)
|
||||
.then((history) => {
|
||||
setCommandHistory(history);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch command history", err);
|
||||
setCommandHistory([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsHistoryLoading(false);
|
||||
});
|
||||
} else {
|
||||
setCommandHistory([]);
|
||||
}
|
||||
}
|
||||
}, [isOpen, activeTab, activeTerminalHostId]);
|
||||
|
||||
// Filter command history based on search query
|
||||
const filteredCommands = searchQuery
|
||||
@@ -158,6 +186,21 @@ export function SSHUtilitySidebar({
|
||||
);
|
||||
}, [sidebarWidth]);
|
||||
|
||||
// Handle window resize to adjust sidebar width
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
|
||||
const maxWidth = Math.floor(window.innerWidth * 0.3);
|
||||
if (sidebarWidth > maxWidth) {
|
||||
setSidebarWidth(Math.max(minWidth, maxWidth));
|
||||
} else if (sidebarWidth < minWidth) {
|
||||
setSidebarWidth(minWidth);
|
||||
}
|
||||
};
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [sidebarWidth, setSidebarWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && activeTab === "snippets") {
|
||||
fetchSnippets();
|
||||
@@ -179,8 +222,8 @@ export function SSHUtilitySidebar({
|
||||
if (startXRef.current == null) return;
|
||||
const dx = startXRef.current - e.clientX; // Reversed because we're on the right
|
||||
const newWidth = Math.round(startWidthRef.current + dx);
|
||||
const minWidth = 300;
|
||||
const maxWidth = Math.round(window.innerWidth * 0.5);
|
||||
const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
|
||||
const maxWidth = Math.round(window.innerWidth * 0.3);
|
||||
|
||||
let finalWidth = newWidth;
|
||||
if (newWidth < minWidth) {
|
||||
@@ -495,25 +538,34 @@ export function SSHUtilitySidebar({
|
||||
|
||||
// Command History handlers
|
||||
const handleCommandSelect = (command: string) => {
|
||||
if (onSelectCommand) {
|
||||
onSelectCommand(command);
|
||||
if (activeTerminal?.terminalRef?.current?.sendInput) {
|
||||
activeTerminal.terminalRef.current.sendInput(command);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommandDelete = (command: string) => {
|
||||
if (onDeleteCommand) {
|
||||
if (activeTerminalHostId) {
|
||||
confirmWithToast(
|
||||
t("commandHistory.deleteConfirmDescription", {
|
||||
defaultValue: `Delete "${command}" from history?`,
|
||||
command,
|
||||
}),
|
||||
() => {
|
||||
onDeleteCommand(command);
|
||||
toast.success(
|
||||
t("commandHistory.deleteSuccess", {
|
||||
defaultValue: "Command deleted from history",
|
||||
}),
|
||||
);
|
||||
async () => {
|
||||
try {
|
||||
await deleteCommandFromHistory(activeTerminalHostId, command);
|
||||
setCommandHistory((prev) => prev.filter((c) => c !== command));
|
||||
toast.success(
|
||||
t("commandHistory.deleteSuccess", {
|
||||
defaultValue: "Command deleted from history",
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
toast.error(
|
||||
t("commandHistory.deleteFailed", {
|
||||
defaultValue: "Failed to delete command.",
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
@@ -542,7 +594,7 @@ export function SSHUtilitySidebar({
|
||||
<div className="absolute right-5 flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSidebarWidth(400)}
|
||||
onClick={() => setSidebarWidth(300)}
|
||||
className="w-[28px] h-[28px]"
|
||||
title="Reset sidebar width"
|
||||
>
|
||||
@@ -855,7 +907,6 @@ export function SSHUtilitySidebar({
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setSelectedCommandIndex(0);
|
||||
}}
|
||||
className="pl-10 pr-10"
|
||||
/>
|
||||
@@ -874,7 +925,7 @@ export function SSHUtilitySidebar({
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{isHistoryLoading ? (
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse py-8">
|
||||
<div className="flex flex-row items-center justify-center text-muted-foreground text-sm animate-pulse py-8">
|
||||
<Loader2 className="animate-spin mr-2" size={16} />
|
||||
<span>
|
||||
{t("commandHistory.loading", {
|
||||
@@ -882,6 +933,21 @@ export function SSHUtilitySidebar({
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
) : !activeTerminal ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Terminal className="h-12 w-12 mb-4 opacity-20 mx-auto" />
|
||||
<p className="mb-2 font-medium">
|
||||
{t("commandHistory.noTerminal", {
|
||||
defaultValue: "No active terminal",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t("commandHistory.noTerminalHint", {
|
||||
defaultValue:
|
||||
"Open a terminal to see its command history.",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : filteredCommands.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
{searchQuery ? (
|
||||
@@ -909,14 +975,14 @@ export function SSHUtilitySidebar({
|
||||
<p className="text-sm">
|
||||
{t("commandHistory.emptyHint", {
|
||||
defaultValue:
|
||||
"Execute commands to build your history",
|
||||
"Execute commands in the active terminal to build its history.",
|
||||
})}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 overflow-y-auto max-h-[calc(100vh-300px)]">
|
||||
<div className="space-y-2 overflow-y-auto max-h-[calc(100vh-280px)]">
|
||||
{filteredCommands.map((command, index) => (
|
||||
<div
|
||||
key={index}
|
||||
@@ -929,42 +995,26 @@ export function SSHUtilitySidebar({
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
Reference in New Issue
Block a user