Compare commits

...

14 Commits

Author SHA1 Message Date
Luca Rossi
0effc563dc fix: drag-and-drop images into editor — add drop overlay and copy-to-attachments (#150)
* feat: add copy_image_to_vault Rust command for native drag-drop

Adds a new Tauri command that copies an image file from a source path
(provided by Tauri's drag-drop event) directly into vault/attachments/.
More efficient than base64 encoding for filesystem drag-drop.

Also adds core:webview:allow-on-drag-drop-event permission.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: handle Tauri native drag-drop for filesystem images

Listen for Tauri's onDragDropEvent to intercept OS-level file drops
that bypass the webview's HTML5 DnD API. When image files are dropped:
1. Copy to vault/attachments via copy_image_to_vault command
2. Insert image block into BlockNote at cursor position

Also passes vaultPath through Editor → EditorContent → SingleEditorView
so the hook can invoke the Tauri command.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: remove nonexistent drag-drop permission from capabilities

The onDragDropEvent API works through core:event:default (included
in core:default), not a separate webview permission.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: correct DragDropEvent type handling for 'over' events

Tauri's DragDropEvent discriminated union only provides `paths` on 'drop'
events, not 'over'. Show drag overlay unconditionally on 'over' since we
can't filter by image paths at that stage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* design: drag-drop-images wireframes (idle + drag-over overlay)

* style: rustfmt mcp.rs and image.rs

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-28 21:31:47 +01:00
Luca Rossi
37ac70f720 feat: AI agent panel UI — 3-layer panel with action cards and WebSocket activity hook (#149)
* feat: AI agent panel UI — 3-layer panel with action cards and WebSocket activity hook

* style: rustfmt mcp.rs

---------

Co-authored-by: Test <test@test.com>
2026-02-28 21:30:47 +01:00
Test
222f697873 fix: derive sidebar type sections dynamically from vault entries
Sidebar previously showed all 8 hardcoded BUILT_IN_SECTION_GROUPS regardless
of whether any notes of those types existed in the vault. Now sections are
derived from actual vault entries — only types with ≥1 active (non-trashed,
non-archived) note appear. BUILT_IN_SECTION_GROUPS is retained as metadata
lookup for icons/labels, not as the source of sections to display.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:48:58 +01:00
Test
681554af58 docs: add VISION.md — core principles and long-term direction 2026-02-28 20:35:15 +01:00
Luca Rossi
87bce9d4cc fix: tags in properties — X button doesn't reserve space, fades in on hover without expanding pill (#148)
* fix: tags X button appears on hover without expanding pill width

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: tags in properties — X button doesn't reserve space, fades in on hover without expanding pill

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-02-28 20:32:58 +01:00
Test
248774ed13 style: fix rustfmt formatting in lib.rs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:24:35 +01:00
Test
6363e84402 fix: shift tab bar right when sidebar+notelist collapsed to avoid traffic lights overlap
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:24:35 +01:00
Test
8106eb86a8 test: add spawn_ws_bridge test to push Rust coverage above 85%
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:18:21 +01:00
Test
7feae25d45 docs: update architecture with full MCP server tool surface and auto-registration
- Document all 14 MCP tools with params
- Add auto-registration flow (Claude Code + Cursor configs)
- Document dual WebSocket bridge (tool port 9710, UI port 9711)
- Add Rust MCP module details (spawn, register, upsert)
- Update startup sequence with MCP initialization steps
- Add register_mcp_tools to Tauri IPC commands table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:18:21 +01:00
Test
cc08055e0a feat: MCP server foundation with full tool surface and auto-registration
- 14 MCP tools: vault CRUD, search, list, context, link, frontmatter edit, and 4 UI actions
- Auto-registers Laputa in ~/.claude/mcp.json and ~/.cursor/mcp.json on startup
- WebSocket bridge spawned on app start (port 9710 tools, 9711 UI actions)
- Frontend useMcpRegistration hook for vault-aware registration
- Comprehensive tests: 26 vault.js tests covering all 9 exported functions
- Added gray-matter dependency for frontmatter parsing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:18:21 +01:00
Test
c789b49bef docs: add design file for notes-type-icon-search fix
Shows search results with "Note" type icon and label displayed
consistently alongside other types like Project and Person.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:10:20 +01:00
Test
bccc0ceed4 fix: show type icon and label for "Note" type in search and autocomplete
The "Note" type was explicitly filtered out with `isA !== 'Note'` in
SearchPanel, NoteAutocomplete, and useNoteSearch, preventing its icon
and label from appearing. Remove this special-casing so all types
display consistently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:08:55 +01:00
Luca Rossi
b924ec9b95 fix: show actual type icons in Properties panel TypeSelector (#147)
* fix: parse `type` frontmatter key after is_a→type migration

The migration converts `Is A:` to `type:` in frontmatter, and the
TypeSelector UI writes to key `type`. But the Rust Frontmatter struct
only read `Is A`, so type selection was silently lost after migration
or UI change, falling back to folder inference.

Add serde alias so both `Is A` and `type` keys are parsed. Also add
`type` to SKIP_KEYS so it's not treated as a generic relationship.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: show actual type icons in Properties panel TypeSelector

Replace generic Circle icon with each type's real icon (from
iconRegistry) in the Type dropdown. Icons resolve via getTypeIcon
with custom icon support from Type documents.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Test <test@test.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 20:01:56 +01:00
Test
c56a8c0e7f fix: hide type note from note list, make header clickable to access it
When browsing a type section (e.g. "Book"), the type-defining note
(types/book.md) no longer appears as a PinnedCard in the note list.
Instead, the header title becomes clickable to navigate to the type note.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 19:44:03 +01:00
42 changed files with 2813 additions and 128 deletions

View File

@@ -1,3 +1,42 @@
Task: pencil-ui-design-light-mode
Summary: Verified all 115 top-level frames in ui-design.pen have theme:{Mode:Light}. No remaining before-variant frames found (only legitimate state-transition labels). No duplicate frames detected. Visual screenshots confirm correct light-mode rendering. Previous commit 165cc0e already applied theme to 25 dark-mode frames and removed 2 before variants.
Commits: 1 (existing)
# Drag & Drop Images — Implementation Summary
## Problem
Dragging image files from Finder into the BlockNote editor didn't work. Tauri v2's
`dragDropEnabled` defaults to `true`, which intercepts OS-level file drops before they
reach the webview's HTML5 DnD API — BlockNote never received the dropped files.
## Solution
Instead of disabling Tauri's drag interception, we use Tauri's `onDragDropEvent` API
to listen for native file drops and handle them directly:
1. **Rust backend** (`src-tauri/src/vault/image.rs`):
- Added `copy_image_to_vault` command that copies a file by OS path into
`vault/attachments/` with a timestamp-prefixed filename
- Refactored shared path logic into `prepare_attachment_path` helper
- Validates file exists and has an image extension before copying
2. **Frontend** (`src/hooks/useImageDrop.ts`):
- Added Tauri `onDragDropEvent` listener that fires on native OS drag-drop
- On `drop`: filters for image paths, copies each to vault, calls `onImageUrl`
- On `over`: shows drag overlay (paths aren't available until drop)
- `copyImageToVault` invokes the new Rust command and returns an asset URL
3. **Editor integration** (`src/components/SingleEditorView.tsx`):
- `useInsertImageCallback` inserts a BlockNote image block at cursor position
- Wired into `useImageDrop` via the `onImageUrl` callback
- `vaultPath` threaded through Editor → EditorContent → SingleEditorView
4. **Existing `/image` slash command** continues to work unchanged (uses `uploadFile`
on `useCreateBlockNote` → `uploadImageFile` → `save_image` Tauri command).
## Commits
- `d45f2e2` feat: add copy_image_to_vault Rust command for native drag-drop
- `b047d0c` feat: handle Tauri native drag-drop for filesystem images
- `2e04f5a` fix: remove nonexistent drag-drop permission from capabilities
- `23ad982` fix: correct DragDropEvent type handling for 'over' events
## Tests
- 4 new Rust tests for `copy_image_to_vault` (success, missing file, non-image, all extensions)
- 1 new frontend test for `useImageDrop` with onImageUrl + vaultPath params
- All 300 Rust tests pass, 1167 frontend tests pass
- Coverage: Rust 85.47%, Frontend 78.64%

53
VISION.md Normal file
View File

@@ -0,0 +1,53 @@
# Laputa — Vision
Laputa is a personal knowledge base where humans and AI agents collaborate as equals.
---
## Core principles
### 1. The vault is the source of truth
Everything lives in the vault as plain text files. Notes, relations, configuration, instructions — all Markdown with YAML frontmatter. No proprietary database, no hidden state. If you can open a terminal, you can read your vault. If you can write Markdown, you can modify it.
### 2. Vault-native configuration
Laputa configures itself through files inside the vault — the same files you write and read every day. There is no separate "settings app" or admin panel. If you want to change a theme, you edit a note. If you want to give instructions to an AI agent, you write a note. If you want to define a template, you create a note.
This applies to:
- **`_themes/`** — themes as notes with a YAML block in the body. Edit `_themes/dark.md`, see the colors change in real time.
- **`AGENTS.md`** — instructions for AI agents. Write what you want them to know about your vault in plain language. They read it before acting.
- **`_templates/`** — note templates per type. Create `_templates/event.md` and every new event starts from that structure.
- **`_procedures/`** — recurring tasks as notes with a `schedule` frontmatter field.
The principle: **if it can be expressed in frontmatter + Markdown, it doesn't need a UI**.
### 3. Structure through types, not folders
Notes have a `type` field. Types determine folders, icons, and colors — but the structure is defined by the data, not the filesystem hierarchy. You can query "all events in February" without knowing anything about folder layout.
Relations between notes are expressed as frontmatter arrays: `people: [Marco, Sara]`. A wikilink `[[Marco]]` in the body navigates to the person note. The graph emerges from the data, not from a separate graph database.
### 4. The file is the interface
You can use Laputa's UI, or you can open a terminal. Or a text editor. Or Claude Code. They all operate on the same files. There is no difference between "the app" and "the vault" — the vault is the app.
This is why Laputa has an MCP server: external agents get the same tools the in-app AI panel uses. The interface is a convenience, not a requirement.
### 5. Humans and AI as collaborators
Pulse — the activity feed — shows the history of the vault without distinguishing between human commits and agent commits. That's intentional. Laputa is designed to be a space where you and your AI agents work together, each contributing to the same knowledge base.
The AI doesn't have a separate workspace. It works in yours.
---
## What Laputa is not
- Not a todo app (though you can use it as one)
- Not a note-taking app that syncs to the cloud (the vault is yours, sync however you want — git, iCloud, rsync)
- Not a replacement for a terminal (power users will use both)
- Not trying to abstract away git (git is a feature, not an implementation detail)
---
## The long game
A vault that grows with you for years. Events, people, projects, thoughts — all interconnected, all version-controlled, all accessible to any tool that can read a file.
Ten years from now, your vault should still be readable. Plain text is forever.

View File

@@ -0,0 +1,340 @@
{
"children": [
{
"type": "frame",
"id": "ai_panel_closed",
"name": "AI Agent Panel — Closed (trigger button in toolbar)",
"x": 0,
"y": 0,
"width": 1280,
"height": 800,
"fill": "$--background",
"layout": "horizontal",
"theme": { "Mode": "Light" },
"children": [
{
"type": "rectangle",
"id": "ai_closed_editor",
"width": "fill_container",
"height": "fill_container",
"fill": "$--muted",
"cornerRadius": 4
},
{
"type": "frame",
"id": "ai_closed_toolbar",
"name": "Right Toolbar",
"layout": "vertical",
"width": 36,
"height": "fill_container",
"fill": "$--background",
"padding": [8, 4],
"gap": 4,
"children": [
{
"type": "frame",
"id": "ai_trigger_btn",
"name": "AI Trigger Button",
"width": 28,
"height": 28,
"cornerRadius": 6,
"fill": "$--muted",
"layout": "horizontal",
"padding": [6, 6],
"children": [
{
"type": "text",
"id": "ai_trigger_icon",
"content": "✦",
"fill": "$--muted-foreground",
"fontSize": 14
}
]
}
]
}
]
},
{
"type": "frame",
"id": "ai_panel_open_idle",
"name": "AI Agent Panel — Open, Idle (empty state)",
"x": 1320,
"y": 0,
"width": 1280,
"height": 800,
"fill": "$--background",
"layout": "horizontal",
"theme": { "Mode": "Light" },
"children": [
{
"type": "rectangle",
"id": "ai_open_editor",
"width": "fill_container",
"height": "fill_container",
"fill": "$--muted",
"cornerRadius": 4
},
{
"type": "frame",
"id": "ai_panel_sidebar",
"name": "AI Panel Sidebar",
"layout": "vertical",
"width": 320,
"height": "fill_container",
"fill": "$--background",
"children": [
{
"type": "frame",
"id": "ai_panel_header",
"name": "Panel Header",
"layout": "horizontal",
"width": "fill_container",
"height": 45,
"padding": [0, 12],
"gap": 8,
"children": [
{
"type": "text",
"id": "ai_header_label",
"content": "AI",
"fontSize": 13,
"fontWeight": "600",
"fill": "$--muted-foreground",
"width": "fill_container"
},
{
"type": "text",
"id": "ai_close_icon",
"content": "✕",
"fontSize": 12,
"fill": "$--muted-foreground"
}
]
},
{
"type": "frame",
"id": "ai_empty_state",
"name": "Empty State",
"layout": "vertical",
"width": "fill_container",
"height": "fill_container",
"padding": [24, 24],
"children": [
{
"type": "text",
"id": "ai_empty_hint",
"content": "Ask the AI agent to work on your vault…",
"fontSize": 13,
"fill": "$--muted-foreground",
"textAlign": "center",
"width": "fill_container"
}
]
},
{
"type": "frame",
"id": "ai_input_row",
"name": "Input Row",
"layout": "horizontal",
"width": "fill_container",
"padding": [8, 12],
"gap": 8,
"children": [
{
"type": "rectangle",
"id": "ai_input_field",
"width": "fill_container",
"height": 32,
"cornerRadius": 6,
"fill": "$--muted"
},
{
"type": "frame",
"id": "ai_send_btn",
"name": "Send Button",
"width": 28,
"height": 28,
"cornerRadius": 6,
"fill": "$--primary",
"layout": "horizontal",
"padding": [6, 6],
"children": [
{
"type": "text",
"id": "ai_send_icon",
"content": "→",
"fontSize": 12,
"fill": "$--primary-foreground"
}
]
}
]
}
]
}
]
},
{
"type": "frame",
"id": "ai_panel_active",
"name": "AI Agent Panel — Active (streaming, action cards visible)",
"x": 2640,
"y": 0,
"width": 1280,
"height": 800,
"fill": "$--background",
"layout": "horizontal",
"theme": { "Mode": "Light" },
"children": [
{
"type": "rectangle",
"id": "ai_active_editor",
"width": "fill_container",
"height": "fill_container",
"fill": "$--muted",
"cornerRadius": 4
},
{
"type": "frame",
"id": "ai_active_panel",
"name": "AI Panel — With Messages",
"layout": "vertical",
"width": 320,
"height": "fill_container",
"fill": "$--background",
"children": [
{
"type": "frame",
"id": "ai_active_header",
"name": "Panel Header",
"layout": "horizontal",
"width": "fill_container",
"height": 45,
"padding": [0, 12],
"gap": 8,
"children": [
{
"type": "text",
"id": "ai_active_label",
"content": "AI",
"fontSize": 13,
"fontWeight": "600",
"fill": "$--muted-foreground",
"width": "fill_container"
}
]
},
{
"type": "frame",
"id": "ai_message_thread",
"name": "Message Thread",
"layout": "vertical",
"width": "fill_container",
"height": "fill_container",
"padding": [12, 12],
"gap": 16,
"children": [
{
"type": "frame",
"id": "ai_user_msg",
"name": "User Message",
"layout": "vertical",
"width": "fill_container",
"gap": 4,
"children": [
{
"type": "text",
"content": "You",
"fontSize": 11,
"fontWeight": "600",
"fill": "$--muted-foreground"
},
{
"type": "text",
"content": "Crea una nota evento per la riunione con Marco domani",
"fontSize": 13,
"fill": "$--foreground",
"width": "fill_container"
}
]
},
{
"type": "frame",
"id": "ai_agent_msg",
"name": "Agent Message + Actions",
"layout": "vertical",
"width": "fill_container",
"gap": 8,
"children": [
{
"type": "frame",
"id": "ai_action_done",
"name": "Action Card — Done",
"layout": "horizontal",
"width": "fill_container",
"height": 28,
"padding": [0, 8],
"gap": 6,
"cornerRadius": 4,
"fill": "$--muted",
"children": [
{
"type": "text",
"content": "✓",
"fontSize": 12,
"fill": "$--primary"
},
{
"type": "text",
"content": "Loaded vault context",
"fontSize": 12,
"fill": "$--foreground",
"width": "fill_container"
}
]
},
{
"type": "frame",
"id": "ai_action_progress",
"name": "Action Card — In Progress",
"layout": "horizontal",
"width": "fill_container",
"height": 28,
"padding": [0, 8],
"gap": 6,
"cornerRadius": 4,
"fill": "$--muted",
"children": [
{
"type": "text",
"content": "◌",
"fontSize": 12,
"fill": "$--primary"
},
{
"type": "text",
"content": "Creating: 2026-03-01-meeting-marco.md",
"fontSize": 12,
"fill": "$--foreground",
"width": "fill_container"
}
]
},
{
"type": "text",
"content": "Ho creato la nota evento e linkato Marco come partecipante. La trovi già aperta in un nuovo tab.",
"fontSize": 13,
"fill": "$--foreground",
"width": "fill_container"
}
]
}
]
}
]
}
]
}
]
}

View File

@@ -0,0 +1,78 @@
{
"children": [
{
"type": "frame",
"id": "drag_drop_idle",
"name": "Drag & Drop Images — Idle (no drag)",
"x": 0,
"y": 0,
"width": 900,
"height": 600,
"fill": "$--background",
"layout": "vertical",
"theme": { "Mode": "Light" },
"children": [
{
"type": "rectangle",
"id": "drag_idle_editor",
"width": "fill_container",
"height": "fill_container",
"fill": "$--background",
"cornerRadius": 0
}
]
},
{
"type": "frame",
"id": "drag_drop_active",
"name": "Drag & Drop Images — Drag Over (overlay shown)",
"x": 940,
"y": 0,
"width": 900,
"height": 600,
"fill": "$--background",
"layout": "vertical",
"theme": { "Mode": "Light" },
"children": [
{
"type": "frame",
"id": "drag_active_container",
"name": "Editor with Drag Overlay",
"layout": "vertical",
"width": "fill_container",
"height": "fill_container",
"children": [
{
"type": "rectangle",
"id": "drag_active_editor_bg",
"width": "fill_container",
"height": "fill_container",
"fill": "$--background"
},
{
"type": "frame",
"id": "drag_overlay",
"name": "Drop Overlay",
"width": "fill_container",
"height": "fill_container",
"layout": "vertical",
"fill": "rgba(0,0,0,0.4)",
"cornerRadius": 8,
"children": [
{
"type": "text",
"id": "drag_overlay_label",
"content": "Drop image to insert",
"fontSize": 18,
"fontWeight": "600",
"fill": "#ffffff",
"textAlign": "center"
}
]
}
]
}
]
}
]
}

View File

@@ -0,0 +1 @@
{"children":[],"variables":{}}

View File

@@ -131,29 +131,97 @@ The context picker controls which notes are sent to the AI as context:
### MCP Server
The MCP server (`mcp-server/`) exposes vault operations as tools:
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Cursor, or any MCP-compatible client).
| Tool | Description |
|------|-------------|
| `open_note` | Open and read a note by path |
| `read_note` | Read note content (alias) |
| `create_note` | Create new note with frontmatter |
| `search_notes` | Search by title or content |
| `append_to_note` | Append text to a note |
#### Tool Surface (14 tools)
**Transports:**
- **stdio** — standard MCP transport (`node mcp-server/index.js`)
- **WebSocket** — live bridge for app integration (`node mcp-server/ws-bridge.js`, port 9710)
| Tool | Params | Description |
|------|--------|-------------|
| `open_note` | `path` | Open and read a note by relative path |
| `read_note` | `path` | Read note content (alias for `open_note`) |
| `create_note` | `path, title, [is_a]` | Create new note with title and optional type frontmatter |
| `search_notes` | `query, [limit]` | Search notes by title or content substring |
| `append_to_note` | `path, text` | Append text to end of existing note |
| `edit_note_frontmatter` | `path, patch` | Merge key-value patch into YAML frontmatter |
| `delete_note` | `path` | Delete a note file from the vault |
| `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 |
| `ui_open_note` | `path` | Open a note in the Laputa 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 |
#### Transports
- **stdio** — standard MCP transport for Claude Code / Cursor (`node mcp-server/index.js`)
- **WebSocket** — live bridge for Laputa 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:
- `~/.claude/mcp.json` (Claude Code)
- `~/.cursor/mcp.json` (Cursor)
The registration is non-destructive (additive — preserves other MCP servers) and uses `upsert` semantics. The entry points to `mcp-server/index.js` with the active vault path as `VAULT_PATH` env var.
Registration also runs from the frontend via the `useMcpRegistration` hook and `register_mcp_tools` Tauri command, ensuring the config stays up-to-date when the vault path changes.
#### Architecture
```
┌─────────────────────────────────────────────────────┐
│ MCP Server (Node.js) │
│ │
│ index.js ─── stdio transport ──→ Claude Code │
│ │ Cursor │
│ ├── vault.js (9 vault operations) │
│ │ ├── findMarkdownFiles ├── deleteNote │
│ │ ├── readNote ├── linkNotes │
│ │ ├── createNote ├── listNotes │
│ │ ├── searchNotes ├── vaultContext │
│ │ ├── appendToNote │
│ │ └── editNoteFrontmatter │
│ │ │
│ └── ws-bridge.js │
│ ├── port 9710: tool bridge ←→ AI clients │
│ └── port 9711: UI bridge ←→ Frontend │
│ │
│ Spawned by Tauri (mcp.rs) on app startup │
│ Auto-registered in ~/.claude/mcp.json │
└─────────────────────────────────────────────────────┘
```
### WebSocket Bridge
The WebSocket bridge (`useMcpBridge` hook) enables real-time vault operations from the frontend:
The WebSocket bridge enables real-time vault operations from both the frontend and external AI clients:
```
Frontend (useMcpBridge) ←→ ws://localhost:9710 ←→ ws-bridge.js ←→ vault.js
MCP stdio tools ←→ ws://localhost:9711 ←→ Frontend UI actions
```
Protocol: JSON-RPC-like with `{id, tool, args}` requests and `{id, result}` responses.
**Tool bridge protocol** (port 9710):
- Request: `{ "id": "req-1", "tool": "search_notes", "args": { "query": "test" } }`
- Response: `{ "id": "req-1", "result": { ... } }`
- Error: `{ "id": "req-1", "error": "message" }`
**UI bridge protocol** (port 9711):
- Broadcast: `{ "type": "ui_action", "action": "open_note", "path": "..." }`
### Rust MCP Module
`src-tauri/src/mcp.rs` manages the MCP server lifecycle:
| 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 |
| `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.
### Rust Backend (Tauri)
@@ -170,24 +238,31 @@ The `ai_chat` Tauri command (`src-tauri/src/ai_chat.rs`) provides a non-streamin
| `src/components/AIChatPanel.tsx` | Main UI: context bar, messages, input, quick actions |
| `src/hooks/useAIChat.ts` | Chat state: messages, streaming, send/retry/clear |
| `src/hooks/useMcpBridge.ts` | WebSocket client for MCP vault tool calls |
| `src/hooks/useMcpRegistration.ts` | Auto-registers Laputa MCP on vault load |
| `src/utils/ai-chat.ts` | API client, token estimation, context builder |
| `src-tauri/src/ai_chat.rs` | Rust Anthropic API client (non-streaming) |
| `mcp-server/index.js` | MCP server entry (stdio transport) |
| `mcp-server/vault.js` | Vault file operations |
| `mcp-server/ws-bridge.js` | WebSocket bridge server |
| `src-tauri/src/mcp.rs` | MCP server spawning + config registration |
| `mcp-server/index.js` | MCP server entry (stdio transport, 14 tools) |
| `mcp-server/vault.js` | Vault file operations (9 functions) |
| `mcp-server/ws-bridge.js` | WebSocket bridge server (tool + UI bridges) |
| `mcp-server/test.js` | 26 unit tests for all vault.js functions |
## Data Flow
### Startup Sequence
```
1. App mounts
2. useVaultLoader fires:
1. Tauri setup:
a. run_startup_tasks() → purge trash, migrate frontmatter, register MCP config
b. spawn_ws_bridge() → start MCP WebSocket bridge (ports 9710, 9711)
2. App mounts
3. useVaultLoader fires:
a. isTauri() ? invoke('list_vault') : mockInvoke('list_vault')
→ VaultEntry[] stored in state
b. Load all content (mock mode) or on-demand (Tauri mode)
c. invoke('get_modified_files') → ModifiedFile[] stored in state
3. User clicks note in NoteList
d. useMcpRegistration → invoke('register_mcp_tools') → ensures MCP config current
4. User clicks note in NoteList
4. useNoteActions.handleSelectNote:
a. invoke('get_note_content') → raw markdown string
b. Add tab { entry, content } to tabs state
@@ -255,6 +330,7 @@ All commands are defined in `src-tauri/src/lib.rs` and registered via `tauri::ge
| `git_commit` | `vault_path, message` | `String` | `git::git_commit()` |
| `git_push` | `vault_path` | `String` | `git::git_push()` |
| `ai_chat` | `request: AiChatRequest` | `AiChatResponse` | `ai_chat::send_chat()` |
| `register_mcp_tools` | `vault_path` | `String` ("registered" or "updated") | `mcp::register_mcp()` |
All commands return `Result<T, String>`. Errors are serialized as JSON error objects to the frontend.

View File

@@ -6,11 +6,16 @@
* VAULT_PATH=/path/to/vault node index.js
*
* Tools:
* - open_note: Open and read a note by path
* - read_note: Read note content (alias for consistency)
* - open_note / read_note: Read a note by path
* - create_note: Create a new note with title and optional frontmatter
* - search_notes: Search notes by title or content
* - append_to_note: Append text to an existing note
* - edit_note_frontmatter: Merge a patch into a note's YAML frontmatter
* - delete_note: Delete a note file
* - link_notes: Add a title to an array property in a note's frontmatter
* - list_notes: List all notes, optionally filtered by type
* - vault_context: Get vault types and recent notes
* - ui_open_note / ui_open_tab / ui_highlight / ui_set_filter: UI actions
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
@@ -18,9 +23,24 @@ import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js'
import { readNote, createNote, searchNotes, appendToNote } from './vault.js'
import {
readNote, createNote, searchNotes, appendToNote,
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
} from './vault.js'
import { startUiBridge } from './ws-bridge.js'
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
// Start the UI bridge so stdio-based MCP tools can broadcast UI actions
const uiBridge = startUiBridge(WS_UI_PORT)
function broadcastUiAction(action, payload) {
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
for (const client of uiBridge.clients) {
if (client.readyState === 1) client.send(msg)
}
}
const TOOLS = [
{
@@ -82,6 +102,103 @@ const TOOLS = [
required: ['path', 'text'],
},
},
{
name: 'edit_note_frontmatter',
description: 'Merge a patch object into a note\'s YAML frontmatter',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note' },
patch: { type: 'object', description: 'Key-value pairs to merge into frontmatter' },
},
required: ['path', 'patch'],
},
},
{
name: 'delete_note',
description: 'Delete a note file from the vault',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note to delete' },
},
required: ['path'],
},
},
{
name: 'link_notes',
description: 'Add a target title to an array property in a note\'s frontmatter (e.g. add "Marco" to people: [])',
inputSchema: {
type: 'object',
properties: {
source_path: { type: 'string', description: 'Relative path to the source note' },
property: { type: 'string', description: 'Frontmatter property name (e.g. "people", "tags")' },
target_title: { type: 'string', description: 'Title to add to the array' },
},
required: ['source_path', 'property', 'target_title'],
},
},
{
name: 'list_notes',
description: 'List all notes in the vault, optionally filtered by type frontmatter field',
inputSchema: {
type: 'object',
properties: {
type_filter: { type: 'string', description: 'Filter by type frontmatter value' },
sort: { type: 'string', enum: ['title', 'mtime'], description: 'Sort order (default: title)' },
},
},
},
{
name: 'vault_context',
description: 'Get vault context: unique entity types and 20 most recently modified notes',
inputSchema: { type: 'object', properties: {} },
},
{
name: 'ui_open_note',
description: 'Open a note in the Laputa UI editor',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note' },
},
required: ['path'],
},
},
{
name: 'ui_open_tab',
description: 'Open a note in a new tab in the Laputa UI',
inputSchema: {
type: 'object',
properties: {
path: { type: 'string', description: 'Relative path to the note' },
},
required: ['path'],
},
},
{
name: 'ui_highlight',
description: 'Highlight a UI element in the Laputa interface',
inputSchema: {
type: 'object',
properties: {
element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'UI element to highlight' },
path: { type: 'string', description: 'Relative path to the note (optional)' },
},
required: ['element'],
},
},
{
name: 'ui_set_filter',
description: 'Set the sidebar filter to show notes of a specific type',
inputSchema: {
type: 'object',
properties: {
type: { type: 'string', description: 'Type to filter by' },
},
required: ['type'],
},
},
]
const TOOL_HANDLERS = {
@@ -90,6 +207,15 @@ const TOOL_HANDLERS = {
create_note: handleCreateNote,
search_notes: handleSearchNotes,
append_to_note: handleAppendToNote,
edit_note_frontmatter: handleEditFrontmatter,
delete_note: handleDeleteNote,
link_notes: handleLinkNotes,
list_notes: handleListNotes,
vault_context: handleVaultContext,
ui_open_note: handleUiOpenNote,
ui_open_tab: handleUiOpenTab,
ui_highlight: handleUiHighlight,
ui_set_filter: handleUiSetFilter,
}
async function handleReadNote(args) {
@@ -117,6 +243,54 @@ async function handleAppendToNote(args) {
return { content: [{ type: 'text', text: `Appended text to ${args.path}` }] }
}
async function handleEditFrontmatter(args) {
const updated = await editNoteFrontmatter(VAULT_PATH, args.path, args.patch)
return { content: [{ type: 'text', text: JSON.stringify(updated) }] }
}
async function handleDeleteNote(args) {
await deleteNote(VAULT_PATH, args.path)
return { content: [{ type: 'text', text: `Deleted ${args.path}` }] }
}
async function handleLinkNotes(args) {
const arr = await linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title)
return { content: [{ type: 'text', text: `${args.property}: [${arr.join(', ')}]` }] }
}
async function handleListNotes(args) {
const notes = await listNotes(VAULT_PATH, args.type_filter, args.sort)
const text = notes.length === 0
? 'No notes found.'
: notes.map(n => `${n.title} (${n.path})${n.type ? ` [${n.type}]` : ''}`).join('\n')
return { content: [{ type: 'text', text }] }
}
async function handleVaultContext() {
const ctx = await vaultContext(VAULT_PATH)
return { content: [{ type: 'text', text: JSON.stringify(ctx, null, 2) }] }
}
function handleUiOpenNote(args) {
broadcastUiAction('open_note', { path: args.path })
return { content: [{ type: 'text', text: `Opening ${args.path} in UI` }] }
}
function handleUiOpenTab(args) {
broadcastUiAction('open_tab', { path: args.path })
return { content: [{ type: 'text', text: `Opening tab for ${args.path}` }] }
}
function handleUiHighlight(args) {
broadcastUiAction('highlight', { element: args.element, path: args.path })
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
}
function handleUiSetFilter(args) {
broadcastUiAction('set_filter', { type: args.type })
return { content: [{ type: 'text', text: `Filter set to ${args.type}` }] }
}
// --- Server setup ---
const server = new Server(

View File

@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
"ws": "^8.19.0"
}
},
@@ -110,6 +111,15 @@
}
}
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -334,6 +344,19 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -425,6 +448,18 @@
"express": ">= 4.11"
}
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"license": "MIT",
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -544,6 +579,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
"license": "MIT",
"dependencies": {
"js-yaml": "^3.13.1",
"kind-of": "^6.0.2",
"section-matter": "^1.0.0",
"strip-bom-string": "^1.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -637,6 +687,15 @@
"node": ">= 0.10"
}
},
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -658,6 +717,19 @@
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -670,6 +742,15 @@
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"license": "BSD-2-Clause"
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -902,6 +983,19 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
"license": "MIT",
"dependencies": {
"extend-shallow": "^2.0.1",
"kind-of": "^6.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
@@ -1046,6 +1140,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -1055,6 +1155,15 @@
"node": ">= 0.8"
}
},
"node_modules/strip-bom-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"gray-matter": "^4.0.3",
"ws": "^8.19.0"
}
}

