Compare commits

...

13 Commits

Author SHA1 Message Date
lucaronin
867e97b199 fix: left-align property values in inspector 2026-04-12 02:03:54 +02:00
lucaronin
2441518cee fix: restore breadcrumb center drag region 2026-04-12 01:52:31 +02:00
lucaronin
010bc32ee1 style: format Rust rename updates 2026-04-12 01:41:26 +02:00
lucaronin
361898b187 refactor: rename app branding from Laputa to Tolaria 2026-04-12 01:35:34 +02:00
lucaronin
5b5f949c74 fix: initialize new notes with empty h1 focus 2026-04-12 00:58:11 +02:00
lucaronin
57a5693922 fix: stabilize new note editor focus 2026-04-12 00:06:49 +02:00
lucaronin
2ca8f1b2a6 fix: remove legacy title section fallback 2026-04-11 23:51:58 +02:00
lucaronin
eb65bb8f05 fix: dedupe new note command palette entries 2026-04-11 22:05:13 +02:00
lucaronin
8fb229ede3 fix: preserve square brackets in parsed note titles 2026-04-11 21:27:30 +02:00
lucaronin
ce84b34890 fix: restore cmd shift i properties shortcut 2026-04-11 20:59:16 +02:00
lucaronin
e98a186389 fix: align note list date row spacing 2026-04-11 20:30:48 +02:00
lucaronin
258b54b074 refactor: make shortcut QA modes explicit 2026-04-11 19:03:15 +02:00
lucaronin
f694b9b5e4 fix: unblock native ai panel shortcut in tauri 2026-04-11 18:34:39 +02:00
136 changed files with 1862 additions and 1565 deletions

View File

