From 240055216ae0a1ceb7a7af92a96b667d6cb830fd Mon Sep 17 00:00:00 2001 From: Ethan Graf Date: Sun, 7 Jun 2026 15:19:47 -0400 Subject: [PATCH] Add sidebar vault selector --- src/App.tsx | 41 +++++++- src/actions/types.ts | 6 ++ src/components/AppSidebar.tsx | 190 ++++++++++++++++++++++++---------- src/icons/index.tsx | 19 ++++ 4 files changed, 200 insertions(+), 56 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 8c93ac4..d9b5252 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -79,6 +79,7 @@ function AppShell() { ); const [openRequest, setOpenRequest] = useState(null); const [openRequestId, setOpenRequestId] = useState(0); + const [selectedProviderId, setSelectedProviderId] = useState(''); const isMac = getRendererPlatform() === 'darwin'; @@ -124,6 +125,10 @@ function AppShell() { 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), @@ -133,9 +138,41 @@ function AppShell() { new WorkspaceActionSet(setWorkspaceKind), new TilingActionSet(tilingHandleRef), ], - [setSidebarOpen, setSettingsOpen, setTheme, navigate, setWorkspaceKind], + [ + 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') { @@ -192,6 +229,8 @@ function AppShell() {
diff --git a/src/actions/types.ts b/src/actions/types.ts index 462df65..475e124 100644 --- a/src/actions/types.ts +++ b/src/actions/types.ts @@ -12,6 +12,12 @@ export interface Hotkey { * Actions are self-contained closures over the state they manipulate. * They do not receive an app-wide context object; instead, each ActionSet * creates actions that close over the specific state/setters they need. + * + * TODO: Support actions that take additional input (e.g. a provider ID to + * switch to, a file path to open). The registry should accept a supplier + * function that provides typeahead options for the command palette, so the + * user can pick from a list of available providers/files when invoking the + * action. */ export interface ActionDefinition { /** Unique ID within the source namespace (e.g. 'sidebar-toggle'). */ diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 56f880c..3928d96 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -1,12 +1,20 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { FileSystemEntry, FileSystemProvider } from '../filesystem/types'; -import { DocFileIcon, SubtextIcon, NewDocumentIcon } from '../icons'; +import { DocFileIcon, NewDocumentIcon, StackedChevronsIcon } from '../icons'; import type { OpenDocRequest } from '../workspaces/workspace'; type AppSidebarProps = { open: boolean; providers: FileSystemProvider[]; + selectedProviderId: string; + onSelectProviderId: (id: string) => void; /** Emitted when the user picks an entry; the shell forwards it to the active workspace. */ onOpenEntry?: (request: OpenDocRequest) => void; }; @@ -44,7 +52,9 @@ function ProviderSection({ useEffect(() => { if (!provider.subscribeReady) return; return provider.subscribeReady(() => { - void queryClient.invalidateQueries({ queryKey: filesQueryKey(provider.id) }); + void queryClient.invalidateQueries({ + queryKey: filesQueryKey(provider.id), + }); }); }, [provider, queryClient]); @@ -87,28 +97,28 @@ function ProviderSection({ return (
-

+

{provider.displayName}

-
+
{!provider.isReady() ? ( -

+

{idleCopy}

) : query.isLoading ? ( -

Loading…

+

Loading…

) : query.isError ? ( -

+

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

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

+

No files.

) : ( -
+
{entries.map((entry) => { const isSelected = selection?.providerId === provider.id && @@ -131,14 +141,14 @@ function ProviderSection({ title: entry.name, }); }} - className={`font-inherit focus-visible:ring-accent/50 flex w-full min-w-0 shrink-0 cursor-pointer items-center gap-2 rounded-md border-0 px-2 py-1.5 text-left text-sm outline-none select-none focus-visible:ring-2 ${ + className={`font-inherit focus-visible:ring-accent/50 flex w-full min-w-0 shrink-0 cursor-pointer items-center gap-1.5 rounded border-0 px-1.5 py-1 text-left text-xs outline-none select-none focus-visible:ring-2 ${ isSelected ? 'bg-accent/10 text-foreground' : 'text-foreground/70 hover:bg-border/35 bg-transparent' }`} > {entry.name} @@ -151,16 +161,86 @@ function ProviderSection({ ); } -export function AppSidebar({ open, providers, onOpenEntry }: AppSidebarProps) { +/** Dropdown selector that tacks to the bottom of the sidebar. */ +function ProviderSelector({ + providers, + selectedId, + onSelect, +}: { + providers: FileSystemProvider[]; + selectedId: string; + onSelect: (id: string) => void; +}) { + const [open, setOpen] = useState(false); + const selected = providers.find((p) => p.id === selectedId); + const containerRef = useRef(null); + + useEffect(() => { + if (!open) return; + function handleClick(e: MouseEvent) { + if (!containerRef.current?.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [open]); + + return ( +
+ + {open && ( +
+ {providers.map((provider) => ( + + ))} +
+ )} +
+ ); +} + +export function AppSidebar({ + open, + providers, + selectedProviderId, + onSelectProviderId, + onOpenEntry, +}: AppSidebarProps) { const queryClient = useQueryClient(); const [selection, setSelection] = useState(null); const [creatingFor, setCreatingFor] = useState(null); const [createError, setCreateError] = useState(null); - const creatableProviders = useMemo( - () => providers.filter((p) => p.isReady()), - [providers], - ); + const selectedProvider = providers.find((p) => p.id === selectedProviderId); + + // Clear stale errors when switching providers. + useEffect(() => { + setCreateError(null); + }, [selectedProviderId]); const handleCreate = useCallback( async (provider: FileSystemProvider) => { @@ -183,7 +263,9 @@ export function AppSidebar({ open, providers, onOpenEntry }: AppSidebarProps) { entryId: entry.id, title: entry.name, }); - void queryClient.invalidateQueries({ queryKey: filesQueryKey(provider.id) }); + void queryClient.invalidateQueries({ + queryKey: filesQueryKey(provider.id), + }); } catch (e: unknown) { setCreateError( e instanceof Error ? e.message : 'Could not create document', @@ -210,56 +292,54 @@ export function AppSidebar({ open, providers, onOpenEntry }: AppSidebarProps) { aria-hidden={!open} >
- {creatableProviders.length > 0 ? ( -
- {creatableProviders.map((provider) => { - const isCreating = creatingFor === provider.id; - return ( - - ); - })} + {/* Top bar: create button for the selected provider only. */} + {selectedProvider && selectedProvider.isReady() ? ( +
+ {createError ? ( -

+

{createError}

) : null}
) : null} -
- {providers.map((provider) => ( + {/* Main scrollable area: only the selected provider. */} +
+ {selectedProvider ? ( - ))} + ) : providers.length === 0 ? ( +

+ No sources available. +

+ ) : null}
+ + {/* Bottom selector: tacked to the bottom of the sidebar. */} + {providers.length > 0 && ( +
+ +
+ )}
); diff --git a/src/icons/index.tsx b/src/icons/index.tsx index 56a609b..84df592 100644 --- a/src/icons/index.tsx +++ b/src/icons/index.tsx @@ -122,3 +122,22 @@ export function SearchIcon({ className }: IconProps) { ); } + +/** Two stacked chevrons: up above down. Used for source/directory selectors. */ +export function StackedChevronsIcon({ className }: IconProps) { + return ( + + + + + ); +} -- 2.51.2