Something went wrong. Try again.
A local-first note taking app
Something went wrong. Try again.
TSX
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531import { 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<WorkspaceKind>( loadInitialWorkspaceKind, ); const [openRequest, setOpenRequest] = useState<OpenDocRequest | null>(null); const [openRequestId, setOpenRequestId] = useState(0); const [selectedProviderId, setSelectedProviderId] = useState<string>( loadInitialSelectedVault, ); const [vaultManagerOpen, setVaultManagerOpen] = useState(false); const [mountedVaults, setMountedVaults] = useState(() => vaultRegistry.getMounted(), ); const [syncTarget, setSyncTarget] = useState<RemoteVault | null>(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<FileSystemProvider[]>(() => { 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<TilingHandle | null>(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<OpenDocRequest | null>( 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 ( <ActionProvider actionSets={actionSets} registry={registry} hotkeyManager={hotkeyManager} hotkeyStore={hotkeyStore} > {/* Indexes each vault's files so `[[wikilinks]]` can resolve, and routes activated links back into the same open channel the sidebar uses. */} <WikiLinkVaultProvider providers={fileSystemProviders} onOpenEntry={handleOpenEntry} > <div className="bg-background text-foreground flex h-svh w-full flex-col overflow-hidden"> <AppTitleBar sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen((o) => !o)} workspaceKind={workspaceKind} onWorkspaceKindChange={setWorkspaceKind} onSplitRight={handleSplitRight} onSplitDown={handleSplitDown} /> <AppBottomBar> <div className="flex min-h-0 flex-1 pb-12" style={{ // Reserve the window's title bar row; custom chrome is `position: fixed` in that region. marginTop: 'calc(env(titlebar-area-y, 0px) + env(titlebar-area-height, 40px))', }} > <AppSidebar open={sidebarOpen} providers={fileSystemProviders} selectedProviderId={selectedProviderId} onSelectProviderId={setSelectedProviderId} onOpenVaultManager={() => setVaultManagerOpen(true)} onOpenEntry={handleOpenEntry} activeDocument={activeDocument} onOpenInSplit={handleOpenInSplit} unsyncedVaults={unsyncedVaults} onSyncRemote={setSyncTarget} /> <main className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden"> <Routes> <Route path="/" element={ <WorkspaceHost kind={workspaceKind} vaultIds={fileSystemProviders.map((p) => p.id)} selectedVaultId={selectedProviderId} openRequestId={openRequestId} openRequest={openRequest} resolveProvider={resolveProvider} tilingHandleRef={tilingHandleRef} onNewFile={handleNewFile} onOpenEntry={handleOpenEntry} onActiveDocumentChange={setActiveDocument} /> } /> <Route path="/about" element={<AboutPage />} /> </Routes> </main> </div> </AppBottomBar> <SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} theme={theme} onToggleTheme={() => setTheme((t) => (t === 'light' ? 'dark' : 'light')) } workspaceKind={workspaceKind} onWorkspaceKindChange={setWorkspaceKind} /> <VaultManager open={vaultManagerOpen} onClose={() => setVaultManagerOpen(false)} onOpenVault={handleOpenVault} onOpenSettings={() => setSettingsOpen(true)} /> <SyncRemoteVaultModal target={syncTarget} onClose={() => setSyncTarget(null)} onConfirm={handleSyncRemoteConfirm} /> </div> </WikiLinkVaultProvider> </ActionProvider> );}
export default function App() { const isMac = getRendererPlatform() === 'darwin';
return ( <QueryClientProvider client={queryClient}> <UserIdentityProvider> <ContextMenuProvider show={electronShowContextMenu}> <MemoryRouter initialEntries={['/']}> {/* TODO: Replace this unsupported screen with full non-macOS UI path. */} {!isMac ? ( <main className="bg-background text-foreground flex h-svh w-full items-center justify-center p-6"> <div className="border-border bg-background max-w-md rounded-md border p-4 text-sm"> Textile currently supports macOS title bar integration only. </div> </main> ) : ( <AppShell /> )} </MemoryRouter> </ContextMenuProvider> </UserIdentityProvider> </QueryClientProvider> );}