feat: shell controller, TabStage, layout and theme plugins with repair view

This commit is contained in:
LukeGus committed 2026-10-18 16:37:36 -05:00
1 parent aff29a9581
commit 86161d66e2
120 files changed
+23693 -3381

No files matched your search

@@ -0,0 +1,342 @@
/* eslint-disable react-refresh/only-export-components */
/* eslint-disable react-hooks/exhaustive-deps */
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { useIsMobile } from "@/hooks/use-mobile";
import { useSyncStatus } from "@/hooks/use-sync-status";
import { isElectron } from "@/lib/electron";
import {
useConfirm,
type ConfirmOptions,
} from "@/components/surface/surface-scope";
import type { Host, TabType } from "@/types/ui-types";
import { requestManage } from "@/manage/manage-requests";
import { useRailItems } from "@/sidebar/rail-items";
import { usePanels } from "@/shell/panel-registry";
import { getTabType, type TabShellCallbacks } from "@/shell/tab-registry";
import { usePluginStore } from "@/plugin-host/plugin-store";
import { setShellCallbacks } from "@/plugin-host/shell-bridge";
import {
createTabStageStore,
type TabStageStore,
} from "@/shell/stage/tab-stage-store";
import { useRegions } from "./use-regions";
import { useHostList } from "./use-hosts";
import { useTabs } from "./use-tabs";
import { useSplit } from "./use-split";
import { useWorkspaceLayout } from "./use-workspace-layout";
import { useShellKeybindings } from "./use-shell-keybindings";
import { useShellLifecycle } from "./use-shell-lifecycle";
import { publishShellController } from "./shell-store";
import type { ShellController, ShellPalette } from "./types";
const ShellControllerContext = createContext<ShellController | null>(null);
const TabStageContext = createContext<TabStageStore | null>(null);
/** The live shell, for core layout code. Throws outside the shell. */
export function useShellController(): ShellController {
const controller = useContext(ShellControllerContext);
if (!controller) throw new Error("useShellController outside the shell");
return controller;
}
/** The live shell, or null outside it. */
export function useOptionalShellController(): ShellController | null {
return useContext(ShellControllerContext);
}
export function useTabStageStore(): TabStageStore {
const store = useContext(TabStageContext);
if (!store) throw new Error("useTabStageStore outside the shell");
return store;
}
/**
* Hands the main area's confirm to code outside it, like closeTab. A layout
* renders it inside the scope its tab-close questions should show in.
*/
export function ShellConfirmCapture(): null {
const { confirmRef } = useShellController();
const confirm = useConfirm();
confirmRef.current = confirm;
return null;
}
/** Owns every bit of shell state and behaviour a layout draws. */
export function ShellControllerProvider({
username,
onLogout,
children,
}: {
username: string;
onLogout: (options?: { manual?: boolean }) => void;
children: React.ReactNode;
}) {
// Re-render when plugins add or remove rail items, panels or tabs.
useRailItems();
usePanels();
const { settled: pluginsSettled } = usePluginStore();
const isMobile = useIsMobile();
const [stageStore] = useState(createTabStageStore);
// The root confirm, for questions the shell itself asks (closing a tab).
const confirmRef = useRef<
((options: ConfirmOptions) => Promise<boolean>) | null
>(null);
const openSingletonRef = useRef<(type: TabType) => string | undefined>(
() => undefined,
);
const lifecycle = useShellLifecycle({
onLogout,
openSingletonTabRef: openSingletonRef,
});
const regions = useRegions(isMobile);
const hosts = useHostList();
const [paletteOpen, setPaletteOpen] = useState(false);
const tabsState = useTabs({
hosts: hosts.list,
hostsRef: hosts.listRef,
hostsLoaded: hosts.loaded,
reloadHosts: hosts.reload,
userPrefs: lifecycle.userPrefs,
userPrefsLoaded: lifecycle.userPrefsLoaded,
pluginsSettled,
confirmRef,
layoutDeps: [
regions.left.view,
regions.left.open,
regions.left.width,
regions.right.view,
regions.right.width,
],
});
openSingletonRef.current = tabsState.openSingletonTab;
const { tabs, activeTabId } = tabsState;
// Keeps the desktop's sync status polled while the app is open; a pass
// that changed data tells the panels to reload.
useSyncStatus();
const palette: ShellPalette = {
isOpen: paletteOpen,
open: () => setPaletteOpen(true),
close: () => setPaletteOpen(false),
setOpen: setPaletteOpen,
};
function editHostInManager(host: Host) {
requestManage({ kind: "host", hostId: host.id });
if (isMobile) regions.left.setOpen(false);
}
/**
* What plugins may ask of the shell. Rebuilt every render so it always
* closes over current state, and published to the plugin runtime.
*/
const shellCallbacksImpl: TabShellCallbacks = {
openTab: (host, type, options) => {
if (!host || getTabType(type)?.singleton) {
tabsState.openSingletonTab(
type,
undefined,
host ?? undefined,
options?.data,
options?.label,
);
return;
}
tabsState.openTab(host, type, undefined, options);
},
openSingletonTab: (type, options) =>
tabsState.openSingletonTab(type, undefined, undefined, options?.data),
connectHost: (host, type) => tabsState.connectHost(host, type as TabType),
closeTab: (tabId) => tabsState.closeTab(tabId),
renameTab: (tabId, label) => tabsState.renameTab(tabId, label),
openRailView: (id) => {
regions.left.setView(id);
regions.left.setOpen(true);
},
closeRailView: (id) => {
regions.left.setView((prev) => (prev === id ? "hosts" : prev));
regions.right.setView((prev) => (prev === id ? null : prev));
},
openHostEditor: (draft) =>
requestManage({ kind: "host", hostId: null, draft }),
saveQuickConnect: tabsState.saveQuickConnectHost,
confirm: (options) =>
confirmRef.current ? confirmRef.current(options) : Promise.resolve(false),
};
// Tabs get one stable bag that forwards to the latest callbacks, so a shell
// render does not re-render every open tab.
const shellImplRef = useRef(shellCallbacksImpl);
useLayoutEffect(() => {
shellImplRef.current = shellCallbacksImpl;
});
const callbacks = useMemo<TabShellCallbacks>(
() => ({
openTab: (...args) => shellImplRef.current.openTab(...args),
openSingletonTab: (...args) =>
shellImplRef.current.openSingletonTab(...args),
connectHost: (...args) => shellImplRef.current.connectHost(...args),
closeTab: (...args) => shellImplRef.current.closeTab(...args),
renameTab: (...args) => shellImplRef.current.renameTab(...args),
openRailView: (...args) => shellImplRef.current.openRailView(...args),
closeRailView: (...args) => shellImplRef.current.closeRailView(...args),
openHostEditor: (...args) =>
shellImplRef.current.openHostEditor?.(...args),
saveQuickConnect: (...args) =>
shellImplRef.current.saveQuickConnect!(...args),
confirm: (...args) => shellImplRef.current.confirm!(...args),
}),
[],
);
// Panels close the sidebar on mobile after opening something, the same as
// the hosts panel does.
const panelCallbacks = useMemo<TabShellCallbacks>(
() => ({
...callbacks,
openTab: (...args) => {
callbacks.openTab(...args);
if (isMobile) regions.left.setOpen(false);
},
}),
[callbacks, isMobile],
);
// Resize all terminals in panes + active terminal when split mode or sidebar changes
const resizeAllTerminals = useCallback(() => {
const id = requestAnimationFrame(() => {
tabs.forEach((tab) => {
if (!tab.terminalRef) return;
const ref = tab.terminalRef.current;
ref?.fit?.();
ref?.notifyResize?.();
});
});
return id;
}, [tabs]);
const { split, splitShapeKey, splitActionsRef } = useSplit({
tabsState,
isMobile,
paneEls: stageStore.paneEls,
callbacks,
resizeAllTerminals,
});
useEffect(() => {
// Refitting every terminal on each drag frame is what made sidebar drags
// stutter; fit once when the drag ends instead.
if (regions.left.dragging || regions.right.dragging) return;
const id = resizeAllTerminals();
return () => cancelAnimationFrame(id);
}, [
splitShapeKey,
regions.left.width,
regions.left.open,
regions.right.width,
regions.right.view,
regions.left.dragging,
regions.right.dragging,
]);
useWorkspaceLayout(tabsState, regions);
useShellKeybindings({
tabsState,
regions,
palette,
splitActionsRef,
toggleFullscreen: lifecycle.toggleAppFullscreen,
});
useEffect(() => {
setShellCallbacks(callbacks);
});
const activeTab = tabs.find((tab) => tab.id === activeTabId);
const controller: ShellController = {
username,
isAdmin: lifecycle.isAdmin,
isMobile,
isElectron: isElectron(),
pluginsSettled,
logout: onLogout,
tabs: {
list: tabs,
topLevel: tabs.filter((tab) => !tab.parentSplitTabId),
activeId: activeTabId,
active: activeTab,
ready: tabsState.tabsReady,
terminalTabs: tabsState.terminalTabs,
targetTerminal: tabsState.targetTerminalTab,
showNumbers: tabsState.showTabNumbers,
background: tabsState.backgroundTabRecords,
setBackground: tabsState.setBackgroundTabRecords,
setActive: tabsState.setActiveTabId,
setList: tabsState.setTabs,
reorder: tabsState.reorderTopLevelTabs,
open: tabsState.openTab,
openSingleton: tabsState.openSingletonTab,
openMultiInstance: tabsState.openMultiInstanceTab,
connectHost: tabsState.connectHost,
close: tabsState.closeTab,
rename: tabsState.renameTab,
refresh: tabsState.refreshTab,
reconnectAll: tabsState.reconnectAllDisconnected,
handleFor: tabsState.handleFor,
},
split,
hosts: {
list: hosts.list,
tree: hosts.tree,
loading: hosts.loading,
loaded: hosts.loaded,
reload: hosts.reload,
edit: editHostInManager,
},
regions,
palette,
fullscreen: {
active: lifecycle.isAppFullscreen,
toggle: lifecycle.toggleAppFullscreen,
},
callbacks,
panelCallbacks,
settings: {
username,
isAdmin: lifecycle.isAdmin,
onLogout,
userPrefs: lifecycle.userPrefs,
onPrefsChange: (updates) =>
lifecycle.setUserPrefs((current) => ({ ...current, ...updates })),
onOpenHostTab: (host: Host) => tabsState.connectHost(host),
},
confirmRef,
resizeAllTerminals,
};
useLayoutEffect(() => {
publishShellController(controller);
});
useEffect(() => () => publishShellController(null), []);
return (
<TabStageContext.Provider value={stageStore}>
<ShellControllerContext.Provider value={controller}>
{children}
</ShellControllerContext.Provider>
</TabStageContext.Provider>
);
}