// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 import { getLayoutModelForTabById, LayoutTreeActionType, LayoutTreeInsertNodeAction, newLayoutNode, } from "@/layout/index"; import { getLayoutModelForStaticTab } from "@/layout/lib/layoutModelHooks"; import { getWebServerEndpoint } from "@/util/endpoints"; import { fetch } from "@/util/fetchutil"; import { getPrefixedSettings, isBlank } from "@/util/util"; import { atom, Atom, PrimitiveAtom, useAtomValue } from "jotai"; import { globalStore } from "./jotaiStore"; import { modalsModel } from "./modalmodel"; import { ClientService, ObjectService } from "./services"; import * as WOS from "./wos"; import { getFileSubject, waveEventSubscribe } from "./wps"; let PLATFORM: NodeJS.Platform = "darwin"; let atoms: GlobalAtomsType; let globalEnvironment: "electron" | "renderer"; const blockComponentModelMap = new Map(); const Counters = new Map(); const ConnStatusMap = new Map>(); type GlobalInitOptions = { tabId: string; platform: NodeJS.Platform; windowId: string; clientId: string; environment: "electron" | "renderer"; }; function initGlobal(initOpts: GlobalInitOptions) { globalEnvironment = initOpts.environment; setPlatform(initOpts.platform); initGlobalAtoms(initOpts); } function setPlatform(platform: NodeJS.Platform) { PLATFORM = platform; } function initGlobalAtoms(initOpts: GlobalInitOptions) { const windowIdAtom = atom(initOpts.windowId) as PrimitiveAtom; const clientIdAtom = atom(initOpts.clientId) as PrimitiveAtom; const uiContextAtom = atom((get) => { const uiContext: UIContext = { windowid: initOpts.windowId, activetabid: initOpts.tabId, }; return uiContext; }) as Atom; const isFullScreenAtom = atom(false) as PrimitiveAtom; try { getApi().onFullScreenChange((isFullScreen) => { globalStore.set(isFullScreenAtom, isFullScreen); }); } catch (_) { // do nothing } try { getApi().onMenuItemAbout(() => { modalsModel.pushModal("AboutModal"); }); } catch (_) { // do nothing } const clientAtom: Atom = atom((get) => { const clientId = get(clientIdAtom); if (clientId == null) { return null; } return WOS.getObjectValue(WOS.makeORef("client", clientId), get); }); const windowDataAtom: Atom = atom((get) => { const windowId = get(windowIdAtom); if (windowId == null) { return null; } const rtn = WOS.getObjectValue(WOS.makeORef("window", windowId), get); return rtn; }); const workspaceAtom: Atom = atom((get) => { const windowData = get(windowDataAtom); if (windowData == null) { return null; } return WOS.getObjectValue(WOS.makeORef("workspace", windowData.workspaceid), get); }); const fullConfigAtom = atom(null) as PrimitiveAtom; const settingsAtom = atom((get) => { return get(fullConfigAtom)?.settings ?? {}; }) as Atom; const tabAtom: Atom = atom((get) => { return WOS.getObjectValue(WOS.makeORef("tab", initOpts.tabId), get); }); const staticTabIdAtom: Atom = atom((get) => { return initOpts.tabId; }); const controlShiftDelayAtom = atom(false); const updaterStatusAtom = atom("up-to-date") as PrimitiveAtom; try { globalStore.set(updaterStatusAtom, getApi().getUpdaterStatus()); getApi().onUpdaterStatusChange((status) => { globalStore.set(updaterStatusAtom, status); }); } catch (_) { // do nothing } const reducedMotionSettingAtom = atom((get) => get(settingsAtom)?.["window:reducedmotion"]); const reducedMotionSystemPreferenceAtom = atom(false); // Composite of the prefers-reduced-motion media query and the window:reducedmotion user setting. const prefersReducedMotionAtom = atom((get) => { const reducedMotionSetting = get(reducedMotionSettingAtom); const reducedMotionSystemPreference = get(reducedMotionSystemPreferenceAtom); return reducedMotionSetting || reducedMotionSystemPreference; }); // Set up a handler for changes to the prefers-reduced-motion media query. if (globalThis.window != null) { const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)"); globalStore.set(reducedMotionSystemPreferenceAtom, !reducedMotionQuery || reducedMotionQuery.matches); reducedMotionQuery?.addEventListener("change", () => { globalStore.set(reducedMotionSystemPreferenceAtom, reducedMotionQuery.matches); }); } const typeAheadModalAtom = atom({}); const modalOpen = atom(false); const allConnStatusAtom = atom((get) => { const connStatuses = Array.from(ConnStatusMap.values()).map((atom) => get(atom)); return connStatuses; }); const flashErrorsAtom = atom([]); const notificationsAtom = atom([]); const notificationPopoverModeAtom = atom(false); const reinitVersion = atom(0); atoms = { // initialized in wave.ts (will not be null inside of application) clientId: clientIdAtom, uiContext: uiContextAtom, client: clientAtom, waveWindow: windowDataAtom, workspace: workspaceAtom, fullConfigAtom, settingsAtom, tabAtom, staticTabId: staticTabIdAtom, isFullScreen: isFullScreenAtom, controlShiftDelayAtom, updaterStatusAtom, prefersReducedMotionAtom, typeAheadModalAtom, modalOpen, allConnStatus: allConnStatusAtom, flashErrors: flashErrorsAtom, notifications: notificationsAtom, notificationPopoverMode: notificationPopoverModeAtom, reinitVersion, }; } function initGlobalWaveEventSubs() { waveEventSubscribe( { eventType: "waveobj:update", handler: (event) => { // console.log("waveobj:update wave event handler", event); const update: WaveObjUpdate = event.data; WOS.updateWaveObject(update); }, }, { eventType: "config", handler: (event) => { // console.log("config wave event handler", event); const fullConfig = (event.data as WatcherUpdate).fullconfig; globalStore.set(atoms.fullConfigAtom, fullConfig); }, }, { eventType: "userinput", handler: (event) => { // console.log("userinput event handler", event); const data: UserInputRequest = event.data; modalsModel.pushModal("UserInputModal", { ...data }); }, }, { eventType: "blockfile", handler: (event) => { // console.log("blockfile event update", event); const fileData: WSFileEventData = event.data; const fileSubject = getFileSubject(fileData.zoneid, fileData.filename); if (fileSubject != null) { fileSubject.next(fileData); } }, } ); } const blockCache = new Map>(); function useBlockCache(blockId: string, name: string, makeFn: () => T): T { let blockMap = blockCache.get(blockId); if (blockMap == null) { blockMap = new Map(); blockCache.set(blockId, blockMap); } let value = blockMap.get(name); if (value == null) { value = makeFn(); blockMap.set(name, value); } return value as T; } function getBlockMetaKeyAtom(blockId: string, key: T): Atom { const blockCache = getSingleBlockAtomCache(blockId); const metaAtomName = "#meta-" + key; let metaAtom = blockCache.get(metaAtomName); if (metaAtom != null) { return metaAtom; } metaAtom = atom((get) => { let blockAtom = WOS.getWaveObjectAtom(WOS.makeORef("block", blockId)); let blockData = get(blockAtom); return blockData?.meta?.[key]; }); blockCache.set(metaAtomName, metaAtom); return metaAtom; } function useBlockMetaKeyAtom(blockId: string, key: T): MetaType[T] { return useAtomValue(getBlockMetaKeyAtom(blockId, key)); } const settingsAtomCache = new Map>(); function getOverrideConfigAtom(blockId: string, key: T): Atom { const blockCache = getSingleBlockAtomCache(blockId); const overrideAtomName = "#settingsoverride-" + key; let overrideAtom = blockCache.get(overrideAtomName); if (overrideAtom != null) { return overrideAtom; } overrideAtom = atom((get) => { const blockMetaKeyAtom = getBlockMetaKeyAtom(blockId, key as any); const metaKeyVal = get(blockMetaKeyAtom); if (metaKeyVal != null) { return metaKeyVal; } const settingsKeyAtom = getSettingsKeyAtom(key); const settingsVal = get(settingsKeyAtom); if (settingsVal != null) { return settingsVal; } return null; }); blockCache.set(overrideAtomName, overrideAtom); return overrideAtom; } function useOverrideConfigAtom(blockId: string, key: T): SettingsType[T] { return useAtomValue(getOverrideConfigAtom(blockId, key)); } function getSettingsKeyAtom(key: T): Atom { let settingsKeyAtom = settingsAtomCache.get(key) as Atom; if (settingsKeyAtom == null) { settingsKeyAtom = atom((get) => { const settings = get(atoms.settingsAtom); if (settings == null) { return null; } return settings[key]; }); settingsAtomCache.set(key, settingsKeyAtom); } return settingsKeyAtom; } function useSettingsKeyAtom(key: T): SettingsType[T] { return useAtomValue(getSettingsKeyAtom(key)); } function useSettingsPrefixAtom(prefix: string): Atom { // TODO: use a shallow equal here to make this more efficient let settingsPrefixAtom = settingsAtomCache.get(prefix + ":") as Atom; if (settingsPrefixAtom == null) { settingsPrefixAtom = atom((get) => { const settings = get(atoms.settingsAtom); if (settings == null) { return {}; } return getPrefixedSettings(settings, prefix); }); settingsAtomCache.set(prefix + ":", settingsPrefixAtom); } return settingsPrefixAtom; } const blockAtomCache = new Map>>(); function getSingleBlockAtomCache(blockId: string): Map> { let blockCache = blockAtomCache.get(blockId); if (blockCache == null) { blockCache = new Map>(); blockAtomCache.set(blockId, blockCache); } return blockCache; } function useBlockAtom(blockId: string, name: string, makeFn: () => Atom): Atom { const blockCache = getSingleBlockAtomCache(blockId); let atom = blockCache.get(name); if (atom == null) { atom = makeFn(); blockCache.set(name, atom); console.log("New BlockAtom", blockId, name); } return atom as Atom; } function useBlockDataLoaded(blockId: string): boolean { const loadedAtom = useBlockAtom(blockId, "block-loaded", () => { return WOS.getWaveObjectLoadingAtom(WOS.makeORef("block", blockId)); }); return useAtomValue(loadedAtom); } /** * Get the preload api. */ function getApi(): ElectronApi { return (window as any).api; } async function createBlock(blockDef: BlockDef, magnified = false, ephemeral = false): Promise { const tabId = globalStore.get(atoms.staticTabId); const layoutModel = getLayoutModelForTabById(tabId); const rtOpts: RuntimeOpts = { termsize: { rows: 25, cols: 80 } }; const blockId = await ObjectService.CreateBlock(blockDef, rtOpts); if (ephemeral) { layoutModel.newEphemeralNode(blockId); return blockId; } const insertNodeAction: LayoutTreeInsertNodeAction = { type: LayoutTreeActionType.InsertNode, node: newLayoutNode(undefined, undefined, undefined, { blockId }), magnified, focused: true, }; layoutModel.treeReducer(insertNodeAction); return blockId; } // when file is not found, returns {data: null, fileInfo: null} async function fetchWaveFile( zoneId: string, fileName: string, offset?: number ): Promise<{ data: Uint8Array; fileInfo: WaveFile }> { const usp = new URLSearchParams(); usp.set("zoneid", zoneId); usp.set("name", fileName); if (offset != null) { usp.set("offset", offset.toString()); } const resp = await fetch(getWebServerEndpoint() + "/wave/file?" + usp.toString()); if (!resp.ok) { if (resp.status === 404) { return { data: null, fileInfo: null }; } throw new Error("error getting wave file: " + resp.statusText); } if (resp.status == 204) { return { data: null, fileInfo: null }; } const fileInfo64 = resp.headers.get("X-ZoneFileInfo"); if (fileInfo64 == null) { throw new Error(`missing zone file info for ${zoneId}:${fileName}`); } const fileInfo = JSON.parse(atob(fileInfo64)); const data = await resp.arrayBuffer(); return { data: new Uint8Array(data), fileInfo }; } function setNodeFocus(nodeId: string) { const layoutModel = getLayoutModelForStaticTab(); layoutModel.focusNode(nodeId); } const objectIdWeakMap = new WeakMap(); let objectIdCounter = 0; function getObjectId(obj: any): number { if (!objectIdWeakMap.has(obj)) { objectIdWeakMap.set(obj, objectIdCounter++); } return objectIdWeakMap.get(obj); } let cachedIsDev: boolean = null; function isDev() { if (cachedIsDev == null) { cachedIsDev = getApi().getIsDev(); } return cachedIsDev; } let cachedUserName: string = null; function getUserName(): string { if (cachedUserName == null) { cachedUserName = getApi().getUserName(); } return cachedUserName; } let cachedHostName: string = null; function getHostName(): string { if (cachedHostName == null) { cachedHostName = getApi().getHostName(); } return cachedHostName; } /** * Open a link in a new window, or in a new web widget. The user can set all links to open in a new web widget using the `web:openlinksinternally` setting. * @param uri The link to open. * @param forceOpenInternally Force the link to open in a new web widget. */ async function openLink(uri: string, forceOpenInternally = false) { if (forceOpenInternally || globalStore.get(atoms.settingsAtom)?.["web:openlinksinternally"]) { const blockDef: BlockDef = { meta: { view: "web", url: uri, }, }; await createBlock(blockDef); } else { getApi().openExternal(uri); } } function registerBlockComponentModel(blockId: string, bcm: BlockComponentModel) { blockComponentModelMap.set(blockId, bcm); } function unregisterBlockComponentModel(blockId: string) { blockComponentModelMap.delete(blockId); } function getBlockComponentModel(blockId: string): BlockComponentModel { return blockComponentModelMap.get(blockId); } function getFocusedBlockId(): string { const layoutModel = getLayoutModelForStaticTab(); const focusedLayoutNode = globalStore.get(layoutModel.focusedNode); return focusedLayoutNode?.data?.blockId; } // pass null to refocus the currently focused block function refocusNode(blockId: string) { if (blockId == null) { blockId = getFocusedBlockId(); if (blockId == null) { return; } } const layoutModel = getLayoutModelForStaticTab(); const layoutNodeId = layoutModel.getNodeByBlockId(blockId); if (layoutNodeId?.id == null) { return; } layoutModel.focusNode(layoutNodeId.id); const bcm = getBlockComponentModel(blockId); const ok = bcm?.viewModel?.giveFocus?.(); if (!ok) { const inputElem = document.getElementById(`${blockId}-dummy-focus`); inputElem?.focus(); } } function countersClear() { Counters.clear(); } function counterInc(name: string, incAmt: number = 1) { let count = Counters.get(name) ?? 0; count += incAmt; Counters.set(name, count); } function countersPrint() { let outStr = ""; for (const [name, count] of Counters.entries()) { outStr += `${name}: ${count}\n`; } console.log(outStr); } async function loadConnStatus() { const connStatusArr = await ClientService.GetAllConnStatus(); if (connStatusArr == null) { return; } for (const connStatus of connStatusArr) { const curAtom = getConnStatusAtom(connStatus.connection); globalStore.set(curAtom, connStatus); } } function subscribeToConnEvents() { waveEventSubscribe({ eventType: "connchange", handler: (event: WaveEvent) => { try { const connStatus = event.data as ConnStatus; if (connStatus == null || isBlank(connStatus.connection)) { return; } console.log("connstatus update", connStatus); let curAtom = getConnStatusAtom(connStatus.connection); globalStore.set(curAtom, connStatus); } catch (e) { console.log("connchange error", e); } }, }); } function getConnStatusAtom(conn: string): PrimitiveAtom { let rtn = ConnStatusMap.get(conn); if (rtn == null) { if (isBlank(conn)) { // create a fake "local" status atom that's always connected const connStatus: ConnStatus = { connection: conn, connected: true, error: null, status: "connected", hasconnected: true, activeconnnum: 0, }; rtn = atom(connStatus); } else { const connStatus: ConnStatus = { connection: conn, connected: false, error: null, status: "disconnected", hasconnected: false, activeconnnum: 0, }; rtn = atom(connStatus); } ConnStatusMap.set(conn, rtn); } return rtn; } function pushFlashError(ferr: FlashErrorType) { if (ferr.expiration == null) { ferr.expiration = Date.now() + 5000; } ferr.id = crypto.randomUUID(); globalStore.set(atoms.flashErrors, (prev) => { return [...prev, ferr]; }); } function addOrUpdateNotification(notif: NotificationType) { globalStore.set(atoms.notifications, (prevNotifications) => { // Remove any existing notification with the same ID const notificationsWithoutThisId = prevNotifications.filter((n) => n.id !== notif.id); // Add the new notification return [...notificationsWithoutThisId, notif]; }); } function pushNotification(notif: NotificationType) { if (!notif.id && notif.persistent) { return; } notif.id = notif.id ?? crypto.randomUUID(); addOrUpdateNotification(notif); } function removeNotificationById(id: string) { globalStore.set(atoms.notifications, (prev) => { return prev.filter((notif) => notif.id !== id); }); } function removeFlashError(id: string) { globalStore.set(atoms.flashErrors, (prev) => { return prev.filter((ferr) => ferr.id !== id); }); } function removeNotification(id: string) { globalStore.set(atoms.notifications, (prev) => { return prev.filter((notif) => notif.id !== id); }); } async function createTab(): Promise { await getApi().createTab(); } export { atoms, counterInc, countersClear, countersPrint, createBlock, createTab, fetchWaveFile, getApi, getBlockComponentModel, getBlockMetaKeyAtom, getConnStatusAtom, getHostName, getObjectId, getOverrideConfigAtom, getSettingsKeyAtom, getUserName, globalStore, initGlobal, initGlobalWaveEventSubs, isDev, loadConnStatus, openLink, PLATFORM, pushFlashError, pushNotification, refocusNode, registerBlockComponentModel, removeFlashError, removeNotification, removeNotificationById, setNodeFocus, setPlatform, subscribeToConnEvents, unregisterBlockComponentModel, useBlockAtom, useBlockCache, useBlockDataLoaded, useBlockMetaKeyAtom, useOverrideConfigAtom, useSettingsKeyAtom, useSettingsPrefixAtom, WOS, };