feat: General UI improvements and translation updates

This commit is contained in:
LukeGus
2025-11-10 23:19:35 -06:00
parent 7e8105a938
commit 08aef18989
28 changed files with 1235 additions and 311 deletions

View File

@@ -8,7 +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 { CommandHistoryProvider } from "@/ui/desktop/apps/terminal/command-history/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";
@@ -31,7 +31,7 @@ function AppContent() {
const { currentTab, tabs } = useTabs();
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
const [rightSidebarWidth, setRightSidebarWidth] = useState(300);
const lastShiftPressTime = useRef(0);
@@ -159,7 +159,7 @@ function AppContent() {
const showProfile = currentTabData?.type === "user_profile";
return (
<div>
<div className="h-screen w-screen overflow-hidden">
<CommandPalette
isOpen={isCommandPaletteOpen}
setIsOpen={setIsCommandPaletteOpen}

View File

@@ -13,6 +13,14 @@ import {
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog.tsx";
import {
Table,
TableBody,
@@ -55,6 +63,7 @@ import {
getSessions,
revokeSession,
revokeAllUserSessions,
convertOIDCToPassword,
} from "@/ui/main-axios.ts";
interface AdminSettingsProps {
@@ -66,7 +75,7 @@ interface AdminSettingsProps {
export function AdminSettings({
isTopbarOpen = true,
rightSidebarOpen = false,
rightSidebarWidth = 400,
rightSidebarWidth = 300,
}: AdminSettingsProps): React.ReactElement {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
@@ -138,6 +147,16 @@ export function AdminSettings({
>([]);
const [sessionsLoading, setSessionsLoading] = React.useState(false);
const [convertUserDialogOpen, setConvertUserDialogOpen] =
React.useState(false);
const [convertTargetUser, setConvertTargetUser] = React.useState<{
id: string;
username: string;
} | null>(null);
const [convertPassword, setConvertPassword] = React.useState("");
const [convertTotpCode, setConvertTotpCode] = React.useState("");
const [convertLoading, setConvertLoading] = React.useState(false);
const requiresImportPassword = React.useMemo(
() => !currentUser?.is_oidc,
[currentUser?.is_oidc],
@@ -636,6 +655,57 @@ export function AdminSettings({
);
};
const handleConvertOIDCUser = (user: { id: string; username: string }) => {
setConvertTargetUser(user);
setConvertPassword("");
setConvertTotpCode("");
setConvertUserDialogOpen(true);
};
const handleConvertSubmit = async () => {
if (!convertTargetUser || !convertPassword) {
toast.error("Password is required");
return;
}
if (convertPassword.length < 8) {
toast.error("Password must be at least 8 characters long");
return;
}
setConvertLoading(true);
try {
const result = await convertOIDCToPassword(
convertTargetUser.id,
convertPassword,
convertTotpCode || undefined,
);
toast.success(
result.message ||
`User ${convertTargetUser.username} converted to password authentication`,
);
setConvertUserDialogOpen(false);
setConvertPassword("");
setConvertTotpCode("");
setConvertTargetUser(null);
fetchUsers();
} catch (error: unknown) {
const err = error as {
response?: { data?: { error?: string; code?: string } };
};
if (err.response?.data?.code === "TOTP_REQUIRED") {
toast.error("TOTP code is required for this user");
} else {
toast.error(
err.response?.data?.error || "Failed to convert user account",
);
}
} finally {
setConvertLoading(false);
}
};
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
const bottomMarginPx = 8;
@@ -1030,15 +1100,35 @@ export function AdminSettings({
: t("admin.local")}
</TableCell>
<TableCell className="px-4">
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteUser(user.username)}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}
>
<Trash2 className="h-4 w-4" />
</Button>
<div className="flex gap-2">
{user.is_oidc && (
<Button
variant="ghost"
size="sm"
onClick={() =>
handleConvertOIDCUser({
id: user.id,
username: user.username,
})
}
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
title="Convert to password authentication"
>
<Lock className="h-4 w-4" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() =>
handleDeleteUser(user.username)
}
className="text-red-600 hover:text-red-700 hover:bg-red-50"
disabled={user.is_admin}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
@@ -1414,6 +1504,79 @@ export function AdminSettings({
</Tabs>
</div>
</div>
{/* Convert OIDC to Password Dialog */}
<Dialog
open={convertUserDialogOpen}
onOpenChange={setConvertUserDialogOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Convert to Password Authentication</DialogTitle>
<DialogDescription>
Convert {convertTargetUser?.username} from OIDC/SSO authentication
to password-based authentication. This will allow the user to log
in with a username and password instead of through an external
provider.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Alert>
<AlertTitle>Important</AlertTitle>
<AlertDescription>
This action will:
<ul className="list-disc list-inside mt-2 space-y-1">
<li>Set a new password for this user</li>
<li>Disable OIDC/SSO login for this account</li>
<li>Log out all active sessions</li>
<li>Preserve all user data (SSH hosts, credentials, etc.)</li>
</ul>
</AlertDescription>
</Alert>
<div className="space-y-2">
<Label htmlFor="convert-password">
New Password (min 8 chars)
</Label>
<PasswordInput
id="convert-password"
value={convertPassword}
onChange={(e) => setConvertPassword(e.target.value)}
placeholder="Enter new password"
disabled={convertLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="convert-totp">
TOTP Code (if user has 2FA enabled)
</Label>
<Input
id="convert-totp"
value={convertTotpCode}
onChange={(e) => setConvertTotpCode(e.target.value)}
placeholder="000000"
disabled={convertLoading}
maxLength={6}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConvertUserDialogOpen(false)}
disabled={convertLoading}
>
Cancel
</Button>
<Button onClick={handleConvertSubmit} disabled={convertLoading}>
{convertLoading ? "Converting..." : "Convert User"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -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"

View File

@@ -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);

View File

@@ -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

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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[];

View File

@@ -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>

View File

@@ -42,7 +42,7 @@ interface TerminalViewProps {
export function AppView({
isTopbarOpen = true,
rightSidebarOpen = false,
rightSidebarWidth = 400,
rightSidebarWidth = 300,
}: TerminalViewProps): React.ReactElement {
const { tabs, currentTab, allSplitScreenTab, removeTab } = useTabs() as {
tabs: TabData[];
@@ -70,16 +70,17 @@ export function AppView({
);
const [ready, setReady] = useState<boolean>(true);
const [resetKey, setResetKey] = useState<number>(0);
const previousStylesRef = useRef<Record<number, React.CSSProperties>>({});
const updatePanelRects = () => {
const updatePanelRects = React.useCallback(() => {
const next: Record<string, DOMRect | null> = {};
Object.entries(panelRefs.current).forEach(([id, el]) => {
if (el) next[id] = el.getBoundingClientRect();
});
setPanelRects(next);
};
}, []);
const fitActiveAndNotify = () => {
const fitActiveAndNotify = React.useCallback(() => {
const visibleIds: number[] = [];
if (allSplitScreenTab.length === 0) {
if (currentTab) visibleIds.push(currentTab);
@@ -95,10 +96,10 @@ export function AppView({
if (ref?.refresh) ref.refresh();
}
});
};
}, [allSplitScreenTab, currentTab, terminalTabs]);
const layoutScheduleRef = useRef<number | null>(null);
const scheduleMeasureAndFit = () => {
const scheduleMeasureAndFit = React.useCallback(() => {
if (layoutScheduleRef.current)
cancelAnimationFrame(layoutScheduleRef.current);
layoutScheduleRef.current = requestAnimationFrame(() => {
@@ -107,18 +108,17 @@ export function AppView({
fitActiveAndNotify();
});
});
};
}, [updatePanelRects, fitActiveAndNotify]);
const hideThenFit = () => {
setReady(false);
const hideThenFit = React.useCallback(() => {
// Don't hide terminals, just fit them immediately
requestAnimationFrame(() => {
updatePanelRects();
requestAnimationFrame(() => {
fitActiveAndNotify();
setReady(true);
});
});
};
}, [updatePanelRects, fitActiveAndNotify]);
const prevStateRef = useRef({
terminalTabsLength: terminalTabs.length,
@@ -158,11 +158,20 @@ export function AppView({
terminalTabs.length,
allSplitScreenTab.join(","),
terminalTabs,
hideThenFit,
]);
useEffect(() => {
scheduleMeasureAndFit();
}, [allSplitScreenTab.length, isTopbarOpen, sidebarState, resetKey]);
}, [
scheduleMeasureAndFit,
allSplitScreenTab.length,
isTopbarOpen,
sidebarState,
resetKey,
rightSidebarOpen,
rightSidebarWidth,
]);
useEffect(() => {
const roContainer = containerRef.current
@@ -174,7 +183,7 @@ export function AppView({
if (containerRef.current && roContainer)
roContainer.observe(containerRef.current);
return () => roContainer?.disconnect();
}, []);
}, [updatePanelRects, fitActiveAndNotify]);
useEffect(() => {
const onWinResize = () => {
@@ -183,7 +192,7 @@ export function AppView({
};
window.addEventListener("resize", onWinResize);
return () => window.removeEventListener("resize", onWinResize);
}, []);
}, [updatePanelRects, fitActiveAndNotify]);
const HEADER_H = 28;
@@ -208,33 +217,39 @@ export function AppView({
if (allSplitScreenTab.length === 0 && mainTab) {
const isFileManagerTab = mainTab.type === "file_manager";
styles[mainTab.id] = {
position: "absolute",
const newStyle = {
position: "absolute" as const,
top: isFileManagerTab ? 0 : 4,
left: isFileManagerTab ? 0 : 4,
right: isFileManagerTab ? 0 : 4,
bottom: isFileManagerTab ? 0 : 4,
zIndex: 20,
display: "block",
pointerEvents: "auto",
opacity: ready ? 1 : 0,
display: "block" as const,
pointerEvents: "auto" as const,
opacity: 1,
transition: "opacity 150ms ease-in-out",
};
styles[mainTab.id] = newStyle;
previousStylesRef.current[mainTab.id] = newStyle;
} else {
layoutTabs.forEach((t: TabData) => {
const rect = panelRects[String(t.id)];
const parentRect = containerRef.current?.getBoundingClientRect();
if (rect && parentRect) {
styles[t.id] = {
position: "absolute",
const newStyle = {
position: "absolute" as const,
top: rect.top - parentRect.top + HEADER_H + 4,
left: rect.left - parentRect.left + 4,
width: rect.width - 8,
height: rect.height - HEADER_H - 8,
zIndex: 20,
display: "block",
pointerEvents: "auto",
opacity: ready ? 1 : 0,
display: "block" as const,
pointerEvents: "auto" as const,
opacity: 1,
transition: "opacity 150ms ease-in-out",
};
styles[t.id] = newStyle;
previousStylesRef.current[t.id] = newStyle;
}
});
}
@@ -248,17 +263,31 @@ export function AppView({
const isVisible =
hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
// Use previous style if available to maintain position
const previousStyle = previousStylesRef.current[t.id];
// For non-split screen tabs, always use the standard position
const isFileManagerTab = t.type === "file_manager";
const standardStyle = {
position: "absolute" as const,
top: isFileManagerTab ? 0 : 4,
left: isFileManagerTab ? 0 : 4,
right: isFileManagerTab ? 0 : 4,
bottom: isFileManagerTab ? 0 : 4,
};
const finalStyle: React.CSSProperties = hasStyle
? { ...styles[t.id], overflow: "hidden" }
: ({
position: "absolute",
inset: 0,
visibility: "hidden",
...(previousStyle || standardStyle),
opacity: 0,
pointerEvents: "none",
zIndex: 0,
transition: "opacity 150ms ease-in-out",
overflow: "hidden",
} as React.CSSProperties);
const effectiveVisible = isVisible && ready;
const effectiveVisible = isVisible;
const isTerminal = t.type === "terminal";
const terminalConfig = {

View File

@@ -289,7 +289,11 @@ export function LeftSidebar({
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
const saved = localStorage.getItem("leftSidebarWidth");
return saved !== null ? parseInt(saved, 10) : 250;
const defaultWidth = 250;
const savedWidth = saved !== null ? parseInt(saved, 10) : defaultWidth;
const minWidth = Math.min(200, Math.floor(window.innerWidth * 0.15));
const maxWidth = Math.floor(window.innerWidth * 0.3);
return Math.min(savedWidth, Math.max(minWidth, maxWidth));
});
const [isResizing, setIsResizing] = useState(false);
@@ -300,6 +304,20 @@ export function LeftSidebar({
localStorage.setItem("leftSidebarWidth", String(sidebarWidth));
}, [sidebarWidth]);
React.useEffect(() => {
const handleResize = () => {
const minWidth = Math.min(200, Math.floor(window.innerWidth * 0.15));
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]);
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
setIsResizing(true);
@@ -314,8 +332,8 @@ export function LeftSidebar({
if (startXRef.current == null) return;
const dx = e.clientX - startXRef.current;
const newWidth = Math.round(startWidthRef.current + dx);
const minWidth = 200;
const maxWidth = Math.round(window.innerWidth * 0.5);
const minWidth = Math.min(200, Math.floor(window.innerWidth * 0.15));
const maxWidth = Math.round(window.innerWidth * 0.3);
if (newWidth >= minWidth && newWidth <= maxWidth) {
setSidebarWidth(newWidth);
} else if (newWidth < minWidth) {
@@ -394,14 +412,14 @@ export function LeftSidebar({
}, []);
return (
<div className="min-h-svh">
<div className="h-screen w-screen overflow-hidden">
<SidebarProvider
open={isSidebarOpen}
style={
{ "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties
}
>
<div className="flex h-screen w-full">
<div className="flex h-screen w-screen overflow-hidden">
<Sidebar variant="floating">
<SidebarHeader>
<SidebarGroupLabel className="text-lg font-bold text-white">

View File

@@ -25,7 +25,7 @@ export function TOTPDialog({
if (!isOpen) return null;
return (
<div className="absolute inset-0 flex items-center justify-center z-50 animate-in fade-in duration-200">
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
<div
className="absolute inset-0 bg-dark-bg rounded-md"
style={{ backgroundColor: backgroundColor || undefined }}

View File

@@ -7,8 +7,8 @@ import { Tab } from "@/ui/desktop/navigation/tabs/Tab.tsx";
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";
import { SSHToolsSidebar } from "@/ui/desktop/apps/tools/SSHToolsSidebar.tsx";
import { useCommandHistory } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
interface TabData {
id: number;
@@ -62,26 +62,46 @@ export function TopNavbar({
const [commandHistoryTabActive, setCommandHistoryTabActive] = useState(false);
const [rightSidebarWidth, setRightSidebarWidth] = useState<number>(() => {
const saved = localStorage.getItem("rightSidebarWidth");
return saved !== null ? parseInt(saved, 10) : 350;
const defaultWidth = 350;
const savedWidth = saved !== null ? parseInt(saved, 10) : defaultWidth;
const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
const maxWidth = Math.floor(window.innerWidth * 0.3);
return Math.min(savedWidth, Math.max(minWidth, maxWidth));
});
React.useEffect(() => {
localStorage.setItem("rightSidebarWidth", String(rightSidebarWidth));
}, [rightSidebarWidth]);
React.useEffect(() => {
const handleResize = () => {
const minWidth = Math.min(300, Math.floor(window.innerWidth * 0.2));
const maxWidth = Math.floor(window.innerWidth * 0.3);
if (rightSidebarWidth > maxWidth) {
setRightSidebarWidth(Math.max(minWidth, maxWidth));
} else if (rightSidebarWidth < minWidth) {
setRightSidebarWidth(minWidth);
}
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [rightSidebarWidth]);
React.useEffect(() => {
if (onRightSidebarStateChange) {
onRightSidebarStateChange(toolsSidebarOpen, rightSidebarWidth);
}
}, [toolsSidebarOpen, rightSidebarWidth, onRightSidebarStateChange]);
const openCommandHistorySidebar = React.useCallback(() => {
setToolsSidebarOpen(true);
setCommandHistoryTabActive(true);
}, []);
// Register function to open command history sidebar
React.useEffect(() => {
commandHistory.setOpenCommandHistory(() => {
setToolsSidebarOpen(true);
setCommandHistoryTabActive(true);
});
}, [commandHistory]);
commandHistory.setOpenCommandHistory(openCommandHistorySidebar);
}, [commandHistory, openCommandHistorySidebar]);
const rightPosition = toolsSidebarOpen
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
@@ -540,7 +560,7 @@ export function TopNavbar({
</div>
)}
<SSHUtilitySidebar
<SSHToolsSidebar
isOpen={toolsSidebarOpen}
onClose={() => setToolsSidebarOpen(false)}
onSnippetExecute={handleSnippetExecute}

View File

@@ -74,7 +74,7 @@ async function handleLogout() {
export function UserProfile({
isTopbarOpen = true,
rightSidebarOpen = false,
rightSidebarWidth = 400,
rightSidebarWidth = 300,
}: UserProfileProps) {
const { t } = useTranslation();
const { state: sidebarState } = useSidebar();