import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { QueryClient, QueryClientProvider, useQuery, } from '@tanstack/react-query'; import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom'; import { UserIdentityProvider } from './auth/UserIdentityContext'; import { AppBottomBar } from './components/AppBottomBar'; import { AppSidebar } from './components/AppSidebar'; import { WikiLinkVaultProvider } from './wikilinks/WikiLinkVaultContext'; import { AppTitleBar } from './components/AppTitleBar'; import { SettingsModal } from './components/SettingsModal'; import type { FileSystemProvider } from './filesystem/types'; import { LocalFilesystemProvider } from './filesystem/providers/localFilesystemProvider'; import { SyncedVaultFilesystemProvider } from './filesystem/providers/syncedVaultFilesystemProvider'; import { VaultDocumentProvider } from './documents/providers/vaultDocumentsProvider'; import { SyncedVaultDocumentsProvider } from './documents/providers/syncedVaultDocumentsProvider'; import { VaultFilesystemApi } from './filesystem/vaultFs'; import { VaultManager } from './components/VaultManager'; import { SyncRemoteVaultModal } from './components/SyncRemoteVaultModal'; import { HabitatRepoImpl } from './vault/habitat-repo'; import { VaultSnapshotManager } from './vault/snapshot'; import { VaultChangeDetector } from './vault/change-detection'; import { VaultSyncEngine } from './vault/engine'; import type { HabitatUri } from './vault/types'; import { createAutomergeDoc } from './habitat/automergeDoc'; import { acquireDocSession } from './habitat/automergeDocSession'; import { useHabitatAuth } from './auth/habitatAuth'; import { HABITAT_VAULT_ROOT_COLLECTION } from './habitat/config'; import { vaultRegistry, VaultRegistry, toVaultConfig, type RemoteVault, } from './vault/registry'; import { getRendererPlatform } from './lib/platform'; import { AboutPage } from './pages/AboutPage'; import { DEFAULT_WORKSPACE_KIND, isWorkspaceKind, WorkspaceHost, type OpenDocRequest, type WorkspaceKind, } from './workspaces/workspace'; import { ActionProvider } from './actions/ActionProvider'; import { ContextMenuProvider, electronShowContextMenu, } from './platform/contextMenu'; import { SidebarActionSet, SettingsActionSet, ThemeActionSet, NavigationActionSet, WorkspaceActionSet, TilingActionSet, createActionServices, type TilingHandle, } from './actions'; const queryClient = new QueryClient({ defaultOptions: { queries: { // File listings are invalidated explicitly (on auth change, on create); // don't refetch silently in the background on every mount/focus. staleTime: 30_000, refetchOnWindowFocus: false, }, }, }); const WORKSPACE_KIND_STORAGE_KEY = 'textile.workspaceKind'; function loadInitialWorkspaceKind(): WorkspaceKind { if (typeof localStorage === 'undefined') return DEFAULT_WORKSPACE_KIND; try { const raw = localStorage.getItem(WORKSPACE_KIND_STORAGE_KEY); return isWorkspaceKind(raw) ? raw : DEFAULT_WORKSPACE_KIND; } catch { return DEFAULT_WORKSPACE_KIND; } } const SELECTED_VAULT_STORAGE_KEY = 'textile.selectedVault'; function loadInitialSelectedVault(): string { if (typeof localStorage === 'undefined') return ''; try { return localStorage.getItem(SELECTED_VAULT_STORAGE_KEY) ?? ''; } catch { return ''; } } function AppShell() { const [theme, setTheme] = useState<'light' | 'dark'>(() => { if (typeof document === 'undefined') return 'light'; return document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'; }); const [sidebarOpen, setSidebarOpen] = useState(true); const [settingsOpen, setSettingsOpen] = useState(false); const [workspaceKind, setWorkspaceKind] = useState( loadInitialWorkspaceKind, ); const [openRequest, setOpenRequest] = useState(null); const [openRequestId, setOpenRequestId] = useState(0); const [selectedProviderId, setSelectedProviderId] = useState( loadInitialSelectedVault, ); const [vaultManagerOpen, setVaultManagerOpen] = useState(false); const [mountedVaults, setMountedVaults] = useState(() => vaultRegistry.getMounted(), ); const [syncTarget, setSyncTarget] = useState(null); const { authInfo } = useHabitatAuth(); const did = authInfo?.did ?? null; const isMac = getRendererPlatform() === 'darwin'; // Core action-system services — created once at app start and passed down. const { registry, hotkeyManager, hotkeyStore } = useMemo( () => createActionServices(isMac), [isMac], ); const navigate = useNavigate(); // Subscribe to vault registry changes. useEffect(() => { return vaultRegistry.subscribe(() => { setMountedVaults(vaultRegistry.getMounted()); }); }, []); const fileSystemProviders = useMemo(() => { const vault = window.textile?.vault; if (!vault) return []; const fs = new VaultFilesystemApi(vault); return mountedVaults.map((vaultEntry) => { const id = `vault-${vaultEntry.id}`; if (vaultEntry.state === 'synced') { const repo = new HabitatRepoImpl(vaultEntry.did); const snapshots = new VaultSnapshotManager(fs); const detector = new VaultChangeDetector(fs, repo); const engine = new VaultSyncEngine(fs, repo, snapshots, detector); const vaultConfig = toVaultConfig(vaultEntry); // First-mount clone + reconcile (out-of-band renames/deletes). void engine .sync(vaultConfig, vaultEntry.path) .catch((e) => console.error('[vault] initial sync failed', e)); const documents = new SyncedVaultDocumentsProvider({ id, vault: vaultConfig, rootPath: vaultEntry.path, engine, snapshots, fs, acquireSession: acquireDocSession, }); return new SyncedVaultFilesystemProvider({ id, rootPath: vaultEntry.path, displayName: vaultEntry.name, documents, defaultEditorKind: 'automerge', fs, vault: vaultConfig, engine, }); } return new LocalFilesystemProvider({ id, rootPath: vaultEntry.path, displayName: vaultEntry.name, documents: new VaultDocumentProvider({ fs, rootPath: vaultEntry.path }), defaultEditorKind: 'automerge', fs, }); }); }, [mountedVaults]); // Vault roots in the PDS, so we can surface ones not yet synced locally. const remoteRootsQuery = useQuery({ queryKey: ['remote-vault-roots', did], queryFn: () => vaultRegistry.listRemoteRoots(did!), enabled: !!did, }); const unsyncedVaults = useMemo( () => VaultRegistry.unsynced(remoteRootsQuery.data ?? [], mountedVaults), [remoteRootsQuery.data, mountedVaults], ); const handleSyncRemoteConfirm = useCallback( async (path: string) => { if (!syncTarget) return; const vaultApi = window.textile?.vault; if (!vaultApi) throw new Error('Filesystem unavailable'); const fs = new VaultFilesystemApi(vaultApi); const repo = new HabitatRepoImpl(syncTarget.did); const snapshots = new VaultSnapshotManager(fs); const detector = new VaultChangeDetector(fs, repo); const engine = new VaultSyncEngine(fs, repo, snapshots, detector); // Initial full clone into the chosen directory before opening it. await engine.sync(toVaultConfig(syncTarget), path); const id = vaultRegistry.idFromPath(path); vaultRegistry.add({ state: 'synced', id, path, name: syncTarget.name, did: syncTarget.did, rootUri: syncTarget.rootUri, }); setSelectedProviderId(`vault-${id}`); }, [syncTarget], ); const resolveProvider = useCallback( (providerId: string) => fileSystemProviders.find((p) => p.id === providerId), [fileSystemProviders], ); const tilingHandleRef = useRef(null); // Build ActionSets — each lives close to the code it adapts. // TODO: Add a vault-switching action that cycles through filesystem providers. // The action registry API needs to support additional input (e.g. typeahead options) // and a supplier for those options, so the command palette can show the list of // available providers when the user invokes the switcher. const actionSets = useMemo( () => [ new SidebarActionSet(setSidebarOpen), new SettingsActionSet(setSettingsOpen), new ThemeActionSet(setTheme), new NavigationActionSet(navigate), new WorkspaceActionSet(setWorkspaceKind), new TilingActionSet(tilingHandleRef), ], [setSidebarOpen, setSettingsOpen, setTheme, navigate, setWorkspaceKind], ); // Initialize selected provider to the first ready one. useEffect(() => { setSelectedProviderId((current) => { if (current) return current; return ( fileSystemProviders.find((p) => p.isReady())?.id ?? fileSystemProviders[0]?.id ?? '' ); }); }, [fileSystemProviders]); // If the selected provider disappears (e.g. a vault is closed), fall back. useEffect(() => { setSelectedProviderId((current) => { if (fileSystemProviders.some((p) => p.id === current)) { return current; } return ( fileSystemProviders.find((p) => p.isReady())?.id ?? fileSystemProviders[0]?.id ?? '' ); }); }, [fileSystemProviders]); useEffect(() => { const root = document.documentElement; if (theme === 'dark') { root.dataset.theme = 'dark'; } else { root.removeAttribute('data-theme'); } }, [theme]); useEffect(() => { const unsubscribe = window.textile?.onOpenSettings(() => { setSettingsOpen(true); }); return () => unsubscribe?.(); }, []); useEffect(() => { if (typeof localStorage === 'undefined') return; try { localStorage.setItem(WORKSPACE_KIND_STORAGE_KEY, workspaceKind); } catch { // Storage may be unavailable (e.g. private mode); preference simply won't persist. } }, [workspaceKind]); useEffect(() => { if (typeof localStorage === 'undefined' || !selectedProviderId) return; try { localStorage.setItem(SELECTED_VAULT_STORAGE_KEY, selectedProviderId); } catch { // Best effort — selection simply won't persist if storage is unavailable. } }, [selectedProviderId]); const handleOpenEntry = useCallback((request: OpenDocRequest) => { setOpenRequest(request); setOpenRequestId((id) => id + 1); }, []); /** * The document the active workspace is showing, reported up by that * workspace. This — not the last thing clicked in the sidebar — is what the * sidebar highlights, so every route to a document (sidebar, wikilink, * internal link, new file, split, tab switch, restored layout) keeps the * highlight correct without having to know the sidebar exists. */ const [activeDocument, setActiveDocument] = useState( null, ); // Split the active tile, then open the file — the new tile becomes active, so // the open lands in it. Splitting is a no-op when no tiling workspace is shown // (e.g. Zen), in which case the file simply opens normally. const handleOpenInSplit = useCallback( (request: OpenDocRequest, direction: 'right' | 'down') => { const handle = tilingHandleRef.current; if (direction === 'right') handle?.splitRight(); else handle?.splitDown(); handleOpenEntry(request); }, [handleOpenEntry], ); const handleSplitRight = useCallback( () => tilingHandleRef.current?.splitRight(), [], ); const handleSplitDown = useCallback( () => tilingHandleRef.current?.splitDown(), [], ); const handleNewFile = useCallback(async () => { const provider = resolveProvider(selectedProviderId); if (!provider) return; try { const entry = await provider.createFile(); handleOpenEntry({ providerId: provider.id, entryId: entry.id, title: entry.name, }); } catch (e) { console.error('[workspace] failed to create new file', e); } }, [resolveProvider, selectedProviderId, handleOpenEntry]); const handleOpenVault = useCallback( async ( vaultPath: string, vaultType: 'local' | 'synced', did: string | null, ) => { const id = vaultRegistry.idFromPath(vaultPath); const name = vaultPath.split('/').pop() || vaultPath; const providerId = `vault-${id}`; // Already open — just focus it (avoids duplicate registry entries). if (vaultRegistry.getMounted().some((v) => v.id === id)) { setSelectedProviderId(providerId); return; } if (vaultType === 'synced') { if (!did) { console.error( '[vault] cannot create a synced vault while signed out', ); return; } // Provision the vault's root directory record so the sync engine has a // rootUri to clone/sync against. let rootUri: HabitatUri; try { const { uri } = await createAutomergeDoc( did, { '@patchwork': { type: 'folder' }, docs: [], name }, HABITAT_VAULT_ROOT_COLLECTION, ); rootUri = uri as HabitatUri; } catch (e) { console.error('[vault] failed to provision synced vault root', e); return; } vaultRegistry.add({ state: 'synced', id, name, path: vaultPath, did, rootUri, }); } else { vaultRegistry.add({ state: 'local', id, name, path: vaultPath }); } // Auto-switch to the newly created vault — same path as the selector. setSelectedProviderId(providerId); }, [], ); return ( {/* Indexes each vault's files so `[[wikilinks]]` can resolve, and routes activated links back into the same open channel the sidebar uses. */}
setSidebarOpen((o) => !o)} workspaceKind={workspaceKind} onWorkspaceKindChange={setWorkspaceKind} onSplitRight={handleSplitRight} onSplitDown={handleSplitDown} />
setVaultManagerOpen(true)} onOpenEntry={handleOpenEntry} activeDocument={activeDocument} onOpenInSplit={handleOpenInSplit} unsyncedVaults={unsyncedVaults} onSyncRemote={setSyncTarget} />
p.id)} selectedVaultId={selectedProviderId} openRequestId={openRequestId} openRequest={openRequest} resolveProvider={resolveProvider} tilingHandleRef={tilingHandleRef} onNewFile={handleNewFile} onOpenEntry={handleOpenEntry} onActiveDocumentChange={setActiveDocument} /> } /> } />
setSettingsOpen(false)} theme={theme} onToggleTheme={() => setTheme((t) => (t === 'light' ? 'dark' : 'light')) } workspaceKind={workspaceKind} onWorkspaceKindChange={setWorkspaceKind} /> setVaultManagerOpen(false)} onOpenVault={handleOpenVault} onOpenSettings={() => setSettingsOpen(true)} /> setSyncTarget(null)} onConfirm={handleSyncRemoteConfirm} />
); } export default function App() { const isMac = getRendererPlatform() === 'darwin'; return ( {/* TODO: Replace this unsupported screen with full non-macOS UI path. */} {!isMac ? (
Textile currently supports macOS title bar integration only.
) : ( )}
); }