mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-11 00:30:57 +00:00
* fix(browser): keep the page live beside overlay side panels The embedded host browser is a native WebContentsView that draws above the renderer, and its occlusion check only yielded to dialogs, menus and poppers. Opening New host / Edit host from the terminal host tree left the page drawn over the overlay host editor panel. Overlay-layout aside panels now carry data-native-view-occluder. Native views cannot be clipped by a parent View on macOS, so the pane shrinks the live page to the rectangle left beside a panel that covers a full-height or full-width edge strip; the page stays interactive while the editor is open. Poppers are checked against that live rectangle, so selects inside the panel no longer freeze the page, and pictures for poppers over the page are offset to where it sits. A panel over the middle of the pane, or one leaving less than 160px, falls back to a picture. * test(browser): provide the pane geometry helper to the first-link harness The first-link acceptance loads useHostBrowserPanel through a VM that strips imports and injects dependencies by name, so it threw "resolveHostBrowserPaneBesidePanels is not defined" once the hook started using the domain helper. Load domain/hostBrowser.ts and pass the helper in.
208 lines
10 KiB
TypeScript
208 lines
10 KiB
TypeScript
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
import { currentBrowserThemeTokens, mountHostBrowser } from '../hostBrowser';
|
|
import { resolveHostBrowserPaneBesidePanels, type HostBrowserPaneRect } from '../../domain/hostBrowser';
|
|
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../../domain/models';
|
|
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
|
import type { HostBrowserPanelSnapshotLayer } from '../../types/global/netcatty-bridge-sync';
|
|
|
|
function overlapsBrowser(rect: HostBrowserPaneRect, element: Element): boolean {
|
|
const style = getComputedStyle(element);
|
|
if (style.display === 'none' || style.visibility === 'hidden' || element.closest('[hidden], [inert], [data-state="closed"]')) return false;
|
|
const overlay = element.getBoundingClientRect();
|
|
return overlay.width > 0 && overlay.height > 0
|
|
&& overlay.x < rect.x + rect.width && overlay.right > rect.x
|
|
&& overlay.y < rect.y + rect.height && overlay.bottom > rect.y;
|
|
}
|
|
|
|
interface BrowserPanelOptions {
|
|
browserId: string;
|
|
host: Host;
|
|
hosts: Host[];
|
|
keys: SSHKey[];
|
|
identities: Identity[];
|
|
knownHosts: KnownHost[];
|
|
terminalSettings: TerminalSettings;
|
|
isVisible: boolean;
|
|
}
|
|
|
|
export function useHostBrowserPanel(options: BrowserPanelOptions) {
|
|
const elementRef = useRef<HTMLDivElement>(null);
|
|
const optionsRef = useRef(options);
|
|
optionsRef.current = options;
|
|
const opened = useRef(false);
|
|
const mountedBrowserId = useRef<string | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [attempt, setAttempt] = useState(0);
|
|
const [readyId, setReadyId] = useState<string | null>(null);
|
|
const [snapshot, setSnapshot] = useState<{ browserId: string; layers: HostBrowserPanelSnapshotLayer[] } | null>(null);
|
|
const { browserId, isVisible } = options;
|
|
|
|
useLayoutEffect(() => {
|
|
const bridge = netcattyBridge.get();
|
|
let frame: number | null = null;
|
|
let previous = '';
|
|
let disposed = false;
|
|
let layoutRevision = 0;
|
|
const element = elementRef.current;
|
|
const doc = element?.ownerDocument;
|
|
const view = doc?.defaultView;
|
|
const observed = new Set<Element>();
|
|
const animations = new Map<Animation, Element>();
|
|
const schedule = () => {
|
|
if (disposed || frame !== null) return;
|
|
frame = requestAnimationFrame(() => {
|
|
frame = null;
|
|
for (const [animation, target] of animations) {
|
|
if (!target.isConnected || (!animation.pending && animation.playState !== 'running')) animations.delete(animation);
|
|
}
|
|
update();
|
|
if (animations.size > 0) schedule();
|
|
});
|
|
};
|
|
const trackAnimations = (target: Element) => {
|
|
for (const animation of target.getAnimations?.() ?? []) {
|
|
if (!animation.pending && animation.playState !== 'running') continue;
|
|
animations.set(animation, target);
|
|
}
|
|
};
|
|
const resizeObserver = isVisible && view?.ResizeObserver ? new view.ResizeObserver(schedule) : null;
|
|
const update = () => {
|
|
const element = elementRef.current;
|
|
const rect = element?.getBoundingClientRect();
|
|
// Native views sit above renderer portals. Only overlapping poppers need
|
|
// to yield; dialogs are modal even when their content is beside the pane.
|
|
// Overlay panels stay open while the user works, so the live page moves
|
|
// beside them when it can and only falls back to a picture otherwise.
|
|
const locked = !!document.querySelector('[data-app-lock-overlay]');
|
|
const modal = !!document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]');
|
|
const overlays = Array.from(document.querySelectorAll(
|
|
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *, '
|
|
+ '[data-native-view-occluder]',
|
|
));
|
|
const isPanel = (overlay: Element) => overlay.matches('[data-native-view-occluder]');
|
|
const panels = rect ? overlays.filter((overlay) => isPanel(overlay) && overlapsBrowser(rect, overlay)) : [];
|
|
const live = rect && resolveHostBrowserPaneBesidePanels(rect, panels.map((panel) => panel.getBoundingClientRect()));
|
|
// Poppers over the part of the pane hidden behind a panel (e.g. a select
|
|
// inside the host editor) no longer touch the live page.
|
|
const covered = modal || (!!rect && !live)
|
|
|| (!!live && overlays.some((overlay) => !isPanel(overlay) && overlapsBrowser(live, overlay)));
|
|
const targets = new Set<Element>(overlays);
|
|
for (let target: Element | null = element; target; target = target.parentElement) targets.add(target);
|
|
for (const target of observed) {
|
|
if (!targets.has(target)) { resizeObserver?.unobserve(target); observed.delete(target); }
|
|
}
|
|
for (const target of targets) {
|
|
if (observed.has(target)) continue;
|
|
observed.add(target);
|
|
resizeObserver?.observe(target);
|
|
trackAnimations(target);
|
|
}
|
|
const visible = isVisible && !!element?.isConnected && !!rect
|
|
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !locked;
|
|
if (!visible) setSnapshot(null);
|
|
const pane = live || rect;
|
|
const bounds = pane ? { x: pane.x, y: pane.y, width: pane.width, height: pane.height } : undefined;
|
|
// Pictures are relative to the native page, which may sit beside a panel.
|
|
const offsetX = rect && bounds ? bounds.x - rect.x : 0;
|
|
const offsetY = rect && bounds ? bounds.y - rect.y : 0;
|
|
const theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
|
|
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
|
|
const layout: Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0] = {
|
|
browserId, visible, occluded: visible && covered, bounds, theme, themeTokens,
|
|
};
|
|
const next = JSON.stringify(layout);
|
|
if (next !== previous) {
|
|
previous = next;
|
|
const revision = ++layoutRevision;
|
|
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
|
|
if (disposed || layoutRevision !== revision) return;
|
|
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers.map((layer) => ({
|
|
...layer, bounds: { ...layer.bounds, x: layer.bounds.x + offsetX, y: layer.bounds.y + offsetY },
|
|
})) } : null);
|
|
}).catch(() => {});
|
|
}
|
|
};
|
|
if (!isVisible) {
|
|
setSnapshot(null);
|
|
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
|
|
return;
|
|
}
|
|
|
|
// Layout, theme and portal changes coalesce into a single frame. Keep
|
|
// following only running animations on the pane's ancestors or overlay
|
|
// containers; unrelated spinner/cursor animations must not restart polling.
|
|
const mutationObserver = view?.MutationObserver ? new view.MutationObserver(schedule) : null;
|
|
if (doc) mutationObserver?.observe(doc.documentElement, {
|
|
subtree: true, childList: true, attributes: true,
|
|
attributeFilter: ['style', 'class', 'hidden', 'inert', 'role', 'aria-modal', 'data-state',
|
|
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder',
|
|
'href', 'media', 'disabled'],
|
|
});
|
|
const onMotion = (event: Event) => {
|
|
const target = event.target;
|
|
if (!element || !view || !(target instanceof view.Element)) return;
|
|
if (!target.contains(element) && !target.matches(
|
|
'[role="dialog"], [role="alertdialog"], [role="menu"], [data-app-lock-overlay], '
|
|
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *, [data-native-view-occluder]',
|
|
)) return;
|
|
trackAnimations(target);
|
|
schedule();
|
|
};
|
|
const motionEvents = ['transitionrun', 'transitionend', 'transitioncancel', 'animationstart', 'animationend', 'animationcancel'];
|
|
for (const event of motionEvents) doc?.addEventListener(event, onMotion, true);
|
|
doc?.addEventListener('scroll', schedule, true);
|
|
doc?.addEventListener('load', schedule, true);
|
|
view?.addEventListener('resize', schedule);
|
|
doc?.fonts?.addEventListener('loadingdone', schedule);
|
|
doc?.fonts?.addEventListener('loadingerror', schedule);
|
|
if (doc?.fonts?.status === 'loading') void doc.fonts.ready.then(schedule);
|
|
update();
|
|
if (animations.size > 0) schedule();
|
|
return () => {
|
|
disposed = true;
|
|
if (frame !== null) cancelAnimationFrame(frame);
|
|
resizeObserver?.disconnect();
|
|
mutationObserver?.disconnect();
|
|
for (const event of motionEvents) doc?.removeEventListener(event, onMotion, true);
|
|
doc?.removeEventListener('scroll', schedule, true);
|
|
doc?.removeEventListener('load', schedule, true);
|
|
view?.removeEventListener('resize', schedule);
|
|
doc?.fonts?.removeEventListener('loadingdone', schedule);
|
|
doc?.fonts?.removeEventListener('loadingerror', schedule);
|
|
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
|
|
};
|
|
}, [browserId, isVisible]);
|
|
|
|
useEffect(() => {
|
|
if (!isVisible || opened.current) return;
|
|
opened.current = true;
|
|
setError(null);
|
|
let cancelled = false;
|
|
void mountHostBrowser({ ...optionsRef.current, browserId }).then(() => {
|
|
if (mountedBrowserId.current === browserId) setReadyId(browserId);
|
|
}).catch((reason: unknown) => {
|
|
opened.current = false;
|
|
if (cancelled) return;
|
|
setError(reason instanceof Error ? reason.message : String(reason));
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [attempt, browserId, isVisible]);
|
|
|
|
useEffect(() => {
|
|
mountedBrowserId.current = browserId;
|
|
return () => {
|
|
opened.current = false;
|
|
mountedBrowserId.current = null;
|
|
// StrictMode replays setup immediately. Do not destroy its pending URL
|
|
// or native browser unless the owning panel actually remains unmounted.
|
|
queueMicrotask(() => {
|
|
if (mountedBrowserId.current === browserId) return;
|
|
void netcattyBridge.get()?.closeHostBrowserPanel?.(browserId).catch(() => {});
|
|
});
|
|
};
|
|
}, [browserId]);
|
|
|
|
return { elementRef, error, ready: readyId === browserId, snapshot: isVisible && snapshot?.browserId === browserId ? snapshot.layers : [],
|
|
retry: () => setAttempt((value) => value + 1) };
|
|
}
|