Compare commits

...

4 Commits

Author SHA1 Message Date
lucaronin
2441518cee fix: restore breadcrumb center drag region 2026-04-12 01:52:31 +02:00
lucaronin
010bc32ee1 style: format Rust rename updates 2026-04-12 01:41:26 +02:00
lucaronin
361898b187 refactor: rename app branding from Laputa to Tolaria 2026-04-12 01:35:34 +02:00
lucaronin
5b5f949c74 fix: initialize new notes with empty h1 focus 2026-04-12 00:58:11 +02:00
90 changed files with 954 additions and 439 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,16 @@
# 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 |
|---|---|---|
@@ -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,7 +233,7 @@ 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.
@@ -495,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
@@ -512,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
@@ -524,7 +524,7 @@ Device Authorization Flow for GitHub-backed vaults:
## Settings
App-level settings persisted at `~/.config/com.laputa.app/settings.json`:
App-level settings persisted at `~/.config/com.tolaria.app/settings.json` (reads legacy `com.laputa.app` on upgrade):
```typescript
interface Settings {

View File

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

View File

@@ -31,7 +31,7 @@ pnpm playwright:regression # Full Playwright regression suite
## Directory Structure
```
laputa-app/
tolaria/
├── src/ # React frontend
│ ├── main.tsx # Entry point (renders <App />)
│ ├── App.tsx # Root component — orchestrates layout + state

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

62
src-tauri/Cargo.lock generated
View File

@@ -2271,37 +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-prevent-default",
"tauri-plugin-process",
"tauri-plugin-updater",
"tempfile",
"tokio",
"uuid",
"walkdir",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -5264,6 +5233,37 @@ dependencies = [
"tokio",
]
[[package]]
name = "tolaria"
version = "0.1.0"
dependencies = [
"base64 0.22.1",
"chrono",
"dirs 5.0.1",
"futures-util",
"gray_matter",
"log",
"mockito",
"regex",
"reqwest 0.12.28",
"sentry",
"serde",
"serde_json",
"serde_yaml",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-log",
"tauri-plugin-opener",
"tauri-plugin-prevent-default",
"tauri-plugin-process",
"tauri-plugin-updater",
"tempfile",
"tokio",
"uuid",
"walkdir",
]
[[package]]
name = "toml"
version = "0.5.11"

View File

