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}
-
-
- Open
-
+
+ {onRevealFile && (
+
+
+ Reveal
+
+ )}
+ {onCopyFilePath && (
+
+
+ Copy path
+
+ )}
+
+
+ Open
+
+
)
}
@@ -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 && (
- {
- event.stopPropagation()
- closeContextMenu()
- openCreateForm()
- }}
- >
-
-
+
)}
{!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 (
+ {
+ event.stopPropagation()
+ onCreate()
+ }}
+ >
+
+
+ )
+}
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 && (
+ onReveal(menu.path)}
+ data-testid="reveal-folder-menu-item"
+ >
+
+ {translate(locale, 'sidebar.action.revealFolderMenu')}
+
+ )}
+ {onCopyPath && (
+ onCopyPath(menu.path)}
+ data-testid="copy-folder-path-menu-item"
+ >
+
+ {translate(locale, 'sidebar.action.copyFolderPathMenu')}
+
+ )}
void
+ folderFileActions?: FolderFileActions
onStartRenameFolder?: (folderPath: string) => void
}
-export function useFolderContextMenu({
- onDeleteFolder,
- onStartRenameFolder,
-}: UseFolderContextMenuInput) {
- const [contextMenu, setContextMenu] = useState(null)
- const menuRef = useRef(null)
-
- const closeContextMenu = useCallback(() => setContextMenu(null), [])
-
+function useContextMenuDismiss(
+ contextMenu: FolderContextMenuState | null,
+ menuRef: RefObject,
+ closeContextMenu: () => void,
+) {
useEffect(() => {
if (!contextMenu) return
@@ -32,7 +30,19 @@ export function useFolderContextMenu({
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleEscape)
}
- }, [closeContextMenu, contextMenu])
+ }, [closeContextMenu, contextMenu, menuRef])
+}
+
+export function useFolderContextMenu({
+ onDeleteFolder,
+ folderFileActions,
+ onStartRenameFolder,
+}: UseFolderContextMenuInput) {
+ const [contextMenu, setContextMenu] = useState(null)
+ const menuRef = useRef(null)
+
+ const closeContextMenu = useCallback(() => setContextMenu(null), [])
+ useContextMenuDismiss(contextMenu, menuRef, closeContextMenu)
const handleOpenMenu = useCallback((node: FolderNode, event: ReactMouseEvent) => {
event.preventDefault()
@@ -54,11 +64,23 @@ export function useFolderContextMenu({
onDeleteFolder?.(folderPath)
}, [closeContextMenu, onDeleteFolder])
+ const handleRevealFromMenu = useCallback((folderPath: string) => {
+ closeContextMenu()
+ folderFileActions?.revealFolder(folderPath)
+ }, [closeContextMenu, folderFileActions])
+
+ const handleCopyPathFromMenu = useCallback((folderPath: string) => {
+ closeContextMenu()
+ folderFileActions?.copyFolderPath(folderPath)
+ }, [closeContextMenu, folderFileActions])
+
return {
closeContextMenu,
contextMenu,
+ handleCopyPathFromMenu,
handleDeleteFromMenu,
handleOpenMenu,
+ handleRevealFromMenu,
handleRenameFromMenu,
menuRef,
}
diff --git a/src/hooks/commands/navigationCommands.ts b/src/hooks/commands/navigationCommands.ts
index 8e7105bb..71c00656 100644
--- a/src/hooks/commands/navigationCommands.ts
+++ b/src/hooks/commands/navigationCommands.ts
@@ -8,6 +8,8 @@ interface NavigationCommandsConfig {
selection?: SidebarSelection
onRenameFolder?: () => void
onDeleteFolder?: () => void
+ onRevealSelectedFolder?: () => void
+ onCopySelectedFolderPath?: () => void
showInbox?: boolean
onGoBack?: () => void
onGoForward?: () => void
@@ -15,27 +17,61 @@ interface NavigationCommandsConfig {
canGoForward?: boolean
}
-function buildFolderCommands(
- folderSelected: boolean,
- onRenameFolder?: () => void,
- onDeleteFolder?: () => void,
-): CommandAction[] {
+interface FolderCommandsConfig {
+ folderSelected: boolean
+ onCopySelectedFolderPath?: () => void
+ onDeleteFolder?: () => void
+ onRenameFolder?: () => void
+ onRevealSelectedFolder?: () => void
+}
+
+function canRunFolderCommand(folderSelected: boolean, action?: () => void): boolean {
+ return folderSelected && action !== undefined
+}
+
+function runOptionalCommand(action?: () => void) {
+ action?.()
+}
+
+function buildFolderCommands({
+ folderSelected,
+ onCopySelectedFolderPath,
+ onDeleteFolder,
+ onRenameFolder,
+ onRevealSelectedFolder,
+}: FolderCommandsConfig): CommandAction[] {
return [
+ {
+ id: 'reveal-selected-folder',
+ label: 'Reveal Folder in Finder',
+ group: 'Navigation',
+ keywords: ['folder', 'directory', 'finder', 'reveal', 'show', 'filesystem'],
+ enabled: canRunFolderCommand(folderSelected, onRevealSelectedFolder),
+ execute: () => runOptionalCommand(onRevealSelectedFolder),
+ },
+ {
+ id: 'copy-selected-folder-path',
+ label: 'Copy Folder Path',
+ group: 'Navigation',
+ keywords: ['folder', 'directory', 'path', 'copy', 'clipboard'],
+ enabled: canRunFolderCommand(folderSelected, onCopySelectedFolderPath),
+ execute: () => runOptionalCommand(onCopySelectedFolderPath),
+ },
{
id: 'rename-folder',
label: 'Rename Folder',
group: 'Navigation',
keywords: ['folder', 'directory', 'sidebar', 'rename'],
- enabled: folderSelected && !!onRenameFolder,
- execute: () => onRenameFolder?.(),
+ enabled: canRunFolderCommand(folderSelected, onRenameFolder),
+ execute: () => runOptionalCommand(onRenameFolder),
},
{
id: 'delete-folder',
label: 'Delete Folder',
group: 'Navigation',
keywords: ['folder', 'directory', 'sidebar', 'delete', 'remove'],
- enabled: folderSelected && !!onDeleteFolder,
- execute: () => onDeleteFolder?.(),
+ enabled: canRunFolderCommand(folderSelected, onDeleteFolder),
+ execute: () => runOptionalCommand(onDeleteFolder),
},
]
}
@@ -81,12 +117,20 @@ export function buildNavigationCommands(config: NavigationCommandsConfig): Comma
selection,
onRenameFolder,
onDeleteFolder,
+ onRevealSelectedFolder,
+ onCopySelectedFolderPath,
showInbox = true,
} = config
const folderSelected = selection?.kind === 'folder'
const commands = [
...buildBaseCommands(config),
- ...buildFolderCommands(folderSelected, onRenameFolder, onDeleteFolder),
+ ...buildFolderCommands({
+ folderSelected,
+ onRenameFolder,
+ onDeleteFolder,
+ onRevealSelectedFolder,
+ onCopySelectedFolderPath,
+ }),
]
return insertInboxCommand(commands, showInbox, onSelect)
}
diff --git a/src/hooks/commands/noteCommands.ts b/src/hooks/commands/noteCommands.ts
index 44f5b2d9..70de0fbb 100644
--- a/src/hooks/commands/noteCommands.ts
+++ b/src/hooks/commands/noteCommands.ts
@@ -4,6 +4,7 @@ import type { CommandAction } from './types'
interface NoteCommandsConfig {
hasActiveNote: boolean
activeTabPath: string | null
+ activeFileKind?: 'markdown' | 'text' | 'binary'
isArchived: boolean
activeNoteHasIcon?: boolean
onCreateNote: () => void
@@ -18,6 +19,9 @@ interface NoteCommandsConfig {
onSetNoteIcon?: () => void
onRemoveNoteIcon?: () => void
onOpenInNewWindow?: () => void
+ onRevealActiveFile?: (path: string) => void
+ onCopyActiveFilePath?: (path: string) => void
+ onOpenActiveFileExternal?: (path: string) => void
onToggleFavorite?: (path: string) => void
isFavorite?: boolean
onToggleOrganized?: (path: string) => void
@@ -138,6 +142,7 @@ function buildPinnedNoteCommands(config: NoteCommandsConfig): CommandAction[] {
function buildOptionalNoteCommands(config: NoteCommandsConfig): CommandAction[] {
return [
...buildRecoveryCommands(config),
+ ...buildFileActionCommands(config),
...buildRetargetingCommands(config),
...buildPresentationCommands(config),
]
@@ -181,6 +186,38 @@ function buildRetargetingCommands(config: NoteCommandsConfig): CommandAction[] {
]
}
+function buildFileActionCommands(config: NoteCommandsConfig): CommandAction[] {
+ const activeFileKind = config.activeFileKind ?? 'markdown'
+ const hasNonMarkdownActiveFile = config.hasActiveNote && activeFileKind !== 'markdown'
+
+ return [
+ createNoteCommand({
+ id: 'reveal-active-file',
+ label: 'Reveal in Finder',
+ keywords: ['file', 'folder', 'finder', 'reveal', 'show', 'filesystem'],
+ enabled: config.hasActiveNote && !!config.onRevealActiveFile,
+ path: config.activeTabPath,
+ run: (path) => config.onRevealActiveFile?.(path),
+ }),
+ createNoteCommand({
+ id: 'copy-active-file-path',
+ label: 'Copy File Path',
+ keywords: ['file', 'path', 'copy', 'clipboard', 'filesystem'],
+ enabled: config.hasActiveNote && !!config.onCopyActiveFilePath,
+ path: config.activeTabPath,
+ run: (path) => config.onCopyActiveFilePath?.(path),
+ }),
+ createNoteCommand({
+ id: 'open-active-file-external',
+ label: 'Open in Default App',
+ keywords: ['file', 'open', 'external', 'default', 'attachment'],
+ enabled: hasNonMarkdownActiveFile && !!config.onOpenActiveFileExternal,
+ path: config.activeTabPath,
+ run: (path) => config.onOpenActiveFileExternal?.(path),
+ }),
+ ]
+}
+
function buildPresentationCommands(config: NoteCommandsConfig): CommandAction[] {
return [
createNoteCommand({
diff --git a/src/hooks/useAppCommands.ts b/src/hooks/useAppCommands.ts
index 3b7f2dfe..03109d2f 100644
--- a/src/hooks/useAppCommands.ts
+++ b/src/hooks/useAppCommands.ts
@@ -96,6 +96,11 @@ interface AppCommandsConfig {
noteListFilter?: NoteListFilter
onSetNoteListFilter?: (filter: NoteListFilter) => void
onOpenInNewWindow?: () => void
+ onRevealActiveFile?: (path: string) => void
+ onCopyActiveFilePath?: (path: string) => void
+ onOpenActiveFileExternal?: (path: string) => void
+ onRevealSelectedFolder?: () => void
+ onCopySelectedFolderPath?: () => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onCustomizeNoteListColumns?: () => void
@@ -116,6 +121,8 @@ type CommandRegistrySelectionState = Pick<
| 'onSelect'
| 'onRenameFolder'
| 'onDeleteFolder'
+ | 'onRevealSelectedFolder'
+ | 'onCopySelectedFolderPath'
| 'showInbox'
| 'onGoBack'
| 'onGoForward'
@@ -169,6 +176,9 @@ type CommandRegistryVaultActions = Pick<
| 'onReloadVault'
| 'onRepairVault'
| 'onOpenInNewWindow'
+ | 'onRevealActiveFile'
+ | 'onCopyActiveFilePath'
+ | 'onOpenActiveFileExternal'
| 'onRestoreDeletedNote'
| 'canRestoreDeletedNote'
>
@@ -363,6 +373,8 @@ function createCommandRegistrySelectionConfig(
onSelect: config.onSelect,
onRenameFolder: config.onRenameFolder,
onDeleteFolder: config.onDeleteFolder,
+ onRevealSelectedFolder: config.onRevealSelectedFolder,
+ onCopySelectedFolderPath: config.onCopySelectedFolderPath,
showInbox: config.showInbox,
onGoBack: config.onGoBack,
onGoForward: config.onGoForward,
@@ -424,6 +436,9 @@ function createCommandRegistryVaultConfig(
onReloadVault: config.onReloadVault,
onRepairVault: config.onRepairVault,
onOpenInNewWindow: config.onOpenInNewWindow,
+ onRevealActiveFile: config.onRevealActiveFile,
+ onCopyActiveFilePath: config.onCopyActiveFilePath,
+ onOpenActiveFileExternal: config.onOpenActiveFileExternal,
onRestoreDeletedNote: config.onRestoreDeletedNote,
canRestoreDeletedNote: config.canRestoreDeletedNote,
}
diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts
index 8f6489f5..a8919f2e 100644
--- a/src/hooks/useCommandRegistry.test.ts
+++ b/src/hooks/useCommandRegistry.test.ts
@@ -281,6 +281,62 @@ describe('useCommandRegistry', () => {
expect(findCommand(result.current, 'archive-note')?.shortcut).toBeUndefined()
})
+ it('exposes active file actions when a note is selected', () => {
+ const onRevealActiveFile = vi.fn()
+ const onCopyActiveFilePath = vi.fn()
+ const config = makeConfig({
+ activeTabPath: '/vault/current.md',
+ entries: [{ path: '/vault/current.md', title: 'Current', fileKind: 'markdown' }],
+ onRevealActiveFile,
+ onCopyActiveFilePath,
+ })
+ const { result } = renderHook(() => useCommandRegistry(config))
+
+ expect(findCommand(result.current, 'reveal-active-file')).toMatchObject({
+ enabled: true,
+ group: 'Note',
+ label: 'Reveal in Finder',
+ })
+ expect(findCommand(result.current, 'copy-active-file-path')).toMatchObject({
+ enabled: true,
+ group: 'Note',
+ label: 'Copy File Path',
+ })
+
+ findCommand(result.current, 'reveal-active-file')!.execute()
+ findCommand(result.current, 'copy-active-file-path')!.execute()
+
+ expect(onRevealActiveFile).toHaveBeenCalledWith('/vault/current.md')
+ expect(onCopyActiveFilePath).toHaveBeenCalledWith('/vault/current.md')
+ })
+
+ it('only enables external open for non-markdown active files', () => {
+ const onOpenActiveFileExternal = vi.fn()
+ const { result, rerender } = renderHook(
+ (props) => useCommandRegistry(props),
+ {
+ initialProps: makeConfig({
+ activeTabPath: '/vault/current.md',
+ entries: [{ path: '/vault/current.md', title: 'Current', fileKind: 'markdown' }],
+ onOpenActiveFileExternal,
+ }),
+ },
+ )
+
+ expect(findCommand(result.current, 'open-active-file-external')?.enabled).toBe(false)
+
+ rerender(makeConfig({
+ activeTabPath: '/vault/Attachments/photo.png',
+ entries: [{ path: '/vault/Attachments/photo.png', title: 'photo.png', fileKind: 'binary' }],
+ onOpenActiveFileExternal,
+ }))
+
+ const command = findCommand(result.current, 'open-active-file-external')!
+ expect(command.enabled).toBe(true)
+ command.execute()
+ expect(onOpenActiveFileExternal).toHaveBeenCalledWith('/vault/Attachments/photo.png')
+ })
+
it('disables Toggle Raw Editor when the active file cannot switch to rich mode', () => {
const config = makeConfig({ onToggleRawEditor: undefined })
const { result } = renderHook(() => useCommandRegistry(config))
@@ -339,9 +395,13 @@ describe('useCommandRegistry', () => {
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)
})
@@ -351,9 +411,13 @@ describe('useCommandRegistry', () => {
selection: { kind: 'filter', filter: 'all' },
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(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)
})
@@ -361,16 +425,24 @@ describe('useCommandRegistry', () => {
it('executes folder command callbacks', () => {
const onRenameFolder = vi.fn()
const onDeleteFolder = vi.fn()
+ const onRevealSelectedFolder = vi.fn()
+ const onCopySelectedFolderPath = vi.fn()
const config = makeConfig({
selection: { kind: 'folder', path: 'projects' },
onRenameFolder,
onDeleteFolder,
+ onRevealSelectedFolder,
+ onCopySelectedFolderPath,
})
const { result } = renderHook(() => useCommandRegistry(config))
+ findCommand(result.current, 'reveal-selected-folder')!.execute()
+ findCommand(result.current, 'copy-selected-folder-path')!.execute()
findCommand(result.current, 'rename-folder')!.execute()
findCommand(result.current, 'delete-folder')!.execute()
+ expect(onRevealSelectedFolder).toHaveBeenCalledTimes(1)
+ expect(onCopySelectedFolderPath).toHaveBeenCalledTimes(1)
expect(onRenameFolder).toHaveBeenCalledTimes(1)
expect(onDeleteFolder).toHaveBeenCalledTimes(1)
})
diff --git a/src/hooks/useCommandRegistry.ts b/src/hooks/useCommandRegistry.ts
index a2f3e922..3da62c6c 100644
--- a/src/hooks/useCommandRegistry.ts
+++ b/src/hooks/useCommandRegistry.ts
@@ -50,6 +50,9 @@ interface CommandRegistryConfig {
onMoveNoteToFolder?: () => void
canMoveNoteToFolder?: boolean
onOpenInNewWindow?: () => void
+ onRevealActiveFile?: (path: string) => void
+ onCopyActiveFilePath?: (path: string) => void
+ onOpenActiveFileExternal?: (path: string) => void
onToggleFavorite?: (path: string) => void
onToggleOrganized?: (path: string) => void
onCustomizeNoteListColumns?: () => void
@@ -92,6 +95,8 @@ interface CommandRegistryConfig {
onSelect: (sel: SidebarSelection) => void
onRenameFolder?: () => void
onDeleteFolder?: () => void
+ onRevealSelectedFolder?: () => void
+ onCopySelectedFolderPath?: () => void
showInbox?: boolean
onGoBack?: () => void
onGoForward?: () => void
@@ -114,7 +119,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, noteLayout, onToggleNoteLayout, onToggleAIChat, onOpenVault, onCreateEmptyVault,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
- onSelect, onRenameFolder, onDeleteFolder,
+ onSelect, onRenameFolder, onDeleteFolder, onRevealSelectedFolder, onCopySelectedFolderPath,
showInbox,
onGoBack, onGoForward, canGoBack, canGoForward,
onCheckForUpdates, onCreateType,
@@ -124,7 +129,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
onReloadVault, onRepairVault,
locale, systemLocale, selectedUiLanguage, onSetUiLanguage,
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
- onOpenInNewWindow, onToggleFavorite, onToggleOrganized,
+ onOpenInNewWindow, onRevealActiveFile, onCopyActiveFilePath, onOpenActiveFileExternal, onToggleFavorite, onToggleOrganized,
onCustomizeNoteListColumns, canCustomizeNoteListColumns,
onRestoreDeletedNote, canRestoreDeletedNote,
selection, noteListFilter, onSetNoteListFilter,
@@ -154,6 +159,8 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
selection,
onRenameFolder,
onDeleteFolder,
+ onRevealSelectedFolder,
+ onCopySelectedFolderPath,
showInbox,
onGoBack,
onGoForward,
@@ -161,22 +168,27 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
canGoForward,
}), [
onQuickOpen, onSelect, selection, onRenameFolder, onDeleteFolder,
- showInbox, onGoBack, onGoForward, canGoBack, canGoForward,
+ onRevealSelectedFolder, onCopySelectedFolderPath, showInbox,
+ onGoBack, onGoForward, canGoBack, canGoForward,
])
const noteCommands = useMemo(() => buildNoteCommands({
- hasActiveNote, activeTabPath, isArchived,
+ hasActiveNote, activeTabPath, activeFileKind: activeEntry?.fileKind ?? 'markdown', isArchived,
onCreateNote, onCreateType, onSave,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
- onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, isFavorite,
+ onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow,
+ onRevealActiveFile, onCopyActiveFilePath, onOpenActiveFileExternal,
+ onToggleFavorite, isFavorite,
onToggleOrganized, isOrganized: activeEntry?.organized ?? false,
onRestoreDeletedNote, canRestoreDeletedNote,
}), [
- hasActiveNote, activeTabPath, isArchived,
+ hasActiveNote, activeTabPath, activeEntry?.fileKind, isArchived,
onCreateNote, onCreateType, onSave, onDeleteNote, onArchiveNote, onUnarchiveNote,
onChangeNoteType, onMoveNoteToFolder, canMoveNoteToFolder,
- onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, isFavorite,
+ onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow,
+ onRevealActiveFile, onCopyActiveFilePath, onOpenActiveFileExternal,
+ onToggleFavorite, isFavorite,
onToggleOrganized, activeEntry?.organized, onRestoreDeletedNote, canRestoreDeletedNote,
])
diff --git a/src/hooks/useFileActions.ts b/src/hooks/useFileActions.ts
new file mode 100644
index 00000000..082f4ffd
--- /dev/null
+++ b/src/hooks/useFileActions.ts
@@ -0,0 +1,88 @@
+import { useCallback, useMemo } from 'react'
+import type { SidebarSelection } from '../types'
+import { folderAbsolutePath } from './folder-actions/folderActionUtils'
+import { copyLocalPath, openLocalFile, revealLocalPath } from '../utils/url'
+
+export interface FolderFileActions {
+ copyFolderPath: (folderPath: string) => void
+ revealFolder: (folderPath: string) => void
+}
+
+interface UseFileActionsInput {
+ selection: SidebarSelection
+ setToastMessage: (message: string) => void
+ vaultPath: string
+}
+
+function fileActionErrorMessage(action: string, error: unknown): string {
+ const detail = error instanceof Error ? error.message : String(error)
+ return `Failed to ${action}: ${detail}`
+}
+
+export function useFileActions({
+ selection,
+ setToastMessage,
+ vaultPath,
+}: UseFileActionsInput) {
+ const revealFile = useCallback((path: string) => {
+ void revealLocalPath(path).catch((error) => {
+ setToastMessage(fileActionErrorMessage('reveal path', error))
+ })
+ }, [setToastMessage])
+
+ const copyFilePath = useCallback((path: string) => {
+ void copyLocalPath(path)
+ .then(() => setToastMessage('File path copied'))
+ .catch((error) => {
+ setToastMessage(fileActionErrorMessage('copy path', error))
+ })
+ }, [setToastMessage])
+
+ const openExternalFile = useCallback((path: string) => {
+ void openLocalFile(path).catch((error) => {
+ setToastMessage(fileActionErrorMessage('open file', error))
+ })
+ }, [setToastMessage])
+
+ const resolveFolderPath = useCallback((folderPath: string) => (
+ folderAbsolutePath({ vaultPath, folderPath })
+ ), [vaultPath])
+
+ const folderActions = useMemo(() => ({
+ copyFolderPath: (folderPath) => {
+ const absolutePath = resolveFolderPath(folderPath)
+ void copyLocalPath(absolutePath)
+ .then(() => setToastMessage('Folder path copied'))
+ .catch((error) => {
+ setToastMessage(fileActionErrorMessage('copy folder path', error))
+ })
+ },
+ revealFolder: (folderPath) => revealFile(resolveFolderPath(folderPath)),
+ }), [resolveFolderPath, revealFile, setToastMessage])
+
+ const revealSelectedFolder = useCallback(() => {
+ if (selection.kind !== 'folder') return
+ folderActions.revealFolder(selection.path)
+ }, [folderActions, selection])
+
+ const copySelectedFolderPath = useCallback(() => {
+ if (selection.kind !== 'folder') return
+ folderActions.copyFolderPath(selection.path)
+ }, [folderActions, selection])
+
+ return useMemo(() => ({
+ copyFilePath,
+ copySelectedFolderPath,
+ folderActions,
+ openExternalFile,
+ revealFile,
+ revealSelectedFolder,
+ }), [
+ copyFilePath,
+ copySelectedFolderPath,
+ folderActions,
+ openExternalFile,
+ revealFile,
+ revealSelectedFolder,
+ ])
+}
diff --git a/src/lib/locales/en.ts b/src/lib/locales/en.ts
index c67c8a41..c42b7600 100644
--- a/src/lib/locales/en.ts
+++ b/src/lib/locales/en.ts
@@ -163,6 +163,8 @@ export const EN_TRANSLATIONS = {
'sidebar.action.createFolder': 'Create folder',
'sidebar.action.renameFolder': 'Rename folder',
'sidebar.action.deleteFolder': 'Delete folder',
+ 'sidebar.action.revealFolderMenu': 'Reveal in Finder',
+ 'sidebar.action.copyFolderPathMenu': 'Copy folder path',
'sidebar.action.renameFolderMenu': 'Rename folder...',
'sidebar.action.deleteFolderMenu': 'Delete folder...',
'sidebar.folder.name': 'Folder name',
@@ -241,6 +243,8 @@ export const EN_TRANSLATIONS = {
'editor.toolbar.restoreArchived': 'Restore this archived note',
'editor.toolbar.archive': 'Archive this note',
'editor.toolbar.delete': 'Delete this note',
+ 'editor.toolbar.revealFile': 'Reveal in Finder',
+ 'editor.toolbar.copyFilePath': 'Copy file path',
'editor.toolbar.openProperties': 'Open the properties panel',
'editor.filename.rename': 'Rename filename',
'editor.filename.renameToTitle': 'Rename the file to match the title',
diff --git a/src/lib/locales/zh-Hans.ts b/src/lib/locales/zh-Hans.ts
index d5d2ec77..a054ed86 100644
--- a/src/lib/locales/zh-Hans.ts
+++ b/src/lib/locales/zh-Hans.ts
@@ -165,6 +165,8 @@ export const ZH_HANS_TRANSLATIONS = {
'sidebar.action.createFolder': '创建文件夹',
'sidebar.action.renameFolder': '重命名文件夹',
'sidebar.action.deleteFolder': '删除文件夹',
+ 'sidebar.action.revealFolderMenu': '在访达中显示',
+ 'sidebar.action.copyFolderPathMenu': '复制文件夹路径',
'sidebar.action.renameFolderMenu': '重命名文件夹...',
'sidebar.action.deleteFolderMenu': '删除文件夹...',
'sidebar.folder.name': '文件夹名称',
@@ -243,6 +245,8 @@ export const ZH_HANS_TRANSLATIONS = {
'editor.toolbar.restoreArchived': '恢复这条归档笔记',
'editor.toolbar.archive': '归档这条笔记',
'editor.toolbar.delete': '删除这条笔记',
+ 'editor.toolbar.revealFile': '在访达中显示',
+ 'editor.toolbar.copyFilePath': '复制文件路径',
'editor.toolbar.openProperties': '打开属性面板',
'editor.filename.rename': '重命名文件名',
'editor.filename.renameToTitle': '将文件重命名为匹配标题',
diff --git a/src/test/setup.ts b/src/test/setup.ts
index 6ce7bb16..5b21ceea 100644
--- a/src/test/setup.ts
+++ b/src/test/setup.ts
@@ -54,7 +54,9 @@ globalThis.IntersectionObserver = class {
// Mock @tauri-apps/plugin-opener for test environment
vi.mock('@tauri-apps/plugin-opener', () => ({
+ openPath: vi.fn(),
openUrl: vi.fn(),
+ revealItemInDir: vi.fn(),
}))
afterEach(() => {
diff --git a/src/utils/url.test.ts b/src/utils/url.test.ts
index 53d5a1fb..15725d31 100644
--- a/src/utils/url.test.ts
+++ b/src/utils/url.test.ts
@@ -1,5 +1,21 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
-import { normalizeExternalUrl, openExternalUrl } from './url'
+import { openPath, revealItemInDir } from '@tauri-apps/plugin-opener'
+import {
+ copyLocalPath,
+ normalizeExternalUrl,
+ openExternalUrl,
+ openLocalFile,
+ revealLocalPath,
+} from './url'
+
+const originalClipboard = navigator.clipboard
+
+function setClipboard(writeText: (value: string) => Promise) {
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText },
+ })
+}
describe('normalizeExternalUrl', () => {
it('keeps valid http URLs and normalizes bare domains', () => {
@@ -16,6 +32,7 @@ describe('normalizeExternalUrl', () => {
describe('openExternalUrl', () => {
afterEach(() => {
+ vi.unstubAllGlobals()
vi.restoreAllMocks()
})
@@ -27,3 +44,39 @@ describe('openExternalUrl', () => {
expect(open).not.toHaveBeenCalled()
})
})
+
+describe('local file actions', () => {
+ afterEach(() => {
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: originalClipboard,
+ })
+ vi.unstubAllGlobals()
+ vi.clearAllMocks()
+ })
+
+ it('opens local paths through the Tauri opener plugin', async () => {
+ vi.stubGlobal('isTauri', true)
+
+ await openLocalFile('/vault/attachments/report.pdf')
+
+ expect(openPath).toHaveBeenCalledWith('/vault/attachments/report.pdf')
+ })
+
+ it('reveals local paths through the Tauri opener plugin', async () => {
+ vi.stubGlobal('isTauri', true)
+
+ await revealLocalPath('/vault/notes/project.md')
+
+ expect(revealItemInDir).toHaveBeenCalledWith('/vault/notes/project.md')
+ })
+
+ it('copies local paths to the clipboard', async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined)
+ setClipboard(writeText)
+
+ await copyLocalPath('/vault/Folder With Spaces/项目.md')
+
+ expect(writeText).toHaveBeenCalledWith('/vault/Folder With Spaces/项目.md')
+ })
+})
diff --git a/src/utils/url.ts b/src/utils/url.ts
index 8b361a25..83bb0ae8 100644
--- a/src/utils/url.ts
+++ b/src/utils/url.ts
@@ -56,3 +56,20 @@ export async function openLocalFile(absolutePath: string): Promise {
await openPath(absolutePath)
}
}
+
+/** Reveal a local file or folder in the system file manager. */
+export async function revealLocalPath(absolutePath: string): Promise {
+ if (isTauri()) {
+ const { revealItemInDir } = await import('@tauri-apps/plugin-opener')
+ await revealItemInDir(absolutePath)
+ }
+}
+
+/** Copy a local file or folder path to the system clipboard. */
+export async function copyLocalPath(absolutePath: string): Promise {
+ if (!navigator.clipboard?.writeText) {
+ throw new Error('Clipboard API is unavailable')
+ }
+
+ await navigator.clipboard.writeText(absolutePath)
+}