feat: show vault root in folder tree

This commit is contained in:
lucaronin
2026-04-30 00:18:33 +02:00
parent ce859aa0e7
commit ec83e9b839
17 changed files with 442 additions and 159 deletions

View File

@@ -301,14 +301,14 @@ type SidebarFilter = 'all' | 'archived' | 'changes' | 'pulse'
type SidebarSelection =
| { kind: 'filter'; filter: SidebarFilter }
| { kind: 'sectionGroup'; type: string } // e.g. type: 'Project'
| { kind: 'folder'; path: string }
| { kind: 'folder'; path: string; rootPath?: string }
| { kind: 'entity'; entry: VaultEntry } // Neighborhood source note
| { kind: 'view'; filename: string }
```
`SidebarSelection.kind === 'folder'` is a first-class navigation target, not just a visual highlight.
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated. Non-mutating reveal/copy-path menu items stay callback-driven from `App` so filesystem convenience actions do not leak into folder mutation hooks.
- `FolderTree` keeps the folder interaction surface decomposed into `FolderTreeRow`, `FolderNameInput`, `FolderContextMenu`, and disclosure/context-menu hooks so nested row rendering, inline rename, and right-click actions stay isolated. The UI wraps backend folder nodes in a synthetic vault-root row with `path: ""` and `rootPath` set to the opened vault so root-level files can be listed without turning the vault root into a mutable folder. Non-mutating reveal/copy-path menu items stay callback-driven from `App` so filesystem convenience actions do not leak into folder mutation hooks.
- `useFolderActions()` composes `useFolderRename()` and `useFolderDelete()` to keep folder mutations selection-aware while the rest of `App.tsx` only wires the resulting callbacks into `Sidebar` and the command registry.
- `useNoteRetargeting()` is the shared retargeting abstraction for note drops and command-palette actions. It owns the "can drop here?" checks, updates `type:` via frontmatter when a note lands on a type section, and delegates folder moves through the same crash-safe rename pipeline used by the backend rename commands.
- A successful folder rename reloads the folder tree plus vault entries, rewrites any affected folder-scoped tabs, and updates `SidebarSelection` to the new relative path when the renamed folder stays selected.
@@ -352,7 +352,7 @@ The renderer uses `viewOrdering` helpers to convert drag or command-palette move
5. Sorts by `modified_at` descending
6. Skips unparseable files with a warning log
The folder tree hides the legacy `type/` directory, since those type documents already appear through the Types sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders.
The folder tree hides the legacy `type/` directory, since those type documents already appear through the Types sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders under the synthetic vault-root row.
Command-facing vault content is filtered through `vault::filter_gitignored_entries`, `vault::filter_gitignored_folders`, and `vault::filter_gitignored_paths` when the app setting `hide_gitignored_files` is enabled. The cache still stores the complete scan; `list_vault`, `reload_vault`, `list_vault_folders`, and search apply the visibility filter at the boundary before React consumes entries. The filter batches paths through `git check-ignore --no-index --stdin`, so negated and specific `.gitignore` patterns follow Git semantics as closely as the app can reasonably support.

View File

@@ -201,7 +201,7 @@ flowchart TD
└──────────────────────────────────────────────────────────────┘
```
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree shows user-created folders plus default vault folders such as `attachments/` and `views/`; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, while keyboard users can use command-palette move actions. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root.
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, while keyboard users can use command-palette move actions. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root.
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Folder-backed lists also show non-Markdown files: previewable image and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count and rich-editor width toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Binary image and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; external-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).

View File

@@ -1622,7 +1622,7 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} onReorderViews={viewOrdering.onReorderViews} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} locale={appLocale} loading={isVaultContentLoading} />
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} onReorderViews={viewOrdering.onReorderViews} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} locale={appLocale} loading={isVaultContentLoading} vaultRootPath={resolvedPath} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>

View File