View File

@@ -3,14 +3,16 @@ import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import path from 'node:path'
import os from 'node:os'
import { readNote, createNote, searchNotes, appendToNote, findMarkdownFiles } from './vault.js'
import {
readNote, createNote, searchNotes, appendToNote, findMarkdownFiles,
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
} from './vault.js'
let tmpDir
before(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'laputa-mcp-test-'))
// Create test vault structure
await fs.mkdir(path.join(tmpDir, 'project'), { recursive: true })
await fs.mkdir(path.join(tmpDir, 'note'), { recursive: true })
@@ -33,6 +35,19 @@ is_a: Note
# Daily Log
Today I worked on the MCP server implementation.
`)
await fs.writeFile(path.join(tmpDir, 'project', 'second-project.md'), `---
title: Second Project
type: Project
status: Draft
belongs_to:
- "[[project/test-project]]"
---
# Second Project
Another project for testing list and context.
`)
})
@@ -43,9 +58,10 @@ after(async () => {
describe('findMarkdownFiles', () => {
it('should find all .md files recursively', async () => {
const files = await findMarkdownFiles(tmpDir)
assert.equal(files.length, 2)
assert.equal(files.length, 3)
assert.ok(files.some(f => f.endsWith('test-project.md')))
assert.ok(files.some(f => f.endsWith('daily-log.md')))
assert.ok(files.some(f => f.endsWith('second-project.md')))
})
})
@@ -100,7 +116,7 @@ describe('searchNotes', () => {
})
it('should respect limit', async () => {
const results = await searchNotes(tmpDir, 'note', 1)
const results = await searchNotes(tmpDir, 'project', 1)
assert.ok(results.length <= 1)
})
})
@@ -113,3 +129,139 @@ describe('appendToNote', () => {
assert.ok(content.includes('Finished testing.'))
})
})
describe('editNoteFrontmatter', () => {
it('should merge a patch into frontmatter', async () => {
const updated = await editNoteFrontmatter(tmpDir, 'project/test-project.md', { status: 'Completed', priority: 'High' })
assert.equal(updated.status, 'Completed')
assert.equal(updated.priority, 'High')
assert.equal(updated.title, 'Test Project')
})
it('should preserve existing frontmatter fields', async () => {
const content = await readNote(tmpDir, 'project/test-project.md')
assert.ok(content.includes('is_a: Project'))
assert.ok(content.includes('status: Completed'))
})
it('should throw for missing file', async () => {
await assert.rejects(
() => editNoteFrontmatter(tmpDir, 'nonexistent.md', { foo: 'bar' }),
{ code: 'ENOENT' }
)
})
})
describe('deleteNote', () => {
it('should delete an existing note', async () => {
const delPath = 'note/to-delete.md'
await createNote(tmpDir, delPath, 'To Delete')
const absPath = path.join(tmpDir, delPath)
// Verify it exists
await fs.access(absPath)
await deleteNote(tmpDir, delPath)
await assert.rejects(
() => fs.access(absPath),
{ code: 'ENOENT' }
)
})
it('should throw for missing file', async () => {
await assert.rejects(
() => deleteNote(tmpDir, 'nonexistent.md'),
{ code: 'ENOENT' }
)
})
})
describe('linkNotes', () => {
it('should add a target to an array property', async () => {
const linkPath = 'project/link-test.md'
await createNote(tmpDir, linkPath, 'Link Test', { is_a: 'Project' })
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
assert.deepEqual(result, ['[[note/daily-log]]'])
})
it('should not duplicate existing links', async () => {
const linkPath = 'project/link-test.md'
await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
assert.equal(result.length, 1)
})
it('should add multiple distinct links', async () => {
const linkPath = 'project/link-test.md'
await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
// Should have daily-log and test-project
assert.ok(result.includes('[[note/daily-log]]'))
assert.ok(result.includes('[[project/test-project]]'))
assert.equal(result.length, 2)
})
})
describe('listNotes', () => {
it('should list all notes sorted by title', async () => {
const notes = await listNotes(tmpDir)
assert.ok(notes.length >= 3)
// Verify sorted by title
for (let i = 1; i < notes.length; i++) {
assert.ok(notes[i - 1].title.localeCompare(notes[i].title) <= 0)
}
})
it('should filter by type', async () => {
const projects = await listNotes(tmpDir, 'Project')
assert.ok(projects.length >= 1)
for (const n of projects) {
assert.equal(n.type, 'Project')
}
})
it('should return empty for unknown type', async () => {
const notes = await listNotes(tmpDir, 'UnknownType12345')
assert.equal(notes.length, 0)
})
it('should support mtime sorting', async () => {
const notes = await listNotes(tmpDir, undefined, 'mtime')
assert.ok(notes.length >= 1)
// Just verify it returns results without crashing
assert.ok(notes[0].path)
assert.ok(notes[0].title)
})
})
describe('vaultContext', () => {
it('should return types, recent notes, and vault path', async () => {
const ctx = await vaultContext(tmpDir)
assert.ok(Array.isArray(ctx.types))
assert.ok(Array.isArray(ctx.recentNotes))
assert.equal(ctx.vaultPath, tmpDir)
})
it('should include known entity types', async () => {
const ctx = await vaultContext(tmpDir)
assert.ok(ctx.types.includes('Project'))
assert.ok(ctx.types.includes('Note'))
})
it('should cap recent notes at 20', async () => {
const ctx = await vaultContext(tmpDir)
assert.ok(ctx.recentNotes.length <= 20)
})
it('should include path and title in recent notes', async () => {
const ctx = await vaultContext(tmpDir)
for (const note of ctx.recentNotes) {
assert.ok(note.path)
assert.ok(note.title)
}
})
})

View File

@@ -3,6 +3,7 @@
*/
import fs from 'node:fs/promises'
import path from 'node:path'
import matter from 'gray-matter'
/**
* Recursively find all .md files under a directory.
@@ -104,6 +105,119 @@ export async function appendToNote(vaultPath, notePath, text) {
await fs.writeFile(absPath, current + separator + text + '\n', 'utf-8')
}
/**
* Merge a patch object into a note's YAML frontmatter.
* @param {string} vaultPath
* @param {string} notePath
* @param {Record<string, unknown>} patch
* @returns {Promise<Record<string, unknown>>} The updated frontmatter.
*/
export async function editNoteFrontmatter(vaultPath, notePath, patch) {
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
const raw = await fs.readFile(absPath, 'utf-8')
const parsed = matter(raw)
Object.assign(parsed.data, patch)
const updated = matter.stringify(parsed.content, parsed.data)
await fs.writeFile(absPath, updated, 'utf-8')
return parsed.data
}
/**
* Delete a note file.
* @param {string} vaultPath
* @param {string} notePath
* @returns {Promise<void>}
*/
export async function deleteNote(vaultPath, notePath) {
const absPath = path.isAbsolute(notePath) ? notePath : path.join(vaultPath, notePath)
await fs.unlink(absPath)
}
/**
* Add a target title to an array property in a note's frontmatter.
* Creates the property as an array if it doesn't exist.
* @param {string} vaultPath
* @param {string} sourcePath
* @param {string} property
* @param {string} targetTitle
* @returns {Promise<string[]>} The updated array.
*/
export async function linkNotes(vaultPath, sourcePath, property, targetTitle) {
const absPath = path.isAbsolute(sourcePath) ? sourcePath : path.join(vaultPath, sourcePath)
const raw = await fs.readFile(absPath, 'utf-8')
const parsed = matter(raw)
const current = Array.isArray(parsed.data[property]) ? parsed.data[property] : []
if (!current.includes(targetTitle)) {
current.push(targetTitle)
}
parsed.data[property] = current
const updated = matter.stringify(parsed.content, parsed.data)
await fs.writeFile(absPath, updated, 'utf-8')
return current
}
/**
* List all notes in the vault, optionally filtered by type.
* @param {string} vaultPath
* @param {string} [typeFilter]
* @param {string} [sort] - 'title' or 'mtime' (default: 'title')
* @returns {Promise<Array<{path: string, title: string, type: string|null}>>}
*/
export async function listNotes(vaultPath, typeFilter, sort = 'title') {
const files = await findMarkdownFiles(vaultPath)
const notes = await Promise.all(files.map(async (filePath) => {
const raw = await fs.readFile(filePath, 'utf-8')
const parsed = matter(raw)
const relativePath = path.relative(vaultPath, filePath)
const title = parsed.data.title || extractTitle(raw, path.basename(filePath, '.md'))
const type = parsed.data.type || parsed.data.is_a || null
const stat = sort === 'mtime' ? await fs.stat(filePath) : null
return { path: relativePath, title, type, mtime: stat?.mtimeMs ?? 0 }
}))
const filtered = typeFilter
? notes.filter(n => n.type === typeFilter)
: notes
if (sort === 'mtime') {
filtered.sort((a, b) => b.mtime - a.mtime)
} else {
filtered.sort((a, b) => a.title.localeCompare(b.title))
}
return filtered.map(({ mtime: _mtime, ...rest }) => rest)
}
/**
* Get vault context: unique types and 20 most recent notes.
* @param {string} vaultPath
* @returns {Promise<{types: string[], recentNotes: Array<{path: string, title: string, type: string|null}>, vaultPath: string}>}
*/
export async function vaultContext(vaultPath) {
const files = await findMarkdownFiles(vaultPath)
const typesSet = new Set()
const notesWithMtime = []
for (const filePath of files) {
const raw = await fs.readFile(filePath, 'utf-8')
const parsed = matter(raw)
const type = parsed.data.type || parsed.data.is_a || null
if (type) typesSet.add(type)
const stat = await fs.stat(filePath)
notesWithMtime.push({
path: path.relative(vaultPath, filePath),
title: parsed.data.title || extractTitle(raw, path.basename(filePath, '.md')),
type,
mtime: stat.mtimeMs,
})
}
notesWithMtime.sort((a, b) => b.mtime - a.mtime)
const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest)
return { types: [...typesSet].sort(), recentNotes, vaultPath }
}
// --- Helpers ---
/**

View File

@@ -5,19 +5,46 @@
* Exposes vault operations over WebSocket so the Laputa app frontend
* can invoke MCP tools in real-time without going through stdio.
*
* Usage:
* VAULT_PATH=/path/to/vault WS_PORT=9710 node ws-bridge.js
* Port 9710: Tool bridge — Claude/AI clients call vault tools here.
* Port 9711: UI bridge — Frontend listens for UI action broadcasts.
*
* Protocol:
* Usage:
* VAULT_PATH=/path/to/vault WS_PORT=9710 WS_UI_PORT=9711 node ws-bridge.js
*
* Protocol (tool bridge):
* Client sends: { "id": "req-1", "tool": "search_notes", "args": { "query": "test" } }
* Server sends: { "id": "req-1", "result": { ... } }
* On error: { "id": "req-1", "error": "message" }
*
* Protocol (UI bridge):
* Server broadcasts: { "type": "ui_action", "action": "open_note", "path": "..." }
*/
import { WebSocketServer } from 'ws'
import { readNote, createNote, searchNotes, appendToNote } from './vault.js'
import {
readNote, createNote, searchNotes, appendToNote,
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
} from './vault.js'
const VAULT_PATH = process.env.VAULT_PATH || process.env.HOME + '/Laputa'
const WS_PORT = parseInt(process.env.WS_PORT || '9710', 10)
const WS_UI_PORT = parseInt(process.env.WS_UI_PORT || '9711', 10)
/** @type {WebSocketServer | null} */
let uiBridge = null
function broadcastUiAction(action, payload) {
if (!uiBridge) return
const msg = JSON.stringify({ type: 'ui_action', action, ...payload })
for (const client of uiBridge.clients) {
if (client.readyState === 1) client.send(msg)
}
}
function buildFrontmatter(args) {
const fm = {}
if (args.is_a) fm.is_a = args.is_a
return fm
}
const TOOL_HANDLERS = {
open_note: (args) => readNote(VAULT_PATH, args.path).then(text => ({ content: text })),
@@ -25,12 +52,15 @@ const TOOL_HANDLERS = {
create_note: (args) => createNote(VAULT_PATH, args.path, args.title, buildFrontmatter(args)),
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
append_to_note: (args) => appendToNote(VAULT_PATH, args.path, args.text).then(() => ({ ok: true })),
}
function buildFrontmatter(args) {
const fm = {}
if (args.is_a) fm.is_a = args.is_a
return fm
edit_note_frontmatter: (args) => editNoteFrontmatter(VAULT_PATH, args.path, args.patch),
delete_note: (args) => deleteNote(VAULT_PATH, args.path).then(() => ({ ok: true })),
link_notes: (args) => linkNotes(VAULT_PATH, args.source_path, args.property, args.target_title),
list_notes: (args) => listNotes(VAULT_PATH, args.type_filter, args.sort),
vault_context: () => vaultContext(VAULT_PATH),
ui_open_note: (args) => { broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
ui_open_tab: (args) => { broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
ui_set_filter: (args) => { broadcastUiAction('set_filter', { type: args.type }); return { ok: true } },
}
async function handleMessage(data) {
@@ -50,6 +80,17 @@ async function handleMessage(data) {
}
}
export function startUiBridge(port = WS_UI_PORT) {
uiBridge = new WebSocketServer({ port })
uiBridge.on('connection', () => {
console.error(`[ws-bridge] UI client connected on port ${port}`)
})
console.error(`[ws-bridge] UI bridge listening on ws://localhost:${port}`)
return uiBridge
}
export function startBridge(port = WS_PORT) {
const wss = new WebSocketServer({ port })
@@ -75,5 +116,6 @@ export function startBridge(port = WS_PORT) {
// Run directly if invoked as main module
const isMain = process.argv[1]?.endsWith('ws-bridge.js')
if (isMain) {
startUiBridge()
startBridge()
}

View File

@@ -2,6 +2,7 @@ pub mod ai_chat;
pub mod frontmatter;
pub mod git;
pub mod github;
pub mod mcp;
pub mod menu;
pub mod search;
pub mod settings;
@@ -9,6 +10,8 @@ pub mod vault;
use std::borrow::Cow;
use std::path::Path;
use std::process::Child;
use std::sync::Mutex;
use ai_chat::{AiChatRequest, AiChatResponse};
use frontmatter::FrontmatterValue;
@@ -134,6 +137,12 @@ fn save_image(vault_path: String, filename: String, data: String) -> Result<Stri
vault::save_image(&vault_path, &filename, &data)
}
#[tauri::command]
fn copy_image_to_vault(vault_path: String, source_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path);
vault::copy_image_to_vault(&vault_path, &source_path)
}
#[tauri::command]
fn rename_note(
vault_path: String,
@@ -195,7 +204,11 @@ fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
for path in &paths {
let path = expand_tilde(path);
frontmatter::update_frontmatter(&path, "Trashed", FrontmatterValue::Bool(true))?;
frontmatter::update_frontmatter(&path, "Trashed at", FrontmatterValue::String(now.clone()))?;
frontmatter::update_frontmatter(
&path,
"Trashed at",
FrontmatterValue::String(now.clone()),
)?;
count += 1;
}
Ok(count)
@@ -266,6 +279,16 @@ async fn search_vault(
.map_err(|e| format!("Search task failed: {}", e))?
}
struct WsBridgeChild(Mutex<Option<Child>>);
#[tauri::command]
async fn register_mcp_tools(vault_path: String) -> Result<String, String> {
let vault_path = expand_tilde(&vault_path).into_owned();
tokio::task::spawn_blocking(move || mcp::register_mcp(&vault_path))
.await
.map_err(|e| format!("Registration task failed: {e}"))?
}
fn log_startup_result(label: &str, result: Result<usize, String>) {
match result {
Ok(n) if n > 0 => log::info!("{}: {} files", label, n),
@@ -291,6 +314,12 @@ fn run_startup_tasks() {
"Migrated is_a to type on startup",
vault::migrate_is_a_to_type(vp_str),
);
// Register Laputa 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}"),
}
}
#[cfg(test)]
@@ -333,6 +362,7 @@ mod tests {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(WsBridgeChild(Mutex::new(None)))
.setup(|app| {
if cfg!(debug_assertions) {
app.handle().plugin(
@@ -360,6 +390,22 @@ pub fn run() {
run_startup_tasks();
// Spawn the MCP WebSocket bridge for the default vault
{
use tauri::Manager;
let vault_path = dirs::home_dir()
.map(|h| h.join("Laputa"))
.unwrap_or_default();
let vp_str = vault_path.to_string_lossy().to_string();
match mcp::spawn_ws_bridge(&vp_str) {
Ok(child) => {
let state: tauri::State<'_, WsBridgeChild> = app.state();
*state.0.lock().unwrap() = Some(child);
}
Err(e) => log::warn!("Failed to start ws-bridge: {}", e),
}
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -379,6 +425,7 @@ pub fn run() {
git_push,
ai_chat,
save_image,
copy_image_to_vault,
purge_trash,
migrate_is_a_to_type,
batch_archive_notes,
@@ -395,8 +442,20 @@ pub fn run() {
search_vault,
create_getting_started_vault,
check_vault_exists,
get_default_vault_path
get_default_vault_path,
register_mcp_tools
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
use tauri::Manager;
if let tauri::RunEvent::Exit = event {
let state: tauri::State<'_, WsBridgeChild> = app_handle.state();
let mut guard = state.0.lock().unwrap();
if let Some(ref mut child) = *guard {
let _ = child.kill();
log::info!("ws-bridge child process killed on exit");
}
}
});
}

297
src-tauri/src/mcp.rs Normal file
View File

@@ -0,0 +1,297 @@
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
/// Find the `node` binary path at runtime.
pub(crate) fn find_node() -> Result<PathBuf, String> {
let output = Command::new("which")
.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());
}
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(PathBuf::from(path))
}
/// Resolve the path to `mcp-server/`.
///
/// In dev mode, uses `CARGO_MANIFEST_DIR` (set at compile time).
/// In release mode, navigates from the current executable.
pub(crate) fn mcp_server_dir() -> Result<PathBuf, String> {
let dev_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("mcp-server");
if dev_path.join("ws-bridge.js").exists() {
return Ok(std::fs::canonicalize(&dev_path).unwrap_or(dev_path));
}
let exe = std::env::current_exe().map_err(|e| format!("Cannot find executable: {e}"))?;
let release_path = exe
.parent()
.and_then(|p| p.parent())
.map(|p| p.join("mcp-server"))
.ok_or_else(|| "Cannot resolve mcp-server directory".to_string())?;
if release_path.join("ws-bridge.js").exists() {
return Ok(release_path);
}
Err(format!(
"mcp-server not found at {} or {}",
dev_path.display(),
release_path.display()
))
}
/// Spawn the WebSocket bridge as a child process.
pub fn spawn_ws_bridge(vault_path: &str) -> Result<Child, String> {
let node = find_node()?;
let server_dir = mcp_server_dir()?;
let script = server_dir.join("ws-bridge.js");
let child = Command::new(node)
.arg(&script)
.env("VAULT_PATH", vault_path)
.env("WS_PORT", "9710")
.env("WS_UI_PORT", "9711")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to spawn ws-bridge: {e}"))?;
log::info!("ws-bridge spawned (pid: {})", child.id());
Ok(child)
}
/// Build the MCP server entry JSON for a given vault path and index.js path.
fn build_mcp_entry(index_js: &str, vault_path: &str) -> serde_json::Value {
serde_json::json!({
"command": "node",
"args": [index_js],
"env": { "VAULT_PATH": vault_path }
})
}
/// Write MCP registration to a list of config file paths.
/// Returns "registered" on first registration, "updated" if already present.
fn register_mcp_to_configs(entry: &serde_json::Value, config_paths: &[PathBuf]) -> String {
let mut status = "registered";
for config_path in config_paths {
match upsert_mcp_config(config_path, entry) {
Ok(true) => status = "updated",
Ok(false) => {}
Err(e) => log::warn!("Failed to update {}: {}", config_path.display(), e),
}
}
status.to_string()
}
/// Register Laputa 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();
let entry = build_mcp_entry(&index_js, vault_path);
let configs: Vec<PathBuf> = [
dirs::home_dir().map(|h| h.join(".claude").join("mcp.json")),
dirs::home_dir().map(|h| h.join(".cursor").join("mcp.json")),
]
.into_iter()
.flatten()
.collect();
Ok(register_mcp_to_configs(&entry, &configs))
}
/// Insert or update the "laputa" 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)
.map_err(|e| format!("Cannot create dir {}: {e}", parent.display()))?;
}
let mut config: serde_json::Value = if config_path.exists() {
let raw = std::fs::read_to_string(config_path)
.map_err(|e| format!("Cannot read {}: {e}", config_path.display()))?;
serde_json::from_str(&raw)
.map_err(|e| format!("Invalid JSON in {}: {e}", config_path.display()))?
} else {
serde_json::json!({})
};
let servers = config
.as_object_mut()
.ok_or("Config is not a JSON object")?
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}));
let was_update = servers.get("laputa").is_some();
servers
.as_object_mut()
.ok_or("mcpServers is not a JSON object")?
.insert("laputa".to_string(), entry.clone());
let json = serde_json::to_string_pretty(&config)
.map_err(|e| format!("Failed to serialize config: {e}"))?;
std::fs::write(config_path, json)
.map_err(|e| format!("Cannot write {}: {e}", config_path.display()))?;
Ok(was_update)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_mcp_entry_produces_correct_json() {
let entry = build_mcp_entry("/path/to/index.js", "/my/vault");
assert_eq!(entry["command"], "node");
assert_eq!(entry["args"][0], "/path/to/index.js");
assert_eq!(entry["env"]["VAULT_PATH"], "/my/vault");
}
#[test]
fn upsert_creates_new_config() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/test/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_eq!(config["mcpServers"]["laputa"]["args"][0], "/test/index.js");
assert_eq!(
config["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
"/test/vault"
);
}
#[test]
fn upsert_updates_existing_config() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let entry1 = build_mcp_entry("/test/index.js", "/vault/v1");
upsert_mcp_config(&config_path, &entry1).unwrap();
let entry2 = build_mcp_entry("/test/index.js", "/vault/v2");
let was_update = upsert_mcp_config(&config_path, &entry2).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_eq!(
config["mcpServers"]["laputa"]["env"]["VAULT_PATH"],
"/vault/v2"
);
}
#[test]
fn upsert_preserves_other_servers() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("mcp.json");
let existing = serde_json::json!({
"mcpServers": {
"other-server": { "command": "other", "args": [] }
}
});
std::fs::write(&config_path, serde_json::to_string(&existing).unwrap()).unwrap();
let entry = build_mcp_entry("/test/index.js", "/vault");
upsert_mcp_config(&config_path, &entry).unwrap();
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());
}
#[test]
fn upsert_creates_parent_dirs() {
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("nested").join("dir").join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
upsert_mcp_config(&config_path, &entry).unwrap();
assert!(config_path.exists());
}
#[test]
fn register_mcp_to_configs_returns_registered_for_new() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("claude").join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
let status = register_mcp_to_configs(&entry, &[config]);
assert_eq!(status, "registered");
}
#[test]
fn register_mcp_to_configs_returns_updated_for_existing() {
let tmp = tempfile::tempdir().unwrap();
let config = tmp.path().join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
// First call
register_mcp_to_configs(&entry, &[config.clone()]);
// Second call
let status = register_mcp_to_configs(&entry, &[config]);
assert_eq!(status, "updated");
}
#[test]
fn find_node_returns_valid_path() {
let node = find_node().unwrap();
assert!(node.exists(), "node binary should exist at {:?}", node);
assert!(
node.to_string_lossy().contains("node"),
"path should contain 'node': {:?}",
node
);
}
#[test]
fn mcp_server_dir_resolves_in_dev() {
let dir = mcp_server_dir().unwrap();
assert!(dir.join("ws-bridge.js").exists());
assert!(dir.join("index.js").exists());
assert!(dir.join("vault.js").exists());
}
#[test]
fn spawn_ws_bridge_starts_and_can_be_killed() {
let tmp = tempfile::tempdir().unwrap();
let vault_path = tmp.path().to_str().unwrap();
let mut child = spawn_ws_bridge(vault_path).unwrap();
assert!(child.id() > 0, "child process should have a valid PID");
// Clean up: kill the spawned process
child.kill().unwrap();
child.wait().unwrap();
}
#[test]
fn register_mcp_to_configs_writes_multiple_configs() {
let tmp = tempfile::tempdir().unwrap();
let claude_cfg = tmp.path().join("claude").join("mcp.json");
let cursor_cfg = tmp.path().join("cursor").join("mcp.json");
let entry = build_mcp_entry("/test/index.js", "/vault");
register_mcp_to_configs(&entry, &[claude_cfg.clone(), cursor_cfg.clone()]);
assert!(claude_cfg.exists());
assert!(cursor_cfg.exists());
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");
}
}

