Compare commits
13 Commits
v0.2026041
...
v0.2026041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
867e97b199 | ||
|
|
2441518cee | ||
|
|
010bc32ee1 | ||
|
|
361898b187 | ||
|
|
5b5f949c74 | ||
|
|
57a5693922 | ||
|
|
2ca8f1b2a6 | ||
|
|
eb65bb8f05 | ||
|
|
8fb229ede3 | ||
|
|
ce84b34890 | ||
|
|
e98a186389 | ||
|
|
258b54b074 | ||
|
|
f694b9b5e4 |
15
.github/workflows/release-canary.yml
vendored
15
.github/workflows/release-canary.yml
vendored
@@ -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>
|
||||
|
||||
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
90
README.md
90
README.md
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Laputa — Product Vision
|
||||
# Tolaria — Product Vision
|
||||
|
||||
*Written by Brian based on conversations with Luca Rossi, Feb–Mar 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
|
||||
|
||||
@@ -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.
|
||||
35
docs/adr/0054-deterministic-shortcut-qa-matrix.md
Normal file
35
docs/adr/0054-deterministic-shortcut-qa-matrix.md
Normal 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.
|
||||
42
docs/adr/0055-h1-is-the-only-editor-title-surface.md
Normal file
42
docs/adr/0055-h1-is-the-only-editor-title-surface.md
Normal 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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
4
mcp-server/package-lock.json
generated
4
mcp-server/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "laputa-app",
|
||||
"name": "tolaria",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
|
||||
111
src-tauri/Cargo.lock
generated
111
src-tauri/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
laputa_lib::run();
|
||||
tolaria_lib::run();
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'}
|
||||
>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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'}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
=============================================
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -298,7 +298,7 @@ function SettingsBody(props: SettingsBodyProps) {
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Privacy & 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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}, [])
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
68
src/constants/appStorage.ts
Normal file
68
src/constants/appStorage.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
118
src/hooks/editorFocusUtils.ts
Normal file
118
src/hooks/editorFocusUtils.ts
Normal 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')
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user