diff --git a/package-lock.json b/package-lock.json index 9925941..f57f264 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,8 @@ "@codemirror/language": "^6.12.3", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.41.1", + "@lezer/highlight": "^1.2.3", + "@lezer/markdown": "^1.6.3", "@libp2p/circuit-relay-v2": "^3.2.24", "@libp2p/identify": "^3.0.39", "@libp2p/interface": "^2.11.0", diff --git a/src/App.tsx b/src/App.tsx index d9b5252..1757a77 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,17 @@ import { AutomergeFilesystemProvider } from './filesystem/providers/automergeFil import { HabitatFilesystemProvider } from './filesystem/providers/habitatFilesystemProvider'; import type { FileSystemProvider } from './filesystem/types'; import { SubtextFilesystemProvider } from './filesystem/providers/subtextFilesystemProvider'; +import { LocalFilesystemProvider } from './filesystem/providers/localFilesystemProvider'; +import { RemoteSyncFilesystemProvider } from './filesystem/providers/remoteSyncFilesystemProvider'; +import { VaultDocumentProvider } from './documents/providers/vaultDocumentsProvider'; +import { VaultFilesystemApi } from './filesystem/vaultFs'; +import { VaultManager } from './components/VaultManager'; +import { + getOpenVaults, + addOpenVault, + subscribeVaults, + vaultIdFromPath, +} from './vault/registry'; import { getRendererPlatform } from './lib/platform'; import { AboutPage } from './pages/AboutPage'; import { @@ -80,6 +91,8 @@ function AppShell() { const [openRequest, setOpenRequest] = useState(null); const [openRequestId, setOpenRequestId] = useState(0); const [selectedProviderId, setSelectedProviderId] = useState(''); + const [vaultManagerOpen, setVaultManagerOpen] = useState(false); + const [openVaults, setOpenVaults] = useState(() => getOpenVaults()); const isMac = getRendererPlatform() === 'darwin'; @@ -91,7 +104,14 @@ function AppShell() { const navigate = useNavigate(); - const fileSystemProviders = useMemo(() => { + // Subscribe to vault registry changes. + useEffect(() => { + return subscribeVaults(() => { + setOpenVaults(getOpenVaults()); + }); + }, []); + + const staticProviders = useMemo(() => { const automergeDocuments = new AutomergeDocumentsProvider(); const habitatDocuments = new HabitatDocumentsProvider(); const providers: FileSystemProvider[] = [ @@ -104,8 +124,6 @@ function AppShell() { defaultEditorKind: 'automerge', }), ]; - // TODO: When the Vault concept is introduced, each vault will choose its own - // source; this conditional registration will move to per-vault configuration. if (isSubtextEnabled()) { providers.push( new SubtextFilesystemProvider({ @@ -116,6 +134,37 @@ function AppShell() { } return providers; }, []); + + const vaultProviders = useMemo(() => { + const vault = window.textile?.vault; + if (!vault) return []; + const fs = new VaultFilesystemApi(vault); + return openVaults.map((vaultEntry) => { + if (vaultEntry.type === 'synced') { + return new RemoteSyncFilesystemProvider({ + id: `vault-${vaultEntry.id}`, + rootPath: vaultEntry.path, + displayName: vaultEntry.name, + documents: new VaultDocumentProvider({ fs, rootPath: vaultEntry.path }), + defaultEditorKind: 'automerge', + }); + } + return new LocalFilesystemProvider({ + id: `vault-${vaultEntry.id}`, + rootPath: vaultEntry.path, + displayName: vaultEntry.name, + documents: new VaultDocumentProvider({ fs, rootPath: vaultEntry.path }), + defaultEditorKind: 'automerge', + fs, + }); + }); + }, [openVaults]); + + const fileSystemProviders = useMemo( + () => [...staticProviders, ...vaultProviders], + [staticProviders, vaultProviders], + ); + const resolveProvider = useCallback( (providerId: string) => fileSystemProviders.find((p) => p.id === providerId), @@ -203,6 +252,18 @@ function AppShell() { setOpenRequestId((id) => id + 1); }, []); + const handleOpenVault = useCallback( + (vaultPath: string, vaultType: 'local' | 'synced', did: string | null) => { + const id = vaultIdFromPath(vaultPath); + const name = vaultPath.split('/').pop() || vaultPath; + const providerId = `vault-${id}`; + addOpenVault({ id, path: vaultPath, type: vaultType, name, did }); + // Auto-switch to the newly created vault — same path as the selector. + setSelectedProviderId(providerId); + }, + [], + ); + return ( setVaultManagerOpen(true)} onOpenEntry={handleOpenEntry} />
@@ -262,6 +324,12 @@ function AppShell() { workspaceKind={workspaceKind} onWorkspaceKindChange={setWorkspaceKind} /> + setVaultManagerOpen(false)} + onOpenVault={handleOpenVault} + onOpenSettings={() => setSettingsOpen(true)} + /> ); diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 3928d96..b726b88 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -6,15 +6,18 @@ import { useState, } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import type { FileSystemEntry, FileSystemProvider } from '../filesystem/types'; -import { DocFileIcon, NewDocumentIcon, StackedChevronsIcon } from '../icons'; +import type { FileSystemEntry, FileSystemProvider, FileSystemTreeNode } from '../filesystem/types'; +import { DocFileIcon, NewDocumentIcon, NewDirectoryIcon, StackedChevronsIcon, PlusIcon } from '../icons'; import type { OpenDocRequest } from '../workspaces/workspace'; +import { FileSystemTree } from './VaultTree'; type AppSidebarProps = { open: boolean; providers: FileSystemProvider[]; selectedProviderId: string; onSelectProviderId: (id: string) => void; + /** Emitted when the user wants to open/create a new vault. */ + onOpenVaultManager?: () => void; /** Emitted when the user picks an entry; the shell forwards it to the active workspace. */ onOpenEntry?: (request: OpenDocRequest) => void; }; @@ -25,11 +28,22 @@ function filesQueryKey(providerId: string) { return ['files', providerId] as const; } +function treeQueryKey(providerId: string) { + return ['tree', providerId] as const; +} + type ProviderSectionProps = { provider: FileSystemProvider; selection: Selection; onSelect: (sel: Selection) => void; onOpenEntry?: (request: OpenDocRequest) => void; + creatingDir: boolean; + newDirName: string; + newDirError: string | null; + onNewDirNameChange: (name: string) => void; + onNewDirErrorChange: (error: string | null) => void; + onNewDirSubmit: (name: string) => void; + onNewDirCancel: () => void; }; function ProviderSection({ @@ -37,124 +51,130 @@ function ProviderSection({ selection, onSelect, onOpenEntry, + creatingDir, + newDirName, + newDirError, + onNewDirNameChange, + onNewDirErrorChange, + onNewDirSubmit, + onNewDirCancel, }: ProviderSectionProps) { const queryClient = useQueryClient(); + const inputRef = useRef(null); - // Core listing query. - const query = useQuery({ - queryKey: filesQueryKey(provider.id), - queryFn: () => provider.listFiles(), + // Tree query for all providers — flat sources return single-level trees. + const treeQuery = useQuery({ + queryKey: treeQueryKey(provider.id), + queryFn: () => provider.getTree(), enabled: provider.isReady(), + refetchInterval: 3_000, }); - // Invalidate (re-fetch) when the provider transitions from not-ready → ready, - // e.g. the user signs in. + // Invalidate when provider becomes ready. useEffect(() => { if (!provider.subscribeReady) return; return provider.subscribeReady(() => { - void queryClient.invalidateQueries({ - queryKey: filesQueryKey(provider.id), - }); - }); - }, [provider, queryClient]); - - // Patch individual entry names in the cache as the user edits document - // headings, without requiring a full re-fetch. - useEffect(() => { - if (!provider.subscribeEntryTitles) return; - return provider.subscribeEntryTitles((entryId, name) => { - queryClient.setQueryData( - filesQueryKey(provider.id), - (entries) => { - if (!entries) return entries; - const index = entries.findIndex((e) => e.id === entryId); - if (index < 0 || entries[index]?.name === name) return entries; - const next = entries.slice(); - next[index] = { id: entryId, name }; - return next; - }, - ); + void queryClient.invalidateQueries({ queryKey: treeQueryKey(provider.id) }); }); }, [provider, queryClient]); - // Clear the selection if the selected entry disappears from this provider's - // listing (e.g. deleted remotely). + // Focus inline input when entering directory creation mode. useEffect(() => { - if (!selection || selection.providerId !== provider.id) return; - if (query.isPending) return; - if (query.data && !query.data.some((e) => e.id === selection.entryId)) { - onSelect(null); + if (creatingDir) { + inputRef.current?.focus(); } - }, [query.data, query.isPending, selection, provider.id, onSelect]); + }, [creatingDir]); - const entries = useMemo( - () => [...(query.data ?? [])].sort((a, b) => a.name.localeCompare(b.name)), - [query.data], + const isSelected = (nodeId: string) => + selection?.providerId === provider.id && selection.entryId === nodeId; + + const handleSelect = useCallback( + (node: { id: string; name: string; type: 'file' | 'folder' }) => { + if (node.type === 'file') { + onSelect({ providerId: provider.id, entryId: node.id }); + onOpenEntry?.({ + providerId: provider.id, + entryId: node.id, + title: node.name, + }); + } + }, + [provider.id, onSelect, onOpenEntry], ); - const idleCopy = - provider.idleHint ?? - 'Connect an account in Settings (⌘,) to see files here.'; + + const handleDirInputChange = (value: string) => { + onNewDirNameChange(value); + const trimmed = value.trim(); + const exists = treeQuery.data?.some((node) => node.name === trimmed); + onNewDirErrorChange(exists && trimmed.length > 0 ? `A directory named "${trimmed}" already exists` : null); + }; + + const handleDirKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + const trimmed = newDirName.trim(); + if (trimmed.length > 0 && !newDirError) { + onNewDirSubmit(trimmed); + } + } else if (e.key === 'Escape') { + onNewDirCancel(); + } + }; return (
-

+

{provider.displayName}

{!provider.isReady() ? ( -

- {idleCopy} -

- ) : query.isLoading ? ( -

Loading…

- ) : query.isError ? ( -

- {query.error instanceof Error - ? query.error.message - : 'Failed to load files'} +

+ {provider.idleHint}

- ) : entries.length === 0 ? ( -

- No files. + ) : treeQuery.isLoading ? ( +

Loading…

+ ) : treeQuery.isError ? ( +

+ {treeQuery.error instanceof Error + ? treeQuery.error.message + : 'Failed to load'}

) : ( -
- {entries.map((entry) => { - const isSelected = - selection?.providerId === provider.id && - selection.entryId === entry.id; - return ( - - ); - })} -
+
+ {newDirError && ( +

+ {newDirError} +

+ )} + + )} + + {treeQuery.data?.length === 0 && !creatingDir && ( +

+ No files. +

+ )} + )}
@@ -194,7 +214,7 @@ function ProviderSelector({ className="flex w-full items-center gap-2 rounded-md px-2 py-1 text-xs hover:bg-border/20 transition-colors cursor-pointer" > - + {selected?.displayName ?? 'Select source'} @@ -228,28 +248,38 @@ export function AppSidebar({ providers, selectedProviderId, onSelectProviderId, + onOpenVaultManager, onOpenEntry, }: AppSidebarProps) { const queryClient = useQueryClient(); const [selection, setSelection] = useState(null); const [creatingFor, setCreatingFor] = useState(null); const [createError, setCreateError] = useState(null); + const [creatingDir, setCreatingDir] = useState(false); + const [newDirName, setNewDirName] = useState(''); + const [newDirError, setNewDirError] = useState(null); const selectedProvider = providers.find((p) => p.id === selectedProviderId); - // Clear stale errors when switching providers. + // Clear stale errors and creation state when switching providers. useEffect(() => { setCreateError(null); + setCreatingDir(false); + setNewDirName(''); + setNewDirError(null); }, [selectedProviderId]); + const sortTreeNodes = (a: FileSystemTreeNode, b: FileSystemTreeNode): number => { + if (a.type === b.type) return a.name.localeCompare(b.name); + return a.type === 'folder' ? -1 : 1; + }; + const handleCreate = useCallback( async (provider: FileSystemProvider) => { setCreateError(null); setCreatingFor(provider.id); try { const entry = await provider.createFile(); - // Optimistically insert so the selection effect doesn't race and clear - // the selection before the invalidation re-fetch resolves. queryClient.setQueryData( filesQueryKey(provider.id), (entries) => @@ -257,15 +287,24 @@ export function AppSidebar({ ? [...entries, entry] : (entries ?? [entry]), ); + // Optimistically insert into tree cache. + queryClient.setQueryData( + treeQueryKey(provider.id), + (nodes) => { + const newNode: FileSystemTreeNode = { id: entry.id, name: entry.name, type: 'file' }; + if (!nodes) return [newNode]; + const next = [...nodes, newNode]; + next.sort(sortTreeNodes); + return next; + }, + ); setSelection({ providerId: provider.id, entryId: entry.id }); onOpenEntry?.({ providerId: provider.id, entryId: entry.id, title: entry.name, }); - void queryClient.invalidateQueries({ - queryKey: filesQueryKey(provider.id), - }); + void queryClient.invalidateQueries({ queryKey: treeQueryKey(provider.id) }); } catch (e: unknown) { setCreateError( e instanceof Error ? e.message : 'Could not create document', @@ -277,6 +316,37 @@ export function AppSidebar({ [queryClient, setSelection, onOpenEntry], ); + const handleCreateDirectory = useCallback( + async (name: string) => { + if (!selectedProvider) return; + try { + const entry = await selectedProvider.createDirectory(name); + // Optimistically insert into tree cache. + queryClient.setQueryData( + treeQueryKey(selectedProvider.id), + (nodes) => { + const newNode: FileSystemTreeNode = { id: entry.id, name: entry.name, type: 'folder', children: [] }; + if (!nodes) return [newNode]; + const next = [...nodes, newNode]; + next.sort(sortTreeNodes); + return next; + }, + ); + setCreatingDir(false); + setNewDirName(''); + setNewDirError(null); + void queryClient.invalidateQueries({ queryKey: treeQueryKey(selectedProvider.id) }); + } catch (e: unknown) { + setNewDirError( + e instanceof Error ? e.message : 'Could not create directory', + ); + } + }, + [selectedProvider, queryClient], + ); + + const supportsDirectory = !!selectedProvider; + return (