feat: add basic file actions

This commit is contained in:
lucaronin
2026-04-27 01:45:16 +02:00
parent d15437face
commit 908daafaa1
26 changed files with 734 additions and 63 deletions

View File

@@ -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:

View File

@@ -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:

View File

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

View File

@@ -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(<BreadcrumbBar entry={baseEntry} {...defaultProps} onRevealFile={onRevealFile} />)
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(<BreadcrumbBar entry={baseEntry} {...defaultProps} onCopyFilePath={onCopyFilePath} />)
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(<BreadcrumbBar entry={baseEntry} {...defaultProps} onToggleOrganized={vi.fn()} />)

View File

@@ -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<BreadcrumbBarProps, 'loc
)
}
function FilePathActions({
entry,
locale = 'en',
onRevealFile,
onCopyFilePath,
}: Pick<BreadcrumbBarProps, 'entry' | 'locale' | 'onRevealFile' | 'onCopyFilePath'>) {
return (
<>
{onRevealFile && (
<IconActionButton
copy={{ label: translate(locale, 'editor.toolbar.revealFile') }}
onClick={() => onRevealFile(entry.path)}
className="hover:text-foreground"
testId="breadcrumb-reveal-file"
>
<FolderOpen size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)}
{onCopyFilePath && (
<IconActionButton
copy={{ label: translate(locale, 'editor.toolbar.copyFilePath') }}
onClick={() => onCopyFilePath(entry.path)}
className="hover:text-foreground"
testId="breadcrumb-copy-file-path"
>
<ClipboardText size={16} className={BREADCRUMB_ICON_CLASS} />
</IconActionButton>
)}
</>
)
}
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 && <RawToggleButton rawMode={rawMode} locale={locale} onToggleRaw={onToggleRaw} />}
<NoteLayoutAction noteLayout={noteLayout} locale={locale} onToggleNoteLayout={onToggleNoteLayout} />
<AIChatAction showAIChat={showAIChat} locale={locale} onToggleAIChat={onToggleAIChat} />
<FilePathActions entry={entry} locale={locale} onRevealFile={onRevealFile} onCopyFilePath={onCopyFilePath} />
<ArchiveAction archived={entry.archived} locale={locale} onArchive={onArchive} onUnarchive={onUnarchive} />
<DeleteAction locale={locale} onDelete={onDelete} />
<InspectorAction inspectorCollapsed={inspectorCollapsed} locale={locale} onToggleInspector={onToggleInspector} />

View File

@@ -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
? <EditorEmptyState locale={locale} />
: activeBinaryTab
? <FilePreview entry={activeBinaryTab.entry} />
? (
<FilePreview
entry={activeBinaryTab.entry}
onCopyFilePath={onCopyFilePath}
onOpenExternalFile={onOpenExternalFile}
onRevealFile={onRevealFile}
/>
)
: <EditorContent
activeTab={activeTab}
isLoadingNewTab={isLoadingNewTab}
@@ -422,6 +438,8 @@ function EditorLayout({
onEditorChange={handleEditorChange}
onToggleFavorite={onToggleFavorite}
onToggleOrganized={onToggleOrganized}
onRevealFile={onRevealFile}
onCopyFilePath={onCopyFilePath}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
@@ -486,7 +504,8 @@ export const Editor = memo(function Editor(props: EditorProps) {
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateMissingType, onCreateAndOpenNote, onInitializeProperties,
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
onToggleFavorite, onToggleOrganized, onRevealFile, onCopyFilePath, onOpenExternalFile,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onRenameFilename,
noteLayout, onToggleNoteLayout,
onFileCreated, onFileModified, onVaultChanged,
@@ -543,6 +562,9 @@ export const Editor = memo(function Editor(props: EditorProps) {
handleEditorChange={handleEditorChange}
onToggleFavorite={onToggleFavorite}
onToggleOrganized={onToggleOrganized}
onRevealFile={onRevealFile}
onCopyFilePath={onCopyFilePath}
onOpenExternalFile={onOpenExternalFile}
onDeleteNote={onDeleteNote}
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}

View File

@@ -0,0 +1,67 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { FilePreview } from './FilePreview'
import type { VaultEntry } from '../types'
vi.mock('@tauri-apps/api/core', () => ({
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(
<FilePreview
entry={imageEntry}
onRevealFile={onRevealFile}
onCopyFilePath={onCopyFilePath}
onOpenExternalFile={onOpenExternalFile}
/>,
)
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')
})
})

View File

@@ -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({
<p className="m-0 text-[11px] text-muted-foreground">{fileTypeLabel}</p>
</div>
</div>
<Button type="button" variant="ghost" size="sm" onClick={onOpenExternal}>
<ArrowSquareOut size={15} />
Open
</Button>
<div className="flex items-center gap-1">
{onRevealFile && (
<Button type="button" variant="ghost" size="sm" onClick={onRevealFile}>
<FolderOpen size={15} />
Reveal
</Button>
)}
{onCopyFilePath && (
<Button type="button" variant="ghost" size="sm" onClick={onCopyFilePath}>
<ClipboardText size={15} />
Copy path
</Button>
)}
<Button type="button" variant="ghost" size="sm" onClick={onOpenExternal}>
<ArrowSquareOut size={15} />
Open
</Button>
</div>
</div>
)
}
@@ -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<HTMLDivElement>) => {
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}
/>
<div className="min-h-0 flex-1 overflow-auto bg-background">
<FilePreviewBody

View File

@@ -223,4 +223,29 @@ describe('FolderTree', () => {
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(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={vi.fn()}
folderFileActions={{
copyFolderPath: onCopyFolderPath,
revealFolder: onRevealFolder,
}}
onStartRenameFolder={vi.fn()}
/>,
)
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')
})
})

View File

@@ -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> | boolean
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | 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 (
<div className="border-b border-border" style={{ padding: '0 6px' }}>
<SidebarGroupHeader label={translate(locale, 'sidebar.group.folders')} collapsed={sectionCollapsed} onToggle={handleToggleSection}>
{onCreateFolder && (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-auto w-auto min-w-0 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
data-testid="create-folder-btn"
title={translate(locale, 'sidebar.action.createFolder')}
aria-label={translate(locale, 'sidebar.action.createFolder')}
onClick={(event) => {
event.stopPropagation()
closeContextMenu()
openCreateForm()
}}
>
<Plus size={12} className="text-muted-foreground hover:text-foreground" />
</Button>
<CreateFolderButton locale={locale} onCreate={handleCreateFolderClick} />
)}
</SidebarGroupHeader>
{!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}
/>
</div>
)
})
function CreateFolderButton({
locale,
onCreate,
}: {
locale: AppLocale
onCreate: () => void
}) {
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-auto w-auto min-w-0 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
data-testid="create-folder-btn"
title={translate(locale, 'sidebar.action.createFolder')}
aria-label={translate(locale, 'sidebar.action.createFolder')}
onClick={(event) => {
event.stopPropagation()
onCreate()
}}
>
<Plus size={12} className="text-muted-foreground hover:text-foreground" />
</Button>
)
}