View File

@@ -14,24 +14,29 @@ fn sanitize_filename(name: &str) -> String {
.collect()
}
/// Save an uploaded image to the vault's attachments directory.
/// Returns the absolute path to the saved file.
pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result<String, String> {
use base64::Engine;
let vault = Path::new(vault_path);
let attachments_dir = vault.join("attachments");
/// Image file extensions considered valid for drag-drop import.
const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"];
/// Prepare the attachments directory and generate a unique target path.
fn prepare_attachment_path(vault_path: &str, filename: &str) -> Result<std::path::PathBuf, String> {
let attachments_dir = Path::new(vault_path).join("attachments");
fs::create_dir_all(&attachments_dir)
.map_err(|e| format!("Failed to create attachments directory: {}", e))?;
// Generate unique filename to avoid collisions
let timestamp = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let unique_name = format!("{}-{}", timestamp, sanitize_filename(filename));
let target_path = attachments_dir.join(&unique_name);
Ok(attachments_dir.join(unique_name))
}
/// Save an uploaded image to the vault's attachments directory.
/// Returns the absolute path to the saved file.
pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result<String, String> {
use base64::Engine;
let target_path = prepare_attachment_path(vault_path, filename)?;
let bytes = base64::engine::general_purpose::STANDARD
.decode(data)
@@ -42,6 +47,35 @@ pub fn save_image(vault_path: &str, filename: &str, data: &str) -> Result<String
Ok(target_path.to_string_lossy().to_string())
}
/// Copy an image file from a source path into the vault's attachments directory.
/// Used for Tauri native drag-drop which provides absolute file paths.
/// Returns the absolute path to the saved file.
pub fn copy_image_to_vault(vault_path: &str, source_path: &str) -> Result<String, String> {
let source = Path::new(source_path);
if !source.exists() {
return Err(format!("Source file does not exist: {}", source_path));
}
let ext = source
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if !IMAGE_EXTENSIONS.contains(&ext.as_str()) {
return Err(format!("Not a supported image format: {}", source_path));
}
let filename = source
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("image");
let target_path = prepare_attachment_path(vault_path, filename)?;
fs::copy(source, &target_path).map_err(|e| format!("Failed to copy image: {}", e))?;
Ok(target_path.to_string_lossy().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -102,4 +136,61 @@ mod tests {
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid base64"));
}
#[test]
fn test_copy_image_to_vault_success() {
let dir = TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
// Create a source image file
let source_path = dir.path().join("source.png");
fs::write(&source_path, b"fake png data").unwrap();
let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap());
assert!(result.is_ok());
let saved_path = result.unwrap();
assert!(std::path::Path::new(&saved_path).exists());
assert!(saved_path.contains("attachments"));
assert!(saved_path.contains("source.png"));
let content = fs::read(&saved_path).unwrap();
assert_eq!(content, b"fake png data");
}
#[test]
fn test_copy_image_to_vault_nonexistent_source() {
let dir = TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let result = copy_image_to_vault(vault_path, "/nonexistent/photo.png");
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
#[test]
fn test_copy_image_to_vault_rejects_non_image() {
let dir = TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
let source_path = dir.path().join("document.pdf");
fs::write(&source_path, b"fake pdf").unwrap();
let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap());
assert!(result.is_err());
assert!(result.unwrap_err().contains("Not a supported image"));
}
#[test]
fn test_copy_image_to_vault_accepts_all_extensions() {
let dir = TempDir::new().unwrap();
let vault_path = dir.path().to_str().unwrap();
for ext in &["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff"] {
let source_path = dir.path().join(format!("img.{}", ext));
fs::write(&source_path, b"data").unwrap();
let result = copy_image_to_vault(vault_path, source_path.to_str().unwrap());
assert!(result.is_ok(), "failed for extension: {}", ext);
}
}
}

View File

@@ -8,7 +8,7 @@ mod trash;
pub use cache::scan_vault_cached;
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
pub use image::save_image;
pub use image::{copy_image_to_vault, save_image};
pub use migration::migrate_is_a_to_type;
pub use rename::{rename_note, RenameResult};
pub use trash::purge_trash;
@@ -74,7 +74,7 @@ pub struct VaultEntry {
/// Intermediate struct to capture YAML frontmatter fields.
#[derive(Debug, Deserialize, Default)]
struct Frontmatter {
#[serde(rename = "Is A")]
#[serde(rename = "Is A", alias = "type")]
is_a: Option<StringOrList>,
#[serde(default)]
aliases: Option<StringOrList>,
@@ -135,6 +135,7 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
/// Only skip keys that can never contain wikilinks.
const SKIP_KEYS: &[&str] = &[
"is a",
"type",
"aliases",
"status",
"cadence",
@@ -968,6 +969,39 @@ References:
assert_eq!(entry.is_a, Some("Type".to_string()));
}
// --- type key (post-migration) tests ---
#[test]
fn test_parse_type_key_lowercase() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Project\n---\n# My Project\n";
let entry = parse_test_entry(&dir, "project/my-project.md", content);
assert_eq!(entry.is_a, Some("Project".to_string()));
}
#[test]
fn test_type_key_generates_type_relationship() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Person\n---\n# Alice\n";
let entry = parse_test_entry(&dir, "person/alice.md", content);
assert_eq!(
entry.relationships.get("Type").unwrap(),
&vec!["[[type/person]]".to_string()]
);
}
#[test]
fn test_type_key_not_in_relationships_as_generic() {
let dir = TempDir::new().unwrap();
let content = "---\ntype: Note\nHas:\n - \"[[task/foo]]\"\n---\n# Test\n";
let entry = parse_test_entry(&dir, "note/test.md", content);
// "type" key itself should not appear as a relationship (it's in SKIP_KEYS)
// Only "Has" and the auto-generated "Type" should be relationships
assert_eq!(entry.relationships.len(), 2);
assert!(entry.relationships.get("Has").is_some());
assert!(entry.relationships.get("Type").is_some());
}
// --- outgoing_links tests ---
#[test]

View File

@@ -13,6 +13,7 @@ import { StatusBar } from './components/StatusBar'
import { SettingsPanel } from './components/SettingsPanel'
import { GitHubVaultModal } from './components/GitHubVaultModal'
import { WelcomeScreen } from './components/WelcomeScreen'
import { useMcpRegistration } from './hooks/useMcpRegistration'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useSettings } from './hooks/useSettings'
import { useNoteActions } from './hooks/useNoteActions'
@@ -123,6 +124,7 @@ function App() {
const { settings, saveSettings } = useSettings()
useEffect(() => { setApiKey(settings.anthropic_key ?? '') }, [settings.anthropic_key])
useMcpRegistration(resolvedPath, setToastMessage)
const autoSync = useAutoSync({
vaultPath: resolvedPath,
@@ -374,6 +376,7 @@ function App() {
canGoForward={navHistory.canGoForward}
onGoBack={handleGoBack}
onGoForward={handleGoForward}
leftPanelsCollapsed={!sidebarVisible && !noteListVisible}
/>
</div>
</div>

View File

@@ -0,0 +1,62 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { AiActionCard } from './AiActionCard'
describe('AiActionCard', () => {
it('renders label text', () => {
render(<AiActionCard tool="create_note" label="Created test.md" status="done" />)
expect(screen.getByText('Created test.md')).toBeTruthy()
})
it('shows pending spinner', () => {
render(<AiActionCard tool="search_notes" label="Searching..." status="pending" />)
expect(screen.getByTestId('status-pending')).toBeTruthy()
})
it('shows done check', () => {
render(<AiActionCard tool="create_note" label="Created" status="done" />)
expect(screen.getByTestId('status-done')).toBeTruthy()
})
it('shows error icon', () => {
render(<AiActionCard tool="delete_note" label="Failed" status="error" />)
expect(screen.getByTestId('status-error')).toBeTruthy()
})
it('is clickable when path and onOpenNote provided', () => {
const onOpenNote = vi.fn()
render(<AiActionCard tool="create_note" label="Open note" path="/vault/test.md" status="done" onOpenNote={onOpenNote} />)
fireEvent.click(screen.getByTestId('ai-action-card'))
expect(onOpenNote).toHaveBeenCalledWith('/vault/test.md')
})
it('has button role when clickable', () => {
render(<AiActionCard tool="create_note" label="Open note" path="/vault/test.md" status="done" onOpenNote={vi.fn()} />)
expect(screen.getByRole('button')).toBeTruthy()
})
it('is not clickable without path', () => {
const onOpenNote = vi.fn()
render(<AiActionCard tool="create_note" label="Created" status="done" onOpenNote={onOpenNote} />)
fireEvent.click(screen.getByTestId('ai-action-card'))
expect(onOpenNote).not.toHaveBeenCalled()
})
it('is not clickable without onOpenNote', () => {
render(<AiActionCard tool="create_note" label="Created" path="/vault/test.md" status="done" />)
const card = screen.getByTestId('ai-action-card')
expect(card.getAttribute('role')).toBeNull()
})
it('uses lighter background for ui_ tools', () => {
render(<AiActionCard tool="ui_open_tab" label="Opened tab" status="done" />)
const card = screen.getByTestId('ai-action-card')
expect(card.style.background).toContain('0.06')
})
it('uses standard background for vault tools', () => {
render(<AiActionCard tool="create_note" label="Created" status="done" />)
const card = screen.getByTestId('ai-action-card')
expect(card.style.background).toContain('0.1')
})
})

View File

@@ -0,0 +1,69 @@
import type { ReactNode } from 'react'
import {
PencilSimple, MagnifyingGlass, Link, Trash, ChartBar, Eye, Sparkle,
CircleNotch, CheckCircle, XCircle,
} from '@phosphor-icons/react'
export type AiActionStatus = 'pending' | 'done' | 'error'
export interface AiActionCardProps {
tool: string
label: string
path?: string
status: AiActionStatus
onOpenNote?: (path: string) => void
}
type IconRenderer = (size: number) => ReactNode
const TOOL_ICON_MAP: Record<string, IconRenderer> = {
create_note: (s) => <PencilSimple size={s} />,
edit_note_frontmatter: (s) => <PencilSimple size={s} />,
append_to_note: (s) => <PencilSimple size={s} />,
search_notes: (s) => <MagnifyingGlass size={s} />,
list_notes: (s) => <MagnifyingGlass size={s} />,
link_notes: (s) => <Link size={s} />,
delete_note: (s) => <Trash size={s} />,
vault_context: (s) => <ChartBar size={s} />,
ui_open_note: (s) => <Eye size={s} />,
ui_open_tab: (s) => <Eye size={s} />,
ui_highlight: (s) => <Sparkle size={s} />,
ui_set_filter: (s) => <Sparkle size={s} />,
}
const DEFAULT_ICON: IconRenderer = (s) => <PencilSimple size={s} />
function StatusIndicator({ status }: { status: AiActionStatus }) {
if (status === 'pending') {
return <CircleNotch size={14} className="ai-spin text-muted-foreground" data-testid="status-pending" />
}
if (status === 'done') {
return <CheckCircle size={14} weight="fill" style={{ color: 'var(--accent-green)' }} data-testid="status-done" />
}
return <XCircle size={14} weight="fill" style={{ color: 'var(--destructive)' }} data-testid="status-error" />
}
export function AiActionCard({ tool, label, path, status, onOpenNote }: AiActionCardProps) {
const renderIcon = TOOL_ICON_MAP[tool] ?? DEFAULT_ICON
const isClickable = !!path && !!onOpenNote
const isUiTool = tool.startsWith('ui_')
return (
<div
className="flex items-center gap-2 rounded"
style={{
padding: '6px 10px',
fontSize: 12,
background: isUiTool ? 'rgba(74, 158, 255, 0.06)' : 'rgba(74, 158, 255, 0.1)',
cursor: isClickable ? 'pointer' : 'default',
}}
onClick={isClickable ? () => onOpenNote(path) : undefined}
role={isClickable ? 'button' : undefined}
data-testid="ai-action-card"
>
<span className="shrink-0 text-muted-foreground">{renderIcon(14)}</span>
<span className="flex-1 truncate">{label}</span>
<StatusIndicator status={status} />
</div>
)
}

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { AiMessage } from './AiMessage'
describe('AiMessage', () => {
it('renders user message', () => {
render(<AiMessage userMessage="Hello AI" actions={[]} />)
expect(screen.getByText('Hello AI')).toBeTruthy()
})
it('renders response text', () => {
render(<AiMessage userMessage="Ask" actions={[]} response="Here is the answer" />)
expect(screen.getByText('Here is the answer')).toBeTruthy()
})
it('shows undo button with response', () => {
render(<AiMessage userMessage="Ask" actions={[]} response="Done" />)
expect(screen.getByTestId('undo-button')).toBeTruthy()
})
it('renders reasoning toggle collapsed by default', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." actions={[]} />)
expect(screen.getByTestId('reasoning-toggle')).toBeTruthy()
expect(screen.queryByTestId('reasoning-content')).toBeNull()
})
it('expands reasoning on toggle click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." actions={[]} />)
fireEvent.click(screen.getByTestId('reasoning-toggle'))
expect(screen.getByTestId('reasoning-content')).toBeTruthy()
expect(screen.getByText('Thinking about it...')).toBeTruthy()
})
it('collapses reasoning on second click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking..." actions={[]} />)
fireEvent.click(screen.getByTestId('reasoning-toggle'))
expect(screen.getByTestId('reasoning-content')).toBeTruthy()
fireEvent.click(screen.getByTestId('reasoning-toggle'))
expect(screen.queryByTestId('reasoning-content')).toBeNull()
})
it('renders action cards', () => {
render(
<AiMessage
userMessage="Do something"
actions={[
{ tool: 'create_note', label: 'Created test.md', status: 'done' },
{ tool: 'search_notes', label: 'Searched', status: 'pending' },
]}
/>,
)
expect(screen.getAllByTestId('ai-action-card')).toHaveLength(2)
})
it('passes onOpenNote to action cards', () => {
const onOpenNote = vi.fn()
render(
<AiMessage
userMessage="Do"
actions={[{ tool: 'create_note', label: 'Open', path: '/vault/note.md', status: 'done' }]}
onOpenNote={onOpenNote}
/>,
)
fireEvent.click(screen.getByTestId('ai-action-card'))
expect(onOpenNote).toHaveBeenCalledWith('/vault/note.md')
})
it('shows streaming indicator when streaming without response', () => {
const { container } = render(
<AiMessage userMessage="Ask" actions={[]} isStreaming />,
)
expect(container.querySelector('.typing-dot')).toBeTruthy()
})
it('does not show streaming indicator when response is present', () => {
const { container } = render(
<AiMessage userMessage="Ask" actions={[]} response="Done" isStreaming />,
)
expect(container.querySelector('.typing-dot')).toBeNull()
})
it('does not render reasoning block when no reasoning', () => {
render(<AiMessage userMessage="Ask" actions={[]} />)
expect(screen.queryByTestId('reasoning-toggle')).toBeNull()
})
it('does not render actions when empty array', () => {
render(<AiMessage userMessage="Ask" actions={[]} />)
expect(screen.queryByTestId('ai-action-card')).toBeNull()
})
})

View File

@@ -0,0 +1,134 @@
import { useState } from 'react'
import { CaretRight, CaretDown, Brain, ArrowCounterClockwise } from '@phosphor-icons/react'
import { AiActionCard, type AiActionStatus } from './AiActionCard'
export interface AiAction {
tool: string
label: string
path?: string
status: AiActionStatus
}
export interface AiMessageProps {
userMessage: string
reasoning?: string
actions: AiAction[]
response?: string
isStreaming?: boolean
onOpenNote?: (path: string) => void
}
function UserBubble({ content }: { content: string }) {
return (
<div className="flex justify-end" style={{ marginBottom: 8 }}>
<div
style={{
background: 'var(--muted)',
color: 'var(--foreground)',
borderRadius: '12px 12px 2px 12px',
maxWidth: '85%',
padding: '8px 12px',
fontSize: 13,
lineHeight: 1.5,
}}
>
{content}
</div>
</div>
)
}
function ReasoningBlock({ text, expanded, onToggle }: {
text: string; expanded: boolean; onToggle: () => void
}) {
return (
<div style={{ marginBottom: 8 }}>
<button
className="flex items-center gap-1.5 w-full border-none bg-transparent cursor-pointer p-0 text-muted-foreground hover:text-foreground transition-colors"
style={{ fontSize: 12, padding: '4px 0' }}
onClick={onToggle}
data-testid="reasoning-toggle"
>
<Brain size={14} />
<span>Reasoning</span>
{expanded ? <CaretDown size={12} /> : <CaretRight size={12} />}
</button>
{expanded && (
<div
className="text-muted-foreground"
style={{ fontSize: 12, lineHeight: 1.5, padding: '4px 0 4px 20px' }}
data-testid="reasoning-content"
>
{text}
</div>
)}
</div>
)
}
function ActionCardsList({ actions, onOpenNote }: {
actions: AiAction[]; onOpenNote?: (path: string) => void
}) {
return (
<div className="flex flex-col gap-1" style={{ marginBottom: 8 }}>
{actions.map((action, i) => (
<AiActionCard
key={`${action.tool}-${i}`}
tool={action.tool}
label={action.label}
path={action.path}
status={action.status}
onOpenNote={onOpenNote}
/>
))}
</div>
)
}
function ResponseBlock({ text }: { text: string }) {
return (
<div style={{ marginBottom: 4 }}>
<div style={{ fontSize: 13, lineHeight: 1.6 }}>{text}</div>
<button
className="flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
style={{ fontSize: 11, marginTop: 4 }}
data-testid="undo-button"
>
<ArrowCounterClockwise size={12} />
<span>Undo</span>
</button>
</div>
)
}
function StreamingIndicator() {
return (
<div className="flex items-center gap-2 text-muted-foreground" style={{ fontSize: 12, padding: '4px 0' }}>
<div className="flex gap-1">
<span className="typing-dot" />
<span className="typing-dot" style={{ animationDelay: '0.2s' }} />
<span className="typing-dot" style={{ animationDelay: '0.4s' }} />
</div>
</div>
)
}
export function AiMessage({ userMessage, reasoning, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
const [reasoningExpanded, setReasoningExpanded] = useState(false)
return (
<div data-testid="ai-message" style={{ marginBottom: 16 }}>
<UserBubble content={userMessage} />
{reasoning && (
<ReasoningBlock
text={reasoning}
expanded={reasoningExpanded}
onToggle={() => setReasoningExpanded(!reasoningExpanded)}
/>
)}
{actions.length > 0 && <ActionCardsList actions={actions} onOpenNote={onOpenNote} />}
{response && <ResponseBlock text={response} />}
{isStreaming && !response && <StreamingIndicator />}
</div>
)
}

View File

@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { AiPanel } from './AiPanel'
describe('AiPanel', () => {
it('renders panel with AI header', () => {
render(<AiPanel onClose={vi.fn()} />)
expect(screen.getByText('AI')).toBeTruthy()
})
it('renders data-testid ai-panel', () => {
render(<AiPanel onClose={vi.fn()} />)
expect(screen.getByTestId('ai-panel')).toBeTruthy()
})
it('calls onClose when close button is clicked', () => {
const onClose = vi.fn()
render(<AiPanel onClose={onClose} />)
// Find close button inside the panel header (last button with X icon)
const panel = screen.getByTestId('ai-panel')
const buttons = panel.querySelectorAll('button')
const closeBtn = buttons[0] // First button in panel is the close button in header
fireEvent.click(closeBtn)
expect(onClose).toHaveBeenCalled()
})
it('renders mock messages', () => {
render(<AiPanel onClose={vi.fn()} />)
const messages = screen.getAllByTestId('ai-message')
expect(messages.length).toBeGreaterThanOrEqual(2)
})
it('renders user message text from mock data', () => {
render(<AiPanel onClose={vi.fn()} />)
expect(screen.getByText('Crea una nota evento per la riunione con Marco domani')).toBeTruthy()
})
it('renders response text from mock data', () => {
render(<AiPanel onClose={vi.fn()} />)
expect(screen.getByText(/Ho creato la nota evento/)).toBeTruthy()
})
it('renders disabled input bar', () => {
render(<AiPanel onClose={vi.fn()} />)
const input = screen.getByPlaceholderText('Ask the AI agent...')
expect(input).toBeTruthy()
expect((input as HTMLInputElement).disabled).toBe(true)
})
it('passes onOpenNote to messages', () => {
const onOpenNote = vi.fn()
render(<AiPanel onClose={vi.fn()} onOpenNote={onOpenNote} />)
// Action cards with paths should be clickable
const cards = screen.getAllByTestId('ai-action-card')
const clickableCard = cards.find(card => card.getAttribute('role') === 'button')
if (clickableCard) {
fireEvent.click(clickableCard)
expect(onOpenNote).toHaveBeenCalled()
}
})
it('renders action cards from mock data', () => {
render(<AiPanel onClose={vi.fn()} />)
const cards = screen.getAllByTestId('ai-action-card')
expect(cards.length).toBeGreaterThanOrEqual(4) // First mock has 4 actions
})
})

120
src/components/AiPanel.tsx Normal file
View File

@@ -0,0 +1,120 @@
import { useRef, useEffect } from 'react'
import { Robot, X, PaperPlaneRight } from '@phosphor-icons/react'
import { AiMessage, type AiAction } from './AiMessage'
export interface AiAgentMessage {
userMessage: string
reasoning?: string
actions: AiAction[]
response?: string
isStreaming?: boolean
}
interface AiPanelProps {
onClose: () => void
onOpenNote?: (path: string) => void
}
const MOCK_MESSAGES: AiAgentMessage[] = [
{
userMessage: 'Crea una nota evento per la riunione con Marco domani',
reasoning: "L'utente vuole creare un evento per una riunione con Marco. Devo creare un file in event/, impostare la data corretta (domani = 2026-03-01), e linkare Marco come partecipante.",
actions: [
{ tool: 'vault_context', label: 'Loaded vault context', status: 'done' },
{ tool: 'create_note', label: 'Created: 2026-03-01-meeting-marco.md', path: 'event/2026-03-01-meeting-marco.md', status: 'done' },
{ tool: 'link_notes', label: 'Linked: Marco \u2192 meeting', status: 'done' },
{ tool: 'ui_open_tab', label: 'Opened tab', path: 'event/2026-03-01-meeting-marco.md', status: 'done' },
],
response: 'Ho creato la nota evento e linkato Marco come partecipante. La trovi già aperta in un nuovo tab.',
},
{
userMessage: 'Cerca tutte le note su TypeScript',
actions: [
{ tool: 'search_notes', label: 'Searched: TypeScript', status: 'done' },
{ tool: 'ui_set_filter', label: 'Filtered results', status: 'done' },
],
response: 'Ho trovato 12 note che menzionano TypeScript. Ho applicato il filtro nella lista note.',
},
]
function PanelHeader({ onClose }: { onClose: () => void }) {
return (
<div
className="flex shrink-0 items-center border-b border-border"
style={{ height: 45, padding: '0 12px', gap: 8 }}
>
<Robot size={16} className="shrink-0 text-muted-foreground" />
<span className="flex-1 text-muted-foreground" style={{ fontSize: 13, fontWeight: 600 }}>
AI
</span>
<button
className="shrink-0 border-none bg-transparent p-1 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={onClose}
title="Close AI panel (\u2318I)"
>
<X size={16} />
</button>
</div>
)
}
function MessageHistory({ messages, onOpenNote }: {
messages: AiAgentMessage[]; onOpenNote?: (path: string) => void
}) {
const endRef = useRef<HTMLDivElement>(null)
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages.length])
return (
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
{messages.map((msg, i) => (
<AiMessage key={i} {...msg} onOpenNote={onOpenNote} />
))}
<div ref={endRef} />
</div>
)
}
function InputBar() {
return (
<div
className="flex shrink-0 items-center gap-2 border-t border-border"
style={{ padding: '8px 12px' }}
>
<input
className="flex-1 border border-border bg-transparent text-muted-foreground"
style={{ fontSize: 13, borderRadius: 8, padding: '8px 10px', outline: 'none', fontFamily: 'inherit' }}
placeholder="Ask the AI agent..."
disabled
/>
<button
className="shrink-0 flex items-center justify-center border-none"
style={{
background: 'var(--muted)',
color: 'var(--muted-foreground)',
borderRadius: 8, width: 32, height: 34,
cursor: 'not-allowed',
}}
disabled
title="Coming in Task 3"
>
<PaperPlaneRight size={16} />
</button>
</div>
)
}
export function AiPanel({ onClose, onOpenNote }: AiPanelProps) {
return (
<aside
className="flex flex-1 flex-col overflow-hidden border-l border-border bg-background text-foreground"
data-testid="ai-panel"
>
<PanelHeader onClose={onClose} />
<MessageHistory messages={MOCK_MESSAGES} onOpenNote={onOpenNote} />
<InputBar />
</aside>
)
}

