import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent, type ReactNode, type RefObject, } from "react"; import clsx from "clsx"; import { create } from "zustand"; import { call, commands } from "@/platform/ipc"; import { shortcutLabel } from "@/platform/keymap"; import { useProject } from "@/platform/project"; import { Icon, IconButton, Menu, showContextMenu, tooltipAttrs, type MenuItem, T } from "@/ui"; import { FOCUSABLE } from "@/ui/popupState"; import { TabStripRegistry } from "@/ui/Tabs"; import { useContributions, type ToolWindowAnchor, type ToolWindowContribution } from "../contributions"; import { useDistractionFree } from "../distractionFree"; import { hideToolWindow, MAX_SPLIT, MIN_SPLIT, moveToolWindow, placed, resetToolWindowPlacements, hasStripeButton, removeFromStripe, setToolWindowSize, setToolWindowSplit, showToolWindow, SLOTS, slotOf, toggleMaximizeToolWindow, toggleToolWindow, useToolWindowLayout, type SlotRecord, type ToolWindowSlot, } from "./store"; import { contentTabRegistry } from "./content"; import styles from "./ToolWindows.module.css"; import { tx } from "@/platform/i18n"; function byOrder(a: ToolWindowContribution, b: ToolWindowContribution) { return (a.order ?? 100) - (b.order ?? 100) || a.title.localeCompare(b.title); } /** Registered tool windows available in the current state (project open or not). */ export function useToolWindows(): ToolWindowContribution[] { const map = useContributions((s) => s.toolWindows); const placements = useToolWindowLayout((s) => s.placements); const hasProject = useProject((s) => !!s.project); return useMemo( () => [...map.values()] .filter((t) => hasProject || t.requiresProject === false) .map((t) => placed(t, placements)) .sort(byOrder), [map, hasProject, placements], ); } /** Visible window per area, ignoring ids that aren't registered (yet) or don't dock there. */ export function useEffectiveVisible(windows: ToolWindowContribution[]): SlotRecord { const visible = useToolWindowLayout((s) => s.visible); return useMemo(() => { const out = {} as SlotRecord; for (const slot of SLOTS) { const id = visible[slot]; out[slot] = id && windows.some((w) => w.id === id && slotOf(w) === slot) ? id : null; } return out; }, [visible, windows]); } // --------------------------------------------------------------------------- // Stripes /** IDEA's Move To anchors (New UI: no top). */ const MOVE_TARGETS: Array<[ToolWindowSlot, string]> = [ ["left", "Left Top"], ["left-split", "Left Bottom"], ["bottom", "Bottom Left"], ["bottom-split", "Bottom Right"], ["right-split", "Right Bottom"], ["right", "Right Top"], ]; /** "Move to" submenu (IDEA: tool window options › Move To). */ export function moveToMenu(tw: ToolWindowContribution): MenuItem { const current = slotOf(tw); return { label: "Move To", submenu: [ ...MOVE_TARGETS.map(([slot, label]): MenuItem => ({ id: slot, label, checked: slot === current ? true : undefined, disabled: slot === current, run: () => moveToolWindow(tw.id, slot), })), { kind: "separator" }, { label: "Restore Default Layout", run: resetToolWindowPlacements }, ], }; } /** Stripe button context menu, as in IDEA: Hide (removes the button), Move to, Show Tool Window Names. */ function stripeButtonMenu(tw: ToolWindowContribution, showNames: boolean): MenuItem[] { return [ { label: "Hide", run: () => removeFromStripe(tw.id) }, { kind: "separator" }, moveToMenu(tw), { kind: "separator" }, { label: "Show Tool Window Names", checked: showNames, run: () => { resetStripeNames(); void setShowToolWindowNames(!showNames); }, }, ]; } async function setShowToolWindowNames(on: boolean) { const settings = await call(commands.settingsSet("global", "appearance.showToolWindowNames", JSON.stringify(on))); useProject.setState({ settings }); } /** Stripe button widths: icons only, and the range with names. */ const STRIPE_ICONS = 32; const STRIPE_MIN = 44; const STRIPE_DEFAULT = 56; const STRIPE_MAX = 96; const STRIPE_KEY = "openidea.stripes.v1"; type StripeSide = "left" | "right"; /** Per stripe: button width with names, and names shown or hidden by a drag (unset: the setting decides). */ interface StripeSize { width: number; names?: boolean; } function readStripes(): Record { const out: Record = { left: { width: STRIPE_DEFAULT }, right: { width: STRIPE_DEFAULT } }; try { const raw = JSON.parse(localStorage.getItem(STRIPE_KEY) ?? "null"); for (const side of ["left", "right"] as const) { const v = raw?.[side]; if (typeof v?.width === "number") out[side].width = Math.min(STRIPE_MAX, Math.max(STRIPE_MIN, v.width)); if (typeof v?.names === "boolean") out[side].names = v.names; } } catch { /* storage unavailable */ } return out; } const useStripes = create>(readStripes); function setStripe(side: StripeSide, size: StripeSize) { useStripes.setState({ [side]: size }); try { localStorage.setItem(STRIPE_KEY, JSON.stringify(useStripes.getState())); } catch { /* storage unavailable */ } } /** "Show Tool Window Names" (menu or settings) applies to both stripes again. */ function resetStripeNames() { const s = useStripes.getState(); if (s.left.names === undefined && s.right.names === undefined) return; setStripe("left", { width: s.left.width }); setStripe("right", { width: s.right.width }); } /** * Resize handle on a stripe's inner edge. Dragging sets the stripe's button width; * narrower than halfway to the named minimum shows icons only. */ function StripeHandle({ side, names, width, frameRef, onPreview, onCommit, }: { side: StripeSide; names: boolean; width: number; frameRef: RefObject; onPreview: (names: boolean) => void; onCommit: (names: boolean, width: number) => void; }) { const sign = side === "left" ? 1 : -1; // The stripe follows the pointer; names show from STRIPE_MIN (truncated), icons only below it. const resolve = (raw: number) => { const px = Math.round(Math.min(STRIPE_MAX, Math.max(STRIPE_ICONS, raw))); return px < STRIPE_MIN ? { names: false, width, px } : { names: true, width: px, px }; }; const onPointerDown = (e: PointerEvent) => { const stripe = frameRef.current?.querySelector(`[data-stripe-side="${side}"]`); if (e.button !== 0 || !stripe) return; e.preventDefault(); const el = e.currentTarget; el.setPointerCapture(e.pointerId); const origin = e.clientX; const start = names ? width : STRIPE_ICONS; const cursor = document.body.style.cursor; document.body.style.cursor = "col-resize"; let next = { names, width, px: start }; const move = (ev: globalThis.PointerEvent) => { const r = resolve(start + sign * (ev.clientX - origin)); if (r.names !== next.names) onPreview(r.names); next = r; stripe.style.setProperty("--stripe-drag", `${r.px}px`); }; const end = () => { el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", end); el.removeEventListener("pointercancel", end); document.body.style.cursor = cursor; stripe.style.removeProperty("--stripe-drag"); onCommit(next.names, next.width); }; el.addEventListener("pointermove", move); el.addEventListener("pointerup", end); el.addEventListener("pointercancel", end); }; const onKeyDown = (e: KeyboardEvent) => { const dir = ["ArrowLeft", "ArrowRight"].indexOf(e.key); if (dir < 0) return; e.preventDefault(); const step = sign * (dir ? 1 : -1) * 8; // One step crosses between icons only and the narrowest named width. if (!names) { if (step > 0) onCommit(true, STRIPE_MIN); } else if (width + step < STRIPE_MIN) onCommit(false, width); else onCommit(true, Math.min(STRIPE_MAX, width + step)); }; return (
); } // Stripe button drag. Pointer events rather than HTML5 drag and drop: with the window's native // file drop enabled, the webview never sees dragover / drop for drags that start inside it. type StripeDrop = { slot: ToolWindowSlot; before: string | null }; const useStripeDrag = create<{ id: string | null; drop: StripeDrop | null }>(() => ({ id: null, drop: null })); /** Movement before a press on a stripe button becomes a drag, px. */ const DRAG_THRESHOLD = 4; /** Set when a drag ends on its own button, so the click that follows doesn't toggle the window. */ let dragEnded = false; /** * Stripe groups (IDEA New UI): each stripe's upper group holds its side, * primary windows then split ones after a separator; the lower group of the * left stripe holds Bottom Left, that of the right stripe Bottom Right. */ function lowerSlot(side: StripeSide): ToolWindowSlot { return side === "left" ? "bottom" : "bottom-split"; } const middle = (r: DOMRect) => r.top + r.height / 2; /** Drop position under the pointer: an area and the button to insert before (null: at the end). */ function stripeDropAt(x: number, y: number): StripeDrop | null { const nav = document.elementFromPoint(x, y)?.closest("[data-stripe-side]"); const side = nav?.dataset.stripeSide; if (!nav || (side !== "left" && side !== "right")) return null; const spacer = nav.querySelector("[data-stripe-spacer]")?.getBoundingClientRect(); const separator = nav.querySelector("[data-stripe-separator]")?.getBoundingClientRect(); const slot: ToolWindowSlot = spacer && y > middle(spacer) ? lowerSlot(side) : separator && y > middle(separator) ? `${side}-split` : side; let before: string | null = null; for (const b of nav.querySelectorAll(`[data-stripe-slot="${slot}"] [data-stripe-button]`)) { if (y < middle(b.getBoundingClientRect())) { before = b.dataset.stripeButton!; break; } } return { slot, before }; } function startStripeDrag(e: PointerEvent, id: string) { if (e.button !== 0) return; const button = e.currentTarget; const start = { x: e.clientX, y: e.clientY }; let ghost: HTMLElement | null = null; const place = (x: number, y: number) => { if (ghost) ghost.style.transform = `translate(${x - start.x}px, ${y - start.y}px)`; }; const onMove = (ev: globalThis.PointerEvent) => { if (!ghost) { if (Math.hypot(ev.clientX - start.x, ev.clientY - start.y) < DRAG_THRESHOLD) return; const r = button.getBoundingClientRect(); ghost = button.cloneNode(true) as HTMLElement; ghost.removeAttribute("data-stripe-button"); ghost.className = clsx(button.className, styles.stripeGhost); Object.assign(ghost.style, { left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px`, }); document.body.appendChild(ghost); useStripeDrag.setState({ id, drop: null }); } place(ev.clientX, ev.clientY); const drop = stripeDropAt(ev.clientX, ev.clientY); const cur = useStripeDrag.getState().drop; if (drop?.slot !== cur?.slot || drop?.before !== cur?.before) useStripeDrag.setState({ drop }); }; const finish = (commit: boolean) => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); window.removeEventListener("pointercancel", onCancel); window.removeEventListener("keydown", onKey, true); if (!ghost) return; ghost.remove(); const { drop } = useStripeDrag.getState(); useStripeDrag.setState({ id: null, drop: null }); dragEnded = true; setTimeout(() => (dragEnded = false)); if (commit && drop && drop.before !== id) moveToolWindow(id, drop.slot, drop.before); }; const onUp = () => finish(true); const onCancel = () => finish(false); const onKey = (ev: globalThis.KeyboardEvent) => { if (ev.key !== "Escape" || !ghost) return; ev.preventDefault(); ev.stopPropagation(); finish(false); }; window.addEventListener("pointermove", onMove); window.addEventListener("pointerup", onUp); window.addEventListener("pointercancel", onCancel); window.addEventListener("keydown", onKey, true); } function StripeBadge({ useBadge }: { useBadge: () => number | string | null }) { const value = useBadge(); if (value === null || value === 0 || value === "") return null; return {typeof value === "number" && value > 99 ? "99+" : value}; } function StripeButton({ tw, active, focused, side, showName, }: { tw: ToolWindowContribution; active: boolean; focused: boolean; side: "left" | "right"; showName: boolean; }) { const shortcut = shortcutLabel(`toolwindow.${tw.id}`); const dragged = useStripeDrag((s) => s.id === tw.id); return ( ); } /** Stripe "…": tool windows without a stripe button; opening one adds its button (IDEA). */ function StripeMore({ windows }: { windows: ToolWindowContribution[] }) { const [open, setOpen] = useState(false); const ref = useRef(null); const items = useMemo( () => [...windows] .sort((a, b) => a.title.localeCompare(b.title)) .map((w) => ({ id: w.id, label: w.title, icon: w.icon, shortcut: shortcutLabel(`toolwindow.${w.id}`), run: () => showToolWindow(w.id, true), })), [windows], ); return ( <> setOpen(false)} anchor={ref} items={items} placement="right-start" aria-label="Tool windows" /> ); } function onStripeKeyDown(e: KeyboardEvent) { if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Home" && e.key !== "End") return; const buttons = [...e.currentTarget.querySelectorAll("button")]; const i = buttons.indexOf(document.activeElement as HTMLButtonElement); const next = e.key === "Home" ? 0 : e.key === "End" ? buttons.length - 1 : i + (e.key === "ArrowDown" ? 1 : -1); buttons[Math.max(0, Math.min(buttons.length - 1, next))]?.focus(); e.preventDefault(); } export function ToolWindowStripe({ side, windows, visible, showNames = true, buttonWidth, }: { side: StripeSide; windows: ToolWindowContribution[]; visible: SlotRecord; /** Tool window names under the icons (IDEA "Show Tool Window Names"). */ showNames?: boolean; /** Button width with names, px. */ buttonWidth?: number; }) { const isActive = (w: ToolWindowContribution) => visible[slotOf(w)] === w.id; const stripeButtons = useToolWindowLayout((s) => s.stripeButtons); const onStripe = windows.filter((w) => hasStripeButton(w, stripeButtons) || isActive(w)); const inSlot = (slot: ToolWindowSlot) => onStripe.filter((w) => slotOf(w) === slot); const primary = inSlot(side); const split = inSlot(`${side}-split`); const lower = inSlot(lowerSlot(side)); const moreWindows = windows.filter((w) => !onStripe.includes(w)); const focused = useToolWindowLayout((s) => s.focused); const drop = useStripeDrag((s) => s.drop); // While a button is dragged the separator shows even without split windows, as a drop target. const dragging = useStripeDrag((s) => !!s.id); const indicator = (slot: ToolWindowSlot, before: string | null) => drop && drop.slot === slot && drop.before === before ? (