feat: add basic file actions
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
16
src/App.tsx
16
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 && (
|
||||
<>
|
||||
<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}
|
||||
|
||||
@@ -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()} />)
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
67
src/components/FilePreview.test.tsx
Normal file
67
src/components/FilePreview.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
|
||||
88
src/hooks/useFileActions.ts
Normal file
88
src/hooks/useFileActions.ts
Normal 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,
|
||||
])
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '将文件重命名为匹配标题',
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user