View File

@@ -12,6 +12,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import { CalendarIcon, XIcon, Check, X, Type, ToggleLeft, Circle, Link, Tag } from 'lucide-react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { getTypeIcon } from './NoteItem'
import { countWords } from '../utils/wikilinks'
import {
type PropertyDisplayMode,
@@ -116,14 +117,34 @@ function TagsValue({ propKey, value, isEditing, vaultTags, onSave, onStartEdit }
{value.map(tag => {
const style = getTagStyle(tag)
return (
<span key={tag} className="group/tag inline-flex items-center gap-0.5 rounded-full" style={{ backgroundColor: style.bg, padding: '1px 6px' }}>
<span style={{ color: style.color, fontFamily: "'IBM Plex Mono', monospace", fontSize: 10, fontWeight: 600, letterSpacing: '1.2px', textTransform: 'uppercase' }}>{tag}</span>
<span
key={tag}
className="group/tag relative inline-flex items-center overflow-hidden rounded-full"
style={{ backgroundColor: style.bg, padding: '1px 6px', maxWidth: 120 }}
>
<span
className="transition-[max-width] duration-150 group-hover/tag:[mask-image:linear-gradient(to_right,black_60%,transparent_100%)]"
style={{
color: style.color,
fontFamily: "'IBM Plex Mono', monospace",
fontSize: 10,
fontWeight: 600,
letterSpacing: '1.2px',
textTransform: 'uppercase' as const,
overflow: 'hidden',
whiteSpace: 'nowrap' as const,
}}
>
{tag}
</span>
<button
className="border-none bg-transparent p-0 leading-none opacity-0 transition-opacity group-hover/tag:opacity-100"
style={{ color: style.color, fontSize: 10 }}
className="ml-0.5 max-w-0 overflow-hidden border-none bg-transparent p-0 leading-none opacity-0 transition-all duration-150 group-hover/tag:max-w-[14px] group-hover/tag:opacity-100"
style={{ color: style.color, fontSize: 10, flexShrink: 0 }}
onClick={() => handleRemove(tag)}
title={`Remove ${tag}`}
>&times;</button>
>
&times;
</button>
</span>
)
})}
@@ -482,9 +503,24 @@ function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null
)
}
function TypeSelector({ isA, customColorKey, availableTypes, typeColorKeys, onUpdateProperty, onNavigate }: {
function TypeSelectorItem({ type, typeColorKeys, typeIconKeys }: {
type: string; typeColorKeys: Record<string, string | null>; typeIconKeys: Record<string, string | null>
}) {
const Icon = getTypeIcon(type, typeIconKeys[type])
const color = getTypeColor(type, typeColorKeys[type])
return (
<>
{/* eslint-disable-next-line react-hooks/static-components -- icon from static map lookup */}
<Icon width={14} height={14} style={{ color }} />
{type}
</>
)
}
function TypeSelector({ isA, customColorKey, availableTypes, typeColorKeys, typeIconKeys, onUpdateProperty, onNavigate }: {
isA?: string | null; customColorKey?: string | null; availableTypes: string[]
typeColorKeys: Record<string, string | null>
typeIconKeys: Record<string, string | null>
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
onNavigate?: (target: string) => void
}) {
@@ -511,8 +547,7 @@ function TypeSelector({ isA, customColorKey, availableTypes, typeColorKeys, onUp
<SelectSeparator />
{options.map(type => (
<SelectItem key={type} value={type}>
<Circle className="size-3" style={{ color: getTypeColor(type, typeColorKeys[type]), fill: getTypeColor(type, typeColorKeys[type]) }} />
{type}
<TypeSelectorItem type={type} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} />
</SelectItem>
))}
</SelectContent>
@@ -642,11 +677,16 @@ function reconcileListUpdate(
function deriveTypeInfo(entries: VaultEntry[] | undefined, entryIsA: string | null) {
const typeEntries = (entries ?? []).filter(e => e.isA === 'Type')
const typeColorKeys: Record<string, string | null> = {}
for (const e of typeEntries) { typeColorKeys[e.title] = e.color ?? null }
const typeIconKeys: Record<string, string | null> = {}
for (const e of typeEntries) {
typeColorKeys[e.title] = e.color ?? null
typeIconKeys[e.title] = e.icon ?? null
}
return {
availableTypes: typeEntries.map(e => e.title).sort((a, b) => a.localeCompare(b)),
customColorKey: entryIsA ? (typeColorKeys[entryIsA] ?? null) : null,
typeColorKeys,
typeIconKeys,
}
}
@@ -714,7 +754,7 @@ function usePropertyPanelState(deps: PropertyPanelDeps) {
const [showAddDialog, setShowAddDialog] = useState(false)
const [displayOverrides, setDisplayOverrides] = useState(() => loadDisplayModeOverrides())
const { availableTypes, customColorKey, typeColorKeys } = useMemo(() => deriveTypeInfo(entries, entryIsA), [entries, entryIsA])
const { availableTypes, customColorKey, typeColorKeys, typeIconKeys } = useMemo(() => deriveTypeInfo(entries, entryIsA), [entries, entryIsA])
const vaultStatuses = useMemo(() => collectVaultStatuses(entries), [entries])
const vaultTagsByKey = useMemo(() => collectAllVaultTags(entries, allContent), [entries, allContent])
const propertyEntries = useMemo(() => Object.entries(frontmatter).filter(isVisibleProperty), [frontmatter])
@@ -746,7 +786,7 @@ function usePropertyPanelState(deps: PropertyPanelDeps) {
return {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
availableTypes, customColorKey, typeColorKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
}
}
@@ -767,7 +807,7 @@ export function DynamicPropertiesPanel({
}) {
const {
editingKey, setEditingKey, showAddDialog, setShowAddDialog, displayOverrides,
availableTypes, customColorKey, typeColorKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
availableTypes, customColorKey, typeColorKeys, typeIconKeys, vaultStatuses, vaultTagsByKey, propertyEntries,
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, allContent, onUpdateProperty, onDeleteProperty, onAddProperty })
@@ -776,7 +816,7 @@ export function DynamicPropertiesPanel({
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2">
<TypeSelector isA={entry.isA} customColorKey={customColorKey} availableTypes={availableTypes} typeColorKeys={typeColorKeys} onUpdateProperty={onUpdateProperty} onNavigate={onNavigate} />
<TypeSelector isA={entry.isA} customColorKey={customColorKey} availableTypes={availableTypes} typeColorKeys={typeColorKeys} typeIconKeys={typeIconKeys} onUpdateProperty={onUpdateProperty} onNavigate={onNavigate} />
{propertyEntries.map(([key, value]) => (
<PropertyRow
key={key} propKey={key} value={value}

View File

@@ -59,6 +59,7 @@ interface EditorProps {
canGoForward?: boolean
onGoBack?: () => void
onGoForward?: () => void
leftPanelsCollapsed?: boolean
}
function EditorEmptyState() {
@@ -80,7 +81,7 @@ export const Editor = memo(function Editor({
vaultPath,
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
onRenameTab, onContentChange, onTitleSync,
canGoBack, canGoForward, onGoBack, onGoForward,
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
}: EditorProps) {
const vaultPathRef = useRef(vaultPath)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
@@ -133,6 +134,7 @@ export const Editor = memo(function Editor({
canGoForward={canGoForward}
onGoBack={onGoBack}
onGoForward={onGoForward}
leftPanelsCollapsed={leftPanelsCollapsed}
/>
<div className="flex flex-1 min-h-0">
{tabs.length === 0
@@ -158,6 +160,7 @@ export const Editor = memo(function Editor({
onRestoreNote={onRestoreNote}
onArchiveNote={onArchiveNote}
onUnarchiveNote={onUnarchiveNote}
vaultPath={vaultPath}
/>
}
{showRightPanel && <ResizeHandle onResize={onInspectorResize} />}

View File

@@ -31,6 +31,7 @@ interface EditorContentProps {
onRestoreNote?: (path: string) => void
onArchiveNote?: (path: string) => void
onUnarchiveNote?: (path: string) => void
vaultPath?: string
}
function EditorLoadingSkeleton() {
@@ -96,7 +97,7 @@ function ActiveTabBreadcrumb({ activeTab, props }: {
export function EditorContent({
activeTab, isLoadingNewTab, entries, editor,
diffMode, diffContent, onToggleDiff,
onNavigateWikilink, onEditorChange,
onNavigateWikilink, onEditorChange, vaultPath,
...breadcrumbProps
}: EditorContentProps) {
return (
@@ -105,7 +106,7 @@ export function EditorContent({
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
{!diffMode && activeTab && (
<div style={{ display: 'flex', flex: 1, flexDirection: 'column', minHeight: 0 }}>
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} />
<SingleEditorView editor={editor} entries={entries} onNavigateWikilink={onNavigateWikilink} onChange={onEditorChange} vaultPath={vaultPath} />
</div>
)}
{isLoadingNewTab && !diffMode && <EditorLoadingSkeleton />}

View File

@@ -37,7 +37,7 @@ function matchEntries(entries: VaultEntry[], typeEntryMap: Record<string, VaultE
return matches.slice(0, MAX_RESULTS).map(e => {
const isA = e.isA
const te = typeEntryMap[isA ?? '']
const noteType = isA && isA !== 'Note' ? isA : undefined
const noteType = isA || undefined
return {
title: e.title,
noteType,

View File

@@ -1107,6 +1107,72 @@ describe('NoteList — multi-select', () => {
})
})
// --- Type note filtering tests ---
const typeEntry: VaultEntry = {
path: '/Users/luca/Laputa/types/project.md',
filename: 'project.md',
title: 'Project',
isA: 'Type',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 200,
snippet: 'Defines the Project type.',
wordCount: 50,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
}
const entriesWithType = [...mockEntries, typeEntry]
describe('NoteList — type note filtering', () => {
beforeEach(() => {
noopSelect.mockClear()
noopReplace.mockClear()
})
it('does not show type note PinnedCard when browsing a sectionGroup', () => {
render(
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
// The type note snippet should NOT be visible (PinnedCard was removed)
expect(screen.queryByText('Defines the Project type.')).not.toBeInTheDocument()
// But the Project instance should still be in the list
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
})
it('shows clickable header title that navigates to type note', () => {
render(
<NoteList entries={entriesWithType} selection={{ kind: 'sectionGroup', type: 'Project' }} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
const headerLink = screen.getByTestId('type-header-link')
expect(headerLink).toBeInTheDocument()
expect(headerLink.textContent).toBe('Project')
expect(headerLink.style.cursor).toBe('pointer')
fireEvent.click(headerLink)
expect(noopReplace).toHaveBeenCalledWith(typeEntry)
})
it('header is not clickable when not viewing a type section', () => {
render(
<NoteList entries={entriesWithType} selection={allSelection} selectedNote={null} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} allContent={{}} onCreateNote={vi.fn()} />
)
expect(screen.queryByTestId('type-header-link')).not.toBeInTheDocument()
})
})
describe('NoteList — traffic light padding when sidebar collapsed', () => {
it('adds left padding to header when sidebarCollapsed is true', () => {
const { container } = render(

View File

@@ -136,31 +136,24 @@ function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggl
)
}
function ListViewHeader({ typeDocument, isTrashView, expiredTrashCount, typeEntryMap, onClickNote }: {
typeDocument: VaultEntry | null; isTrashView: boolean; expiredTrashCount: number
typeEntryMap: Record<string, VaultEntry>; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
function ListViewHeader({ isTrashView, expiredTrashCount }: {
isTrashView: boolean; expiredTrashCount: number
}) {
return (
<>
{typeDocument && <PinnedCard entry={typeDocument} typeEntryMap={typeEntryMap} onClickNote={onClickNote} />}
<TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
</>
)
return <TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
}
function ListView({ typeDocument, isTrashView, isChangesView, expiredTrashCount, searched, query, renderItem, typeEntryMap, onClickNote }: {
typeDocument: VaultEntry | null; isTrashView: boolean; isChangesView?: boolean; expiredTrashCount: number
function ListView({ isTrashView, isChangesView, expiredTrashCount, searched, query, renderItem }: {
isTrashView: boolean; isChangesView?: boolean; expiredTrashCount: number
searched: VaultEntry[]; query: string
renderItem: (entry: VaultEntry) => React.ReactNode
typeEntryMap: Record<string, VaultEntry>; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
}) {
const emptyText = isChangesView ? 'No pending changes' : isTrashView ? 'Trash is empty' : (query ? 'No matching notes' : 'No notes found')
const hasHeader = typeDocument || (isTrashView && expiredTrashCount > 0)
const hasHeader = isTrashView && expiredTrashCount > 0
if (searched.length === 0) {
return (
<div className="h-full overflow-y-auto">
{hasHeader && <ListViewHeader typeDocument={typeDocument} isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} typeEntryMap={typeEntryMap} onClickNote={onClickNote} />}
{hasHeader && <ListViewHeader isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} />}
<EmptyMessage text={emptyText} />
</div>
)
@@ -172,7 +165,7 @@ function ListView({ typeDocument, isTrashView, isChangesView, expiredTrashCount,
data={searched}
overscan={200}
components={{
Header: hasHeader ? () => <ListViewHeader typeDocument={typeDocument} isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} typeEntryMap={typeEntryMap} onClickNote={onClickNote} /> : undefined,
Header: hasHeader ? () => <ListViewHeader isTrashView={isTrashView} expiredTrashCount={expiredTrashCount} /> : undefined,
}}
itemContent={(_index, entry) => renderItem(entry)}
/>
@@ -369,7 +362,14 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
return (
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
<div className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-4" onMouseDown={onDragMouseDown} style={{ cursor: 'default', paddingLeft: sidebarCollapsed ? 80 : undefined }}>
<h3 className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold">{resolveHeaderTitle(selection, typeDocument)}</h3>
<h3
className="m-0 min-w-0 flex-1 truncate text-[14px] font-semibold"
style={typeDocument ? { cursor: 'pointer' } : undefined}
onClick={typeDocument ? () => onReplaceActiveTab(typeDocument) : undefined}
data-testid={typeDocument ? 'type-header-link' : undefined}
>
{resolveHeaderTitle(selection, typeDocument)}
</h3>
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
{!isEntityView && <SortDropdown groupLabel="__list__" current={listSort} direction={listDirection} onChange={handleSortChange} />}
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={() => { setSearchVisible(!searchVisible); if (searchVisible) setSearch('') }} title="Search notes">
@@ -391,7 +391,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
{isEntityView && selection.kind === 'entity' ? (
<EntityView entity={selection.entry} groups={searchedGroups} query={query} collapsedGroups={collapsedGroups} sortPrefs={sortPrefs} onToggleGroup={toggleGroup} onSortChange={handleSortChange} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
) : (
<ListView typeDocument={typeDocument} isTrashView={isTrashView} isChangesView={isChangesView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} />
<ListView isTrashView={isTrashView} isChangesView={isChangesView} expiredTrashCount={expiredTrashCount} searched={searched} query={query} renderItem={renderItem} />
)}
</div>

View File

@@ -193,7 +193,7 @@ function SearchContent({
{results.map((result, i) => {
const entry = entryLookup.get(result.path)
const isA = entry?.isA ?? result.noteType
const noteType = isA && isA !== 'Note' ? isA : null
const noteType = isA || null
const te = typeEntryMap[isA ?? '']
const typeColor = noteType ? getTypeColor(isA, te?.color) : undefined
const TypeIcon = getTypeIcon(isA ?? null, te?.icon)

View File

@@ -227,7 +227,7 @@ describe('Sidebar', () => {
expect(screen.getByText('Favorites')).toBeInTheDocument()
})
it('renders section group headers with new labels', () => {
it('renders section group headers only for types present in entries', () => {
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.getByText('Projects')).toBeInTheDocument()
expect(screen.getByText('Experiments')).toBeInTheDocument()
@@ -236,7 +236,8 @@ describe('Sidebar', () => {
expect(screen.getByText('People')).toBeInTheDocument()
expect(screen.getByText('Events')).toBeInTheDocument()
expect(screen.getByText('Topics')).toBeInTheDocument()
expect(screen.getByText('Types')).toBeInTheDocument()
// No entries with isA: 'Type' in mockEntries → Types section absent
expect(screen.queryByText('Types')).not.toBeInTheDocument()
})
it('shows entity names under their section groups after expanding', () => {
@@ -328,7 +329,7 @@ describe('Sidebar', () => {
const onCreateType = vi.fn()
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onCreateType={onCreateType} />)
const createButtons = screen.getAllByTitle(/^New /)
expect(createButtons.length).toBe(8) // Projects, Experiments, Responsibilities, Procedures, People, Events, Topics, Types
expect(createButtons.length).toBe(7) // Projects, Experiments, Responsibilities, Procedures, People, Events, Topics (no Type entries → no Types section)
})
it('calls onCreateType with correct type when + button is clicked', () => {
@@ -445,14 +446,39 @@ describe('Sidebar', () => {
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
icon: null,
color: null,
order: null,
outgoingLinks: [],
},
{
path: '/vault/book/ddia.md',
filename: 'ddia.md',
title: 'Designing Data-Intensive Applications',
isA: 'Book',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
trashed: false,
trashedAt: null,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 400,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
outgoingLinks: [],
},
]
it('renders custom type sections derived from Type entries', () => {
it('renders custom type sections derived from actual entries', () => {
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} onCreateType={() => {}} />)
expect(screen.getByText('Books')).toBeInTheDocument()
expect(screen.getByText('Recipes')).toBeInTheDocument()
@@ -478,6 +504,36 @@ describe('Sidebar', () => {
expect(onCreateNewType).toHaveBeenCalled()
})
it('does not show section for type with zero active entries', () => {
// Only Type definitions exist for Book, no actual Book instances
const entriesNoBookInstance = entriesWithCustomTypes.filter((e) => !(e.isA === 'Book' && e.title !== 'Book'))
render(<Sidebar entries={entriesNoBookInstance} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.queryByText('Books')).not.toBeInTheDocument()
// Recipes still has an instance (Pasta Carbonara)
expect(screen.getByText('Recipes')).toBeInTheDocument()
})
it('hides type section when all entries of that type are trashed', () => {
const entriesWithTrashedOnly: VaultEntry[] = [
{
path: '/vault/event/cancelled.md', filename: 'cancelled.md', title: 'Cancelled Event',
isA: 'Event', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
cadence: null, archived: false, trashed: true, trashedAt: 1700000000,
modifiedAt: 1700000000, createdAt: null, fileSize: 100, snippet: '', wordCount: 0,
relationships: {}, icon: null, color: null, order: null, outgoingLinks: [],
},
]
render(<Sidebar entries={entriesWithTrashedOnly} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.queryByText('Events')).not.toBeInTheDocument()
})
it('shows no sections when entries list is empty', () => {
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} />)
expect(screen.queryByText('Projects')).not.toBeInTheDocument()
expect(screen.queryByText('People')).not.toBeInTheDocument()
expect(screen.queryByText('Events')).not.toBeInTheDocument()
})
it('does not show built-in types as custom sections', () => {
const projectTypeEntry: VaultEntry = {
path: '/vault/type/project.md',

View File

@@ -2,6 +2,7 @@ import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react'
import type { VaultEntry, SidebarSelection } from '../types'
import { resolveIcon } from '../utils/iconRegistry'
import { buildTypeEntryMap } from '../utils/typeColors'
import { pluralizeType } from '../hooks/useCommandRegistry'
import { TypeCustomizePopover } from './TypeCustomizePopover'
import { useSectionVisibility } from '../hooks/useSectionVisibility'
import {
@@ -48,7 +49,8 @@ const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
{ label: 'Types', type: 'Type', Icon: StackSimple },
]
const BUILT_IN_TYPES = new Set(BUILT_IN_SECTION_GROUPS.map((s) => s.type))
/** Metadata lookup for well-known types (icon/label only — NOT used to determine which sections to show) */
const BUILT_IN_TYPE_MAP = new Map(BUILT_IN_SECTION_GROUPS.map((sg) => [sg.type, sg]))
// --- Hooks ---
@@ -63,19 +65,31 @@ function useOutsideClick(ref: React.RefObject<HTMLElement | null>, isOpen: boole
}, [ref, isOpen, onClose])
}
function applyOverrides(typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
return BUILT_IN_SECTION_GROUPS.map((sg) => {
const te = typeEntryMap[sg.type]
if (!te?.icon && !te?.color) return sg
return { ...sg, Icon: te?.icon ? resolveIcon(te.icon) : sg.Icon, customColor: te?.color ?? null }
})
/** Collect unique isA values from active (non-trashed, non-archived) entries, excluding generic Note */
function collectActiveTypes(entries: VaultEntry[]): Set<string> {
const types = new Set<string>()
for (const e of entries) {
if (e.isA && e.isA !== 'Note' && !e.trashed && !e.archived) types.add(e.isA)
}
return types
}
function buildCustomSections(entries: VaultEntry[]): SectionGroup[] {
return entries
.filter((e) => e.isA === 'Type' && !BUILT_IN_TYPES.has(e.title))
.sort((a, b) => a.title.localeCompare(b.title))
.map((e) => ({ label: e.title + 's', type: e.title, Icon: resolveIcon(e.icon), customColor: e.color }))
/** Build a single SectionGroup for a type, using built-in metadata or Type entry for icon/label */
function buildSectionGroup(type: string, typeEntryMap: Record<string, VaultEntry>): SectionGroup {
const builtIn = BUILT_IN_TYPE_MAP.get(type)
const typeEntry = typeEntryMap[type]
const customColor = typeEntry?.color ?? null
if (builtIn) {
const Icon = typeEntry?.icon ? resolveIcon(typeEntry.icon) : builtIn.Icon
return { ...builtIn, Icon, customColor }
}
return { label: pluralizeType(type), type, Icon: resolveIcon(typeEntry?.icon ?? null), customColor }
}
/** Build sections dynamically from actual vault entries — only types with ≥1 active note appear */
function buildDynamicSections(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
const activeTypes = collectActiveTypes(entries)
return Array.from(activeTypes, (type) => buildSectionGroup(type, typeEntryMap))
}
function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
@@ -89,9 +103,8 @@ function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, Vault
function useSidebarSections(entries: VaultEntry[], isSectionVisible: (type: string) => boolean) {
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const allSectionGroups = useMemo(() => {
const built = applyOverrides(typeEntryMap)
const custom = buildCustomSections(entries)
return sortSections([...built, ...custom], typeEntryMap)
const sections = buildDynamicSections(entries, typeEntryMap)
return sortSections(sections, typeEntryMap)
}, [entries, typeEntryMap])
const visibleSections = useMemo(() => allSectionGroups.filter((g) => isSectionVisible(g.type)), [allSectionGroups, isSectionVisible])
const sectionIds = useMemo(() => visibleSections.map((g) => g.type), [visibleSections])

View File

@@ -11,18 +11,31 @@ import { WikilinkSuggestionMenu, type WikilinkSuggestionItem } from './WikilinkS
import type { VaultEntry } from '../types'
import { _wikilinkEntriesRef } from './editorSchema'
/** Insert an image block after the current cursor position. */
function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
const editorRef = useRef(editor)
useEffect(() => { editorRef.current = editor }, [editor])
return useCallback((url: string) => {
const e = editorRef.current
const cursorBlock = e.getTextCursorPosition().block
e.insertBlocks([{ type: 'image' as const, props: { url } }], cursorBlock, 'after')
}, [])
}
/** Single BlockNote editor view — content is swapped via replaceBlocks */
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange }: {
export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange, vaultPath }: {
editor: ReturnType<typeof useCreateBlockNote>
entries: VaultEntry[]
onNavigateWikilink: (target: string) => void
onChange?: () => void
vaultPath?: string
}) {
const navigateRef = useRef(onNavigateWikilink)
useEffect(() => { navigateRef.current = onNavigateWikilink }, [onNavigateWikilink])
const { cssVars } = useEditorTheme()
const containerRef = useRef<HTMLDivElement>(null)
const { isDragOver } = useImageDrop({ containerRef })
const onImageUrl = useInsertImageCallback(editor)
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
useEffect(() => {
_wikilinkEntriesRef.current = entries

View File

@@ -23,6 +23,7 @@ interface TabBarProps {
canGoForward?: boolean
onGoBack?: () => void
onGoForward?: () => void
leftPanelsCollapsed?: boolean
}
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
@@ -326,7 +327,7 @@ function TabBarActions({ onCreateNote }: { onCreateNote?: () => void }) {
export const TabBar = memo(function TabBar({
tabs, activeTabPath, getNoteStatus, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs, onRenameTab,
canGoBack, canGoForward, onGoBack, onGoForward,
canGoBack, canGoForward, onGoBack, onGoForward, leftPanelsCollapsed,
}: TabBarProps) {
const { dragIndex, dropIndex, handleDragStart, handleDragEnd, handleDragOver, handleDrop, handleBarDragLeave } = useTabDrag(onReorderTabs)
const [editingPath, setEditingPath] = useState<string | null>(null)
@@ -335,7 +336,7 @@ export const TabBar = memo(function TabBar({
return (
<div
className="flex shrink-0 items-stretch"
style={{ height: 52, background: 'var(--sidebar)' } as React.CSSProperties}
style={{ height: 52, background: 'var(--sidebar)', paddingLeft: leftPanelsCollapsed ? 80 : 0 } as React.CSSProperties}
onDragLeave={handleBarDragLeave}
>
<NavButtons canGoBack={canGoBack} canGoForward={canGoForward} onGoBack={onGoBack} onGoForward={onGoForward} />

View File

@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { renderHook, act } from '@testing-library/react'
import { useAiActivity } from './useAiActivity'
let lastWsInstance: MockWebSocket | null = null
class MockWebSocket {
onmessage: ((event: MessageEvent) => void) | null = null
onerror: (() => void) | null = null
close = vi.fn()
url: string
constructor(url: string) {
this.url = url
lastWsInstance = this // eslint-disable-line @typescript-eslint/no-this-alias
}
}
beforeEach(() => {
lastWsInstance = null
vi.stubGlobal('WebSocket', MockWebSocket)
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
function sendWsMessage(data: Record<string, unknown>) {
lastWsInstance?.onmessage?.(new MessageEvent('message', { data: JSON.stringify(data) }))
}
describe('useAiActivity', () => {
it('initializes with null highlight', () => {
const { result } = renderHook(() => useAiActivity())
expect(result.current.highlightElement).toBeNull()
expect(result.current.highlightPath).toBeNull()
})
it('connects to ws://localhost:9711', () => {
renderHook(() => useAiActivity())
expect(lastWsInstance).not.toBeNull()
expect(lastWsInstance!.url).toBe('ws://localhost:9711')
})
it('sets highlight on ui_action highlight message', () => {
const { result } = renderHook(() => useAiActivity())
act(() => {
sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'editor', path: '/vault/test.md' })
})
expect(result.current.highlightElement).toBe('editor')
expect(result.current.highlightPath).toBe('/vault/test.md')
})
it('auto-clears highlight after 800ms', () => {
const { result } = renderHook(() => useAiActivity())
act(() => {
sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'tab', path: '/vault/note.md' })
})
expect(result.current.highlightElement).toBe('tab')
act(() => { vi.advanceTimersByTime(800) })
expect(result.current.highlightElement).toBeNull()
expect(result.current.highlightPath).toBeNull()
})
it('resets timer on repeated highlight messages', () => {
const { result } = renderHook(() => useAiActivity())
act(() => {
sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'editor' })
})
act(() => { vi.advanceTimersByTime(500) })
// Second message resets the timer
act(() => {
sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'notelist' })
})
expect(result.current.highlightElement).toBe('notelist')
act(() => { vi.advanceTimersByTime(500) })
// Still active — only 500ms since the second message
expect(result.current.highlightElement).toBe('notelist')
act(() => { vi.advanceTimersByTime(300) })
expect(result.current.highlightElement).toBeNull()
})
it('ignores non-highlight messages', () => {
const { result } = renderHook(() => useAiActivity())
act(() => {
sendWsMessage({ type: 'ui_action', action: 'other_action' })
})
expect(result.current.highlightElement).toBeNull()
})
it('ignores malformed JSON', () => {
const { result } = renderHook(() => useAiActivity())
act(() => {
lastWsInstance?.onmessage?.(new MessageEvent('message', { data: 'not json' }))
})
expect(result.current.highlightElement).toBeNull()
})
it('closes WebSocket on unmount', () => {
const { unmount } = renderHook(() => useAiActivity())
unmount()
expect(lastWsInstance!.close).toHaveBeenCalled()
})
it('handles highlight with no path', () => {
const { result } = renderHook(() => useAiActivity())
act(() => {
sendWsMessage({ type: 'ui_action', action: 'highlight', element: 'properties' })
})
expect(result.current.highlightElement).toBe('properties')
expect(result.current.highlightPath).toBeNull()
})
})

View File

@@ -0,0 +1,64 @@
import { useState, useEffect, useRef } from 'react'
export type HighlightElement = 'editor' | 'tab' | 'properties' | 'notelist' | null
export interface AiActivity {
highlightElement: HighlightElement
highlightPath: string | null
}
const WS_UI_URL = 'ws://localhost:9711'
const HIGHLIGHT_DURATION_MS = 800
/**
* Listens on the UI WebSocket bridge (port 9711) for highlight events
* from the AI agent. Sets highlightElement for 800ms then auto-clears.
*/
export function useAiActivity(): AiActivity {
const [highlightElement, setHighlightElement] = useState<HighlightElement>(null)
const [highlightPath, setHighlightPath] = useState<string | null>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
let ws: WebSocket | null = null
let mounted = true
try {
ws = new WebSocket(WS_UI_URL)
ws.onmessage = (event) => {
if (!mounted) return
try {
const data = JSON.parse(event.data as string)
if (data.type === 'ui_action' && data.action === 'highlight') {
setHighlightElement(data.element ?? null)
setHighlightPath(data.path ?? null)
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
if (mounted) {
setHighlightElement(null)
setHighlightPath(null)
}
}, HIGHLIGHT_DURATION_MS)
}
} catch {
// Ignore parse errors from malformed messages
}
}
ws.onerror = () => {
// Silent — UI bridge may not be running
}
} catch {
// WebSocket connection failed — bridge not available
}
return () => {
mounted = false
ws?.close()
if (timerRef.current) clearTimeout(timerRef.current)
}
}, [])
return { highlightElement, highlightPath }
}

View File

@@ -105,10 +105,10 @@ describe('useImageDrop', () => {
container.remove()
})
function renderImageDrop() {
function renderImageDrop(opts?: { onImageUrl?: (url: string) => void; vaultPath?: string }) {
const ref = createRef<HTMLDivElement>()
Object.defineProperty(ref, 'current', { value: container, writable: true })
return renderHook(() => useImageDrop({ containerRef: ref }))
return renderHook(() => useImageDrop({ containerRef: ref, ...opts }))
}
it('sets isDragOver to true on dragover with image files', () => {
@@ -160,4 +160,11 @@ describe('useImageDrop', () => {
act(() => { container.dispatchEvent(createDragEvent('dragleave', [], { relatedTarget: document.body })) })
}
})
it('passes onImageUrl and vaultPath without error', () => {
const onImageUrl = vi.fn()
const { result } = renderImageDrop({ onImageUrl, vaultPath: '/vault' })
// Should render without error; Tauri event listener is skipped in browser mode
expect(result.current.isDragOver).toBe(false)
})
})

View File

@@ -1,8 +1,9 @@
import { useEffect, useState, type RefObject } from 'react'
import { useEffect, useRef, useState, type RefObject } from 'react'
import { invoke, convertFileSrc } from '@tauri-apps/api/core'
import { isTauri } from '../mock-tauri'
const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'tiff']
function hasImageFiles(dt: DataTransfer): boolean {
for (let i = 0; i < dt.items.length; i++) {
@@ -11,6 +12,11 @@ function hasImageFiles(dt: DataTransfer): boolean {
return false
}
function isImagePath(path: string): boolean {
const ext = path.split('.').pop()?.toLowerCase() ?? ''
return IMAGE_EXTENSIONS.includes(ext)
}
/** Upload an image file — saves to vault/attachments in Tauri, returns data URL in browser */
export async function uploadImageFile(file: File, vaultPath?: string): Promise<string> {
if (isTauri() && vaultPath) {
@@ -34,13 +40,27 @@ export async function uploadImageFile(file: File, vaultPath?: string): Promise<s
})
}
interface UseImageDropOptions {
containerRef: RefObject<HTMLDivElement | null>
/** Copy a dropped file (by OS path) into vault/attachments and return its asset URL. */
async function copyImageToVault(sourcePath: string, vaultPath: string): Promise<string> {
const savedPath = await invoke<string>('copy_image_to_vault', { vaultPath, sourcePath })
return convertFileSrc(savedPath)
}
export function useImageDrop({ containerRef }: UseImageDropOptions) {
const [isDragOver, setIsDragOver] = useState(false)
interface UseImageDropOptions {
containerRef: RefObject<HTMLDivElement | null>
/** Called with an asset URL for each image dropped via Tauri native drag-drop. */
onImageUrl?: (url: string) => void
vaultPath?: string
}
export function useImageDrop({ containerRef, onImageUrl, vaultPath }: UseImageDropOptions) {
const [isDragOver, setIsDragOver] = useState(false)
const onImageUrlRef = useRef(onImageUrl)
useEffect(() => { onImageUrlRef.current = onImageUrl }, [onImageUrl])
const vaultPathRef = useRef(vaultPath)
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
// HTML5 DnD visual feedback (works in browser mode; BlockNote handles the actual upload)
useEffect(() => {
const container = containerRef.current
if (!container) return
@@ -75,5 +95,46 @@ export function useImageDrop({ containerRef }: UseImageDropOptions) {
}
}, [containerRef])
// Tauri native file drop — intercepts OS file drops that bypass HTML5 DnD
useEffect(() => {
if (!isTauri()) return
let unlisten: (() => void) | null = null
let mounted = true
void (async () => {
try {
const { getCurrentWebview } = await import('@tauri-apps/api/webview')
if (!mounted) return
unlisten = await getCurrentWebview().onDragDropEvent((event) => {
const payload = event.payload
if (payload.type === 'over') {
// Tauri 'over' events don't include paths — show overlay for any drag
setIsDragOver(true)
} else if (payload.type === 'drop') {
setIsDragOver(false)
const imagePaths = payload.paths.filter(isImagePath)
const vault = vaultPathRef.current
const callback = onImageUrlRef.current
if (imagePaths.length > 0 && vault && callback) {
for (const sourcePath of imagePaths) {
void copyImageToVault(sourcePath, vault).then(callback)
}
}
} else {
setIsDragOver(false)
}
})
} catch {
// Tauri webview API not available (e.g. older Tauri version)
}
})()
return () => {
mounted = false
unlisten?.()
}
}, [])
return { isDragOver }
}

View File

@@ -0,0 +1,33 @@
import { useEffect, useRef } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
}
/**
* Registers Laputa as an MCP server in Claude Code and Cursor config files.
* Fires once per vault path (skips duplicates).
*/
export function useMcpRegistration(
vaultPath: string,
onToast: (msg: string) => void,
) {
const registeredRef = useRef<string | null>(null)
useEffect(() => {
if (registeredRef.current === vaultPath) return
registeredRef.current = vaultPath
tauriCall<string>('register_mcp_tools', { vaultPath })
.then((status) => {
if (status === 'registered') {
onToast('Laputa registered as MCP tool for Claude Code')
}
})
.catch(() => {
// Silently ignore — not critical for app operation
})
}, [vaultPath]) // eslint-disable-line react-hooks/exhaustive-deps -- onToast is stable via ref
}

View File

@@ -69,12 +69,12 @@ describe('useNoteSearch', () => {
expect(project?.typeLightColor).toBeTruthy()
})
it('excludes noteType and light color for Note entries', () => {
it('includes noteType and colors for Note entries', () => {
const { result } = renderHook(() => useNoteSearch(entries, ''))
const note = result.current.results.find((r) => r.title === 'Beta Notes')
expect(note?.noteType).toBeUndefined()
expect(note?.typeColor).toBeUndefined()
expect(note?.typeLightColor).toBeUndefined()
expect(note?.noteType).toBe('Note')
expect(note?.typeColor).toBeTruthy()
expect(note?.typeLightColor).toBeTruthy()
})
it('includes original VaultEntry in results', () => {

View File

@@ -12,7 +12,7 @@ export interface NoteSearchResult extends NoteSearchResultItem {
}
function toResult(e: VaultEntry, typeEntryMap: Record<string, VaultEntry>): NoteSearchResult {
const noteType = e.isA && e.isA !== 'Note' ? e.isA : undefined
const noteType = e.isA || undefined
const te = typeEntryMap[e.isA ?? '']
return {
entry: e,

View File

@@ -167,6 +167,11 @@ export const mockHandlers: Record<string, (args: any) => any> = {
const vault = args.vault_path ?? '/Users/luca/Laputa'
return `${vault}/attachments/${Date.now()}-${args.filename}`
},
copy_image_to_vault: (args: { vault_path?: string; source_path: string }) => {
const vault = args.vault_path ?? '/Users/luca/Laputa'
const filename = args.source_path.split('/').pop() ?? 'image.png'
return `${vault}/attachments/${Date.now()}-${filename}`
},
get_settings: () => ({ ...mockSettings }),
save_settings: (args: { settings: Settings }) => {
const s = args.settings
@@ -234,6 +239,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
return args.path.includes('demo-vault-v2')
},
create_getting_started_vault: () => '/Users/mock/Documents/Laputa',
register_mcp_tools: () => 'registered',
}
export function addMockEntry(_entry: VaultEntry, content: string): void {