228 lines
8.9 KiB
TypeScript
228 lines
8.9 KiB
TypeScript
"use client";
|
|
|
|
import React, { useCallback, useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { panelPortalManager } from "./panel-portal-manager";
|
|
import type { IDockviewPanelProps } from "dockview-react";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PanelPortalHost — renders all persistent panel content via React portals
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* A render function that receives the panel ID and dockview-compatible props.
|
|
* The host calls this to decide *what* to render inside each portal element.
|
|
*/
|
|
export type PortalRenderer = (
|
|
panelId: string,
|
|
component: string,
|
|
params: Record<string, unknown>,
|
|
) => ReactNode;
|
|
|
|
type PanelPortalHostProps = {
|
|
/** Render function called for each registered panel. */
|
|
renderPanel: PortalRenderer;
|
|
};
|
|
|
|
/**
|
|
* Renders panel content into persistent portal elements that live outside the
|
|
* dockview tree. Mount this as a sibling to `<DockviewReact>`.
|
|
*/
|
|
export function PanelPortalHost({ renderPanel }: PanelPortalHostProps) {
|
|
// Re-render when panels are added/removed OR when any panel's params change.
|
|
// The version counter bumps on all three — we read ids() at render time
|
|
// rather than encoding them in the snapshot so panel ids can contain any
|
|
// character (a path like `file:path/with,comma.txt` would break a delimiter).
|
|
useSyncExternalStore(
|
|
useCallback((cb) => panelPortalManager.subscribe(cb), []),
|
|
useCallback(() => panelPortalManager.getVersion(), []),
|
|
useCallback(() => panelPortalManager.getVersion(), []),
|
|
);
|
|
|
|
const panelIds = panelPortalManager.ids();
|
|
|
|
return (
|
|
<>
|
|
{panelIds.map((panelId) => {
|
|
const entry = panelPortalManager.get(panelId);
|
|
if (!entry) return null;
|
|
return createPortal(
|
|
renderPanel(panelId, entry.component, entry.params),
|
|
entry.element,
|
|
panelId,
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// usePortalSlot — hook for dockview panel wrappers (slot components)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Attaches the persistent portal element for `panelId` into the dockview
|
|
* panel's container on mount, and detaches it on unmount (without destroying).
|
|
*
|
|
* Also updates the stored `api` and `params` on every mount, so the portal
|
|
* content can read the latest dockview state.
|
|
*
|
|
* @param envId — when provided, tags the portal as env-scoped so it
|
|
* gets cleaned up on env switch via `releaseByEnv()`.
|
|
*/
|
|
export function usePortalSlot(
|
|
props: IDockviewPanelProps,
|
|
envId?: string,
|
|
): React.RefObject<HTMLDivElement | null> {
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const panelId = props.api.id;
|
|
const component = props.api.component;
|
|
const params = props.params ?? {};
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
const entry = panelPortalManager.acquire(panelId, component, params, props.api, envId);
|
|
|
|
// Reparent the portal element into this panel's DOM slot, then restore any
|
|
// scroll positions captured before the previous detach. Browsers reset
|
|
// scrollTop/scrollLeft when an element is removed from the DOM, so without
|
|
// this the sidebar (and any other scrollable descendant of a portal) jumps
|
|
// back to the top after every dockview layout switch.
|
|
container.appendChild(entry.element);
|
|
const cancelRestore = restorePortalScroll(entry.element);
|
|
// Track every scroll inside this portal so we always have an up-to-date
|
|
// snapshot to restore after the next reattach. We do this via a capturing
|
|
// listener because dockview can rip the DOM apart before React runs the
|
|
// cleanup below (removeChild fires too late to read scrollTop reliably).
|
|
const onScroll = (e: Event) => {
|
|
const target = e.target;
|
|
if (target instanceof Element && entry.element.contains(target)) {
|
|
const html = target as HTMLElement;
|
|
scrollSnapshots.set(target, { top: html.scrollTop, left: html.scrollLeft });
|
|
}
|
|
};
|
|
entry.element.addEventListener("scroll", onScroll, true);
|
|
|
|
return () => {
|
|
cancelRestore();
|
|
entry.element.removeEventListener("scroll", onScroll, true);
|
|
// Detach only — do NOT release. The element stays in the manager
|
|
// and will be re-adopted when the panel remounts after fromJSON().
|
|
if (entry.element.parentNode === container) {
|
|
container.removeChild(entry.element);
|
|
}
|
|
};
|
|
// envId in deps: when env changes, env-scoped panels re-acquire fresh
|
|
// portals (old ones were released by releaseByEnv in the store action).
|
|
// Global panels pass envId=undefined so this is a no-op for them.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [panelId, envId]);
|
|
|
|
// Forward dockview's param updates into the portal manager so preview-tab
|
|
// content (file-editor, diff-viewer, commit-detail) re-renders when the
|
|
// single preview panel switches to a different file/diff/commit.
|
|
useEffect(() => {
|
|
const disposable = props.api.onDidParametersChange((next) => {
|
|
panelPortalManager.updateParams(panelId, next as Record<string, unknown>);
|
|
});
|
|
return () => disposable.dispose();
|
|
}, [panelId, props.api]);
|
|
|
|
return containerRef;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scroll preservation across portal detach/reattach
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Per-element scroll snapshots, kept up-to-date by a capturing scroll
|
|
* listener attached to each portal element. Stored in a WeakMap so detached
|
|
* subtrees can be garbage collected if the portal is ever released.
|
|
*/
|
|
const scrollSnapshots = new WeakMap<Element, { top: number; left: number }>();
|
|
|
|
/** Window during which we keep re-applying snapshots after attach. */
|
|
const RESTORE_WINDOW_MS = 1500;
|
|
|
|
/**
|
|
* Restore captured scroll positions on every scrollable descendant of `portal`,
|
|
* then keep re-applying for a short window so that a snapshot which gets
|
|
* clamped by a not-yet-laid-out container (`scrollTop` capped to
|
|
* `scrollHeight - clientHeight`) is corrected once layout settles.
|
|
*
|
|
* Returns a function that cancels any pending retries (call from cleanup so
|
|
* we never write to a detached subtree).
|
|
*/
|
|
function restorePortalScroll(portal: Element): () => void {
|
|
// Collect every descendant that has a snapshot up front so we don't re-walk
|
|
// the tree on each retry.
|
|
const targets: Array<{ el: HTMLElement; snap: { top: number; left: number } }> = [];
|
|
const walker = document.createTreeWalker(portal, NodeFilter.SHOW_ELEMENT);
|
|
// Skip the portal element itself — it is never user-scrolled, and the loop
|
|
// body assumes Element nodes (guaranteed by SHOW_ELEMENT for descendants).
|
|
let node = walker.nextNode() as HTMLElement | null;
|
|
while (node) {
|
|
const snap = scrollSnapshots.get(node);
|
|
if (snap && (snap.top > 0 || snap.left > 0)) {
|
|
targets.push({ el: node, snap });
|
|
}
|
|
node = walker.nextNode() as HTMLElement | null;
|
|
}
|
|
if (targets.length === 0) return () => {};
|
|
|
|
// Track which elements still need retrying. An element is removed from
|
|
// `pending` once the layout has grown enough to accommodate the snapshot,
|
|
// so subsequent ResizeObserver/rAF callbacks won't fight a deliberate user
|
|
// scroll (including scrolling upward, away from the snapshot position).
|
|
const pending = new Set(targets.map((t) => t.el));
|
|
let cancelled = false;
|
|
let stopId = 0;
|
|
let ro: ResizeObserver | null = null;
|
|
|
|
const stop = () => {
|
|
cancelled = true;
|
|
ro?.disconnect();
|
|
window.clearTimeout(stopId);
|
|
};
|
|
|
|
const apply = () => {
|
|
for (const { el, snap } of targets) {
|
|
if (!pending.has(el)) continue;
|
|
if (el.scrollTop < snap.top) el.scrollTop = snap.top;
|
|
if (el.scrollLeft < snap.left) el.scrollLeft = snap.left;
|
|
// Once layout can fully accommodate the snapshot, the clamping bug is
|
|
// resolved — drop the element from pending so future ticks stay out of
|
|
// the way of any user-initiated scroll.
|
|
const canReachTop = el.scrollHeight - el.clientHeight >= snap.top;
|
|
const canReachLeft = el.scrollWidth - el.clientWidth >= snap.left;
|
|
if (canReachTop && canReachLeft) pending.delete(el);
|
|
}
|
|
if (pending.size === 0) stop();
|
|
};
|
|
apply();
|
|
|
|
if (!cancelled) {
|
|
ro = new ResizeObserver(() => {
|
|
if (cancelled) return;
|
|
apply();
|
|
});
|
|
for (const { el } of targets) ro.observe(el);
|
|
// A short rAF chain catches the common case where layout settles within
|
|
// the next two frames without a measurable size change.
|
|
requestAnimationFrame(() => {
|
|
if (cancelled) return;
|
|
apply();
|
|
requestAnimationFrame(() => {
|
|
if (cancelled) return;
|
|
apply();
|
|
});
|
|
});
|
|
stopId = window.setTimeout(stop, RESTORE_WINDOW_MS);
|
|
}
|
|
|
|
return stop;
|
|
}
|