feat: preview vault images in app
This commit is contained in:
@@ -143,9 +143,22 @@ interface VaultEntry {
|
||||
trashed: boolean // Kept for backward compatibility (Trash system removed — delete is permanent)
|
||||
trashedAt: number | null // Kept for backward compatibility (Trash system removed)
|
||||
properties: Record<string, string> // Scalar frontmatter fields (custom properties)
|
||||
fileKind?: 'markdown' | 'text' | 'binary' // Controls editor/raw/preview behavior
|
||||
}
|
||||
```
|
||||
|
||||
### File kinds and binary previews
|
||||
|
||||
`VaultEntry.fileKind` comes from the Rust vault scanner and intentionally stays coarse-grained:
|
||||
|
||||
| `fileKind` | Source files | UI behavior |
|
||||
|---|---|---|
|
||||
| `markdown` or absent | `.md`, `.markdown` | Full Tolaria note model: frontmatter, BlockNote, raw editor, relationships, title sync |
|
||||
| `text` | UTF-8 editable formats such as `.yml`, `.json`, `.ts`, `.py`, `.sh` | Opens through the raw editor without Markdown note semantics |
|
||||
| `binary` | Images, PDFs, archives, other non-text files | Stays a normal vault file; previewable images open in `FilePreview`, unsupported or broken binaries show an explicit fallback |
|
||||
|
||||
Image previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects.
|
||||
|
||||
### Entity Types (isA / type)
|
||||
|
||||
Entity type is stored in the `type:` frontmatter field (e.g. `type: Quarter`). The legacy field name `Is A:` is still accepted as an alias for backwards compatibility but new notes use `type:`. The `VaultEntry.isA` property in TypeScript/Rust holds the resolved value.
|
||||
|
||||
@@ -179,8 +179,8 @@ 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/`.
|
||||
- **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. 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. Decomposed into `Editor` (orchestrator), `EditorContent`, `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.
|
||||
- **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).
|
||||
|
||||
Panels are separated by `ResizeHandle` components that support drag-to-resize.
|
||||
|
||||
32
docs/adr/0086-in-app-image-file-preview.md
Normal file
32
docs/adr/0086-in-app-image-file-preview.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0086"
|
||||
title: "In-app image previews for binary vault files"
|
||||
status: active
|
||||
date: 2026-04-26
|
||||
supersedes: "0041"
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0041 made the vault scanner index all visible files and introduced `fileKind` as `"markdown"`, `"text"`, or `"binary"`. Binary files were deliberately shown as inert entries until Tolaria had a dedicated preview model.
|
||||
|
||||
That made image references visible in folder views, but opening an image still felt outside the normal Tolaria workflow. Users need to inspect screenshots, diagrams, and other image assets while keeping their place in the vault.
|
||||
|
||||
## Decision
|
||||
|
||||
**Tolaria previews supported image files in the editor pane while keeping them as ordinary binary `VaultEntry` files.**
|
||||
|
||||
- The scanner keeps the existing `fileKind: "binary"` representation. Image previewability is inferred in the renderer from the file extension, not by introducing a proprietary image document type.
|
||||
- Opening a binary entry creates the same single active-tab state used for notes, but with empty content and no `get_note_content` text read.
|
||||
- `FilePreview` renders supported image extensions through Tauri's asset protocol (`convertFileSrc`) so the original file remains on disk.
|
||||
- Broken images and unsupported binary files render an explicit fallback state with an intentional "Open in default app" action instead of launching another app automatically.
|
||||
- Note-list rows use an image indicator for previewable image binaries. Unsupported binary rows remain muted and non-clickable in the normal list surface.
|
||||
- The preview surface is keyboard focusable and `Escape` returns focus to the note list, matching the app's keyboard-first navigation model.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The existing `VaultEntry` model and cache version do not need to change.
|
||||
- Supported image files can participate in normal selection/navigation context without being converted into Markdown notes.
|
||||
- Unsupported/broken binary files have a clear in-app state when reached through navigation paths that can select them.
|
||||
- Any future PDF, audio, or video preview should extend the same file-preview renderer rather than adding new vault-owned document representations.
|
||||
@@ -18,6 +18,11 @@ Object.defineProperty(window, 'matchMedia', {
|
||||
})),
|
||||
})
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
convertFileSrc: vi.fn((path: string) => `asset://localhost/${encodeURIComponent(path)}`),
|
||||
invoke: vi.fn(),
|
||||
}))
|
||||
|
||||
// Hoisted mock editor — available before vi.mock factory runs.
|
||||
// Tests can reconfigure spies (e.g. mockTryParse.mockResolvedValue) before rendering.
|
||||
const mockEditor = vi.hoisted(() => ({
|
||||
@@ -255,6 +260,98 @@ describe('Editor', () => {
|
||||
expect(screen.getByTestId('blocknote-view')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an in-app image preview for binary image tabs', () => {
|
||||
const imageEntry: VaultEntry = {
|
||||
...mockEntry,
|
||||
path: '/vault/assets/photo.png',
|
||||
filename: 'photo.png',
|
||||
title: 'photo.png',
|
||||
fileKind: 'binary',
|
||||
}
|
||||
|
||||
renderEditor({
|
||||
tabs: [{ entry: imageEntry, content: '' }],
|
||||
activeTabPath: imageEntry.path,
|
||||
entries: [imageEntry],
|
||||
})
|
||||
|
||||
const preview = screen.getByTestId('file-preview')
|
||||
expect(preview).toHaveAttribute('tabindex', '0')
|
||||
expect(screen.getByRole('img', { name: 'photo.png' })).toHaveAttribute(
|
||||
'src',
|
||||
'asset://localhost/%2Fvault%2Fassets%2Fphoto.png',
|
||||
)
|
||||
expect(screen.queryByTestId('blocknote-view')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a graceful fallback when an image preview fails to render', () => {
|
||||
const imageEntry: VaultEntry = {
|
||||
...mockEntry,
|
||||
path: '/vault/assets/broken.png',
|
||||
filename: 'broken.png',
|
||||
title: 'broken.png',
|
||||
fileKind: 'binary',
|
||||
}
|
||||
|
||||
renderEditor({
|
||||
tabs: [{ entry: imageEntry, content: '' }],
|
||||
activeTabPath: imageEntry.path,
|
||||
entries: [imageEntry],
|
||||
})
|
||||
|
||||
fireEvent.error(screen.getByRole('img', { name: 'broken.png' }))
|
||||
|
||||
expect(screen.getByTestId('file-preview-fallback')).toHaveTextContent('Image preview failed')
|
||||
expect(screen.getByRole('button', { name: 'Open in default app' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows an explicit unsupported-file fallback for non-image binary tabs', () => {
|
||||
const binaryEntry: VaultEntry = {
|
||||
...mockEntry,
|
||||
path: '/vault/assets/archive.zip',
|
||||
filename: 'archive.zip',
|
||||
title: 'archive.zip',
|
||||
fileKind: 'binary',
|
||||
}
|
||||
|
||||
renderEditor({
|
||||
tabs: [{ entry: binaryEntry, content: '' }],
|
||||
activeTabPath: binaryEntry.path,
|
||||
entries: [binaryEntry],
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('file-preview-fallback')).toHaveTextContent('Preview unavailable')
|
||||
expect(screen.getByText('ZIP file')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('moves focus back to the note list when Escape is pressed on the file preview', () => {
|
||||
const imageEntry: VaultEntry = {
|
||||
...mockEntry,
|
||||
path: '/vault/assets/photo.png',
|
||||
filename: 'photo.png',
|
||||
title: 'photo.png',
|
||||
fileKind: 'binary',
|
||||
}
|
||||
|
||||
render(
|
||||
<>
|
||||
<div data-testid="note-list-container" tabIndex={0} />
|
||||
<Editor
|
||||
{...defaultProps}
|
||||
tabs={[{ entry: imageEntry, content: '' }]}
|
||||
activeTabPath={imageEntry.path}
|
||||
entries={[imageEntry]}
|
||||
/>
|
||||
</>,
|
||||
)
|
||||
|
||||
const preview = screen.getByTestId('file-preview')
|
||||
preview.focus()
|
||||
fireEvent.keyDown(preview, { key: 'Escape' })
|
||||
|
||||
expect(screen.getByTestId('note-list-container')).toHaveFocus()
|
||||
})
|
||||
|
||||
it('passes the runtime CSP style nonce into BlockNote and TipTap', () => {
|
||||
renderEditor({
|
||||
tabs: [mockTab],
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useDragRegion } from '../hooks/useDragRegion'
|
||||
import { formatShortcutDisplay } from '../hooks/appCommandCatalog'
|
||||
import { EditorRightPanel } from './EditorRightPanel'
|
||||
import { EditorContent } from './EditorContent'
|
||||
import { FilePreview } from './FilePreview'
|
||||
import { schema } from './editorSchema'
|
||||
import {
|
||||
applyPendingRawExitContent,
|
||||
@@ -389,12 +390,16 @@ function EditorLayout({
|
||||
onUnsupportedAiPaste?: (message: string) => void
|
||||
locale?: AppLocale
|
||||
}) {
|
||||
const activeBinaryTab = activeTab?.entry.fileKind === 'binary' ? activeTab : null
|
||||
|
||||
return (
|
||||
<div className="editor flex flex-col min-h-0 overflow-hidden bg-background text-foreground">
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{tabs.length === 0
|
||||
? <EditorEmptyState locale={locale} />
|
||||
: <EditorContent
|
||||
: activeBinaryTab
|
||||
? <FilePreview entry={activeBinaryTab.entry} />
|
||||
: <EditorContent
|
||||
activeTab={activeTab}
|
||||
isLoadingNewTab={isLoadingNewTab}
|
||||
entries={entries}
|
||||
|
||||
180
src/components/FilePreview.tsx
Normal file
180
src/components/FilePreview.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
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 type { VaultEntry } from '../types'
|
||||
import { isImagePreviewEntry, previewFileTypeLabel } from '../utils/filePreview'
|
||||
import { focusNoteListContainer } from '../utils/neighborhoodHistory'
|
||||
import { openLocalFile } from '../utils/url'
|
||||
import { Button } from './ui/button'
|
||||
|
||||
interface FilePreviewProps {
|
||||
entry: VaultEntry
|
||||
}
|
||||
|
||||
interface FilePreviewFallbackProps {
|
||||
icon: 'warning' | 'file'
|
||||
title: string
|
||||
description: string
|
||||
onOpenExternal: () => void
|
||||
}
|
||||
|
||||
function FilePreviewFallback({ icon, title, description, onOpenExternal }: FilePreviewFallbackProps) {
|
||||
const Icon = icon === 'warning' ? WarningCircle : FileDashed
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full min-h-[260px] flex-col items-center justify-center gap-4 px-8 text-center"
|
||||
data-testid="file-preview-fallback"
|
||||
>
|
||||
<Icon size={34} className="text-muted-foreground" aria-hidden="true" />
|
||||
<div className="space-y-1">
|
||||
<h2 className="m-0 text-[15px] font-semibold text-foreground">{title}</h2>
|
||||
<p className="m-0 max-w-md text-[13px] leading-6 text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onOpenExternal}>
|
||||
<ArrowSquareOut size={15} />
|
||||
Open in default app
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilePreviewHeader({
|
||||
entry,
|
||||
isImage,
|
||||
fileTypeLabel,
|
||||
onOpenExternal,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
isImage: boolean
|
||||
fileTypeLabel: string
|
||||
onOpenExternal: () => void
|
||||
}) {
|
||||
const HeaderIcon = isImage ? ImageSquare : FileDashed
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<HeaderIcon size={17} className="shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<h1 className="m-0 truncate text-[14px] font-semibold text-foreground">{entry.title}</h1>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
function FilePreviewImage({
|
||||
entry,
|
||||
imageSrc,
|
||||
onImageError,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
imageSrc: string
|
||||
onImageError: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full min-h-[260px] items-center justify-center p-6">
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={entry.title}
|
||||
className="max-h-full max-w-full object-contain"
|
||||
data-testid="image-file-preview"
|
||||
onError={onImageError}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function shouldRenderImagePreview(isImage: boolean, imageSrc: string | null, imageFailed: boolean): imageSrc is string {
|
||||
return isImage && imageSrc !== null && !imageFailed
|
||||
}
|
||||
|
||||
function FilePreviewBody({
|
||||
entry,
|
||||
isImage,
|
||||
imageSrc,
|
||||
imageFailed,
|
||||
onImageError,
|
||||
onOpenExternal,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
isImage: boolean
|
||||
imageSrc: string | null
|
||||
imageFailed: boolean
|
||||
onImageError: () => void
|
||||
onOpenExternal: () => void
|
||||
}) {
|
||||
if (shouldRenderImagePreview(isImage, imageSrc, imageFailed)) {
|
||||
return <FilePreviewImage entry={entry} imageSrc={imageSrc} onImageError={onImageError} />
|
||||
}
|
||||
|
||||
return (
|
||||
<FilePreviewFallback
|
||||
icon={isImage ? 'warning' : 'file'}
|
||||
title={isImage ? 'Image preview failed' : 'Preview unavailable'}
|
||||
description={
|
||||
isImage
|
||||
? 'Tolaria could not render this image file in the preview.'
|
||||
: 'Tolaria does not have an in-app preview for this file type.'
|
||||
}
|
||||
onOpenExternal={onOpenExternal}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function FilePreview({ entry }: FilePreviewProps) {
|
||||
const [imageFailed, setImageFailed] = useState(false)
|
||||
const isImage = isImagePreviewEntry(entry)
|
||||
const imageSrc = useMemo(() => (isImage ? convertFileSrc(entry.path) : null), [entry.path, isImage])
|
||||
const fileTypeLabel = previewFileTypeLabel(entry)
|
||||
const handleImageError = useCallback(() => setImageFailed(true), [])
|
||||
|
||||
const handleOpenExternal = useCallback(() => {
|
||||
void openLocalFile(entry.path).catch((error) => {
|
||||
console.warn('Failed to open file with default app:', error)
|
||||
})
|
||||
}, [entry.path])
|
||||
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key !== 'Escape') return
|
||||
event.preventDefault()
|
||||
focusNoteListContainer(document)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section
|
||||
className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"
|
||||
data-testid="file-preview"
|
||||
tabIndex={0}
|
||||
role="group"
|
||||
aria-label={`Preview ${entry.title}`}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<FilePreviewHeader
|
||||
entry={entry}
|
||||
isImage={isImage}
|
||||
fileTypeLabel={fileTypeLabel}
|
||||
onOpenExternal={handleOpenExternal}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-background">
|
||||
<FilePreviewBody
|
||||
entry={entry}
|
||||
isImage={isImage}
|
||||
imageSrc={imageSrc}
|
||||
imageFailed={imageFailed}
|
||||
onImageError={handleImageError}
|
||||
onOpenExternal={handleOpenExternal}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -19,11 +19,11 @@ describe('NoteItem', () => {
|
||||
openExternalUrl.mockClear()
|
||||
})
|
||||
|
||||
it('renders binary files as non-clickable muted rows', () => {
|
||||
it('renders unsupported binary files as non-clickable muted rows', () => {
|
||||
const binaryEntry = makeEntry({
|
||||
path: '/vault/photo.png',
|
||||
filename: 'photo.png',
|
||||
title: 'photo.png',
|
||||
path: '/vault/archive.zip',
|
||||
filename: 'archive.zip',
|
||||
title: 'archive.zip',
|
||||
fileKind: 'binary',
|
||||
})
|
||||
const onClickNote = vi.fn()
|
||||
@@ -38,6 +38,26 @@ describe('NoteItem', () => {
|
||||
expect(onClickNote).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders image files as clickable rows with an image file indicator', () => {
|
||||
const imageEntry = makeEntry({
|
||||
path: '/vault/photo.png',
|
||||
filename: 'photo.png',
|
||||
title: 'photo.png',
|
||||
fileKind: 'binary',
|
||||
})
|
||||
const onClickNote = vi.fn()
|
||||
|
||||
render(<NoteItem entry={imageEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClickNote} />)
|
||||
|
||||
const item = screen.getByTestId('image-file-item')
|
||||
expect(item.className).not.toContain('opacity-50')
|
||||
expect(item).toHaveAttribute('title', 'Open image preview')
|
||||
|
||||
fireEvent.click(item)
|
||||
expect(onClickNote).toHaveBeenCalledWith(imageEntry, expect.any(Object))
|
||||
expect(screen.getByTestId('type-icon')).toHaveAttribute('data-file-preview-kind', 'image')
|
||||
})
|
||||
|
||||
it('renders text files as clickable rows', () => {
|
||||
const textEntry = makeEntry({
|
||||
path: '/vault/config.yml',
|
||||
|
||||
@@ -4,11 +4,12 @@ import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, FileText, StackSimple,
|
||||
File, FileDashed,
|
||||
File, FileDashed, ImageSquare,
|
||||
} from '@phosphor-icons/react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { resolveIcon } from '../utils/iconRegistry'
|
||||
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
|
||||
import { isImagePreviewEntry } from '../utils/filePreview'
|
||||
import { NoteTitleIcon } from './NoteTitleIcon'
|
||||
import { PropertyChips } from './note-item/PropertyChips'
|
||||
import { ChangeNoteContent } from './note-item/ChangeNoteContent'
|
||||
@@ -61,7 +62,7 @@ function StateBadge({ archived }: { archived: boolean }) {
|
||||
}
|
||||
|
||||
type NoteItemVisualState = {
|
||||
isBinary: boolean
|
||||
isUnavailableBinary: boolean
|
||||
isSelected: boolean
|
||||
isMultiSelected: boolean
|
||||
isHighlighted: boolean
|
||||
@@ -89,8 +90,8 @@ const NOTE_ITEM_ROW_CLASS_NAMES: Record<NoteItemRowState, string> = {
|
||||
default: 'cursor-pointer hover:bg-muted',
|
||||
}
|
||||
|
||||
function resolveNoteItemRowState({ isBinary, isSelected, isMultiSelected, isHighlighted }: NoteItemVisualState): NoteItemRowState {
|
||||
if (isBinary) return 'binary'
|
||||
function resolveNoteItemRowState({ isUnavailableBinary, isSelected, isMultiSelected, isHighlighted }: NoteItemVisualState): NoteItemRowState {
|
||||
if (isUnavailableBinary) return 'binary'
|
||||
if (isMultiSelected) return 'multiSelected'
|
||||
if (isSelected) return 'selected'
|
||||
if (isHighlighted) return 'highlighted'
|
||||
@@ -104,11 +105,22 @@ function noteItemClassName(state: NoteItemVisualState) {
|
||||
function NoteTypeIndicator({
|
||||
TypeIcon,
|
||||
typeColor,
|
||||
filePreviewKind,
|
||||
}: {
|
||||
TypeIcon: ComponentType<SVGAttributes<SVGSVGElement>>
|
||||
typeColor: string
|
||||
filePreviewKind?: 'image'
|
||||
}) {
|
||||
return <TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
|
||||
return (
|
||||
<TypeIcon
|
||||
width={14}
|
||||
height={14}
|
||||
className="absolute right-3 top-2.5"
|
||||
style={{ color: typeColor }}
|
||||
data-testid="type-icon"
|
||||
data-file-preview-kind={filePreviewKind}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NoteSnippet({ snippet }: { snippet?: string | null }) {
|
||||
@@ -190,6 +202,7 @@ function InteractiveNoteDetails({
|
||||
}
|
||||
|
||||
function resolveNoteTypeIcon(entry: VaultEntry, customIcon?: string | null): ComponentType<SVGAttributes<SVGSVGElement>> {
|
||||
if (isImagePreviewEntry(entry)) return ImageSquare
|
||||
if (entry.fileKind && entry.fileKind !== 'markdown') return getFileKindIcon(entry.fileKind)
|
||||
return getTypeIcon(entry.isA, customIcon)
|
||||
}
|
||||
@@ -197,6 +210,7 @@ function resolveNoteTypeIcon(entry: VaultEntry, customIcon?: string | null): Com
|
||||
function StandardNoteContent({
|
||||
entry,
|
||||
isBinary,
|
||||
isUnavailableBinary,
|
||||
noteStatus,
|
||||
isSelected,
|
||||
typeColor,
|
||||
@@ -207,6 +221,7 @@ function StandardNoteContent({
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
isBinary: boolean
|
||||
isUnavailableBinary: boolean
|
||||
noteStatus: NoteStatus
|
||||
isSelected: boolean
|
||||
typeColor: string
|
||||
@@ -217,15 +232,16 @@ function StandardNoteContent({
|
||||
}) {
|
||||
const te = typeEntryMap[entry.isA ?? '']
|
||||
const TypeIcon = resolveNoteTypeIcon(entry, te?.icon)
|
||||
const filePreviewKind = isImagePreviewEntry(entry) ? 'image' : undefined
|
||||
|
||||
return (
|
||||
<>
|
||||
<NoteTypeIndicator TypeIcon={TypeIcon} typeColor={typeColor} />
|
||||
<NoteTypeIndicator TypeIcon={TypeIcon} typeColor={typeColor} filePreviewKind={filePreviewKind} />
|
||||
<div className="space-y-2" data-testid="note-content-stack">
|
||||
{isBinary ? (
|
||||
<NoteTitleRow
|
||||
entry={entry}
|
||||
isBinary={true}
|
||||
isBinary={isUnavailableBinary}
|
||||
isSelected={isSelected}
|
||||
noteStatus={noteStatus}
|
||||
/>
|
||||
@@ -316,10 +332,10 @@ type NoteItemProps = {
|
||||
|
||||
function createNoteItemClickHandler(
|
||||
entry: VaultEntry,
|
||||
isBinary: boolean,
|
||||
isUnavailableBinary: boolean,
|
||||
onClickNote: NoteItemProps['onClickNote'],
|
||||
) {
|
||||
if (isBinary) {
|
||||
if (isUnavailableBinary) {
|
||||
return (event: ReactMouseEvent) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
@@ -328,9 +344,46 @@ function createNoteItemClickHandler(
|
||||
return (event: ReactMouseEvent) => onClickNote(entry, event)
|
||||
}
|
||||
|
||||
function resolveNoteItemSurfaceStyle({
|
||||
isUnavailableBinary,
|
||||
isSelected,
|
||||
isMultiSelected,
|
||||
typeColor,
|
||||
typeLightColor,
|
||||
}: Pick<NoteItemVisualState, 'isUnavailableBinary' | 'isSelected' | 'isMultiSelected'> & {
|
||||
typeColor: string
|
||||
typeLightColor: string
|
||||
}) {
|
||||
if (isUnavailableBinary) return BINARY_NOTE_STYLE
|
||||
return noteItemStyle(isSelected, isMultiSelected, typeColor, typeLightColor)
|
||||
}
|
||||
|
||||
function resolveNoteItemTestId({
|
||||
isMultiSelected,
|
||||
isImagePreview,
|
||||
isUnavailableBinary,
|
||||
}: Pick<NoteItemVisualState, 'isMultiSelected' | 'isUnavailableBinary'> & {
|
||||
isImagePreview: boolean
|
||||
}) {
|
||||
if (isMultiSelected) return 'multi-selected-item'
|
||||
if (isImagePreview) return 'image-file-item'
|
||||
return isUnavailableBinary ? 'binary-file-item' : undefined
|
||||
}
|
||||
|
||||
function resolveNoteItemTitle({
|
||||
isImagePreview,
|
||||
isUnavailableBinary,
|
||||
}: Pick<NoteItemVisualState, 'isUnavailableBinary'> & {
|
||||
isImagePreview: boolean
|
||||
}) {
|
||||
if (isImagePreview) return 'Open image preview'
|
||||
return isUnavailableBinary ? 'Cannot open this file type' : undefined
|
||||
}
|
||||
|
||||
function resolveNoteItemSurfaceProps({
|
||||
entry,
|
||||
isBinary,
|
||||
isUnavailableBinary,
|
||||
isImagePreview,
|
||||
isSelected,
|
||||
isMultiSelected,
|
||||
isHighlighted,
|
||||
@@ -341,6 +394,7 @@ function resolveNoteItemSurfaceProps({
|
||||
typeLightColor,
|
||||
}: NoteItemVisualState & {
|
||||
entry: VaultEntry
|
||||
isImagePreview: boolean
|
||||
onClickNote: NoteItemProps['onClickNote']
|
||||
onPrefetch?: NoteItemProps['onPrefetch']
|
||||
onContextMenu?: NoteItemProps['onContextMenu']
|
||||
@@ -348,13 +402,13 @@ function resolveNoteItemSurfaceProps({
|
||||
typeLightColor: string
|
||||
}): NoteItemSurfaceProps {
|
||||
return {
|
||||
className: noteItemClassName({ isBinary, isSelected, isMultiSelected, isHighlighted }),
|
||||
style: isBinary ? BINARY_NOTE_STYLE : noteItemStyle(isSelected, isMultiSelected, typeColor, typeLightColor),
|
||||
onClick: createNoteItemClickHandler(entry, isBinary, onClickNote),
|
||||
className: noteItemClassName({ isUnavailableBinary, isSelected, isMultiSelected, isHighlighted }),
|
||||
style: resolveNoteItemSurfaceStyle({ isUnavailableBinary, isSelected, isMultiSelected, typeColor, typeLightColor }),
|
||||
onClick: createNoteItemClickHandler(entry, isUnavailableBinary, onClickNote),
|
||||
onContextMenu: onContextMenu ? (event) => onContextMenu(entry, event) : undefined,
|
||||
onMouseEnter: !isBinary && onPrefetch ? () => onPrefetch(entry.path) : undefined,
|
||||
testId: isMultiSelected ? 'multi-selected-item' : isBinary ? 'binary-file-item' : undefined,
|
||||
title: isBinary ? 'Cannot open this file type' : undefined,
|
||||
onMouseEnter: entry.fileKind !== 'binary' && onPrefetch ? () => onPrefetch(entry.path) : undefined,
|
||||
testId: resolveNoteItemTestId({ isMultiSelected, isImagePreview, isUnavailableBinary }),
|
||||
title: resolveNoteItemTitle({ isImagePreview, isUnavailableBinary }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,6 +446,7 @@ function NoteItemRow({
|
||||
function NoteItemContent({
|
||||
entry,
|
||||
isBinary,
|
||||
isUnavailableBinary,
|
||||
isSelected,
|
||||
noteStatus,
|
||||
changeStatus,
|
||||
@@ -403,6 +458,7 @@ function NoteItemContent({
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
isBinary: boolean
|
||||
isUnavailableBinary: boolean
|
||||
isSelected: boolean
|
||||
noteStatus: NoteStatus
|
||||
changeStatus?: NoteItemProps['changeStatus']
|
||||
@@ -427,6 +483,7 @@ function NoteItemContent({
|
||||
<StandardNoteContent
|
||||
entry={entry}
|
||||
isBinary={isBinary}
|
||||
isUnavailableBinary={isUnavailableBinary}
|
||||
noteStatus={noteStatus}
|
||||
isSelected={isSelected}
|
||||
typeColor={typeColor}
|
||||
@@ -440,13 +497,16 @@ function NoteItemContent({
|
||||
|
||||
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', changeStatus, typeEntryMap, allEntries, displayPropsOverride, onClickNote, onPrefetch, onContextMenu }: NoteItemProps) {
|
||||
const isBinary = entry.fileKind === 'binary'
|
||||
const isImagePreview = isImagePreviewEntry(entry)
|
||||
const isUnavailableBinary = isBinary && !isImagePreview
|
||||
const te = typeEntryMap[entry.isA ?? '']
|
||||
const displayProps = resolveDisplayProps(entry, typeEntryMap, displayPropsOverride)
|
||||
const typeColor = isBinary ? 'var(--muted-foreground)' : getTypeColor(entry.isA ?? 'Note', te?.color)
|
||||
const typeColor = isImagePreview ? 'var(--accent-blue)' : isBinary ? 'var(--muted-foreground)' : getTypeColor(entry.isA ?? 'Note', te?.color)
|
||||
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color)
|
||||
const surfaceProps = resolveNoteItemSurfaceProps({
|
||||
entry,
|
||||
isBinary,
|
||||
isUnavailableBinary,
|
||||
isImagePreview,
|
||||
isSelected,
|
||||
isMultiSelected,
|
||||
isHighlighted,
|
||||
@@ -467,6 +527,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
|
||||
<NoteItemContent
|
||||
entry={entry}
|
||||
isBinary={isBinary}
|
||||
isUnavailableBinary={isUnavailableBinary}
|
||||
isSelected={isSelected}
|
||||
noteStatus={noteStatus}
|
||||
changeStatus={changeStatus}
|
||||
|
||||
@@ -422,6 +422,12 @@ describe('noteListHooks extra', () => {
|
||||
vi.useFakeTimers()
|
||||
const deletedEntry = makeDeletedEntry()
|
||||
const liveEntry = makeEntry({ path: '/vault/note/live.md', filename: 'live.md', title: 'Live' })
|
||||
const imageEntry = makeEntry({
|
||||
path: '/vault/assets/photo.png',
|
||||
filename: 'photo.png',
|
||||
title: 'photo.png',
|
||||
fileKind: 'binary',
|
||||
})
|
||||
const onReplaceActiveTab = vi.fn()
|
||||
const onOpenDeletedNote = vi.fn()
|
||||
const onAutoTriggerDiff = vi.fn()
|
||||
@@ -453,6 +459,7 @@ describe('noteListHooks extra', () => {
|
||||
act(() => {
|
||||
keyboardOptions.onOpen(deletedEntry)
|
||||
keyboardOptions.onPrefetch(liveEntry)
|
||||
keyboardOptions.onPrefetch(imageEntry)
|
||||
routeNoteClickMock.mockImplementationOnce((
|
||||
entry: VaultEntry,
|
||||
_event: unknown,
|
||||
@@ -469,6 +476,7 @@ describe('noteListHooks extra', () => {
|
||||
expect(onReplaceActiveTab).toHaveBeenCalledWith(liveEntry)
|
||||
expect(onAutoTriggerDiff).toHaveBeenCalledOnce()
|
||||
expect(prefetchNoteContentMock).toHaveBeenCalledWith(liveEntry.path)
|
||||
expect(prefetchNoteContentMock).not.toHaveBeenCalledWith(imageEntry.path)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
@@ -793,6 +793,77 @@ function resolvePropertyPicker(options: {
|
||||
})
|
||||
}
|
||||
|
||||
type ResolvePropertyPickerOptions = Parameters<typeof resolvePropertyPicker>[0]
|
||||
|
||||
function useResolvedPropertyPicker({
|
||||
selectedView,
|
||||
viewAvailableProperties,
|
||||
viewDefaultDisplay,
|
||||
onUpdateViewDefinition,
|
||||
isAllNotesView,
|
||||
allNotesAvailableProperties,
|
||||
hasCustomAllNotesProperties,
|
||||
allNotesNoteListProperties,
|
||||
allNotesDefaultDisplay,
|
||||
onUpdateAllNotesNoteListProperties,
|
||||
isInboxView,
|
||||
inboxAvailableProperties,
|
||||
hasCustomInboxProperties,
|
||||
inboxNoteListProperties,
|
||||
inboxDefaultDisplay,
|
||||
onUpdateInboxNoteListProperties,
|
||||
isSectionGroup,
|
||||
typeDocument,
|
||||
onUpdateTypeSort,
|
||||
typeAvailableProperties,
|
||||
}: ResolvePropertyPickerOptions) {
|
||||
return useMemo<NoteListPropertyPicker | null>(() => {
|
||||
return resolvePropertyPicker({
|
||||
selectedView,
|
||||
viewAvailableProperties,
|
||||
viewDefaultDisplay,
|
||||
onUpdateViewDefinition,
|
||||
isAllNotesView,
|
||||
allNotesAvailableProperties,
|
||||
hasCustomAllNotesProperties,
|
||||
allNotesNoteListProperties,
|
||||
allNotesDefaultDisplay,
|
||||
onUpdateAllNotesNoteListProperties,
|
||||
isInboxView,
|
||||
inboxAvailableProperties,
|
||||
hasCustomInboxProperties,
|
||||
inboxNoteListProperties,
|
||||
inboxDefaultDisplay,
|
||||
onUpdateInboxNoteListProperties,
|
||||
isSectionGroup,
|
||||
typeDocument,
|
||||
onUpdateTypeSort,
|
||||
typeAvailableProperties,
|
||||
})
|
||||
}, [
|
||||
allNotesAvailableProperties,
|
||||
allNotesDefaultDisplay,
|
||||
allNotesNoteListProperties,
|
||||
hasCustomAllNotesProperties,
|
||||
hasCustomInboxProperties,
|
||||
isAllNotesView,
|
||||
inboxAvailableProperties,
|
||||
inboxDefaultDisplay,
|
||||
inboxNoteListProperties,
|
||||
isInboxView,
|
||||
isSectionGroup,
|
||||
onUpdateAllNotesNoteListProperties,
|
||||
onUpdateInboxNoteListProperties,
|
||||
onUpdateTypeSort,
|
||||
onUpdateViewDefinition,
|
||||
selectedView,
|
||||
typeAvailableProperties,
|
||||
typeDocument,
|
||||
viewAvailableProperties,
|
||||
viewDefaultDisplay,
|
||||
])
|
||||
}
|
||||
|
||||
export function useListPropertyPicker({
|
||||
entries,
|
||||
selection,
|
||||
@@ -830,57 +901,38 @@ export function useListPropertyPicker({
|
||||
hasCustomViewProperties: viewState.hasCustomProperties,
|
||||
})
|
||||
|
||||
const propertyPicker = useMemo<NoteListPropertyPicker | null>(() => {
|
||||
return resolvePropertyPicker({
|
||||
selectedView: viewState.selectedView,
|
||||
viewAvailableProperties: viewState.availableProperties,
|
||||
viewDefaultDisplay: viewState.defaultDisplay,
|
||||
onUpdateViewDefinition,
|
||||
isAllNotesView,
|
||||
allNotesAvailableProperties: allNotesState.availableProperties,
|
||||
hasCustomAllNotesProperties,
|
||||
allNotesNoteListProperties,
|
||||
allNotesDefaultDisplay: allNotesState.defaultDisplay,
|
||||
onUpdateAllNotesNoteListProperties,
|
||||
isInboxView,
|
||||
inboxAvailableProperties: inboxState.availableProperties,
|
||||
hasCustomInboxProperties,
|
||||
inboxNoteListProperties,
|
||||
inboxDefaultDisplay: inboxState.defaultDisplay,
|
||||
onUpdateInboxNoteListProperties,
|
||||
isSectionGroup,
|
||||
typeDocument,
|
||||
onUpdateTypeSort,
|
||||
typeAvailableProperties,
|
||||
})
|
||||
}, [
|
||||
allNotesState.availableProperties,
|
||||
allNotesState.defaultDisplay,
|
||||
allNotesNoteListProperties,
|
||||
hasCustomAllNotesProperties,
|
||||
hasCustomInboxProperties,
|
||||
isAllNotesView,
|
||||
inboxState.availableProperties,
|
||||
inboxState.defaultDisplay,
|
||||
viewState.availableProperties,
|
||||
viewState.defaultDisplay,
|
||||
viewState.selectedView,
|
||||
const propertyPicker = useResolvedPropertyPicker({
|
||||
selectedView: viewState.selectedView,
|
||||
viewAvailableProperties: viewState.availableProperties,
|
||||
viewDefaultDisplay: viewState.defaultDisplay,
|
||||
onUpdateViewDefinition,
|
||||
isAllNotesView,
|
||||
allNotesAvailableProperties: allNotesState.availableProperties,
|
||||
hasCustomAllNotesProperties,
|
||||
allNotesNoteListProperties,
|
||||
allNotesDefaultDisplay: allNotesState.defaultDisplay,
|
||||
onUpdateAllNotesNoteListProperties,
|
||||
inboxNoteListProperties,
|
||||
isInboxView,
|
||||
isSectionGroup,
|
||||
inboxAvailableProperties: inboxState.availableProperties,
|
||||
hasCustomInboxProperties,
|
||||
inboxNoteListProperties,
|
||||
inboxDefaultDisplay: inboxState.defaultDisplay,
|
||||
onUpdateInboxNoteListProperties,
|
||||
isSectionGroup,
|
||||
typeDocument,
|
||||
onUpdateTypeSort,
|
||||
typeAvailableProperties,
|
||||
typeDocument,
|
||||
])
|
||||
})
|
||||
|
||||
return { displayPropsOverride, propertyPicker }
|
||||
}
|
||||
|
||||
// --- useNoteListInteractions ---
|
||||
|
||||
function canPrefetchEntryContent(entry: VaultEntry): boolean {
|
||||
return !isDeletedNoteEntry(entry) && entry.fileKind !== 'binary'
|
||||
}
|
||||
|
||||
interface UseNoteListInteractionsParams {
|
||||
searched: VaultEntry[]
|
||||
searchedGroups: Array<{ entries: VaultEntry[] }>
|
||||
@@ -970,7 +1022,7 @@ function useKeyboardInteractionState({
|
||||
}, [onOpenDeletedNote, onReplaceActiveTab])
|
||||
|
||||
const handleKeyboardPrefetch = useCallback((entry: VaultEntry) => {
|
||||
if (!isDeletedNoteEntry(entry)) prefetchNoteContent(entry.path)
|
||||
if (canPrefetchEntryContent(entry)) prefetchNoteContent(entry.path)
|
||||
}, [])
|
||||
|
||||
const handleNeighborhoodOpen = useCallback(async (entry: VaultEntry) => {
|
||||
|
||||
@@ -212,6 +212,20 @@ describe('useTabManagement (single-note model)', () => {
|
||||
warnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('opens binary image files without trying to load them as text notes', async () => {
|
||||
const { result } = renderHook(() => useTabManagement())
|
||||
await selectNote(result, {
|
||||
path: '/vault/assets/photo.png',
|
||||
filename: 'photo.png',
|
||||
title: 'photo.png',
|
||||
fileKind: 'binary',
|
||||
})
|
||||
|
||||
expectSingleActiveTab(result, '/vault/assets/photo.png')
|
||||
expect(result.current.tabs[0].content).toBe('')
|
||||
expect(vi.mocked(mockInvoke)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns to the empty state when no active vault is selected', async () => {
|
||||
vi.mocked(mockInvoke).mockRejectedValueOnce(new Error('No active vault selected'))
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
@@ -167,6 +167,18 @@ interface TabManagementOptions {
|
||||
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
}
|
||||
|
||||
interface NavigateToEntryOptions {
|
||||
entry: VaultEntry
|
||||
forceReload?: boolean
|
||||
navSeqRef: React.MutableRefObject<number>
|
||||
tabsRef: React.MutableRefObject<Tab[]>
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
|
||||
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
|
||||
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
}
|
||||
|
||||
function syncActiveTabPath(
|
||||
activeTabPathRef: React.MutableRefObject<string | null>,
|
||||
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>,
|
||||
@@ -242,6 +254,29 @@ function startEntryNavigation(options: {
|
||||
return { seq, cachedContent }
|
||||
}
|
||||
|
||||
function openBinaryEntry(options: {
|
||||
entry: VaultEntry
|
||||
navSeqRef: React.MutableRefObject<number>
|
||||
tabsRef: React.MutableRefObject<Tab[]>
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
|
||||
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
|
||||
}) {
|
||||
const {
|
||||
entry,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
setTabs,
|
||||
setActiveTabPath,
|
||||
} = options
|
||||
|
||||
navSeqRef.current += 1
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
setSingleTab(tabsRef, setTabs, { entry, content: '' })
|
||||
finishNoteOpenTrace(entry.path)
|
||||
}
|
||||
|
||||
function isMissingNotePathError(error: unknown): boolean {
|
||||
const message = error instanceof Error
|
||||
? error.message
|
||||
@@ -434,20 +469,22 @@ function handleEntryLoadFailure(options: {
|
||||
failNoteOpenTrace(entry.path, 'load-failed')
|
||||
}
|
||||
|
||||
async function navigateToEntry(options: {
|
||||
entry: VaultEntry
|
||||
forceReload?: boolean
|
||||
navSeqRef: React.MutableRefObject<number>
|
||||
tabsRef: React.MutableRefObject<Tab[]>
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
|
||||
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
|
||||
onMissingNotePath?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
onUnreadableNoteContent?: (entry: VaultEntry, error: unknown) => void | Promise<void>
|
||||
}) {
|
||||
function reopenAlreadyViewingEntry({
|
||||
entry,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
setActiveTabPath,
|
||||
}: Pick<NavigateToEntryOptions, 'entry' | 'tabsRef' | 'activeTabPathRef' | 'setActiveTabPath'>): boolean {
|
||||
if (!isAlreadyViewingPath(tabsRef, activeTabPathRef, entry.path)) return false
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
finishNoteOpenTrace(entry.path)
|
||||
return true
|
||||
}
|
||||
|
||||
async function loadTextEntry(options: Required<Pick<NavigateToEntryOptions, 'forceReload'>> & NavigateToEntryOptions) {
|
||||
const {
|
||||
entry,
|
||||
forceReload = false,
|
||||
forceReload,
|
||||
navSeqRef,
|
||||
tabsRef,
|
||||
activeTabPathRef,
|
||||
@@ -457,16 +494,6 @@ async function navigateToEntry(options: {
|
||||
onUnreadableNoteContent,
|
||||
} = options
|
||||
|
||||
if (entry.fileKind === 'binary') {
|
||||
failNoteOpenTrace(entry.path, 'binary-entry')
|
||||
return
|
||||
}
|
||||
if (!forceReload && isAlreadyViewingPath(tabsRef, activeTabPathRef, entry.path)) {
|
||||
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
|
||||
finishNoteOpenTrace(entry.path)
|
||||
return
|
||||
}
|
||||
|
||||
const { seq, cachedContent } = startEntryNavigation({
|
||||
entry,
|
||||
navSeqRef,
|
||||
@@ -509,6 +536,19 @@ async function navigateToEntry(options: {
|
||||
}
|
||||
}
|
||||
|
||||
async function navigateToEntry(options: NavigateToEntryOptions) {
|
||||
const forceReload = options.forceReload ?? false
|
||||
|
||||
if (options.entry.fileKind === 'binary') {
|
||||
openBinaryEntry(options)
|
||||
return
|
||||
}
|
||||
|
||||
if (!forceReload && reopenAlreadyViewingEntry(options)) return
|
||||
|
||||
await loadTextEntry({ ...options, forceReload })
|
||||
}
|
||||
|
||||
export function useTabManagement(options: TabManagementOptions = {}) {
|
||||
// Single-note model: tabs has 0 or 1 elements.
|
||||
const [tabs, setTabs] = useState<Tab[]>([])
|
||||
|
||||
38
src/utils/filePreview.ts
Normal file
38
src/utils/filePreview.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const IMAGE_PREVIEW_EXTENSIONS = new Set([
|
||||
'apng',
|
||||
'avif',
|
||||
'bmp',
|
||||
'gif',
|
||||
'ico',
|
||||
'jpeg',
|
||||
'jpg',
|
||||
'png',
|
||||
'svg',
|
||||
'tif',
|
||||
'tiff',
|
||||
'webp',
|
||||
])
|
||||
|
||||
function extensionFromFilename(filename: string): string | null {
|
||||
const lastSegment = filename.split(/[\\/]/u).pop() ?? filename
|
||||
const dotIndex = lastSegment.lastIndexOf('.')
|
||||
if (dotIndex <= 0 || dotIndex === lastSegment.length - 1) return null
|
||||
return lastSegment.slice(dotIndex + 1).toLowerCase()
|
||||
}
|
||||
|
||||
export function previewExtension(entry: Pick<VaultEntry, 'filename' | 'path'>): string | null {
|
||||
return extensionFromFilename(entry.filename) ?? extensionFromFilename(entry.path)
|
||||
}
|
||||
|
||||
export function isImagePreviewEntry(entry: Pick<VaultEntry, 'fileKind' | 'filename' | 'path'>): boolean {
|
||||
if (entry.fileKind !== 'binary') return false
|
||||
const extension = previewExtension(entry)
|
||||
return extension ? IMAGE_PREVIEW_EXTENSIONS.has(extension) : false
|
||||
}
|
||||
|
||||
export function previewFileTypeLabel(entry: Pick<VaultEntry, 'filename' | 'path'>): string {
|
||||
const extension = previewExtension(entry)
|
||||
return extension ? `${extension.toUpperCase()} file` : 'File'
|
||||
}
|
||||
Reference in New Issue
Block a user