@@ -1,5 +1,5 @@
[package]
name = "laputa"
name = "tolaria"
version = "0.1.0"
description = "Personal knowledge and life management app"
authors = ["you"]
@@ -9,7 +9,7 @@ edition = "2021"
rust-version = "1.77.2"
[lib]
name = "laputa_lib"
name = "tolaria_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ fn run_startup_tasks() {
// Seed AGENTS.md and config.md at vault root if missing
vault::seed_config_files(vp_str);
// Register Laputa MCP server in Claude Code and Cursor configs
// Register Tolaria MCP server in Claude Code and Cursor configs
match mcp::register_mcp(vp_str) {
Ok(status) => log::info!("MCP registration: {status}"),
Err(e) => log::warn!("MCP registration failed: {e}"),

View File

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

View File

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

View File

@@ -128,7 +128,7 @@ fn build_app_menu(app: &App) -> MenuResult {
.id(APP_CHECK_FOR_UPDATES)
.build(app)?;
Ok(SubmenuBuilder::new(app, "Laputa")
Ok(SubmenuBuilder::new(app, "Tolaria")
.about(None)
.separator()
.item(&check_updates_item)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -55,6 +55,13 @@ describe('BreadcrumbBar — drag region', () => {
const bar = container.firstElementChild as HTMLElement
expect(bar.dataset.tauriDragRegion).toBeDefined()
})
it('marks the center spacer as a drag region', () => {
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
const spacer = container.querySelector('.breadcrumb-bar__drag-spacer')
expect(spacer).toHaveAttribute('data-tauri-drag-region')
expect(spacer).toHaveAttribute('aria-hidden', 'true')
})
})
describe('BreadcrumbBar — delete', () => {

View File

@@ -529,9 +529,14 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
boxSizing: 'border-box',
}}
>
<div className="breadcrumb-bar__title flex-1 min-w-0">
<div className="breadcrumb-bar__title min-w-0">
<BreadcrumbTitle entry={entry} onRenameFilename={onRenameFilename} />
</div>
<div
aria-hidden="true"
data-tauri-drag-region
className="breadcrumb-bar__drag-spacer min-w-0 flex-1"
/>
<BreadcrumbActions entry={entry} {...actionProps} />
</div>
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
const EDITABLE_SELECTOR = '.bn-editor [contenteditable="true"], .ProseMirror[contenteditable="true"]'
const ROOT_EDITABLE_SELECTOR = '.ProseMirror[contenteditable="true"]'
const FALLBACK_EDITABLE_SELECTOR = '.bn-editor [contenteditable="true"]'
const MAX_FOCUS_ATTEMPTS = 12
interface TiptapChain {
@@ -33,7 +34,7 @@ function selectFirstHeading(editor: FocusableEditor): void {
}
})
if (from === -1 || from >= to) return
if (from === -1 || to === -1 || from > to) return
tiptap.chain().setTextSelection({ from, to }).run()
}
@@ -42,11 +43,43 @@ function hasEditableFocus(): boolean {
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
}
function focusEditableNode(): boolean {
const editable = document.querySelector<HTMLElement>(EDITABLE_SELECTOR)
if (!editable) return false
function canFocusWindow(): boolean {
return !navigator.userAgent.toLowerCase().includes('jsdom')
}
function focusEditableCandidate(editable: HTMLElement): boolean {
if (canFocusWindow()) {
window.focus?.()
}
editable.focus()
return true
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 {

View File

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

View File

@@ -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 } },
@@ -222,5 +222,18 @@ describe('useEditorFocus', () => {
expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 16 })
expect(tiptap._chainResult.run).toHaveBeenCalled()
})
it('collapses selection to the caret for an empty H1', () => {
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const tiptap = makeTiptapMock(true, 2)
const { editor } = setup(true, tiptap)
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
expect(editor.focus).toHaveBeenCalled()
expect(tiptap.chain).toHaveBeenCalled()
expect(tiptap._chainResult.setTextSelection).toHaveBeenCalledWith({ from: 3, to: 3 })
expect(tiptap._chainResult.run).toHaveBeenCalled()
})
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { renderHook, waitFor, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage'
// localStorage mock
const localStorageMock = (() => {
@@ -67,7 +68,7 @@ describe('useOnboarding', () => {
})
it('shows vault-missing when previously dismissed and vault gone', async () => {
localStorage.setItem('laputa_welcome_dismissed', '1')
localStorage.setItem(APP_STORAGE_KEYS.welcomeDismissed, '1')
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
@@ -88,7 +89,7 @@ describe('useOnboarding', () => {
})
it('clears the persisted active vault when the saved path no longer exists', async () => {
localStorage.setItem('laputa_welcome_dismissed', '1')
localStorage.setItem(LEGACY_APP_STORAGE_KEYS.welcomeDismissed, '1')
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
@@ -142,7 +143,7 @@ describe('useOnboarding', () => {
expect(mockInvokeFn).toHaveBeenCalledWith('create_getting_started_vault', {
targetPath: '/mock/Documents/Getting Started',
})
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
expect(localStorage.getItem(APP_STORAGE_KEYS.welcomeDismissed)).toBe('1')
})
it('handleCreateVault does nothing when picker is cancelled', async () => {
@@ -246,7 +247,7 @@ describe('useOnboarding', () => {
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/new/vault' })
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
expect(localStorage.getItem(APP_STORAGE_KEYS.welcomeDismissed)).toBe('1')
})
it('handleCreateNewVault does nothing when picker is cancelled', async () => {
@@ -289,7 +290,7 @@ describe('useOnboarding', () => {
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/selected/folder' })
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
expect(localStorage.getItem(APP_STORAGE_KEYS.welcomeDismissed)).toBe('1')
})
it('handleOpenFolder does nothing when picker is cancelled', async () => {
@@ -331,7 +332,7 @@ describe('useOnboarding', () => {
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/vault/missing' })
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
expect(localStorage.getItem(APP_STORAGE_KEYS.welcomeDismissed)).toBe('1')
})
it('falls back to ready if commands fail', async () => {

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../constants/appStorage'
import { pickFolder } from '../utils/vault-dialog'
type OnboardingState =
@@ -15,8 +16,6 @@ function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
const DISMISSED_KEY = 'laputa_welcome_dismissed'
interface PersistedVaultList {
vaults?: Array<{ label: string; path: string }>
active_vault?: string | null
@@ -25,7 +24,7 @@ interface PersistedVaultList {
function wasDismissed(): boolean {
try {
return localStorage.getItem(DISMISSED_KEY) === '1'
return getAppStorageItem('welcomeDismissed') === '1'
} catch {
return false
}
@@ -33,7 +32,8 @@ function wasDismissed(): boolean {
function markDismissed(): void {
try {
localStorage.setItem(DISMISSED_KEY, '1')
localStorage.setItem(APP_STORAGE_KEYS.welcomeDismissed, '1')
localStorage.removeItem(LEGACY_APP_STORAGE_KEYS.welcomeDismissed)
} catch {
// localStorage may be unavailable in some contexts
}

View File

@@ -165,7 +165,7 @@ describe('useUpdater', () => {
})
expect(mockOpenExternalUrl).toHaveBeenCalledWith(
'https://refactoringhq.github.io/laputa-app/'
'https://refactoringhq.github.io/tolaria/'
)
})

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { isTauri } from '../mock-tauri'
import { openExternalUrl } from '../utils/url'
const RELEASE_NOTES_URL = 'https://refactoringhq.github.io/laputa-app/'
const RELEASE_NOTES_URL = 'https://refactoringhq.github.io/tolaria/'
export type UpdateStatus =
| { state: 'idle' }

View File

@@ -1,4 +1,5 @@
import { useState, useCallback, useEffect } from 'react'
import { getAppStorageItem } from '../constants/appStorage'
import { getVaultConfig, updateVaultConfigField, subscribeVaultConfig } from '../utils/vaultConfigStore'
export type ViewMode = 'editor-only' | 'editor-list' | 'all'
@@ -11,10 +12,8 @@ function loadViewMode(): ViewMode {
const stored = getVaultConfig().view_mode
if (isViewMode(stored)) return stored
// Fallback to localStorage during initial load (before vault config is ready)
try {
const ls = localStorage.getItem('laputa-view-mode')
if (isViewMode(ls)) return ls
} catch { /* ignore */ }
const ls = getAppStorageItem('viewMode')
if (isViewMode(ls)) return ls
return 'all'
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { getAppStorageItem } from '../constants/appStorage'
import { getVaultConfig, updateVaultConfigField, subscribeVaultConfig } from '../utils/vaultConfigStore'
const MIN_ZOOM = 80
@@ -17,13 +18,11 @@ function loadPersistedZoom(): number {
const fromConfig = configToPercent(getVaultConfig().zoom)
if (fromConfig !== null) return fromConfig
// Fallback to localStorage during initial load
try {
const stored = localStorage.getItem('laputa:zoom-level')
if (stored !== null) {
const val = Number(stored)
if (val >= MIN_ZOOM && val <= MAX_ZOOM && val % STEP === 0) return val
}
} catch { /* ignore */ }
const stored = getAppStorageItem('zoom')
if (stored !== null) {
const val = Number(stored)
if (val >= MIN_ZOOM && val <= MAX_ZOOM && val % STEP === 0) return val
}
return DEFAULT_ZOOM
}

View File

@@ -64,7 +64,7 @@ window.__laputaTest = {
}
if (!window.__laputaTest?.dispatchBrowserMenuCommand) {
throw new Error('Laputa test bridge is missing dispatchBrowserMenuCommand')
throw new Error('Tolaria test bridge is missing dispatchBrowserMenuCommand')
}
window.__laputaTest.dispatchBrowserMenuCommand(id)

View File

@@ -315,13 +315,13 @@ export const mockHandlers: Record<string, (args: any) => any> = {
rename_note_filename: handleRenameNoteFilename,
github_list_repos: () => [
{ name: 'laputa-vault', full_name: 'lucaong/laputa-vault', description: 'Personal knowledge vault — markdown + YAML frontmatter', private: true, clone_url: 'https://github.com/lucaong/laputa-vault.git', html_url: 'https://github.com/lucaong/laputa-vault', updated_at: '2026-02-20T10:30:00Z' },
{ name: 'laputa-app', full_name: 'lucaong/laputa-app', description: 'Laputa desktop app — Tauri + React + CodeMirror 6', private: false, clone_url: 'https://github.com/lucaong/laputa-app.git', html_url: 'https://github.com/lucaong/laputa-app', updated_at: '2026-02-19T15:00:00Z' },
{ name: 'tolaria', full_name: 'lucaong/tolaria', description: 'Tolaria desktop app — Tauri + React + CodeMirror 6', private: false, clone_url: 'https://github.com/lucaong/tolaria.git', html_url: 'https://github.com/lucaong/tolaria', updated_at: '2026-02-19T15:00:00Z' },
{ name: 'dotfiles', full_name: 'lucaong/dotfiles', description: 'My macOS dotfiles and config', private: false, clone_url: 'https://github.com/lucaong/dotfiles.git', html_url: 'https://github.com/lucaong/dotfiles', updated_at: '2026-01-15T08:00:00Z' },
{ name: 'notes-archive', full_name: 'lucaong/notes-archive', description: 'Archived notes from 2024', private: true, clone_url: 'https://github.com/lucaong/notes-archive.git', html_url: 'https://github.com/lucaong/notes-archive', updated_at: '2025-12-01T12:00:00Z' },
{ name: 'obsidian-vault', full_name: 'lucaong/obsidian-vault', description: null, private: true, clone_url: 'https://github.com/lucaong/obsidian-vault.git', html_url: 'https://github.com/lucaong/obsidian-vault', updated_at: '2025-11-05T09:00:00Z' },
],
github_create_repo: (args: { name: string; private: boolean }) => ({
name: args.name, full_name: `lucaong/${args.name}`, description: 'Laputa vault', private: args.private,
name: args.name, full_name: `lucaong/${args.name}`, description: 'Tolaria vault', private: args.private,
clone_url: `https://github.com/lucaong/${args.name}.git`, html_url: `https://github.com/lucaong/${args.name}`,
updated_at: new Date().toISOString(),
}),

View File

@@ -12,14 +12,14 @@ import { buildAgentSystemPrompt, streamClaudeAgent } from './ai-agent'
describe('buildAgentSystemPrompt', () => {
it('returns preamble when no vault context', () => {
const prompt = buildAgentSystemPrompt()
expect(prompt).toContain('working inside Laputa')
expect(prompt).toContain('working inside Tolaria')
expect(prompt).toContain('full shell access')
expect(prompt).not.toContain('Vault context')
})
it('appends vault context when provided', () => {
const prompt = buildAgentSystemPrompt('Recent notes: foo, bar')
expect(prompt).toContain('working inside Laputa')
expect(prompt).toContain('working inside Tolaria')
expect(prompt).toContain('Vault context:')
expect(prompt).toContain('Recent notes: foo, bar')
})

View File

@@ -2,7 +2,7 @@
* AI Agent utilities — Claude CLI agent mode with full shell access + MCP vault tools.
*
* The agent has full native tool access (bash, read, write, edit) plus
* Laputa-specific MCP tools (search_notes, get_vault_context, get_note, open_note).
* Tolaria-specific MCP tools (search_notes, get_vault_context, get_note, open_note).
* The frontend receives streaming events for text, tool calls, and completion.
*/
@@ -10,13 +10,13 @@ import { isTauri } from '../mock-tauri'
// --- Agent system prompt ---
const AGENT_SYSTEM_PREAMBLE = `You are working inside Laputa, a personal knowledge management app.
const AGENT_SYSTEM_PREAMBLE = `You are working inside Tolaria, a personal knowledge management app.
Notes are markdown files with YAML frontmatter. Standard fields: title, type (aliased is_a), date, tags.
You have full shell access. Use bash for file operations, search, bulk edits.
Use the provided MCP tools for: full-text search (search_notes), vault orientation (get_vault_context), parsed note reading (get_note), and opening notes in the UI (open_note).
When you create or edit a note, call open_note(path) so the user sees it in Laputa.
When you create or edit a note, call open_note(path) so the user sees it in Tolaria.
When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.
Be concise and helpful. When you've completed a task, briefly summarize what you did.`

View File

@@ -30,7 +30,7 @@ export function buildSystemPrompt(
}
const preamble = [
'You are a helpful AI assistant integrated into Laputa, a personal knowledge management app.',
'You are a helpful AI assistant integrated into Tolaria, a personal knowledge management app.',
'The user has selected the following notes as context. Use them to answer questions accurately.',
'You can use MCP tools to read the full content of any note.',
'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.',

View File

@@ -148,7 +148,7 @@ describe('buildContextualPrompt', () => {
it('includes the system preamble', () => {
const active = makeEntry({ path: '/vault/a.md', title: 'Alpha' })
const prompt = buildContextualPrompt(active, [])
expect(prompt).toContain('AI assistant integrated into Laputa')
expect(prompt).toContain('AI assistant integrated into Tolaria')
})
})
@@ -171,7 +171,7 @@ describe('buildContextSnapshot', () => {
it('includes system preamble', () => {
const result = buildContextSnapshot({ activeEntry: active, entries })
expect(result).toContain('AI assistant integrated into Laputa')
expect(result).toContain('AI assistant integrated into Tolaria')
expect(result).toContain('Context Snapshot')
})

View File

@@ -163,7 +163,7 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string {
}
const preamble = [
'You are an AI assistant integrated into Laputa, a personal knowledge management app.',
'You are an AI assistant integrated into Tolaria, a personal knowledge management app.',
'The user is viewing a specific note. Use the structured context below to answer questions accurately.',
'You can also use MCP tools to search, read, create, or edit notes in the vault.',
'If the body field is empty but wordCount is > 0, the content may be stale — use get_note to read the full note from disk.',
@@ -179,7 +179,7 @@ export function buildContextualPrompt(
linkedEntries: VaultEntry[],
): string {
const parts: string[] = [
'You are an AI assistant integrated into Laputa, a personal knowledge management app.',
'You are an AI assistant integrated into Tolaria, a personal knowledge management app.',
'The user is viewing a specific note. Use the note and its linked context to answer questions accurately.',
'You can also use MCP tools to search, read, create, or edit notes in the vault.',
'',

View File

@@ -1,4 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage'
import type { VaultConfig } from '../types'
import { migrateLocalStorageToVaultConfig } from './configMigration'
@@ -34,8 +35,8 @@ describe('migrateLocalStorageToVaultConfig', () => {
// 2. Migration flag already set — idempotent
it('returns config unchanged when migration flag is already set', () => {
store['laputa:config-migrated-to-vault'] = '1'
store['laputa:zoom-level'] = '120'
store[APP_STORAGE_KEYS.configMigrationFlag] = '1'
store[APP_STORAGE_KEYS.zoom] = '120'
const config = makeConfig()
const result = migrateLocalStorageToVaultConfig(config)
@@ -48,81 +49,81 @@ describe('migrateLocalStorageToVaultConfig', () => {
['100', 1.0],
['150', 1.5],
])('migrates zoom "%s" → %s', (raw, expected) => {
store['laputa:zoom-level'] = raw
store[APP_STORAGE_KEYS.zoom] = raw
const result = migrateLocalStorageToVaultConfig(makeConfig())
expect(result.zoom).toBe(expected)
})
it('ignores invalid zoom values', () => {
store['laputa:zoom-level'] = 'banana'
store[APP_STORAGE_KEYS.zoom] = 'banana'
expect(migrateLocalStorageToVaultConfig(makeConfig()).zoom).toBeNull()
store['laputa:zoom-level'] = '50'
store[APP_STORAGE_KEYS.zoom] = '50'
expect(migrateLocalStorageToVaultConfig(makeConfig()).zoom).toBeNull()
store['laputa:zoom-level'] = '200'
store[APP_STORAGE_KEYS.zoom] = '200'
expect(migrateLocalStorageToVaultConfig(makeConfig()).zoom).toBeNull()
})
// 4. View mode migration
it.each(['editor-only', 'editor-list', 'all'])('migrates view mode "%s"', (mode) => {
store['laputa-view-mode'] = mode
store[APP_STORAGE_KEYS.viewMode] = mode
const result = migrateLocalStorageToVaultConfig(makeConfig())
expect(result.view_mode).toBe(mode)
})
it('ignores invalid view mode strings', () => {
store['laputa-view-mode'] = 'split-screen'
store[APP_STORAGE_KEYS.viewMode] = 'split-screen'
const result = migrateLocalStorageToVaultConfig(makeConfig())
expect(result.view_mode).toBeNull()
})
// 5. Tag colors migration
it('migrates populated tag colors', () => {
store['laputa:tag-color-overrides'] = JSON.stringify({ project: '#ff0000' })
store[APP_STORAGE_KEYS.tagColors] = JSON.stringify({ project: '#ff0000' })
const result = migrateLocalStorageToVaultConfig(makeConfig())
expect(result.tag_colors).toEqual({ project: '#ff0000' })
})
it('ignores empty tag colors object', () => {
store['laputa:tag-color-overrides'] = JSON.stringify({})
store[APP_STORAGE_KEYS.tagColors] = JSON.stringify({})
const result = migrateLocalStorageToVaultConfig(makeConfig())
expect(result.tag_colors).toBeNull()
})
// 6. Status colors migration
it('migrates populated status colors', () => {
store['laputa:status-color-overrides'] = JSON.stringify({ done: '#00ff00', wip: '#ffaa00' })
store[APP_STORAGE_KEYS.statusColors] = JSON.stringify({ done: '#00ff00', wip: '#ffaa00' })
const result = migrateLocalStorageToVaultConfig(makeConfig())
expect(result.status_colors).toEqual({ done: '#00ff00', wip: '#ffaa00' })
})
it('ignores empty status colors object', () => {
store['laputa:status-color-overrides'] = JSON.stringify({})
store[APP_STORAGE_KEYS.statusColors] = JSON.stringify({})
const result = migrateLocalStorageToVaultConfig(makeConfig())
expect(result.status_colors).toBeNull()
})
// 7. Property display modes migration
it('migrates populated property display modes', () => {
store['laputa:display-mode-overrides'] = JSON.stringify({ tags: 'inline' })
store[APP_STORAGE_KEYS.propertyModes] = JSON.stringify({ tags: 'inline' })
const result = migrateLocalStorageToVaultConfig(makeConfig())
expect(result.property_display_modes).toEqual({ tags: 'inline' })
})
it('ignores empty property display modes object', () => {
store['laputa:display-mode-overrides'] = JSON.stringify({})
store[APP_STORAGE_KEYS.propertyModes] = JSON.stringify({})
const result = migrateLocalStorageToVaultConfig(makeConfig())
expect(result.property_display_modes).toBeNull()
})
// 8. Existing config values are NOT overwritten
it('does not overwrite existing config values with localStorage data', () => {
store['laputa:zoom-level'] = '120'
store['laputa-view-mode'] = 'all'
store['laputa:tag-color-overrides'] = JSON.stringify({ x: '#fff' })
store['laputa:status-color-overrides'] = JSON.stringify({ y: '#000' })
store['laputa:display-mode-overrides'] = JSON.stringify({ z: 'compact' })
store[APP_STORAGE_KEYS.zoom] = '120'
store[APP_STORAGE_KEYS.viewMode] = 'all'
store[APP_STORAGE_KEYS.tagColors] = JSON.stringify({ x: '#fff' })
store[APP_STORAGE_KEYS.statusColors] = JSON.stringify({ y: '#000' })
store[APP_STORAGE_KEYS.propertyModes] = JSON.stringify({ z: 'compact' })
const existing = makeConfig({
zoom: 0.9,
@@ -142,8 +143,8 @@ describe('migrateLocalStorageToVaultConfig', () => {
// 10. loaded=null (no vault config file yet) — uses defaults then migrates
it('migrates from localStorage when loaded is null', () => {
store['laputa:zoom-level'] = '110'
store['laputa-view-mode'] = 'editor-list'
store[APP_STORAGE_KEYS.zoom] = '110'
store[APP_STORAGE_KEYS.viewMode] = 'editor-list'
const result = migrateLocalStorageToVaultConfig(null)
expect(result.zoom).toBe(1.1)
expect(result.view_mode).toBe('editor-list')
@@ -151,10 +152,10 @@ describe('migrateLocalStorageToVaultConfig', () => {
// 11. Migration flag is set after migration
it('sets migration flag so next call returns unchanged', () => {
store['laputa:zoom-level'] = '80'
store[APP_STORAGE_KEYS.zoom] = '80'
const first = migrateLocalStorageToVaultConfig(makeConfig())
expect(first.zoom).toBe(0.8)
expect(store['laputa:config-migrated-to-vault']).toBe('1')
expect(store[APP_STORAGE_KEYS.configMigrationFlag]).toBe('1')
const second = migrateLocalStorageToVaultConfig(makeConfig())
expect(second.zoom).toBeNull()
@@ -172,10 +173,19 @@ describe('migrateLocalStorageToVaultConfig', () => {
// 13. JSON parse error in colors — skips that field
it('skips fields with invalid JSON without affecting other migrations', () => {
store['laputa:tag-color-overrides'] = '{bad json'
store['laputa:zoom-level'] = '90'
store[APP_STORAGE_KEYS.tagColors] = '{bad json'
store[APP_STORAGE_KEYS.zoom] = '90'
const result = migrateLocalStorageToVaultConfig(makeConfig())
expect(result.tag_colors).toBeNull()
expect(result.zoom).toBe(0.9)
})
it('still migrates legacy Laputa storage keys when Tolaria keys are absent', () => {
store[LEGACY_APP_STORAGE_KEYS.zoom] = '110'
store[LEGACY_APP_STORAGE_KEYS.viewMode] = 'editor-only'
const result = migrateLocalStorageToVaultConfig(makeConfig())
expect(result.zoom).toBe(1.1)
expect(result.view_mode).toBe('editor-only')
})
})

View File

@@ -1,14 +1,15 @@
import { APP_STORAGE_KEYS, copyLegacyAppStorageKeys, getAppStorageItem } from '../constants/appStorage'
import type { VaultConfig } from '../types'
const MIGRATION_FLAG = 'laputa:config-migrated-to-vault'
const MIGRATION_FLAG = APP_STORAGE_KEYS.configMigrationFlag
/** Keys to migrate from localStorage to vault config file. */
const LS_KEYS = {
zoom: 'laputa:zoom-level',
viewMode: 'laputa-view-mode',
tagColors: 'laputa:tag-color-overrides',
statusColors: 'laputa:status-color-overrides',
propertyModes: 'laputa:display-mode-overrides',
zoom: APP_STORAGE_KEYS.zoom,
viewMode: APP_STORAGE_KEYS.viewMode,
tagColors: APP_STORAGE_KEYS.tagColors,
statusColors: APP_STORAGE_KEYS.statusColors,
propertyModes: APP_STORAGE_KEYS.propertyModes,
} as const
function readJson<T>(key: string): T | null {
@@ -31,6 +32,8 @@ export function migrateLocalStorageToVaultConfig(loaded: VaultConfig | null): Va
status_colors: null, property_display_modes: null, inbox: null,
}
copyLegacyAppStorageKeys()
// Skip migration if already done
try {
if (localStorage.getItem(MIGRATION_FLAG) === '1') return base
@@ -43,7 +46,7 @@ export function migrateLocalStorageToVaultConfig(loaded: VaultConfig | null): Va
// Zoom (localStorage stores as string "80""150", vault config stores as fraction 0.81.5)
if (result.zoom === null) {
try {
const raw = localStorage.getItem(LS_KEYS.zoom)
const raw = getAppStorageItem('zoom')
if (raw !== null) {
const val = Number(raw)
if (val >= 80 && val <= 150) result.zoom = val / 100
@@ -54,7 +57,7 @@ export function migrateLocalStorageToVaultConfig(loaded: VaultConfig | null): Va
// View mode
if (result.view_mode === null) {
try {
const raw = localStorage.getItem(LS_KEYS.viewMode)
const raw = getAppStorageItem('viewMode')
if (raw === 'editor-only' || raw === 'editor-list' || raw === 'all') {
result.view_mode = raw
}
@@ -63,19 +66,19 @@ export function migrateLocalStorageToVaultConfig(loaded: VaultConfig | null): Va
// Tag colors
if (result.tag_colors === null) {
const colors = readJson<Record<string, string>>(LS_KEYS.tagColors)
const colors = readJson<Record<string, string>>(LS_KEYS.tagColors) ?? readJson<Record<string, string>>('laputa:tag-color-overrides')
if (colors && Object.keys(colors).length > 0) result.tag_colors = colors
}
// Status colors
if (result.status_colors === null) {
const colors = readJson<Record<string, string>>(LS_KEYS.statusColors)
const colors = readJson<Record<string, string>>(LS_KEYS.statusColors) ?? readJson<Record<string, string>>('laputa:status-color-overrides')
if (colors && Object.keys(colors).length > 0) result.status_colors = colors
}
// Property display modes
if (result.property_display_modes === null) {
const modes = readJson<Record<string, string>>(LS_KEYS.propertyModes)
const modes = readJson<Record<string, string>>(LS_KEYS.propertyModes) ?? readJson<Record<string, string>>('laputa:display-mode-overrides')
if (modes && Object.keys(modes).length > 0) result.property_display_modes = modes
}

View File

@@ -1,4 +1,5 @@
import type { VaultEntry, SidebarSelection, InboxPeriod, ViewFile } from '../types'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS, getAppStorageItem } from '../constants/appStorage'
import { evaluateView } from './viewFilters'
import { wikilinkTarget, resolveEntry } from './wikilink'
@@ -176,8 +177,6 @@ export function getSortComparator(option: SortOption, direction?: SortDirection)
return makeBuiltinComparator(option, flip)
}
const SORT_STORAGE_KEY = 'laputa-sort-preferences'
/** Serialize a SortConfig to the string format stored in type frontmatter: "option:direction". */
export function serializeSortConfig(config: SortConfig): string {
return `${config.option}:${config.direction}`
@@ -197,7 +196,7 @@ export function parseSortConfig(raw: string | null | undefined): SortConfig | nu
export function loadSortPreferences(): Record<string, SortConfig> {
try {
const raw = localStorage.getItem(SORT_STORAGE_KEY)
const raw = getAppStorageItem('sortPreferences')
if (!raw) return {}
const parsed = JSON.parse(raw)
const result: Record<string, SortConfig> = {}
@@ -218,21 +217,24 @@ export function loadSortPreferences(): Record<string, SortConfig> {
export function saveSortPreferences(prefs: Record<string, SortConfig>) {
try {
localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(prefs))
localStorage.setItem(APP_STORAGE_KEYS.sortPreferences, JSON.stringify(prefs))
localStorage.removeItem(LEGACY_APP_STORAGE_KEYS.sortPreferences)
} catch { /* ignore */ }
}
/** Remove the `__list__` key from localStorage sort preferences (used during migration). */
export function clearListSortFromLocalStorage(): void {
try {
const raw = localStorage.getItem(SORT_STORAGE_KEY)
const raw = getAppStorageItem('sortPreferences')
if (!raw) return
const parsed = JSON.parse(raw)
delete parsed['__list__']
if (Object.keys(parsed).length === 0) {
localStorage.removeItem(SORT_STORAGE_KEY)
localStorage.removeItem(APP_STORAGE_KEYS.sortPreferences)
localStorage.removeItem(LEGACY_APP_STORAGE_KEYS.sortPreferences)
} else {
localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify(parsed))
localStorage.setItem(APP_STORAGE_KEYS.sortPreferences, JSON.stringify(parsed))
localStorage.removeItem(LEGACY_APP_STORAGE_KEYS.sortPreferences)
}
} catch { /* ignore */ }
}

View File

@@ -178,7 +178,7 @@ describe('display mode overrides (localStorage)', () => {
})
it('handles corrupted localStorage gracefully', () => {
localStorage.setItem('laputa:display-mode-overrides', 'not valid json')
localStorage.setItem('tolaria:display-mode-overrides', 'not valid json')
expect(loadDisplayModeOverrides()).toEqual({})
})
})

View File

@@ -1,4 +1,5 @@
import type { FrontmatterValue } from '../components/Inspector'
import { getAppStorageItem } from '../constants/appStorage'
import { isValidCssColor, isColorKeyName } from './colorUtils'
import { updateVaultConfigField } from './vaultConfigStore'
import { CalendarIcon, Type, ToggleLeft, Circle, Link, Tag, Palette } from 'lucide-react'
@@ -49,8 +50,6 @@ export function detectPropertyType(key: string, value: FrontmatterValue): Proper
return detectStringType(key, String(value))
}
const STORAGE_KEY = 'laputa:display-mode-overrides'
let vaultOverrides: Record<string, PropertyDisplayMode> | null = null
/** Initialize display mode overrides from vault config (replaces localStorage). */
@@ -60,9 +59,10 @@ export function initDisplayModeOverrides(overrides: Record<string, string>): voi
export function loadDisplayModeOverrides(): Record<string, PropertyDisplayMode> {
if (vaultOverrides !== null) return { ...vaultOverrides }
const raw = getAppStorageItem('propertyModes')
if (!raw) return {}
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? JSON.parse(raw) : {}
return JSON.parse(raw)
} catch {
return {}
}

View File

@@ -1,3 +1,4 @@
import { getAppStorageItem } from '../constants/appStorage'
import { ACCENT_COLORS } from './typeColors'
import { updateVaultConfigField } from './vaultConfigStore'
@@ -41,8 +42,6 @@ export const SUGGESTED_STATUSES = [
'Archived',
]
const STORAGE_KEY = 'laputa:status-color-overrides'
const COLOR_KEY_TO_STYLE: Record<string, StatusStyle> = Object.fromEntries(
ACCENT_COLORS.map(c => [c.key, { bg: c.cssLight, color: c.css }]),
)
@@ -55,9 +54,10 @@ export function initStatusColors(overrides: Record<string, string>): void {
}
function loadColorOverrides(): Record<string, string> {
const raw = getAppStorageItem('statusColors')
if (!raw) return {}
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as Record<string, string>) : {}
return JSON.parse(raw) as Record<string, string>
} catch {
return {}
}

View File

@@ -1,3 +1,4 @@
import { getAppStorageItem } from '../constants/appStorage'
import { ACCENT_COLORS } from './typeColors'
import { updateVaultConfigField } from './vaultConfigStore'
@@ -22,8 +23,6 @@ function hashTagColor(tag: string): TagStyle {
return { bg: accent.cssLight, color: accent.css }
}
const STORAGE_KEY = 'laputa:tag-color-overrides'
const COLOR_KEY_TO_STYLE: Record<string, TagStyle> = Object.fromEntries(
ACCENT_COLORS.map(c => [c.key, { bg: c.cssLight, color: c.css }]),
)
@@ -36,9 +35,10 @@ export function initTagColors(overrides: Record<string, string>): void {
}
function loadColorOverrides(): Record<string, string> {
const raw = getAppStorageItem('tagColors')
if (!raw) return {}
try {
const raw = localStorage.getItem(STORAGE_KEY)
return raw ? (JSON.parse(raw) as Record<string, string>) : {}
return JSON.parse(raw) as Record<string, string>
} catch {
return {}
}

View File

@@ -266,7 +266,7 @@ export async function openFixtureVaultDesktopHarness(
case 'trigger_menu_command': {
const commandId = String(args?.id ?? '')
const bridge = window.__laputaTest?.dispatchBrowserMenuCommand
if (!bridge) throw new Error('Laputa test bridge is missing dispatchBrowserMenuCommand')
if (!bridge) throw new Error('Tolaria test bridge is missing dispatchBrowserMenuCommand')
bridge(commandId)
return null
}

View File

@@ -36,6 +36,25 @@ function untitledRow(page: Page, typeLabel: string) {
return page.getByText(new RegExp(`^Untitled ${typeLabel}(?: \\d+)?$`, 'i')).first()
}
async function expectReadyEmptyTitleHeading(page: Page): Promise<void> {
await expect.poll(async () => page.evaluate(() => {
const active = document.activeElement as HTMLElement | null
const firstBlock = document.querySelector('.bn-block-content') as HTMLElement | null
const inlineHeading = firstBlock?.querySelector('.bn-inline-content') as HTMLElement | null
return {
editorFocused: Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]')),
contentType: firstBlock?.getAttribute('data-content-type') ?? null,
placeholder: inlineHeading ? getComputedStyle(inlineHeading, '::before').content : null,
}
}), {
timeout: 5_000,
}).toEqual({
editorFocused: true,
contentType: 'heading',
placeholder: '"Title"',
})
}
test.describe('Create note crash fix', () => {
test.beforeEach(() => {
tempVaultDir = createFixtureVaultCopy()
@@ -53,6 +72,7 @@ test.describe('Create note crash fix', () => {
await selectSection(page, 'Projects')
await createNoteFromListHeader(page)
await expect(untitledRow(page, 'project')).toBeVisible({ timeout: 5_000 })
await expectReadyEmptyTitleHeading(page)
expect(errors).toEqual([])
})
@@ -66,6 +86,7 @@ test.describe('Create note crash fix', () => {
await page.locator('body').click()
await sendShortcut(page, 'n', ['Control'])
await expect(untitledRow(page, 'note')).toBeVisible({ timeout: 5_000 })
await expectReadyEmptyTitleHeading(page)
expect(errors).toEqual([])
})
@@ -78,6 +99,7 @@ test.describe('Create note crash fix', () => {
await selectSection(page, 'Events')
await createNoteFromListHeader(page)
await expect(untitledRow(page, 'event')).toBeVisible({ timeout: 5_000 })
await expectReadyEmptyTitleHeading(page)
expect(errors).toEqual([])
})
@@ -93,6 +115,7 @@ test.describe('Create note crash fix', () => {
await executeCommand(page, 'new procedure')
await expect(untitledRow(page, 'procedure')).toBeVisible({ timeout: 5_000 })
await expectReadyEmptyTitleHeading(page)
expect(errors).toEqual([])
})
})

View File

@@ -18,21 +18,17 @@ function slugifyTitle(title: string): string {
}
async function createUntitledNote(page: Page): Promise<void> {
await page.locator('body').click()
await triggerMenuCommand(page, 'file-new-note')
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('breadcrumb-filename-trigger')).toContainText(/untitled-note-\d+(?:-\d+)?/i, {
timeout: 5_000,
})
await expect.poll(async () => page.evaluate(() => {
const active = document.activeElement as HTMLElement | null
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
}), {
timeout: 5_000,
}).toBe(true)
await expectReadyEmptyTitleHeading(page)
}
async function writeNewHeading(page: Page, title: string): Promise<void> {
await page.keyboard.type(`# ${title}`)
await page.keyboard.type(title)
await page.keyboard.press('Enter')
}
@@ -62,6 +58,23 @@ async function expectEditorFocused(page: Page): Promise<void> {
}).toBe(true)
}
async function expectReadyEmptyTitleHeading(page: Page): Promise<void> {
await expectEditorFocused(page)
await expect.poll(async () => page.evaluate(() => {
const firstBlock = document.querySelector('.bn-block-content') as HTMLElement | null
const inlineHeading = firstBlock?.querySelector('.bn-inline-content') as HTMLElement | null
return {
contentType: firstBlock?.getAttribute('data-content-type') ?? null,
placeholder: inlineHeading ? getComputedStyle(inlineHeading, '::before').content : null,
}
}), {
timeout: 5_000,
}).toEqual({
contentType: 'heading',
placeholder: '"Title"',
})
}
let tempVaultDir: string
test.beforeEach(async ({ page }, testInfo) => {
@@ -90,11 +103,11 @@ test('@smoke new-note H1 auto-rename keeps the editor usable and leaves no untit
for (const [index, title] of titles.entries()) {
await createUntitledNote(page)
await expectEditorFocused(page)
await writeNewHeading(page, title)
await expectActiveFilename(page, slugifyTitle(title))
await expectRenamedFile(tempVaultDir, `${slugifyTitle(title)}.md`)
await expectEditorFocused(page)
await expectFileContentContains(tempVaultDir, `${slugifyTitle(title)}.md`, `# ${title}`)
if (index === 0) {
await page.keyboard.type(' focus-probe')

View File

@@ -6,7 +6,7 @@ test.describe('Telemetry consent dialog', () => {
// The consent dialog should NOT appear (only appears when null)
await page.goto('/')
await page.waitForLoadState('networkidle')
await expect(page.getByText('Help improve Laputa')).not.toBeVisible({ timeout: 5000 })
await expect(page.getByText('Help improve Tolaria')).not.toBeVisible({ timeout: 5000 })
})
test('privacy toggles are visible in settings panel', async ({ page }) => {

View File

@@ -34,7 +34,7 @@ export async function triggerMenuCommand(page: Page, id: string): Promise<void>
await new Promise((resolve) => window.setTimeout(resolve, 50))
}
throw new Error('Laputa test bridge is missing dispatchBrowserMenuCommand')
throw new Error('Tolaria test bridge is missing dispatchBrowserMenuCommand')
}, id)
}
@@ -45,7 +45,7 @@ export async function dispatchShortcutEvent(
await page.evaluate((eventInit) => {
const bridge = window.__laputaTest?.dispatchShortcutEvent
if (typeof bridge !== 'function') {
throw new Error('Laputa test bridge is missing dispatchShortcutEvent')
throw new Error('Tolaria test bridge is missing dispatchShortcutEvent')
}
bridge(eventInit)
}, init)
@@ -59,7 +59,7 @@ export async function triggerShortcutCommand(
await page.evaluate((payload) => {
const bridge = window.__laputaTest?.triggerShortcutCommand
if (typeof bridge !== 'function') {
throw new Error('Laputa test bridge is missing triggerShortcutCommand')
throw new Error('Tolaria test bridge is missing triggerShortcutCommand')
}
bridge(payload.id, payload.options)
}, { id, options })