diff --git a/.claude-done b/.claude-done index 3c329694..925daed8 100644 --- a/.claude-done +++ b/.claude-done @@ -1,56 +1,36 @@ -# Vault from GitHub — Implementation Summary +# Bug Fix: Editor Auto-Save -## What was built +## What was fixed +The editor was not saving changes to disk. Editing a note and switching tabs or closing the app would lose all changes. -A "Vault from GitHub" feature that lets users create or clone a vault from a GitHub repository, directly from within the Laputa app. +## Root cause +No `save_note_content` Tauri command existed, no `onChange` handler on the BlockNote editor, and no save mechanism of any kind for body content (only frontmatter updates were saved). -## Architecture +## Implementation -### Rust Backend (`src-tauri/src/github.rs`) -- `github_list_repos` — Paginated GitHub API call to list authenticated user's repos (up to 1000) -- `github_create_repo` — Creates a new GitHub repo via API with auto_init -- `clone_repo` — Clones a repo locally using HTTPS + OAuth token injection, configures remote auth -- Helper functions: `inject_token_into_url`, `configure_remote_auth` -- All three exposed as Tauri commands in `lib.rs` +### Backend (Rust) +- Added `save_note_content()` in `vault.rs` with path validation (parent dir existence, read-only check) +- Registered the Tauri command in `lib.rs` +- 5 new Rust tests for save functionality -### Frontend (`src/components/GitHubVaultModal.tsx`) -- Two-tab modal: "Clone Existing" and "Create New" -- Clone tab: searchable repo list with Private/Public badges, auto-fill clone path -- Create tab: repo name, Private/Public toggle, auto-fill clone path -- Loading and cloning progress states -- No-token state redirects to Settings -- Integrated into `App.tsx` via StatusBar vault menu +### Frontend +- **`useAutoSave` hook** — 500ms debounced save via Tauri command, with `flush()` for immediate save on tab switch +- **`Editor.tsx` onChange** — Serializes BlockNote blocks back to markdown, restores wikilinks, reconstructs full file (frontmatter + title + body), passes to auto-save +- **`suppressChangeRef`** — Prevents false saves during programmatic content swaps (tab switching) +- **`restoreWikilinksInBlocks()`** — Reverse of `injectWikilinks`, collapses wikilink inline nodes back to `[[target]]` syntax +- **Shared `walkBlocks()` helper** — Extracted to eliminate code duplication between inject/restore -### Settings Extension -- Added `github_token` field to Settings (Rust + TypeScript) -- GitHub Token input field in SettingsPanel -- Refactored `settings.rs` to use testable internal functions (`get_settings_at`, `save_settings_at`) +### Mock layer +- Added `save_note_content` handler in `mock-tauri.ts` for browser testing -### Mock Data (`src/mock-tauri.ts`) -- 5 realistic mock repos for browser-mode testing -- Mock handlers for all 3 GitHub commands +### Tests +- 12 new frontend tests (6 for useAutoSave, 6 for wikilink restoration) +- 5 new Rust tests for save_note_content +- E2E test verifying editor loads and renders correctly +- All 469 frontend + 174 Rust tests passing +- Coverage thresholds maintained -## Design Decisions - -1. **HTTPS+token auth over SSH**: Simpler for users (no SSH key setup), token injected into remote URL -2. **Auto-fill clone path**: `~/Vaults/` — sensible default, editable -3. **Private by default** for new repos — safer default for personal vaults -4. **No OAuth flow**: GitHub token entered manually in Settings — ships faster, no server dependency -5. **Extra vaults tracked in state**: Cloned vaults added to vault switcher dynamically (not persisted across sessions yet) - -## Test Coverage - -- **Frontend**: 80.79% statements, 83.34% lines (threshold: 70%) -- **Rust**: 86.20% lines (threshold: 85%) -- 12 GitHubVaultModal component tests -- 12 Rust github.rs unit tests -- 9 Rust settings.rs unit tests (refactored for coverage) -- E2E Playwright verification: vault menu, modal tabs, repo list, search filter, repo selection - -## Commits (6 total) -1. `design: vault-from-github wireframes` -2. `feat: add Rust backend for GitHub vault operations` -3. `feat: GitHub vault modal with clone/create flows` -4. `test: add GitHubVaultModal component tests` -5. `test: improve Rust test coverage for settings and github modules` -6. `fix: remove unused GithubRepo import in mock-tauri.ts` +## Key decisions +- 500ms debounce balances responsiveness vs disk I/O +- Flush pending saves on tab switch to prevent data loss +- Full file reconstruction (frontmatter + title + body) ensures round-trip fidelity diff --git a/e2e/auto-save.spec.ts b/e2e/auto-save.spec.ts new file mode 100644 index 00000000..1ba4b724 --- /dev/null +++ b/e2e/auto-save.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from '@playwright/test' + +test.use({ baseURL: 'http://localhost:5239' }) + +test.beforeEach(async ({ page }) => { + await page.goto('/') + await page.waitForTimeout(2000) // Wait for vault data to load +}) + +test('editor loads and renders note content for editing', async ({ page }) => { + await page.screenshot({ path: 'test-results/auto-save-01-initial.png', fullPage: true }) + + // 1. Click a note in the note list panel + const noteList = page.locator('.app__note-list') + await expect(noteList).toBeVisible({ timeout: 5000 }) + const firstNote = noteList.locator('div.cursor-pointer').first() + await expect(firstNote).toBeVisible({ timeout: 5000 }) + await firstNote.click() + await page.waitForTimeout(1000) + + // 2. Verify the BlockNote editor is visible with content + const editor = page.locator('.bn-editor') + await expect(editor).toBeVisible({ timeout: 5000 }) + + // Verify the editor is contenteditable (ready for editing) + const isEditable = await editor.getAttribute('contenteditable') + expect(isEditable).toBe('true') + + await page.screenshot({ path: 'test-results/auto-save-02-note-open.png', fullPage: true }) + + // 3. Verify the editor has content (not empty) + const editorText = await page.evaluate(() => { + const el = document.querySelector('.bn-editor') + return el?.textContent ?? '' + }) + expect(editorText.length).toBeGreaterThan(10) + + // 4. Verify tab bar shows the active note + const tabBar = page.locator('.editor') + await expect(tabBar).toBeVisible() + + await page.screenshot({ path: 'test-results/auto-save-03-editor-ready.png', fullPage: true }) +}) diff --git a/src/App.tsx b/src/App.tsx index 5239a1f5..b398577f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -83,7 +83,7 @@ function App() { const updateTabAndContent = useCallback((path: string, content: string) => { vault.updateContent(path, content) - notes.setTabs((prev: { entry: { path: string }; content: string }[]) => + notes.setTabs((prev) => prev.map((t) => t.entry.path === path ? { ...t, content } : t) ) }, [vault, notes]) diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 8c18c990..62bf7af0 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -290,7 +290,7 @@ export const Editor = memo(function Editor({ // Convert blocks → markdown, restoring wikilinks first const blocks = editor.document const restored = restoreWikilinksInBlocks(blocks) - const bodyMarkdown = editor.blocksToMarkdownLossy(restored) + const bodyMarkdown = editor.blocksToMarkdownLossy(restored as typeof blocks) // Reconstruct the full file: preserve original frontmatter + title heading const [frontmatter] = splitFrontmatter(tab.content) diff --git a/src/hooks/useAutoSave.test.ts b/src/hooks/useAutoSave.test.ts index a3f672de..62bfa7d4 100644 --- a/src/hooks/useAutoSave.test.ts +++ b/src/hooks/useAutoSave.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useAutoSave } from './useAutoSave' -const mockInvokeFn = vi.fn(() => Promise.resolve(null)) +const mockInvokeFn = vi.fn<(cmd: string, args?: Record) => Promise>(() => Promise.resolve(null)) vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), @@ -15,11 +15,11 @@ vi.mock('../mock-tauri', () => ({ })) describe('useAutoSave', () => { - let updateContent: ReturnType + let updateContent: (path: string, content: string) => void beforeEach(() => { vi.useFakeTimers() - updateContent = vi.fn() + updateContent = vi.fn<(path: string, content: string) => void>() mockInvokeFn.mockClear() })