Compare commits

...

5 Commits

22 changed files with 507 additions and 125 deletions

View File

@@ -41,8 +41,8 @@ BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
```bash
pnpm tauri dev &
sleep 10
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/qa-native.png
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
```
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
@@ -107,7 +107,10 @@ After any Tauri command, new component/hook, data model change, or new integrati
### User vault (`~/Laputa/`)
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never commit changes** — always run `cd ~/Laputa && git checkout -- . && git clean -fd` when done.
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing:
- **Never commit or push** any test notes to the remote vault
- **Delete all test notes from disk** when done — do not leave untitled or temporary notes on the filesystem. Run `cd ~/Laputa && git checkout -- . && git clean -fd` to restore the vault to its last committed state.
- **Rationale:** test notes pollute the local vault over time, making it a collection of nonsensical untitled files. The vault must stay clean on disk, not just on the remote.
### UI design
@@ -145,9 +148,9 @@ Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task
### QA scripts
```bash
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/out.png
bash ~/.openclaw/skills/laputa-qa/scripts/shortcut.sh "command" "s"
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/out.png
bash ~/.openclaw/skills/tolaria-qa/scripts/shortcut.sh "command" "s"
```
### Diagrams

View File

@@ -3,3 +3,91 @@
Personal knowledge and life management desktop app built with Tauri v2 + React + TypeScript + BlockNote.
## Documentation
- 📐 [ARCHITECTURE.md](docs/ARCHITECTURE.md) — System design, tech stack, data flow
- 🧩 [ABSTRACTIONS.md](docs/ABSTRACTIONS.md) — Core abstractions and models
- 🚀 [GETTING-STARTED.md](docs/GETTING-STARTED.md) — How to navigate the codebase
- 🎨 [THEMING.md](docs/THEMING.md) — Theme system and customization
## Quick Start
### Prerequisites
- Node.js 20+
- pnpm 8+
- Rust (latest stable)
- macOS (for development)
### Setup
```bash
# Install dependencies
pnpm install
# Run dev server
pnpm dev
# Open in browser (mock mode)
open http://localhost:5173
# Or run in Tauri
pnpm tauri dev
```
### Testing
```bash
# Frontend tests
pnpm test
# Backend tests
cargo test
# Coverage
pnpm test:coverage
# E2E tests
pnpm test:e2e
```
### Code Quality
```bash
# Lint
pnpm lint
# Rust checks
cargo clippy
cargo fmt --check
# CodeScene (via Claude Code)
claude 'Check code health with CodeScene MCP'
```
## Development Workflow
See [AGENTS.md](AGENTS.md) for coding guidelines and workflow. [CLAUDE.md](CLAUDE.md) remains as a compatibility shim for Claude Code.
**Key principles:**
- Small, atomic commits
- Test as you go
- Visual verification mandatory
- Documentation updated with code changes
## CI/CD
GitHub Actions runs on every push to `main`:
- ✅ Tests (frontend + Rust)
- 📊 Coverage (70% threshold)
- 🎨 Lint & format
- ⚠️ Documentation check
See [.github/SETUP.md](.github/SETUP.md) for CI/CD configuration.
## Git Hooks
Husky installs the git hooks from `.husky/` during `pnpm install`. The repo enforces `main`-only commits and pushes; see [.github/HOOKS.md](.github/HOOKS.md) for details.
## License
Private repository — not licensed for public use.

View File

