feat: General bug fixes, added server stat commands, improved split screen, link accounts, etc
This commit is contained in:
@@ -31,7 +31,7 @@ function AppContent() {
|
||||
const { currentTab, tabs } = useTabs();
|
||||
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(false);
|
||||
const [rightSidebarWidth, setRightSidebarWidth] = useState(300);
|
||||
const [rightSidebarWidth, setRightSidebarWidth] = useState(400);
|
||||
|
||||
const lastShiftPressTime = useRef(0);
|
||||
|
||||
@@ -69,6 +69,8 @@ function AppContent() {
|
||||
setIsAuthenticated(false);
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
// Clear invalid token
|
||||
localStorage.removeItem("jwt");
|
||||
} else {
|
||||
setIsAuthenticated(true);
|
||||
setIsAdmin(!!meRes.is_admin);
|
||||
@@ -80,6 +82,9 @@ function AppContent() {
|
||||
setIsAdmin(false);
|
||||
setUsername(null);
|
||||
|
||||
// Clear invalid token on any auth error
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
const errorCode = err?.response?.data?.code;
|
||||
if (errorCode === "SESSION_EXPIRED") {
|
||||
console.warn("Session expired - please log in again");
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
Trash2,
|
||||
Users,
|
||||
Database,
|
||||
Lock,
|
||||
Link2,
|
||||
Download,
|
||||
Upload,
|
||||
Monitor,
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
getSessions,
|
||||
revokeSession,
|
||||
revokeAllUserSessions,
|
||||
convertOIDCToPassword,
|
||||
linkOIDCToPasswordAccount,
|
||||
} from "@/ui/main-axios.ts";
|
||||
|
||||
interface AdminSettingsProps {
|
||||
@@ -75,7 +75,7 @@ interface AdminSettingsProps {
|
||||
export function AdminSettings({
|
||||
isTopbarOpen = true,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 300,
|
||||
rightSidebarWidth = 400,
|
||||
}: AdminSettingsProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
@@ -107,6 +107,7 @@ export function AdminSettings({
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
password_hash?: string;
|
||||
}>
|
||||
>([]);
|
||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||
@@ -147,15 +148,13 @@ export function AdminSettings({
|
||||
>([]);
|
||||
const [sessionsLoading, setSessionsLoading] = React.useState(false);
|
||||
|
||||
const [convertUserDialogOpen, setConvertUserDialogOpen] =
|
||||
React.useState(false);
|
||||
const [convertTargetUser, setConvertTargetUser] = React.useState<{
|
||||
const [linkAccountAlertOpen, setLinkAccountAlertOpen] = React.useState(false);
|
||||
const [linkOidcUser, setLinkOidcUser] = 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 [linkTargetUsername, setLinkTargetUsername] = React.useState("");
|
||||
const [linkLoading, setLinkLoading] = React.useState(false);
|
||||
|
||||
const requiresImportPassword = React.useMemo(
|
||||
() => !currentUser?.is_oidc,
|
||||
@@ -655,54 +654,41 @@ export function AdminSettings({
|
||||
);
|
||||
};
|
||||
|
||||
const handleConvertOIDCUser = (user: { id: string; username: string }) => {
|
||||
setConvertTargetUser(user);
|
||||
setConvertPassword("");
|
||||
setConvertTotpCode("");
|
||||
setConvertUserDialogOpen(true);
|
||||
const handleLinkOIDCUser = (user: { id: string; username: string }) => {
|
||||
setLinkOidcUser(user);
|
||||
setLinkTargetUsername("");
|
||||
setLinkAccountAlertOpen(true);
|
||||
};
|
||||
|
||||
const handleConvertSubmit = async () => {
|
||||
if (!convertTargetUser || !convertPassword) {
|
||||
toast.error("Password is required");
|
||||
const handleLinkSubmit = async () => {
|
||||
if (!linkOidcUser || !linkTargetUsername.trim()) {
|
||||
toast.error("Target username is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (convertPassword.length < 8) {
|
||||
toast.error("Password must be at least 8 characters long");
|
||||
return;
|
||||
}
|
||||
|
||||
setConvertLoading(true);
|
||||
setLinkLoading(true);
|
||||
try {
|
||||
const result = await convertOIDCToPassword(
|
||||
convertTargetUser.id,
|
||||
convertPassword,
|
||||
convertTotpCode || undefined,
|
||||
const result = await linkOIDCToPasswordAccount(
|
||||
linkOidcUser.id,
|
||||
linkTargetUsername.trim(),
|
||||
);
|
||||
|
||||
toast.success(
|
||||
result.message ||
|
||||
`User ${convertTargetUser.username} converted to password authentication`,
|
||||
`OIDC user ${linkOidcUser.username} linked to ${linkTargetUsername}`,
|
||||
);
|
||||
setConvertUserDialogOpen(false);
|
||||
setConvertPassword("");
|
||||
setConvertTotpCode("");
|
||||
setConvertTargetUser(null);
|
||||
setLinkAccountAlertOpen(false);
|
||||
setLinkTargetUsername("");
|
||||
setLinkOidcUser(null);
|
||||
fetchUsers();
|
||||
fetchSessions();
|
||||
} 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",
|
||||
);
|
||||
}
|
||||
toast.error(err.response?.data?.error || "Failed to link accounts");
|
||||
} finally {
|
||||
setConvertLoading(false);
|
||||
setLinkLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1095,26 +1081,28 @@ export function AdminSettings({
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
{user.is_oidc
|
||||
? t("admin.external")
|
||||
: t("admin.local")}
|
||||
{user.is_oidc && user.password_hash
|
||||
? "Dual Auth"
|
||||
: user.is_oidc
|
||||
? t("admin.external")
|
||||
: t("admin.local")}
|
||||
</TableCell>
|
||||
<TableCell className="px-4">
|
||||
<div className="flex gap-2">
|
||||
{user.is_oidc && (
|
||||
{user.is_oidc && !user.password_hash && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleConvertOIDCUser({
|
||||
handleLinkOIDCUser({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
})
|
||||
}
|
||||
className="text-blue-600 hover:text-blue-700 hover:bg-blue-50"
|
||||
title="Convert to password authentication"
|
||||
title="Link to password account"
|
||||
>
|
||||
<Lock className="h-4 w-4" />
|
||||
<Link2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -1505,78 +1493,87 @@ export function AdminSettings({
|
||||
</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>
|
||||
{/* Link OIDC to Password Account Dialog */}
|
||||
{linkAccountAlertOpen && (
|
||||
<Dialog
|
||||
open={linkAccountAlertOpen}
|
||||
onOpenChange={setLinkAccountAlertOpen}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5" />
|
||||
Link OIDC Account to Password Account
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Link{" "}
|
||||
<span className="font-mono text-foreground">
|
||||
{linkOidcUser?.username}
|
||||
</span>{" "}
|
||||
(OIDC user) to an existing password account. This will enable
|
||||
dual authentication for the password account.
|
||||
</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-4 py-4">
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Warning: OIDC User Data Will Be Deleted</AlertTitle>
|
||||
<AlertDescription>
|
||||
This action will:
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Delete the OIDC user account and all their data</li>
|
||||
<li>
|
||||
Add OIDC login capability to the target password account
|
||||
</li>
|
||||
<li>
|
||||
Allow the password account to login with both password and
|
||||
OIDC
|
||||
</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 className="space-y-2">
|
||||
<Label
|
||||
htmlFor="link-target-username"
|
||||
className="text-base font-semibold text-foreground"
|
||||
>
|
||||
Target Password Account Username
|
||||
</Label>
|
||||
<Input
|
||||
id="link-target-username"
|
||||
value={linkTargetUsername}
|
||||
onChange={(e) => setLinkTargetUsername(e.target.value)}
|
||||
placeholder="Enter username of password account"
|
||||
disabled={linkLoading}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && linkTargetUsername.trim()) {
|
||||
handleLinkSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setLinkAccountAlertOpen(false)}
|
||||
disabled={linkLoading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLinkSubmit}
|
||||
disabled={linkLoading || !linkTargetUsername.trim()}
|
||||
variant="destructive"
|
||||
>
|
||||
{linkLoading ? "Linking..." : "Link Accounts"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,7 +122,10 @@ export function CommandPalette({
|
||||
if (adminTab) {
|
||||
setCurrentTab(adminTab.id);
|
||||
} else {
|
||||
const id = addTab({ type: "admin", title: t("commandPalette.adminSettings") });
|
||||
const id = addTab({
|
||||
type: "admin",
|
||||
title: t("commandPalette.adminSettings"),
|
||||
});
|
||||
setCurrentTab(id);
|
||||
}
|
||||
setIsOpen(false);
|
||||
@@ -133,7 +136,10 @@ export function CommandPalette({
|
||||
if (userProfileTab) {
|
||||
setCurrentTab(userProfileTab.id);
|
||||
} else {
|
||||
const id = addTab({ type: "user_profile", title: t("commandPalette.userProfile") });
|
||||
const id = addTab({
|
||||
type: "user_profile",
|
||||
title: t("commandPalette.userProfile"),
|
||||
});
|
||||
setCurrentTab(id);
|
||||
}
|
||||
setIsOpen(false);
|
||||
@@ -288,83 +294,93 @@ export function CommandPalette({
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading={t("commandPalette.hosts")}>
|
||||
{hosts.map((host, index) => {
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`host-${index}-${host.id}`}
|
||||
value={`host-${index}-${title}-${host.id}`}
|
||||
onSelect={() => {
|
||||
if (host.enableTerminal) {
|
||||
handleHostTerminalClick(host);
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 h-7 border-1 border-dark-border"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="right"
|
||||
className="w-56 bg-dark-bg border-dark-border text-white"
|
||||
{hosts.length > 0 && (
|
||||
<>
|
||||
<CommandGroup heading={t("commandPalette.hosts")}>
|
||||
{hosts.map((host, index) => {
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`host-${index}-${host.id}`}
|
||||
value={`host-${index}-${title}-${host.id}`}
|
||||
onSelect={() => {
|
||||
if (host.enableTerminal) {
|
||||
handleHostTerminalClick(host);
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostServerDetailsClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="flex-1">{t("commandPalette.openServerDetails")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostFileManagerClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="flex-1">{t("commandPalette.openFileManager")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostEditClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="flex-1">{t("commandPalette.edit")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 h-7 border-1 border-dark-border"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="right"
|
||||
className="w-56 bg-dark-bg border-dark-border text-white"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostServerDetailsClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("commandPalette.openServerDetails")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostFileManagerClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("commandPalette.openFileManager")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostEditClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("commandPalette.edit")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
<CommandGroup heading={t("commandPalette.links")}>
|
||||
<CommandItem onSelect={handleGitHub}>
|
||||
<Github />
|
||||
|
||||
@@ -62,7 +62,7 @@ export function Dashboard({
|
||||
isTopbarOpen,
|
||||
onSelectView,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 300,
|
||||
rightSidebarWidth = 400,
|
||||
}: DashboardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||
|
||||
@@ -19,7 +19,7 @@ export function HostManager({
|
||||
initialTab = "host_viewer",
|
||||
hostConfig,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 300,
|
||||
rightSidebarWidth = 400,
|
||||
}: HostManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
|
||||
@@ -110,12 +110,12 @@ function JumpHostItem({
|
||||
{index + 1}.
|
||||
</span>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<PopoverTrigger asChild className="flex-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="flex-1 justify-between"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedHost
|
||||
? `${selectedHost.name || `${selectedHost.username}@${selectedHost.ip}`}`
|
||||
@@ -123,7 +123,10 @@ function JumpHostItem({
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[400px] p-0">
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("hosts.searchServers")} />
|
||||
<CommandEmpty>{t("hosts.noServerFound")}</CommandEmpty>
|
||||
@@ -133,7 +136,7 @@ function JumpHostItem({
|
||||
.map((host) => (
|
||||
<CommandItem
|
||||
key={host.id}
|
||||
value={`${host.name} ${host.ip} ${host.username}`}
|
||||
value={`${host.name} ${host.ip} ${host.username} ${host.id}`}
|
||||
onSelect={() => {
|
||||
onUpdate(host.id);
|
||||
setOpen(false);
|
||||
@@ -162,7 +165,112 @@ function JumpHostItem({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onRemove}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRemove}
|
||||
className="ml-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface QuickActionItemProps {
|
||||
quickAction: { name: string; snippetId: number };
|
||||
index: number;
|
||||
snippets: Array<{ id: number; name: string; content: string }>;
|
||||
onUpdate: (name: string, snippetId: number) => void;
|
||||
onRemove: () => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
function QuickActionItem({
|
||||
quickAction,
|
||||
index,
|
||||
snippets,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
t,
|
||||
}: QuickActionItemProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const selectedSnippet = snippets.find((s) => s.id === quickAction.snippetId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3 border rounded-lg bg-muted/30">
|
||||
<div className="flex flex-col gap-2 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<Input
|
||||
placeholder={t("hosts.quickActionName")}
|
||||
value={quickAction.name}
|
||||
onChange={(e) => onUpdate(e.target.value, quickAction.snippetId)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild className="w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedSnippet
|
||||
? selectedSnippet.name
|
||||
: t("hosts.selectSnippet")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("hosts.searchSnippets")} />
|
||||
<CommandEmpty>{t("hosts.noSnippetFound")}</CommandEmpty>
|
||||
<CommandGroup className="max-h-[300px] overflow-y-auto">
|
||||
{snippets.map((snippet) => (
|
||||
<CommandItem
|
||||
key={snippet.id}
|
||||
value={`${snippet.name} ${snippet.content} ${snippet.id}`}
|
||||
onSelect={() => {
|
||||
onUpdate(quickAction.name, snippet.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
quickAction.snippetId === snippet.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{snippet.name}</span>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[350px]">
|
||||
{snippet.content}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRemove}
|
||||
className="ml-2"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -198,6 +306,10 @@ interface SSHHost {
|
||||
jumpHosts?: Array<{
|
||||
hostId: number;
|
||||
}>;
|
||||
quickActions?: Array<{
|
||||
name: string;
|
||||
snippetId: number;
|
||||
}>;
|
||||
statsConfig?: StatsConfig;
|
||||
terminalConfig?: TerminalConfig;
|
||||
createdAt: string;
|
||||
@@ -440,6 +552,14 @@ export function HostManagerEditor({
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
quickActions: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
snippetId: z.number().min(1),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.authType === "none") {
|
||||
@@ -528,6 +648,7 @@ export function HostManagerEditor({
|
||||
defaultPath: "/",
|
||||
tunnelConnections: [],
|
||||
jumpHosts: [],
|
||||
quickActions: [],
|
||||
statsConfig: DEFAULT_STATS_CONFIG,
|
||||
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
||||
forceKeyboardInteractive: false,
|
||||
@@ -612,6 +733,9 @@ export function HostManagerEditor({
|
||||
jumpHosts: Array.isArray(cleanedHost.jumpHosts)
|
||||
? cleanedHost.jumpHosts
|
||||
: [],
|
||||
quickActions: Array.isArray(cleanedHost.quickActions)
|
||||
? cleanedHost.quickActions
|
||||
: [],
|
||||
statsConfig: parsedStatsConfig,
|
||||
terminalConfig: {
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
@@ -670,6 +794,7 @@ export function HostManagerEditor({
|
||||
defaultPath: "/",
|
||||
tunnelConnections: [],
|
||||
jumpHosts: [],
|
||||
quickActions: [],
|
||||
statsConfig: DEFAULT_STATS_CONFIG,
|
||||
terminalConfig: DEFAULT_TERMINAL_CONFIG,
|
||||
forceKeyboardInteractive: false,
|
||||
@@ -730,6 +855,7 @@ export function HostManagerEditor({
|
||||
defaultPath: data.defaultPath || "/",
|
||||
tunnelConnections: data.tunnelConnections || [],
|
||||
jumpHosts: data.jumpHosts || [],
|
||||
quickActions: data.quickActions || [],
|
||||
statsConfig: data.statsConfig || DEFAULT_STATS_CONFIG,
|
||||
terminalConfig: data.terminalConfig || DEFAULT_TERMINAL_CONFIG,
|
||||
forceKeyboardInteractive: Boolean(data.forceKeyboardInteractive),
|
||||
@@ -2983,6 +3109,70 @@ export function HostManagerEditor({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{t("hosts.quickActions")}
|
||||
</h3>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t("hosts.quickActionsDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="quickActions"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.quickActionsList")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-3">
|
||||
{field.value.map((quickAction, index) => (
|
||||
<QuickActionItem
|
||||
key={index}
|
||||
quickAction={quickAction}
|
||||
index={index}
|
||||
snippets={snippets}
|
||||
onUpdate={(name, snippetId) => {
|
||||
const newQuickActions = [...field.value];
|
||||
newQuickActions[index] = {
|
||||
name,
|
||||
snippetId,
|
||||
};
|
||||
field.onChange(newQuickActions);
|
||||
}}
|
||||
onRemove={() => {
|
||||
const newQuickActions = field.value.filter(
|
||||
(_, i) => i !== index,
|
||||
);
|
||||
field.onChange(newQuickActions);
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
field.onChange([
|
||||
...field.value,
|
||||
{ name: "", snippetId: 0 },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("hosts.addQuickAction")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.quickActionsOrder")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -1240,6 +1240,9 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.username}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
ID: {host.id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||
{host.folder && host.folder !== "" && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx";
|
||||
import {
|
||||
getServerStatusById,
|
||||
getServerMetricsById,
|
||||
executeSnippet,
|
||||
type ServerMetrics,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
@@ -29,6 +30,11 @@ import {
|
||||
} from "./widgets";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
interface QuickAction {
|
||||
name: string;
|
||||
snippetId: number;
|
||||
}
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -37,6 +43,7 @@ interface HostConfig {
|
||||
folder?: string;
|
||||
enableFileManager?: boolean;
|
||||
tunnelConnections?: unknown[];
|
||||
quickActions?: QuickAction[];
|
||||
statsConfig?: string | StatsConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -81,6 +88,9 @@ export function Server({
|
||||
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
const [showStatsUI, setShowStatsUI] = React.useState(true);
|
||||
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const statsConfig = React.useMemo((): StatsConfig => {
|
||||
if (!currentHostConfig?.statsConfig) {
|
||||
@@ -450,38 +460,147 @@ export function Server({
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{metricsEnabled && showStatsUI && (
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 max-h-[50vh] overflow-y-auto relative">
|
||||
{!metrics && serverStatus === "offline" ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
|
||||
</div>
|
||||
<p className="text-gray-300 mb-1">
|
||||
{t("serverStats.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("serverStats.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{enabledWidgets.map((widgetType) => (
|
||||
<div key={widgetType} className="h-[280px]">
|
||||
{renderWidget(widgetType)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(metricsEnabled && showStatsUI) ||
|
||||
(currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0) ? (
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 overflow-y-auto relative flex-1 flex flex-col">
|
||||
{currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0 && (
|
||||
<div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>
|
||||
<h3 className="text-sm font-semibold text-gray-400 mb-2">
|
||||
{t("serverStats.quickActions")}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentHostConfig.quickActions.map((action, index) => {
|
||||
const isExecuting = executingActions.has(
|
||||
action.snippetId,
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="font-semibold"
|
||||
disabled={isExecuting}
|
||||
onClick={async () => {
|
||||
if (!currentHostConfig) return;
|
||||
|
||||
<SimpleLoader
|
||||
visible={isLoadingMetrics && !metrics}
|
||||
message={t("serverStats.loadingMetrics")}
|
||||
/>
|
||||
setExecutingActions((prev) =>
|
||||
new Set(prev).add(action.snippetId),
|
||||
);
|
||||
toast.loading(
|
||||
t("serverStats.executingQuickAction", {
|
||||
name: action.name,
|
||||
}),
|
||||
{ id: `quick-action-${action.snippetId}` },
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await executeSnippet(
|
||||
action.snippetId,
|
||||
currentHostConfig.id,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
t("serverStats.quickActionSuccess", {
|
||||
name: action.name,
|
||||
}),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description: result.output
|
||||
? result.output.substring(0, 200)
|
||||
: undefined,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t("serverStats.quickActionFailed", {
|
||||
name: action.name,
|
||||
}),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description:
|
||||
result.error ||
|
||||
result.output ||
|
||||
undefined,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
t("serverStats.quickActionError", {
|
||||
name: action.name,
|
||||
}),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description:
|
||||
error?.message || "Unknown error",
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setExecutingActions((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(action.snippetId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
title={t("serverStats.executeQuickAction", {
|
||||
name: action.name,
|
||||
})}
|
||||
>
|
||||
{isExecuting ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
|
||||
{action.name}
|
||||
</div>
|
||||
) : (
|
||||
action.name
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{metricsEnabled &&
|
||||
showStatsUI &&
|
||||
(!metrics && serverStatus === "offline" ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
|
||||
</div>
|
||||
<p className="text-gray-300 mb-1">
|
||||
{t("serverStats.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("serverStats.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{enabledWidgets.map((widgetType) => (
|
||||
<div key={widgetType} className="h-[280px]">
|
||||
{renderWidget(widgetType)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{metricsEnabled && showStatsUI && (
|
||||
<SimpleLoader
|
||||
visible={isLoadingMetrics && !metrics}
|
||||
message={t("serverStats.loadingMetrics")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{currentHostConfig?.tunnelConnections &&
|
||||
currentHostConfig.tunnelConnections.length > 0 && (
|
||||
|
||||
@@ -221,7 +221,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
// Load command history for autocomplete on mount (Stage 3)
|
||||
useEffect(() => {
|
||||
if (hostConfig.id) {
|
||||
// Check if command autocomplete is enabled
|
||||
const autocompleteEnabled =
|
||||
localStorage.getItem("commandAutocomplete") !== "false";
|
||||
|
||||
if (hostConfig.id && autocompleteEnabled) {
|
||||
import("@/ui/main-axios.ts")
|
||||
.then((module) => module.getCommandHistory(hostConfig.id!))
|
||||
.then((history) => {
|
||||
@@ -231,6 +235,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
console.error("Failed to load autocomplete history:", error);
|
||||
autocompleteHistory.current = [];
|
||||
});
|
||||
} else {
|
||||
autocompleteHistory.current = [];
|
||||
}
|
||||
}, [hostConfig.id]);
|
||||
|
||||
@@ -1318,6 +1324,20 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Check if command autocomplete is enabled in settings
|
||||
const autocompleteEnabled =
|
||||
localStorage.getItem("commandAutocomplete") !== "false";
|
||||
|
||||
if (!autocompleteEnabled) {
|
||||
// If disabled, let the terminal handle Tab normally (send to server)
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "input", data: "\t" }),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentCmd = getCurrentCommandRef.current().trim();
|
||||
if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) {
|
||||
// Filter commands that start with current input
|
||||
@@ -1328,7 +1348,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
cmd !== currentCmd &&
|
||||
cmd.length > currentCmd.length,
|
||||
)
|
||||
.slice(0, 10); // Show up to 10 matches
|
||||
.slice(0, 5); // Show up to 5 matches for better UX
|
||||
|
||||
if (matches.length === 1) {
|
||||
// Only one match - auto-complete directly
|
||||
@@ -1359,21 +1379,31 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const cellWidth =
|
||||
terminal.cols > 0 ? rect.width / terminal.cols : 10;
|
||||
|
||||
// Estimate autocomplete menu height (max-h-[240px] from component)
|
||||
const menuHeight = 240;
|
||||
const cursorBottomY = rect.top + (cursorY + 1) * cellHeight;
|
||||
const spaceBelow = window.innerHeight - cursorBottomY;
|
||||
const spaceAbove = rect.top + cursorY * cellHeight;
|
||||
// Calculate actual menu height based on number of items
|
||||
// Each item is ~32px (py-1.5), footer is ~32px, max total 240px
|
||||
const itemHeight = 32;
|
||||
const footerHeight = 32;
|
||||
const maxMenuHeight = 240;
|
||||
const estimatedMenuHeight = Math.min(
|
||||
matches.length * itemHeight + footerHeight,
|
||||
maxMenuHeight,
|
||||
);
|
||||
|
||||
// Show above cursor if not enough space below
|
||||
// Get cursor position in viewport coordinates
|
||||
const cursorBottomY = rect.top + (cursorY + 1) * cellHeight;
|
||||
const cursorTopY = rect.top + cursorY * cellHeight;
|
||||
const spaceBelow = window.innerHeight - cursorBottomY;
|
||||
const spaceAbove = cursorTopY;
|
||||
|
||||
// Show above cursor if not enough space below and more space above
|
||||
const showAbove =
|
||||
spaceBelow < menuHeight && spaceAbove > spaceBelow;
|
||||
spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow;
|
||||
|
||||
setAutocompletePosition({
|
||||
top: showAbove
|
||||
? rect.top + cursorY * cellHeight - menuHeight
|
||||
? Math.max(0, cursorTopY - estimatedMenuHeight)
|
||||
: cursorBottomY,
|
||||
left: rect.left + cursorX * cellWidth,
|
||||
left: Math.max(0, rect.left + cursorX * cellWidth),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -33,33 +33,44 @@ export function CommandAutocomplete({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate max height for suggestions list to ensure footer is always visible
|
||||
// Footer height is approximately 32px (text + padding + border)
|
||||
const footerHeight = 32;
|
||||
const maxSuggestionsHeight = 240 - footerHeight;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed z-[9999] bg-dark-bg border border-dark-border rounded-md shadow-lg max-h-[240px] overflow-y-auto min-w-[200px] max-w-[600px]"
|
||||
className="fixed z-[9999] bg-dark-bg border border-dark-border rounded-md shadow-lg min-w-[200px] max-w-[600px] flex flex-col"
|
||||
style={{
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
maxHeight: "240px",
|
||||
}}
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={index}
|
||||
ref={index === selectedIndex ? selectedRef : null}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors",
|
||||
"hover:bg-dark-hover",
|
||||
index === selectedIndex && "bg-blue-500/20 text-blue-400",
|
||||
)}
|
||||
onClick={() => onSelect(suggestion)}
|
||||
onMouseEnter={() => {
|
||||
// Optional: update selected index on hover
|
||||
}}
|
||||
>
|
||||
{suggestion}
|
||||
</div>
|
||||
))}
|
||||
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-dark-border bg-dark-bg/50">
|
||||
<div
|
||||
className="overflow-y-auto"
|
||||
style={{ maxHeight: `${maxSuggestionsHeight}px` }}
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={index}
|
||||
ref={index === selectedIndex ? selectedRef : null}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors",
|
||||
"hover:bg-dark-hover",
|
||||
index === selectedIndex && "bg-gray-500/20 text-gray-400",
|
||||
)}
|
||||
onClick={() => onSelect(suggestion)}
|
||||
onMouseEnter={() => {
|
||||
// Optional: update selected index on hover
|
||||
}}
|
||||
>
|
||||
{suggestion}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-dark-border bg-dark-bg/50 shrink-0">
|
||||
Tab/Enter to complete • ↑↓ to navigate • Esc to close
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
Search,
|
||||
Loader2,
|
||||
Terminal,
|
||||
LayoutGrid,
|
||||
MonitorCheck,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -67,7 +69,7 @@ interface TabData {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SSHUtilitySidebarProps {
|
||||
interface SSHToolsSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSnippetExecute: (content: string) => void;
|
||||
@@ -85,12 +87,21 @@ export function SSHToolsSidebar({
|
||||
setSidebarWidth,
|
||||
initialTab,
|
||||
onTabChange,
|
||||
}: SSHUtilitySidebarProps) {
|
||||
}: SSHToolsSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const { tabs, currentTab } = useTabs() as {
|
||||
const {
|
||||
tabs,
|
||||
currentTab,
|
||||
allSplitScreenTab,
|
||||
setSplitScreenTab,
|
||||
setCurrentTab,
|
||||
} = useTabs() as {
|
||||
tabs: TabData[];
|
||||
currentTab: number | null;
|
||||
allSplitScreenTab: number[];
|
||||
setSplitScreenTab: (tabId: number) => void;
|
||||
setCurrentTab: (tabId: number) => void;
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState(initialTab || "ssh-tools");
|
||||
|
||||
@@ -141,6 +152,17 @@ export function SSHToolsSidebar({
|
||||
const [historyRefreshCounter, setHistoryRefreshCounter] = useState(0);
|
||||
const commandHistoryScrollRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
// Split Screen state
|
||||
const [splitMode, setSplitMode] = useState<"none" | "2" | "3" | "4">("none");
|
||||
const [splitAssignments, setSplitAssignments] = useState<Map<number, number>>(
|
||||
new Map(),
|
||||
);
|
||||
const [previewKey, setPreviewKey] = useState(0);
|
||||
const [draggedTabId, setDraggedTabId] = useState<number | null>(null);
|
||||
const [dragOverCellIndex, setDragOverCellIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Resize state
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const startXRef = React.useRef<number | null>(null);
|
||||
@@ -152,6 +174,15 @@ export function SSHToolsSidebar({
|
||||
activeUiTab?.type === "terminal" ? activeUiTab : undefined;
|
||||
const activeTerminalHostId = activeTerminal?.hostConfig?.id;
|
||||
|
||||
// Get splittable tabs (terminal, server, file_manager)
|
||||
const splittableTabs = tabs.filter(
|
||||
(tab: TabData) =>
|
||||
tab.type === "terminal" ||
|
||||
tab.type === "server" ||
|
||||
tab.type === "file_manager" ||
|
||||
tab.type === "user_profile",
|
||||
);
|
||||
|
||||
// Fetch command history
|
||||
useEffect(() => {
|
||||
if (isOpen && activeTab === "command-history") {
|
||||
@@ -567,6 +598,148 @@ export function SSHToolsSidebar({
|
||||
toast.success(t("snippets.copySuccess", { name: snippet.name }));
|
||||
};
|
||||
|
||||
// Split Screen handlers
|
||||
const handleSplitModeChange = (mode: "none" | "2" | "3" | "4") => {
|
||||
setSplitMode(mode);
|
||||
|
||||
if (mode === "none") {
|
||||
// Clear all splits
|
||||
handleClearSplit();
|
||||
} else {
|
||||
// Clear assignments when changing modes
|
||||
setSplitAssignments(new Map());
|
||||
setPreviewKey((prev) => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragStart = (tabId: number) => {
|
||||
setDraggedTabId(tabId);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedTabId(null);
|
||||
setDragOverCellIndex(null);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, cellIndex: number) => {
|
||||
e.preventDefault();
|
||||
setDragOverCellIndex(cellIndex);
|
||||
};
|
||||
|
||||
const handleDragLeave = () => {
|
||||
setDragOverCellIndex(null);
|
||||
};
|
||||
|
||||
const handleDrop = (cellIndex: number) => {
|
||||
if (draggedTabId === null) return;
|
||||
|
||||
setSplitAssignments((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
// Remove this tab from any other cell
|
||||
Array.from(newMap.entries()).forEach(([idx, id]) => {
|
||||
if (id === draggedTabId && idx !== cellIndex) {
|
||||
newMap.delete(idx);
|
||||
}
|
||||
});
|
||||
newMap.set(cellIndex, draggedTabId);
|
||||
return newMap;
|
||||
});
|
||||
|
||||
setDraggedTabId(null);
|
||||
setDragOverCellIndex(null);
|
||||
setPreviewKey((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleRemoveFromCell = (cellIndex: number) => {
|
||||
setSplitAssignments((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.delete(cellIndex);
|
||||
setPreviewKey((prev) => prev + 1);
|
||||
return newMap;
|
||||
});
|
||||
};
|
||||
|
||||
const handleApplySplit = () => {
|
||||
if (splitMode === "none") {
|
||||
handleClearSplit();
|
||||
return;
|
||||
}
|
||||
|
||||
if (splitAssignments.size === 0) {
|
||||
toast.error(
|
||||
t("splitScreen.error.noAssignments", {
|
||||
defaultValue: "Please drag tabs to cells before applying",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const requiredSlots = parseInt(splitMode);
|
||||
|
||||
// Validate: All layout spots must be filled
|
||||
if (splitAssignments.size < requiredSlots) {
|
||||
toast.error(
|
||||
t("splitScreen.error.fillAllSlots", {
|
||||
defaultValue: `Please fill all ${requiredSlots} layout spots before applying`,
|
||||
count: requiredSlots,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build ordered array of tab IDs based on cell index (0, 1, 2, 3)
|
||||
const orderedTabIds: number[] = [];
|
||||
for (let i = 0; i < requiredSlots; i++) {
|
||||
const tabId = splitAssignments.get(i);
|
||||
if (tabId !== undefined) {
|
||||
orderedTabIds.push(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
// First, clear ALL existing splits
|
||||
const currentSplits = [...allSplitScreenTab];
|
||||
currentSplits.forEach((tabId) => {
|
||||
setSplitScreenTab(tabId); // Toggle off
|
||||
});
|
||||
|
||||
// Then, add only the newly assigned tabs to split IN ORDER
|
||||
orderedTabIds.forEach((tabId) => {
|
||||
setSplitScreenTab(tabId); // Toggle on
|
||||
});
|
||||
|
||||
// Set first assigned tab as active if current tab is not in split
|
||||
if (!orderedTabIds.includes(currentTab ?? 0)) {
|
||||
setCurrentTab(orderedTabIds[0]);
|
||||
}
|
||||
|
||||
toast.success(
|
||||
t("splitScreen.success", {
|
||||
defaultValue: "Split screen applied",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleClearSplit = () => {
|
||||
// Remove all tabs from split screen
|
||||
allSplitScreenTab.forEach((tabId) => {
|
||||
setSplitScreenTab(tabId);
|
||||
});
|
||||
|
||||
setSplitMode("none");
|
||||
setSplitAssignments(new Map());
|
||||
setPreviewKey((prev) => prev + 1);
|
||||
|
||||
toast.success(
|
||||
t("splitScreen.cleared", {
|
||||
defaultValue: "Split screen cleared",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleResetToSingle = () => {
|
||||
handleClearSplit();
|
||||
};
|
||||
|
||||
// Command History handlers
|
||||
const handleCommandSelect = (command: string) => {
|
||||
if (activeTerminal?.terminalRef?.current?.sendInput) {
|
||||
@@ -616,7 +789,7 @@ export function SSHToolsSidebar({
|
||||
<div className="absolute right-5 flex gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setSidebarWidth(300)}
|
||||
onClick={() => setSidebarWidth(400)}
|
||||
className="w-[28px] h-[28px]"
|
||||
title="Reset sidebar width"
|
||||
>
|
||||
@@ -640,7 +813,7 @@ export function SSHToolsSidebar({
|
||||
onValueChange={handleTabChange}
|
||||
className="flex flex-col h-full overflow-hidden"
|
||||
>
|
||||
<TabsList className="w-full grid grid-cols-3 mb-4 flex-shrink-0">
|
||||
<TabsList className="w-full grid grid-cols-4 mb-4 flex-shrink-0">
|
||||
<TabsTrigger value="ssh-tools">
|
||||
{t("sshTools.title")}
|
||||
</TabsTrigger>
|
||||
@@ -650,6 +823,9 @@ export function SSHToolsSidebar({
|
||||
<TabsTrigger value="command-history">
|
||||
{t("commandHistory.title", { defaultValue: "History" })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="split-screen">
|
||||
{t("splitScreen.title", { defaultValue: "Split Screen" })}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="ssh-tools" className="space-y-4">
|
||||
@@ -755,7 +931,7 @@ export function SSHToolsSidebar({
|
||||
href="https://github.com/Termix-SSH/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
className="gray-500 hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
@@ -832,7 +1008,7 @@ export function SSHToolsSidebar({
|
||||
{snippets.map((snippet) => (
|
||||
<div
|
||||
key={snippet.id}
|
||||
className="bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group"
|
||||
className="bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-gray-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group"
|
||||
>
|
||||
<div className="mb-2">
|
||||
<h3 className="text-sm font-medium text-white mb-1">
|
||||
@@ -843,6 +1019,9 @@ export function SSHToolsSidebar({
|
||||
{snippet.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
ID: {snippet.id}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded p-2 mb-3">
|
||||
@@ -950,6 +1129,12 @@ export function SSHToolsSidebar({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground bg-muted/30 px-2 py-1.5 rounded">
|
||||
{t("commandHistory.tabHint", {
|
||||
defaultValue:
|
||||
"Use Tab in Terminal to autocomplete from command history",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
@@ -1040,6 +1225,219 @@ export function SSHToolsSidebar({
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent
|
||||
value="split-screen"
|
||||
className="flex flex-col flex-1 overflow-hidden"
|
||||
>
|
||||
<div className="space-y-4 flex-1 overflow-y-auto overflow-x-hidden pb-4">
|
||||
{/* Split Mode Tabs */}
|
||||
<Tabs
|
||||
value={splitMode}
|
||||
onValueChange={(value) =>
|
||||
handleSplitModeChange(
|
||||
value as "none" | "2" | "3" | "4",
|
||||
)
|
||||
}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="w-full grid grid-cols-4">
|
||||
<TabsTrigger value="none">
|
||||
{t("splitScreen.none", { defaultValue: "None" })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="2">
|
||||
{t("splitScreen.twoSplit", {
|
||||
defaultValue: "2-Split",
|
||||
})}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="3">
|
||||
{t("splitScreen.threeSplit", {
|
||||
defaultValue: "3-Split",
|
||||
})}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="4">
|
||||
{t("splitScreen.fourSplit", {
|
||||
defaultValue: "4-Split",
|
||||
})}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{/* Drag-and-Drop Interface */}
|
||||
{splitMode !== "none" && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
{/* Available Tabs List */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">
|
||||
{t("splitScreen.availableTabs", {
|
||||
defaultValue: "Available Tabs",
|
||||
})}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{t("splitScreen.dragTabsHint", {
|
||||
defaultValue:
|
||||
"Drag tabs into the grid below to position them",
|
||||
})}
|
||||
</p>
|
||||
<div className="space-y-1 max-h-[200px] overflow-y-auto">
|
||||
{splittableTabs.map((tab) => {
|
||||
const isAssigned = Array.from(
|
||||
splitAssignments.values(),
|
||||
).includes(tab.id);
|
||||
const isDragging = draggedTabId === tab.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
draggable={!isAssigned}
|
||||
onDragStart={() => handleDragStart(tab.id)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className={`
|
||||
px-3 py-2 rounded-md text-sm cursor-move transition-all
|
||||
${
|
||||
isAssigned
|
||||
? "bg-dark-bg/50 text-muted-foreground cursor-not-allowed opacity-50"
|
||||
: "bg-dark-bg border border-dark-border hover:border-gray-400 hover:bg-dark-bg-input"
|
||||
}
|
||||
${isDragging ? "opacity-50" : ""}
|
||||
`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{tab.title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Drop Grid */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">
|
||||
{t("splitScreen.layout", {
|
||||
defaultValue: "Layout",
|
||||
})}
|
||||
</label>
|
||||
<div
|
||||
className={`grid gap-2 ${
|
||||
splitMode === "2"
|
||||
? "grid-cols-2"
|
||||
: splitMode === "3"
|
||||
? "grid-cols-2 grid-rows-2"
|
||||
: "grid-cols-2 grid-rows-2"
|
||||
}`}
|
||||
>
|
||||
{Array.from(
|
||||
{ length: parseInt(splitMode) },
|
||||
(_, idx) => {
|
||||
const assignedTabId =
|
||||
splitAssignments.get(idx);
|
||||
const assignedTab = assignedTabId
|
||||
? tabs.find((t) => t.id === assignedTabId)
|
||||
: null;
|
||||
const isHovered = dragOverCellIndex === idx;
|
||||
const isEmpty = !assignedTabId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
onDragOver={(e) => handleDragOver(e, idx)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={() => handleDrop(idx)}
|
||||
className={`
|
||||
relative bg-dark-bg border-2 rounded-md p-3 min-h-[100px]
|
||||
flex flex-col items-center justify-center transition-all
|
||||
${splitMode === "3" && idx === 2 ? "col-span-2" : ""}
|
||||
${
|
||||
isEmpty
|
||||
? "border-dashed border-dark-border"
|
||||
: "border-solid border-gray-400 bg-gray-500/10"
|
||||
}
|
||||
${
|
||||
isHovered && draggedTabId
|
||||
? "border-gray-500 bg-gray-500/20 ring-2 ring-gray-500/50"
|
||||
: ""
|
||||
}
|
||||
`}
|
||||
>
|
||||
{assignedTab ? (
|
||||
<>
|
||||
<span className="text-sm text-white truncate w-full text-center mb-2">
|
||||
{assignedTab.title}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleRemoveFromCell(idx)
|
||||
}
|
||||
className="h-6 text-xs hover:bg-red-500/20"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("splitScreen.dropHere", {
|
||||
defaultValue: "Drop tab here",
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
onClick={handleApplySplit}
|
||||
className="flex-1"
|
||||
disabled={splitAssignments.size === 0}
|
||||
>
|
||||
{t("splitScreen.apply", {
|
||||
defaultValue: "Apply Split",
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClearSplit}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("splitScreen.clear", {
|
||||
defaultValue: "Clear",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Help Text for None mode */}
|
||||
{splitMode === "none" && (
|
||||
<div className="text-center py-8">
|
||||
<LayoutGrid className="h-12 w-12 mb-4 opacity-20 mx-auto" />
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{t("splitScreen.selectMode", {
|
||||
defaultValue:
|
||||
"Select a split mode to get started",
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("splitScreen.helpText", {
|
||||
defaultValue:
|
||||
"Choose how many tabs you want to display at once",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</SidebarContent>
|
||||
{isOpen && (
|
||||
|
||||
@@ -555,7 +555,11 @@ export function Auth({
|
||||
const error = urlParams.get("error");
|
||||
|
||||
if (error) {
|
||||
toast.error(`${t("errors.oidcAuthFailed")}: ${error}`);
|
||||
if (error === "registration_disabled") {
|
||||
toast.error(t("messages.registrationDisabled"));
|
||||
} else {
|
||||
toast.error(`${t("errors.oidcAuthFailed")}: ${error}`);
|
||||
}
|
||||
setOidcLoading(false);
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
return;
|
||||
|
||||
@@ -42,7 +42,7 @@ interface TerminalViewProps {
|
||||
export function AppView({
|
||||
isTopbarOpen = true,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 300,
|
||||
rightSidebarWidth = 400,
|
||||
}: TerminalViewProps): React.ReactElement {
|
||||
const { tabs, currentTab, allSplitScreenTab, removeTab } = useTabs() as {
|
||||
tabs: TabData[];
|
||||
@@ -204,16 +204,12 @@ export function AppView({
|
||||
|
||||
const renderTerminalsLayer = () => {
|
||||
const styles: Record<number, React.CSSProperties> = {};
|
||||
const splitTabs = terminalTabs.filter((tab: TabData) =>
|
||||
allSplitScreenTab.includes(tab.id),
|
||||
);
|
||||
// Use allSplitScreenTab order directly - it maintains the order tabs were added
|
||||
const layoutTabs = allSplitScreenTab
|
||||
.map((tabId) => terminalTabs.find((tab: TabData) => tab.id === tabId))
|
||||
.filter((t): t is TabData => t !== null && t !== undefined);
|
||||
|
||||
const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab);
|
||||
const layoutTabs = [
|
||||
mainTab,
|
||||
...splitTabs.filter(
|
||||
(t: TabData) => t && t.id !== (mainTab && (mainTab as TabData).id),
|
||||
),
|
||||
].filter((t): t is TabData => t !== null && t !== undefined);
|
||||
|
||||
if (allSplitScreenTab.length === 0 && mainTab) {
|
||||
const isFileManagerTab = mainTab.type === "file_manager";
|
||||
@@ -358,16 +354,10 @@ export function AppView({
|
||||
};
|
||||
|
||||
const renderSplitOverlays = () => {
|
||||
const splitTabs = terminalTabs.filter((tab: TabData) =>
|
||||
allSplitScreenTab.includes(tab.id),
|
||||
);
|
||||
const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab);
|
||||
const layoutTabs = [
|
||||
mainTab,
|
||||
...splitTabs.filter(
|
||||
(t: TabData) => t && t.id !== (mainTab && (mainTab as TabData).id),
|
||||
),
|
||||
].filter((t): t is TabData => t !== null && t !== undefined);
|
||||
// Use allSplitScreenTab order directly - it maintains the order tabs were added
|
||||
const layoutTabs = allSplitScreenTab
|
||||
.map((tabId) => terminalTabs.find((tab: TabData) => tab.id === tabId))
|
||||
.filter((t): t is TabData => t !== null && t !== undefined);
|
||||
if (allSplitScreenTab.length === 0) return null;
|
||||
|
||||
const handleStyle = {
|
||||
|
||||
@@ -60,9 +60,10 @@ export function TopNavbar({
|
||||
|
||||
const [toolsSidebarOpen, setToolsSidebarOpen] = useState(false);
|
||||
const [commandHistoryTabActive, setCommandHistoryTabActive] = useState(false);
|
||||
const [splitScreenTabActive, setSplitScreenTabActive] = useState(false);
|
||||
const [rightSidebarWidth, setRightSidebarWidth] = useState<number>(() => {
|
||||
const saved = localStorage.getItem("rightSidebarWidth");
|
||||
const defaultWidth = 350;
|
||||
const defaultWidth = 400;
|
||||
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);
|
||||
@@ -132,7 +133,11 @@ export function TopNavbar({
|
||||
};
|
||||
|
||||
const handleTabSplit = (tabId: number) => {
|
||||
setSplitScreenTab(tabId);
|
||||
// Open the sidebar to the split-screen tab
|
||||
setToolsSidebarOpen(true);
|
||||
setCommandHistoryTabActive(false);
|
||||
setSplitScreenTabActive(true);
|
||||
// Optional: could pass tabId to pre-select this tab in the sidebar
|
||||
};
|
||||
|
||||
const handleTabClose = (tabId: number) => {
|
||||
@@ -371,17 +376,7 @@ export function TopNavbar({
|
||||
const isAdmin = tab.type === "admin";
|
||||
const isUserProfile = tab.type === "user_profile";
|
||||
const isSplittable = isTerminal || isServer || isFileManager;
|
||||
const isSplitButtonDisabled =
|
||||
(isActive && !isSplitScreenActive) ||
|
||||
((allSplitScreenTab?.length || 0) >= 3 && !isSplit);
|
||||
const disableSplit =
|
||||
!isSplittable ||
|
||||
isSplitButtonDisabled ||
|
||||
isActive ||
|
||||
currentTabIsHome ||
|
||||
currentTabIsSshManager ||
|
||||
currentTabIsAdmin ||
|
||||
currentTabIsUserProfile;
|
||||
const disableSplit = !isSplittable;
|
||||
const disableActivate =
|
||||
isSplit ||
|
||||
((tab.type === "home" ||
|
||||
@@ -390,7 +385,7 @@ export function TopNavbar({
|
||||
tab.type === "user_profile") &&
|
||||
isSplitScreenActive);
|
||||
const isHome = tab.type === "home";
|
||||
const disableClose = (isSplitScreenActive && isActive) || isHome;
|
||||
const disableClose = isHome;
|
||||
|
||||
const isDraggingThisTab = dragState.draggedIndex === index;
|
||||
const isTheDraggedTab = tab.id === dragState.draggedId;
|
||||
@@ -566,12 +561,17 @@ export function TopNavbar({
|
||||
onSnippetExecute={handleSnippetExecute}
|
||||
sidebarWidth={rightSidebarWidth}
|
||||
setSidebarWidth={setRightSidebarWidth}
|
||||
commandHistory={commandHistory.commandHistory}
|
||||
onSelectCommand={commandHistory.onSelectCommand}
|
||||
onDeleteCommand={commandHistory.onDeleteCommand}
|
||||
isHistoryLoading={commandHistory.isLoading}
|
||||
initialTab={commandHistoryTabActive ? "command-history" : undefined}
|
||||
onTabChange={() => setCommandHistoryTabActive(false)}
|
||||
initialTab={
|
||||
commandHistoryTabActive
|
||||
? "command-history"
|
||||
: splitScreenTabActive
|
||||
? "split-screen"
|
||||
: undefined
|
||||
}
|
||||
onTabChange={() => {
|
||||
setCommandHistoryTabActive(false);
|
||||
setSplitScreenTabActive(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -143,7 +143,7 @@ export function Tab({
|
||||
onClick={!disableActivate ? onActivate : undefined}
|
||||
style={{
|
||||
marginBottom: "-2px",
|
||||
borderBottom: isActive ? "2px solid white" : "none",
|
||||
borderBottom: isActive || isSplit ? "2px solid white" : "none",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
@@ -175,7 +175,10 @@ export function Tab({
|
||||
}
|
||||
>
|
||||
<SeparatorVertical
|
||||
className={cn("h-4 w-4", isSplit && "text-white")}
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
isSplit ? "text-white" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -156,11 +156,32 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
}
|
||||
|
||||
setTabs((prev) => prev.filter((tab) => tab.id !== tabId));
|
||||
setAllSplitScreenTab((prev) => prev.filter((id) => id !== tabId));
|
||||
|
||||
// Remove from split screen
|
||||
setAllSplitScreenTab((prev) => {
|
||||
const newSplits = prev.filter((id) => id !== tabId);
|
||||
// Auto-clear split mode if only 1 or fewer tabs remain in split
|
||||
if (newSplits.length <= 1) {
|
||||
return [];
|
||||
}
|
||||
return newSplits;
|
||||
});
|
||||
|
||||
if (currentTab === tabId) {
|
||||
const remainingTabs = tabs.filter((tab) => tab.id !== tabId);
|
||||
setCurrentTab(remainingTabs.length > 0 ? remainingTabs[0].id : 1);
|
||||
if (remainingTabs.length > 0) {
|
||||
// Try to set current tab to another split tab first, if any remain
|
||||
const remainingSplitTabs = allSplitScreenTab.filter(
|
||||
(id) => id !== tabId,
|
||||
);
|
||||
if (remainingSplitTabs.length > 0) {
|
||||
setCurrentTab(remainingSplitTabs[0]);
|
||||
} else {
|
||||
setCurrentTab(remainingTabs[0].id);
|
||||
}
|
||||
} else {
|
||||
setCurrentTab(1); // Home tab
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -168,7 +189,7 @@ export function TabProvider({ children }: TabProviderProps) {
|
||||
setAllSplitScreenTab((prev) => {
|
||||
if (prev.includes(tabId)) {
|
||||
return prev.filter((id) => id !== tabId);
|
||||
} else if (prev.length < 3) {
|
||||
} else if (prev.length < 4) {
|
||||
return [...prev, tabId];
|
||||
}
|
||||
return prev;
|
||||
|
||||
@@ -74,7 +74,7 @@ async function handleLogout() {
|
||||
export function UserProfile({
|
||||
isTopbarOpen = true,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 300,
|
||||
rightSidebarWidth = 400,
|
||||
}: UserProfileProps) {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
@@ -97,6 +97,9 @@ export function UserProfile({
|
||||
const [fileColorCoding, setFileColorCoding] = useState<boolean>(
|
||||
localStorage.getItem("fileColorCoding") !== "false",
|
||||
);
|
||||
const [commandAutocomplete, setCommandAutocomplete] = useState<boolean>(
|
||||
localStorage.getItem("commandAutocomplete") !== "false",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserInfo();
|
||||
@@ -145,6 +148,11 @@ export function UserProfile({
|
||||
window.dispatchEvent(new Event("fileColorCodingChanged"));
|
||||
};
|
||||
|
||||
const handleCommandAutocompleteToggle = (enabled: boolean) => {
|
||||
setCommandAutocomplete(enabled);
|
||||
localStorage.setItem("commandAutocomplete", enabled.toString());
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setDeleteLoading(true);
|
||||
@@ -363,6 +371,23 @@ export function UserProfile({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-dark-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-gray-300">
|
||||
{t("profile.commandAutocomplete")}
|
||||
</Label>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{t("profile.commandAutocompleteDesc")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={commandAutocomplete}
|
||||
onCheckedChange={handleCommandAutocompleteToggle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-dark-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
||||
@@ -77,6 +77,7 @@ interface UserInfo {
|
||||
is_admin: boolean;
|
||||
is_oidc: boolean;
|
||||
data_unlocked: boolean;
|
||||
password_hash?: string;
|
||||
}
|
||||
|
||||
interface UserCount {
|
||||
@@ -396,10 +397,12 @@ function createApiInstance(
|
||||
errorMessage === "Authentication required";
|
||||
|
||||
if (isSessionExpired || isSessionNotFound) {
|
||||
// Clear token from localStorage
|
||||
localStorage.removeItem("jwt");
|
||||
|
||||
// Clear Electron settings cache
|
||||
if (isElectron()) {
|
||||
localStorage.removeItem("jwt");
|
||||
} else {
|
||||
localStorage.removeItem("jwt");
|
||||
electronSettingsCache.delete("jwt");
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -420,6 +423,12 @@ function createApiInstance(
|
||||
"Authentication error - token may be invalid",
|
||||
errorMessage,
|
||||
);
|
||||
|
||||
// Clear invalid token
|
||||
localStorage.removeItem("jwt");
|
||||
if (isElectron()) {
|
||||
electronSettingsCache.delete("jwt");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -873,6 +882,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
|
||||
defaultPath: hostData.defaultPath || "/",
|
||||
tunnelConnections: hostData.tunnelConnections || [],
|
||||
jumpHosts: hostData.jumpHosts || [],
|
||||
quickActions: hostData.quickActions || [],
|
||||
statsConfig: hostData.statsConfig
|
||||
? typeof hostData.statsConfig === "string"
|
||||
? hostData.statsConfig
|
||||
@@ -938,6 +948,7 @@ export async function updateSSHHost(
|
||||
defaultPath: hostData.defaultPath || "/",
|
||||
tunnelConnections: hostData.tunnelConnections || [],
|
||||
jumpHosts: hostData.jumpHosts || [],
|
||||
quickActions: hostData.quickActions || [],
|
||||
statsConfig: hostData.statsConfig
|
||||
? typeof hostData.statsConfig === "string"
|
||||
? hostData.statsConfig
|
||||
@@ -2874,6 +2885,21 @@ export async function deleteSnippet(
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeSnippet(
|
||||
snippetId: number,
|
||||
hostId: number,
|
||||
): Promise<{ success: boolean; output: string; error?: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/snippets/execute", {
|
||||
snippetId,
|
||||
hostId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "execute snippet");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HOMEPAGE API
|
||||
// ============================================================================
|
||||
@@ -2966,10 +2992,17 @@ export async function saveCommandToHistory(
|
||||
/**
|
||||
* Get command history for a specific host
|
||||
* Returns array of unique commands ordered by most recent
|
||||
* @param hostId - The host ID to fetch history for
|
||||
* @param limit - Maximum number of commands to return (default: 100)
|
||||
*/
|
||||
export async function getCommandHistory(hostId: number): Promise<string[]> {
|
||||
export async function getCommandHistory(
|
||||
hostId: number,
|
||||
limit: number = 100,
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const response = await authApi.get(`/terminal/command_history/${hostId}`);
|
||||
const response = await authApi.get(`/terminal/command_history/${hostId}`, {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "fetch command history");
|
||||
@@ -3011,25 +3044,23 @@ export async function clearCommandHistory(
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OIDC TO PASSWORD CONVERSION
|
||||
// OIDC ACCOUNT LINKING
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Convert an OIDC user to a password-based user
|
||||
* Link an OIDC user to an existing password account (merges OIDC into password account)
|
||||
*/
|
||||
export async function convertOIDCToPassword(
|
||||
targetUserId: string,
|
||||
newPassword: string,
|
||||
totpCode?: string,
|
||||
export async function linkOIDCToPasswordAccount(
|
||||
oidcUserId: string,
|
||||
targetUsername: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/convert-oidc-to-password", {
|
||||
targetUserId,
|
||||
newPassword,
|
||||
totpCode,
|
||||
const response = await authApi.post("/users/link-oidc-to-password", {
|
||||
oidcUserId,
|
||||
targetUsername,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "convert OIDC user to password");
|
||||
throw handleApiError(error, "link OIDC account to password account");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,7 +495,12 @@ export function Auth({
|
||||
const error = urlParams.get("error");
|
||||
|
||||
if (error) {
|
||||
const errorMessage = `${t("errors.oidcAuthFailed")}: ${error}`;
|
||||
let errorMessage: string;
|
||||
if (error === "registration_disabled") {
|
||||
errorMessage = t("messages.registrationDisabled");
|
||||
} else {
|
||||
errorMessage = `${t("errors.oidcAuthFailed")}: ${error}`;
|
||||
}
|
||||
setError(errorMessage);
|
||||
setOidcLoading(false);
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
|
||||
Reference in New Issue
Block a user