Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import type { WorkspaceStore } from "@superset/panes";
import { Button } from "@superset/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@superset/ui/dropdown-menu";
import { toast } from "@superset/ui/sonner";
import { workspaceTrpc } from "@superset/workspace-client";
import { Archive, ChevronDown, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { getRelativeTime } from "renderer/screens/main/components/WorkspacesListView/utils";
import { useStore } from "zustand";
import type { StoreApi } from "zustand/vanilla";
import type { PaneViewerData, TerminalPaneData } from "../../types";

interface BackgroundTerminalsButtonProps {
workspaceId: string;
store: StoreApi<WorkspaceStore<PaneViewerData>>;
}

/**
* Tab-bar control that surfaces running terminal daemon sessions for the
* workspace that have no pane attached (e.g. moved to background via the
* terminal pane header). Renders nothing when there are none; otherwise a
* single button with a dropdown to re-open or kill each background session.
*/
export function BackgroundTerminalsButton({
workspaceId,
store,
}: BackgroundTerminalsButtonProps) {
const [isOpen, setIsOpen] = useState(false);
const tabs = useStore(store, (s) => s.tabs);
const utils = workspaceTrpc.useUtils();
const killSession = workspaceTrpc.terminal.killSession.useMutation();
const sessionsQuery = workspaceTrpc.terminal.listSessions.useQuery(
{ workspaceId },
{ refetchInterval: isOpen ? 2_000 : 5_000, refetchOnWindowFocus: true },
);

const attachedTerminalIds = useMemo(() => {
const ids = new Set<string>();
for (const tab of tabs) {
for (const pane of Object.values(tab.panes)) {
if (pane.kind !== "terminal") continue;
const data = pane.data as Partial<TerminalPaneData>;
if (data.terminalId) ids.add(data.terminalId);
}
}
return ids;
}, [tabs]);

const backgroundSessions = useMemo(() => {
const sessions = sessionsQuery.data?.sessions ?? [];
return sessions
.filter((session) => !attachedTerminalIds.has(session.terminalId))
.sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
}, [sessionsQuery.data?.sessions, attachedTerminalIds]);

if (backgroundSessions.length === 0) return null;

const label = `${backgroundSessions.length} background terminal session${
backgroundSessions.length === 1 ? "" : "s"
}`;

const handleAdopt = (terminalId: string) => {
store.getState().addTab({
panes: [
{
kind: "terminal",
data: { terminalId } as TerminalPaneData,
},
],
});
void utils.terminal.listSessions.invalidate({ workspaceId });
setIsOpen(false);
};

const handleKill = async (terminalId: string) => {
try {
await killSession.mutateAsync({ terminalId, workspaceId });
} catch (error) {
console.error(
"[BackgroundTerminalsButton] Failed to kill session:",
error,
);
toast.error("Failed to close terminal session");
} finally {
void utils.terminal.listSessions.invalidate({ workspaceId });
}
};

return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button
className="h-7 gap-1 rounded-md border border-border/60 bg-muted/30 px-2 text-xs text-muted-foreground shadow-none hover:bg-accent/60 hover:text-foreground"
size="sm"
type="button"
variant="ghost"
>
<Archive className="size-3.5" />
<span>{label}</span>
<ChevronDown className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuLabel className="text-xs">
Background terminal sessions
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="max-h-80 overflow-y-auto">
{backgroundSessions.map((session) => (
<DropdownMenuItem
key={session.terminalId}
className="group flex items-center gap-2"
onSelect={() => handleAdopt(session.terminalId)}
>
<Archive className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 truncate text-xs">
{session.title ?? "Terminal"}
</span>
{session.createdAt > 0 && (
<span className="shrink-0 text-xs text-muted-foreground/70">
{getRelativeTime(session.createdAt, { format: "compact" })}
</span>
)}
<button
type="button"
aria-label="Close terminal session"
title="Close terminal session"
disabled={
killSession.isPending &&
killSession.variables?.terminalId === session.terminalId
}
className="shrink-0 rounded p-0.5 opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive disabled:pointer-events-none disabled:opacity-30 group-hover:opacity-100"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void handleKill(session.terminalId);
}}
>
<Trash2 className="size-3" />
</button>
</DropdownMenuItem>
))}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { BackgroundTerminalsButton } from "./BackgroundTerminalsButton";
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { RendererContext } from "@superset/panes";
import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip";
import { Archive } from "lucide-react";
import { markTerminalForBackground } from "renderer/lib/terminal/terminal-background-intents";
import type {
PaneViewerData,
TerminalPaneData,
Expand All @@ -19,6 +22,11 @@ export function TerminalHeaderExtras({

const data = context.pane.data as TerminalPaneData;

const handleMoveToBackground = () => {
markTerminalForBackground(data.terminalId);
void context.actions.close();
};

return (
<div className="flex items-center gap-0.5">
<TerminalRemoteControlButton
Expand All @@ -29,6 +37,24 @@ export function TerminalHeaderExtras({
terminalId={data.terminalId}
terminalInstanceId={context.pane.id}
/>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="Move terminal to background"
onClick={(event) => {
event.stopPropagation();
handleMoveToBackground();
}}
className="rounded p-1 text-muted-foreground/60 transition-colors hover:text-muted-foreground"
>
<Archive className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom" showArrow={false}>
Move terminal to background
</TooltipContent>
</Tooltip>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ResizablePanel } from "renderer/screens/main/components/ResizablePanel"
import { getV2NotificationSourcesForTab } from "renderer/stores/v2-notifications";
import { useWorkspace } from "../providers/WorkspaceProvider";
import { AddTabMenu } from "./components/AddTabMenu";
import { BackgroundTerminalsButton } from "./components/BackgroundTerminalsButton";
import { V2NotificationStatusIndicator } from "./components/V2NotificationStatusIndicator";
import { V2PresetsBar } from "./components/V2PresetsBar";
import { V2WorkspaceRunButton } from "./components/V2WorkspaceRunButton";
Expand Down Expand Up @@ -275,6 +276,12 @@ function V2WorkspaceContent() {
onToggleShowPresetsBar={setShowPresetsBar}
/>
)}
renderTabBarTrailing={() => (
<BackgroundTerminalsButton
workspaceId={workspaceId}
store={store}
/>
)}
renderEmptyState={() => (
<WorkspaceEmptyState
onOpenBrowser={addBrowserTab}
Expand Down
2 changes: 2 additions & 0 deletions packages/panes/src/react/components/Workspace/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function Workspace<TData>({
renderTabIcon,
renderEmptyState,
renderAddTabMenu,
renderTabBarTrailing,
renderBelowTabBar,
onBeforeCloseTab,
onAfterCloseTab,
Expand Down Expand Up @@ -96,6 +97,7 @@ export function Workspace<TData>({
}
renderTabIcon={renderTabIcon}
renderAddTabMenu={renderAddTabMenu}
renderTabBarTrailing={renderTabBarTrailing}
renderTabAccessory={renderTabAccessory}
/>
{renderBelowTabBar?.()}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ interface TabBarProps<TData> {
onMovePaneToNewTab: (paneId: string, toIndex: number) => void;
renderTabIcon?: (tab: Tab<TData>) => ReactNode;
renderAddTabMenu?: () => ReactNode;
renderTabBarTrailing?: () => ReactNode;
renderTabAccessory?: (tab: Tab<TData>) => ReactNode;
}

Expand Down Expand Up @@ -82,6 +83,7 @@ export function TabBar<TData>({
onMovePaneToNewTab,
renderTabIcon,
renderAddTabMenu,
renderTabBarTrailing,
renderTabAccessory,
}: TabBarProps<TData>) {
const tabsTrackRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -173,6 +175,11 @@ export function TabBar<TData>({
<AddTabButton renderAddTabMenu={renderAddTabMenu} />
</div>
<div className="flex min-w-0 flex-1 items-stretch" />
{renderTabBarTrailing && (
<div className="flex h-full shrink-0 items-center px-1">
{renderTabBarTrailing()}
</div>
)}
</div>
);
}
Expand Down Expand Up @@ -228,6 +235,11 @@ export function TabBar<TData>({
<AddTabButton renderAddTabMenu={renderAddTabMenu} />
</div>
)}
{renderTabBarTrailing && (
<div className="flex h-full shrink-0 items-center px-1">
{renderTabBarTrailing()}
</div>
)}
</div>
);
}
2 changes: 2 additions & 0 deletions packages/panes/src/react/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ export interface WorkspaceProps<TData> {
renderTabIcon?: (tab: Tab<TData>) => ReactNode;
renderEmptyState?: () => ReactNode;
renderAddTabMenu?: () => ReactNode;
/** Rendered at the trailing (right) edge of the tab bar row. */
renderTabBarTrailing?: () => ReactNode;
renderBelowTabBar?: () => ReactNode;
onBeforeClosePane?: (
pane: Pane<TData>,
Expand Down
Loading