feat: General UI improvements and translation updates
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
@@ -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>
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user