diff --git a/src/apps/officer-web/frontend.tsx b/src/apps/officer-web/frontend.tsx index bfdcdd6e..59a1b2db 100644 --- a/src/apps/officer-web/frontend.tsx +++ b/src/apps/officer-web/frontend.tsx @@ -2,7 +2,7 @@ import './lib/console-buffer'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { App } from './App'; -import { AppRegistry, WidgetRegistry } from 'officerdev'; +import { seedAppRegistry, seedWidgetRegistry } from 'officerdev'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ColorModeProvider } from '@/components/ui/ThemeProvider'; import { I18nBridge } from '@/lib/I18nBridge'; @@ -20,6 +20,11 @@ const queryClient = new QueryClient({ }, }); +// Before the first render, not from a component inside it: a panel that renders before its registry is +// populated draws an empty box, and `useGlobal`'s initialData gives no second chance to fill it. +seedAppRegistry(queryClient); +seedWidgetRegistry(queryClient); + const elem = document.getElementById('root')!; const app = ( @@ -29,8 +34,6 @@ const app = ( - - diff --git a/src/workspaces/hooks/src/useGlobal.ts b/src/workspaces/hooks/src/useGlobal.ts index d6993ef2..893bca1d 100644 --- a/src/workspaces/hooks/src/useGlobal.ts +++ b/src/workspaces/hooks/src/useGlobal.ts @@ -1,9 +1,17 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; +/** + * Where a `useGlobal` slot lives in the query cache. Exported so a value can be put there before anything + * renders — `initialData` only applies to whichever component reads the slot first, so a slot that must be + * populated regardless of mount order has to be written, not defaulted. + */ +export const globalQueryKey = (key: string | string[]) => + Array.isArray(key) ? ['USE_GLOBAL', ...key] : ['USE_GLOBAL', key]; + export const useGlobal = (key: string | string[], initialData: T | (() => T)) => { const queryClient = useQueryClient(); - const theKey = Array.isArray(key) ? ['USE_GLOBAL', ...key] : ['USE_GLOBAL', key]; + const theKey = globalQueryKey(key); const theInitialData = typeof initialData === 'function' ? (initialData as () => T)() : initialData; const { data } = useQuery({ diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 613704c2..b6ff7dee 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -2,7 +2,6 @@ import { appRegistryMetas as fileBrowserMetas } from '../apps/FileBrowser'; import { appRegistryMetas as terminalMetas } from '../apps/Terminal'; import { appRegistryMetas as codeEditorMetas } from '../apps/CodeEditor'; import { appRegistryMetas as chatMetas } from '../apps/Chat'; -import { appRegistryMetas as fileViewerMetas } from '../apps/FileViewer'; import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards'; import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory'; import { appRegistryMetas as widgetMetas } from '../apps/Widgets'; @@ -19,14 +18,15 @@ import { appRegistryMetas as walletMetas } from '../apps/Wallet'; import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor'; import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer'; import { appRegistryMetas as davMetas } from '../apps/Dav'; -import { useAppRegistry } from './useAppRegistry'; +import type { QueryClient } from '@tanstack/react-query'; +import { globalQueryKey } from 'hooks/useGlobal'; +import { metasToRegistry } from './useAppRegistry'; export const apps = [ ...fileBrowserMetas, ...terminalMetas, ...codeEditorMetas, ...chatMetas, - ...fileViewerMetas, ...dashboardMetas, ...chatHistoryMetas, ...widgetMetas, @@ -45,7 +45,20 @@ export const apps = [ ...davMetas, ]; -export const AppRegistry = () => { - useAppRegistry(apps); - return null; +/** + * Put the app list in the cache before the first render, rather than seeding it from a component mounted + * for its side effect. + * + * The old `` seeded through `useGlobal`'s `initialData`, which only applies to whoever + * reads the slot first. It therefore worked entirely by sitting above `` in `frontend.tsx`: any + * `WorkspaceView` that rendered before it created the slot as `{}` and there was no second chance — + * `initialData` is not a write. Every panel on that screen would have rendered as an empty box, for a + * reason visible nowhere near the screen that broke. + * + * It is a function taking the client rather than a module-scope side effect because the app list imports + * every panel app, and every panel app imports the Workspace framework. Keeping the call in `frontend.tsx` + * — the one module that is nobody's dependency — is what keeps that from becoming an import cycle. + */ +export const seedAppRegistry = (queryClient: QueryClient) => { + queryClient.setQueryData(globalQueryKey('APP_REGISTRY'), metasToRegistry(apps)); }; diff --git a/src/workspaces/officerdev/src/AppRegistry/useAppRegistry/useAppRegistry.ts b/src/workspaces/officerdev/src/AppRegistry/useAppRegistry/useAppRegistry.ts index eae13efa..c7830ba9 100644 --- a/src/workspaces/officerdev/src/AppRegistry/useAppRegistry/useAppRegistry.ts +++ b/src/workspaces/officerdev/src/AppRegistry/useAppRegistry/useAppRegistry.ts @@ -3,14 +3,16 @@ import { useGlobal } from 'hooks/useGlobal'; export type AppRegistryMeta = { key: string } & AppRegistryEntry; -export function useAppRegistry(initialApps: AppRegistryMeta[] = []) { - const [registry, setRegistry] = useGlobal('APP_REGISTRY', () => metasToRegistry(initialApps)); +const EMPTY_REGISTRY: AppRegistryMap = {}; - const registerApp = (key: string, entry: AppRegistryEntry) => { - setRegistry((prev) => ({ ...prev, [key]: entry })); - }; - - return { registry, registerApp }; +/** + * Read the app registry. It is written once, by `seedAppRegistry`, before anything renders — there is no + * `registerApp` any more because nothing ever called one, and a registry that can be added to at runtime + * is a registry whose contents depend on what has mounted so far. + */ +export function useAppRegistry() { + const [registry] = useGlobal('APP_REGISTRY', EMPTY_REGISTRY); + return { registry }; } export type UseAppRegistryType = ReturnType; diff --git a/src/workspaces/officerdev/src/WidgetRegistry/WidgetRegistry.tsx b/src/workspaces/officerdev/src/WidgetRegistry/WidgetRegistry.tsx index fe010fda..95f14323 100644 --- a/src/workspaces/officerdev/src/WidgetRegistry/WidgetRegistry.tsx +++ b/src/workspaces/officerdev/src/WidgetRegistry/WidgetRegistry.tsx @@ -4,7 +4,10 @@ import { widgetRegistryMetas as pomodoroMetas } from 'widgets/Pomodoro'; import { widgetRegistryMetas as dailyGoalsMetas } from 'widgets/DailyGoals'; import { widgetRegistryMetas as quickNotesMetas } from 'widgets/QuickNotes'; import { widgetRegistryMetas as musicPlayerMetas } from 'widgets/MusicPlayer'; -import { useWidgetRegistry } from './useWidgetRegistry'; +import type { QueryClient } from '@tanstack/react-query'; +import { globalQueryKey } from 'hooks/useGlobal'; +import type { WidgetRegistry as WidgetRegistryMap } from './types'; +import type { WidgetRegistryMeta } from './useWidgetRegistry'; const widgets = [ ...clockMetas, @@ -15,7 +18,10 @@ const widgets = [ ...musicPlayerMetas, ]; -export const WidgetRegistry = () => { - useWidgetRegistry(widgets); - return null; +const metasToRegistry = (metas: WidgetRegistryMeta[]): WidgetRegistryMap => + Object.fromEntries(metas.map(({ key, ...entry }) => [key, entry])); + +/** Same shape, and the same reasoning, as `seedAppRegistry`. */ +export const seedWidgetRegistry = (queryClient: QueryClient) => { + queryClient.setQueryData(globalQueryKey('WIDGET_REGISTRY'), metasToRegistry(widgets)); }; diff --git a/src/workspaces/officerdev/src/WidgetRegistry/useWidgetRegistry.ts b/src/workspaces/officerdev/src/WidgetRegistry/useWidgetRegistry.ts index a7079d25..ad4dc8ca 100644 --- a/src/workspaces/officerdev/src/WidgetRegistry/useWidgetRegistry.ts +++ b/src/workspaces/officerdev/src/WidgetRegistry/useWidgetRegistry.ts @@ -3,15 +3,10 @@ import { useGlobal } from 'hooks/useGlobal'; export type WidgetRegistryMeta = { key: string } & WidgetRegistryEntry; -export function useWidgetRegistry(initialWidgets: WidgetRegistryMeta[] = []) { - const [registry, setRegistry] = useGlobal('WIDGET_REGISTRY', () => metasToRegistry(initialWidgets)); +const EMPTY_REGISTRY: WidgetRegistry = {}; - const registerWidget = (key: string, entry: WidgetRegistryEntry) => { - setRegistry((prev) => ({ ...prev, [key]: entry })); - }; - - return { registry, registerWidget }; +/** Read the widget registry. Written once by `seedWidgetRegistry`; see `useAppRegistry` for the reasoning. */ +export function useWidgetRegistry() { + const [registry] = useGlobal('WIDGET_REGISTRY', EMPTY_REGISTRY); + return { registry }; } - -const metasToRegistry = (metas: WidgetRegistryMeta[]): WidgetRegistry => - Object.fromEntries(metas.map(({ key, ...entry }) => [key, entry])); diff --git a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/FileViewer/FileViewerPanelWrapper.tsx deleted file mode 100644 index d0c4649c..00000000 --- a/src/workspaces/officerdev/src/apps/FileViewer/FileViewerPanelWrapper.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { ReactNode } from 'react'; -import { usePanelChannel } from 'hooks/usePanelChannel'; -import { FileViewerProvider } from './FileViewerProvider'; - -type FileViewerChannelState = { - filePath: string; - fileName: string; - root?: string; -} | null; - -const FILE_VIEWER_CHANNEL = 'file-viewer'; - -export const FileViewerPanelProvider = ({ panelId, children }: { panelId: string; children: ReactNode }) => { - const [state] = usePanelChannel(`${FILE_VIEWER_CHANNEL}:${panelId}`, null); - - if (!state) { - return ( -
- No file selected -
- ); - } - - return ( - - {children} - - ); -}; diff --git a/src/workspaces/officerdev/src/apps/FileViewer/index.ts b/src/workspaces/officerdev/src/apps/FileViewer/index.ts index a84420eb..5865781c 100644 --- a/src/workspaces/officerdev/src/apps/FileViewer/index.ts +++ b/src/workspaces/officerdev/src/apps/FileViewer/index.ts @@ -1,25 +1,14 @@ -import type { AppRegistryMeta } from '../../AppRegistry'; -import { Eye } from 'lucide-react'; -import { FileViewerBody } from './FileViewerBody'; -import { FileViewerHeader } from './FileViewerHeader'; -import { FileViewerPanelProvider } from './FileViewerPanelWrapper'; - export { FileViewerView } from './FileViewerView'; export { FileViewerProvider } from './FileViewerProvider'; export { FileViewerHeader } from './FileViewerHeader'; export { FileViewerBody } from './FileViewerBody'; -export { FileViewerPanelProvider } from './FileViewerPanelWrapper'; export { useFileViewer } from './FileViewerContext'; export { getFileType, getLang, getExt, getArchiveBaseName, ARCHIVE_EXTS, type FileType } from './file-types'; -export const appRegistryMetas: AppRegistryMeta[] = [ - { - key: 'officerdev/file-viewer', - name: 'File Viewer', - icon: Eye, - component: FileViewerBody, - header: FileViewerHeader, - provider: FileViewerPanelProvider, - availableOnPanel: false, - }, -]; +// No `appRegistryMetas` here on purpose. The file viewer is not a panel app you can put on a dashboard: +// it is mounted as an ephemeral panel by `useFileViewerPanels`, which supplies `FileViewerBody`/`Header` +// itself along with a provider that reads the path from the URL. The registration that used to sit here +// pointed at a provider fed by a `file-viewer:` channel that nothing in the repo ever wrote, and +// carried `availableOnPanel: false` so it could not be picked either — it would have rendered "No file +// selected" forever. No stored layout referenced it (checked across dashboards, screens, +// dashboard_defaults, user_state and user_settings), so it was removed rather than repaired. diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index ec9ce5d0..b24c0e13 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -90,7 +90,6 @@ export { FileViewerProvider, FileViewerHeader, FileViewerBody, - FileViewerPanelProvider, useFileViewer, getFileType, getLang,