fix: resolve TypeScript build errors and add E2E test
- Fix type annotations in App.tsx (setTabs callback) - Cast restoreWikilinksInBlocks result in Editor.tsx for BlockNote compatibility - Fix vi.fn() mock types in useAutoSave.test.ts - Add E2E test verifying editor loads and renders note content - Update .claude-done with editor-save-bug summary Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
76
.claude-done
76
.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/<repo-name>` — 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
|
||||
|
||||
43
e2e/auto-save.spec.ts
Normal file
43
e2e/auto-save.spec.ts
Normal file
@@ -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 })
|
||||
})
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, unknown>) => Promise<null>>(() => 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<typeof vi.fn>
|
||||
let updateContent: (path: string, content: string) => void
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
updateContent = vi.fn()
|
||||
updateContent = vi.fn<(path: string, content: string) => void>()
|
||||
mockInvokeFn.mockClear()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user