diff --git a/frontend/src/components/FolderFilterInput.tsx b/frontend/src/components/FolderFilterInput.tsx
new file mode 100644
index 0000000..a736359
--- /dev/null
+++ b/frontend/src/components/FolderFilterInput.tsx
@@ -0,0 +1,46 @@
+import { Search, X } from 'lucide-react'
+
+interface Props {
+ value: string
+ onChange: (value: string) => void
+ placeholder?: string
+ autoFocus?: boolean
+ className?: string
+}
+
+/** Name filter for a folder tree: a search field with an "x" at the end of the line that
+ * clears it (Escape does too). */
+export default function FolderFilterInput({
+ value,
+ onChange,
+ placeholder = 'Filter folders…',
+ autoFocus = false,
+ className = '',
+}: Props) {
+ return (
+
-
- {relativeDate(note.modified_at)}
-
- {note.is_shared && (
-
- )}
- {onPin && (
-
- )}
- {onToggleSelect && (
-
- )}
-
+
+ {/* Dates sit on their own line under the header so both fit on a narrow card. */}
+
+ Created {relativeDate(note.created_at)}
+ ·
+ Modified {relativeDate(note.modified_at)}
-
- {/* Bottom: title + preview + tags */}
-
{note.title || 'Untitled'}
{note.content_preview || 'No content'}
{visibleTags.length > 0 && (
-
+
{visibleTags.map((tag) => (
= {}): Folder {
+ return {
+ id, name, parent_folder_id: parent, sort_order: 0, icon_type: null, icon_value: null,
+ color: null, system_key: null, search_query: null, created_at: '', modified_at: '', ...extra,
+ }
+}
+
+// Work
+// ├─ Projects
+// │ └─ Alpha
+// └─ Meetings
+// Personal
+// └─ Recipes
+// Archive Bin (system)
+// └─ Old Alpha
+const folders = [
+ folder('work', 'Work'),
+ folder('projects', 'Projects', 'work'),
+ folder('alpha', 'Alpha', 'projects'),
+ folder('meetings', 'Meetings', 'work'),
+ folder('personal', 'Personal'),
+ folder('recipes', 'Recipes', 'personal'),
+ folder('bin', 'Archive Bin', null, { system_key: 'archive' }),
+ folder('old', 'Old Alpha', 'bin'),
+]
+
+describe('folderFilterIds', () => {
+ it('returns null (no filtering) for a blank query', () => {
+ expect(folderFilterIds(folders, '')).toBeNull()
+ expect(folderFilterIds(folders, ' ')).toBeNull()
+ })
+
+ it('keeps matches and every ancestor, but not siblings or other branches', () => {
+ const keep = folderFilterIds(folders, 'alpha')!
+ expect([...keep].sort()).toEqual(['alpha', 'bin', 'old', 'projects', 'work'])
+ })
+
+ it('matches case-insensitively and by substring', () => {
+ expect([...folderFilterIds(folders, 'REC')!]).toEqual(['recipes', 'personal'])
+ })
+
+ it('keeps a matching parent without pulling in its non-matching children', () => {
+ const keep = folderFilterIds(folders, 'work')!
+ expect([...keep]).toEqual(['work'])
+ })
+
+ it('is empty when nothing matches', () => {
+ expect(folderFilterIds(folders, 'zzz')!.size).toBe(0)
+ })
+})
+
+describe('pruneForest', () => {
+ it('leaves only the kept branches', () => {
+ const pruned = pruneForest(buildForest(folders), folderFilterIds(folders, 'alpha')!)
+ const shape = (nodes: ReturnType): unknown[] =>
+ nodes.map((n) => [n.folder.id, shape(n.children)])
+ // Equal sort_order, so siblings fall back to name order: "Archive Bin" before "Work".
+ expect(shape(pruned)).toEqual([
+ ['bin', [['old', []]]],
+ ['work', [['projects', [['alpha', []]]]]],
+ ])
+ })
+})
+
+describe('folderMenuTarget', () => {
+ const byId = indexById(folders)
+
+ it('classifies the bin, things inside it, saved searches and ordinary folders', () => {
+ expect(folderMenuTarget(byId.get('bin')!, 'bin', byId).kind).toBe('bin')
+ expect(folderMenuTarget(byId.get('old')!, 'bin', byId).kind).toBe('archived')
+ expect(folderMenuTarget(folder('dyn', 'Saved', null, { search_query: 'tag:x' }), 'bin', byId).kind).toBe('dynamic')
+ expect(folderMenuTarget(byId.get('alpha')!, 'bin', byId).kind).toBe('normal')
+ })
+})
diff --git a/frontend/src/utils/folderTree.ts b/frontend/src/utils/folderTree.ts
index 7761ffd..18029ec 100644
--- a/frontend/src/utils/folderTree.ts
+++ b/frontend/src/utils/folderTree.ts
@@ -75,6 +75,49 @@ export function ancestorIds(folderId: string, byId: Map, maxDept
return out
}
+/** Ids of the folders a name filter keeps: every folder whose name contains `query`
+ * (case-insensitive) plus all their ancestors, so a match is always reachable from the
+ * root. Returns null for a blank query, meaning "no filtering — show everything". */
+export function folderFilterIds(folders: Folder[], query: string): Set | null {
+ const q = query.trim().toLowerCase()
+ if (!q) return null
+ const byId = indexById(folders)
+ const keep = new Set()
+ for (const f of folders) {
+ if (!f.name.toLowerCase().includes(q)) continue
+ keep.add(f.id)
+ for (const a of ancestorIds(f.id, byId)) keep.add(a)
+ }
+ return keep
+}
+
+/** Drop every node of a nested forest whose folder isn't in `keep`. */
+export function pruneForest(forest: FolderNode[], keep: Set): FolderNode[] {
+ return forest
+ .filter((n) => keep.has(n.folder.id))
+ .map((n) => ({ folder: n.folder, children: pruneForest(n.children, keep) }))
+}
+
+/** What a folder context menu was opened on. `create` is "add something here" — the
+ * "All notes" root row (folderId null) or the background of the folder being viewed. */
+export type FolderMenuTarget =
+ | { kind: 'create'; folderId: string | null }
+ | { kind: 'normal' | 'archived' | 'dynamic'; folder: Folder }
+ | { kind: 'bin' }
+
+/** Which menu a folder gets: the Archive Bin, anything inside the bin, a saved-search
+ * folder (a leaf), or an ordinary folder. */
+export function folderMenuTarget(
+ folder: Folder,
+ archiveId: string | null,
+ byId: Map,
+): FolderMenuTarget {
+ if (folder.id === archiveId) return { kind: 'bin' }
+ if (isDynamicFolder(folder)) return { kind: 'dynamic', folder }
+ if (isInArchive(folder.id, byId, archiveId)) return { kind: 'archived', folder }
+ return { kind: 'normal', folder }
+}
+
/** True if folderId is the Archive Bin itself or lives anywhere inside it. */
export function isInArchive(
folderId: string | null,
diff --git a/frontend/src/views/ListView.tsx b/frontend/src/views/ListView.tsx
index fb40331..58e12f1 100644
--- a/frontend/src/views/ListView.tsx
+++ b/frontend/src/views/ListView.tsx
@@ -15,6 +15,7 @@ import FolderBreadcrumb from '@/components/FolderBreadcrumb'
import FolderPickerModal from '@/components/FolderPickerModal'
import FolderCustomizeModal from '@/components/FolderCustomizeModal'
import FolderTreePanel from '@/components/FolderTreePanel'
+import FolderMenu, { type FolderMenuActions } from '@/components/FolderMenu'
import ImportUrlModal from '@/components/ImportUrlModal'
import BulkExportMenu from '@/components/BulkExportMenu'
import AIConversationPanel from '@/components/AIConversationPanel'
@@ -29,7 +30,7 @@ import { parseMarkdownFrontmatter, buildImportedMarkdown } from '@/utils/markdow
import { rewriteImageUrls } from '@/utils/blocks'
import { importUrlApi, type UrlExtractResult } from '@/api/importUrl'
import { resolveFolderIcon } from '@/utils/folderIcons'
-import { indexById, findArchiveFolder, isInArchive, ancestorIds } from '@/utils/folderTree'
+import { indexById, findArchiveFolder, isInArchive, ancestorIds, folderMenuTarget, type FolderMenuTarget } from '@/utils/folderTree'
import { notesApi } from '@/api/notes'
import type { NoteListItem } from '@/api/notes'
import type { Folder } from '@/api/folders'
@@ -68,6 +69,8 @@ function DraggableNote({ note, cardView, children }: { note: NoteListItem; cardV
ref={setNodeRef}
{...attributes}
{...listeners}
+ // Right-click on a card is the browser's; only the bare background opens the add menu.
+ onContextMenu={(e) => e.stopPropagation()}
className={`${cardView ? `${CARD_MAX_WIDTH} min-w-0` : ''} ${isDragging ? 'opacity-50' : ''}`}
>
{children}
@@ -104,6 +107,9 @@ export default function ListView() {
const [viewMode, setViewMode] = useState(storedViewMode)
const [panelOpen, setPanelOpen] = useState(false)
const [fabMenuOpen, setFabMenuOpen] = useState(false)
+ // Right-click menu over the folder bar chips or the notes background.
+ const [folderMenu, setFolderMenu] = useState<{ target: FolderMenuTarget; x: number; y: number } | null>(null)
+ const closeFolderMenu = useCallback(() => setFolderMenu(null), [])
const [activeDrag, setActiveDrag] = useState<
{ type: 'note'; label: string } | { type: 'folder'; folder: Folder } | null
>(null)
@@ -659,13 +665,34 @@ export default function ListView() {
))
}
+ // One set of handlers behind every folder menu: the tree, the folder bar and the notes
+ // background.
+ const folderActions: FolderMenuActions = {
+ onNewSubfolder: handleNewSubfolder,
+ onNewDynamicFolder: handleNewDynamicFolder,
+ onNewNote: handleNewNoteInFolder,
+ onImport: handleImportToFolder,
+ onImportUrl: (id) => setImportUrlTarget(id),
+ onMove: (f) => setMoveTarget({ id: f.id }),
+ onCustomize: handleCustomizeFolder,
+ onDelete: handleDeleteFolder,
+ onEmptyArchive: handleEmptyArchive,
+ }
+
const folderBarProps = {
folders: visibleSubfolders,
onOpen: openFolder,
onOpenDynamic: openDynamicFolder,
- onMove: (f: Folder) => setMoveTarget({ id: f.id }),
- onCustomize: handleCustomizeFolder,
- onDelete: handleDeleteFolder,
+ onOpenMenu: (f: Folder, x: number, y: number) =>
+ setFolderMenu({ target: folderMenuTarget(f, archiveId, foldersById), x, y }),
+ }
+
+ // Right-click on empty space in the notes view offers the same add menu as the tree,
+ // aimed at the folder being viewed. Links keep the browser's menu (open in new tab…).
+ function handleBackgroundContextMenu(e: React.MouseEvent) {
+ if ((e.target as HTMLElement).closest('a')) return
+ e.preventDefault()
+ setFolderMenu({ target: { kind: 'create', folderId }, x: e.clientX, y: e.clientY })
}
const newNotePath = folderId ? `/notes/new?folder=${folderId}` : '/notes/new'
@@ -814,19 +841,11 @@ export default function ListView() {
currentFolderId={folderId}
onOpenFolder={openFolder}
onOpenDynamic={openDynamicFolder}
- onNewSubfolder={handleNewSubfolder}
- onNewDynamicFolder={handleNewDynamicFolder}
- onNewNote={handleNewNoteInFolder}
- onImport={handleImportToFolder}
- onImportUrl={(id) => setImportUrlTarget(id)}
- onMove={(f) => setMoveTarget({ id: f.id })}
- onCustomize={handleCustomizeFolder}
- onDelete={handleDeleteFolder}
- onEmptyArchive={handleEmptyArchive}
+ {...folderActions}
draggingFolderId={activeDrag?.type === 'folder' ? activeDrag.folder.id : null}
/>