@@ -19,6 +19,7 @@ const mockFolders: FolderNode[] = [
]
const defaultSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
const vaultRootPath = '/Users/luca/Laputa'
describe('FolderTree', () => {
it('renders nothing when folders is empty', () => {
@@ -36,6 +37,53 @@ describe('FolderTree', () => {
expect(screen.getByText('journal')).toBeInTheDocument()
})
it('renders the vault root as the top-level folder when a vault path is available', () => {
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={vi.fn()}
vaultRootPath={vaultRootPath}
/>,
)
expect(screen.getByText('Laputa')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Laputa' })).toBeInTheDocument()
expect(screen.getByText('projects')).toBeInTheDocument()
expect(screen.getByText('areas')).toBeInTheDocument()
expect(screen.getByText('journal')).toBeInTheDocument()
})
it('renders the vault root even when the vault has no subfolders', () => {
render(
<FolderTree
folders={[]}
selection={defaultSelection}
onSelect={vi.fn()}
vaultRootPath={vaultRootPath}
/>,
)
expect(screen.getByText('Laputa')).toBeInTheDocument()
})
it('lets the vault root collapse and expand its nested folders', () => {
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={vi.fn()}
vaultRootPath={vaultRootPath}
/>,
)
fireEvent.click(screen.getByLabelText('Collapse Laputa'))
expect(screen.queryByText('projects')).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Expand Laputa'))
expect(screen.getByText('projects')).toBeInTheDocument()
})
it('expands children when clicking the folder chevron', () => {
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
expect(screen.queryByText('laputa')).not.toBeInTheDocument()
@@ -51,6 +99,21 @@ describe('FolderTree', () => {
expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: 'projects' })
})
it('selects the vault root with the root path attached', () => {
const onSelect = vi.fn()
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={onSelect}
vaultRootPath={vaultRootPath}
/>,
)
fireEvent.click(screen.getByTestId('folder-row:'))
expect(onSelect).toHaveBeenCalledWith({ kind: 'folder', path: '', rootPath: vaultRootPath })
})
it('expands children when single-clicking a folder row with children', () => {
vi.useFakeTimers()
function FolderTreeHarness() {
@@ -248,4 +311,33 @@ describe('FolderTree', () => {
fireEvent.click(screen.getByTestId('copy-folder-path-menu-item'))
expect(onCopyFolderPath).toHaveBeenCalledWith('projects')
})
it('keeps destructive folder actions off the vault root row and menu', () => {
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={vi.fn()}
folderFileActions={{
copyFolderPath: vi.fn(),
revealFolder: vi.fn(),
}}
onDeleteFolder={vi.fn()}
onRenameFolder={vi.fn().mockResolvedValue(true)}
onStartRenameFolder={vi.fn()}
onCancelRenameFolder={vi.fn()}
vaultRootPath={vaultRootPath}
/>,
)
expect(screen.queryByTestId('rename-folder-btn:')).not.toBeInTheDocument()
expect(screen.queryByTestId('delete-folder-btn:')).not.toBeInTheDocument()
fireEvent.contextMenu(screen.getByText('Laputa'))
expect(screen.getByTestId('reveal-folder-menu-item')).toBeInTheDocument()
expect(screen.getByTestId('copy-folder-path-menu-item')).toBeInTheDocument()
expect(screen.queryByText('Rename folder...')).not.toBeInTheDocument()
expect(screen.queryByTestId('delete-folder-menu-item')).not.toBeInTheDocument()
})
})

View File

@@ -1,6 +1,8 @@
import {
memo,
type MouseEvent as ReactMouseEvent,
useCallback,
useMemo,
} from 'react'
import {
Plus,
@@ -30,6 +32,53 @@ interface FolderTreeProps {
collapsed?: boolean
locale?: AppLocale
onToggle?: () => void
vaultRootPath?: string
}
interface FolderTreeBodyProps extends Pick<
FolderTreeProps,
| 'locale'
| 'onCancelRenameFolder'
| 'onDeleteFolder'
| 'onRenameFolder'
| 'onSelect'
| 'onStartRenameFolder'
| 'renamingFolderPath'
| 'selection'
> {
displayedExpanded: Record<string, boolean>
displayedFolders: FolderNode[]
isCreating: boolean
onCancelCreateFolder: () => void
onCreateFolderSubmit: (value: string) => Promise<boolean>
rootPath?: string
sectionCollapsed: boolean
toggleFolder: (path: string) => void
onOpenMenu: (node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => void
}
function vaultRootLabel(vaultRootPath: string, locale: AppLocale): string {
const trimmed = vaultRootPath.trim().replace(/[\\/]+$/g, '')
return trimmed.split(/[\\/]/).filter(Boolean).pop() || translate(locale, 'status.vault.default')
}
function buildRootNode(folders: FolderNode[], vaultRootPath: string | undefined, locale: AppLocale): FolderNode | null {
if (!vaultRootPath?.trim()) return null
return {
name: vaultRootLabel(vaultRootPath, locale),
path: '',
children: folders,
}
}
function useDisplayedFolders(folders: FolderNode[], expanded: Record<string, boolean>, vaultRootPath: string | undefined, locale: AppLocale) {
return useMemo(() => {
const rootNode = buildRootNode(folders, vaultRootPath, locale)
return {
displayedExpanded: rootNode ? { '': true, ...expanded } : expanded,
displayedFolders: rootNode ? [rootNode] : folders,
}
}, [expanded, folders, locale, vaultRootPath])
}
export const FolderTree = memo(function FolderTree({
@@ -46,6 +95,7 @@ export const FolderTree = memo(function FolderTree({
collapsed: externalCollapsed,
locale = 'en',
onToggle,
vaultRootPath,
}: FolderTreeProps) {
const {
closeCreateForm,
@@ -93,7 +143,9 @@ export const FolderTree = memo(function FolderTree({
openCreateForm()
}, [closeContextMenu, openCreateForm])
if (folders.length === 0 && !isCreating) return null
const { displayedExpanded, displayedFolders } = useDisplayedFolders(folders, expanded, vaultRootPath, locale)
if (displayedFolders.length === 0 && !isCreating) return null
return (
<div className="border-b border-border" style={{ padding: '0 6px' }}>
@@ -102,41 +154,25 @@ export const FolderTree = memo(function FolderTree({
<CreateFolderButton locale={locale} onCreate={handleCreateFolderClick} />
)}
</SidebarGroupHeader>
{!sectionCollapsed && (
<div className="flex flex-col gap-0.5 pb-2">
{folders.map((node) => (
<FolderTreeRow
key={node.path}
depth={0}
expanded={expanded}
node={node}
onDeleteFolder={onDeleteFolder}
onOpenMenu={handleOpenMenu}
onRenameFolder={onRenameFolder}
onSelect={onSelect}
onStartRenameFolder={onStartRenameFolder}
onToggle={toggleFolder}
onCancelRenameFolder={onCancelRenameFolder}
locale={locale}
renamingFolderPath={renamingFolderPath}
selection={selection}
/>
))}
{isCreating && (
<div style={{ paddingLeft: 8 }}>
<FolderNameInput
ariaLabel={translate(locale, 'sidebar.folder.newName')}
initialValue=""
placeholder={translate(locale, 'sidebar.folder.name')}
submitOnBlur={true}
testId="new-folder-input"
onCancel={closeCreateForm}
onSubmit={handleCreateFolderSubmit}
/>
</div>
)}
</div>
)}
<FolderTreeBody
displayedExpanded={displayedExpanded}
displayedFolders={displayedFolders}
isCreating={isCreating}
locale={locale}
onCancelCreateFolder={closeCreateForm}
onCancelRenameFolder={onCancelRenameFolder}
onCreateFolderSubmit={handleCreateFolderSubmit}
onDeleteFolder={onDeleteFolder}
onOpenMenu={handleOpenMenu}
onRenameFolder={onRenameFolder}
onSelect={onSelect}
onStartRenameFolder={onStartRenameFolder}
renamingFolderPath={renamingFolderPath}
rootPath={vaultRootPath}
sectionCollapsed={sectionCollapsed}
selection={selection}
toggleFolder={toggleFolder}
/>
<FolderContextMenu
menu={contextMenu}
menuRef={menuRef}
@@ -150,6 +186,65 @@ export const FolderTree = memo(function FolderTree({
)
})
function FolderTreeBody({
displayedExpanded,
displayedFolders,
isCreating,
locale = 'en',
onCancelCreateFolder,
onCancelRenameFolder,
onCreateFolderSubmit,
onDeleteFolder,
onOpenMenu,
onRenameFolder,
onSelect,
onStartRenameFolder,
renamingFolderPath,
rootPath,
sectionCollapsed,
selection,
toggleFolder,
}: FolderTreeBodyProps) {
if (sectionCollapsed) return null
return (
<div className="flex flex-col gap-0.5 pb-2">
{displayedFolders.map((node) => (
<FolderTreeRow
key={node.path}
depth={0}
expanded={displayedExpanded}
node={node}
onDeleteFolder={onDeleteFolder}
onOpenMenu={onOpenMenu}
onRenameFolder={onRenameFolder}
onSelect={onSelect}
onStartRenameFolder={onStartRenameFolder}
onToggle={toggleFolder}
onCancelRenameFolder={onCancelRenameFolder}
locale={locale}
renamingFolderPath={renamingFolderPath}
rootPath={rootPath}
selection={selection}
/>
))}
{isCreating && (
<div style={{ paddingLeft: 8 }}>
<FolderNameInput
ariaLabel={translate(locale, 'sidebar.folder.newName')}
initialValue=""
placeholder={translate(locale, 'sidebar.folder.name')}
submitOnBlur={true}
testId="new-folder-input"
onCancel={onCancelCreateFolder}
onSubmit={onCreateFolderSubmit}
/>
</div>
)}
</div>
)
}
function CreateFolderButton({
locale,
onCreate,

View File

@@ -57,6 +57,7 @@ interface SidebarProps {
renamingFolderPath?: string | null
onStartRenameFolder?: (folderPath: string) => void
onCancelRenameFolder?: () => void
vaultRootPath?: string
showInbox?: boolean
inboxCount?: number
locale?: AppLocale
@@ -84,6 +85,7 @@ interface SidebarNavigationProps extends Pick<
| 'renamingFolderPath'
| 'onStartRenameFolder'
| 'onCancelRenameFolder'
| 'vaultRootPath'
| 'showInbox'
| 'inboxCount'
| 'onCreateNewType'
@@ -166,6 +168,7 @@ type SidebarFoldersNavigationProps = Pick<
| 'renamingFolderPath'
| 'onStartRenameFolder'
| 'onCancelRenameFolder'
| 'vaultRootPath'
| 'groupCollapsed'
| 'toggleGroup'
| 'locale'
@@ -313,6 +316,7 @@ function SidebarFoldersNavigation({
renamingFolderPath,
onStartRenameFolder,
onCancelRenameFolder,
vaultRootPath,
groupCollapsed,
toggleGroup,
locale,
@@ -343,6 +347,7 @@ function SidebarFoldersNavigation({
collapsed={groupCollapsed.folders}
locale={locale}
onToggle={() => toggleGroup('folders')}
vaultRootPath={vaultRootPath}
/>
)
}
@@ -366,6 +371,7 @@ function SidebarNavigation({
renamingFolderPath,
onStartRenameFolder,
onCancelRenameFolder,
vaultRootPath,
showInbox = true,
inboxCount = 0,
locale = 'en',
@@ -458,6 +464,7 @@ function SidebarNavigation({
renamingFolderPath={renamingFolderPath}
onStartRenameFolder={onStartRenameFolder}
onCancelRenameFolder={onCancelRenameFolder}
vaultRootPath={vaultRootPath}
groupCollapsed={groupCollapsed}
toggleGroup={toggleGroup}
locale={locale}
@@ -473,7 +480,7 @@ function useSidebarDndSensors() {
)
}
export const Sidebar = memo(function Sidebar({
function useSidebarRuntime({
entries,
selection,
onSelect,
@@ -482,27 +489,7 @@ export const Sidebar = memo(function Sidebar({
onReorderSections,
onRenameSection,
onToggleTypeVisibility,
onSelectFavorite,
onReorderFavorites,
views = [],
onCreateView,
onEditView,
onDeleteView,
onReorderViews,
folders = [],
onCreateFolder,
onRenameFolder,
onDeleteFolder,
folderFileActions,
renamingFolderPath,
onStartRenameFolder,
onCancelRenameFolder,
showInbox = true,
inboxCount = 0,
locale = 'en',
onCollapse,
onCreateNewType,
loading = false,
}: SidebarProps) {
const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries)
const { activeCount, archivedCount } = useEntryCounts(entries)
@@ -526,7 +513,6 @@ export const Sidebar = memo(function Sidebar({
const reordered = computeReorder(sectionIds, active.id as string, over.id as string)
if (reordered) onReorderSections?.(reordered.map((typeName, order) => ({ typeName, order })))
}, [sectionIds, onReorderSections])
const viewActions = { onCreateView, onEditView, onDeleteView, onReorderViews }
const sectionProps: SidebarSectionProps = {
entries,
@@ -540,60 +526,112 @@ export const Sidebar = memo(function Sidebar({
onRenameCancel: typeInteractions.cancelRename,
}
return {
activeCount,
allSectionGroups,
archivedCount,
groupCollapsed,
handleDragEnd,
isSectionVisible,
sectionIds,
sectionProps,
sensors,
toggleGroup,
toggleVisibility,
typeEntryMap,
typeInteractions,
visibleSections,
}
}
function SidebarRuntimeNavigation({
props,
runtime,
}: {
props: SidebarProps
runtime: ReturnType<typeof useSidebarRuntime>
}) {
return (
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground">
<SidebarTitleBar locale={locale} onCollapse={onCollapse} />
<SidebarNavigation
entries={entries}
selection={selection}
onSelect={onSelect}
onSelectFavorite={onSelectFavorite}
onReorderFavorites={onReorderFavorites}
views={views}
{...viewActions}
folders={folders}
onCreateFolder={onCreateFolder}
onRenameFolder={onRenameFolder}
onDeleteFolder={onDeleteFolder}
folderFileActions={folderFileActions}
renamingFolderPath={renamingFolderPath}
onStartRenameFolder={onStartRenameFolder}
onCancelRenameFolder={onCancelRenameFolder}
showInbox={showInbox}
inboxCount={inboxCount}
locale={locale}
loading={loading}
onCreateNewType={onCreateNewType}
activeCount={activeCount}
archivedCount={archivedCount}
groupCollapsed={groupCollapsed}
toggleGroup={toggleGroup}
visibleSections={visibleSections}
allSectionGroups={allSectionGroups}
sectionIds={sectionIds}
sensors={sensors}
handleDragEnd={handleDragEnd}
sectionProps={sectionProps}
typeInteractions={typeInteractions}
isSectionVisible={isSectionVisible}
toggleVisibility={toggleVisibility}
/>
<SidebarNavigation
entries={props.entries}
selection={props.selection}
onSelect={props.onSelect}
onSelectFavorite={props.onSelectFavorite}
onReorderFavorites={props.onReorderFavorites}
views={props.views}
onCreateView={props.onCreateView}
onEditView={props.onEditView}
onDeleteView={props.onDeleteView}
onReorderViews={props.onReorderViews}
folders={props.folders}
onCreateFolder={props.onCreateFolder}
onRenameFolder={props.onRenameFolder}
onDeleteFolder={props.onDeleteFolder}
folderFileActions={props.folderFileActions}
renamingFolderPath={props.renamingFolderPath}
onStartRenameFolder={props.onStartRenameFolder}
onCancelRenameFolder={props.onCancelRenameFolder}
vaultRootPath={props.vaultRootPath}
showInbox={props.showInbox}
inboxCount={props.inboxCount}
locale={props.locale}
loading={props.loading}
onCreateNewType={props.onCreateNewType}
activeCount={runtime.activeCount}
archivedCount={runtime.archivedCount}
groupCollapsed={runtime.groupCollapsed}
toggleGroup={runtime.toggleGroup}
visibleSections={runtime.visibleSections}
allSectionGroups={runtime.allSectionGroups}
sectionIds={runtime.sectionIds}
sensors={runtime.sensors}
handleDragEnd={runtime.handleDragEnd}
sectionProps={runtime.sectionProps}
typeInteractions={runtime.typeInteractions}
isSectionVisible={runtime.isSectionVisible}
toggleVisibility={runtime.toggleVisibility}
/>
)
}
function SidebarInteractionOverlays({
locale,
runtime,
}: {
locale: AppLocale
runtime: ReturnType<typeof useSidebarRuntime>
}) {
return (
<>
<ContextMenuOverlay
pos={typeInteractions.contextMenuPos}
type={typeInteractions.contextMenuType}
innerRef={typeInteractions.contextMenuRef}
onOpenCustomize={typeInteractions.openCustomizeTarget}
onStartRename={typeInteractions.handleStartRename}
pos={runtime.typeInteractions.contextMenuPos}
type={runtime.typeInteractions.contextMenuType}
innerRef={runtime.typeInteractions.contextMenuRef}
onOpenCustomize={runtime.typeInteractions.openCustomizeTarget}
onStartRename={runtime.typeInteractions.handleStartRename}
locale={locale}
/>
<CustomizeOverlay
target={typeInteractions.customizeTarget}
typeEntryMap={typeEntryMap}
innerRef={typeInteractions.popoverRef}
onCustomize={typeInteractions.handleCustomize}
onChangeTemplate={typeInteractions.handleChangeTemplate}
onClose={typeInteractions.closeCustomizeTarget}
target={runtime.typeInteractions.customizeTarget}
typeEntryMap={runtime.typeEntryMap}
innerRef={runtime.typeInteractions.popoverRef}
onCustomize={runtime.typeInteractions.handleCustomize}
onChangeTemplate={runtime.typeInteractions.handleChangeTemplate}
onClose={runtime.typeInteractions.closeCustomizeTarget}
/>
</>
)
}
export const Sidebar = memo(function Sidebar(props: SidebarProps) {
const locale = props.locale ?? 'en'
const runtime = useSidebarRuntime(props)
return (
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground">
<SidebarTitleBar locale={locale} onCollapse={props.onCollapse} />
<SidebarRuntimeNavigation props={props} runtime={runtime} />
<SidebarInteractionOverlays locale={locale} runtime={runtime} />
</aside>
)
})

View File

@@ -29,6 +29,7 @@ export function FolderContextMenu({
locale = 'en',
}: FolderContextMenuProps) {
if (!menu) return null
const canMutateFolder = menu.path.length > 0
return (
<div
@@ -61,25 +62,29 @@ export function FolderContextMenu({
{translate(locale, 'sidebar.action.copyFolderPathMenu')}
</Button>
)}
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start gap-2 px-2 py-1.5 text-sm"
onClick={() => onRename(menu.path)}
>
<PencilSimple size={14} />
{translate(locale, 'sidebar.action.renameFolderMenu')}
</Button>
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start gap-2 px-2 py-1.5 text-sm text-destructive hover:text-destructive"
onClick={() => onDelete?.(menu.path)}
data-testid="delete-folder-menu-item"
>
<Trash size={14} />
{translate(locale, 'sidebar.action.deleteFolderMenu')}
</Button>
{canMutateFolder && (
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start gap-2 px-2 py-1.5 text-sm"
onClick={() => onRename(menu.path)}
>
<PencilSimple size={14} />
{translate(locale, 'sidebar.action.renameFolderMenu')}
</Button>
)}
{canMutateFolder && (
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start gap-2 px-2 py-1.5 text-sm text-destructive hover:text-destructive"
onClick={() => onDelete?.(menu.path)}
data-testid="delete-folder-menu-item"
>
<Trash size={14} />
{translate(locale, 'sidebar.action.deleteFolderMenu')}
</Button>
)}
</div>
)
}

View File

@@ -215,7 +215,7 @@ function FolderSelectButton({
paddingLeft: hasChildren ? 0 : contentInset,
paddingRight: hasActions ? 48 : 16,
}}
title={node.path}
title={node.path || node.name}
onClick={(event) => onClick(event.detail)}
onDoubleClick={onDoubleClick}
data-testid={`folder-row:${node.path}`}

View File

@@ -17,6 +17,7 @@ interface FolderTreeRowProps {
onCancelRenameFolder?: () => void
locale?: AppLocale
renamingFolderPath?: string | null
rootPath?: string
selection: SidebarSelection
}
@@ -64,6 +65,7 @@ function FolderChildren({
onCancelRenameFolder,
locale,
renamingFolderPath,
rootPath,
selection,
}: FolderTreeRowProps) {
const isExpanded = expanded[node.path] ?? false
@@ -91,6 +93,7 @@ function FolderChildren({
onCancelRenameFolder={onCancelRenameFolder}
locale={locale}
renamingFolderPath={renamingFolderPath}
rootPath={rootPath}
selection={selection}
/>
))}
@@ -111,16 +114,18 @@ export const FolderTreeRow = memo(function FolderTreeRow({
onCancelRenameFolder,
locale = 'en',
renamingFolderPath,
rootPath,
selection,
}: FolderTreeRowProps) {
const isExpanded = expanded[node.path] ?? false
const isRenaming = renamingFolderPath === node.path
const isSelected = selection.kind === 'folder' && selection.path === node.path
const canMutateFolder = node.path.length > 0
const depthIndent = depth * 16
const contentInset = 16
const selectFolder = useCallback(() => {
onSelect({ kind: 'folder', path: node.path })
}, [node.path, onSelect])
onSelect(node.path === '' ? { kind: 'folder', path: '', rootPath } : { kind: 'folder', path: node.path })
}, [node.path, onSelect, rootPath])
const row = (
<FolderItemRow
contentInset={contentInset}
@@ -128,10 +133,10 @@ export const FolderTreeRow = memo(function FolderTreeRow({
isExpanded={isExpanded}
isSelected={isSelected}
node={node}
onDeleteFolder={onDeleteFolder}
onDeleteFolder={canMutateFolder ? onDeleteFolder : undefined}
onOpenMenu={onOpenMenu}
onSelect={selectFolder}
onStartRenameFolder={onStartRenameFolder}
onStartRenameFolder={canMutateFolder ? onStartRenameFolder : undefined}
onToggle={onToggle}
locale={locale}
/>
@@ -162,6 +167,7 @@ export const FolderTreeRow = memo(function FolderTreeRow({
onCancelRenameFolder={onCancelRenameFolder}
locale={locale}
renamingFolderPath={renamingFolderPath}
rootPath={rootPath}
selection={selection}
/>
</>

View File

@@ -24,7 +24,10 @@ function useExpandedFolders(selection: SidebarSelection, renamingFolderPath?: st
)
const toggleFolder = useCallback((path: string) => {
setManualExpanded((current) => ({ ...current, [path]: !current[path] }))
setManualExpanded((current) => {
const defaultExpanded = path === ''
return { ...current, [path]: !(current[path] ?? defaultExpanded) }
})
}, [])
return {

View File

@@ -18,6 +18,7 @@ interface NavigationCommandsConfig {
}
interface FolderCommandsConfig {
canMutateFolder: boolean
folderSelected: boolean
onCopySelectedFolderPath?: () => void
onDeleteFolder?: () => void
@@ -34,6 +35,7 @@ function runOptionalCommand(action?: () => void) {
}
function buildFolderCommands({
canMutateFolder,
folderSelected,
onCopySelectedFolderPath,
onDeleteFolder,
@@ -62,7 +64,7 @@ function buildFolderCommands({
label: 'Rename Folder',
group: 'Navigation',
keywords: ['folder', 'directory', 'sidebar', 'rename'],
enabled: canRunFolderCommand(folderSelected, onRenameFolder),
enabled: canRunFolderCommand(canMutateFolder, onRenameFolder),
execute: () => runOptionalCommand(onRenameFolder),
},
{
@@ -70,7 +72,7 @@ function buildFolderCommands({
label: 'Delete Folder',
group: 'Navigation',
keywords: ['folder', 'directory', 'sidebar', 'delete', 'remove'],
enabled: canRunFolderCommand(folderSelected, onDeleteFolder),
enabled: canRunFolderCommand(canMutateFolder, onDeleteFolder),
execute: () => runOptionalCommand(onDeleteFolder),
},
]
@@ -122,9 +124,11 @@ export function buildNavigationCommands(config: NavigationCommandsConfig): Comma
showInbox = true,
} = config
const folderSelected = selection?.kind === 'folder'
const canMutateFolder = folderSelected && selection.path.length > 0
const commands = [
...buildBaseCommands(config),
...buildFolderCommands({
canMutateFolder,
folderSelected,
onRenameFolder,
onDeleteFolder,

View File

@@ -78,7 +78,7 @@ export function useFolderDelete({
}, [activeTabPathRef, closeAllTabs, confirmDeleteFolder, reloadFolders, reloadVault, selection, setSelection, setTabs, setToastMessage, vaultPath])
const deleteSelectedFolder = useCallback(() => {
if (selection.kind !== 'folder') return
if (selection.kind !== 'folder' || !selection.path) return
requestDeleteFolder(selection.path)
}, [requestDeleteFolder, selection])

View File

@@ -72,7 +72,7 @@ export function useFolderRename({
}, [activeTabPathRef, handleSwitchTab, reloadFolders, reloadVault, selection, setSelection, setTabs, setToastMessage, vaultPath])
const renameSelectedFolder = useCallback(() => {
if (selection.kind !== 'folder') return
if (selection.kind !== 'folder' || !selection.path) return
startFolderRename(selection.path)
}, [selection, startFolderRename])

View File

@@ -52,6 +52,20 @@ function findCommand(commands: CommandAction[], id: string): CommandAction | und
return commands.find(c => c.id === id)
}
function expectFolderCommandStates(overrides: Record<string, unknown>, expected: {
copy: boolean
delete: boolean
rename: boolean
reveal: boolean
}) {
const { result } = renderHook(() => useCommandRegistry(makeConfig(overrides)))
expect(findCommand(result.current, 'reveal-selected-folder')?.enabled).toBe(expected.reveal)
expect(findCommand(result.current, 'copy-selected-folder-path')?.enabled).toBe(expected.copy)
expect(findCommand(result.current, 'rename-folder')?.enabled).toBe(expected.rename)
expect(findCommand(result.current, 'delete-folder')?.enabled).toBe(expected.delete)
}
describe('useCommandRegistry', () => {
it('includes resolve-conflicts command in Git group', () => {
const config = makeConfig()
@@ -482,35 +496,33 @@ describe('useCommandRegistry', () => {
})
it('enables folder commands when a folder is selected', () => {
const config = makeConfig({
expectFolderCommandStates({
selection: { kind: 'folder', path: 'projects' },
onRenameFolder: vi.fn(),
onDeleteFolder: vi.fn(),
onRevealSelectedFolder: vi.fn(),
onCopySelectedFolderPath: vi.fn(),
})
const { result } = renderHook(() => useCommandRegistry(config))
expect(findCommand(result.current, 'reveal-selected-folder')?.enabled).toBe(true)
expect(findCommand(result.current, 'copy-selected-folder-path')?.enabled).toBe(true)
expect(findCommand(result.current, 'rename-folder')?.enabled).toBe(true)
expect(findCommand(result.current, 'delete-folder')?.enabled).toBe(true)
}, { copy: true, delete: true, rename: true, reveal: true })
})
it('disables folder commands outside folder selection', () => {
const config = makeConfig({
expectFolderCommandStates({
selection: { kind: 'filter', filter: 'all' },
onRenameFolder: vi.fn(),
onDeleteFolder: vi.fn(),
onRevealSelectedFolder: vi.fn(),
onCopySelectedFolderPath: vi.fn(),
})
const { result } = renderHook(() => useCommandRegistry(config))
}, { copy: false, delete: false, rename: false, reveal: false })
})
expect(findCommand(result.current, 'reveal-selected-folder')?.enabled).toBe(false)
expect(findCommand(result.current, 'copy-selected-folder-path')?.enabled).toBe(false)
expect(findCommand(result.current, 'rename-folder')?.enabled).toBe(false)
expect(findCommand(result.current, 'delete-folder')?.enabled).toBe(false)
it('keeps root folder reveal and copy commands enabled without destructive actions', () => {
expectFolderCommandStates({
selection: { kind: 'folder', path: '', rootPath: '/Users/luca/Laputa' },
onRenameFolder: vi.fn(),
onDeleteFolder: vi.fn(),
onRevealSelectedFolder: vi.fn(),
onCopySelectedFolderPath: vi.fn(),
}, { copy: true, delete: false, rename: false, reveal: true })
})
it('executes folder command callbacks', () => {

View File

@@ -200,7 +200,7 @@ export type InboxPeriod = 'week' | 'month' | 'quarter' | 'all'
export type SidebarSelection =
| { kind: 'filter'; filter: SidebarFilter }
| { kind: 'sectionGroup'; type: string }
| { kind: 'folder'; path: string }
| { kind: 'folder'; path: string; rootPath?: string }
| { kind: 'entity'; entry: VaultEntry }
| { kind: 'view'; filename: string }

View File

@@ -86,6 +86,19 @@ describe('filterEntries', () => {
expect(filterEntries(entries, { kind: 'folder', path: 'Client Work' }).map((entry) => entry.title)).toEqual(['Alpha', 'Beta'])
expect(filterEntries(entries, { kind: 'folder', path: '客户' }).map((entry) => entry.title)).toEqual(['Unicode'])
})
it('shows only direct root-level files when selecting the vault root folder', () => {
const entries = [
makeEntry({ path: '/Users/luca/Laputa/root-note.md', title: 'Root Note', fileKind: 'markdown' }),
makeEntry({ path: '/Users/luca/Laputa/config.json', title: 'config.json', fileKind: 'text' }),
makeEntry({ path: '/Users/luca/Laputa/projects/nested.md', title: 'Nested Note', fileKind: 'markdown' }),
makeEntry({ path: '/Users/luca/Laputa/assets/logo.png', title: 'Logo', fileKind: 'binary' }),
]
const result = filterEntries(entries, { kind: 'folder', path: '', rootPath: '/Users/luca/Laputa' })
expect(result.map((entry) => entry.title)).toEqual(['Root Note', 'config.json'])
})
})
describe('countByFilter', () => {

View File

@@ -417,9 +417,24 @@ function filterViewEntries(entries: VaultEntry[], filename: string, views?: View
return evaluateView(view.definition, entries.filter(isMarkdown))
}
function filterFolderEntries(entries: VaultEntry[], folderPath: string, subFilter?: NoteListFilter): VaultEntry[] {
function isDirectRootEntry(entryPath: string, rootPath?: string): boolean {
const normalizedEntryPath = normalizeFolderPath(entryPath)
const normalizedRootPath = rootPath ? normalizeFolderPath(rootPath) : ''
if (!normalizedRootPath) return !normalizedEntryPath.includes('/')
if (!normalizedEntryPath.startsWith(`${normalizedRootPath}/`)) return false
const relativePath = normalizedEntryPath.slice(normalizedRootPath.length + 1)
return relativePath.length > 0 && !relativePath.includes('/')
}
function filterRootEntries(entries: VaultEntry[], rootPath: string | undefined, subFilter?: NoteListFilter): VaultEntry[] {
const rootEntries = entries.filter((entry) => isDirectRootEntry(entry.path, rootPath))
return subFilter ? applySubFilter(rootEntries, subFilter) : rootEntries.filter(isActive)
}
function filterFolderEntries(entries: VaultEntry[], selection: Extract<SidebarSelection, { kind: 'folder' }>, subFilter?: NoteListFilter): VaultEntry[] {
if (!selection.path) return filterRootEntries(entries, selection.rootPath, subFilter)
// Folder view shows ALL files (text + binary), not just markdown
const folderEntries = entries.filter((entry) => isInFolder(entry.path, folderPath))
const folderEntries = entries.filter((entry) => isInFolder(entry.path, selection.path))
return subFilter ? applySubFilter(folderEntries, subFilter) : folderEntries.filter(isActive)
}
@@ -443,7 +458,7 @@ function filterTopLevelEntries(
function filterByKind(entries: VaultEntry[], selection: SidebarSelection, subFilter?: NoteListFilter, views?: ViewFile[]): VaultEntry[] {
if (selection.kind === 'entity') return []
if (selection.kind === 'view') return filterViewEntries(entries, selection.filename, views)
if (selection.kind === 'folder') return filterFolderEntries(entries, selection.path, subFilter)
if (selection.kind === 'folder') return filterFolderEntries(entries, selection, subFilter)
if (selection.kind === 'sectionGroup') return filterSectionGroupEntries(entries, selection.type, subFilter)
if (selection.kind === 'filter') return filterTopLevelEntries(entries, selection, subFilter)
return []