@@ -172,7 +172,9 @@ jobs:
run: |
VERSION="${{ needs.version.outputs.version }}"
TAG="${{ needs.version.outputs.tag }}"
REPO="refactoringhq/laputa-app"
REPO="${GITHUB_REPOSITORY}"
REPO_NAME="${REPO#*/}"
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
@@ -180,7 +182,7 @@ jobs:
cat > latest-canary.json << EOF
{
"version": "${VERSION}",
"notes": "Canary build. See https://refactoringhq.github.io/laputa-app/ for release notes.",
"notes": "Canary build. See ${PAGES_URL} for release notes.",
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"platforms": {
"darwin-aarch64": {
@@ -196,7 +198,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.version.outputs.tag }}
name: Laputa ${{ needs.version.outputs.version }} (Canary)
name: Tolaria ${{ needs.version.outputs.version }} (Canary)
body_path: release_notes.md
draft: false
prerelease: true
@@ -227,8 +229,9 @@ jobs:
run: |
mkdir -p _site
gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
# Download stable latest.json from existing GH Pages (preserve it)
curl -fsSL "https://refactoringhq.github.io/laputa-app/latest.json" -o _site/latest.json || echo '{}' > _site/latest.json
curl -fsSL "${PAGES_URL}/latest.json" -o _site/latest.json || echo '{}' > _site/latest.json
# Copy canary latest.json from this release
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "latest-canary.json" --output _site/latest-canary.json || true
cat > _site/index.html << 'HTMLEOF'
@@ -237,7 +240,7 @@ jobs:
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Laputa — Release History</title>
<title>Tolaria — Release History</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #F7F6F3; color: #37352F; line-height: 1.6; padding: 2rem; max-width: 720px; margin: 0 auto; }
@@ -255,7 +258,7 @@ jobs:
</style>
</head>
<body>
<h1>Laputa Release History</h1>
<h1>Tolaria Release History</h1>
<p class="subtitle">Auto-updated on every release</p>
<div id="releases"></div>
<script>

View File

@@ -173,7 +173,9 @@ jobs:
run: |
VERSION="${{ needs.version.outputs.version }}"
TAG="${{ needs.version.outputs.tag }}"
REPO="refactoringhq/laputa-app"
REPO="${GITHUB_REPOSITORY}"
REPO_NAME="${REPO#*/}"
PAGES_URL="https://refactoringhq.github.io/${REPO_NAME}/"
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
@@ -181,7 +183,7 @@ jobs:
cat > latest.json << EOF
{
"version": "${VERSION}",
"notes": "See https://refactoringhq.github.io/laputa-app/ for full release notes.",
"notes": "See ${PAGES_URL} for full release notes.",
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"platforms": {
"darwin-aarch64": {
@@ -197,7 +199,7 @@ jobs:
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.version.outputs.tag }}
name: Laputa ${{ needs.version.outputs.version }}
name: Tolaria ${{ needs.version.outputs.version }}
body_path: release_notes.md
draft: false
prerelease: false
@@ -228,17 +230,18 @@ jobs:
run: |
mkdir -p _site
gh api repos/${{ github.repository }}/releases --paginate > _site/releases.json
PAGES_URL="https://refactoringhq.github.io/${GITHUB_REPOSITORY#*/}"
# Copy latest.json to GitHub Pages for auto-updater endpoint
gh release download --repo ${{ github.repository }} --pattern "latest.json" --output _site/latest.json || true
# Preserve canary latest.json from existing GH Pages
curl -fsSL "https://refactoringhq.github.io/laputa-app/latest-canary.json" -o _site/latest-canary.json || true
curl -fsSL "${PAGES_URL}/latest-canary.json" -o _site/latest-canary.json || true
cat > _site/index.html << 'HTMLEOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Laputa — Release History</title>
<title>Tolaria — Release History</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #F7F6F3; color: #37352F; line-height: 1.6; padding: 2rem; max-width: 720px; margin: 0 auto; }
@@ -255,7 +258,7 @@ jobs:
</style>
</head>
<body>
<h1>Laputa Release History</h1>
<h1>Tolaria Release History</h1>
<p class="subtitle">Auto-updated on every release</p>
<div id="releases"></div>
<script>

View File

@@ -1,4 +1,4 @@
# AGENTS.md — Laputa App
# AGENTS.md — Tolaria App
> Quick links: [Project Spec](docs/PROJECT-SPEC.md) · [Architecture](docs/ARCHITECTURE.md) · [Abstractions](docs/ABSTRACTIONS.md) · [Wireframes](ui-design.pen)
@@ -130,7 +130,7 @@ Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task
| Toggle/switch | `Switch` or `ToggleGroup` from shadcn/ui |
| Dialog/modal | `Dialog` from shadcn/ui |
**When in doubt:** search `src/components/` for an existing component before building new. **Visual language:** all new UI must feel native to Laputa — if it looks like a browser default, it's wrong.
**When in doubt:** search `src/components/` for an existing component before building new. **Visual language:** all new UI must feel native to Tolaria — if it looks like a browser default, it's wrong.
---

View File

@@ -1,93 +1,5 @@
# Laputa App
# Tolaria App
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

@@ -1,20 +1,20 @@
# Abstractions
Key abstractions and domain models in Laputa.
Key abstractions and domain models in Tolaria.
## Design Philosophy
Laputa's abstractions follow the **convention over configuration** principle: standard field names and folder structures have well-defined meanings and trigger UI behavior automatically. This makes vaults legible both to humans and to AI agents — the more a vault follows conventions, the less custom configuration an AI needs to navigate it correctly.
Tolaria's abstractions follow the **convention over configuration** principle: standard field names and folder structures have well-defined meanings and trigger UI behavior automatically. This makes vaults legible both to humans and to AI agents — the more a vault follows conventions, the less custom configuration an AI needs to navigate it correctly.
The full set of design principles is documented in [ARCHITECTURE.md](./ARCHITECTURE.md#design-principles).
## Semantic Field Names (conventions)
These frontmatter field names have special meaning in Laputa's UI:
These frontmatter field names have special meaning in Tolaria's UI:
| Field | Meaning | UI behavior |
|---|---|---|
| `title:` | Human-readable title (synced with filename) | Breadcrumb, sidebar. Filename = `slugify(title).md` |
| `title:` | Legacy display-title fallback for older notes | Used only when a note has no H1; new notes do not write it automatically |
| `type:` | Entity type (Project, Person, Quarter…) | Type chip in note list + sidebar grouping |
| `status:` | Lifecycle stage (active, done, blocked…) | Colored chip in note list + editor header |
| `icon:` | Per-note icon (emoji, Phosphor name, or HTTP/HTTPS image URL) | Rendered on note title surfaces; editable from the Properties panel |
@@ -35,7 +35,7 @@ Any frontmatter field whose name starts with `_` is a **system property**:
- It is **not shown** in the Properties panel (neither for notes nor for Type notes)
- It is **not exposed** as a user-visible property in search, filters, or the UI
- It **is editable** directly in the raw editor (power users can access it if needed)
- It is used by Laputa internally for configuration, behavior, and UI preferences
- It is used by Tolaria internally for configuration, behavior, and UI preferences
Examples:
```yaml
@@ -233,20 +233,21 @@ All `[[wikilinks]]` in the note body (not frontmatter) are extracted by regex an
### Title / Filename Sync
Laputa separates **display title** from the file identifier:
Tolaria separates **display title** from the file identifier:
- **Display title resolution** (`extract_title` in `vault/parsing.rs`): first `# H1` on the first non-empty body line, then legacy frontmatter `title:`, then slug-to-title from the filename stem.
- **Opening a note is read-only**: selecting a note does not inject or auto-correct `title:` frontmatter.
- **On rename / explicit title edits** (`rename_note`): Laputa updates both filename and `title` frontmatter atomically, plus wikilinks across the vault.
- **Explicit filename actions** (`rename_note`): breadcrumb rename/sync actions update the filename and wikilinks across the vault. The editor body remains the title editing surface.
- **Untitled drafts** start as `untitled-*.md` and are auto-renamed on save once the note gains an H1.
### Title Field (UI)
### Title Surface (UI)
The dedicated `TitleField` is a fallback editing surface, not the canonical one:
The BlockNote body is the only title editing surface:
- If the note already has an H1, the editor body is the primary title surface and the dedicated title row is hidden.
- If the note has no H1 and is not an untitled draft, `TitleField` appears above the editor and `onTitleSync` updates `title:` frontmatter plus the filename.
- `TitleField` also responds to `laputa:focus-editor` events with `selectTitle: true` for new-note flows that start without an H1.
- The first H1 is the canonical display title.
- There is no separate title row above the editor, even when a note has no H1.
- Notes without an H1 show the editor body and placeholder only.
- Filename changes are explicit breadcrumb actions, not a dedicated title-input side effect.
### Sidebar Selection
@@ -494,7 +495,7 @@ No indexing step required — search runs directly against the filesystem.
### Vault Switching
`useVaultSwitcher` hook manages multiple vaults:
- Persists vault list to `~/.config/com.laputa.app/vaults.json`
- Persists vault list to `~/.config/com.tolaria.app/vaults.json` (reads legacy `com.laputa.app` on upgrade)
- Switching closes all tabs and resets sidebar
- Supports adding, removing, hiding/restoring vaults
- Default vault: Getting Started demo vault
@@ -511,7 +512,7 @@ Per-vault settings stored locally and scoped by vault path:
`useOnboarding` hook detects first launch:
- If vault path doesn't exist → show `WelcomeScreen`
- User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen folder
- Welcome state tracked in localStorage (`laputa_welcome_dismissed`)
- Welcome state tracked in localStorage (`tolaria_welcome_dismissed`, with legacy fallback)
### GitHub Integration
@@ -523,7 +524,7 @@ Device Authorization Flow for GitHub-backed vaults:
## Settings
App-level settings persisted at `~/.config/com.laputa.app/settings.json`:
App-level settings persisted at `~/.config/com.tolaria.app/settings.json` (reads legacy `com.laputa.app` on upgrade):
```typescript
interface Settings {

View File

@@ -1,6 +1,6 @@
# Architecture
Laputa is a personal knowledge and life management desktop app. It reads a vault of markdown files with YAML frontmatter and presents them in a four-panel UI inspired by Bear Notes.
Tolaria is a personal knowledge and life management desktop app. It reads a vault of markdown files with YAML frontmatter and presents them in a four-panel UI inspired by Bear Notes.
## Design Principles
@@ -10,13 +10,13 @@ The vault is a folder of plain markdown files. The app never owns the data — i
### Convention over configuration
Laputa is opinionated. Standard field names (`type:`, `status:`, `url:`, `Workspace:`, `Belongs to:`, `start_date:`, `end_date:`) have well-defined meanings and trigger specific UI behavior — without any setup. This is not convention *instead of* configuration: users can override defaults via config files in their vault (e.g. `config/relations.md`, `config/semantic-properties.md`). But the defaults work out of the box, and most users never need to touch them.
Tolaria is opinionated. Standard field names (`type:`, `status:`, `url:`, `Workspace:`, `Belongs to:`, `start_date:`, `end_date:`) have well-defined meanings and trigger specific UI behavior — without any setup. This is not convention *instead of* configuration: users can override defaults via config files in their vault (e.g. `config/relations.md`, `config/semantic-properties.md`). But the defaults work out of the box, and most users never need to touch them.
This principle directly serves AI-readability: the more structure comes from shared conventions rather than per-user custom configurations, the easier it is for an AI agent to understand and navigate the vault correctly — without needing bespoke instructions for every setup.
### Where to store state: vault vs. app settings
When deciding where to persist a piece of data, ask: **"Would the user want this to follow them across all their Laputa installations — other devices, future platforms (tablet, web)?"**
When deciding where to persist a piece of data, ask: **"Would the user want this to follow them across all their Tolaria installations — other devices, future platforms (tablet, web)?"**
| Follows the vault | Stays with the installation |
|-------------------|-----------------------------|
@@ -26,7 +26,7 @@ When deciding where to persist a piece of data, ask: **"Would the user want this
| Property display order | Window size / position |
| Any user-visible customization of how content is organized or displayed | Any machine-specific or credential-type setting |
**Rule:** If the information is about *how the content is structured or presented* and the user would expect it to be consistent wherever they open their vault, store it in the vault (frontmatter of the relevant note, using the `_field` underscore convention for system properties). If it's about *this specific installation of the app*, store it in `~/.config/com.laputa.app/settings.json` or localStorage.
**Rule:** If the information is about *how the content is structured or presented* and the user would expect it to be consistent wherever they open their vault, store it in the vault (frontmatter of the relevant note, using the `_field` underscore convention for system properties). If it's about *this specific installation of the app*, store it in `~/.config/com.tolaria.app/settings.json` or localStorage.
Examples:
- ✅ Vault: `_pinned_properties` in a Type note (every device should show the same pinned properties)
@@ -265,7 +265,7 @@ Token budget: 60% of 180k context limit (~108k tokens max). Active note gets pri
### Authentication
Claude CLI (agent mode) uses its own authentication — no API key configuration needed in Laputa.
Claude CLI (agent mode) uses its own authentication — no API key configuration needed in Tolaria.
## MCP Server
@@ -285,7 +285,7 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
| `link_notes` | `source_path, property, target_title` | Add a target to an array property in frontmatter |
| `list_notes` | `[type_filter], [sort]` | List all notes, optionally filtered by type |
| `vault_context` | — | Get vault summary: entity types + 20 recent notes + configFiles |
| `ui_open_note` | `path` | Open a note in the Laputa UI editor |
| `ui_open_note` | `path` | Open a note in the Tolaria UI editor |
| `ui_open_tab` | `path` | Open a note in a new UI tab |
| `ui_highlight` | `element, [path]` | Highlight a UI element (editor, tab, properties, notelist) |
| `ui_set_filter` | `type` | Set the sidebar filter to a specific type |
@@ -293,13 +293,13 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
### Transports
- **stdio** — standard MCP transport for Claude Code / Cursor (`node mcp-server/index.js`)
- **WebSocket** — live bridge for Laputa app integration:
- **WebSocket** — live bridge for Tolaria app integration:
- Port **9710**: Tool bridge — AI/Claude clients call vault tools here
- Port **9711**: UI bridge — Frontend listens for UI action broadcasts from MCP tools
### Auto-Registration
On app startup, Laputa automatically registers itself as an MCP server in:
On app startup, Tolaria automatically registers itself as an MCP server in:
- `~/.claude/mcp.json` (Claude Code)
- `~/.cursor/mcp.json` (Cursor)
@@ -349,7 +349,7 @@ flowchart LR
| Function | Purpose |
|----------|---------|
| `spawn_ws_bridge(vault_path)` | Spawns `ws-bridge.js` as child process with VAULT_PATH env |
| `register_mcp(vault_path)` | Writes Laputa entry to Claude Code and Cursor MCP configs |
| `register_mcp(vault_path)` | Writes Tolaria entry to Claude Code and Cursor MCP configs |
| `upsert_mcp_config(path, entry)` | Atomic config file update (create/merge, preserves others) |
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler.
@@ -400,7 +400,7 @@ The app uses a single light theme with no user-configurable theming (see [ADR-00
### Vault List
Persisted at `~/.config/com.laputa.app/vaults.json`:
Persisted at `~/.config/com.tolaria.app/vaults.json` (reads legacy `com.laputa.app` on upgrade):
```json
{
"vaults": [{ "label": "My Vault", "path": "/path/to/vault" }],
@@ -440,7 +440,7 @@ Implements GitHub Device Authorization Flow for cloning/creating GitHub-backed v
2. `github_device_flow_start()` returns a user code + verification URL
3. User authorizes at `github.com/login/device`
4. App polls `github_device_flow_poll()` until authorized
5. Token stored in `~/.config/com.laputa.app/settings.json`
5. Token stored in `~/.config/com.tolaria.app/settings.json`
**Vault operations:**
- `GitHubVaultModal`: Clone existing repo or create new private/public repo
@@ -729,11 +729,15 @@ Selection-dependent note actions are wired through both the command palette and
Shortcut routing is explicit:
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs and modifier rules
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and deterministic QA metadata
- `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs
- macOS browser-reserved chords such as `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
- `menu.rs` and `useMenuEvents` emit the same command IDs for native menu clicks and accelerators
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
- Deterministic QA uses both real key events in a Tauri-like environment and `trigger_menu_command` to prove the keyboard path and the native menu path without relying on flaky macOS key synthesis
- Deterministic QA uses two explicit proof paths from the shared manifest:
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`
- native menu-command proof through `trigger_menu_command`
- The browser harness is only a deterministic desktop command bridge; exact native accelerator delivery still requires real Tauri QA for commands flagged as manual-native-critical
## Auto-Release & In-App Updates
@@ -817,7 +821,7 @@ sequenceDiagram
### Updates
Laputa uses the Tauri updater plugin for automatic updates:
Tolaria uses the Tauri updater plugin for automatic updates:
- Builds from `main` branch are published as GitHub Releases
- `latest.json` is published to GitHub Pages for the updater plugin

View File

@@ -31,7 +31,7 @@ pnpm playwright:regression # Full Playwright regression suite
## Directory Structure
```
laputa-app/
tolaria/
├── src/ # React frontend
│ ├── main.tsx # Entry point (renders <App />)
│ ├── App.tsx # Root component — orchestrates layout + state
@@ -65,7 +65,6 @@ laputa-app/
│ │ ├── WelcomeScreen.tsx # Onboarding screen
│ │ ├── GitHubVaultModal.tsx # GitHub vault clone/create
│ │ ├── GitHubDeviceFlow.tsx # GitHub OAuth device flow
│ │ ├── TitleField.tsx # Editable note title above editor
│ │ ├── ConflictResolverModal.tsx # Git conflict resolution
│ │ ├── CommitDialog.tsx # Git commit modal
│ │ ├── CreateNoteDialog.tsx # New note modal
@@ -282,11 +281,16 @@ type SidebarSelection =
### Command Registry
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut.
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
For automated QA, prefer real key events in a Tauri-like environment for shortcut behavior and `window.__laputaTest.triggerMenuCommand()` for the native menu click path. Do not treat flaky synthesized macOS keystrokes as proof that a shortcut works.
For automated shortcut QA, use the explicit proof path from `appCommandCatalog.ts`:
- `window.__laputaTest.triggerShortcutCommand()` for deterministic renderer shortcut-event coverage
- `window.__laputaTest.triggerMenuCommand()` for deterministic native menu-command coverage
That browser harness is a deterministic desktop command bridge, not real native accelerator QA. For macOS browser-reserved chords, still perform native QA in the real Tauri app because the webview-init prevent-default layer is only active there. Do not treat flaky synthesized macOS keystrokes as proof that a shortcut works unless you also confirm the visible app behavior.
## Running Tests
@@ -341,7 +345,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
1. Register the command in `useAppCommands.ts` via the command registry
2. Add a corresponding menu bar item in `menu.rs` for discoverability
3. If it has a keyboard shortcut, register it in `appCommandCatalog.ts` with the canonical command ID and modifier rule, then wire the matching native menu item in `menu.rs` if it should also appear in the menu bar
3. If it has a keyboard shortcut, register it in `appCommandCatalog.ts` with the canonical command ID, modifier rule, and deterministic QA mode, then wire the matching native menu item in `menu.rs` if it should also appear in the menu bar
4. If its enabled state depends on runtime selection (active note, deleted preview, Git status, etc.), thread that flag through `useMenuEvents.ts` and `update_menu_state` so the native menu enables/disables correctly
### Modify styling

View File

@@ -1,4 +1,4 @@
# Laputa — Product Vision
# Tolaria — Product Vision
*Written by Brian based on conversations with Luca Rossi, FebMar 2026.*
*This is a living document — update it as the vision evolves.*
@@ -13,21 +13,21 @@ The best projects are built by people who have an unusually strong answer to "wh
**Luca Rossi** is a startup founder and former generalist CTO — someone who can build a product end-to-end across code, design, scope, and product. And for the last five years, full-time, he has run Refactoring: a technical newsletter with nearly 200,000 subscribers, for which he has written over 300 original articles. In word count, that's roughly two *Lord of the Rings* novels.
Personal knowledge management has been an obsession since university. But over the last five years it stopped being a hobby and became *table stakes* — the system that makes writing 300 articles possible. Laputa is an attempt to bottle that system.
Personal knowledge management has been an obsession since university. But over the last five years it stopped being a hobby and became *table stakes* — the system that makes writing 300 articles possible. Tolaria is an attempt to bottle that system.
The credibility is real: if you wonder whether this person knows how to organize knowledge for sustained output, the output speaks for itself. The method inside Laputa is not theorized — it's been battle-tested for years at scale.
The credibility is real: if you wonder whether this person knows how to organize knowledge for sustained output, the output speaks for itself. The method inside Tolaria is not theorized — it's been battle-tested for years at scale.
**The distribution is built in.** Refactoring reaches ~200,000 engineers, managers, and technical leaders — exactly the people most receptive to a tool like this. The audience already trusts the author on this topic, because they've been reading his writing about knowledge management and learning for years.
This is not a product looking for a market. It's a tool built by its first power user, for an audience that already knows and trusts him.
**Why Laputa, in the context of Refactoring.**
**Why Tolaria, in the context of Refactoring.**
Refactoring is a newsletter about how software is built, how teams work, and how digital products are developed — written from Luca's experience and conversations with other tech leaders. A natural question follows: what is the author's own current experience building software with AI?
Laputa answers that question directly and publicly. If it works — if it becomes a real product used by real people — it validates the author's capabilities and authority to write about these topics. Not as theory, but as demonstrated practice. Anyone can look at the GitHub repository, see 100 commits a day, and verify: this person actually does this.
Tolaria answers that question directly and publicly. If it works — if it becomes a real product used by real people — it validates the author's capabilities and authority to write about these topics. Not as theory, but as demonstrated practice. Anyone can look at the GitHub repository, see 100 commits a day, and verify: this person actually does this.
This is why Laputa is **free and open source**: success becomes a reputation and acquisition channel for Refactoring. The attention and trust earned through a well-executed open source project converts — through sponsorships, paid subscriptions, and brand authority — into the business that Refactoring runs on.
This is why Tolaria is **free and open source**: success becomes a reputation and acquisition channel for Refactoring. The attention and trust earned through a well-executed open source project converts — through sponsorships, paid subscriptions, and brand authority — into the business that Refactoring runs on.
The strategy is coherent: build the tool you describe, make the work visible, let the product speak for the author.
@@ -45,7 +45,7 @@ The problem has two distinct layers:
2. **Methodological**: even with the right tool, most people don't know *how* to organize knowledge so it becomes useful over time — what to capture, how to connect things, how to turn raw notes into a system that works with you instead of against you.
Laputa addresses both layers, together. That's what makes it different.
Tolaria addresses both layers, together. That's what makes it different.
---
@@ -53,11 +53,11 @@ Laputa addresses both layers, together. That's what makes it different.
Most PKM tools give you a blank canvas and leave the rest to you. They solve the first problem (somewhere to put things) but not the second (how to organize them). The result is that sophisticated users build complex custom systems, while everyone else gives up.
Laputa's position is different: **we ship the method alongside the tool.**
Tolaria's position is different: **we ship the method alongside the tool.**
The method is opinionated but not rigid. It tells you: here's how to think about your work, here's where different kinds of notes belong, here's how to connect them. If it fits your needs — great, start immediately. If your situation is different — customize it. The types, the relationships, the structure can all be changed. But you don't have to figure it out from scratch.
This combination — an opinionated method on top of a technically excellent foundation — is what makes Laputa genuinely useful to people who are stuck, not just people who already know what they're doing.
This combination — an opinionated method on top of a technically excellent foundation — is what makes Tolaria genuinely useful to people who are stuck, not just people who already know what they're doing.
---
@@ -65,7 +65,7 @@ This combination — an opinionated method on top of a technically excellent fou
### The knowledge ontology
Laputa organizes work around two axes:
Tolaria organizes work around two axes:
| | **One-time** | **Recurring** |
|---|---|---|
@@ -84,7 +84,7 @@ This ontology is not arbitrary. It maps cleanly to how both individuals and orga
### Knowledge has a purpose
A principle that underlies everything in Laputa: **notes exist to get things done.** Not to be stored for some abstract future use. Not to show how organized you are. To do something.
A principle that underlies everything in Tolaria: **notes exist to get things done.** Not to be stored for some abstract future use. Not to show how organized you are. To do something.
This is the difference between a knowledge system that works over years and one that collapses after a few weeks. Without a real purpose, the maintenance cost of taking notes is never justified, and people stop. With a purpose — writing regularly, building things, making decisions — the system pays for itself.
@@ -120,7 +120,7 @@ This is convention *over* configuration — not convention *instead of* it.
## The foundation: architecture that earns trust
The method is only as good as the system it runs on. Laputa's architecture is built around a single principle: **your knowledge is yours, permanently and unconditionally.**
The method is only as good as the system it runs on. Tolaria's architecture is built around a single principle: **your knowledge is yours, permanently and unconditionally.**
### Local files, version-controlled with Git
@@ -134,11 +134,11 @@ A vault of plain Markdown files, version-controlled with Git, is dramatically mo
An AI agent working on a local vault can read thousands of notes in seconds, understand their structure, write new ones, connect existing ones, and commit the changes back — all with full comprehension. Notion's AI can't do this. No SaaS-based AI can do this, because the architecture doesn't allow it.
More importantly: the more a vault follows Laputa's conventions, the *less configuration an AI needs* to navigate it. Shared conventions make knowledge legible to both humans and AI without bespoke instructions for every setup. The method and the AI-native architecture reinforce each other.
More importantly: the more a vault follows Tolaria's conventions, the *less configuration an AI needs* to navigate it. Shared conventions make knowledge legible to both humans and AI without bespoke instructions for every setup. The method and the AI-native architecture reinforce each other.
### Open and exit-friendly
The trust between Laputa and the user is earned daily, not enforced by format. If something better comes along, you take your Markdown files and leave. The exit door is always open.
The trust between Tolaria and the user is earned daily, not enforced by format. If something better comes along, you take your Markdown files and leave. The exit door is always open.
---
@@ -147,9 +147,9 @@ The trust between Laputa and the user is earned daily, not enforced by format. I
Obsidian is the obvious comparison. The difference is philosophy:
- **Obsidian** is a blank canvas. Infinitely configurable via plugins and community extensions. Powerful for users who want to build their own system — and who have the time and patience to do so.
- **Laputa** is opinionated. It ships with a complete point of view: a knowledge framework, semantic conventions, and defaults that work immediately. No plugin hunting. No configuration required to get started.
- **Tolaria** is opinionated. It ships with a complete point of view: a knowledge framework, semantic conventions, and defaults that work immediately. No plugin hunting. No configuration required to get started.
Obsidian also treats Git as an afterthought — its business model is built around proprietary sync. In Laputa, Git is a first-class citizen: the natural, obvious way to sync, collaborate, and maintain history.
Obsidian also treats Git as an afterthought — its business model is built around proprietary sync. In Tolaria, Git is a first-class citizen: the natural, obvious way to sync, collaborate, and maintain history.
---
@@ -157,7 +157,7 @@ Obsidian also treats Git as an afterthought — its business model is built arou
### Three stages of adoption
Laputa is designed to grow through three natural stages — not pivots, but extensions of the same foundation:
Tolaria is designed to grow through three natural stages — not pivots, but extensions of the same foundation:
**Stage 1: Personal PKM + AI context** *(current)*
A single person manages their knowledge, life, and work in a local vault. The primary collaborator is AI. The vault gives structure to one person's context, making it legible to an AI that can assist meaningfully across all areas of work and life. The method helps structure the knowledge; the AI helps use it.
@@ -172,7 +172,7 @@ The ontology scales to organizations. Companies have projects, responsibilities,
### The right early adopters
The first users who will get the most from Laputa are technically-minded individuals who:
The first users who will get the most from Tolaria are technically-minded individuals who:
- Are frustrated with Notion's performance, complexity, or lock-in
- Understand or are comfortable with Git
- Want a system that's AI-native by design, not by bolted-on features

View File

@@ -0,0 +1,30 @@
---
type: ADR
id: "0053"
title: "Webview-init prevention for browser-reserved shortcuts"
status: active
date: 2026-04-11
---
## Context
ADR 0052 made renderer-first shortcut handling the primary path for command execution, with native menu accelerators deduped afterward. That works for normal shortcuts, but native QA on macOS showed that `Cmd+Shift+L` still failed to reach the app even though the shared command path and the Note menu item both worked.
The gap is WKWebView itself: some browser-reserved chords are swallowed by the webview before the renderer-level shortcut listener can execute. That makes the shortcut untestable with the real native keypress even though the command bus is correct.
## Decision
**Laputa will keep renderer-first shortcut execution, but for macOS browser-reserved chords we will add a narrow Tauri webview-init prevention layer using `tauri-plugin-prevent-default` so the real keystroke reaches the shared command path.**
## Options considered
- **Option A** (chosen): Add a narrow `tauri-plugin-prevent-default` registration for only the known browser-reserved chords we actually use. This preserves ADR 0052, keeps the command bus unified, and fixes the real native keystroke path without broad shortcut capture.
- **Option B**: Keep relying on renderer capture listeners alone. Simpler, but it fails for chords that WKWebView consumes before renderer code sees them.
- **Option C**: Use a global shortcut plugin as the fallback path. This would catch the keystroke natively, but it reserves the chord outside Laputa and is too heavy for app-local shortcuts.
## Consequences
- Shortcut ownership stays unified: command IDs and execution still live in the shared renderer/native command bus.
- macOS-only browser-reserved chords now have one extra declaration point in `src-tauri/src/lib.rs`, and that list must stay intentionally small.
- Native QA remains mandatory for any shortcut added to that list, because browser dev and mocked Tauri tests do not exercise the webview-init layer.
- Re-evaluate this decision if Tauri/WKWebView exposes a better app-local native shortcut hook that does not require browser-reserved-key workarounds.

View File

@@ -0,0 +1,35 @@
---
type: ADR
id: "0054"
title: "Deterministic shortcut QA matrix"
status: active
date: 2026-04-11
---
## Context
ADR 0052 made renderer-first shortcut execution the primary runtime path, and ADR 0053 added a narrow macOS webview-init prevent-default layer for browser-reserved chords such as `Cmd+Shift+L`. Those decisions improved behavior, but the automated QA story was still muddy:
- browser smoke tests were describing a mocked desktop harness as if it were native Tauri QA
- some tests used `page.keyboard.press()` for commands whose real desktop accelerators are intercepted or reserved by the browser shell
- native menu command coverage existed, but the catalog did not declare which deterministic proof path each shortcut should use
That made it too easy to ship a shortcut with passing automation while overstating what the automation had actually proven.
## Decision
**Laputa will treat shortcut QA as an explicit part of the shared command manifest. Every shortcut-capable command must have a deterministic automated proof path, and the test harness must distinguish renderer shortcut-event proof from native menu-command proof instead of calling the browser harness “native Tauri QA”.**
## Options considered
- **Option A** (chosen): Add a deterministic shortcut QA matrix to the shared command catalog. Renderer shortcut handling can be exercised through synthetic `keydown` events generated from the manifest, while native menu commands are exercised through `trigger_menu_command`. Pros: deterministic, explicit, and honest about what is being proved. Cons: still requires real native QA for exact accelerator delivery on macOS.
- **Option B**: Keep using ad hoc Playwright key presses and browser-side menu shims. Lower change cost, but still allows false claims about native coverage and still depends on browser-reserved shortcuts behaving nicely.
- **Option C**: Block all shortcut work until full native Tauri automation exists. Strongest eventual guarantee, but it would leave the keyboard-first app without a usable deterministic QA strategy today.
## Consequences
- `appCommandCatalog.ts` now owns not just command IDs and modifier rules, but also the deterministic QA mode for each shortcut-capable command.
- Browser harness smoke tests must describe themselves as a desktop command bridge, not native app QA.
- Renderer shortcut behavior can be verified deterministically without depending on browser chrome or flaky AppleScript key synthesis.
- Native menu-command behavior can be verified deterministically through the Tauri command bridge.
- Exact desktop accelerator delivery still requires real Tauri QA for commands flagged as needing manual native verification, especially browser-reserved macOS chords.

View File

@@ -0,0 +1,42 @@
---
type: ADR
id: "0055"
title: "H1 is the only editor title surface"
status: active
date: 2026-04-11
supersedes: "0044"
---
## Context
ADR-0044 moved Laputa to H1-as-title, but the frontend still carried a legacy fallback: when a note had no H1, `TitleField` and the old title section could reappear above the editor. That left two competing title surfaces in the product and made it possible for deleting an H1 to resurrect UI that was supposed to be gone.
The result was both behavioral drift and stale tests: some code paths still treated the dedicated title row as a valid editing surface even though the product direction is now keyboard-first writing directly in the document body.
## Decision
**The editor body is now the only title surface. Laputa never renders a separate title section above the editor, regardless of whether a note currently has an H1.**
Display-title behavior stays:
1. First H1 in the body
2. Legacy frontmatter `title:`
3. Filename-derived fallback
But the UI no longer exposes a dedicated title field for cases 2 or 3. When a note has no H1, the editor simply shows normal body content or the empty-editor placeholder.
Filename operations remain explicit:
- untitled notes still auto-rename from H1 on save
- manual filename rename/sync remains in the breadcrumb
## Options considered
- **Option A** (chosen): remove the fallback title section entirely. This makes the editor honest, removes a stale code path, and keeps title editing aligned with the keyboard-first document model.
- **Option B**: keep the fallback title field for non-H1 notes. This preserves an alternate rename path, but it reintroduces the exact dual-surface ambiguity that ADR-0044 tried to escape.
- **Option C**: hide the title section with CSS only. Low churn, but it leaves dead render/state paths in place and makes regressions like “delete H1 and old title row returns” easy to reintroduce.
## Consequences
- Deleting an H1 no longer reveals any legacy title UI; the user stays in the editor body.
- `TitleField` and the title-section render path are removed from the frontend.
- Breadcrumb filename controls are now the only explicit file-identifier editing surface outside the editor body.
- Older tests that asserted title editing through `TitleField` are obsolete and should be replaced by H1-title or breadcrumb-filename coverage.

View File

@@ -99,7 +99,7 @@ proposed → active → superseded
| [0041](0041-filekind-all-files-in-vault-scanner.md) | fileKind field — scan all vault files, not just markdown | active |
| [0042](0042-trash-auto-purge-safety-model.md) | Trash auto-purge safety model | superseded → [0045](0045-permanent-delete-no-trash.md) |
| [0043](0043-reactive-vault-state-on-save.md) | Reactive vault state: editor changes propagate immediately to all UI | active |
| [0044](0044-h1-as-title-primary-source.md) | H1 as primary title source — filename as stable identifier | active |
| [0044](0044-h1-as-title-primary-source.md) | H1 as primary title source — filename as stable identifier | superseded → [0055](0055-h1-is-the-only-editor-title-surface.md) |
| [0045](0045-permanent-delete-no-trash.md) | Permanent delete with confirm modal — no Trash system | active |
| [0046](0046-starter-vault-cloned-from-github.md) | Starter vault cloned from GitHub at runtime — no bundled content | active |
| [0047](0047-regex-mode-for-view-filter-conditions.md) | Regex mode for view filter conditions | active |
@@ -108,3 +108,6 @@ proposed → active → superseded
| [0050](0050-deterministic-shortcut-command-routing.md) | Deterministic shortcut command routing | superseded → [0051](0051-shared-shortcut-manifest-for-testable-routing.md) |
| [0051](0051-shared-shortcut-manifest-for-testable-routing.md) | Shared shortcut manifest for testable routing | superseded → [0052](0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md) |
| [0052](0052-renderer-first-shortcut-execution-with-native-menu-dedupe.md) | Renderer-first shortcut execution with native-menu dedupe | active |
| [0053](0053-webview-init-prevention-for-browser-reserved-shortcuts.md) | Webview-init prevention for browser-reserved shortcuts | active |
| [0054](0054-deterministic-shortcut-qa-matrix.md) | Deterministic shortcut QA matrix | active |
| [0055](0055-h1-is-the-only-editor-title-surface.md) | H1 is the only editor title surface | active |

View File

@@ -7,12 +7,16 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Inter:wght@400;500;600;700&family=JetBrains+Mono&display=swap" rel="stylesheet" />
<title>laputa-scaffold</title>
<title>Tolaria</title>
</head>
<body>
<script>
// Apply saved theme before React mounts to prevent flash
var t = localStorage.getItem('laputa-theme');
var t = localStorage.getItem('tolaria-theme');
if (t === null) {
t = localStorage.getItem('laputa-theme');
if (t !== null) localStorage.setItem('tolaria-theme', t);
}
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
</script>
<div id="root"></div>

View File

@@ -1,15 +1,15 @@
#!/usr/bin/env node
/**
* Laputa MCP Server — lightweight vault tools for AI agents.
* Tolaria MCP Server — lightweight vault tools for AI agents.
*
* The agent has full shell access (bash, read, write, edit).
* These MCP tools provide Laputa-specific capabilities that
* These MCP tools provide Tolaria-specific capabilities that
* native tools cannot replace:
*
* - search_notes: full-text search across vault notes
* - get_vault_context: vault structure overview (types, note count, folders)
* - get_note: parsed frontmatter + content (convenience over raw cat)
* - open_note: signal Laputa UI to open a note as a tab
* - open_note: signal Tolaria UI to open a note as a tab
* - highlight_editor: visually highlight a UI element (editor, tab, etc.)
* - refresh_vault: trigger vault rescan so new/modified files appear
*/
@@ -87,7 +87,7 @@ const TOOLS = [
},
{
name: 'open_note',
description: 'Open a note in the Laputa UI as a new tab. Use after creating or editing a note so the user can see it.',
description: 'Open a note in the Tolaria UI as a new tab. Use after creating or editing a note so the user can see it.',
inputSchema: {
type: 'object',
properties: {
@@ -98,7 +98,7 @@ const TOOLS = [
},
{
name: 'highlight_editor',
description: 'Visually highlight a UI element in Laputa (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
description: 'Visually highlight a UI element in Tolaria (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
inputSchema: {
type: 'object',
properties: {
@@ -110,7 +110,7 @@ const TOOLS = [
},
{
name: 'refresh_vault',
description: 'Trigger a vault rescan so new or modified files appear immediately in the Laputa note list.',
description: 'Trigger a vault rescan so new or modified files appear immediately in the Tolaria note list.',
inputSchema: {
type: 'object',
properties: {
@@ -152,7 +152,7 @@ function handleOpenNote(args) {
// then signal the UI to open it in a tab.
broadcastUiAction('vault_changed', { path: args.path })
broadcastUiAction('open_tab', { path: args.path })
return { content: [{ type: 'text', text: `Opening ${args.path} in Laputa` }] }
return { content: [{ type: 'text', text: `Opening ${args.path} in Tolaria` }] }
}
function handleHighlightEditor(args) {
@@ -168,7 +168,7 @@ function handleRefreshVault(args) {
// --- Server setup ---
const server = new Server(
{ name: 'laputa-mcp-server', version: '0.3.0' },
{ name: 'tolaria-mcp-server', version: '0.3.0' },
{ capabilities: { tools: {} } },
)
@@ -195,7 +195,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
async function main() {
const transport = new StdioServerTransport()
await server.connect(transport)
console.error(`Laputa MCP server running (vault: ${VAULT_PATH})`)
console.error(`Tolaria MCP server running (vault: ${VAULT_PATH})`)
}
main().catch(console.error)

View File

@@ -1,11 +1,11 @@
{
"name": "laputa-mcp-server",
"name": "tolaria-mcp-server",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "laputa-mcp-server",
"name": "tolaria-mcp-server",
"version": "0.1.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",

View File

@@ -1,7 +1,7 @@
{
"name": "laputa-mcp-server",
"name": "tolaria-mcp-server",
"version": "0.1.0",
"description": "MCP server for Laputa vault operations",
"description": "MCP server for Tolaria vault operations",
"type": "module",
"main": "index.js",
"scripts": {

View File

@@ -1,8 +1,8 @@
#!/usr/bin/env node
/**
* WebSocket bridge for Laputa MCP tools.
* WebSocket bridge for Tolaria MCP tools.
*
* Exposes vault operations over WebSocket so the Laputa app frontend
* Exposes vault operations over WebSocket so the Tolaria app frontend
* can invoke MCP tools in real-time without going through stdio.
*
* Port 9710: Tool bridge — Claude/AI clients call vault tools here.
@@ -74,7 +74,7 @@ async function handleMessage(data) {
/**
* Attempt to start the UI bridge WebSocket server.
* Returns a Promise that resolves to the WebSocketServer or null if the port
* is unavailable (e.g. another Laputa instance owns it).
* is unavailable (e.g. another Tolaria instance owns it).
*/
export function startUiBridge(port = WS_UI_PORT) {
return new Promise((resolve) => {

View File

@@ -1,5 +1,5 @@
{
"name": "laputa-app",
"name": "tolaria",
"private": true,
"version": "0.1.0",
"type": "module",

111
src-tauri/Cargo.lock generated
View File

@@ -1012,6 +1012,12 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "embed-resource"
version = "3.0.6"
@@ -2150,6 +2156,15 @@ dependencies = [
"once_cell",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.17"
@@ -2256,36 +2271,6 @@ dependencies = [
"selectors",
]
[[package]]
name = "laputa"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
"dirs 5.0.1",
"futures-util",
"gray_matter",
"log",
"mockito",
"regex",
"reqwest 0.12.28",
"sentry",
"serde",
"serde_json",
"serde_yaml",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-updater",
"tempfile",
"tokio",
"uuid",
"walkdir",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -4504,6 +4489,27 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.115",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -4887,6 +4893,20 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-prevent-default"
version = "4.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6260061932cad80647a823d0c5a3633f4eec62160a8f57bad0ab82b537477ef2"
dependencies = [
"bitflags 2.11.0",
"itertools",
"serde",
"strum",
"tauri",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-process"
version = "2.3.1"
@@ -5213,6 +5233,37 @@ dependencies = [
"tokio",
]
[[package]]
name = "tolaria"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
"dirs 5.0.1",
"futures-util",
"gray_matter",
"log",
"mockito",
"regex",
"reqwest 0.12.28",
"sentry",
"serde",
"serde_json",
"serde_yaml",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-prevent-default",
"tauri-plugin-process",
"tauri-plugin-updater",
"tempfile",
"tokio",
"uuid",
"walkdir",
]
[[package]]
name = "toml"
version = "0.5.11"

View File

@@ -1,5 +1,5 @@
[package]
name = "laputa"
name = "tolaria"
version = "0.1.0"
description = "Personal knowledge and life management app"
authors = ["you"]
@@ -9,7 +9,7 @@ edition = "2021"
rust-version = "1.77.2"
[lib]
name = "laputa_lib"
name = "tolaria_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
@@ -35,6 +35,7 @@ tauri-plugin-dialog = "2"
tauri-plugin-updater = "2.10.0"
tauri-plugin-process = "2.3.1"
tauri-plugin-opener = "2"
tauri-plugin-prevent-default = "4.0.4"
sentry = "0.37"
uuid = { version = "1", features = ["v4"] }

View File

@@ -28077,7 +28077,7 @@ var TOOLS = [
},
{
name: "open_note",
description: "Open a note in the Laputa UI as a new tab. Use after creating or editing a note so the user can see it.",
description: "Open a note in the Tolaria UI as a new tab. Use after creating or editing a note so the user can see it.",
inputSchema: {
type: "object",
properties: {
@@ -28088,7 +28088,7 @@ var TOOLS = [
},
{
name: "highlight_editor",
description: "Visually highlight a UI element in Laputa (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.",
description: "Visually highlight a UI element in Tolaria (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.",
inputSchema: {
type: "object",
properties: {
@@ -28100,7 +28100,7 @@ var TOOLS = [
},
{
name: "refresh_vault",
description: "Trigger a vault rescan so new or modified files appear immediately in the Laputa note list.",
description: "Trigger a vault rescan so new or modified files appear immediately in the Tolaria note list.",
inputSchema: {
type: "object",
properties: {
@@ -28134,7 +28134,7 @@ async function handleGetNote(args) {
function handleOpenNote(args) {
broadcastUiAction("vault_changed", { path: args.path });
broadcastUiAction("open_tab", { path: args.path });
return { content: [{ type: "text", text: `Opening ${args.path} in Laputa` }] };
return { content: [{ type: "text", text: `Opening ${args.path} in Tolaria` }] };
}
function handleHighlightEditor(args) {
broadcastUiAction("highlight", { element: args.element, path: args.path });
@@ -28145,7 +28145,7 @@ function handleRefreshVault(args) {
return { content: [{ type: "text", text: "Vault refresh triggered" }] };
}
var server = new Server(
{ name: "laputa-mcp-server", version: "0.3.0" },
{ name: "tolaria-mcp-server", version: "0.3.0" },
{ capabilities: { tools: {} } }
);
server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -28169,7 +28169,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`Laputa MCP server running (vault: ${VAULT_PATH})`);
console.error(`Tolaria MCP server running (vault: ${VAULT_PATH})`);
}
main().catch(console.error);
/*! Bundled license information:

View File

@@ -500,8 +500,8 @@
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = club.refactoring.laputa;
PRODUCT_NAME = "laputa";
PRODUCT_BUNDLE_IDENTIFIER = club.refactoring.tolaria;
PRODUCT_NAME = Tolaria;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALID_ARCHS = arm64;
@@ -531,8 +531,8 @@
);
"LIBRARY_SEARCH_PATHS[arch=arm64]" = "$(inherited) $(PROJECT_DIR)/Externals/arm64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
"LIBRARY_SEARCH_PATHS[arch=x86_64]" = "$(inherited) $(PROJECT_DIR)/Externals/x86_64/$(CONFIGURATION) $(SDKROOT)/usr/lib/swift $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME) $(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)";
PRODUCT_BUNDLE_IDENTIFIER = club.refactoring.laputa;
PRODUCT_NAME = "laputa";
PRODUCT_BUNDLE_IDENTIFIER = club.refactoring.tolaria;
PRODUCT_NAME = Tolaria;
SDKROOT = iphoneos;
TARGETED_DEVICE_FAMILY = "1,2";
VALID_ARCHS = arm64;

View File

@@ -1,6 +1,6 @@
name: laputa
name: Tolaria
options:
bundleIdPrefix: club.refactoring.laputa
bundleIdPrefix: club.refactoring.tolaria
deploymentTarget:
iOS: 14.0
fileGroups: [../../src]
@@ -10,8 +10,8 @@ configs:
settingGroups:
app:
base:
PRODUCT_NAME: laputa
PRODUCT_BUNDLE_IDENTIFIER: club.refactoring.laputa
PRODUCT_NAME: Tolaria
PRODUCT_BUNDLE_IDENTIFIER: club.refactoring.tolaria
targetTemplates:
app:
type: application
@@ -85,4 +85,4 @@ targets:
basedOnDependencyAnalysis: false
outputFiles:
- $(SRCROOT)/Externals/x86_64/${CONFIGURATION}/libapp.a
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a
- $(SRCROOT)/Externals/arm64/${CONFIGURATION}/libapp.a

View File

@@ -208,7 +208,7 @@ fn build_mcp_config(vault_path: &str) -> Result<String, String> {
let index_js = server_dir.join("index.js");
let config = serde_json::json!({
"mcpServers": {
"laputa": {
"tolaria": {
"command": "node",
"args": [index_js.to_string_lossy()],
"env": { "VAULT_PATH": vault_path }
@@ -508,9 +508,9 @@ mod tests {
fn build_mcp_config_is_valid_json() {
if let Ok(config_str) = build_mcp_config("/tmp/test-vault") {
let parsed: serde_json::Value = serde_json::from_str(&config_str).unwrap();
assert!(parsed["mcpServers"]["laputa"]["command"].is_string());
assert!(parsed["mcpServers"]["tolaria"]["command"].is_string());
assert_eq!(
parsed["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
parsed["mcpServers"]["tolaria"]["env"]["VAULT_PATH"],
"/tmp/test-vault"
);
}

View File

@@ -35,7 +35,7 @@ pub struct GitCommit {
pub date: i64,
}
const DEFAULT_GITIGNORE: &str = "# Laputa app files (machine-specific, never commit)\n\
const DEFAULT_GITIGNORE: &str = "# Tolaria app files (machine-specific, never commit)\n\
.laputa/settings.json\n\
\n\
# macOS\n\
@@ -101,7 +101,10 @@ fn run_git(dir: &Path, args: &[&str]) -> Result<(), String> {
/// Set local user.name and user.email if not already configured.
fn ensure_author_config(dir: &Path) -> Result<(), String> {
for (key, fallback) in [("user.name", "Laputa"), ("user.email", "vault@laputa.app")] {
for (key, fallback) in [
("user.name", "Tolaria"),
("user.email", "vault@tolaria.app"),
] {
let check = Command::new("git")
.args(["config", key])
.current_dir(dir)

View File

@@ -22,7 +22,7 @@ async fn github_list_repos_with_base(
.get(&url)
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Laputa-App")
.header("User-Agent", "Tolaria-App")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
@@ -74,14 +74,14 @@ async fn github_create_repo_with_base(
"name": name,
"private": private,
"auto_init": true,
"description": "Laputa vault"
"description": "Tolaria vault"
});
let response = client
.post(format!("{}/user/repos", api_base))
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Laputa-App")
.header("User-Agent", "Tolaria-App")
.header("X-GitHub-Api-Version", "2022-11-28")
.json(&body)
.send()
@@ -114,7 +114,7 @@ async fn github_get_user_with_base(token: &str, api_base: &str) -> Result<GitHub
.get(format!("{}/user", api_base))
.header("Authorization", format!("Bearer {}", token))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "Laputa-App")
.header("User-Agent", "Tolaria-App")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
@@ -256,7 +256,7 @@ mod tests {
async fn test_github_create_repo_success() {
let repo = mock_create_repo(
201,
r#"{"name":"new-repo","full_name":"user/new-repo","description":"Laputa vault","private":true,"clone_url":"https://github.com/user/new-repo.git","html_url":"https://github.com/user/new-repo","updated_at":"2026-02-01T00:00:00Z"}"#,
r#"{"name":"new-repo","full_name":"user/new-repo","description":"Tolaria vault","private":true,"clone_url":"https://github.com/user/new-repo.git","html_url":"https://github.com/user/new-repo","updated_at":"2026-02-01T00:00:00Z"}"#,
)
.await
.unwrap();

View File

@@ -12,7 +12,7 @@ async fn github_device_flow_start_with_base(base_url: &str) -> Result<DeviceFlow
let response = client
.post(format!("{}/login/device/code", base_url))
.header("Accept", "application/json")
.header("User-Agent", "Laputa-App")
.header("User-Agent", "Tolaria-App")
.form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "repo")])
.send()
.await
@@ -50,7 +50,7 @@ async fn github_device_flow_poll_with_base(
let response = client
.post(format!("{}/login/oauth/access_token", base_url))
.header("Accept", "application/json")
.header("User-Agent", "Laputa-App")
.header("User-Agent", "Tolaria-App")
.form(&[
("client_id", GITHUB_CLIENT_ID),
("device_code", device_code),

View File

@@ -128,11 +128,11 @@ fn configure_remote_auth(local_path: &str, original_url: &str, token: &str) -> R
// Also configure git user if not set
let _ = Command::new("git")
.args(["config", "user.email", "laputa@app.local"])
.args(["config", "user.email", "tolaria@app.local"])
.current_dir(vault)
.output();
let _ = Command::new("git")
.args(["config", "user.name", "Laputa App"])
.args(["config", "user.name", "Tolaria App"])
.current_dir(vault)
.output();
@@ -266,12 +266,12 @@ mod tests {
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.email", "laputa@app.local"])
.args(["config", "user.email", "tolaria@app.local"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.name", "Laputa App"])
.args(["config", "user.name", "Tolaria App"])
.current_dir(path)
.output()
.unwrap();

View File

@@ -48,7 +48,7 @@ fn run_startup_tasks() {
// Seed AGENTS.md and config.md at vault root if missing
vault::seed_config_files(vp_str);
// Register Laputa MCP server in Claude Code and Cursor configs
// Register Tolaria MCP server in Claude Code and Cursor configs
match mcp::register_mcp(vp_str) {
Ok(status) => log::info!("MCP registration: {status}"),
Err(e) => log::warn!("MCP registration failed: {e}"),
@@ -86,6 +86,7 @@ fn setup_common_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error::
#[cfg(desktop)]
fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
setup_macos_webview_shortcut_prevention(app)?;
app.handle()
.plugin(tauri_plugin_updater::Builder::new().build())?;
app.handle().plugin(tauri_plugin_process::init())?;
@@ -94,6 +95,35 @@ fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error:
Ok(())
}
const MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS: &[&str] = &["L"];
#[cfg(all(desktop, target_os = "macos"))]
fn setup_macos_webview_shortcut_prevention(
app: &mut tauri::App,
) -> Result<(), Box<dyn std::error::Error>> {
use tauri_plugin_prevent_default::ModifierKey::{MetaKey, ShiftKey};
use tauri_plugin_prevent_default::{Flags, KeyboardShortcut};
let mut builder = tauri_plugin_prevent_default::Builder::new().with_flags(Flags::empty());
// WKWebView can swallow some browser-reserved chords before our shared
// renderer shortcut handler sees them. Keep this list narrow and verify
// every addition with native QA.
for key in MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS {
builder = builder.shortcut(KeyboardShortcut::with_modifiers(key, &[MetaKey, ShiftKey]));
}
app.handle().plugin(builder.build())?;
Ok(())
}
#[cfg(not(all(desktop, target_os = "macos")))]
fn setup_macos_webview_shortcut_prevention(
_app: &mut tauri::App,
) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
fn setup_app(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
setup_common_plugins(app)?;
@@ -214,3 +244,13 @@ pub fn run() {
handle_run_event(app_handle, &event);
});
}
#[cfg(test)]
mod tests {
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
#[test]
fn macos_webview_shortcut_prevention_includes_ai_panel_shortcut() {
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS, ["L"]);
}
}

View File

@@ -2,5 +2,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
laputa_lib::run();
tolaria_lib::run();
}

View File

@@ -2,6 +2,9 @@ use serde::Serialize;
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
const MCP_SERVER_NAME: &str = "tolaria";
const LEGACY_MCP_SERVER_NAME: &str = "laputa";
/// Status of the MCP server installation.
#[derive(Debug, Serialize, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
@@ -20,11 +23,45 @@ pub(crate) fn find_node() -> Result<PathBuf, String> {
.arg("node")
.output()
.map_err(|e| format!("Failed to run `which node`: {e}"))?;
if !output.status.success() {
return Err("node not found in PATH".into());
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Ok(PathBuf::from(path));
}
}
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(PathBuf::from(path))
if let Some(path) = fallback_node_path() {
return Ok(path);
}
Err("node not found in PATH or common install locations".into())
}
fn fallback_node_path() -> Option<PathBuf> {
let mut candidates = vec![
PathBuf::from("/opt/homebrew/bin/node"),
PathBuf::from("/usr/local/bin/node"),
];
if let Some(home) = dirs::home_dir() {
candidates.push(home.join(".volta").join("bin").join("node"));
let nvm_dir = home.join(".nvm").join("versions").join("node");
if let Ok(entries) = std::fs::read_dir(nvm_dir) {
let mut versions = entries
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
.collect::<Vec<_>>();
versions.sort();
versions.reverse();
candidates.extend(
versions
.into_iter()
.map(|version| version.join("bin").join("node")),
);
}
}
candidates.into_iter().find(|path| path.is_file())
}
/// Resolve the path to `mcp-server/`.
@@ -102,7 +139,7 @@ fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf])
status.to_string()
}
/// Register Laputa as an MCP server in Claude Code and Cursor config files.
/// Register Tolaria as an MCP server in Claude Code and Cursor config files.
pub fn register_mcp(vault_path: &str) -> Result<String, String> {
let server_dir = mcp_server_dir()?;
let index_js = server_dir.join("index.js").to_string_lossy().into_owned();
@@ -120,7 +157,7 @@ pub fn register_mcp(vault_path: &str) -> Result<String, String> {
Ok(register_mcp_to_configs(&entry, &configs))
}
/// Insert or update the "laputa" entry in an MCP config file.
/// Insert or update the Tolaria entry in an MCP config file.
fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result<bool, String> {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
@@ -142,12 +179,14 @@ fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result<bo
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}));
let was_update = servers.get("laputa").is_some();
servers
let servers = servers
.as_object_mut()
.ok_or("mcpServers is not a JSON object")?
.insert("laputa".to_string(), entry.clone());
.ok_or("mcpServers is not a JSON object")?;
let was_update =
servers.get(MCP_SERVER_NAME).is_some() || servers.get(LEGACY_MCP_SERVER_NAME).is_some();
servers.remove(LEGACY_MCP_SERVER_NAME);
servers.insert(MCP_SERVER_NAME.to_string(), entry.clone());
let json = serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize config: {e}"))?;
@@ -159,7 +198,7 @@ fn upsert_mcp_config(config_path: &Path, entry: &serde_json::Value) -> Result<bo
/// Check whether the MCP server is properly installed and registered.
///
/// Returns `Installed` when the laputa entry exists in `~/.claude/mcp.json`
/// Returns `Installed` when the Tolaria entry exists in `~/.claude/mcp.json`
/// and the referenced index.js file is present. Returns `NoClaudeCli` when
/// the Claude CLI binary cannot be found. Otherwise returns `NotInstalled`.
pub fn check_mcp_status() -> McpStatus {
@@ -187,10 +226,15 @@ pub fn check_mcp_status() -> McpStatus {
Err(_) => return McpStatus::NotInstalled,
};
let entry = &config["mcpServers"]["laputa"];
if entry.is_null() {
let Some(servers) = config.get("mcpServers").and_then(|value| value.as_object()) else {
return McpStatus::NotInstalled;
}
};
let Some(entry) = servers
.get(MCP_SERVER_NAME)
.or_else(|| servers.get(LEGACY_MCP_SERVER_NAME))
else {
return McpStatus::NotInstalled;
};
// Verify the referenced index.js actually exists on disk
if let Some(index_js) = entry["args"]
@@ -231,9 +275,12 @@ mod tests {
let raw = std::fs::read_to_string(&config_path).unwrap();
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(config["mcpServers"]["laputa"]["args"][0], "/test/index.js");
assert_eq!(
config["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/test/index.js"
);
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
"/test/vault"
);
}
@@ -253,11 +300,40 @@ mod tests {
let raw = std::fs::read_to_string(&config_path).unwrap();
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(
config["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
config["mcpServers"][MCP_SERVER_NAME]["env"]["VAULT_PATH"],
"/vault/v2"
);
}
#[test]
fn upsert_migrates_legacy_server_name() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let existing = serde_json::json!({
"mcpServers": {
"laputa": {
"command": "node",
"args": ["/old/index.js"],
"env": { "VAULT_PATH": "/old" }
}
}
});
std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap();
let entry = build_mcp_entry("/test/index.js", "/vault");
let was_update = upsert_mcp_config(&config_path, &entry).unwrap();
assert!(was_update);
let raw = std::fs::read_to_string(&config_path).unwrap();
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert!(config["mcpServers"][LEGACY_MCP_SERVER_NAME].is_null());
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/test/index.js"
);
}
#[test]
fn upsert_preserves_other_servers() {
let tmp = tempfile::tempdir().unwrap();
@@ -276,7 +352,7 @@ mod tests {
let raw = std::fs::read_to_string(&config_path).unwrap();
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert!(config["mcpServers"]["other-server"].is_object());
assert!(config["mcpServers"]["laputa"].is_object());
assert!(config["mcpServers"][MCP_SERVER_NAME].is_object());
}
#[test]
@@ -358,7 +434,10 @@ mod tests {
let raw = std::fs::read_to_string(&claude_cfg).unwrap();
let config: serde_json::Value = serde_json::from_str(&raw).unwrap();
assert_eq!(config["mcpServers"]["laputa"]["args"][0], "/test/index.js");
assert_eq!(
config["mcpServers"][MCP_SERVER_NAME]["args"][0],
"/test/index.js"
);
}
#[test]
fn upsert_returns_error_for_invalid_json() {

View File

@@ -128,7 +128,7 @@ fn build_app_menu(app: &App) -> MenuResult {
.id(APP_CHECK_FOR_UPDATES)
.build(app)?;
Ok(SubmenuBuilder::new(app, "Laputa")
Ok(SubmenuBuilder::new(app, "Tolaria")
.about(None)
.separator()
.item(&check_updates_item)
@@ -212,9 +212,10 @@ fn build_view_menu(app: &App) -> MenuResult {
.id(VIEW_ALL)
.accelerator("CmdOrCtrl+3")
.build(app)?;
// Keep Cmd+Shift+I on the renderer path. The menu item stays available,
// but the native accelerator has proven unreliable for this command.
let toggle_properties = MenuItemBuilder::new("Toggle Properties Panel")
.id(VIEW_TOGGLE_PROPERTIES)
.accelerator("CmdOrCtrl+Shift+I")
.build(app)?;
let command_palette = MenuItemBuilder::new("Command Palette")
.id(VIEW_COMMAND_PALETTE)

View File

@@ -2,6 +2,9 @@ use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
const APP_CONFIG_DIR: &str = "com.tolaria.app";
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Settings {
pub github_token: Option<String>,
@@ -14,10 +17,32 @@ pub struct Settings {
pub release_channel: Option<String>,
}
fn app_config_dir() -> Result<PathBuf, String> {
dirs::config_dir().ok_or_else(|| "Could not determine config directory".to_string())
}
fn preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
Ok(app_config_dir()?.join(APP_CONFIG_DIR).join(file_name))
}
fn resolve_existing_or_preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
let preferred = preferred_app_config_path(file_name)?;
if preferred.exists() {
return Ok(preferred);
}
let legacy = app_config_dir()?
.join(LEGACY_APP_CONFIG_DIR)
.join(file_name);
if legacy.exists() {
return Ok(legacy);
}
Ok(preferred)
}
fn settings_path() -> Result<PathBuf, String> {
dirs::config_dir()
.map(|d| d.join("com.laputa.app").join("settings.json"))
.ok_or_else(|| "Could not determine config directory".to_string())
resolve_existing_or_preferred_app_config_path("settings.json")
}
fn get_settings_at(path: &PathBuf) -> Result<Settings, String> {
@@ -69,13 +94,11 @@ pub fn get_settings() -> Result<Settings, String> {
}
pub fn save_settings(settings: Settings) -> Result<(), String> {
save_settings_at(&settings_path()?, settings)
save_settings_at(&preferred_app_config_path("settings.json")?, settings)
}
fn last_vault_file() -> Result<PathBuf, String> {
dirs::config_dir()
.map(|d| d.join("com.laputa.app").join("last-vault.txt"))
.ok_or_else(|| "Could not determine config directory".to_string())
resolve_existing_or_preferred_app_config_path("last-vault.txt")
}
fn get_last_vault_at(path: &PathBuf) -> Option<String> {
@@ -99,7 +122,7 @@ pub fn get_last_vault() -> Option<String> {
}
pub fn set_last_vault(vault_path: &str) -> Result<(), String> {
set_last_vault_at(&last_vault_file()?, vault_path)
set_last_vault_at(&preferred_app_config_path("last-vault.txt")?, vault_path)
}
#[cfg(test)]
@@ -251,7 +274,20 @@ mod tests {
fn test_settings_path_returns_ok() {
let result = settings_path();
assert!(result.is_ok());
assert!(result.unwrap().to_str().unwrap().contains("com.laputa.app"));
let path = result.unwrap();
let path = path.to_str().unwrap();
assert!(path.contains("com.tolaria.app") || path.contains("com.laputa.app"));
}
#[test]
fn test_preferred_settings_path_uses_tolaria_namespace() {
let result = preferred_app_config_path("settings.json");
assert!(result.is_ok());
assert!(result
.unwrap()
.to_str()
.unwrap()
.contains("com.tolaria.app"));
}
#[test]

View File

@@ -12,7 +12,8 @@ use super::{is_md_file, parse_md_file, parse_non_md_file, scan_vault, VaultEntry
/// Bump this when VaultEntry fields change to force a full rescan.
/// v12: fix gray_matter YAML sanitization (unquoted colons / hash comments in list items)
const CACHE_VERSION: u32 = 12;
/// v13: preserve plain square brackets in parsed markdown H1 titles
const CACHE_VERSION: u32 = 13;
#[derive(Debug, Serialize, Deserialize)]
struct VaultCache {

View File

@@ -159,7 +159,7 @@ mod tests {
assert!(vault.join("AGENTS.md").exists());
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(content.contains("Laputa Vault"));
assert!(content.contains("Tolaria Vault"));
// Must NOT create config/ directory
assert!(!vault.join("config").exists());
}
@@ -205,7 +205,7 @@ mod tests {
seed_config_files(vault.to_str().unwrap());
let content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(content.contains("Laputa Vault"));
assert!(content.contains("Tolaria Vault"));
}
#[test]
@@ -272,7 +272,7 @@ mod tests {
assert!(vault.join("AGENTS.md").exists());
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(root.contains("Laputa Vault"));
assert!(root.contains("Tolaria Vault"));
}
#[test]
@@ -305,7 +305,7 @@ mod tests {
assert!(!vault.join("config").exists());
let agents = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
assert!(agents.contains("Laputa Vault"));
assert!(agents.contains("Tolaria Vault"));
}
#[test]

View File

@@ -19,9 +19,9 @@ pub fn vault_exists(path: &str) -> bool {
/// Default AGENTS.md content — vault instructions for AI agents.
/// Describes Laputa vault mechanics only; no vault-specific structure.
/// The vault scanner will pick this up as a regular entry.
pub(super) const AGENTS_MD: &str = r##"# AGENTS.md — Laputa Vault
pub(super) const AGENTS_MD: &str = r##"# AGENTS.md — Tolaria Vault
This is a [Laputa](https://github.com/refactoringhq/laputa-app) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
This is a [Tolaria](https://github.com/refactoringhq/tolaria) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
## Note structure
@@ -120,7 +120,8 @@ fn create_getting_started_vault_from_repo(
}
fn getting_started_repo_url() -> String {
std::env::var("LAPUTA_GETTING_STARTED_REPO_URL")
std::env::var("TOLARIA_GETTING_STARTED_REPO_URL")
.or_else(|_| std::env::var("LAPUTA_GETTING_STARTED_REPO_URL"))
.unwrap_or_else(|_| GETTING_STARTED_REPO_URL.to_string())
}
@@ -143,7 +144,7 @@ mod tests {
fs::create_dir_all(path.join("views")).unwrap();
fs::write(
path.join("welcome.md"),
"# Welcome to Laputa\n\nThis is the starter vault.\n",
"# Welcome to Tolaria\n\nThis is the starter vault.\n",
)
.unwrap();
fs::write(
@@ -158,12 +159,12 @@ mod tests {
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.email", "laputa@app.local"])
.args(["config", "user.email", "tolaria@app.local"])
.current_dir(path)
.output()
.unwrap();
StdCommand::new("git")
.args(["config", "user.name", "Laputa App"])
.args(["config", "user.name", "Tolaria App"])
.current_dir(path)
.output()
.unwrap();

View File

@@ -249,7 +249,9 @@ fn extract_wikilink_display(inner: &str) -> &str {
inner.find('|').map_or(inner, |idx| &inner[idx + 1..])
}
/// Process a markdown link `[text](url)`, extracting only the link text.
/// Process bracketed text.
/// Real markdown links `[text](url)` are unwrapped to `text`.
/// Plain bracketed text `[text]` is preserved verbatim.
fn process_markdown_link(
chars: &mut std::iter::Peekable<impl Iterator<Item = char>>,
result: &mut String,
@@ -258,8 +260,13 @@ fn process_markdown_link(
if chars.peek() == Some(&'(') {
chars.next();
skip_until(chars, ')');
result.push_str(&inner);
return;
}
result.push('[');
result.push_str(&inner);
result.push(']');
}
/// Collect chars inside a wikilink until `]]`, consuming both closing brackets.
@@ -363,6 +370,15 @@ mod tests {
assert_eq!(extract_h1_title(content), Some("Spaced Title".to_string()));
}
#[test]
fn test_extract_h1_title_preserves_plain_square_brackets() {
let content = "# [26Q2] Tolaria MVP\n\nBody.";
assert_eq!(
extract_h1_title(content),
Some("[26Q2] Tolaria MVP".to_string())
);
}
#[test]
fn test_extract_h1_title_none_when_no_h1() {
assert_eq!(extract_h1_title("Just body text."), None);
@@ -726,7 +742,7 @@ mod tests {
#[test]
fn test_strip_markdown_chars_bracket_without_url() {
assert_eq!(strip_markdown_chars("[just brackets]"), "just brackets");
assert_eq!(strip_markdown_chars("[just brackets]"), "[just brackets]");
}
#[test]

View File

@@ -2,6 +2,9 @@ use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
const APP_CONFIG_DIR: &str = "com.tolaria.app";
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct VaultEntry {
pub label: String,
@@ -16,10 +19,32 @@ pub struct VaultList {
pub hidden_defaults: Vec<String>,
}
fn app_config_dir() -> Result<PathBuf, String> {
dirs::config_dir().ok_or_else(|| "Could not determine config directory".to_string())
}
fn preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
Ok(app_config_dir()?.join(APP_CONFIG_DIR).join(file_name))
}
fn resolve_existing_or_preferred_app_config_path(file_name: &str) -> Result<PathBuf, String> {
let preferred = preferred_app_config_path(file_name)?;
if preferred.exists() {
return Ok(preferred);
}
let legacy = app_config_dir()?
.join(LEGACY_APP_CONFIG_DIR)
.join(file_name);
if legacy.exists() {
return Ok(legacy);
}
Ok(preferred)
}
fn vault_list_path() -> Result<PathBuf, String> {
dirs::config_dir()
.map(|d| d.join("com.laputa.app").join("vaults.json"))
.ok_or_else(|| "Could not determine config directory".to_string())
resolve_existing_or_preferred_app_config_path("vaults.json")
}
fn load_at(path: &PathBuf) -> Result<VaultList, String> {
@@ -46,7 +71,7 @@ pub fn load_vault_list() -> Result<VaultList, String> {
}
pub fn save_vault_list(list: &VaultList) -> Result<(), String> {
save_at(&vault_list_path()?, list)
save_at(&preferred_app_config_path("vaults.json")?, list)
}
#[cfg(test)]
@@ -131,7 +156,20 @@ mod tests {
fn vault_list_path_returns_ok() {
let result = vault_list_path();
assert!(result.is_ok());
assert!(result.unwrap().to_str().unwrap().contains("com.laputa.app"));
let path = result.unwrap();
let path = path.to_str().unwrap();
assert!(path.contains("com.tolaria.app") || path.contains("com.laputa.app"));
}
#[test]
fn preferred_vault_list_path_uses_tolaria_namespace() {
let result = preferred_app_config_path("vaults.json");
assert!(result.is_ok());
assert!(result
.unwrap()
.to_str()
.unwrap()
.contains("com.tolaria.app"));
}
#[test]

View File

@@ -1,8 +1,8 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "laputa",
"productName": "Tolaria",
"version": "0.1.0",
"identifier": "club.refactoring.laputa",
"identifier": "club.refactoring.tolaria",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5202",
@@ -13,7 +13,7 @@
"withGlobalTauri": true,
"windows": [
{
"title": "Laputa",
"title": "Tolaria",
"width": 1400,
"height": 900,
"minWidth": 1200,
@@ -54,9 +54,9 @@
"plugins": {
"updater": {
"endpoints": [
"https://refactoringhq.github.io/laputa-app/latest.json"
"https://refactoringhq.github.io/tolaria/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEE4NkQ5MDI3REVCRkFGNUMKUldSY3I3L2VKNUJ0cU5JRlRZZlp3NGhnU3ZwbkVKeGVvREpmb2sxRVJndHFpVFZPNlArbEE5R1IK"
}
}
}
}

View File

@@ -128,7 +128,10 @@ describe('App', () => {
vi.clearAllMocks()
// Reset view mode and onboarding state between tests
localStorage.removeItem('tolaria-view-mode')
localStorage.removeItem('tolaria-view-mode')
localStorage.removeItem('laputa-view-mode')
localStorage.removeItem('tolaria_welcome_dismissed')
localStorage.removeItem('laputa_welcome_dismissed')
})

View File

@@ -707,7 +707,6 @@ function App() {
onUnarchiveNote={activeDeletedFile ? undefined : entryActions.handleUnarchiveNote}
onContentChange={appSave.handleContentChange}
onSave={appSave.handleSave}
onTitleSync={activeDeletedFile ? undefined : appSave.handleTitleSync}
onRenameFilename={activeDeletedFile ? undefined : handleFilenameRename}
rawToggleRef={rawToggleRef}
diffToggleRef={diffToggleRef}

View File

@@ -31,7 +31,7 @@ const TOOL_ICON_MAP: Record<string, IconRenderer> = {
Read: (s) => <File size={s} />,
Glob: (s) => <FolderOpen size={s} />,
Grep: (s) => <MagnifyingGlass size={s} />,
// Laputa MCP tools
// Tolaria MCP tools
search_notes: (s) => <MagnifyingGlass size={s} />,
get_vault_context: (s) => <ChartBar size={s} />,
get_note: (s) => <File size={s} />,
@@ -100,7 +100,7 @@ function DetailBlock({ label, content, isError }: {
)
}
/** Whether this tool is a Laputa UI-only tool (lighter styling). */
/** Whether this tool is a Tolaria UI-only tool (lighter styling). */
function isUiOnlyTool(tool: string): boolean {
return tool === 'open_note'
}

View File

@@ -55,6 +55,13 @@ describe('BreadcrumbBar — drag region', () => {
const bar = container.firstElementChild as HTMLElement
expect(bar.dataset.tauriDragRegion).toBeDefined()
})
it('marks the center spacer as a drag region', () => {
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
const spacer = container.querySelector('.breadcrumb-bar__drag-spacer')
expect(spacer).toHaveAttribute('data-tauri-drag-region')
expect(spacer).toHaveAttribute('aria-hidden', 'true')
})
})
describe('BreadcrumbBar — delete', () => {
@@ -141,26 +148,16 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
const bar = container.querySelector('.breadcrumb-bar')!
expect(bar).toHaveClass('border-b', 'border-transparent')
expect(bar).not.toHaveAttribute('data-title-hidden')
bar.setAttribute('data-title-hidden', '')
expect(bar).toHaveAttribute('data-title-hidden')
})
it('uses the active separator state when raw mode forces the title into the breadcrumb', () => {
it('keeps the breadcrumb title visible in raw mode', () => {
const { container } = render(
<BreadcrumbBar entry={baseEntry} {...defaultProps} rawMode onToggleRaw={vi.fn()} />,
)
expect(container.querySelector('.breadcrumb-bar')).toHaveAttribute('data-title-hidden')
})
it('keeps the breadcrumb title visible when the separate title section is absent', () => {
const { container } = render(
<BreadcrumbBar entry={baseEntry} {...defaultProps} showTitleSection={false} />,
)
expect(container.querySelector('.breadcrumb-bar')).toHaveAttribute('data-title-hidden')
})
})
describe('BreadcrumbBar — filename controls', () => {

View File

@@ -43,7 +43,6 @@ interface BreadcrumbBarProps {
onArchive?: () => void
onUnarchive?: () => void
onRenameFilename?: (path: string, newFilenameStem: string) => void
showTitleSection?: boolean
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
barRef?: React.Ref<HTMLDivElement>
}
@@ -515,17 +514,13 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
entry,
barRef,
onRenameFilename,
showTitleSection = true,
...actionProps
}: BreadcrumbBarProps) {
// In raw/diff mode the title section is not rendered — always show title in breadcrumb.
// Using a prop-driven attribute avoids the timing issues of DOM mutation in useEffect.
const titleAlwaysVisible = !showTitleSection || actionProps.rawMode || actionProps.diffMode
return (
<div
ref={barRef}
data-tauri-drag-region
{...(titleAlwaysVisible ? { 'data-title-hidden': '' } : {})}
data-title-hidden=""
className="breadcrumb-bar flex shrink-0 items-center border-b border-transparent"
style={{
height: 52,
@@ -534,9 +529,14 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
boxSizing: 'border-box',
}}
>
<div className="breadcrumb-bar__title flex-1 min-w-0">
<div className="breadcrumb-bar__title min-w-0">
<BreadcrumbTitle entry={entry} onRenameFilename={onRenameFilename} />
</div>
<div
aria-hidden="true"
data-tauri-drag-region
className="breadcrumb-bar__drag-spacer min-w-0 flex-1"
/>
<BreadcrumbActions entry={entry} {...actionProps} />
</div>
)

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

@@ -19,7 +19,7 @@ const makeCommand = (overrides: Partial<CommandAction> = {}): CommandAction => (
const commands: CommandAction[] = [
makeCommand({ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', keywords: ['find'] }),
makeCommand({ id: 'create-note', label: 'Create New Note', group: 'Note', shortcut: '⌘N' }),
makeCommand({ id: 'create-note', label: 'New Note', group: 'Note', shortcut: '⌘N' }),
makeCommand({ id: 'commit-push', label: 'Commit & Push', group: 'Git', keywords: ['git', 'sync'] }),
makeCommand({ id: 'open-settings', label: 'Open Settings', group: 'Settings', shortcut: '⌘,' }),
makeCommand({ id: 'disabled-cmd', label: 'Disabled Command', group: 'Note', enabled: false }),
@@ -47,7 +47,7 @@ describe('CommandPalette', () => {
it('shows all enabled commands grouped by category', () => {
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
expect(screen.getByText('Search Notes')).toBeInTheDocument()
expect(screen.getByText('Create New Note')).toBeInTheDocument()
expect(screen.getByText('New Note')).toBeInTheDocument()
expect(screen.getByText('Commit & Push')).toBeInTheDocument()
expect(screen.getByText('Open Settings')).toBeInTheDocument()
// Disabled command should not appear
@@ -115,7 +115,7 @@ describe('CommandPalette', () => {
fireEvent.keyDown(window, { key: 'ArrowDown' })
fireEvent.keyDown(window, { key: 'Enter' })
// Second enabled command (Create New Note) should execute
// Second enabled command (New Note) should execute
expect(commands[1].execute).toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
})
@@ -168,7 +168,7 @@ describe('CommandPalette', () => {
describe('relevance ranking', () => {
const relevanceCommands: CommandAction[] = [
makeCommand({ id: 'create-note', label: 'Create New Note', group: 'Note' }),
makeCommand({ id: 'create-note', label: 'New Note', group: 'Note' }),
makeCommand({ id: 'toggle-raw', label: 'Toggle Raw Editor', group: 'View' }),
makeCommand({ id: 'search-notes', label: 'Search Notes', group: 'Navigation' }),
]
@@ -182,24 +182,20 @@ describe('CommandPalette', () => {
).map(el => el.textContent)
}
it('ranks "Toggle Raw Editor" before "Create New Note" for query "raw"', () => {
it('shows only the relevant raw command for query "raw"', () => {
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'raw' } })
const labels = getVisibleLabels()
const rawIdx = labels.indexOf('Toggle Raw Editor')
const createIdx = labels.indexOf('Create New Note')
expect(rawIdx).toBeGreaterThanOrEqual(0)
expect(createIdx).toBeGreaterThanOrEqual(0)
expect(rawIdx).toBeLessThan(createIdx)
expect(labels).toEqual(['Toggle Raw Editor'])
})
it('ranks "Create New Note" first for query "new note"', () => {
it('ranks "New Note" first for query "new note"', () => {
render(<CommandPalette open={true} commands={relevanceCommands} onClose={onClose} />)
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'new note' } })
const labels = getVisibleLabels()
expect(labels[0]).toBe('Create New Note')
expect(labels[0]).toBe('New Note')
})
it('preserves default section order with empty query', () => {

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

@@ -204,57 +204,6 @@
width: 100%;
}
/* --- Title Section: wraps icon + title + separator --- */
.title-section {
width: 100%;
flex-shrink: 0;
}
.title-section__heading {
position: relative;
}
.title-section__inline-add-icon {
position: absolute;
left: 8px;
top: 0;
z-index: 1;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.title-section:hover .title-section__inline-add-icon,
.title-section__inline-add-icon:focus-within {
opacity: 1;
pointer-events: auto;
}
.title-section__row {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
padding-top: 8px;
margin-left: 8px;
}
/* No emoji: title aligns flush left (no indent for icon area) */
.title-section__row--no-icon {
margin-left: 0;
}
/* When emoji is present, restore top padding to the row itself */
.title-section__row:has(.note-icon-button--active) {
padding-top: 32px;
}
.title-section__separator {
border-bottom: 1px solid var(--border-primary, rgba(0, 0, 0, 0.08));
margin-top: 12px;
margin-left: 8px;
}
/* --- Note Icon Area --- */
.note-icon-area {
display: flex;
@@ -303,7 +252,6 @@
transition: opacity 0.15s;
}
.title-section:hover .note-icon-button--add,
.note-icon-button--add:focus-visible {
opacity: 1;
}
@@ -352,56 +300,6 @@
color: var(--destructive, #ef4444);
}
/* --- Title Field --- */
.title-field {
flex: 1;
min-width: 0;
}
.title-field__input {
display: block;
width: 100%;
border: none;
outline: none;
background: transparent;
font-size: var(--headings-h1-font-size, 32px);
font-weight: var(--headings-h1-font-weight, 700);
line-height: var(--headings-h1-line-height, 1.2);
letter-spacing: var(--headings-h1-letter-spacing, -0.015em);
color: var(--foreground);
padding: 0;
resize: none;
overflow: hidden;
font-family: inherit;
field-sizing: content;
}
.title-field__input::placeholder {
color: var(--text-faint, #ccc);
}
.title-field__input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.title-field__filename {
display: block;
font-size: 11px;
color: var(--text-faint, #999);
margin-top: 2px;
}
/* When the legacy title UI is shown, hide the first H1 heading in BlockNote
to avoid duplicate title display. Otherwise the editor's H1 remains visible
and serves as the title surface. */
.title-section[data-title-ui-visible] ~ .editor__blocknote-container [data-node-type="blockContainer"]:first-child [data-content-type="heading"][data-level="1"] {
display: none;
}
.title-section[data-title-ui-visible] ~ .editor__blocknote-container [data-node-type="blockContainer"]:first-child:has([data-content-type="heading"][data-level="1"]) {
display: none;
}
/* =============================================
Disable BlockNote/Mantine editor animations
=============================================

View File

@@ -54,8 +54,6 @@ interface EditorProps {
onUnarchiveNote?: (path: string) => void
onContentChange?: (path: string, content: string) => void
onSave?: () => void
/** Called when the user edits the title in TitleField. */
onTitleSync?: (path: string, newTitle: string) => void
/** Called when the user explicitly renames the filename from the breadcrumb. */
onRenameFilename?: (path: string, newFilenameStem: string) => void
canGoBack?: boolean
@@ -205,7 +203,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
showAIChat, onToggleAIChat,
vaultPath, noteList, noteListFilter,
onToggleFavorite, onToggleOrganized, onDeleteNote, onArchiveNote, onUnarchiveNote,
onContentChange, onSave, onTitleSync, onRenameFilename,
onContentChange, onSave, onRenameFilename,
onFileCreated, onFileModified, onVaultChanged,
isConflicted, onKeepMine, onKeepTheirs,
} = props
@@ -256,7 +254,6 @@ export const Editor = memo(function Editor(props: EditorProps) {
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
rawLatestContentRef={rawLatestContentRef}
onTitleChange={onTitleSync}
onRenameFilename={onRenameFilename}
isConflicted={isConflicted}
onKeepMine={onKeepMine}

View File

@@ -68,6 +68,12 @@
color: var(--headings-h1-color);
letter-spacing: var(--headings-h1-letter-spacing);
}
.editor__blocknote-container [data-content-type="heading"]:not([data-level])[data-is-empty-and-focused] .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before,
.editor__blocknote-container [data-content-type="heading"]:not([data-level])[data-is-only-empty-block] .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before,
.editor__blocknote-container [data-content-type="heading"][data-level="1"][data-is-empty-and-focused] .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before,
.editor__blocknote-container [data-content-type="heading"][data-level="1"][data-is-only-empty-block] .bn-inline-content:has(> .ProseMirror-trailingBreak:only-child)::before {
content: "Title" !important;
}
.editor__blocknote-container .bn-block-outer:has(> .bn-block > [data-content-type="heading"][data-level="2"]) {
margin-top: var(--headings-h2-margin-top) !important;

View File

@@ -1,7 +1,7 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { FeedbackDialog } from './FeedbackDialog'
import { LAPUTA_GITHUB_ISSUES_URL } from '../constants/feedback'
import { TOLARIA_GITHUB_ISSUES_URL } from '../constants/feedback'
vi.mock('../utils/url', () => ({
openExternalUrl: vi.fn().mockResolvedValue(undefined),
@@ -32,7 +32,7 @@ describe('FeedbackDialog', () => {
fireEvent.click(screen.getByRole('button', { name: 'Go to Issues' }))
await waitFor(() => expect(openExternalUrl).toHaveBeenCalledWith(LAPUTA_GITHUB_ISSUES_URL))
await waitFor(() => expect(openExternalUrl).toHaveBeenCalledWith(TOLARIA_GITHUB_ISSUES_URL))
expect(onClose).not.toHaveBeenCalled()
expect(screen.getByTestId('feedback-dialog')).toBeInTheDocument()
})

View File

@@ -8,7 +8,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { LAPUTA_GITHUB_ISSUES_URL } from '../constants/feedback'
import { TOLARIA_GITHUB_ISSUES_URL } from '../constants/feedback'
import { openExternalUrl } from '../utils/url'
interface FeedbackDialogProps {
@@ -18,7 +18,7 @@ interface FeedbackDialogProps {
export function FeedbackDialog({ open, onClose }: FeedbackDialogProps) {
const handleOpenIssues = () => {
void openExternalUrl(LAPUTA_GITHUB_ISSUES_URL)
void openExternalUrl(TOLARIA_GITHUB_ISSUES_URL)
}
return (

View File

@@ -27,7 +27,7 @@ export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredMod
<GitBranch size={36} className="text-muted-foreground" />
<h2 className="m-0 text-lg font-semibold text-foreground">Git repository required</h2>
<p className="m-0 text-center text-[13px] leading-relaxed text-muted-foreground">
Laputa uses a git repository to track changes, detect moved files, and keep your vault safe.
Tolaria uses a git repository to track changes, detect moved files, and keep your vault safe.
We'll create a local repo — no remote needed.
</p>
{error && (

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

@@ -123,7 +123,7 @@ describe('NoteItem', () => {
render(<NoteItem entry={entry} isSelected={false} typeEntryMap={{}} onClickNote={vi.fn()} />)
const dateRow = screen.getByTestId('note-date-row')
expect(dateRow.className).toContain('justify-between')
expect(dateRow.className).toContain('grid')
expect(dateRow).toHaveTextContent('2d ago')
expect(dateRow).toHaveTextContent('Created 5d ago')
})

View File

@@ -163,7 +163,7 @@ function StandardNoteContent({
<>
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
<div className="space-y-2 pr-5" data-testid="note-content-stack">
<div className="space-y-2" data-testid="note-content-stack">
<NoteTitleRow
entry={entry}
isBinary={isBinary}
@@ -206,7 +206,7 @@ function NoteTitleRow({
noteStatus: NoteStatus
}) {
return (
<div className={cn('truncate text-[13px]', isBinary ? 'text-muted-foreground' : 'text-foreground', isSelected && !isBinary ? 'font-semibold' : 'font-medium')}>
<div className={cn('truncate pr-5 text-[13px]', isBinary ? 'text-muted-foreground' : 'text-foreground', isSelected && !isBinary ? 'font-semibold' : 'font-medium')}>
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
<NoteTitleIcon icon={entry.icon} size={15} className="mr-1" testId="note-title-icon" />
{entry.title}
@@ -222,9 +222,9 @@ function NoteDateRow({ entry }: { entry: VaultEntry }) {
if (!modifiedLabel && !createdLabel) return null
return (
<div className="flex items-center justify-between gap-3 text-[10px] text-muted-foreground" data-testid="note-date-row">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 text-[10px] text-muted-foreground" data-testid="note-date-row">
<span>{modifiedLabel}</span>
{createdLabel && <span className="ml-auto">{createdLabel}</span>}
{createdLabel && <span className="justify-self-end text-right">{createdLabel}</span>}
</div>
)
}

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { fireEvent, screen } from '@testing-library/react'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage'
import { getSortComparator } from '../utils/noteListHelpers'
import { makeEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
@@ -107,7 +108,8 @@ describe('getSortComparator', () => {
describe('NoteList sort controls', () => {
beforeEach(() => {
try {
localStorage.removeItem('laputa-sort-preferences')
localStorage.removeItem(APP_STORAGE_KEYS.sortPreferences)
localStorage.removeItem(LEGACY_APP_STORAGE_KEYS.sortPreferences)
} catch {
// ignore storage failures in tests
}

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

@@ -19,7 +19,7 @@ export function RenameDetectedBanner({ renames, onUpdate, onDismiss }: RenameDet
<div className="flex items-center gap-3 border-b border-border bg-accent/50 px-4 py-2 text-[13px]">
<ArrowsClockwise size={16} className="shrink-0 text-accent-foreground" />
<span className="flex-1 text-foreground">
{count} file{count !== 1 ? 's' : ''} renamed outside Laputa. Update wikilinks?
{count} file{count !== 1 ? 's' : ''} renamed outside Tolaria. Update wikilinks?
</span>
<button
className="shrink-0 cursor-pointer rounded-md bg-primary px-3 py-1 text-[12px] font-medium text-primary-foreground transition-colors hover:bg-primary/90"

View File

@@ -298,7 +298,7 @@ function SettingsBody(props: SettingsBodyProps) {
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Privacy &amp; Telemetry</div>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
Anonymous data helps us fix bugs and improve Laputa. No vault content, note titles, or file paths are ever sent.
Anonymous data helps us fix bugs and improve Tolaria. No vault content, note titles, or file paths are ever sent.
</div>
</div>
@@ -320,7 +320,7 @@ function OrganizationWorkflowSection({
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Workflow</div>
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
Choose whether Laputa shows the Inbox workflow and the organized toggle.
Choose whether Tolaria shows the Inbox workflow and the organized toggle.
</div>
</div>

View File

@@ -946,6 +946,14 @@ describe('Sidebar', () => {
expect(screen.getByText('My Favorite Note')).toBeInTheDocument()
})
it('preserves plain square brackets in favorite titles', () => {
const bracketedFavorite = { ...favEntry, title: '[26Q2] Tolaria MVP' }
render(<Sidebar entries={[...mockEntries, bracketedFavorite]} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('[26Q2] Tolaria MVP')).toBeInTheDocument()
})
it('hides FAVORITES section when no favorites', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.queryByText('FAVORITES')).not.toBeInTheDocument()

View File

@@ -5,7 +5,7 @@ import { TelemetryConsentDialog } from './TelemetryConsentDialog'
describe('TelemetryConsentDialog', () => {
it('renders the consent dialog', () => {
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
expect(screen.getByText('Help improve Laputa')).toBeDefined()
expect(screen.getByText('Help improve Tolaria')).toBeDefined()
expect(screen.getByText(/anonymous crash reports/i)).toBeDefined()
})

View File

@@ -19,7 +19,7 @@ export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsent
<div style={{ textAlign: 'center' }}>
<h2 style={{ fontSize: 18, fontWeight: 600, color: 'var(--foreground)', margin: 0 }}>
Help improve Laputa
Help improve Tolaria
</h2>
<p style={{ fontSize: 13, color: 'var(--muted-foreground)', lineHeight: 1.6, marginTop: 8 }}>
Send anonymous crash reports to help us fix bugs faster.

View File

@@ -1,168 +0,0 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { TitleField } from './TitleField'
describe('TitleField', () => {
it('renders the title in the input', () => {
render(<TitleField title="My Note" filename="my-note.md" onTitleChange={() => {}} />)
expect(screen.getByTestId('title-field-input')).toHaveValue('My Note')
})
it('calls onTitleChange on blur with new value', () => {
const onChange = vi.fn()
render(<TitleField title="Old Title" filename="old-title.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
fireEvent.change(input, { target: { value: 'New Title' } })
fireEvent.blur(input)
expect(onChange).toHaveBeenCalledWith('New Title')
})
it('does not call onTitleChange if title unchanged', () => {
const onChange = vi.fn()
render(<TitleField title="Same Title" filename="same-title.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
fireEvent.focus(input)
fireEvent.blur(input)
expect(onChange).not.toHaveBeenCalled()
})
it('reverts to original title if input is emptied', () => {
const onChange = vi.fn()
render(<TitleField title="Keep This" filename="keep-this.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
fireEvent.change(input, { target: { value: '' } })
fireEvent.blur(input)
expect(onChange).not.toHaveBeenCalled()
expect(input).toHaveValue('Keep This')
})
it('shows filename indicator when slug differs and title is focused', () => {
render(<TitleField title="My Note" filename="wrong-name.md" onTitleChange={() => {}} />)
// Not shown when unfocused
expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument()
// Shown when focused
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.getByTestId('title-field-filename')).toHaveTextContent('my-note.md')
})
it('does not show filename when slug matches and not editing', () => {
render(<TitleField title="My Note" filename="my-note.md" onTitleChange={() => {}} />)
expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument()
})
it('disables input when editable is false', () => {
render(<TitleField title="Read Only" filename="read-only.md" editable={false} onTitleChange={() => {}} />)
expect(screen.getByTestId('title-field-input')).toBeDisabled()
})
it('commits title on Enter key', () => {
const onChange = vi.fn()
render(<TitleField title="Before" filename="before.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
fireEvent.change(input, { target: { value: 'After' } })
fireEvent.keyDown(input, { key: 'Enter' })
// In jsdom, blur() after keyDown needs explicit blur event
fireEvent.blur(input)
expect(onChange).toHaveBeenCalledWith('After')
})
it('reverts on Escape key', () => {
const onChange = vi.fn()
render(<TitleField title="Original" filename="original.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
fireEvent.change(input, { target: { value: 'Changed' } })
fireEvent.keyDown(input, { key: 'Escape' })
// Escape reverts value and blurs
expect(input).toHaveValue('Original')
})
it('shows new title optimistically after commit (before prop updates)', () => {
const onChange = vi.fn()
const { rerender } = render(<TitleField title="Old Title" filename="old-title.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
fireEvent.focus(input)
fireEvent.change(input, { target: { value: 'New Title' } })
fireEvent.blur(input)
// After commit, should show new title even though prop is still "Old Title"
expect(input).toHaveValue('New Title')
// After prop updates to match, should still show new title
rerender(<TitleField title="New Title" filename="new-title.md" onTitleChange={onChange} />)
expect(input).toHaveValue('New Title')
})
it('resets optimistic title when prop changes from external source', () => {
const onChange = vi.fn()
const { rerender } = render(<TitleField title="Title A" filename="title-a.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
// Simulate external title change (e.g., tab switch)
rerender(<TitleField title="Title B" filename="title-b.md" onTitleChange={onChange} />)
expect(input).toHaveValue('Title B')
})
it('responds to laputa:focus-editor event with selectTitle', () => {
render(<TitleField title="Focus Me" filename="focus-me.md" onTitleChange={() => {}} />)
const input = screen.getByTestId('title-field-input')
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
expect(document.activeElement).toBe(input)
})
it('resets stale localValue when title prop changes after focus', () => {
// Regression: creating a new note fires focus-editor before React re-renders,
// so handleFocus captures the OLD note's title into localValue.
// When React re-renders with the new title, localValue should be cleared.
const onChange = vi.fn()
const { rerender } = render(<TitleField title="Old Note" filename="old-note.md" onTitleChange={onChange} />)
const input = screen.getByTestId('title-field-input')
// Simulate: focus fires while title prop is still "Old Note"
fireEvent.focus(input)
expect(input).toHaveValue('Old Note')
// React re-renders with new note's title (tab switched)
rerender(<TitleField title="Untitled note" filename="untitled-note.md" onTitleChange={onChange} />)
expect(input).toHaveValue('Untitled note')
})
it('shows vault-relative path (without .md) only when title is focused', () => {
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
// Path hidden by default
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
// Focus title → path appears
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
// No bare filename shown when path is visible
expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument()
})
it('hides path on blur', () => {
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
const input = screen.getByTestId('title-field-input')
fireEvent.focus(input)
expect(screen.getByTestId('title-field-path')).toBeInTheDocument()
fireEvent.blur(input)
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
})
it('hides path for notes at vault root even when focused', () => {
render(<TitleField title="Root Note" filename="root-note.md" notePath="/Users/luca/Laputa/root-note.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
})
it('hides path when vaultPath is not provided', () => {
render(<TitleField title="Note" filename="note.md" onTitleChange={() => {}} />)
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
})
it('resolves vault-relative path when paths differ by symlink prefix', () => {
// vaultPath uses symlink /Users/luca/... but notePath is canonical /Volumes/Jupiter/...
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Workspace/laputa-app/demo-vault-v2" onTitleChange={() => {}} />)
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
})
it('handles vaultPath with trailing slash', () => {
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa/" onTitleChange={() => {}} />)
fireEvent.focus(screen.getByTestId('title-field-input'))
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
})
})

View File

@@ -1,170 +0,0 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { slugify } from '../hooks/useNoteCreation'
interface TitleFieldProps {
title: string
filename: string
editable?: boolean
/** Absolute path of the note file. */
notePath?: string
/** Absolute path of the vault root. */
vaultPath?: string
/** Called when the user finishes editing the title (blur or Enter). */
onTitleChange: (newTitle: string) => void
}
/** Manages local edit + optimistic title state for TitleField. */
function useOptimisticTitle(title: string, onTitleChange: (t: string) => void) {
const [localValue, setLocalValue] = useState<string | null>(null)
// [optimisticTitle, forPropTitle]: shown after commit until title prop catches up
const [optimistic, setOptimistic] = useState<[string, string] | null>(null)
const isFocusedRef = useRef(false)
const [prevTitle, setPrevTitle] = useState(title)
// Reset local edit when the title prop changes (e.g. note switch).
// This prevents a stale handleFocus closure from locking in the old note's title
// when focus-editor fires before React re-renders with the new tab.
if (prevTitle !== title) {
setPrevTitle(title)
if (localValue !== null) setLocalValue(null)
}
// Clear optimistic once the prop changes (rename completed or tab switched)
const optimisticValue = optimistic && optimistic[1] === title ? optimistic[0] : null
const value = localValue ?? optimisticValue ?? title
const isEditing = localValue !== null || optimisticValue !== null
const handleFocus = useCallback(() => {
isFocusedRef.current = true
setLocalValue(title)
}, [title])
const commitTitle = useCallback(() => {
isFocusedRef.current = false
const trimmed = (localValue ?? '').trim()
if (trimmed && trimmed !== title) {
setLocalValue(null)
setOptimistic([trimmed, title])
onTitleChange(trimmed)
} else {
setLocalValue(null)
}
}, [localValue, title, onTitleChange])
const revert = useCallback(() => setLocalValue(null), [])
const setEdit = useCallback((v: string) => setLocalValue(v), [])
return { value, isEditing, handleFocus, commitTitle, revert, setEdit }
}
/**
* Dedicated title input field above the editor.
* Displays the title as an editable field and shows the resulting filename below.
*/
export function TitleField({ title, filename, editable = true, notePath, vaultPath, onTitleChange }: TitleFieldProps) {
const inputRef = useRef<HTMLTextAreaElement>(null)
const [isFocused, setIsFocused] = useState(false)
const { value, isEditing, handleFocus, commitTitle, revert, setEdit } =
useOptimisticTitle(title, onTitleChange)
// Auto-resize textarea to fit content (fallback for browsers without field-sizing: content)
const autoResize = useCallback(() => {
const el = inputRef.current
if (!el) return
el.style.height = 'auto'
el.style.height = el.scrollHeight + 'px'
}, [])
useEffect(() => {
autoResize()
// Schedule a second measurement after the browser has painted, to catch
// cases where scrollHeight is stale on the first read (e.g. tab switch).
const id = requestAnimationFrame(autoResize)
return () => cancelAnimationFrame(id)
}, [value, autoResize])
// Re-measure when container width changes (text may re-wrap)
useEffect(() => {
const el = inputRef.current
if (!el) return
const ro = new ResizeObserver(autoResize)
ro.observe(el)
return () => ro.disconnect()
}, [autoResize])
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail
if (detail?.selectTitle && inputRef.current) {
inputRef.current.focus()
inputRef.current.select()
}
}
window.addEventListener('laputa:focus-editor', handler)
return () => window.removeEventListener('laputa:focus-editor', handler)
}, [])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault()
inputRef.current?.blur()
}
if (e.key === 'Escape') {
revert()
inputRef.current?.blur()
}
}, [revert])
const expectedSlug = slugify(value.trim() || title)
const currentStem = filename.replace(/\.md$/, '')
// Compute vault-relative path (only for notes in subdirectories)
const relativePath = (() => {
if (!notePath || !vaultPath) return null
const vp = vaultPath.replace(/\/+$/, '')
const np = notePath.replace(/\.md$/, '')
if (np.startsWith(vp + '/')) return np.slice(vp.length + 1)
// Fallback: match by vault directory name for symlink-resolved paths
const vaultName = vp.split('/').pop()
if (!vaultName) return null
const segments = np.split('/')
const idx = segments.lastIndexOf(vaultName)
if (idx >= 0) return segments.slice(idx + 1).join('/')
return null
})()
const isSubdirectory = relativePath != null && relativePath.includes('/')
// Show path only when title is focused and note is in a subdirectory
const showRelativePath = isFocused && isSubdirectory
// Show filename hint when slug differs, but only when focused (not always)
const showFilename = isFocused && !showRelativePath && (isEditing || currentStem !== expectedSlug)
return (
<div className="title-field" data-testid="title-field">
<textarea
ref={inputRef}
className="title-field__input"
value={value}
rows={1}
onChange={e => { setEdit(e.target.value); autoResize() }}
onFocus={() => { setIsFocused(true); handleFocus() }}
onBlur={() => { setIsFocused(false); commitTitle() }}
onKeyDown={handleKeyDown}
disabled={!editable}
placeholder="Untitled"
spellCheck={false}
data-testid="title-field-input"
/>
{showFilename && (
<span className="title-field__filename" data-testid="title-field-filename">
{expectedSlug}.md
</span>
)}
{showRelativePath && (
<span className="title-field__path" data-testid="title-field-path" style={{ display: 'block', fontSize: 11, color: 'var(--muted-foreground)', marginTop: 2 }}>
{relativePath}
</span>
)}
</div>
)
}

View File

@@ -40,7 +40,7 @@ describe('UpdateBanner', () => {
render(<UpdateBanner status={status} actions={actions} />)
expect(screen.getByTestId('update-banner')).toBeTruthy()
expect(screen.getByText(/Laputa 1\.5\.0/)).toBeTruthy()
expect(screen.getByText(/Tolaria 1\.5\.0/)).toBeTruthy()
expect(screen.getByText('is available')).toBeTruthy()
expect(screen.getByTestId('update-now-btn')).toBeTruthy()
expect(screen.getByTestId('update-release-notes')).toBeTruthy()
@@ -78,7 +78,7 @@ describe('UpdateBanner', () => {
const status: UpdateStatus = { state: 'downloading', version: '1.5.0', progress: 0.65 }
render(<UpdateBanner status={status} actions={makeActions()} />)
expect(screen.getByText(/Downloading Laputa 1\.5\.0/)).toBeTruthy()
expect(screen.getByText(/Downloading Tolaria 1\.5\.0/)).toBeTruthy()
expect(screen.getByText('65%')).toBeTruthy()
const progressBar = screen.getByTestId('update-progress')
@@ -98,7 +98,7 @@ describe('UpdateBanner', () => {
const status: UpdateStatus = { state: 'ready', version: '1.5.0' }
render(<UpdateBanner status={status} actions={makeActions()} />)
expect(screen.getByText(/Laputa 1\.5\.0/)).toBeTruthy()
expect(screen.getByText(/Tolaria 1\.5\.0/)).toBeTruthy()
expect(screen.getByText(/restart to apply/)).toBeTruthy()
expect(screen.getByTestId('update-restart-btn')).toBeTruthy()
})

View File

@@ -29,7 +29,7 @@ export function UpdateBanner({ status, actions }: UpdateBannerProps) {
<>
<Download size={14} style={{ color: '#fff', flexShrink: 0 }} />
<span>
<strong>Laputa {status.version}</strong> is available
<strong>Tolaria {status.version}</strong> is available
</span>
<button
data-testid="update-release-notes"
@@ -87,7 +87,7 @@ export function UpdateBanner({ status, actions }: UpdateBannerProps) {
{status.state === 'downloading' && (
<>
<RefreshCw size={14} style={{ color: '#fff', flexShrink: 0, animation: 'spin 1s linear infinite' }} />
<span>Downloading Laputa {status.version}...</span>
<span>Downloading Tolaria {status.version}...</span>
<div
style={{
flex: 1,
@@ -119,7 +119,7 @@ export function UpdateBanner({ status, actions }: UpdateBannerProps) {
<>
<RefreshCw size={14} style={{ color: 'var(--accent-green, #0F7B0F)', flexShrink: 0 }} />
<span>
<strong>Laputa {status.version}</strong> is ready restart to apply
<strong>Tolaria {status.version}</strong> is ready restart to apply
</span>
<button
data-testid="update-restart-btn"

View File

@@ -18,7 +18,7 @@ describe('WelcomeScreen', () => {
describe('welcome mode', () => {
it('renders welcome title and subtitle', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByText('Welcome to Laputa')).toBeInTheDocument()
expect(screen.getByText('Welcome to Tolaria')).toBeInTheDocument()
expect(screen.getByText(/Wiki-linked knowledge management/)).toBeInTheDocument()
})

View File

@@ -220,7 +220,7 @@ export function WelcomeScreen({
<div style={{ textAlign: 'center' }}>
<h1 style={TITLE_STYLE}>
{isWelcome ? 'Welcome to Laputa' : 'Vault not found'}
{isWelcome ? 'Welcome to Tolaria' : 'Vault not found'}
</h1>
<p style={{ ...SUBTITLE_STYLE, marginTop: 8 }}>
{isWelcome

View File

@@ -7,18 +7,6 @@ vi.mock('../BreadcrumbBar', () => ({
BreadcrumbBar: () => <div data-testid="breadcrumb-bar" />,
}))
vi.mock('../TitleField', () => ({
TitleField: () => <div data-testid="title-field-input" />,
}))
vi.mock('../NoteIcon', () => ({
NoteIcon: ({ icon }: { icon: string | null }) => (
icon
? <button type="button" data-testid="note-icon-display" />
: <button type="button" data-testid="note-icon-add" />
),
}))
vi.mock('../ArchivedNoteBanner', () => ({
ArchivedNoteBanner: () => <div data-testid="archived-banner" />,
}))
@@ -69,12 +57,7 @@ function createModel(overrides: Record<string, unknown> = {}) {
onKeepTheirs: vi.fn(),
breadcrumbBarRef: createRef<HTMLDivElement>(),
wordCount: 12,
titleSectionRef: createRef<HTMLDivElement>(),
showTitleSection: true,
hasDisplayIcon: false,
entryIcon: null,
vaultPath: '/vault',
onTitleChange: vi.fn(),
cssVars: {},
onNavigateWikilink: vi.fn(),
onEditorChange: vi.fn(),
@@ -95,27 +78,12 @@ function createModel(overrides: Record<string, unknown> = {}) {
}
describe('EditorContentLayout', () => {
it('does not render a standalone add-icon row when the note has no icon', () => {
it('never renders the legacy title section', () => {
const { container } = render(<EditorContentLayout {...createModel()} />)
expect(container.querySelector('.title-section__add-icon')).toBeNull()
expect(container.querySelector('.title-section__inline-add-icon')).not.toBeNull()
expect(screen.getByTestId('note-icon-add')).toBeInTheDocument()
})
it('keeps the existing icon and title inside the same title row', () => {
const { container } = render(
<EditorContentLayout
{...createModel({
hasDisplayIcon: true,
entryIcon: 'rocket',
})}
/>,
)
const titleRow = container.querySelector('.title-section__row')
expect(titleRow?.querySelector('[data-testid="note-icon-display"]')).not.toBeNull()
expect(titleRow?.querySelector('[data-testid="title-field-input"]')).not.toBeNull()
expect(container.querySelector('.title-section')).toBeNull()
expect(screen.queryByTestId('title-field-input')).not.toBeInTheDocument()
expect(screen.getByTestId('single-editor-view')).toBeInTheDocument()
})
it('shows the loading skeleton instead of stale editor chrome while switching tabs', () => {

View File

@@ -1,8 +1,6 @@
import type React from 'react'
import { DiffView } from '../DiffView'
import { BreadcrumbBar } from '../BreadcrumbBar'
import { TitleField } from '../TitleField'
import { NoteIcon } from '../NoteIcon'
import { ArchivedNoteBanner } from '../ArchivedNoteBanner'
import { ConflictNoteBanner } from '../ConflictNoteBanner'
import { RawEditorView } from '../RawEditorView'
@@ -99,14 +97,12 @@ function ActiveTabBreadcrumb({
barRef,
wordCount,
path,
showTitleSection,
actions,
}: {
activeTab: NonNullable<EditorContentModel['activeTab']>
barRef: React.RefObject<HTMLDivElement | null>
wordCount: number
path: string
showTitleSection: boolean
actions: BreadcrumbActions
}) {
return (
@@ -114,7 +110,6 @@ function ActiveTabBreadcrumb({
entry={activeTab.entry}
wordCount={wordCount}
barRef={barRef}
showTitleSection={showTitleSection}
showDiffToggle={actions.showDiffToggle}
diffMode={actions.diffMode}
diffLoading={actions.diffLoading}
@@ -136,50 +131,6 @@ function ActiveTabBreadcrumb({
)
}
function TitleSection({
activeTab,
entryIcon,
hasDisplayIcon,
path,
showTitleSection,
titleSectionRef,
vaultPath,
onTitleChange,
}: Pick<
EditorContentModel,
'activeTab' | 'entryIcon' | 'hasDisplayIcon' | 'path' | 'showTitleSection' | 'titleSectionRef' | 'vaultPath' | 'onTitleChange'
>) {
if (!activeTab) return null
return (
<div ref={titleSectionRef} className="title-section" data-title-ui-visible={showTitleSection || undefined}>
{showTitleSection && (
<>
<div className="title-section__heading">
{!hasDisplayIcon && (
<div className="title-section__inline-add-icon">
<NoteIcon icon={null} editable />
</div>
)}
<div className={`title-section__row${hasDisplayIcon ? '' : ' title-section__row--no-icon'}`}>
{hasDisplayIcon && <NoteIcon icon={entryIcon} editable />}
<TitleField
title={activeTab.entry.title}
filename={activeTab.entry.filename}
editable
notePath={path}
vaultPath={vaultPath}
onTitleChange={(newTitle) => onTitleChange?.(path, newTitle)}
/>
</div>
</div>
<div className="title-section__separator" />
</>
)}
</div>
)
}
function EditorChrome({
isArchived,
onUnarchiveNote,
@@ -213,52 +164,28 @@ function EditorChrome({
function EditorCanvas({
showEditor,
cssVars,
activeTab,
entryIcon,
hasDisplayIcon,
path,
showTitleSection,
titleSectionRef,
vaultPath,
onTitleChange,
editor,
entries,
onNavigateWikilink,
onEditorChange,
isDeletedPreview,
vaultPath,
}: Pick<
EditorContentModel,
| 'showEditor'
| 'cssVars'
| 'activeTab'
| 'entryIcon'
| 'hasDisplayIcon'
| 'path'
| 'showTitleSection'
| 'titleSectionRef'
| 'vaultPath'
| 'onTitleChange'
| 'editor'
| 'entries'
| 'onNavigateWikilink'
| 'onEditorChange'
| 'isDeletedPreview'
| 'vaultPath'
>) {
if (!showEditor) return null
return (
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
<div className="editor-content-wrapper">
<TitleSection
activeTab={activeTab}
entryIcon={entryIcon}
hasDisplayIcon={hasDisplayIcon}
path={path}
showTitleSection={showTitleSection}
titleSectionRef={titleSectionRef}
vaultPath={vaultPath}
onTitleChange={onTitleChange}
/>
<SingleEditorView
editor={editor}
entries={entries}
@@ -293,12 +220,7 @@ export function EditorContentLayout(model: EditorContentModel) {
onKeepTheirs,
breadcrumbBarRef,
wordCount,
titleSectionRef,
showTitleSection,
hasDisplayIcon,
entryIcon,
vaultPath,
onTitleChange,
cssVars,
onNavigateWikilink,
onEditorChange,
@@ -321,7 +243,6 @@ export function EditorContentLayout(model: EditorContentModel) {
barRef={breadcrumbBarRef}
wordCount={wordCount}
path={path}
showTitleSection={showTitleSection}
actions={{
diffMode: model.diffMode,
diffLoading: model.diffLoading,
@@ -365,14 +286,7 @@ export function EditorContentLayout(model: EditorContentModel) {
<EditorCanvas
showEditor={showEditor}
cssVars={cssVars}
activeTab={activeTab}
entryIcon={entryIcon}
hasDisplayIcon={hasDisplayIcon}
path={path}
showTitleSection={showTitleSection}
titleSectionRef={titleSectionRef}
vaultPath={vaultPath}
onTitleChange={onTitleChange}
editor={editor}
entries={entries}
onNavigateWikilink={onNavigateWikilink}

View File

@@ -46,47 +46,47 @@ function deriveState(tab: EditorContentTab | null, overrides?: Partial<VaultEntr
}
describe('deriveEditorContentState', () => {
it('hides the legacy title section when loaded content contains a top-level H1', () => {
it('marks loaded content with a top-level H1 as titled', () => {
const state = deriveState({
entry: baseEntry,
content: '---\ntitle: Legacy Project\n---\n# Legacy Project\n\nBody',
})
expect(state.hasH1).toBe(true)
expect(state.showTitleSection).toBe(false)
expect(state.showEditor).toBe(true)
})
it('keeps the title section for notes without an H1', () => {
it('keeps editor content visible for notes without an H1', () => {
const state = deriveState({
entry: baseEntry,
content: '---\ntitle: Legacy Project\n---\nBody without a heading',
})
expect(state.hasH1).toBe(false)
expect(state.showTitleSection).toBe(false)
expect(state.showEditor).toBe(true)
})
it('hides the legacy title section when a frontmatter title drives the display title', () => {
it('keeps editor content visible when a legacy frontmatter title exists', () => {
const state = deriveState({
entry: baseEntry,
content: '---\ntitle: Spring 2026\nstatus: Active\n---\n## Goals',
})
expect(state.hasH1).toBe(false)
expect(state.showTitleSection).toBe(false)
expect(state.showEditor).toBe(true)
})
it('keeps the title section when the document title still comes from the filename', () => {
it('does not fall back to a separate title section when the filename drives the display title', () => {
const state = deriveState({
entry: baseEntry,
content: '---\nstatus: Active\n---\nBody without a heading',
})
expect(state.hasH1).toBe(false)
expect(state.showTitleSection).toBe(true)
expect(state.showEditor).toBe(true)
})
it('hides the title section for untitled drafts before they get an H1', () => {
it('keeps untitled drafts in the editor even before they get an H1', () => {
const draftEntry = {
...baseEntry,
path: '/vault/untitled-note-1700000000.md',
@@ -105,6 +105,6 @@ describe('deriveEditorContentState', () => {
})
expect(state.hasH1).toBe(false)
expect(state.showTitleSection).toBe(false)
expect(state.showEditor).toBe(true)
})
})

View File

@@ -1,5 +1,5 @@
import type { NoteStatus, VaultEntry } from '../../types'
import { contentDefinesDisplayTitle, extractH1TitleFromContent } from '../../utils/noteTitle'
import { extractH1TitleFromContent } from '../../utils/noteTitle'
import { countWords } from '../../utils/wikilinks'
export interface EditorContentTab {
@@ -14,17 +14,11 @@ interface EditorContentStateInput {
activeStatus: NoteStatus
}
interface TitleSectionState {
hasDisplayTitle: boolean
hasH1: boolean
}
interface VisibilityState {
effectiveRawMode: boolean
isDeletedPreview: boolean
isNonMarkdownText: boolean
showEditor: boolean
showTitleSection: boolean
}
export interface EditorContentState {
@@ -35,7 +29,6 @@ export interface EditorContentState {
isNonMarkdownText: boolean
effectiveRawMode: boolean
showEditor: boolean
showTitleSection: boolean
path: string
wordCount: number
}
@@ -49,38 +42,18 @@ function contentHasTopLevelH1(activeTab: EditorContentTab | null): boolean {
return activeTab ? extractH1TitleFromContent(activeTab.content) !== null : false
}
function contentDefinesTitle(activeTab: EditorContentTab | null): boolean {
return activeTab ? contentDefinesDisplayTitle(activeTab.content) : false
}
function resolveHasH1(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): boolean {
return contentHasTopLevelH1(activeTab) || freshEntry?.hasH1 === true || activeTab?.entry.hasH1 === true
}
function resolveHasDisplayTitle(activeTab: EditorContentTab | null, hasH1: boolean): boolean {
return hasH1 || contentDefinesTitle(activeTab)
}
function deriveTitleSectionState(activeTab: EditorContentTab | null, freshEntry: VaultEntry | undefined): TitleSectionState {
const hasH1 = resolveHasH1(activeTab, freshEntry)
return {
hasDisplayTitle: resolveHasDisplayTitle(activeTab, hasH1),
hasH1,
}
}
function deriveVisibilityState(input: {
activeStatus: NoteStatus
activeTab: EditorContentTab | null
freshEntry: VaultEntry | undefined
hasDisplayTitle: boolean
rawMode: boolean
}): VisibilityState {
const {
activeStatus,
activeTab,
freshEntry,
hasDisplayTitle,
rawMode,
} = input
const isDeletedPreview = !!activeTab && !freshEntry
@@ -92,36 +65,23 @@ function deriveVisibilityState(input: {
isNonMarkdownText,
effectiveRawMode,
showEditor: !effectiveRawMode,
showTitleSection: !isDeletedPreview && !hasDisplayTitle && !isUnsavedUntitledDraft(activeTab, activeStatus),
}
}
function isUnsavedUntitledDraft(activeTab: EditorContentTab | null, activeStatus: NoteStatus): boolean {
if (!activeTab) return false
if (!activeTab.entry.filename.startsWith('untitled-')) return false
return activeStatus === 'new' || activeStatus === 'unsaved' || activeStatus === 'pendingSave'
}
export function deriveEditorContentState({
activeTab,
entries,
rawMode,
activeStatus,
}: EditorContentStateInput): EditorContentState {
export function deriveEditorContentState(input: EditorContentStateInput): EditorContentState {
const { activeTab, entries, rawMode } = input
const freshEntry = findFreshEntry(activeTab, entries)
const titleState = deriveTitleSectionState(activeTab, freshEntry)
const hasH1 = resolveHasH1(activeTab, freshEntry)
const visibilityState = deriveVisibilityState({
activeStatus,
activeTab,
freshEntry,
hasDisplayTitle: titleState.hasDisplayTitle,
rawMode,
})
return {
freshEntry,
isArchived: freshEntry?.archived ?? activeTab?.entry.archived ?? false,
hasH1: titleState.hasH1,
hasH1,
...visibilityState,
path: activeTab?.entry.path ?? '',
wordCount: activeTab ? countWords(activeTab.content) : 0,

View File

@@ -1,9 +1,8 @@
import type React from 'react'
import { useEffect, useRef } from 'react'
import { useRef } from 'react'
import type { useCreateBlockNote } from '@blocknote/react'
import type { NoteStatus, VaultEntry } from '../../types'
import { useEditorTheme } from '../../hooks/useTheme'
import { resolveNoteIcon } from '../../utils/noteIcon'
import { deriveEditorContentState } from './editorContentState'
export interface Tab {
@@ -39,61 +38,17 @@ export interface EditorContentProps {
onUnarchiveNote?: (path: string) => void
vaultPath?: string
rawLatestContentRef?: React.MutableRefObject<string | null>
onTitleChange?: (path: string, newTitle: string) => void
onRenameFilename?: (path: string, newFilenameStem: string) => void
isConflicted?: boolean
onKeepMine?: (path: string) => void
onKeepTheirs?: (path: string) => void
}
function useBreadcrumbTitleVisibility({
showEditor,
showTitleSection,
path,
breadcrumbBarRef,
titleSectionRef,
}: {
showEditor: boolean
showTitleSection: boolean
path: string
breadcrumbBarRef: React.RefObject<HTMLDivElement | null>
titleSectionRef: React.RefObject<HTMLDivElement | null>
}) {
useEffect(() => {
if (!showEditor) return
const bar = breadcrumbBarRef.current
const titleSection = titleSectionRef.current
if (!bar || !titleSection) return
if (!showTitleSection) {
bar.setAttribute('data-title-hidden', '')
return () => {
bar.removeAttribute('data-title-hidden')
}
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) bar.removeAttribute('data-title-hidden')
else bar.setAttribute('data-title-hidden', '')
},
{ threshold: 0 },
)
observer.observe(titleSection)
return () => {
observer.disconnect()
bar.removeAttribute('data-title-hidden')
}
}, [path, showEditor, showTitleSection, breadcrumbBarRef, titleSectionRef])
}
export function useEditorContentModel(props: EditorContentProps) {
const {
activeTab,
entries,
rawMode,
activeStatus,
diffMode,
} = props
@@ -105,29 +60,17 @@ export function useEditorContentModel(props: EditorContentProps) {
effectiveRawMode,
showEditor: showContentEditor,
path,
showTitleSection,
wordCount,
} = deriveEditorContentState({
activeTab,
entries,
rawMode,
activeStatus,
activeStatus: props.activeStatus,
})
const showEditor = !diffMode && showContentEditor
const entryIcon = activeTab?.entry.icon ?? null
const hasDisplayIcon = resolveNoteIcon(entryIcon).kind !== 'none'
const titleSectionRef = useRef<HTMLDivElement | null>(null)
const breadcrumbBarRef = useRef<HTMLDivElement | null>(null)
useBreadcrumbTitleVisibility({
showEditor,
showTitleSection,
path,
breadcrumbBarRef,
titleSectionRef,
})
return {
...props,
cssVars,
@@ -136,11 +79,7 @@ export function useEditorContentModel(props: EditorContentProps) {
effectiveRawMode,
forceRawMode: isNonMarkdownText || isDeletedPreview,
showEditor,
entryIcon,
hasDisplayIcon,
path,
showTitleSection,
titleSectionRef,
breadcrumbBarRef,
wordCount,
}

View File

@@ -1,10 +1,9 @@
import { useState, useMemo, useEffect, useCallback, type RefObject } from 'react'
import type { VaultEntry } from '../../types'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../../constants/appStorage'
import { buildTypeEntryMap } from '../../utils/typeColors'
import { buildDynamicSections, sortSections } from '../../utils/sidebarSections'
const SIDEBAR_COLLAPSED_KEY = 'laputa:sidebar-collapsed'
export type SidebarGroupKey = 'favorites' | 'views' | 'sections' | 'folders'
export function useOutsideClick(ref: RefObject<HTMLElement | null>, isOpen: boolean, onClose: () => void) {
@@ -34,7 +33,7 @@ export function useSidebarSections(entries: VaultEntry[]) {
function loadCollapsedState(): Record<SidebarGroupKey, boolean> {
try {
const raw = localStorage.getItem(SIDEBAR_COLLAPSED_KEY)
const raw = getAppStorageItem('sidebarCollapsed')
if (raw) return JSON.parse(raw)
} catch {
// Ignore localStorage failures and fall back to defaults.
@@ -48,7 +47,8 @@ export function useSidebarCollapsed() {
const toggle = useCallback((key: SidebarGroupKey) => {
setCollapsed((prev) => {
const next = { ...prev, [key]: !prev[key] }
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, JSON.stringify(next))
localStorage.setItem(APP_STORAGE_KEYS.sidebarCollapsed, JSON.stringify(next))
localStorage.removeItem(LEGACY_APP_STORAGE_KEYS.sidebarCollapsed)
return next
})
}, [])

View File

@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NoteList } from './NoteList'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage'
import type { VaultEntry, SidebarSelection } from '../types'
const localStorageMock = (() => {
@@ -88,7 +89,7 @@ describe('useNoteListSort (via NoteList)', () => {
})
it('migrates localStorage sort to type frontmatter when type has no sort', () => {
localStorageMock.setItem('laputa-sort-preferences', JSON.stringify({ '__list__': { option: 'title', direction: 'asc' } }))
localStorageMock.setItem(APP_STORAGE_KEYS.sortPreferences, JSON.stringify({ '__list__': { option: 'title', direction: 'asc' } }))
const onUpdateTypeSort = vi.fn()
const updateEntry = vi.fn()
const typeDoc = makeEntry({ path: '/project.md', title: 'Project', isA: 'Type', sort: null })
@@ -106,7 +107,7 @@ describe('useNoteListSort (via NoteList)', () => {
})
it('does not migrate if type already has sort', () => {
localStorageMock.setItem('laputa-sort-preferences', JSON.stringify({ '__list__': { option: 'title', direction: 'asc' } }))
localStorageMock.setItem(APP_STORAGE_KEYS.sortPreferences, JSON.stringify({ '__list__': { option: 'title', direction: 'asc' } }))
const onUpdateTypeSort = vi.fn()
const updateEntry = vi.fn()
const typeDoc = makeEntry({ path: '/project.md', title: 'Project', isA: 'Type', sort: 'modified:desc' })
@@ -123,7 +124,7 @@ describe('useNoteListSort (via NoteList)', () => {
})
it('falls back to modified when property sort references missing property', () => {
localStorageMock.setItem('laputa-sort-preferences', JSON.stringify({ '__list__': { option: 'property:priority', direction: 'asc' } }))
localStorageMock.setItem(APP_STORAGE_KEYS.sortPreferences, JSON.stringify({ '__list__': { option: 'property:priority', direction: 'asc' } }))
const entries = [
makeEntry({ path: '/a.md', title: 'Alpha', modifiedAt: 1000, properties: {} }),
makeEntry({ path: '/b.md', title: 'Beta', modifiedAt: 3000, properties: {} }),
@@ -136,7 +137,7 @@ describe('useNoteListSort (via NoteList)', () => {
})
it('uses property sort when property exists in entries', () => {
localStorageMock.setItem('laputa-sort-preferences', JSON.stringify({ '__list__': { option: 'property:priority', direction: 'asc' } }))
localStorageMock.setItem(APP_STORAGE_KEYS.sortPreferences, JSON.stringify({ '__list__': { option: 'property:priority', direction: 'asc' } }))
const entries = [
makeEntry({ path: '/b.md', title: 'Beta', modifiedAt: 3000, properties: { priority: 2 } }),
makeEntry({ path: '/a.md', title: 'Alpha', modifiedAt: 1000, properties: { priority: 1 } }),
@@ -147,4 +148,17 @@ describe('useNoteListSort (via NoteList)', () => {
expect(items[0].textContent).toBe('Alpha')
expect(items[1].textContent).toBe('Beta')
})
it('reads legacy list sort preferences when Tolaria key is absent', () => {
localStorageMock.setItem(LEGACY_APP_STORAGE_KEYS.sortPreferences, JSON.stringify({ '__list__': { option: 'title', direction: 'asc' } }))
const entries = [
makeEntry({ path: '/c.md', title: 'Charlie', modifiedAt: 3000 }),
makeEntry({ path: '/a.md', title: 'Alpha', modifiedAt: 1000 }),
]
renderNoteList({ entries, selection: { kind: 'filter', filter: 'all' } })
const items = screen.getAllByText(/Alpha|Charlie/)
expect(items[0].textContent).toBe('Alpha')
expect(items[1].textContent).toBe('Charlie')
})
})

View File

@@ -0,0 +1,68 @@
export const APP_STORAGE_KEYS = {
theme: 'tolaria-theme',
zoom: 'tolaria:zoom-level',
viewMode: 'tolaria-view-mode',
tagColors: 'tolaria:tag-color-overrides',
statusColors: 'tolaria:status-color-overrides',
propertyModes: 'tolaria:display-mode-overrides',
configMigrationFlag: 'tolaria:config-migrated-to-vault',
legacyMigrationFlag: 'tolaria:legacy-storage-migrated',
sortPreferences: 'tolaria-sort-preferences',
sidebarCollapsed: 'tolaria:sidebar-collapsed',
welcomeDismissed: 'tolaria_welcome_dismissed',
} as const
export const LEGACY_APP_STORAGE_KEYS = {
theme: 'laputa-theme',
zoom: 'laputa:zoom-level',
viewMode: 'laputa-view-mode',
tagColors: 'laputa:tag-color-overrides',
statusColors: 'laputa:status-color-overrides',
propertyModes: 'laputa:display-mode-overrides',
configMigrationFlag: 'laputa:config-migrated-to-vault',
sortPreferences: 'laputa-sort-preferences',
sidebarCollapsed: 'laputa:sidebar-collapsed',
welcomeDismissed: 'laputa_welcome_dismissed',
} as const
type MigratableStorageKey = keyof typeof LEGACY_APP_STORAGE_KEYS
const MIGRATABLE_STORAGE_KEYS: MigratableStorageKey[] = [
'theme',
'zoom',
'viewMode',
'tagColors',
'statusColors',
'propertyModes',
'configMigrationFlag',
'sortPreferences',
'sidebarCollapsed',
'welcomeDismissed',
]
export function copyLegacyAppStorageKeys(): void {
try {
if (localStorage.getItem(APP_STORAGE_KEYS.legacyMigrationFlag) === '1') return
for (const key of MIGRATABLE_STORAGE_KEYS) {
if (localStorage.getItem(APP_STORAGE_KEYS[key]) !== null) continue
const legacyValue = localStorage.getItem(LEGACY_APP_STORAGE_KEYS[key])
if (legacyValue !== null) {
localStorage.setItem(APP_STORAGE_KEYS[key], legacyValue)
}
}
localStorage.setItem(APP_STORAGE_KEYS.legacyMigrationFlag, '1')
} catch {
// Ignore unavailable or restricted localStorage implementations.
}
}
export function getAppStorageItem(key: MigratableStorageKey): string | null {
try {
return localStorage.getItem(APP_STORAGE_KEYS[key]) ?? localStorage.getItem(LEGACY_APP_STORAGE_KEYS[key])
} catch {
return null
}
}

View File

@@ -1 +1 @@
export const LAPUTA_GITHUB_ISSUES_URL = 'https://github.com/refactoringhq/laputa-app/issues'
export const TOLARIA_GITHUB_ISSUES_URL = 'https://github.com/refactoringhq/tolaria/issues'

View File

@@ -51,8 +51,27 @@ export type AppCommandShortcutCombo =
| 'command-or-ctrl'
| 'command-or-ctrl-shift'
| 'command-shift'
export type AppCommandDeterministicQaMode =
| 'renderer-shortcut-event'
| 'native-menu-command'
type ShortcutEventLike = Pick<KeyboardEvent, 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey' | 'key' | 'code'>
export interface AppCommandDeterministicQaDefinition {
preferredMode: AppCommandDeterministicQaMode
supportsRendererShortcutEvent: boolean
supportsNativeMenuCommand: boolean
requiresManualNativeAcceleratorQa: boolean
}
export interface AppCommandShortcutEventOptions {
preferControl?: boolean
}
export type AppCommandShortcutEventInit = Pick<
KeyboardEventInit,
'altKey' | 'bubbles' | 'cancelable' | 'code' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'
>
type SimpleHandlerKey =
| 'onOpenSettings'
| 'onCheckForUpdates'
@@ -109,6 +128,7 @@ export interface AppCommandDefinition {
route: AppCommandRoute
menuOwned: boolean
shortcut?: AppCommandShortcutDefinition
preferredShortcutQaMode?: AppCommandDeterministicQaMode
}
export const APP_COMMAND_DEFINITIONS: Record<AppCommandId, AppCommandDefinition> = {
@@ -177,6 +197,7 @@ export const APP_COMMAND_DEFINITIONS: Record<AppCommandId, AppCommandDefinition>
[APP_COMMAND_IDS.viewToggleProperties]: {
route: { kind: 'handler', handler: 'onToggleInspector' },
menuOwned: true,
preferredShortcutQaMode: 'renderer-shortcut-event',
shortcut: { combo: 'command-or-ctrl-shift', key: 'i', code: 'KeyI', display: '⌘⇧I' },
},
[APP_COMMAND_IDS.viewToggleAiChat]: {
@@ -312,6 +333,19 @@ const NATIVE_MENU_COMMAND_SET = new Set<string>(
.map(([id]) => id),
)
const MANUAL_NATIVE_ACCELERATOR_QA_COMMAND_SET = new Set<AppCommandId>([
APP_COMMAND_IDS.appSettings,
APP_COMMAND_IDS.fileNewNote,
APP_COMMAND_IDS.fileDailyNote,
APP_COMMAND_IDS.fileQuickOpen,
APP_COMMAND_IDS.fileSave,
APP_COMMAND_IDS.editFindInVault,
APP_COMMAND_IDS.viewToggleAiChat,
APP_COMMAND_IDS.viewCommandPalette,
APP_COMMAND_IDS.noteToggleOrganized,
APP_COMMAND_IDS.noteToggleFavorite,
])
const shortcutKeyMaps = {
'command-or-ctrl': new Map<string, AppCommandId>(),
'command-or-ctrl-shift': new Map<string, AppCommandId>(),
@@ -353,6 +387,43 @@ export function isNativeMenuCommandId(value: string): value is AppCommandId {
return NATIVE_MENU_COMMAND_SET.has(value)
}
export function getDeterministicShortcutQaDefinition(
id: AppCommandId,
): AppCommandDeterministicQaDefinition | null {
const definition = APP_COMMAND_DEFINITIONS[id]
if (!definition.shortcut) return null
return {
preferredMode:
definition.preferredShortcutQaMode
?? (definition.menuOwned ? 'native-menu-command' : 'renderer-shortcut-event'),
supportsRendererShortcutEvent: true,
supportsNativeMenuCommand: definition.menuOwned,
requiresManualNativeAcceleratorQa: MANUAL_NATIVE_ACCELERATOR_QA_COMMAND_SET.has(id),
}
}
export function getShortcutEventInit(
id: AppCommandId,
options: AppCommandShortcutEventOptions = {},
): AppCommandShortcutEventInit | null {
const shortcut = APP_COMMAND_DEFINITIONS[id].shortcut
if (!shortcut) return null
const useControl = options.preferControl ?? false
return {
key: shortcut.key,
code: shortcut.code,
altKey: false,
bubbles: true,
cancelable: true,
ctrlKey: useControl,
metaKey: !useControl,
shiftKey: shortcut.combo !== 'command-or-ctrl',
}
}
export function shortcutCombosForEvent({
altKey,
ctrlKey,

View File

@@ -10,6 +10,10 @@ import {
resetAppCommandDispatchStateForTests,
type AppCommandHandlers,
} from './appCommandDispatcher'
import {
getDeterministicShortcutQaDefinition,
getShortcutEventInit,
} from './appCommandCatalog'
function makeHandlers(): AppCommandHandlers {
return {
@@ -73,6 +77,45 @@ describe('appCommandDispatcher', () => {
expect(findShortcutCommandId('command-shift', '¬', 'KeyL')).toBe(APP_COMMAND_IDS.viewToggleAiChat)
})
it('gives every shortcut command an explicit deterministic QA strategy', () => {
expect(getDeterministicShortcutQaDefinition(APP_COMMAND_IDS.fileNewNote)).toMatchObject({
preferredMode: 'native-menu-command',
supportsRendererShortcutEvent: true,
supportsNativeMenuCommand: true,
requiresManualNativeAcceleratorQa: true,
})
expect(getDeterministicShortcutQaDefinition(APP_COMMAND_IDS.viewToggleProperties)).toMatchObject({
preferredMode: 'renderer-shortcut-event',
supportsRendererShortcutEvent: true,
supportsNativeMenuCommand: true,
requiresManualNativeAcceleratorQa: false,
})
expect(getDeterministicShortcutQaDefinition(APP_COMMAND_IDS.noteToggleFavorite)).toMatchObject({
preferredMode: 'renderer-shortcut-event',
supportsRendererShortcutEvent: true,
supportsNativeMenuCommand: false,
requiresManualNativeAcceleratorQa: true,
})
})
it('builds deterministic keyboard events from the shared shortcut manifest', () => {
expect(getShortcutEventInit(APP_COMMAND_IDS.viewToggleAiChat)).toMatchObject({
key: 'l',
code: 'KeyL',
metaKey: true,
ctrlKey: false,
shiftKey: true,
})
expect(getShortcutEventInit(APP_COMMAND_IDS.viewToggleAiChat, { preferControl: true })).toMatchObject({
key: 'l',
code: 'KeyL',
metaKey: false,
ctrlKey: true,
shiftKey: true,
})
expect(getShortcutEventInit(APP_COMMAND_IDS.appCheckForUpdates)).toBeNull()
})
it('resolves event modifiers through the shared shortcut catalog', () => {
expect(
findShortcutCommandIdForEvent({

View File

@@ -35,7 +35,7 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
} = config
return [
{ id: 'create-note', label: 'Create New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'add'], enabled: true, execute: onCreateNote },
{ id: 'create-note', label: 'New Note', group: 'Note', shortcut: '⌘N', keywords: ['new', 'create', 'add'], enabled: true, execute: onCreateNote },
{ id: 'create-type', label: 'New Type', group: 'Note', keywords: ['new', 'create', 'type', 'template'], enabled: !!onCreateType, execute: () => onCreateType?.() },
{ id: 'open-daily-note', label: "Open Today's Note", group: 'Note', shortcut: '⌘J', keywords: ['daily', 'journal', 'today'], enabled: true, execute: onOpenDailyNote },
{ id: 'save-note', label: 'Save Note', group: 'Note', shortcut: '⌘S', keywords: ['write'], enabled: hasActiveNote, execute: onSave },

View File

@@ -7,6 +7,15 @@ const PLURAL_OVERRIDES: Record<string, string> = {
}
const DEFAULT_TYPES = ['Event', 'Person', 'Project', 'Note']
const DEFAULT_TYPE_CANONICAL_CASE = new Map(
DEFAULT_TYPES.map(type => [type.toLowerCase(), type] as const),
)
function canonicalizeTypeName(type: string): string | null {
const trimmedType = type.trim()
if (!trimmedType) return null
return DEFAULT_TYPE_CANONICAL_CASE.get(trimmedType.toLowerCase()) ?? trimmedType
}
export function pluralizeType(type: string): string {
if (PLURAL_OVERRIDES[type]) return PLURAL_OVERRIDES[type]
@@ -16,16 +25,26 @@ export function pluralizeType(type: string): string {
}
export function extractVaultTypes(entries: VaultEntry[]): string[] {
const typeSet = new Set<string>()
const typeMap = new Map<string, string>()
for (const e of entries) {
if (e.isA === 'Type' && e.title) {
typeSet.add(e.title)
} else if (e.isA && e.isA !== 'Type') {
typeSet.add(e.isA)
const rawType =
e.isA === 'Type'
? e.title
: e.isA && e.isA !== 'Type'
? e.isA
: null
if (!rawType) continue
const canonicalType = canonicalizeTypeName(rawType)
if (!canonicalType) continue
const typeKey = canonicalType.toLowerCase()
if (!typeMap.has(typeKey)) {
typeMap.set(typeKey, canonicalType)
}
}
if (typeSet.size === 0) return DEFAULT_TYPES
return Array.from(typeSet).sort()
if (typeMap.size === 0) return DEFAULT_TYPES
return Array.from(typeMap.values()).sort()
}
export function buildTypeCommands(
@@ -34,19 +53,27 @@ export function buildTypeCommands(
onSelect: (sel: SidebarSelection) => void,
): CommandAction[] {
return types.flatMap((type) => {
const slug = type.toLowerCase().replace(/\s+/g, '-')
const plural = pluralizeType(type)
return [
{
id: `new-${slug}`, label: `New ${type}`, group: 'Note' as const,
keywords: ['new', 'create', type.toLowerCase()],
enabled: true, execute: () => onCreateNoteOfType(type),
},
{
id: `list-${slug}`, label: `List ${plural}`, group: 'Navigation' as const,
keywords: ['list', 'show', 'filter', type.toLowerCase(), plural.toLowerCase()],
enabled: true, execute: () => onSelect({ kind: 'sectionGroup', type }),
},
]
const canonicalType = canonicalizeTypeName(type)
if (!canonicalType) return []
const slug = canonicalType.toLowerCase().replace(/\s+/g, '-')
const plural = pluralizeType(canonicalType)
const commands: CommandAction[] = []
if (canonicalType.toLowerCase() !== 'note') {
commands.push({
id: `new-${slug}`, label: `New ${canonicalType}`, group: 'Note' as const,
keywords: ['new', 'create', canonicalType.toLowerCase()],
enabled: true, execute: () => onCreateNoteOfType(canonicalType),
})
}
commands.push({
id: `list-${slug}`, label: `List ${plural}`, group: 'Navigation' as const,
keywords: ['list', 'show', 'filter', canonicalType.toLowerCase(), plural.toLowerCase()],
enabled: true, execute: () => onSelect({ kind: 'sectionGroup', type: canonicalType }),
})
return commands
})
}

View File

@@ -0,0 +1,118 @@
const ROOT_EDITABLE_SELECTOR = '.ProseMirror[contenteditable="true"]'
const FALLBACK_EDITABLE_SELECTOR = '.bn-editor [contenteditable="true"]'
const MAX_FOCUS_ATTEMPTS = 12
interface TiptapChain {
setTextSelection: (pos: { from: number; to: number }) => TiptapChain
run: () => void
}
export interface TiptapEditor {
state: { doc: { descendants: (cb: (node: { type: { name: string }; nodeSize: number }, pos: number) => boolean | void) => void } }
chain: () => TiptapChain
}
export interface FocusableEditor {
focus: () => void
_tiptapEditor?: TiptapEditor
}
/** Select all text in the first heading block via the TipTap chain API. */
function selectFirstHeading(editor: FocusableEditor): void {
const tiptap = editor._tiptapEditor
if (!tiptap?.state?.doc) return
let from = -1
let to = -1
tiptap.state.doc.descendants((node, pos) => {
if (from !== -1) return false
if (node.type.name === 'heading') {
from = pos + 1
to = pos + node.nodeSize - 1
return false
}
})
if (from === -1 || to === -1 || from > to) return
tiptap.chain().setTextSelection({ from, to }).run()
}
function hasEditableFocus(): boolean {
const active = document.activeElement as HTMLElement | null
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
}
function canFocusWindow(): boolean {
return !navigator.userAgent.toLowerCase().includes('jsdom')
}
function focusEditableCandidate(editable: HTMLElement): boolean {
if (canFocusWindow()) {
window.focus?.()
}
editable.focus()
if (hasEditableFocus()) return true
const selection = window.getSelection()
if (selection && editable.isContentEditable) {
const range = document.createRange()
range.selectNodeContents(editable)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
editable.focus()
}
return hasEditableFocus()
}
function focusEditableNode(): boolean {
const rootEditable = document.querySelector<HTMLElement>(ROOT_EDITABLE_SELECTOR)
if (rootEditable && focusEditableCandidate(rootEditable)) {
return true
}
const fallbackEditable = document.querySelector<HTMLElement>(FALLBACK_EDITABLE_SELECTOR)
if (fallbackEditable && focusEditableCandidate(fallbackEditable)) {
return true
}
return false
}
function logFocusTiming(t0: number | undefined, label: 'focus' | 'focus+select'): void {
if (!t0) return
console.debug(`[perf] createNote → ${label}: ${(performance.now() - t0).toFixed(1)}ms`)
}
export function focusEditorWithRetries(
editor: FocusableEditor,
selectTitle: boolean,
t0: number | undefined,
attempt = 0,
): void {
editor.focus()
if (!hasEditableFocus()) {
focusEditableNode()
}
if (!hasEditableFocus() && attempt < MAX_FOCUS_ATTEMPTS) {
requestAnimationFrame(() => focusEditorWithRetries(editor, selectTitle, t0, attempt + 1))
return
}
if (!selectTitle) {
logFocusTiming(t0, 'focus')
return
}
// Defer selection to the next animation frame so the new note's content
// (applied via queueMicrotask inside a React effect triggered by the tab
// change) is in the document before we try to select the heading.
// Between two rAF callbacks, all pending macrotasks — including React's
// MessageChannel re-render and the subsequent queueMicrotask content swap
// — complete, so the heading block is guaranteed to exist by rAF 2.
requestAnimationFrame(() => {
selectFirstHeading(editor)
logFocusTiming(t0, 'focus+select')
})
}

View File

@@ -8,7 +8,7 @@
* Response text accumulates internally and is revealed as a complete block on done.
*
* Detects file operations (Write/Edit/Bash) and notifies the parent via callbacks
* so the Laputa UI can auto-open new notes and live-refresh modified notes.
* so the Tolaria UI can auto-open new notes and live-refresh modified notes.
*/
import { useState, useCallback, useRef, useEffect } from 'react'
import type { AiAction } from '../components/AiMessage'
@@ -316,7 +316,7 @@ function formatToolLabel(toolName: string, input?: string): string {
break
}
// Laputa MCP tools
// Tolaria MCP tools
const mcpLabels: Record<string, string> = {
search_notes: 'Searching notes',
get_vault_context: 'Loading vault context',

View File

@@ -219,6 +219,24 @@ describe('useCommandRegistry', () => {
cmd!.execute()
expect(onOpenFeedback).toHaveBeenCalledOnce()
})
it('keeps a single canonical New Note command when generic note types are present', () => {
const config = makeConfig({
entries: [
{ path: '/type-note.md', title: 'Note', isA: 'Type' },
{ path: '/lowercase-note.md', title: 'lowercase-note', isA: 'note' },
],
})
const { result } = renderHook(() => useCommandRegistry(config))
const newNoteCommands = result.current.filter(command => command.label.toLowerCase() === 'new note')
expect(newNoteCommands).toHaveLength(1)
expect(newNoteCommands[0]).toMatchObject({
id: 'create-note',
shortcut: '⌘N',
})
})
})
describe('pluralizeType', () => {
@@ -274,6 +292,15 @@ describe('extractVaultTypes', () => {
expect(types).toHaveLength(2)
})
it('deduplicates default types case-insensitively and keeps canonical casing', () => {
const entries = [
{ path: '/note-type.md', title: 'note', isA: 'Type' },
{ path: '/note-instance.md', title: 'Example', isA: 'Note' },
{ path: '/project-instance.md', title: 'Project Plan', isA: 'project' },
] as never[]
expect(extractVaultTypes(entries)).toEqual(['Note', 'Project'])
})
})
describe('groupSortKey', () => {
@@ -396,4 +423,16 @@ describe('buildTypeCommands', () => {
expect(commands[2].id).toBe('new-event')
expect(commands[3].id).toBe('list-event')
})
it('omits the generic Note create command while keeping navigation for notes', () => {
const onCreateNoteOfType = vi.fn()
const onSelect = vi.fn()
const commands = buildTypeCommands(['Note', 'Project'], onCreateNoteOfType, onSelect)
expect(commands.map(command => command.id)).toEqual([
'list-note',
'new-project',
'list-project',
])
})
})

View File

@@ -2,10 +2,10 @@ import { describe, it, expect, vi, afterEach } from 'vitest'
import { renderHook } from '@testing-library/react'
import { useEditorFocus } from './useEditorFocus'
function makeTiptapMock(hasHeading = true) {
function makeTiptapMock(hasHeading = true, headingNodeSize = 15) {
const chainResult = { setTextSelection: vi.fn().mockReturnThis(), run: vi.fn() }
const descendantsMock = vi.fn().mockImplementation((cb: (node: { type: { name: string }; nodeSize: number }, pos: number) => boolean | void) => {
if (hasHeading) cb({ type: { name: 'heading' }, nodeSize: 15 }, 2)
if (hasHeading) cb({ type: { name: 'heading' }, nodeSize: headingNodeSize }, 2)
})
return {
state: { doc: { descendants: descendantsMock } },
@@ -16,14 +16,21 @@ function makeTiptapMock(hasHeading = true) {
}
describe('useEditorFocus', () => {
afterEach(() => { vi.restoreAllMocks() })
afterEach(() => {
vi.restoreAllMocks()
document.body.innerHTML = ''
})
function setup(isMounted: boolean, tiptap?: ReturnType<typeof makeTiptapMock>) {
const editable = document.createElement('div')
editable.setAttribute('contenteditable', 'true')
editable.tabIndex = -1
document.body.appendChild(editable)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
const editor = { focus: vi.fn(), _tiptapEditor: tiptap } as any
const editor = { focus: vi.fn(() => editable.focus()), _tiptapEditor: tiptap } as any
const mountedRef = { current: isMounted }
renderHook(() => useEditorFocus(editor, mountedRef))
return { editor, tiptap }
return { editor, tiptap, editable }
}
it('focuses editor via rAF when already mounted', async () => {
@@ -84,8 +91,12 @@ describe('useEditorFocus', () => {
})
it('cleans up event listener on unmount', () => {
const editable = document.createElement('div')
editable.setAttribute('contenteditable', 'true')
editable.tabIndex = -1
document.body.appendChild(editable)
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
const editor = { focus: vi.fn() } as any
const editor = { focus: vi.fn(() => editable.focus()) } as any
const mountedRef = { current: true }
const { unmount } = renderHook(() => useEditorFocus(editor, mountedRef))
@@ -96,6 +107,25 @@ describe('useEditorFocus', () => {
expect(editor.focus).not.toHaveBeenCalled()
})
it('falls back to focusing the editable DOM node when editor.focus does not make it active', () => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const editable = document.createElement('div')
editable.className = 'ProseMirror'
editable.setAttribute('contenteditable', 'true')
editable.tabIndex = -1
document.body.appendChild(editable)
const editableFocus = vi.spyOn(editable, 'focus')
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- minimal mock for test
const editor = { focus: vi.fn(), _tiptapEditor: undefined } as any
const mountedRef = { current: true }
renderHook(() => useEditorFocus(editor, mountedRef))
window.dispatchEvent(new CustomEvent('laputa:focus-editor'))
expect(editor.focus).toHaveBeenCalled()
expect(editableFocus).toHaveBeenCalled()
})
describe('selectTitle behavior', () => {
it('selects H1 text when selectTitle is true and editor is mounted', () => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
@@ -192,5 +222,18 @@ describe('useEditorFocus', () => {
expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 16 })
expect(tiptap._chainResult.run).toHaveBeenCalled()
})
it('collapses selection to the caret for an empty H1', () => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const tiptap = makeTiptapMock(true, 2)
const { editor } = setup(true, tiptap)
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
expect(editor.focus).toHaveBeenCalled()
expect(tiptap.chain).toHaveBeenCalled()
expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 3 })
expect(tiptap._chainResult.run).toHaveBeenCalled()
})
})
})

View File

@@ -1,38 +1,54 @@
import { useEffect } from 'react'
import { focusEditorWithRetries, type FocusableEditor } from './editorFocusUtils'
const TAB_SWAP_EVENT_NAME = 'laputa:editor-tab-swapped'
const FOCUS_EVENT_NAME = 'laputa:focus-editor'
const SWAP_WAIT_FALLBACK_MS = 250
interface TiptapChain {
setTextSelection: (pos: { from: number; to: number }) => TiptapChain
run: () => void
interface FocusEventDetail {
t0?: number
selectTitle?: boolean
path?: string | null
}
interface TiptapEditor {
state: { doc: { descendants: (cb: (node: { type: { name: string }; nodeSize: number }, pos: number) => boolean | void) => void } }
chain: () => TiptapChain
function scheduleEditorFocus(
editor: FocusableEditor,
editorMountedRef: React.RefObject<boolean>,
selectTitle: boolean,
t0: number | undefined,
): void {
if (editorMountedRef.current) {
requestAnimationFrame(() => focusEditorWithRetries(editor, selectTitle, t0))
return
}
setTimeout(() => focusEditorWithRetries(editor, selectTitle, t0), 80)
}
/** Select all text in the first heading block via the TipTap chain API. */
function selectFirstHeading(editor: { _tiptapEditor?: TiptapEditor }): void {
const tiptap = editor._tiptapEditor
if (!tiptap?.state?.doc) return
function registerPendingTabFocus(
targetPath: string,
scheduleFocus: () => void,
pendingCleanups: Set<() => void>,
): void {
const handleTabSwap = (event: Event) => {
const swapPath = (event as CustomEvent).detail?.path
if (swapPath !== targetPath) return
cleanupPending()
scheduleFocus()
}
let from = -1
let to = -1
const fallbackTimer = window.setTimeout(() => {
cleanupPending()
scheduleFocus()
}, SWAP_WAIT_FALLBACK_MS)
tiptap.state.doc.descendants((node, pos) => {
if (from !== -1) return false
if (node.type.name === 'heading') {
from = pos + 1
to = pos + node.nodeSize - 1
return false
}
})
const cleanupPending = () => {
window.clearTimeout(fallbackTimer)
window.removeEventListener(TAB_SWAP_EVENT_NAME, handleTabSwap)
pendingCleanups.delete(cleanupPending)
}
if (from === -1 || from >= to) return
tiptap.chain().setTextSelection({ from, to }).run()
pendingCleanups.add(cleanupPending)
window.addEventListener(TAB_SWAP_EVENT_NAME, handleTabSwap)
}
/**
@@ -42,68 +58,24 @@ function selectFirstHeading(editor: { _tiptapEditor?: TiptapEditor }): void {
* When selectTitle is true, also selects all text in the first H1 block.
*/
export function useEditorFocus(
editor: { focus: () => void; _tiptapEditor?: TiptapEditor },
editor: FocusableEditor,
editorMountedRef: React.RefObject<boolean>,
) {
useEffect(() => {
const pendingCleanups = new Set<() => void>()
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail as { t0?: number; selectTitle?: boolean; path?: string | null } | undefined
const detail = (e as CustomEvent).detail as FocusEventDetail | undefined
const t0 = detail?.t0
const selectTitle = detail?.selectTitle ?? false
const targetPath = detail?.path ?? null
const doFocus = () => {
editor.focus()
if (!selectTitle) {
if (t0) console.debug(`[perf] createNote → focus: ${(performance.now() - t0).toFixed(1)}ms`)
return
}
// Defer selection to the next animation frame so the new note's content
// (applied via queueMicrotask inside a React effect triggered by the tab
// change) is in the document before we try to select the heading.
// Between two rAF callbacks, all pending macrotasks — including React's
// MessageChannel re-render and the subsequent queueMicrotask content swap
// — complete, so the heading block is guaranteed to exist by rAF 2.
requestAnimationFrame(() => {
selectFirstHeading(editor)
if (t0) console.debug(`[perf] createNote → focus+select: ${(performance.now() - t0).toFixed(1)}ms`)
})
}
const scheduleFocus = () => {
if (editorMountedRef.current) {
requestAnimationFrame(doFocus)
return
}
setTimeout(doFocus, 80)
}
const scheduleFocus = () => scheduleEditorFocus(editor, editorMountedRef, selectTitle, t0)
if (!targetPath) {
scheduleFocus()
return
}
const handleTabSwap = (event: Event) => {
const swapPath = (event as CustomEvent).detail?.path
if (swapPath !== targetPath) return
cleanupPending()
scheduleFocus()
}
const fallbackTimer = window.setTimeout(() => {
cleanupPending()
scheduleFocus()
}, SWAP_WAIT_FALLBACK_MS)
const cleanupPending = () => {
window.clearTimeout(fallbackTimer)
window.removeEventListener(TAB_SWAP_EVENT_NAME, handleTabSwap)
pendingCleanups.delete(cleanupPending)
}
pendingCleanups.add(cleanupPending)
window.addEventListener(TAB_SWAP_EVENT_NAME, handleTabSwap)
registerPendingTabFocus(targetPath, scheduleFocus, pendingCleanups)
}
window.addEventListener(FOCUS_EVENT_NAME, handler)

View File

@@ -51,6 +51,11 @@ describe('extractEditorBody', () => {
const content = '---\ntitle: My Project\ntype: Project\nstatus: Active\n---\n\n# My Project\n\n'
expect(extractEditorBody(content)).toBe('# My Project\n\n')
})
it('preserves an empty H1 for untitled-note content', () => {
const content = '---\ntype: Note\nstatus: Active\n---\n\n# \n\n'
expect(extractEditorBody(content)).toBe('# \n\n')
})
})
describe('getH1TextFromBlocks', () => {
@@ -167,7 +172,14 @@ function makeTab(path: string, title: string) {
}
}
function makeUntitledTab(path: string, title = 'Untitled Note 1') {
function makeUntitledTab(path: string, title = 'Untitled Note 1', remainder = '') {
return {
entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never,
content: `---\ntype: Note\nstatus: Active\n---\n\n# \n\n${remainder}`,
}
}
function makeBlankBodyTab(path: string, title = 'Untitled Note 1') {
return {
entry: { path, title, filename: `${title}.md`, type: 'Note', status: 'Active', aliases: [], isA: '' } as never,
content: '---\ntype: Note\nstatus: Active\n---\n',
@@ -270,7 +282,7 @@ describe('useEditorTabSwap raw mode sync', () => {
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const populatedTab = makeTab('a.md', 'Note A')
const untitledTab = makeUntitledTab('untitled.md')
const untitledTab = makeBlankBodyTab('untitled.md')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
@@ -290,6 +302,109 @@ describe('useEditorTabSwap raw mode sync', () => {
expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
})
it('renders empty H1 untitled notes via TipTap HTML content', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const populatedTab = makeTab('a.md', 'Note A')
const untitledTab = makeUntitledTab('untitled.md')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
mockEditor.tryParseMarkdownToBlocks.mockClear()
mockEditor.replaceBlocks.mockClear()
mockEditor._tiptapEditor.commands.setContent.mockClear()
rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
expect(mockEditor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<h1></h1><p></p>')
})
it('renders empty H1 typed notes with template content under the title', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
mockEditor.blocksToHTMLLossy.mockReturnValue('<h2>Objective</h2><p></p>')
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const populatedTab = makeTab('a.md', 'Note A')
const typedUntitledTab = makeUntitledTab('untitled.md', 'Untitled Project 1', '## Objective\n\n')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
mockEditor.tryParseMarkdownToBlocks.mockClear()
mockEditor._tiptapEditor.commands.setContent.mockClear()
rerender({ tabs: [typedUntitledTab], activeTabPath: 'untitled.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith('## Objective\n\n')
expect(mockEditor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<h1></h1><h2>Objective</h2><p></p>')
})
it('ignores editor change events before the pending tab swap applies a new untitled note', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const onContentChange = vi.fn()
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const populatedTab = makeTab('a.md', 'Note A')
const untitledTab = makeUntitledTab('untitled.md')
const { result, rerender } = renderHook(
({ tabs, activeTabPath }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, onContentChange,
}),
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md' } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
const queued: Array<() => void> = []
vi.spyOn(globalThis, 'queueMicrotask').mockImplementation((cb: VoidFunction) => {
queued.push(cb)
})
rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md' })
expect(queued).toHaveLength(1)
act(() => {
result.current.handleEditorChange()
})
expect(onContentChange).not.toHaveBeenCalled()
await act(async () => {
queued.shift()?.()
await Promise.resolve()
})
})
it('re-parses from tab.content when rawMode transitions from true to false', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

View File

@@ -76,11 +76,20 @@ function cacheEditorState(
}
function buildFastPathBlocks(preprocessed: string): EditorBlocks | null {
if (!preprocessed.trim()) {
const trimmed = preprocessed.trim()
if (!trimmed) {
return [{ type: 'paragraph', content: [] }]
}
const h1OnlyMatch = preprocessed.trim().match(/^# (.+)$/)
if (trimmed === '#') {
return [
{ type: 'heading', props: { level: 1, textColor: 'default', backgroundColor: 'default', textAlignment: 'left' }, content: [], children: [] },
{ type: 'paragraph', content: [], children: [] },
]
}
const h1OnlyMatch = trimmed.match(/^# (.+)$/)
if (!h1OnlyMatch) return null
return [
@@ -93,6 +102,21 @@ function isBlankBodyContent(content: string): boolean {
return extractEditorBody(content).trim() === ''
}
function extractBodyRemainderAfterEmptyH1(content: string): string | null {
const body = extractEditorBody(content)
const [firstLine, secondLine, ...rest] = body.split('\n')
if (!firstLine) return null
const normalizedFirstLine = firstLine.trimEnd()
if (normalizedFirstLine !== '#' && normalizedFirstLine !== '# ') return null
if (secondLine === '') {
return rest.join('\n').trimStart()
}
return [secondLine, ...rest].join('\n').trimStart()
}
function blankParagraphBlocks(): EditorBlocks {
return [{ type: 'paragraph', content: [], children: [] }]
}
@@ -187,6 +211,40 @@ function applyBlankStateToEditor(
})
}
function applyHtmlStateToEditor(
editor: ReturnType<typeof useCreateBlockNote>,
html: string,
suppressChangeRef: MutableRefObject<boolean>,
) {
suppressChangeRef.current = true
try {
editor._tiptapEditor.commands.setContent(html)
} catch (err) {
console.error('applyHtmlStateToEditor failed:', err)
suppressChangeRef.current = false
throw err
}
queueMicrotask(() => { suppressChangeRef.current = false })
requestAnimationFrame(() => {
const scrollEl = document.querySelector('.editor__blocknote-container')
if (scrollEl) scrollEl.scrollTop = 0
})
}
async function resolveEmptyHeadingHtml(
editor: ReturnType<typeof useCreateBlockNote>,
content: string,
): Promise<string | null> {
const remainder = extractBodyRemainderAfterEmptyH1(content)
if (remainder === null) return null
if (!remainder.trim()) return '<h1></h1><p></p>'
const parsed = await parseMarkdownBlocks(editor, preProcessWikilinks(remainder))
const withWikilinks = injectWikilinks(parsed)
return `<h1></h1>${editor.blocksToHTMLLossy(withWikilinks as typeof parsed)}`
}
function findActiveTab(tabs: Tab[], activeTabPath: string | null): Tab | undefined {
return activeTabPath
? tabs.find(tab => tab.entry.path === activeTabPath)
@@ -350,8 +408,13 @@ function scheduleTabSwap(options: {
suppressChangeRef,
} = options
suppressChangeRef.current = true
const doSwap = () => {
if (prevActivePathRef.current !== targetPath) return
if (prevActivePathRef.current !== targetPath) {
suppressChangeRef.current = false
return
}
rawSwapPendingRef.current = false
if (isBlankBodyContent(activeTab.content)) {
@@ -361,6 +424,21 @@ function scheduleTabSwap(options: {
return
}
void resolveEmptyHeadingHtml(editor, activeTab.content)
.then((html) => {
if (prevActivePathRef.current !== targetPath || !html) return
applyHtmlStateToEditor(editor, html, suppressChangeRef)
requestAnimationFrame(() => signalEditorTabSwapped(targetPath))
})
.catch((err: unknown) => {
suppressChangeRef.current = false
console.error('Failed to render empty heading state:', err)
})
if (extractBodyRemainderAfterEmptyH1(activeTab.content) !== null) {
return
}
void resolveBlocksForTarget(editor, cache, targetPath, activeTab.content)
.then(({ blocks, scrollTop }) => {
if (prevActivePathRef.current !== targetPath) return
@@ -368,6 +446,7 @@ function scheduleTabSwap(options: {
requestAnimationFrame(() => signalEditorTabSwapped(targetPath))
})
.catch((err: unknown) => {
suppressChangeRef.current = false
console.error('Failed to parse/swap editor content:', err)
})
}

View File

@@ -1,5 +1,5 @@
/**
* Hook for communicating with the Laputa MCP WebSocket bridge.
* Hook for communicating with the Tolaria MCP WebSocket bridge.
*
* Provides typed tool invocations for vault operations:
* - readNote, createNote, searchNotes, appendToNote

View File

@@ -91,7 +91,7 @@ describe('useMcpStatus', () => {
})
expect(result.current.mcpStatus).toBe('installed')
expect(onToast).toHaveBeenCalledWith('MCP server installed successfully')
expect(onToast).toHaveBeenCalledWith('Tolaria MCP server installed successfully')
})
it('install action shows error toast on failure', async () => {
@@ -127,7 +127,7 @@ describe('useMcpStatus', () => {
renderHook(() => useMcpStatus('/vault', onToast))
await waitFor(() => {
expect(onToast).toHaveBeenCalledWith('Laputa registered as MCP tool for Claude Code')
expect(onToast).toHaveBeenCalledWith('Tolaria registered as MCP tool for Claude Code')
})
})
@@ -155,7 +155,7 @@ describe('useMcpStatus', () => {
})
expect(result.current.mcpStatus).toBe('installed')
expect(onToast).toHaveBeenCalledWith('MCP server restored successfully')
expect(onToast).toHaveBeenCalledWith('Tolaria MCP server restored successfully')
})
it('does not show toast when already registered', async () => {
@@ -173,6 +173,6 @@ describe('useMcpStatus', () => {
})
// 'updated' should not trigger a toast
expect(onToast).not.toHaveBeenCalledWith('Laputa registered as MCP tool for Claude Code')
expect(onToast).not.toHaveBeenCalledWith('Tolaria registered as MCP tool for Claude Code')
})
})

View File

@@ -49,7 +49,7 @@ export function useMcpStatus(
tauriCall<string>('register_mcp_tools', { vaultPath })
.then((result) => {
if (result === 'registered') {
onToastRef.current('Laputa registered as MCP tool for Claude Code')
onToastRef.current('Tolaria registered as MCP tool for Claude Code')
}
setStatus('installed')
})
@@ -64,7 +64,7 @@ export function useMcpStatus(
try {
await tauriCall<string>('register_mcp_tools', { vaultPath })
setStatus('installed')
onToastRef.current(wasInstalled ? 'MCP server restored successfully' : 'MCP server installed successfully')
onToastRef.current(wasInstalled ? 'Tolaria MCP server restored successfully' : 'Tolaria MCP server installed successfully')
} catch (e) {
setStatus('not_installed')
onToastRef.current(`MCP install failed: ${e}`)

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react'
import { isTauri } from '../mock-tauri'
import type { AppCommandShortcutEventInit, AppCommandShortcutEventOptions } from './appCommandCatalog'
import {
APP_COMMAND_EVENT_NAME,
executeAppCommand,
@@ -11,8 +12,10 @@ 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
}
}
}

View File

@@ -211,6 +211,22 @@ describe('buildNoteContent', () => {
const content = buildNoteContent({ title: 'My Note', type: 'Note', status: 'Active', template: null })
expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n')
})
it('prepends an empty H1 for untitled-note creation flows', () => {
const content = buildNoteContent({ title: null, type: 'Note', status: 'Active', initialEmptyHeading: true })
expect(content).toBe('---\ntype: Note\nstatus: Active\n---\n\n# \n\n')
})
it('keeps the empty H1 ahead of templates for typed untitled notes', () => {
const content = buildNoteContent({
title: null,
type: 'Project',
status: 'Active',
template: '## Objective\n\n## Notes\n\n',
initialEmptyHeading: true,
})
expect(content).toBe('---\ntype: Project\nstatus: Active\n---\n\n# \n\n## Objective\n\n## Notes\n\n')
})
})
describe('resolveTemplate', () => {

View File

@@ -132,6 +132,21 @@ describe('buildNoteContent', () => {
const content = buildNoteContent({ title: 'P', type: 'Project', status: 'Active', template: '## Objective\n\n' })
expect(content).toContain('## Objective')
})
it('prepends an empty H1 when requested for untitled-note flows', () => {
expect(buildNoteContent({ title: null, type: 'Note', status: 'Active', initialEmptyHeading: true })).toBe('---\ntype: Note\nstatus: Active\n---\n\n# \n\n')
})
it('keeps the empty H1 before any template content', () => {
const content = buildNoteContent({
title: null,
type: 'Project',
status: 'Active',
template: '## Objective\n\n',
initialEmptyHeading: true,
})
expect(content).toBe('---\ntype: Project\nstatus: Active\n---\n\n# \n\n## Objective\n\n')
})
})
describe('resolveNewNote', () => {
@@ -247,6 +262,7 @@ describe('useNoteCreation hook', () => {
expect(addEntry).toHaveBeenCalledTimes(1)
expect(addEntry.mock.calls[0][0].title).toBe('Untitled Note 1700000000')
expect(addEntry.mock.calls[0][0].filename).toBe('untitled-note-1700000000.md')
expect(openTabWithContent.mock.calls[0][1]).toBe('---\ntype: Note\nstatus: Active\n---\n\n# \n\n')
vi.restoreAllMocks()
})
@@ -352,6 +368,7 @@ describe('useNoteCreation hook', () => {
expect(focusListener).toHaveBeenCalledTimes(1)
const event = focusListener.mock.calls[0][0] as CustomEvent
expect(event.detail.path).toMatch(/\/test\/vault\/untitled-note-\d+\.md$/)
expect(event.detail.selectTitle).toBe(true)
window.removeEventListener('laputa:focus-editor', focusListener)
})

View File

@@ -89,15 +89,23 @@ export interface NoteContentParams {
type: string
status: string | null
template?: string | null
initialEmptyHeading?: boolean
}
export function buildNoteContent({ title, type, status, template }: NoteContentParams): string {
function buildNoteBody({ template, initialEmptyHeading }: Pick<NoteContentParams, 'template' | 'initialEmptyHeading'>): string {
if (initialEmptyHeading) {
return template ? `\n# \n\n${template}` : '\n# \n\n'
}
return template ? `\n${template}` : ''
}
export function buildNoteContent({ title, type, status, template, initialEmptyHeading = false }: NoteContentParams): string {
const lines = ['---']
if (title) lines.push(`title: ${title}`)
lines.push(`type: ${type}`)
if (status) lines.push(`status: ${status}`)
lines.push('---')
const body = template ? `\n${template}` : ''
const body = buildNoteBody({ template, initialEmptyHeading })
return `${lines.join('\n')}\n${body}`
}
@@ -246,12 +254,12 @@ function createNoteImmediate(deps: ImmediateCreateDeps, type?: string): void {
const template = resolveTemplate({ entries: deps.entries, typeName: noteType })
const status = NO_STATUS_TYPES.has(noteType) ? null : 'Active'
const entry = buildNewEntry({ path: `${deps.vaultPath}/${slug}.md`, slug, title, type: noteType, status })
const content = buildNoteContent({ title: null, type: noteType, status, template })
const content = buildNoteContent({ title: null, type: noteType, status, template, initialEmptyHeading: true })
deps.openTabWithContent(entry, content)
addEntryWithMock(entry, content, deps.addEntry)
deps.trackUnsaved?.(entry.path)
deps.markContentPending?.(entry.path, content)
signalFocusEditor({ path: entry.path })
signalFocusEditor({ path: entry.path, selectTitle: true })
}
interface RelationshipCreateDeps {

Some files were not shown because too many files have changed in this diff Show More