View File

@@ -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> | boolean
onRenameFolder?: (folderPath: string, nextName: string) => Promise<boolean> | 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}

View File

@@ -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,

View File

@@ -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

View File

@@ -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<HTMLDivElement | null>
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 && (
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start gap-2 px-2 py-1.5 text-sm"
onClick={() => onReveal(menu.path)}
data-testid="reveal-folder-menu-item"
>
<FolderOpen size={14} />
{translate(locale, 'sidebar.action.revealFolderMenu')}
</Button>
)}
{onCopyPath && (
<Button
type="button"
variant="ghost"
className="h-auto w-full justify-start gap-2 px-2 py-1.5 text-sm"
onClick={() => onCopyPath(menu.path)}
data-testid="copy-folder-path-menu-item"
>
<ClipboardText size={14} />
{translate(locale, 'sidebar.action.copyFolderPathMenu')}
</Button>
)}
<Button
type="button"
variant="ghost"

View File

@@ -1,21 +1,19 @@
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type RefObject } from 'react'
import type { FolderNode } from '../../types'
import type { FolderFileActions } from '../../hooks/useFileActions'
import type { FolderContextMenuState } from './FolderContextMenu'
interface UseFolderContextMenuInput {
onDeleteFolder?: (folderPath: string) => void
folderFileActions?: FolderFileActions
onStartRenameFolder?: (folderPath: string) => void
}
export function useFolderContextMenu({
onDeleteFolder,
onStartRenameFolder,
}: UseFolderContextMenuInput) {
const [contextMenu, setContextMenu] = useState<FolderContextMenuState | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
const closeContextMenu = useCallback(() => setContextMenu(null), [])
function useContextMenuDismiss(
contextMenu: FolderContextMenuState | null,
menuRef: RefObject<HTMLDivElement | null>,
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<FolderContextMenuState | null>(null)
const menuRef = useRef<HTMLDivElement>(null)
const closeContextMenu = useCallback(() => setContextMenu(null), [])
useContextMenuDismiss(contextMenu, menuRef, closeContextMenu)
const handleOpenMenu = useCallback((node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => {
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,
}

View File

@@ -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)
}

View File

@@ -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({

View File

@@ -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,
}

View File

@@ -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)
})

View File

@@ -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,
])

View File

@@ -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<FolderFileActions>(() => ({
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,
])
}

View File

@@ -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',

View File

@@ -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': '将文件重命名为匹配标题',

View File

@@ -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(() => {

View File

@@ -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<void>) {
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')
})
})

View File

@@ -56,3 +56,20 @@ export async function openLocalFile(absolutePath: string): Promise<void> {
await openPath(absolutePath)
}
}
/** Reveal a local file or folder in the system file manager. */
export async function revealLocalPath(absolutePath: string): Promise<void> {
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<void> {
if (!navigator.clipboard?.writeText) {
throw new Error('Clipboard API is unavailable')
}
await navigator.clipboard.writeText(absolutePath)
}