diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 6fe73c1e..0d382332 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -157,9 +157,9 @@ interface VaultEntry { |---|---|---| | `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 | +| `binary` | Images, PDFs, archives, other non-text files | Stays a normal vault file; previewable images and PDFs 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. +Asset previewability is inferred in the renderer from the filename extension (`src/utils/filePreview.ts`) rather than stored as a new persisted kind. Supported images render through `` and supported PDFs render through the webview's PDF object renderer, both backed by Tauri asset URLs. This keeps the filesystem as source of truth and avoids converting assets into proprietary objects. ### Note Content Freshness diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f10436f0..69861ae6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -193,8 +193,8 @@ flowchart TD ``` - **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, 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. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types: pointer users can drag the existing view row, while keyboard users can use command-palette move actions. 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, first-class Mermaid diagram blocks, 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. +- **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 and PDF binaries get file indicators 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, first-class Mermaid diagram blocks, 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 and PDF 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. diff --git a/docs/adr/0098-in-app-image-and-pdf-file-previews.md b/docs/adr/0098-in-app-image-and-pdf-file-previews.md new file mode 100644 index 00000000..6e83cfd9 --- /dev/null +++ b/docs/adr/0098-in-app-image-and-pdf-file-previews.md @@ -0,0 +1,28 @@ +--- +type: ADR +id: "0098" +title: "In-app image and PDF previews for binary vault files" +status: active +date: 2026-04-29 +supersedes: "0086" +--- + +## Context + +ADR-0086 introduced the `FilePreview` path for image binaries while keeping binary files as ordinary `VaultEntry` records. The same file-first model should now cover PDFs, because asset-heavy vaults often mix screenshots, diagrams, and document exports that users need to inspect without leaving Tolaria. + +## Decision + +**Tolaria previews supported image and PDF files in the editor pane while keeping them as ordinary binary vault files.** + +- The scanner keeps the coarse `fileKind: "binary"` representation. Previewability stays a renderer concern inferred from the file extension in `src/utils/filePreview.ts`. +- Supported images render with `` and supported PDFs render with the webview PDF object renderer, both using Tauri asset URLs from `convertFileSrc`. +- PDF preview fallback content lives inside the PDF object so unsupported or failed renderers still expose an explicit "Open in default app" escape hatch. +- Note-list rows for previewable images and PDFs remain clickable and carry file-specific indicators; unsupported binary rows stay muted and non-clickable. +- `Escape` on the preview surface returns keyboard focus to the note list, matching the existing image-preview keyboard behavior. + +## Consequences + +- PDFs do not become notes and do not get Markdown editor semantics. +- The asset preview surface can keep growing to additional safe binary formats without changing the vault scanner or persisted cache shape. +- Broken PDFs may rely on the webview's own renderer failure state, but the surrounding Tolaria preview chrome still provides reveal, copy path, and default-app actions. diff --git a/docs/adr/README.md b/docs/adr/README.md index e53bf792..2ff5fe1f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -149,3 +149,4 @@ proposed → active → superseded | [0094](0094-gitignored-content-visibility-boundary-filter.md) | Gitignored content visibility as a command-boundary filter | active | | [0095](0095-saved-view-order-field.md) | Saved views use an explicit YAML order field | active | | [0097](0097-gemini-cli-agent-adapter.md) | Gemini CLI agent adapter | active | +| [0098](0098-in-app-image-and-pdf-file-previews.md) | In-app image and PDF previews for binary vault files | active | diff --git a/lara.lock b/lara.lock index 16dd7c99..2c20689a 100644 --- a/lara.lock +++ b/lara.lock @@ -14,6 +14,7 @@ files: command.openLanguageSettings.keywords: 68efcb7e847b1a9d472baa609824be80 command.useSystemLanguage: b7f0315c47d4a59faa2a49571fcf1fca command.openH1Setting: 05b4f6edc0e98b29670e8ee902cdb9bf + command.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03 command.contribute: 9887a4451812854f0f1b6f669a874307 command.checkUpdates: f77f38f684c8b974dd4a56bb321cfd5f command.group.navigation: 846495f9ceed11accf8879f555936a7d @@ -70,6 +71,10 @@ files: command.view.toggleAiPanel: 4279cbf31767465f25feff6e7bdd336e command.view.newAiChat: 1b0a886d6e77f628ec96c2d71cdb0a9b command.view.toggleBacklinks: b3c4be2d2c07bb8df181355a2c3658a7 + command.view.moveViewUp: 76f92d3250e028609349d825c672f13e + command.view.moveViewDown: 9deea30162b8d3234f8d52795ad7393f + command.view.moveNamedViewUp: b4ac634d13a09eacb680ef12c69dd1f7 + command.view.moveNamedViewDown: 15302c02df26fe52def77636e9141711 command.view.zoomIn: ae4a8e406b707636392b6673fca0e6a6 command.view.zoomOut: 97326f8cd9df886a6b19af180fb7dcc9 command.view.resetZoom: 193ee8c32391fc5cc303a51617cfd046 @@ -81,6 +86,9 @@ files: command.settings.setupExternalAi: 24eea679eb699763daa2e3f2a67fcf9a command.settings.reloadVault: f6e506dad6b6cf2be24d9438d458ae54 command.settings.repairVault: cee560b1fd5ad9d1ff1c19a0a2afe77a + command.settings.useLightMode: a76d5b1c076983bc113d247f0520c166 + command.settings.useDarkMode: ba7873c42ae00542ba71db9be3b6761f + command.settings.toggleGitignoredFilesVisibility: 0a2d3ea4e8ff8b00a801183caeb97a03 command.ai.openAgents: 612abe804edd0f5ae228a534f77f3d23 command.ai.restoreGuidance: 23331fecc692d11d85cf24838ed273f2 command.ai.switchToAgent: 5bb02187e653b360ab7ed661403271f2 @@ -115,6 +123,10 @@ files: settings.titles.description: b94aeeca78dd376cc19b9aa019e53f6c settings.titles.autoRename: 5383db8e790ebf5f956d9c59cb4c4f67 settings.titles.autoRenameDescription: cc28c28d8817736e658082a2261d9a6e + settings.vaultContent.title: 78b883ce9acb9c25e611158a518d9185 + settings.vaultContent.description: a53487bf1c7898a1459dc1510e216f7d + settings.vaultContent.hideGitignored: 2c07ee6c8bfe746d356f44275d42f90e + settings.vaultContent.hideGitignoredDescription: e8b1ddfa416610f9d6eb299fa123a1d6 settings.aiAgents.title: 27f08387b26e1ceeb1b60bd9c97e7a47 settings.aiAgents.description: a13222e67eb121676ff6dfc7b085f02d settings.aiAgents.default: 6af573559fd05d8c35bc57b41cc78e24 @@ -138,6 +150,68 @@ files: settings.cancel: ea4788705e6873b424c65e91c2846b19 settings.save: c9cc8cce247e49bae79f15173ce97354 common.cancel: ea4788705e6873b424c65e91c2846b19 + ai.permission.safe.short: c6eea0560cd6f377e78dff2c85cc9122 + ai.permission.safe.control: 386cc66605d50e5bf1fb726f97e55b55 + ai.permission.safe.tooltip: 3bd635e2b2223da302994d77bfd7edfd + ai.permission.powerUser.short: d4a47db271fc1ef3cd17f7396326b057 + ai.permission.powerUser.control: d4a47db271fc1ef3cd17f7396326b057 + ai.permission.powerUser.tooltip: 2585ecdb79ca02a3ee779bb61552de03 + ai.permission.modeAria: 3ff6346566a4c44fd5c7395907125d3e + ai.permission.changed: b5d06ad47e6053fe5a73f4571a40e00c + ai.panel.title: 4412225fe5b326643b8c2e1d9b7b10ae + ai.panel.status.checking: 118740af1c4521b917e29791d661db82 + ai.panel.status.missing: fe07f8eac233c229d81cbe9aa25668a0 + ai.panel.status.ready: 296a0ac791eab2df130789f55fdc109b + ai.panel.copyMcpConfig: 6a8c9fe401557c870e3a90bbfab01b25 + ai.panel.mcpConfig: 53bac93d0314941c8d2c1163026f3ad9 + ai.panel.newChat: 1b0a886d6e77f628ec96c2d71cdb0a9b + ai.panel.close: d00ba22b83762f5a6e66db0be9e916d7 + ai.panel.linkedCount: 5292fda189a357c9597b1cb0e1b0900b + ai.panel.empty.checkingTitle: 0dde2076cb53fe497d05d231296d50bb + ai.panel.empty.checkingDescription: 31e41510d851c366860377ba0c956f0b + ai.panel.empty.missingTitle: a2b764da52cb43b191bb4ecfd8ea4d26 + ai.panel.empty.missingDescription: 91825c7d86b1d588d44a33e1d94af950 + ai.panel.empty.withContextTitle: 66f02985937083e4ef62b98cc9f25aee + ai.panel.empty.noContextTitle: e919aeb0a49b58e494e43e5f6f6597c9 + ai.panel.empty.withContextDescription: dd8faefcc8160a2db800a41d6628facb + ai.panel.empty.noContextDescription: 6a6e610835808016d1f2790c3f991a54 + ai.panel.placeholder.checking: 08fec77719c37d3d6279b2641b37b4d3 + ai.panel.placeholder.missing: 547c492026d115608067988b7cfbb9a9 + ai.panel.placeholder.ready: 31f5265c2e0432a7ca10d08e5c6f3114 + ai.panel.send: 747c6a3564774149c989884e0c581d53 + mcp.setup.manageTitle: d786c7b9ab9b2406258648931bca0e01 + mcp.setup.setupTitle: b4b373f690c8a0008885b31e78e93a5c + mcp.setup.connectedDescription: a2a3be70c0ac9fa7ef112df5252249fa + mcp.setup.setupDescription: 8bf199ffbcf303a99abac078c0c5e094 + mcp.setup.reconnect: 813ba447b5e64c17ff9b68c693358ca3 + mcp.setup.connect: fb95777dbc9f7b22014dc360a2957652 + mcp.setup.disconnect: 42ae25231906c83927831e0ef7c317ac + mcp.setup.connecting: 182e7eda4e721ad00737460b88701dee + mcp.setup.disconnecting: 11de134aefe51cb4a5c545b5a057a871 + mcp.setup.nodeRequirement: ac58a35cf21d2204fed548c227f77594 + mcp.setup.writeEntryDescription: 370aa4403d2ffc37f574503bc3cd00e6 + mcp.setup.clientPathsDescription: 94565852412051c799e78d2f4525ff53 + mcp.setup.geminiGuidanceDescription: 182cf5b84b2f56cb1cf41dcdb5e093d8 + mcp.setup.manual.title: 2188ca52346158cbd103ca7ce7c12dd9 + mcp.setup.manual.copy: 6a8c9fe401557c870e3a90bbfab01b25 + mcp.setup.manual.loading: 11ee201c121c8e2015c065952474a3ea + mcp.setup.manual.unavailable: 8b4330b839c41bad378c86c91ee0b5cd + mcp.toast.connected: 7716c0f9dad41a7fdbef592074459239 + mcp.toast.refreshed: 227d8721ebec3077792b1223c1f30e54 + mcp.toast.disconnected: 9b5254079561087d679e35aa10c80638 + mcp.toast.alreadyDisconnected: 645bec2e33bd9de2b334b704d9fa84cf + mcp.toast.configCopied: 458b50444b9a1cb069b00475c0951b8a + mcp.toast.configCopyFailed: b451cfb95f87458d142d026a8aca74cf + mcp.toast.setupFailed: 51f290962c08d8ba76bfc6c19552eaed + mcp.toast.disconnectFailed: ddc3387ab6e996ce15bc4950a3548e1e + mcp.error.clipboardUnavailable: 3b9ed3fe7dba627edb74e1cdaa02b2ee + save.toast.saved: 248336101b461380a4b2391a7625493d + save.toast.nothingToSave: 86ae8cef7371e639f3c007449a2f7d1f + save.toast.missingActiveVault: 49cf5064a7dce757f895950d8beeed68 + save.error.failed: b55b9fefe93fabae3c277e4e7956a534 + save.error.autoFailed: 09b2b131f07baddb5ba8f5a3ee6234c5 + save.error.invalidPath: c96fb669715e86508f4c5e73da9f6995 + savedViews.reordered: 531081a566b826eed2e714ce70a0de08 locale.en: 78463a384a5aa4fad5fa73e2f506ecfc sidebar.nav.inbox: 3882d32c66e7e768145ecd8f104b0c08 sidebar.nav.allNotes: cd76cf851b08a9d2feafaf255bc1f4d5 @@ -149,6 +223,9 @@ files: sidebar.action.createView: ba019414ea8c7fcdb4a83a70ec1bb639 sidebar.action.editView: acdf34bd08b0692b906d446e590b1eb9 sidebar.action.deleteView: c8f4f9cd8ff820f955474e5c9f70ff39 + sidebar.action.reorderView: ca2f42548fd57160d22fd0809bf8ed6e + sidebar.action.moveViewUp: e601b7f0901f12efb71f7821c0fdb79e + sidebar.action.moveViewDown: 6f86951085367864e3e4eb13f5c8688e sidebar.action.customizeSections: 050a134cad1e9c6f04abe5d635592703 sidebar.action.renameSection: 8936914051df04e7550d0eeb4a31e0e0 sidebar.action.customizeIconColor: 9a2f685c74d261ab483ef00dee5314fe @@ -318,6 +395,8 @@ files: status.vault.cloneGit: a7d0aef7c6237a36e54fd5a26e9c23fe status.vault.cloneGettingStarted: 9953a11a477b441976a626a9acf5d7df status.vault.notFound: 3119b7fa94c96209bca571b7c7ed0b7e + status.vault.reloading: 893da50ef3a9e01d8a99ff5d3ceb55e9 + status.vault.reloadingTooltip: 4a884bd7d113797ad16f905f70742da8 status.vault.remove: 7f3b4f76df23626170940dbc55c70728 status.remote.noneConfigured: 18a4edd4e8d862ccb343937f45585fb1 status.remote.inSync: a56f571b4df55d88fb06e61e343b3c91 diff --git a/src/App.tsx b/src/App.tsx index 9a994bb6..d8ac7747 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -469,7 +469,7 @@ function App() { mcpConfigError, loadMcpConfigSnippet, copyMcpConfig, - } = useMcpStatus(resolvedPath, setToastMessage) + } = useMcpStatus(resolvedPath, setToastMessage, appLocale) const gitRemoteStatus = useGitRemoteStatus(resolvedPath) const loadVaultModifiedFiles = vault.loadModifiedFiles const refreshGitRemoteStatus = gitRemoteStatus.refreshRemoteStatus @@ -772,6 +772,7 @@ function App() { replaceEntry: vault.replaceEntry, resolvedPath, initialH1AutoRenameEnabled: settings.initial_h1_auto_rename_enabled !== false, onInternalVaultWrite: recentVaultWrites.markInternalWrite, + locale: appLocale, }) const aiActivity = useAiActivity({ @@ -1335,6 +1336,7 @@ function App() { reloadViews: vault.reloadViews, loadModifiedFiles: vault.loadModifiedFiles, onToast: setToastMessage, + locale: appLocale, }) const activeNoteModified = useMemo( () => vault.modifiedFiles.some((file) => file.path === notes.activeTabPath), @@ -1726,7 +1728,7 @@ function App() { /> - + {deleteActions.confirmDelete && ( void onFileModified?: (relativePath: string) => void onVaultChanged?: () => void @@ -49,6 +51,7 @@ interface AiPanelViewProps { defaultAiAgent?: AiAgentId defaultAiAgentReadiness?: AiAgentReadiness defaultAiAgentReady?: boolean + locale?: AppLocale activeEntry?: VaultEntry | null entries?: VaultEntry[] } @@ -66,6 +69,7 @@ export function AiPanelView({ defaultAiAgent: providedDefaultAiAgent, defaultAiAgentReadiness: providedDefaultAiAgentReadiness, defaultAiAgentReady: providedDefaultAiAgentReady, + locale = 'en', activeEntry, entries, }: AiPanelViewProps) { @@ -117,6 +121,7 @@ export function AiPanelView({ {activeEntry && ( - + )} diff --git a/src/components/AiPanelChrome.tsx b/src/components/AiPanelChrome.tsx index 286928a8..8c9bbde7 100644 --- a/src/components/AiPanelChrome.tsx +++ b/src/components/AiPanelChrome.tsx @@ -8,26 +8,19 @@ import { TooltipProvider } from '@/components/ui/tooltip' import { WikilinkChatInput } from './WikilinkChatInput' import { extractInlineWikilinkReferences } from './inlineWikilinkText' import { - AI_AGENT_PERMISSION_MODE_LABELS, + aiAgentPermissionModeLabels, type AiAgentPermissionMode, } from '../lib/aiAgentPermissionMode' +import { createTranslator, type AppLocale } from '../lib/i18n' import type { AiAgentMessage } from '../hooks/useCliAiAgent' import type { AiAgentReadiness } from '../lib/aiAgents' import type { NoteReference } from '../utils/ai-context' import type { VaultEntry } from '../types' -const AI_PERMISSION_MODE_TOOLTIPS: Record = { - safe: { - label: 'Vault Safe keeps agents limited to file, search, and edit tools.', - }, - power_user: { - label: 'Power User also allows local shell commands for this vault.', - }, -} - interface AiPanelHeaderProps { agentLabel: string agentReadiness: AiAgentReadiness + locale?: AppLocale permissionMode: AiAgentPermissionMode permissionModeDisabled: boolean onPermissionModeChange: (mode: AiAgentPermissionMode) => void @@ -38,12 +31,14 @@ interface AiPanelHeaderProps { interface AiPanelContextBarProps { activeEntry: VaultEntry + locale?: AppLocale linkedCount: number } interface AiPanelMessageHistoryProps { agentLabel: string agentReadiness: AiAgentReadiness + locale?: AppLocale messages: AiAgentMessage[] isActive: boolean onOpenNote?: (path: string) => void @@ -55,6 +50,7 @@ interface AiPanelComposerProps { entries: VaultEntry[] agentLabel: string agentReadiness: AiAgentReadiness + locale?: AppLocale input: string inputRef: React.RefObject isActive: boolean @@ -66,23 +62,54 @@ interface AiPanelComposerProps { function getComposerPlaceholder( agentLabel: string, agentReadiness: AiAgentReadiness, + t: ReturnType, ): string { if (agentReadiness === 'checking') { - return 'Checking AI agent availability...' + return t('ai.panel.placeholder.checking') } if (agentReadiness === 'missing') { - return `${agentLabel} is not installed. Open AI Agents in Settings.` + return t('ai.panel.placeholder.missing', { agent: agentLabel }) } - return `Ask ${agentLabel}` + return t('ai.panel.placeholder.ready', { agent: agentLabel }) +} + +function permissionModeTooltip( + mode: AiAgentPermissionMode, + t: ReturnType, +): { label: string } { + return { + label: t(mode === 'power_user' + ? 'ai.permission.powerUser.tooltip' + : 'ai.permission.safe.tooltip'), + } +} + +function headerStatusText({ + agentLabel, + agentReadiness, + modeLabel, + t, +}: { + agentLabel: string + agentReadiness: AiAgentReadiness + modeLabel: string + t: ReturnType +}): string { + if (agentReadiness === 'checking') return t('ai.panel.status.checking') + if (agentReadiness === 'missing') return t('ai.panel.status.missing', { agent: agentLabel }) + return t('ai.panel.status.ready', { agent: agentLabel, mode: modeLabel }) } function AiPanelEmptyState({ agentLabel, agentReadiness, hasContext, -}: Pick) { + locale = 'en', +}: Pick) { + const t = createTranslator(locale) + if (agentReadiness === 'checking') { return (

- Checking AI agent availability + {t('ai.panel.empty.checkingTitle')}

- Messages can be sent when the selected agent is ready + {t('ai.panel.empty.checkingDescription')}

) @@ -108,10 +135,10 @@ function AiPanelEmptyState({ >

- {agentLabel} is not available on this machine + {t('ai.panel.empty.missingTitle', { agent: agentLabel })}

- Install it or switch the default AI agent in Settings + {t('ai.panel.empty.missingDescription')}

) @@ -125,14 +152,14 @@ function AiPanelEmptyState({

{hasContext - ? `Ask ${agentLabel} about this note and its linked context` - : `Open a note, then ask ${agentLabel} about it` + ? t('ai.panel.empty.withContextTitle', { agent: agentLabel }) + : t('ai.panel.empty.noContextTitle', { agent: agentLabel }) }

{hasContext - ? 'Summarize, find connections, expand ideas' - : 'The AI will use the active note as context' + ? t('ai.panel.empty.withContextDescription') + : t('ai.panel.empty.noContextDescription') }

@@ -142,6 +169,7 @@ function AiPanelEmptyState({ export function AiPanelHeader({ agentLabel, agentReadiness, + locale = 'en', permissionMode, permissionModeDisabled, onPermissionModeChange, @@ -149,7 +177,8 @@ export function AiPanelHeader({ onCopyMcpConfig, onNewChat, }: AiPanelHeaderProps) { - const modeLabel = AI_AGENT_PERMISSION_MODE_LABELS[permissionMode].short + const t = createTranslator(locale) + const modeLabel = aiAgentPermissionModeLabels(permissionMode, locale).short return (
- AI Agent + {t('ai.panel.title')} - {agentReadiness === 'checking' - ? 'Checking availability' - : `${agentLabel}${agentReadiness === 'missing' ? ' · not installed' : ` · ${modeLabel}`}`} + {headerStatusText({ agentLabel, agentReadiness, modeLabel, t })}
{onCopyMcpConfig ? ( @@ -175,12 +202,12 @@ export function AiPanelHeader({ size="xs" onClick={onCopyMcpConfig} className="h-7 gap-1.5 px-2 text-[11px]" - aria-label="Copy MCP config" - title="Copy MCP config" + aria-label={t('ai.panel.copyMcpConfig')} + title={t('ai.panel.copyMcpConfig')} data-testid="ai-copy-mcp-config" > - MCP config + {t('ai.panel.mcpConfig')} ) : null} @@ -198,14 +225,15 @@ export function AiPanelHeader({ variant="ghost" size="icon-xs" onClick={onClose} - aria-label="Close AI panel" - title="Close AI panel" + aria-label={t('ai.panel.close')} + title={t('ai.panel.close')} >
@@ -215,19 +243,23 @@ export function AiPanelHeader({ function AiPermissionModeToggle({ value, + locale = 'en', disabled, onChange, }: { value: AiAgentPermissionMode + locale?: AppLocale disabled: boolean onChange: (mode: AiAgentPermissionMode) => void }) { + const t = createTranslator(locale) + return (
{(['safe', 'power_user'] as const).map((mode) => { @@ -235,7 +267,7 @@ function AiPermissionModeToggle({ return ( @@ -253,7 +285,7 @@ function AiPermissionModeToggle({ } onClick={() => onChange(mode)} > - {AI_AGENT_PERMISSION_MODE_LABELS[mode].control} + {aiAgentPermissionModeLabels(mode, locale).control} ) @@ -263,7 +295,9 @@ function AiPermissionModeToggle({ ) } -export function AiPanelContextBar({ activeEntry, linkedCount }: AiPanelContextBarProps) { +export function AiPanelContextBar({ activeEntry, linkedCount, locale = 'en' }: AiPanelContextBarProps) { + const t = createTranslator(locale) + return (
{activeEntry.title} {linkedCount > 0 && ( - + {linkedCount} linked + {t('ai.panel.linkedCount', { count: linkedCount })} )}
) @@ -282,6 +316,7 @@ export function AiPanelContextBar({ activeEntry, linkedCount }: AiPanelContextBa export function AiPanelMessageHistory({ agentLabel, agentReadiness, + locale = 'en', messages, isActive, onOpenNote, @@ -300,6 +335,7 @@ export function AiPanelMessageHistory({ )} @@ -320,6 +356,7 @@ export function AiPanelComposer({ entries, agentLabel, agentReadiness, + locale = 'en', input, inputRef, isActive, @@ -327,9 +364,10 @@ export function AiPanelComposer({ onSend, onUnsupportedAiPaste, }: AiPanelComposerProps) { + const t = createTranslator(locale) const composerDisabled = isActive || agentReadiness !== 'ready' const canSend = !composerDisabled && input.trim().length > 0 - const placeholder = getComposerPlaceholder(agentLabel, agentReadiness) + const placeholder = getComposerPlaceholder(agentLabel, agentReadiness, t) const sendButtonStyle = { background: canSend ? 'var(--primary)' : 'var(--muted)', color: canSend ? 'var(--primary-foreground)' : 'var(--muted-foreground)', @@ -365,8 +403,8 @@ export function AiPanelComposer({ style={sendButtonStyle} onClick={() => onSend(input, extractInlineWikilinkReferences(input, entries))} disabled={!canSend} - aria-label="Send message" - title="Send message" + aria-label={t('ai.panel.send')} + title={t('ai.panel.send')} data-testid="agent-send" > diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index edac9b3a..d3ec3b4e 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -284,6 +284,28 @@ describe('Editor', () => { expect(screen.queryByTestId('blocknote-view')).not.toBeInTheDocument() }) + it('renders an in-app PDF preview for binary PDF tabs', () => { + const pdfEntry: VaultEntry = { + ...mockEntry, + path: '/vault/assets/report.pdf', + filename: 'report.pdf', + title: 'report.pdf', + fileKind: 'binary', + } + + renderEditor({ + tabs: [{ entry: pdfEntry, content: '' }], + activeTabPath: pdfEntry.path, + entries: [pdfEntry], + }) + + expect(screen.getByTestId('pdf-file-preview')).toHaveAttribute( + 'data', + 'asset://localhost/%2Fvault%2Fassets%2Freport.pdf', + ) + expect(screen.queryByTestId('blocknote-view')).not.toBeInTheDocument() + }) + it('shows a graceful fallback when an image preview fails to render', () => { const imageEntry: VaultEntry = { ...mockEntry, diff --git a/src/components/EditorRightPanel.tsx b/src/components/EditorRightPanel.tsx index 63f2cd3b..3fb106a7 100644 --- a/src/components/EditorRightPanel.tsx +++ b/src/components/EditorRightPanel.tsx @@ -64,6 +64,7 @@ export function EditorRightPanel({ entries, noteList, noteListFilter, + locale, onOpenNote, onFileCreated, onFileModified, @@ -95,6 +96,7 @@ export function EditorRightPanel({ defaultAiAgent={defaultAiAgent} defaultAiAgentReadiness={defaultAiAgentReadiness} defaultAiAgentReady={defaultAiAgentReady} + locale={locale} activeEntry={inspectorEntry} entries={entries} /> diff --git a/src/components/FilePreview.test.tsx b/src/components/FilePreview.test.tsx index 59bc0872..5ef7d12c 100644 --- a/src/components/FilePreview.test.tsx +++ b/src/components/FilePreview.test.tsx @@ -40,6 +40,12 @@ const imageEntry: VaultEntry = { hasH1: false, fileKind: 'binary', } +const pdfEntry: VaultEntry = { + ...imageEntry, + path: '/vault/Attachments/report.pdf', + filename: 'report.pdf', + title: 'report.pdf', +} describe('FilePreview', () => { it('routes header file actions to the active file path', () => { @@ -64,4 +70,24 @@ describe('FilePreview', () => { expect(onCopyFilePath).toHaveBeenCalledWith('/vault/Attachments/photo.png') expect(onOpenExternalFile).toHaveBeenCalledWith('/vault/Attachments/photo.png') }) + + it('renders supported PDF files through the asset preview path', () => { + render() + + expect(screen.getByTestId('pdf-file-preview')).toHaveAttribute('data', 'asset:///vault/Attachments/report.pdf') + expect(screen.getByText('PDF file')).toBeInTheDocument() + }) + + it('renders supported PDFs when binary metadata is unavailable', () => { + render() + + expect(screen.getByTestId('pdf-file-preview')).toHaveAttribute('data', 'asset:///vault/Attachments/report.pdf') + }) + + it('provides a graceful fallback when a PDF preview cannot render', () => { + render() + + expect(screen.getByTestId('file-preview-fallback')).toHaveTextContent('PDF preview failed') + expect(screen.getByRole('button', { name: 'Open in default app' })).toBeInTheDocument() + }) }) diff --git a/src/components/FilePreview.tsx b/src/components/FilePreview.tsx index 10e02124..3c16562d 100644 --- a/src/components/FilePreview.tsx +++ b/src/components/FilePreview.tsx @@ -1,8 +1,8 @@ import { useCallback, useMemo, useState, type KeyboardEvent } from 'react' import { convertFileSrc } from '@tauri-apps/api/core' -import { ArrowSquareOut, ClipboardText, FileDashed, FolderOpen, ImageSquare, WarningCircle } from '@phosphor-icons/react' +import { ArrowSquareOut, ClipboardText, FileDashed, FilePdf, FolderOpen, ImageSquare, WarningCircle } from '@phosphor-icons/react' import type { VaultEntry } from '../types' -import { isImagePreviewEntry, previewFileTypeLabel } from '../utils/filePreview' +import { filePreviewKind, previewFileTypeLabel, type FilePreviewKind } from '../utils/filePreview' import { focusNoteListContainer } from '../utils/neighborhoodHistory' import { openLocalFile } from '../utils/url' import { Button } from './ui/button' @@ -21,6 +21,42 @@ interface FilePreviewFallbackProps { onOpenExternal: () => void } +function fallbackContentForPreviewKind(previewKind: FilePreviewKind | null): Omit { + if (previewKind === 'image') { + return { + icon: 'warning', + title: 'Image preview failed', + description: 'Tolaria could not render this image file in the preview.', + } + } + + if (previewKind === 'pdf') { + return { + icon: 'warning', + title: 'PDF preview failed', + description: 'Tolaria could not render this PDF file in the preview.', + } + } + + return { + icon: 'file', + title: 'Preview unavailable', + description: 'Tolaria does not have an in-app preview for this file type.', + } +} + +function FilePreviewHeaderIcon({ previewKind }: { previewKind: FilePreviewKind | null }) { + if (previewKind === 'image') { + return