Files
kandev/apps/web/components/task/preview-tab.tsx
T
José Almeida 3e676e3bb2 feat(web): double-click tab to toggle maximize (#823)
* feat(web): double-click tab to toggle maximize

Adds a dblclick gesture on dockview tabs that toggles maximize/restore on
the containing group, mirroring the existing maximize button. Preview tabs
keep their promote-to-pin shortcut: first dblclick pins, then dblclick
maximizes. Sidebar group is excluded.

* fix(web): add cursor-pointer to tab wrappers for consistency

* fix(web): add cursor-pointer to plan-tab wrapper

* fix(web): make tab dblclick maximize resilient to layout rebuild

Refactor the dblclick-to-maximize handler so it reads the panel's group ID
and store state at call time instead of from a render-time closure, and
guards against firing while the dockview store is mid-layout-restore.

This addresses oscillation when double-clicking a session tab on a task
that has multiple sessions: the maximize call now stops event propagation,
respects isRestoringLayout, and always uses the live api.group.id, so it
remains correct even after dockview rebuilds groups via fromJSON.
2026-05-05 13:55:46 +01:00

161 lines
5.2 KiB
TypeScript

"use client";
import { useCallback } from "react";
import { DockviewDefaultTab, type IDockviewPanelHeaderProps } from "dockview-react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@kandev/ui/context-menu";
import { cn } from "@/lib/utils";
import { useDockviewStore } from "@/lib/state/dockview-store";
import type { PreviewType } from "@/lib/state/dockview-panel-actions";
import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize";
/**
* Middle-click to close any tab (preview or pinned).
* Call `event.preventDefault()` to suppress the browser autoscroll gesture.
*/
export function useMiddleClickClose(
api: IDockviewPanelHeaderProps["api"],
containerApi: IDockviewPanelHeaderProps["containerApi"],
) {
return useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (event.button !== 1) return;
event.preventDefault();
event.stopPropagation();
const panel = containerApi.getPanel(api.id);
if (panel) containerApi.removePanel(panel);
},
[api, containerApi],
);
}
function useTabContextActions(
api: IDockviewPanelHeaderProps["api"],
containerApi: IDockviewPanelHeaderProps["containerApi"],
) {
const handleClose = useCallback(() => {
const panel = containerApi.getPanel(api.id);
if (panel) containerApi.removePanel(panel);
}, [api, containerApi]);
const handleCloseOthers = useCallback(() => {
const toClose = api.group.panels.filter(
(p) => p.id !== api.id && p.id !== "chat" && !p.id.startsWith("session:"),
);
for (const panel of toClose) containerApi.removePanel(panel);
}, [api, containerApi]);
return { handleClose, handleCloseOthers };
}
/**
* Preview tab: italic title + double-click to pin + middle-click to close.
* One per preview type (file-editor / file-diff / commit-detail).
*/
function PreviewTab(props: IDockviewPanelHeaderProps & { type: PreviewType }) {
const { api, containerApi, type } = props;
const promote = useDockviewStore((s) => s.promotePreviewToPinned);
const onMouseDown = useMiddleClickClose(api, containerApi);
const { handleClose, handleCloseOthers } = useTabContextActions(api, containerApi);
const handleMaximizeDblClick = useTabMaximizeOnDoubleClick(api);
const isPromoted = (props.params as Record<string, unknown> | undefined)?.promoted === true;
const onDoubleClick = useCallback(
(event: React.MouseEvent) => {
if (!isPromoted) {
promote(type);
return;
}
handleMaximizeDblClick(event);
},
[promote, type, isPromoted, handleMaximizeDblClick],
);
const handleKeepOpen = useCallback(() => {
promote(type);
}, [promote, type]);
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className={cn(
"flex h-full items-center cursor-pointer select-none",
!isPromoted && "italic",
)}
onMouseDown={onMouseDown}
onDoubleClick={onDoubleClick}
title={isPromoted ? undefined : "Double-click to keep this tab open"}
data-testid={`preview-tab-${type}`}
>
<DockviewDefaultTab {...props} />
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem className="cursor-pointer" onSelect={handleClose}>
Close
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer" onSelect={handleCloseOthers}>
Close Others
</ContextMenuItem>
{!isPromoted && (
<>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer" onSelect={handleKeepOpen}>
Keep Open
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
);
}
export function PreviewFileTab(props: IDockviewPanelHeaderProps) {
return <PreviewTab {...props} type="file-editor" />;
}
export function PreviewDiffTab(props: IDockviewPanelHeaderProps) {
return <PreviewTab {...props} type="file-diff" />;
}
export function PreviewCommitTab(props: IDockviewPanelHeaderProps) {
return <PreviewTab {...props} type="commit-detail" />;
}
/**
* Default (non-preview) tab for pinned file/diff/commit panels.
* Adds middle-click-to-close and a right-click context menu.
*/
export function PinnedDefaultTab(props: IDockviewPanelHeaderProps) {
const { api, containerApi } = props;
const onMouseDown = useMiddleClickClose(api, containerApi);
const { handleClose, handleCloseOthers } = useTabContextActions(api, containerApi);
const onDoubleClick = useTabMaximizeOnDoubleClick(api);
return (
<ContextMenu>
<ContextMenuTrigger asChild>
<div
className="flex h-full items-center cursor-pointer select-none"
onMouseDown={onMouseDown}
onDoubleClick={onDoubleClick}
>
<DockviewDefaultTab {...props} />
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem className="cursor-pointer" onSelect={handleClose}>
Close
</ContextMenuItem>
<ContextMenuItem className="cursor-pointer" onSelect={handleCloseOthers}>
Close Others
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}