diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index b5ed72f4..07fdc935 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -312,7 +312,7 @@ The folder tree hides only the dedicated `type/` directory, since note types alr A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`. -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, and folder mutations cannot step outside the active vault. +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. ### Vault Caching diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e1b817ca..5a8a5f3c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -595,7 +595,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers | | `filename_rules.rs` | Cross-platform validation for note filenames, folder names, and custom view filenames | | `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures | -| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames | +| `image.rs` | `save_image` / `copy_image_to_vault` — save editor image attachments with sanitized filenames | | `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` | | `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` | | `getting_started.rs` | Clones and normalizes the public Getting Started starter vault | @@ -713,8 +713,8 @@ The desktop MCP WebSocket bridge is intentionally local-only. `mcp-server/ws-bri | `save_vault_config` | Save per-vault UI config | | `get_default_vault_path` | Get default vault path | | `get_build_number` | Get app build number | -| `save_image` | Save base64 image to vault | -| `copy_image_to_vault` | Copy image file to vault | +| `save_image` | Save base64 image to `attachments/` and refresh the active vault asset scope | +| `copy_image_to_vault` | Copy image file to `attachments/` and refresh the active vault asset scope | | `update_menu_state` | Update native menu checkmarks and enabled/disabled state for selection-dependent actions | | `trigger_menu_command` | Emit a native menu command ID for deterministic shortcut QA | | `update_current_window_min_size` | Update the active Tauri window's minimum size and optionally grow it to fit restored panes | diff --git a/e2e/image-upload.spec.ts b/e2e/image-upload.spec.ts index 0781a729..82dfdb77 100644 --- a/e2e/image-upload.spec.ts +++ b/e2e/image-upload.spec.ts @@ -1,6 +1,11 @@ -import { test, expect } from '@playwright/test' +import { test, expect, type Page } from '@playwright/test' import * as path from 'path' import * as fs from 'fs' +import { + createFixtureVaultCopy, + openFixtureVault, + removeFixtureVaultCopy, +} from '../tests/helpers/fixtureVault' // Minimal valid PNG: 1x1 red pixel const TEST_PNG_BASE64 = @@ -11,16 +16,28 @@ function createTestPng(filepath: string) { fs.writeFileSync(filepath, Buffer.from(TEST_PNG_BASE64, 'base64')) } -test('image upload via file picker displays image with data URL', async ({ page }) => { - await page.goto('/') - await page.waitForTimeout(1000) +let tempVaultDir: string - // Open a note - await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 }) - await page.waitForTimeout(500) +async function openImageTestNote(page: Page) { + await page.locator('[data-testid="note-list-container"]').getByText('Alpha Project', { exact: true }).click() const editor = page.locator('.bn-editor') await expect(editor).toBeVisible({ timeout: 10000 }) + return editor +} + +test.beforeEach(async ({ page }, testInfo) => { + testInfo.setTimeout(60_000) + tempVaultDir = createFixtureVaultCopy() + await openFixtureVault(page, tempVaultDir) +}) + +test.afterEach(async () => { + removeFixtureVaultCopy(tempVaultDir) +}) + +test('image upload via file picker displays image with data URL', async ({ page }) => { + const editor = await openImageTestNote(page) await editor.click() await page.waitForTimeout(200) @@ -63,16 +80,37 @@ test('image upload via file picker displays image with data URL', async ({ page if (fs.existsSync(testImagePath)) fs.unlinkSync(testImagePath) }) +test('image paste into editor inserts image block', async ({ page }) => { + const editor = await openImageTestNote(page) + await editor.click() + + await page.evaluate((base64) => { + const editorElement = document.querySelector('.bn-editor') + if (!editorElement) throw new Error('Editor not found') + + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + + const file = new File([bytes], 'pasted-image.png', { type: 'image/png' }) + const clipboardData = new DataTransfer() + clipboardData.items.add(file) + editorElement.dispatchEvent(new ClipboardEvent('paste', { + clipboardData, + bubbles: true, + cancelable: true, + })) + }, TEST_PNG_BASE64) + + const images = page.locator('.bn-editor img') + await expect(images.first()).toBeVisible({ timeout: 5000 }) + + const src = await images.first().getAttribute('src') + expect(src).toMatch(/^data:/) +}) + test('editor has uploadFile configured (no error on image block insert)', async ({ page }) => { - await page.goto('/') - await page.waitForTimeout(1000) - - // Click first note - await page.locator('[data-testid="type-icon"]').first().click({ timeout: 10000 }) - await page.waitForTimeout(500) - - const editor = page.locator('.bn-editor') - await expect(editor).toBeVisible({ timeout: 10000 }) + const editor = await openImageTestNote(page) // Capture console errors const errors: string[] = [] diff --git a/src-tauri/src/commands/vault/file_cmds.rs b/src-tauri/src/commands/vault/file_cmds.rs index 471043a4..88952952 100644 --- a/src-tauri/src/commands/vault/file_cmds.rs +++ b/src-tauri/src/commands/vault/file_cmds.rs @@ -40,6 +40,31 @@ fn with_requested_root_path( with_requested_root(raw_vault_path.as_ref(), action) } +fn sync_image_asset_scope( + app_handle: &tauri::AppHandle, + requested_root: &str, +) -> Result<(), String> { + #[cfg(desktop)] + crate::sync_vault_asset_scope(app_handle, Path::new(requested_root))?; + #[cfg(not(desktop))] + let _ = requested_root; + #[cfg(not(desktop))] + let _ = app_handle; + Ok(()) +} + +fn with_image_asset_scope( + app_handle: &tauri::AppHandle, + vault_path: &Path, + action: impl FnOnce(&str) -> Result, +) -> Result { + with_requested_root_path(vault_path, |requested_root| { + let saved_path = action(requested_root)?; + sync_image_asset_scope(app_handle, requested_root)?; + Ok(saved_path) + }) +} + fn with_writable_note_path( path: PathBuf, vault_path: Option, @@ -148,15 +173,24 @@ pub fn sync_note_title(path: PathBuf, vault_path: Option) -> Result Result { - with_requested_root_path(vault_path.as_path(), |requested_root| { +pub fn save_image( + app_handle: tauri::AppHandle, + vault_path: PathBuf, + filename: String, + data: String, +) -> Result { + with_image_asset_scope(&app_handle, vault_path.as_path(), |requested_root| { vault::save_image(requested_root, &filename, &data) }) } #[tauri::command] -pub fn copy_image_to_vault(vault_path: PathBuf, source_path: PathBuf) -> Result { - with_requested_root_path(vault_path.as_path(), |requested_root| { +pub fn copy_image_to_vault( + app_handle: tauri::AppHandle, + vault_path: PathBuf, + source_path: PathBuf, +) -> Result { + with_image_asset_scope(&app_handle, vault_path.as_path(), |requested_root| { vault::copy_image_to_vault(requested_root, source_path.to_string_lossy().as_ref()) }) }