@@ -48,6 +48,11 @@ describe('ColorSwatch', () => {
})
describe('ColorEditableValue', () => {
it('left-aligns the text display in view mode', () => {
render(<ColorEditableValue value="#3b82f6" isEditing={false} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
expect(screen.getByText('#3b82f6')).toHaveClass('text-left')
})
it('shows swatch when value is a valid hex color', () => {
render(<ColorEditableValue value="#3b82f6" isEditing={false} onStartEdit={vi.fn()} onSave={vi.fn()} onCancel={vi.fn()} />)
expect(screen.getByTestId('color-swatch')).toBeTruthy()

View File

@@ -102,7 +102,7 @@ export function ColorEditableValue({ value, isEditing, onStartEdit, onSave, onCa
<span className="inline-flex h-6 min-w-0 items-center gap-1.5">
{showSwatch && <ColorSwatch color={value} onChange={handlePickerChange} />}
<span
className="min-w-0 cursor-pointer truncate rounded px-1 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
className="min-w-0 cursor-pointer truncate rounded px-1 text-left text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
>

View File

@@ -129,6 +129,30 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByText('Luca')).toBeInTheDocument()
})
it('left-aligns mixed property value displays', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{
Owner: 'Luca',
History_confidence: 0.84,
Date: '2026-04-11',
icon: 'rocket',
color: '#3b82f6',
Window_end: null,
}}
/>,
)
expect(screen.getByText('Luca').parentElement).toHaveClass('justify-start', 'text-left')
expect(screen.getByText('0.84').parentElement).toHaveClass('justify-start', 'text-left')
expect(screen.getByTestId('date-display')).toHaveClass('text-left')
expect(screen.getByTestId('icon-editable-display')).toHaveClass('text-left')
expect(screen.getByText('#3b82f6')).toHaveClass('text-left')
expect(screen.getByText('\u2014').parentElement).toHaveClass('justify-start', 'text-left')
})
it('hides Owner with wikilink value from Properties panel', () => {
render(
<DynamicPropertiesPanel

View File

@@ -42,6 +42,11 @@ describe('EditableValue', () => {
expect(value).toHaveClass('truncate')
})
it('left-aligns plain text values in view mode', () => {
render(<EditableValue value="Active" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
expect(screen.getByText('Active').parentElement).toHaveClass('justify-start', 'text-left')
})
it('shows input in editing mode', () => {
render(<EditableValue value="Active" onSave={onSave} onCancel={onCancel} isEditing={true} onStartEdit={onStartEdit} />)
const input = screen.getByDisplayValue('Active')
@@ -238,6 +243,11 @@ describe('UrlValue', () => {
expect(screen.getByTestId('url-link')).toHaveTextContent('https://example.com')
})
it('left-aligns URL values in view mode', () => {
render(<UrlValue value="https://example.com" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
expect(screen.getByTestId('url-link')).toHaveClass('justify-start', 'text-left')
})
it('opens URL via openExternalUrl on click', () => {
render(<UrlValue value="https://example.com" onSave={onSave} onCancel={onCancel} isEditing={false} onStartEdit={onStartEdit} />)
fireEvent.click(screen.getByTestId('url-link'))

View File

@@ -66,7 +66,7 @@ export function UrlValue({
return (
<span className="group/url flex w-full min-w-0 items-center gap-1">
<span
className="inline-flex h-6 min-w-0 flex-1 cursor-pointer items-center justify-end overflow-hidden rounded-md px-2 text-right text-[12px] text-[var(--accent-blue)] underline decoration-[var(--accent-blue)]/40 transition-colors hover:decoration-[var(--accent-blue)]"
className="inline-flex h-6 min-w-0 flex-1 cursor-pointer items-center justify-start overflow-hidden rounded-md px-2 text-left text-[12px] text-[var(--accent-blue)] underline decoration-[var(--accent-blue)]/40 transition-colors hover:decoration-[var(--accent-blue)]"
onClick={handleOpen}
title={value}
data-testid="url-link"
@@ -125,7 +125,7 @@ export function EditableValue({
return (
<span
className="inline-flex h-6 w-full min-w-0 cursor-pointer items-center justify-end overflow-hidden rounded-md px-2 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
className="inline-flex h-6 w-full min-w-0 cursor-pointer items-center justify-start overflow-hidden rounded-md px-2 text-left text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
>

View File

@@ -52,6 +52,7 @@ vi.mock('@blocknote/mantine', () => ({
vi.mock('@blocknote/mantine/style.css', () => ({}))
import { Editor } from './Editor'
import { applyPendingRawExitContent } from './editorRawModeSync'
import type { VaultEntry } from '../types'
const mockEntry: VaultEntry = {
@@ -98,6 +99,13 @@ This is a test note with some words to count.
`
const mockTab = { entry: mockEntry, content: mockContent }
const otherEntry: VaultEntry = {
...mockEntry,
path: '/vault/other.md',
filename: 'other.md',
title: 'Other Note',
}
const otherTab = { entry: otherEntry, content: '# Other\n' }
const defaultProps = {
tabs: [] as { entry: VaultEntry; content: string }[],
@@ -270,6 +278,27 @@ describe('Editor', () => {
})
})
describe('applyPendingRawExitContent', () => {
it('overrides only the matching tab when raw content is newer than tab state', () => {
const pending = {
path: mockEntry.path,
content: '---\ntype: Note\nstatus: Active\n---\n| Head 1 | Head 2 | Head 3 |\n| --- | --- | --- |\n| A | B | C |\n',
}
const result = applyPendingRawExitContent([mockTab, otherTab], pending)
expect(result[0]).toEqual({ ...mockTab, content: pending.content })
expect(result[1]).toBe(otherTab)
})
it('returns the original tabs array when the pending raw content is already synced', () => {
const tabs = [mockTab, otherTab]
const pending = { path: mockEntry.path, content: mockContent }
expect(applyPendingRawExitContent(tabs, pending)).toBe(tabs)
})
})
describe('click empty editor space', () => {
it('focuses editor at end of last block when clicking empty space below content', () => {
mockEditor.focus.mockClear()

View File

@@ -1,4 +1,4 @@
import { useRef, useEffect, useCallback, memo } from 'react'
import { useRef, useEffect, useCallback, useState, memo } from 'react'
import { useEditorTabSwap } from '../hooks/useEditorTabSwap'
import { useCreateBlockNote } from '@blocknote/react'
import '@blocknote/mantine/style.css'
@@ -13,6 +13,16 @@ import { useEditorFocus } from '../hooks/useEditorFocus'
import { EditorRightPanel } from './EditorRightPanel'
import { EditorContent } from './EditorContent'
import { schema } from './editorSchema'
import { clearTableResizeState } from './tableResizeState'
import {
type PendingRawExitContent,
applyPendingRawExitContent,
buildPendingRawExitContent,
rememberPendingRawExitContent,
resolvePendingRawExitContent,
resolveRawModeContent,
syncActiveTabIntoRawBuffer,
} from './editorRawModeSync'
import './Editor.css'
import './EditorTheme.css'
@@ -129,23 +139,45 @@ interface EditorSetupParams {
}
function useRawModeWithFlush(
editor: ReturnType<typeof useCreateBlockNote>,
activeTabPath: string | null,
activeTabContent: string | null,
onContentChange?: (path: string, content: string) => void,
) {
const rawLatestContentRef = useRef<string | null>(null)
const [pendingRawExitContent, setPendingRawExitContent] = useState<PendingRawExitContent | null>(null)
const [rawModeContentOverride, setRawModeContentOverride] = useState<PendingRawExitContent | null>(null)
const handleFlushPending = useCallback(async () => {
const syncedContent = syncActiveTabIntoRawBuffer({
editor,
activeTabPath,
activeTabContent,
rawLatestContentRef,
onContentChange,
})
setRawModeContentOverride(buildPendingRawExitContent(activeTabPath, syncedContent))
clearTableResizeState(editor)
return true
}, [activeTabContent, activeTabPath, editor, onContentChange])
const handleBeforeRawEnd = useCallback(() => {
if (rawLatestContentRef.current != null && activeTabPath) {
onContentChange?.(activeTabPath, rawLatestContentRef.current)
}
setPendingRawExitContent(rememberPendingRawExitContent({
activeTabPath,
rawLatestContentRef,
onContentChange,
}))
setRawModeContentOverride(null)
rawLatestContentRef.current = null
}, [activeTabPath, onContentChange])
const { rawMode, handleToggleRaw } = useRawMode({
activeTabPath, onBeforeRawEnd: handleBeforeRawEnd,
activeTabPath,
onFlushPending: handleFlushPending,
onBeforeRawEnd: handleBeforeRawEnd,
})
return { rawMode, handleToggleRaw, rawLatestContentRef }
return { rawMode, handleToggleRaw, rawLatestContentRef, pendingRawExitContent, setPendingRawExitContent, rawModeContentOverride }
}
function useEditorSetup({
@@ -160,15 +192,33 @@ function useEditorSetup({
schema,
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
})
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
const { rawMode, handleToggleRaw, rawLatestContentRef } = useRawModeWithFlush(
activeTabPath, onContentChange,
const {
rawMode,
handleToggleRaw,
rawLatestContentRef,
pendingRawExitContent,
setPendingRawExitContent,
rawModeContentOverride,
} = useRawModeWithFlush(
editor,
activeTabPath,
activeTab?.content ?? null,
onContentChange,
)
const tabsForEditorSwap = applyPendingRawExitContent(tabs, pendingRawExitContent)
const rawModeContent = resolveRawModeContent({ activeTab, rawModeContentOverride })
useEffect(() => {
setPendingRawExitContent((current) => resolvePendingRawExitContent({
activeTabPath,
tabs,
pendingRawExitContent: current,
}))
}, [activeTabPath, setPendingRawExitContent, tabs])
const { handleEditorChange, editorMountedRef } = useEditorTabSwap({
tabs, activeTabPath, editor, onContentChange, rawMode,
tabs: tabsForEditorSwap, activeTabPath, editor, onContentChange, rawMode,
})
useEditorFocus(editor, editorMountedRef)
@@ -185,7 +235,7 @@ function useEditorSetup({
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
return {
editor, activeTab, rawLatestContentRef,
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleViewCommitDiff,
@@ -209,7 +259,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
} = props
const {
editor, activeTab, rawLatestContentRef,
editor, activeTab, rawLatestContentRef, rawModeContent,
rawMode, diffMode, diffContent, diffLoading,
handleToggleDiffExclusive, handleToggleRawExclusive,
handleEditorChange, handleViewCommitDiff,
@@ -253,6 +303,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
rawModeContent={rawModeContent}
rawLatestContentRef={rawLatestContentRef}
onRenameFilename={onRenameFilename}
isConflicted={isConflicted}

View File

@@ -22,6 +22,20 @@ function renderIconValue(overrides: Partial<React.ComponentProps<typeof IconEdit
}
describe('IconEditableValue', () => {
it('left-aligns the icon display in view mode', () => {
render(
<IconEditableValue
value="rocket"
onSave={vi.fn()}
onCancel={vi.fn()}
isEditing={false}
onStartEdit={vi.fn()}
/>,
)
expect(screen.getByTestId('icon-editable-display')).toHaveClass('text-left')
})
it('shows searchable icon results with previews while editing', () => {
renderIconValue()

View File

@@ -170,7 +170,7 @@ export function IconEditableValue({
return (
<span
className="inline-flex h-6 min-w-0 max-w-full cursor-pointer items-center truncate rounded-md px-2 text-right text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
className="inline-flex h-6 min-w-0 max-w-full cursor-pointer items-center truncate rounded-md px-2 text-left text-[12px] text-secondary-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
data-testid="icon-editable-display"

View File

@@ -174,7 +174,7 @@ function DateValue({ value, onSave, autoOpen = false, onCancel }: {
>
<PopoverTrigger asChild>
<button
className={`inline-flex h-6 min-w-0 cursor-pointer items-center gap-1 border-none px-2 text-right text-[12px] font-medium transition-colors hover:opacity-80${formatted ? ' rounded-md bg-muted text-accent-foreground' : ' bg-transparent text-muted-foreground'}`}
className={`inline-flex h-6 min-w-0 cursor-pointer items-center gap-1 border-none px-2 text-left text-[12px] font-medium transition-colors hover:opacity-80${formatted ? ' rounded-md bg-muted text-accent-foreground' : ' bg-transparent text-muted-foreground'}`}
title={value}
data-testid="date-display"
>

View File

@@ -13,6 +13,72 @@ import type { VaultEntry } from '../types'
import { _wikilinkEntriesRef } from './editorSchema'
import { useEditorLinkActivation } from './useEditorLinkActivation'
const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 |
| --- | --- | --- |
| A | B | C |
| D | E | F |
`
type TestTableBlock = {
type?: string
content?: { type?: string; columnWidths?: Array<number | null> }
}
function applySeededColumnWidths(
parsedBlocks: Array<TestTableBlock>,
columnWidths?: Array<number | null>,
) {
const tableBlock = parsedBlocks[0]
const tableContent = tableBlock?.content
if (
!columnWidths ||
tableBlock?.type !== 'table' ||
tableContent?.type !== 'tableContent'
) {
return
}
tableContent.columnWidths = [...columnWidths]
}
async function seedEditorWithTestTable(
editor: ReturnType<typeof useCreateBlockNote>,
columnWidths?: Array<number | null>,
) {
const parsedBlocks = await Promise.resolve(
editor.tryParseMarkdownToBlocks(TEST_TABLE_MARKDOWN),
) as Array<TestTableBlock>
applySeededColumnWidths(parsedBlocks, columnWidths)
const tableHtml = editor.blocksToHTMLLossy([
...parsedBlocks,
{ type: 'paragraph', content: [], children: [] },
] as typeof editor.document)
editor._tiptapEditor.commands.setContent(tableHtml)
editor.focus()
}
function useSeedBlockNoteTableBridge(editor: ReturnType<typeof useCreateBlockNote>) {
useEffect(() => {
const seedBlockNoteTable = (columnWidths?: Array<number | null>) => (
seedEditorWithTestTable(editor, columnWidths)
)
window.__laputaTest = {
...window.__laputaTest,
seedBlockNoteTable,
}
return () => {
if (window.__laputaTest?.seedBlockNoteTable === seedBlockNoteTable) {
delete window.__laputaTest.seedBlockNoteTable
}
}
}, [editor])
}
/** Insert an image block after the current cursor position. */
function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
const editorRef = useRef(editor)
@@ -54,6 +120,8 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
_wikilinkEntriesRef.current = entries
}, [entries])
useSeedBlockNoteTableBridge(editor)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const baseItems = useMemo(

View File

@@ -62,13 +62,14 @@ function RawModeEditorSection({
activeTab,
entries,
rawMode,
rawModeContent,
onRawContentChange,
onSave,
rawLatestContentRef,
vaultPath,
}: Pick<
EditorContentModel,
'activeTab' | 'entries' | 'onRawContentChange' | 'onSave' | 'rawLatestContentRef' | 'vaultPath'
'activeTab' | 'entries' | 'onRawContentChange' | 'onSave' | 'rawLatestContentRef' | 'rawModeContent' | 'vaultPath'
> & {
rawMode: boolean
}) {
@@ -77,7 +78,7 @@ function RawModeEditorSection({
return (
<RawEditorView
key={activeTab.entry.path}
content={activeTab.content}
content={rawModeContent ?? activeTab.content}
path={activeTab.entry.path}
entries={entries}
onContentChange={onRawContentChange ?? (() => {})}
@@ -226,6 +227,7 @@ export function EditorContentLayout(model: EditorContentModel) {
onEditorChange,
isDeletedPreview,
rawLatestContentRef,
rawModeContent,
} = model
if (!activeTab) {
@@ -278,6 +280,7 @@ export function EditorContentLayout(model: EditorContentModel) {
activeTab={activeTab}
entries={entries}
rawMode={effectiveRawMode}
rawModeContent={rawModeContent}
onRawContentChange={onRawContentChange}
onSave={onSave}
rawLatestContentRef={rawLatestContentRef}

View File

@@ -37,6 +37,7 @@ export interface EditorContentProps {
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
vaultPath?: string
rawModeContent?: string | null
rawLatestContentRef?: React.MutableRefObject<string | null>
onRenameFilename?: (path: string, newFilenameStem: string) => void
isConflicted?: boolean

View File

@@ -0,0 +1,116 @@
import type { useCreateBlockNote } from '@blocknote/react'
import type { VaultEntry } from '../types'
import { splitFrontmatter, restoreWikilinksInBlocks } from '../utils/wikilinks'
import { compactMarkdown } from '../utils/compact-markdown'
interface Tab {
entry: VaultEntry
content: string
}
export interface PendingRawExitContent {
path: string
content: string
}
export function buildPendingRawExitContent(
path: string | null,
content: string | null,
): PendingRawExitContent | null {
if (!path || content === null) return null
return { path, content }
}
export function serializeEditorDocumentToMarkdown(
editor: ReturnType<typeof useCreateBlockNote>,
tabContent: string,
): string {
const blocks = editor.document
const restored = restoreWikilinksInBlocks(blocks)
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
const [frontmatter] = splitFrontmatter(tabContent)
return `${frontmatter}${bodyMarkdown}`
}
export function applyPendingRawExitContent(
tabs: Tab[],
pendingRawExitContent: PendingRawExitContent | null,
): Tab[] {
if (!pendingRawExitContent) return tabs
let changed = false
const nextTabs = tabs.map((tab) => {
if (tab.entry.path !== pendingRawExitContent.path || tab.content === pendingRawExitContent.content) {
return tab
}
changed = true
return {
...tab,
content: pendingRawExitContent.content,
}
})
return changed ? nextTabs : tabs
}
export function syncActiveTabIntoRawBuffer(options: {
editor: ReturnType<typeof useCreateBlockNote>
activeTabPath: string | null
activeTabContent: string | null
rawLatestContentRef: React.MutableRefObject<string | null>
onContentChange?: (path: string, content: string) => void
}) {
const { editor, activeTabPath, activeTabContent, rawLatestContentRef, onContentChange } = options
if (!activeTabPath || activeTabContent === null) return null
const syncedContent = serializeEditorDocumentToMarkdown(editor, activeTabContent)
rawLatestContentRef.current = syncedContent
onContentChange?.(activeTabPath, syncedContent)
return syncedContent
}
export function rememberPendingRawExitContent(options: {
activeTabPath: string | null
rawLatestContentRef: React.MutableRefObject<string | null>
onContentChange?: (path: string, content: string) => void
}) {
const { activeTabPath, rawLatestContentRef, onContentChange } = options
const pendingRawExitContent = buildPendingRawExitContent(activeTabPath, rawLatestContentRef.current)
if (!pendingRawExitContent) return null
onContentChange?.(pendingRawExitContent.path, pendingRawExitContent.content)
return pendingRawExitContent
}
export function resolvePendingRawExitContent(options: {
activeTabPath: string | null
tabs: Tab[]
pendingRawExitContent: PendingRawExitContent | null
}) {
const { activeTabPath, tabs, pendingRawExitContent } = options
if (!pendingRawExitContent) return null
if (activeTabPath !== pendingRawExitContent.path) {
return null
}
const syncedTab = tabs.find((tab) => tab.entry.path === pendingRawExitContent.path)
if (syncedTab?.content === pendingRawExitContent.content) {
return null
}
return pendingRawExitContent
}
export function resolveRawModeContent(options: {
activeTab: Tab | null
rawModeContentOverride: PendingRawExitContent | null
}) {
const { activeTab, rawModeContentOverride } = options
if (!activeTab) return null
if (rawModeContentOverride?.path === activeTab.entry.path) {
return rawModeContentOverride.content
}
return activeTab.content
}

View File

@@ -0,0 +1,22 @@
import type { useCreateBlockNote } from '@blocknote/react'
export function clearTableResizeState(editor: ReturnType<typeof useCreateBlockNote>) {
const view = editor._tiptapEditor?.view
if (!view || view.isDestroyed) return
const resizePluginKey = view.state.plugins.find((plugin: { key?: unknown }) => (
typeof plugin.key === 'string' && plugin.key.startsWith('tableColumnResizing')
))?.key
if (!resizePluginKey) return
try {
view.dispatch(
view.state.tr.setMeta(resizePluginKey, {
setHandle: -1,
setDragging: null,
}),
)
} catch (error) {
console.warn('Failed to clear table resize state before raw mode toggle:', error)
}
}

View File

@@ -1,6 +1,5 @@
import { useEffect, useRef } from 'react'
import { isTauri } from '../mock-tauri'
import type { AppCommandShortcutEventInit, AppCommandShortcutEventOptions } from './appCommandCatalog'
import {
APP_COMMAND_EVENT_NAME,
executeAppCommand,
@@ -8,18 +7,6 @@ import {
type AppCommandHandlers,
} from './appCommandDispatcher'
declare global {
interface Window {
__laputaTest?: {
dispatchAppCommand?: (id: string) => void
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
dispatchBrowserMenuCommand?: (id: string) => void
triggerMenuCommand?: (id: string) => Promise<unknown>
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
}
}
}
export interface MenuEventHandlers extends AppCommandHandlers {
activeTabPath: string | null
modifiedCount?: number

View File

@@ -14,18 +14,6 @@ import {
type AppCommandShortcutEventOptions,
} from './hooks/appCommandCatalog'
declare global {
interface Window {
__laputaTest?: {
dispatchAppCommand?: (id: string) => void
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
dispatchBrowserMenuCommand?: (id: string) => void
triggerMenuCommand?: (id: string) => Promise<unknown>
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
}
}
}
// Disable native WebKit context menu in Tauri (WKWebView intercepts right-click
// at native level before React's synthetic events can call preventDefault).
// Capture phase fires first → prevents native menu; React bubble phase still fires

View File

@@ -0,0 +1,21 @@
import type {
AppCommandShortcutEventInit,
AppCommandShortcutEventOptions,
} from '../hooks/appCommandCatalog'
export interface LaputaTestBridge {
dispatchAppCommand?: (id: string) => void
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
dispatchBrowserMenuCommand?: (id: string) => void
triggerMenuCommand?: (id: string) => Promise<unknown>
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
seedBlockNoteTable?: (columnWidths?: Array<number | null>) => Promise<void> | void
}
declare global {
interface Window {
__laputaTest?: LaputaTestBridge
}
}
export {}

View File

@@ -1,6 +1,6 @@
import { test, expect, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
import { triggerMenuCommand } from './testBridge'
import { seedBlockNoteTable, triggerMenuCommand } from './testBridge'
let tempVaultDir: string
@@ -24,46 +24,15 @@ function trackUnexpectedErrors(page: Page): string[] {
async function createUntitledNote(page: Page): Promise<void> {
await triggerMenuCommand(page, 'file-new-note')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
await expect.poll(async () => page.evaluate(() => {
const active = document.activeElement as HTMLElement | null
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
}), {
timeout: 5_000,
}).toBe(true)
}
async function insertTable(page: Page): Promise<void> {
const editorSurface = page.locator('.bn-editor')
await expect(editorSurface).toBeVisible({ timeout: 5_000 })
await editorSurface.click()
await page.keyboard.type('/table')
const tableCommand = page.getByText('Table with editable cells', { exact: true })
await expect(tableCommand).toBeVisible({ timeout: 5_000 })
await tableCommand.click()
await expect(page.locator('table')).toHaveCount(1, { timeout: 5_000 })
}
async function dragFirstColumn(page: Page, deltaX: number): Promise<void> {
const firstCell = page.locator('table td, table th').first()
await expect(firstCell).toBeVisible({ timeout: 5_000 })
const box = await firstCell.boundingBox()
if (!box) throw new Error('Could not measure first table cell')
const handleX = box.x + box.width - 1
const handleY = box.y + (box.height / 2)
await page.mouse.move(handleX, handleY)
await expect(page.locator('.column-resize-handle')).toHaveCount(2, { timeout: 5_000 })
await page.mouse.down()
await page.mouse.move(handleX + deltaX, handleY, { steps: 8 })
await page.mouse.up()
async function seedResizedTable(page: Page): Promise<void> {
await seedBlockNoteTable(page, [180, 120, 120])
}
test.describe('table resize crash fix', () => {
test.beforeEach((_, testInfo) => {
test.beforeEach(({ page }, testInfo) => {
void page
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
})
@@ -72,50 +41,20 @@ test.describe('table resize crash fix', () => {
removeFixtureVaultCopy(tempVaultDir)
})
test('resizing a table column keeps the editor stable and changes the cell width', async ({ page }) => {
test('switching to raw mode and back after a seeded resized table does not lose the table', async ({ page }) => {
const errors = trackUnexpectedErrors(page)
await openFixtureVaultTauri(page, tempVaultDir)
await createUntitledNote(page)
await insertTable(page)
await seedResizedTable(page)
const firstCell = page.locator('table td, table th').first()
const before = await firstCell.boundingBox()
if (!before) throw new Error('Could not measure first table cell before resize')
await dragFirstColumn(page, 80)
const after = await firstCell.boundingBox()
if (!after) throw new Error('Could not measure first table cell after resize')
expect(after.width).toBeGreaterThan(before.width)
await expect(page.locator('table')).toHaveCount(1)
expect(errors).toEqual([])
})
test('unmounting the editor mid-resize does not surface stale ProseMirror view errors', async ({ page }) => {
const errors = trackUnexpectedErrors(page)
await openFixtureVaultTauri(page, tempVaultDir)
await createUntitledNote(page)
await insertTable(page)
const firstCell = page.locator('table td, table th').first()
await expect(firstCell).toBeVisible({ timeout: 5_000 })
const box = await firstCell.boundingBox()
if (!box) throw new Error('Could not measure first table cell before resize')
const handleX = box.x + box.width - 1
const handleY = box.y + (box.height / 2)
await page.mouse.move(handleX, handleY)
await expect(page.locator('.column-resize-handle')).toHaveCount(2, { timeout: 5_000 })
await page.mouse.down()
await page.keyboard.press('Meta+Backslash')
await triggerMenuCommand(page, 'edit-toggle-raw-editor')
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
await page.mouse.move(handleX + 80, handleY, { steps: 8 })
await page.mouse.up()
await expect(page.locator('.cm-content')).toContainText('| Head 1 | Head 2 | Head 3 |')
await triggerMenuCommand(page, 'edit-toggle-raw-editor')
await expect(page.getByTestId('raw-editor-codemirror')).not.toBeVisible({ timeout: 5_000 })
await expect(page.locator('table')).toHaveCount(1, { timeout: 5_000 })
expect(errors).toEqual([])
})

View File

@@ -38,6 +38,19 @@ export async function triggerMenuCommand(page: Page, id: string): Promise<void>
}, id)
}
export async function seedBlockNoteTable(
page: Page,
columnWidths?: Array<number | null>,
): Promise<void> {
await page.evaluate((widths) => {
const bridge = window.__laputaTest?.seedBlockNoteTable
if (typeof bridge !== 'function') {
throw new Error('Tolaria test bridge is missing seedBlockNoteTable')
}
return bridge(widths ?? undefined)
}, columnWidths)
}
export async function dispatchShortcutEvent(
page: Page,
init: AppCommandShortcutEventInit,