feat: General bug fixes, added server stat commands, improved split screen, link accounts, etc

This commit is contained in:
LukeGus
2025-11-12 00:58:02 -06:00
parent 26b71c0b69
commit 8028e5d7cb
34 changed files with 1724 additions and 588 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 !== "" && (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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