[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
379
application/state/usePluginViewLifecycle.ts
Normal file
379
application/state/usePluginViewLifecycle.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
isPluginShortcutEditableEvent,
|
||||
normalizePluginKeyboardEvent,
|
||||
normalizePluginShortcut,
|
||||
resolvePluginShortcutPlatform,
|
||||
} from './pluginKeybindings';
|
||||
import { PLUGIN_THEME_TOKEN_NAMES } from './pluginContributionEnvironment';
|
||||
import { useActiveTabId } from './activeTabStore';
|
||||
import {
|
||||
pluginViewTabStore,
|
||||
resolvePluginViewRequest,
|
||||
usePluginViewTabs,
|
||||
} from './pluginViewTabStore';
|
||||
import {
|
||||
PluginViewLifecycleController,
|
||||
reconcilePluginViewTabCatalog,
|
||||
resolvePluginViewSnapshotSelection,
|
||||
type HostedPluginViewState,
|
||||
type PluginViewSnapshotSelection,
|
||||
} from './pluginViewLifecycle';
|
||||
import {
|
||||
canRetainPluginViewInScope,
|
||||
resolvePluginRetainedViewKey,
|
||||
resolvePluginViewWindowScope,
|
||||
} from './pluginViewScopes';
|
||||
import { usePluginContributions } from './usePluginContributions';
|
||||
|
||||
export const OPEN_PLUGIN_VIEW_EVENT = 'netcatty:open-plugin-view';
|
||||
|
||||
export interface OpenPluginViewDetail {
|
||||
viewId: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type ResolvedPluginView = {
|
||||
plugin: NetcattyPluginContributionSnapshot['plugins'][number];
|
||||
view: NetcattyPluginContributionSnapshot['plugins'][number]['views'][number];
|
||||
};
|
||||
|
||||
export function requestOpenPluginView(detail: OpenPluginViewDetail) {
|
||||
window.dispatchEvent(new CustomEvent(OPEN_PLUGIN_VIEW_EVENT, { detail }));
|
||||
}
|
||||
|
||||
export function usePluginViewLifecycle({
|
||||
locale,
|
||||
theme,
|
||||
suppliedThemeTokens,
|
||||
keybindingContext,
|
||||
}: {
|
||||
locale: string;
|
||||
theme: string;
|
||||
suppliedThemeTokens?: Record<string, string>;
|
||||
keybindingContext: Record<string, unknown>;
|
||||
}) {
|
||||
const [requested, setRequested] = useState<OpenPluginViewDetail | null>(null);
|
||||
const activeTabId = useActiveTabId();
|
||||
const pluginViewTabs = usePluginViewTabs();
|
||||
const activePluginTab = pluginViewTabs.find((tab) => tab.id === activeTabId) ?? null;
|
||||
const effectiveRequested = resolvePluginViewRequest(requested, activePluginTab);
|
||||
const viewQueryContext = useMemo(
|
||||
() => effectiveRequested?.context ?? { 'netcatty.surface': 'view' },
|
||||
[effectiveRequested?.context],
|
||||
);
|
||||
const viewQueryContextKey = useMemo(() => JSON.stringify(viewQueryContext), [viewQueryContext]);
|
||||
const contributions = usePluginContributions({ locale, context: keybindingContext });
|
||||
const viewContributions = usePluginContributions({ locale, context: viewQueryContext });
|
||||
const {
|
||||
snapshot,
|
||||
executeCommand,
|
||||
openView,
|
||||
closeView,
|
||||
setViewBounds,
|
||||
setViewVisibility,
|
||||
setEnvironment,
|
||||
onViewClosed,
|
||||
} = contributions;
|
||||
const [instance, setInstance] = useState<HostedPluginViewState | null>(null);
|
||||
const lifecycleRef = useRef(new PluginViewLifecycleController<HostedPluginViewState>());
|
||||
const closeViewRef = useRef(closeView);
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const resolvedActiveView = useMemo(() => viewContributions.snapshot.plugins
|
||||
.flatMap((plugin) => plugin.views.map((view) => ({ plugin, view })))
|
||||
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible) ?? null,
|
||||
[effectiveRequested?.viewId, viewContributions.snapshot.plugins]);
|
||||
const stableActiveViewRef = useRef<PluginViewSnapshotSelection<ResolvedPluginView> | null>(null);
|
||||
const activeView = resolvePluginViewSnapshotSelection({
|
||||
resolved: resolvedActiveView,
|
||||
previous: stableActiveViewRef.current,
|
||||
loading: viewContributions.loading,
|
||||
requestedViewId: effectiveRequested?.viewId,
|
||||
contextKey: viewQueryContextKey,
|
||||
});
|
||||
const activeViewId = activeView?.view.id;
|
||||
const viewScopeId = typeof window === 'undefined'
|
||||
? 'window:server'
|
||||
: resolvePluginViewWindowScope(window.location);
|
||||
const retainedViewKey = activeViewId
|
||||
? resolvePluginRetainedViewKey(activeViewId, viewScopeId)
|
||||
: null;
|
||||
|
||||
useEffect(() => { closeViewRef.current = closeView; }, [closeView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewContributions.loading) return;
|
||||
stableActiveViewRef.current = resolvedActiveView && effectiveRequested?.viewId
|
||||
? {
|
||||
requestViewId: effectiveRequested.viewId,
|
||||
contextKey: viewQueryContextKey,
|
||||
value: resolvedActiveView,
|
||||
}
|
||||
: null;
|
||||
}, [effectiveRequested?.viewId, resolvedActiveView, viewContributions.loading, viewQueryContextKey]);
|
||||
|
||||
useEffect(() => onViewClosed((event) => {
|
||||
const next = lifecycleRef.current.handleHostClose(event.instanceId);
|
||||
if (next.matchedCurrent) {
|
||||
setInstance(null);
|
||||
setRequested(null);
|
||||
}
|
||||
if (next.closedTabId) pluginViewTabStore.close(next.closedTabId);
|
||||
}), [onViewClosed]);
|
||||
|
||||
useEffect(() => pluginViewTabStore.onDidClose(({ tab }) => {
|
||||
const next = lifecycleRef.current.handleTabClose(tab.id);
|
||||
if (next.matchedCurrent) {
|
||||
setInstance(null);
|
||||
setRequested(null);
|
||||
}
|
||||
for (const instanceId of next.instanceIds) {
|
||||
void closeViewRef.current(instanceId).catch(() => {});
|
||||
}
|
||||
}), []);
|
||||
|
||||
useEffect(() => {
|
||||
const bindings = snapshot.plugins.flatMap((plugin) => plugin.keybindings)
|
||||
.filter((binding) => binding.enabled)
|
||||
.sort((left, right) => `${left.command}:${left.key}`.localeCompare(`${right.command}:${right.key}`));
|
||||
const platformKey = resolvePluginShortcutPlatform(navigator.platform);
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || event.isComposing || isPluginShortcutEditableEvent(event)) return;
|
||||
const pressed = normalizePluginKeyboardEvent(event);
|
||||
if (!pressed) return;
|
||||
const binding = bindings.find((candidate) => {
|
||||
const declared = candidate[platformKey] ?? candidate.key;
|
||||
return normalizePluginShortcut(declared, platformKey) === pressed;
|
||||
});
|
||||
if (!binding) return;
|
||||
event.preventDefault();
|
||||
void executeCommand(binding.command, binding.args, keybindingContext);
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [executeCommand, keybindingContext, snapshot.plugins]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: Event) => setRequested((event as CustomEvent<OpenPluginViewDetail>).detail);
|
||||
window.addEventListener(OPEN_PLUGIN_VIEW_EVENT, listener);
|
||||
return () => window.removeEventListener(OPEN_PLUGIN_VIEW_EVENT, listener);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reconcilePluginViewTabCatalog({
|
||||
loading: contributions.loading,
|
||||
plugins: contributions.snapshot.plugins,
|
||||
store: pluginViewTabStore,
|
||||
});
|
||||
}, [contributions.loading, contributions.snapshot.plugins]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!requested || activeView?.view.location !== 'tab') return;
|
||||
pluginViewTabStore.open({
|
||||
pluginId: activeView.plugin.id,
|
||||
pluginName: activeView.plugin.displayName,
|
||||
viewId: activeView.view.id,
|
||||
title: activeView.view.title,
|
||||
icon: activeView.view.icon,
|
||||
context: requested.context,
|
||||
});
|
||||
setRequested(null);
|
||||
}, [activeView, requested]);
|
||||
|
||||
const hideOrClose = useCallback(async (current: HostedPluginViewState) => {
|
||||
if (!current.retainContextWhenHidden) {
|
||||
await closeView(current.id);
|
||||
return;
|
||||
}
|
||||
const key = resolvePluginRetainedViewKey(current.viewId, current.scopeId);
|
||||
lifecycleRef.current.retain(key, current);
|
||||
try {
|
||||
await setViewVisibility(current.id, false);
|
||||
} catch {
|
||||
lifecycleRef.current.removeRetained(key);
|
||||
await closeView(current.id);
|
||||
}
|
||||
}, [closeView, setViewVisibility]);
|
||||
|
||||
const close = useCallback(async () => {
|
||||
lifecycleRef.current.markViewClosed(retainedViewKey);
|
||||
const current = lifecycleRef.current.takeCurrent();
|
||||
setInstance(null);
|
||||
setRequested(null);
|
||||
if (current) await closeView(current.id);
|
||||
}, [closeView, retainedViewKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const removed = lifecycleRef.current.removeRetainedWhere(
|
||||
(retained) => !canRetainPluginViewInScope(retained.scopeId, viewScopeId),
|
||||
);
|
||||
for (const retained of removed) void closeView(retained.id).catch(() => {});
|
||||
}, [closeView, viewScopeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instance) return;
|
||||
if (effectiveRequested?.viewId === instance.viewId
|
||||
&& activeViewId === instance.viewId
|
||||
&& instance.scopeId === viewScopeId) return;
|
||||
lifecycleRef.current.takeCurrent();
|
||||
setInstance(null);
|
||||
if (canRetainPluginViewInScope(instance.scopeId, viewScopeId)) {
|
||||
void hideOrClose(instance);
|
||||
} else {
|
||||
void closeView(instance.id).catch(() => {});
|
||||
}
|
||||
}, [activeViewId, closeView, effectiveRequested?.viewId, hideOrClose, instance, viewScopeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeViewId || !retainedViewKey || !mountRef.current || instance) return;
|
||||
let cancelled = false;
|
||||
const openingTabId = activePluginTab?.id;
|
||||
const openingToken = lifecycleRef.current.beginOpen({
|
||||
viewKey: retainedViewKey,
|
||||
tabId: openingTabId,
|
||||
label: activePluginTab?.id ?? activeViewId,
|
||||
});
|
||||
const bounds = mountRef.current.getBoundingClientRect();
|
||||
const nextBounds = {
|
||||
x: Math.round(bounds.x),
|
||||
y: Math.round(bounds.y),
|
||||
width: Math.max(1, Math.round(bounds.width)),
|
||||
height: Math.max(1, Math.round(bounds.height)),
|
||||
};
|
||||
void (async () => {
|
||||
let opened = lifecycleRef.current.takeRetained(retainedViewKey);
|
||||
if (opened) {
|
||||
try {
|
||||
await setViewBounds(opened.id, nextBounds);
|
||||
await setViewVisibility(opened.id, true);
|
||||
} catch {
|
||||
try { await closeView(opened.id); } catch { /* best-effort cleanup after restore failure */ }
|
||||
opened = null;
|
||||
}
|
||||
}
|
||||
if (!opened) {
|
||||
const result = await openView({
|
||||
viewId: activeViewId,
|
||||
scopeId: viewScopeId,
|
||||
bounds: nextBounds,
|
||||
context: effectiveRequested?.context,
|
||||
});
|
||||
opened = {
|
||||
id: result.instanceId,
|
||||
viewId: activeViewId,
|
||||
scopeId: viewScopeId,
|
||||
retainContextWhenHidden: activeView.view.retainContextWhenHidden === true,
|
||||
...(activePluginTab ? { tabId: activePluginTab.id } : {}),
|
||||
};
|
||||
}
|
||||
if (lifecycleRef.current.consumeHostClose(opened.id)) {
|
||||
throw new Error('Plugin view closed while its open response was in flight');
|
||||
}
|
||||
if (lifecycleRef.current.shouldCloseOpen(openingToken)) {
|
||||
await closeView(opened.id);
|
||||
return;
|
||||
}
|
||||
if (cancelled) {
|
||||
await hideOrClose(opened);
|
||||
return;
|
||||
}
|
||||
lifecycleRef.current.setCurrent(opened);
|
||||
setInstance(opened);
|
||||
})().catch(() => {
|
||||
if (cancelled) return;
|
||||
if (activePluginTab) pluginViewTabStore.close(activePluginTab.id);
|
||||
else setRequested(null);
|
||||
}).finally(() => {
|
||||
lifecycleRef.current.finishOpen({
|
||||
token: openingToken,
|
||||
viewKey: retainedViewKey,
|
||||
tabId: openingTabId,
|
||||
});
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [
|
||||
activePluginTab,
|
||||
activeView?.view.retainContextWhenHidden,
|
||||
activeViewId,
|
||||
closeView,
|
||||
effectiveRequested?.context,
|
||||
hideOrClose,
|
||||
instance,
|
||||
openView,
|
||||
retainedViewKey,
|
||||
setViewBounds,
|
||||
setViewVisibility,
|
||||
viewScopeId,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!instance || !mountRef.current) return;
|
||||
const update = () => {
|
||||
const bounds = mountRef.current?.getBoundingClientRect();
|
||||
if (!bounds) return;
|
||||
void setViewBounds(instance.id, {
|
||||
x: Math.round(bounds.x),
|
||||
y: Math.round(bounds.y),
|
||||
width: Math.max(1, Math.round(bounds.width)),
|
||||
height: Math.max(1, Math.round(bounds.height)),
|
||||
});
|
||||
};
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(mountRef.current);
|
||||
window.addEventListener('resize', update);
|
||||
update();
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', update);
|
||||
};
|
||||
}, [instance, setViewBounds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contributions.available) return;
|
||||
const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const forcedColorsQuery = window.matchMedia('(forced-colors: active)');
|
||||
const contrastQuery = window.matchMedia('(prefers-contrast: more)');
|
||||
let frame = 0;
|
||||
const publish = () => {
|
||||
frame = 0;
|
||||
const styles = suppliedThemeTokens ? null : getComputedStyle(document.documentElement);
|
||||
const themeTokens = suppliedThemeTokens ?? Object.fromEntries(PLUGIN_THEME_TOKEN_NAMES
|
||||
.map((name) => [name, styles?.getPropertyValue(name).trim() ?? '']));
|
||||
void setEnvironment({
|
||||
locale,
|
||||
theme,
|
||||
reducedMotion: reducedMotionQuery.matches,
|
||||
highContrast: forcedColorsQuery.matches || contrastQuery.matches,
|
||||
themeTokens,
|
||||
}).catch(() => {});
|
||||
};
|
||||
const schedulePublish = () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(publish);
|
||||
};
|
||||
const queries = [reducedMotionQuery, forcedColorsQuery, contrastQuery];
|
||||
for (const query of queries) query.addEventListener?.('change', schedulePublish);
|
||||
const observer = new MutationObserver(schedulePublish);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style'] });
|
||||
publish();
|
||||
return () => {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
for (const query of queries) query.removeEventListener?.('change', schedulePublish);
|
||||
};
|
||||
}, [contributions.available, locale, setEnvironment, suppliedThemeTokens, theme]);
|
||||
|
||||
useEffect(() => () => {
|
||||
const views = lifecycleRef.current.drain();
|
||||
const ids = new Set(views.map((view) => view.id));
|
||||
for (const id of ids) void closeViewRef.current(id);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
activeView,
|
||||
close,
|
||||
effectiveRequested,
|
||||
mountRef,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user