From 908daafaa13afd92d1ba56da83d2d29c40813f03 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 27 Apr 2026 01:45:16 +0200 Subject: [PATCH] feat: add basic file actions --- docs/ABSTRACTIONS.md | 4 +- docs/ARCHITECTURE.md | 4 +- src/App.tsx | 16 +++- src/components/BreadcrumbBar.test.tsx | 20 +++++ src/components/BreadcrumbBar.tsx | 39 ++++++++ src/components/Editor.tsx | 26 +++++- src/components/FilePreview.test.tsx | 67 ++++++++++++++ src/components/FilePreview.tsx | 55 ++++++++++-- src/components/FolderTree.test.tsx | 25 ++++++ src/components/FolderTree.tsx | 56 ++++++++---- src/components/Sidebar.tsx | 19 +++- .../editor-content/EditorContentLayout.tsx | 6 ++ .../editor-content/useEditorContentModel.ts | 2 + .../folder-tree/FolderContextMenu.tsx | 30 ++++++- .../folder-tree/useFolderContextMenu.ts | 44 +++++++--- src/hooks/commands/navigationCommands.ts | 64 +++++++++++--- src/hooks/commands/noteCommands.ts | 37 ++++++++ src/hooks/useAppCommands.ts | 15 ++++ src/hooks/useCommandRegistry.test.ts | 72 +++++++++++++++ src/hooks/useCommandRegistry.ts | 26 ++++-- src/hooks/useFileActions.ts | 88 +++++++++++++++++++ src/lib/locales/en.ts | 4 + src/lib/locales/zh-Hans.ts | 4 + src/test/setup.ts | 2 + src/utils/url.test.ts | 55 +++++++++++- src/utils/url.ts | 17 ++++ 26 files changed, 734 insertions(+), 63 deletions(-) create mode 100644 src/components/FilePreview.test.tsx create mode 100644 src/hooks/useFileActions.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 6b0985f2..ff09a255 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -294,7 +294,7 @@ type SidebarSelection = `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. +- `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. - `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. @@ -339,6 +339,8 @@ A `vault_health_check` command detects stray files in non-protected subfolders a Command-layer path access is fenced to the active vault before file operations reach the vault backend. `src-tauri/src/commands/vault/boundary.rs` canonicalizes the configured/requested vault root, rejects `..` escapes and absolute paths outside that root, and validates writable targets through the nearest existing ancestor so note reads, saves, deletes, view-file edits, folder mutations, and image attachment writes cannot step outside the active vault. Image attachment commands refresh the runtime asset scope after saving so files created under a previously missing `attachments/` directory can render immediately. +UI-only file actions operate on paths that are already selected or indexed in React state. Reveal-in-Finder and external-open calls route through the Tauri opener plugin, while copy-path uses the browser clipboard API; none of those actions mutate vault contents or bypass the backend write boundary. + ### Vault Caching `vault::scan_vault_cached(path)` wraps scanning with git-based caching: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bc12483e..2d7e6d2e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -178,7 +178,7 @@ flowchart TD └──────────────────────────────────────────────────────────────┘ ``` -- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), 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. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete, 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 document in `type/`. +- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), 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. 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 document in `type/`. - **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 binaries get an image indicator 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 note-layout toggle, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, 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-screen left-aligned note column while preserving the same readable max width. Binary image files render through `FilePreview` as ordinary vault files using Tauri asset URLs, with explicit unsupported/broken fallback states and keyboard focus returning 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). @@ -799,7 +799,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c | Cmd+[ / Cmd+] | Navigate back / forward | | `[[` in editor | Open wikilink suggestion menu | -Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Rename Folder" and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths. +Selection-dependent actions are wired through the command palette and the native menus. For example, a deleted file opened from Changes view becomes a read-only diff preview, and that state enables the "Restore Deleted Note" menu/command while normal note mutation actions stay disabled. Folder selection follows the same pattern: when `selection.kind === 'folder'`, the command palette exposes "Reveal Folder in Finder", "Copy Folder Path", "Rename Folder", and "Delete Folder", and the sidebar row can launch the same flows directly through inline rename or the folder context menu. Active files also expose "Reveal in Finder" and "Copy File Path" through the command palette; non-Markdown file tabs additionally expose "Open in Default App", matching the `FilePreview` header controls. Active notes now follow the same shared-action model for retargeting: Cmd+K can open "Change Note Type…" and "Move Note to Folder…", and the sidebar drop targets call the same hook-backed implementations instead of maintaining separate mutation paths. Shortcut routing is explicit: diff --git a/src/App.tsx b/src/App.tsx index fe2fbf8e..776560b0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -66,6 +66,7 @@ import { useAiActivity } from './hooks/useAiActivity' import { useBulkActions } from './hooks/useBulkActions' import { useDeleteActions } from './hooks/useDeleteActions' import { useFolderActions } from './hooks/useFolderActions' +import { useFileActions } from './hooks/useFileActions' import { useLayoutPanels } from './hooks/useLayoutPanels' import { useConflictFlow } from './hooks/useConflictFlow' import { useAppSave } from './hooks/useAppSave' @@ -824,6 +825,11 @@ function App() { reloadFolders: vault.reloadFolders, setToastMessage, }) + const fileActions = useFileActions({ + selection: effectiveSelection, + setToastMessage, + vaultPath: resolvedPath, + }) const handleRemoveNoteIconCommand = useCallback(() => { if (notes.activeTabPath) handleRemoveNoteIcon(notes.activeTabPath) @@ -1371,6 +1377,8 @@ function App() { onSelect: handleSetSelection, onRenameFolder: folderActions.renameSelectedFolder, onDeleteFolder: folderActions.deleteSelectedFolder, + onRevealSelectedFolder: fileActions.revealSelectedFolder, + onCopySelectedFolderPath: fileActions.copySelectedFolderPath, showInbox: explicitOrganizationEnabled, onReplaceActiveTab: notes.handleReplaceActiveTab, onSelectNote: notes.handleSelectNote, @@ -1410,6 +1418,9 @@ function App() { noteListFilter, onSetNoteListFilter: setNoteListFilter, onOpenInNewWindow: handleOpenInNewWindow, + onRevealActiveFile: fileActions.revealFile, + onCopyActiveFilePath: fileActions.copyFilePath, + onOpenActiveFileExternal: fileActions.openExternalFile, onToggleFavorite: entryActions.handleToggleFavorite, onToggleOrganized: toggleOrganizedCommand, onCustomizeNoteListColumns: handleCustomizeNoteListColumns, @@ -1512,7 +1523,7 @@ function App() { {sidebarVisible && ( <>
- +
@@ -1564,6 +1575,9 @@ function App() { noteListFilter={aiNoteListFilter} onToggleFavorite={activeDeletedFile ? undefined : entryActions.handleToggleFavorite} onToggleOrganized={activeDeletedFile || !explicitOrganizationEnabled ? undefined : toggleOrganizedCommand} + onRevealFile={fileActions.revealFile} + onCopyFilePath={fileActions.copyFilePath} + onOpenExternalFile={fileActions.openExternalFile} onDeleteNote={activeDeletedFile ? undefined : deleteActions.handleDeleteNote} onArchiveNote={activeDeletedFile ? undefined : entryActions.handleArchiveNote} onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote} diff --git a/src/components/BreadcrumbBar.test.tsx b/src/components/BreadcrumbBar.test.tsx index 1d59119d..4692a0e8 100644 --- a/src/components/BreadcrumbBar.test.tsx +++ b/src/components/BreadcrumbBar.test.tsx @@ -135,6 +135,26 @@ describe('BreadcrumbBar — archive/unarchive', () => { }) }) +describe('BreadcrumbBar — file actions', () => { + it('reveals the current file from the breadcrumb toolbar', () => { + const onRevealFile = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Reveal in Finder' })) + + expect(onRevealFile).toHaveBeenCalledWith('/vault/note/test.md') + }) + + it('copies the current file path from the breadcrumb toolbar', () => { + const onCopyFilePath = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'Copy file path' })) + + expect(onCopyFilePath).toHaveBeenCalledWith('/vault/note/test.md') + }) +}) + describe('BreadcrumbBar — organized shortcut hint', () => { it('shows Cmd+E on the organized toggle tooltip', async () => { render() diff --git a/src/components/BreadcrumbBar.tsx b/src/components/BreadcrumbBar.tsx index 82a454c1..44081fbe 100644 --- a/src/components/BreadcrumbBar.tsx +++ b/src/components/BreadcrumbBar.tsx @@ -15,6 +15,8 @@ import { Trash, Archive, ArrowUUpLeft, + ClipboardText, + FolderOpen, Star, CheckCircle, ArrowsClockwise, @@ -42,6 +44,8 @@ interface BreadcrumbBarProps { onToggleInspector?: () => void onToggleFavorite?: () => void onToggleOrganized?: () => void + onRevealFile?: (path: string) => void + onCopyFilePath?: (path: string) => void onDelete?: () => void onArchive?: () => void onUnarchive?: () => void @@ -375,6 +379,38 @@ function DeleteAction({ locale = 'en', onDelete }: Pick) { + return ( + <> + {onRevealFile && ( + onRevealFile(entry.path)} + className="hover:text-foreground" + testId="breadcrumb-reveal-file" + > + + + )} + {onCopyFilePath && ( + onCopyFilePath(entry.path)} + className="hover:text-foreground" + testId="breadcrumb-copy-file-path" + > + + + )} + + ) +} + function InspectorAction({ inspectorCollapsed, locale = 'en', @@ -596,6 +632,8 @@ function BreadcrumbActions({ onToggleInspector, onToggleFavorite, onToggleOrganized, + onRevealFile, + onCopyFilePath, onDelete, onArchive, onUnarchive, @@ -615,6 +653,7 @@ function BreadcrumbActions({ {!forceRawMode && } + diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 664712dd..979f9075 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -69,6 +69,9 @@ interface EditorProps { noteListFilter?: { type: string | null; query: string } onToggleFavorite?: (path: string) => void onToggleOrganized?: (path: string) => void + onRevealFile?: (path: string) => void + onCopyFilePath?: (path: string) => void + onOpenExternalFile?: (path: string) => void onDeleteNote?: (path: string) => void onArchiveNote?: (path: string) => void onUnarchiveNote?: (path: string) => void @@ -299,6 +302,9 @@ function EditorLayout({ handleEditorChange, onToggleFavorite, onToggleOrganized, + onRevealFile, + onCopyFilePath, + onOpenExternalFile, onDeleteNote, onArchiveNote, onUnarchiveNote, @@ -356,6 +362,9 @@ function EditorLayout({ handleEditorChange: () => void onToggleFavorite?: (path: string) => void onToggleOrganized?: (path: string) => void + onRevealFile?: (path: string) => void + onCopyFilePath?: (path: string) => void + onOpenExternalFile?: (path: string) => void onDeleteNote?: (path: string) => void onArchiveNote?: (path: string) => void onUnarchiveNote?: (path: string) => void @@ -398,7 +407,14 @@ function EditorLayout({ {tabs.length === 0 ? : activeBinaryTab - ? + ? ( + + ) : ({ + convertFileSrc: (path: string) => `asset://${path}`, +})) + +const imageEntry: VaultEntry = { + path: '/vault/Attachments/photo.png', + filename: 'photo.png', + title: 'photo.png', + isA: null, + aliases: [], + belongsTo: [], + relatedTo: [], + status: null, + archived: false, + modifiedAt: 1700000000, + createdAt: 1700000000, + fileSize: 100, + snippet: '', + wordCount: 0, + relationships: {}, + icon: null, + color: null, + order: null, + sidebarLabel: null, + template: null, + sort: null, + view: null, + visible: null, + organized: false, + favorite: false, + favoriteIndex: null, + listPropertiesDisplay: [], + outgoingLinks: [], + properties: {}, + hasH1: false, + fileKind: 'binary', +} + +describe('FilePreview', () => { + it('routes header file actions to the active file path', () => { + const onRevealFile = vi.fn() + const onCopyFilePath = vi.fn() + const onOpenExternalFile = vi.fn() + + render( + , + ) + + fireEvent.click(screen.getByRole('button', { name: 'Reveal' })) + fireEvent.click(screen.getByRole('button', { name: 'Copy path' })) + fireEvent.click(screen.getByRole('button', { name: 'Open' })) + + expect(onRevealFile).toHaveBeenCalledWith('/vault/Attachments/photo.png') + expect(onCopyFilePath).toHaveBeenCalledWith('/vault/Attachments/photo.png') + expect(onOpenExternalFile).toHaveBeenCalledWith('/vault/Attachments/photo.png') + }) +}) diff --git a/src/components/FilePreview.tsx b/src/components/FilePreview.tsx index 01fe901a..10e02124 100644 --- a/src/components/FilePreview.tsx +++ b/src/components/FilePreview.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState, type KeyboardEvent } from 'react' import { convertFileSrc } from '@tauri-apps/api/core' -import { ArrowSquareOut, FileDashed, ImageSquare, WarningCircle } from '@phosphor-icons/react' +import { ArrowSquareOut, ClipboardText, FileDashed, FolderOpen, ImageSquare, WarningCircle } from '@phosphor-icons/react' import type { VaultEntry } from '../types' import { isImagePreviewEntry, previewFileTypeLabel } from '../utils/filePreview' import { focusNoteListContainer } from '../utils/neighborhoodHistory' @@ -9,6 +9,9 @@ import { Button } from './ui/button' interface FilePreviewProps { entry: VaultEntry + onCopyFilePath?: (path: string) => void + onOpenExternalFile?: (path: string) => void + onRevealFile?: (path: string) => void } interface FilePreviewFallbackProps { @@ -44,11 +47,15 @@ function FilePreviewHeader({ isImage, fileTypeLabel, onOpenExternal, + onRevealFile, + onCopyFilePath, }: { entry: VaultEntry isImage: boolean fileTypeLabel: string onOpenExternal: () => void + onRevealFile?: () => void + onCopyFilePath?: () => void }) { const HeaderIcon = isImage ? ImageSquare : FileDashed @@ -64,10 +71,24 @@ function FilePreviewHeader({

{fileTypeLabel}

- +
+ {onRevealFile && ( + + )} + {onCopyFilePath && ( + + )} + +
) } @@ -131,7 +152,12 @@ function FilePreviewBody({ ) } -export function FilePreview({ entry }: FilePreviewProps) { +export function FilePreview({ + entry, + onCopyFilePath, + onOpenExternalFile, + onRevealFile, +}: FilePreviewProps) { const [imageFailed, setImageFailed] = useState(false) const isImage = isImagePreviewEntry(entry) const imageSrc = useMemo(() => (isImage ? convertFileSrc(entry.path) : null), [entry.path, isImage]) @@ -139,10 +165,23 @@ export function FilePreview({ entry }: FilePreviewProps) { const handleImageError = useCallback(() => setImageFailed(true), []) const handleOpenExternal = useCallback(() => { + if (onOpenExternalFile) { + onOpenExternalFile(entry.path) + return + } + void openLocalFile(entry.path).catch((error) => { console.warn('Failed to open file with default app:', error) }) - }, [entry.path]) + }, [entry.path, onOpenExternalFile]) + + const handleRevealFile = useCallback(() => { + onRevealFile?.(entry.path) + }, [entry.path, onRevealFile]) + + const handleCopyFilePath = useCallback(() => { + onCopyFilePath?.(entry.path) + }, [entry.path, onCopyFilePath]) const handleKeyDown = useCallback((event: KeyboardEvent) => { if (event.key !== 'Escape') return @@ -164,6 +203,8 @@ export function FilePreview({ entry }: FilePreviewProps) { isImage={isImage} fileTypeLabel={fileTypeLabel} onOpenExternal={handleOpenExternal} + onRevealFile={onRevealFile ? handleRevealFile : undefined} + onCopyFilePath={onCopyFilePath ? handleCopyFilePath : undefined} />
{ fireEvent.click(screen.getByTestId('delete-folder-menu-item')) expect(onDeleteFolder).toHaveBeenCalledWith('projects') }) + + it('opens folder file actions from the context menu', () => { + const onRevealFolder = vi.fn() + const onCopyFolderPath = vi.fn() + render( + , + ) + + fireEvent.contextMenu(screen.getByText('projects')) + fireEvent.click(screen.getByTestId('reveal-folder-menu-item')) + expect(onRevealFolder).toHaveBeenCalledWith('projects') + + fireEvent.contextMenu(screen.getByText('projects')) + fireEvent.click(screen.getByTestId('copy-folder-path-menu-item')) + expect(onCopyFolderPath).toHaveBeenCalledWith('projects') + }) }) diff --git a/src/components/FolderTree.tsx b/src/components/FolderTree.tsx index 6930cdc6..5bd63e24 100644 --- a/src/components/FolderTree.tsx +++ b/src/components/FolderTree.tsx @@ -14,6 +14,7 @@ import { useFolderContextMenu } from './folder-tree/useFolderContextMenu' import { useFolderTreeDisclosure } from './folder-tree/useFolderTreeDisclosure' import { SidebarGroupHeader } from './sidebar/SidebarGroupHeader' import { translate, type AppLocale } from '../lib/i18n' +import type { FolderFileActions } from '../hooks/useFileActions' interface FolderTreeProps { folders: FolderNode[] @@ -22,6 +23,7 @@ interface FolderTreeProps { onCreateFolder?: (name: string) => Promise | boolean onRenameFolder?: (folderPath: string, nextName: string) => Promise | boolean onDeleteFolder?: (folderPath: string) => void + folderFileActions?: FolderFileActions renamingFolderPath?: string | null onStartRenameFolder?: (folderPath: string) => void onCancelRenameFolder?: () => void @@ -37,6 +39,7 @@ export const FolderTree = memo(function FolderTree({ onCreateFolder, onRenameFolder, onDeleteFolder, + folderFileActions, renamingFolderPath, onStartRenameFolder, onCancelRenameFolder, @@ -61,12 +64,15 @@ export const FolderTree = memo(function FolderTree({ const { closeContextMenu, contextMenu, + handleCopyPathFromMenu, handleDeleteFromMenu, handleOpenMenu, + handleRevealFromMenu, handleRenameFromMenu, menuRef, } = useFolderContextMenu({ onDeleteFolder, + folderFileActions, onStartRenameFolder, }) @@ -82,28 +88,18 @@ export const FolderTree = memo(function FolderTree({ return created }, [closeCreateForm, onCreateFolder]) + const handleCreateFolderClick = useCallback(() => { + closeContextMenu() + openCreateForm() + }, [closeContextMenu, openCreateForm]) + if (folders.length === 0 && !isCreating) return null return (
{onCreateFolder && ( - + )} {!sectionCollapsed && ( @@ -145,9 +141,37 @@ export const FolderTree = memo(function FolderTree({ menu={contextMenu} menuRef={menuRef} onDelete={handleDeleteFromMenu} + onReveal={handleRevealFromMenu} + onCopyPath={handleCopyPathFromMenu} onRename={handleRenameFromMenu} locale={locale} />
) }) + +function CreateFolderButton({ + locale, + onCreate, +}: { + locale: AppLocale + onCreate: () => void +}) { + return ( + + ) +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index bebf2f94..6e05bf83 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -23,6 +23,7 @@ import { } from './sidebar/SidebarSections' import { useSidebarTypeInteractions } from './sidebar/useSidebarTypeInteractions' import type { AppLocale } from '../lib/i18n' +import type { FolderFileActions } from '../hooks/useFileActions' interface SidebarProps { entries: VaultEntry[] @@ -46,6 +47,7 @@ interface SidebarProps { onCreateFolder?: (name: string) => Promise | boolean onRenameFolder?: (folderPath: string, nextName: string) => Promise | boolean onDeleteFolder?: (folderPath: string) => void + folderFileActions?: FolderFileActions renamingFolderPath?: string | null onStartRenameFolder?: (folderPath: string) => void onCancelRenameFolder?: () => void @@ -70,6 +72,7 @@ interface SidebarNavigationProps extends Pick< | 'onCreateFolder' | 'onRenameFolder' | 'onDeleteFolder' + | 'folderFileActions' | 'renamingFolderPath' | 'onStartRenameFolder' | 'onCancelRenameFolder' @@ -107,6 +110,7 @@ function SidebarNavigation({ onCreateFolder, onRenameFolder, onDeleteFolder, + folderFileActions, renamingFolderPath, onStartRenameFolder, onCancelRenameFolder, @@ -194,6 +198,7 @@ function SidebarNavigation({ onCreateFolder={onCreateFolder} onRenameFolder={onRenameFolder} onDeleteFolder={onDeleteFolder} + folderFileActions={folderFileActions} renamingFolderPath={renamingFolderPath} onStartRenameFolder={onStartRenameFolder} onCancelRenameFolder={onCancelRenameFolder} @@ -205,6 +210,13 @@ function SidebarNavigation({ ) } +function useSidebarDndSensors() { + return useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ) +} + export const Sidebar = memo(function Sidebar({ entries, selection, @@ -224,6 +236,7 @@ export const Sidebar = memo(function Sidebar({ onCreateFolder, onRenameFolder, onDeleteFolder, + folderFileActions, renamingFolderPath, onStartRenameFolder, onCancelRenameFolder, @@ -247,10 +260,7 @@ export const Sidebar = memo(function Sidebar({ const isSectionVisible = useCallback((type: string) => typeEntryMap[type]?.visible !== false, [typeEntryMap]) const toggleVisibility = useCallback((type: string) => onToggleTypeVisibility?.(type), [onToggleTypeVisibility]) - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), - useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), - ) + const sensors = useSidebarDndSensors() const handleDragEnd = useCallback((event: DragEndEvent) => { const { active, over } = event @@ -288,6 +298,7 @@ export const Sidebar = memo(function Sidebar({ onCreateFolder={onCreateFolder} onRenameFolder={onRenameFolder} onDeleteFolder={onDeleteFolder} + folderFileActions={folderFileActions} renamingFolderPath={renamingFolderPath} onStartRenameFolder={onStartRenameFolder} onCancelRenameFolder={onCancelRenameFolder} diff --git a/src/components/editor-content/EditorContentLayout.tsx b/src/components/editor-content/EditorContentLayout.tsx index 274b3698..fc7e4afc 100644 --- a/src/components/editor-content/EditorContentLayout.tsx +++ b/src/components/editor-content/EditorContentLayout.tsx @@ -26,6 +26,8 @@ type BreadcrumbActions = Pick< | 'showDiffToggle' | 'onToggleFavorite' | 'onToggleOrganized' + | 'onRevealFile' + | 'onCopyFilePath' | 'onDeleteNote' | 'onArchiveNote' | 'onUnarchiveNote' @@ -135,6 +137,8 @@ function ActiveTabBreadcrumb({ onToggleInspector={actions.onToggleInspector} onToggleFavorite={bindPath(actions.onToggleFavorite, path)} onToggleOrganized={bindPath(actions.onToggleOrganized, path)} + onRevealFile={actions.onRevealFile} + onCopyFilePath={actions.onCopyFilePath} onDelete={bindPath(actions.onDeleteNote, path)} onArchive={bindPath(actions.onArchiveNote, path)} onUnarchive={bindPath(actions.onUnarchiveNote, path)} @@ -282,6 +286,8 @@ export function EditorContentLayout(model: EditorContentModel) { showDiffToggle: model.showDiffToggle, onToggleFavorite: model.onToggleFavorite, onToggleOrganized: model.onToggleOrganized, + onRevealFile: model.onRevealFile, + onCopyFilePath: model.onCopyFilePath, onDeleteNote: model.onDeleteNote, onArchiveNote: model.onArchiveNote, onUnarchiveNote: model.onUnarchiveNote, diff --git a/src/components/editor-content/useEditorContentModel.ts b/src/components/editor-content/useEditorContentModel.ts index b0419e54..b1c5ce3d 100644 --- a/src/components/editor-content/useEditorContentModel.ts +++ b/src/components/editor-content/useEditorContentModel.ts @@ -34,6 +34,8 @@ export interface EditorContentProps { onEditorChange?: () => void onToggleFavorite?: (path: string) => void onToggleOrganized?: (path: string) => void + onRevealFile?: (path: string) => void + onCopyFilePath?: (path: string) => void onDeleteNote?: (path: string) => void onArchiveNote?: (path: string) => void onUnarchiveNote?: (path: string) => void diff --git a/src/components/folder-tree/FolderContextMenu.tsx b/src/components/folder-tree/FolderContextMenu.tsx index 6f3030d9..796781b4 100644 --- a/src/components/folder-tree/FolderContextMenu.tsx +++ b/src/components/folder-tree/FolderContextMenu.tsx @@ -1,5 +1,5 @@ import type { RefObject } from 'react' -import { PencilSimple, Trash } from '@phosphor-icons/react' +import { ClipboardText, FolderOpen, PencilSimple, Trash } from '@phosphor-icons/react' import { Button } from '@/components/ui/button' import { translate, type AppLocale } from '../../lib/i18n' @@ -13,6 +13,8 @@ interface FolderContextMenuProps { menu: FolderContextMenuState | null menuRef: RefObject onDelete?: (folderPath: string) => void + onReveal?: (folderPath: string) => void + onCopyPath?: (folderPath: string) => void onRename: (folderPath: string) => void locale?: AppLocale } @@ -21,6 +23,8 @@ export function FolderContextMenu({ menu, menuRef, onDelete, + onReveal, + onCopyPath, onRename, locale = 'en', }: FolderContextMenuProps) { @@ -33,6 +37,30 @@ export function FolderContextMenu({ style={{ left: menu.x, top: menu.y, minWidth: 180 }} data-testid="folder-context-menu" > + {onReveal && ( + + )} + {onCopyPath && ( + + )}