mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: shell controller, TabStage, layout and theme plugins with repair view
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user