Compare commits

...

17 Commits

Author SHA1 Message Date
Test
e47c653b8b feat: add + button to TYPES header for creating new types
Matches the existing pattern in VIEWS and FOLDERS sections. The button
triggers onCreateNewType which opens the CreateTypeDialog.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:49:50 +02:00
Test
60ecc4dd0e fix: use full vault reload after view create/delete for reliable FOLDERS update
The previous approach used reload_vault_entry + addEntry wrapped in a
try/catch, which silently swallowed failures and left the .yml file
invisible in the FOLDERS view. Replace with reloadVault() which does a
full rescan and is guaranteed to include the new/deleted file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 19:38:39 +02:00
Test
5323ec6ede fix: rustfmt formatting in discard_file_changes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:26:07 +02:00
Test
a007b37089 feat: add per-file discard changes in Changes view
Right-click a file in the Changes view to discard its uncommitted changes.
Shows a confirmation dialog before executing. Handles modified (git checkout),
untracked (delete), and deleted (git restore) files. Includes path traversal
safety checks in the Rust backend.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 18:16:52 +02:00
Test
3c4f6ccabd fix: coerce claudeCodeStatus/Version null→undefined for AppCommandsConfig 2026-04-04 16:46:25 +02:00
Test
313b11fb0e fix: add claudeCodeStatus/claudeCodeVersion to AppCommandsConfig (TS error) 2026-04-04 16:40:15 +02:00
Test
df1cff97b9 fix: update pnpm lockfile for @blocknote/core patch (CI frozen-lockfile fix) 2026-04-04 16:38:56 +02:00
Test
2b419b48a0 feat: add Claude Code detected/missing badge to status bar
Uses the existing check_claude_cli Tauri command to detect the claude
binary at app startup and shows a status badge — green/neutral when
found, orange warning when missing with a click-to-install link.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 16:31:05 +02:00
Test
f8d080e678 feat: add body content filter for Views (contains/does not contain)
Adds 'body' as a built-in filter field that searches note snippet text
(case-insensitive). Visually separated from property fields in the
FilterBuilder dropdown with a separator. Combines with other filters via AND.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 16:13:21 +02:00
Test
9f905169cb feat: add suggested property/relationship slots in Inspector
Properties panel shows Status/Date/URL slots as muted clickable rows when
not already present. Relationships panel shows Belongs to/Related to/Has
slots. Clicking a slot adds the property and auto-focuses the value field.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:42:10 +02:00
Test
ef0288268c feat: move Info section below Backlinks in Inspector, add Phosphor icon
Reorders Inspector sections to: Properties → Relationships → Backlinks → Info → History.
Extracts NoteInfoPanel into its own component with Info icon matching other section headers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 15:15:43 +02:00
Test
1d3927ca2b fix: rename "Link existing" to "Add relationship" in Inspector
Aligns relationship button label with the "Add property" pattern used
elsewhere in the Inspector panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:45:02 +02:00
Test
2feff35764 feat: enrich getting-started-vault with real PKM example notes (Luca's system, guided Welcome) 2026-04-04 14:40:35 +02:00
Test
6f66cf0d9c fix: wikilink autocomplete triggers mid-text, not only on empty lines
Patches @blocknote/core SuggestionMenu plugin to fix multi-character
trigger detection. The original code used textBetween(from - len, from)
which included text before the trigger, causing [[ to only match at
line starts. Fixed to textBetween(from - (len - 1), from).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:33:52 +02:00
Test
aaa46f8d20 refactor: simplify getting-started-vault (3 types: Note/Topic/Person, no prefixes in filenames, Welcome as home) 2026-04-04 14:30:37 +02:00
Test
f384b6fad3 feat: redesign welcome screen with three vault options
Shows "Create a new vault" (empty + git init), "Open existing vault"
(folder picker), and "Get started with a template" (Getting Started
vault). Adds create_empty_vault Rust command for the new-vault flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 14:25:52 +02:00
Test
de122ad045 feat: include Type definitions in command palette's dynamic commands
extractVaultTypes now also reads entries where isA === 'Type' and adds
their title, so "New <Type>" commands appear as soon as a Type is
defined — not only after an instance of that type exists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:55:42 +02:00
69 changed files with 1720 additions and 671 deletions

View File

@@ -1,16 +1,3 @@
# Laputa app files (machine-specific, never commit)
.laputa/settings.json
# macOS
.DS_Store
.AppleDouble
.LSOverride
# Thumbnails
._*
# Editors
.vscode/
.idea/
*.swp
*.swo

View File

@@ -0,0 +1,89 @@
# CLAUDE.md — Laputa Vault Guide
This file explains how Laputa vaults work so you can create and edit notes correctly.
## Note structure
Every note is a markdown file with YAML frontmatter:
```yaml
---
title: My Note Title # required — do NOT use H1 in the body
is_a: TypeName # the note's type (must match a type file in the vault)
status: Active # example property
url: https://example.com # example property
belongs_to: "[[Other Note]]" # relationship via wikilink
related_to:
- "[[Note A]]"
- "[[Note B]]"
---
Body content in markdown. No H1 — the title is in the frontmatter.
```
**Key rules:**
- `title` is the note's display name — never use `# H1` in the body
- `is_a` must match the `title` of an existing type file
- Properties are any YAML key-value pairs in the frontmatter
- System properties are prefixed with `_` (e.g. `_pinned`, `_organized`, `_icon`) — don't show these to users
## Types
A type is a note with `is_a: Type` in the frontmatter. It lives in the vault root:
```yaml
---
title: Book
is_a: Type
_icon: BookOpen # Phosphor icon name
_color: "#8b5cf6" # hex color for sidebar
---
Description of the type.
```
To create a new type: create a markdown file with `is_a: Type`.
## Relationships
Relationships are frontmatter properties whose values are wikilinks:
```yaml
belongs_to: "[[Project Name]]"
related_to:
- "[[Note A]]"
- "[[Note B]]"
has:
- "[[Child Note]]"
```
Standard names: `belongs_to`, `related_to`, `has`. Custom names are allowed.
## Wikilinks
Syntax: `[[Note Title]]` or `[[filename]]`. Used for relationships and inline references.
## Views
Saved filters stored as `.view.json` in the `views/` folder:
```json
{
"title": "Active Notes",
"filters": [
{"property": "is_a", "operator": "equals", "value": "Note"},
{"property": "status", "operator": "equals", "value": "Active"}
],
"sort": {"property": "title", "direction": "asc"}
}
```
## What you can do on this vault
- Create/edit notes with correct frontmatter
- Create new type files
- Add or modify relationships between notes
- Create/edit views in `views/`
- Change `_icon` and `_color` on type files
- Edit `CLAUDE.md` (this file)
**Do not** modify app configuration files — those are local to each installation.

View File

@@ -0,0 +1,27 @@
---
title: AI and Git
is_a: Note
belongs_to: "[[Getting Started]]"
---
## Claude Code
Laputa integrates with [Claude Code](https://docs.anthropic.com/claude-code) — Anthropic's CLI agent. If you have `claude` installed, you can ask it to operate directly on your vault:
```
claude "Create a note for the book Zero to One by Peter Thiel, with a rating and a topic"
```
Claude understands Laputa's format (frontmatter, types, wikilinks, relationships) and creates or edits files accordingly. Your vault's `CLAUDE.md` file gives it full context.
## Git sync
Your vault is a Git repository. Every save in Laputa is tracked as a file change. Use the **Changes** view in the sidebar to see what's modified, commit with a message, and push to a remote.
```bash
# From inside your vault folder
git remote add origin https://github.com/you/my-vault.git
git push -u origin main
```
After that, Laputa can push and pull directly from the app.

View File

@@ -0,0 +1,38 @@
---
title: Capturing People and Meetings
is_a: Note
related_to: "[[Personal Knowledge Management]]"
author: "[[Luca Rossi]]"
date: 2025-01-28
---
The Person type is one of the most useful in my vault. Here's how I use it.
## One note per person
Every person I interact with meaningfully gets a Person note. Not just colleagues — also people I meet at conferences, authors whose work I follow, collaborators I might reach out to.
A minimal Person note looks like this:
```yaml
---
title: Matteo Cellini
is_a: Person
role: Head of Partnerships
related_to: "[[Refactoring Newsletter]]"
---
```
The body holds context: how we met, what they're working on, anything I want to remember.
## Meetings as connections
When I have a meeting, I create a note for it and link everyone present via `related_to`. This means every Person note accumulates backlinks over time — a natural history of interactions without any manual effort.
## Finding things later
The power comes when you need to remember something. Open a person's note, look at their backlinks — you see every meeting, every shared project, every note that mentioned them. It's the closest thing I've found to having a good memory.
## The pattern
Person notes are intentionally sparse upfront. I add context as I interact with people. A note that starts as just a name and a role grows into something genuinely useful over months.

View File

@@ -1,61 +1,6 @@
---
title: "Getting Started"
type: Note
Related to:
- "[[What is Laputa]]"
- "[[Keyboard Shortcuts]]"
- "[[Laputa Onboarding]]"
title: Getting Started
is_a: Topic
---
Welcome to your Laputa vault! This note walks you through the key features so you can start building your personal knowledge graph.
## Editor
Laputa uses a rich markdown editor. Write in plain markdown with headings, lists, checkboxes, code blocks, and blockquotes. Every note has YAML frontmatter at the top (between `---` delimiters) that stores metadata like type, status, and relationships.
Wiki-links connect notes together: type `[[` in the editor to search and link to any note in your vault.
## Types
Types define the kind of entity a note represents — Note, Project, Person, Topic, Task, or any custom type you create. Each type gets its own icon, color, and sidebar section. To create a new type, add a markdown file with `type: Type` in the frontmatter.
## Sidebar
The left sidebar organizes your vault by type. Each type gets its own collapsible section. Special sections include:
- **Inbox** — notes without a type
- **All Notes** — every note in the vault
- **Archive** — archived notes
- **Trash** — deleted notes (recoverable for 30 days)
## Properties
Open the **Inspector** panel (right side) to view and edit a note's properties. Click any value to change it. Use the **+ Add property** button to add custom fields. Properties containing `[[wiki-links]]` become navigable relationships.
## Relationships
Connect notes through frontmatter fields like `Belongs to`, `Related to`, and `Has`. These appear as clickable pills in the Inspector. Backlinks are computed automatically — linking A to B makes B show a backlink to A.
## Views
Views are saved filters that show a subset of your notes. Create a view to see, for example, all active projects or all tasks belonging to a specific project. Views live in the `views/` folder as YAML files. This vault includes an "Active Projects" view that filters for projects with status Active.
## Favorites
Pin frequently used notes to the Favorites section at the top of the sidebar. Toggle a note's favorite status from the Inspector or the command palette.
## Search
Press **Cmd+P** to quick-open any note by title. Use the search bar in the sidebar for full-text search across all notes.
## Command palette
Press **Cmd+K** to open the command palette. From here you can create notes, switch types, toggle views, and access every action in the app.
## AI
Laputa integrates with Claude Code. When Claude Code is running in your vault directory, the status bar shows a green badge. You can use AI to help organize, summarize, and connect your notes.
## Git sync
Your vault is a standard git repository. Use the **Changes** view in the sidebar to see modified files, commit changes, and push to a remote. This means your vault works with any git hosting service for backup and sync across devices.
This topic groups all the onboarding notes for Laputa. Start with [[Welcome to Laputa]] for an overview, then explore each note at your own pace.

View File

@@ -0,0 +1,31 @@
---
title: How I Organize My Vault
is_a: Note
related_to: "[[Personal Knowledge Management]]"
author: "[[Luca Rossi]]"
date: 2025-01-15
---
My vault follows a structure loosely inspired by PARA, adapted to how I actually think and work.
## The four types I use
**Projects** — things with a clear outcome and an end date. Building a feature, writing an article, preparing a talk. Projects are active or done, never vague.
**Responsibilities** — areas I own ongoing, with no end date. Newsletter, health, finances, team. A Responsibility never "completes" — it just gets better or worse.
**Topics** — concepts, ideas, and subjects I care about. Personal Knowledge Management, Software Architecture, Cycling Training. Topics are the intellectual threads that run through everything else.
**People** — anyone I interact with meaningfully. Each person has a note with context, how we met, what we've worked on together.
## How they connect
A Project `belongs_to` a Responsibility. A note `related_to` a Topic. A meeting note `related_to` the people who attended. Over time, these connections turn a flat list of files into something closer to how memory actually works.
## Events
I also sync calendar events into my vault as Event notes — one note per meeting or important event, linked to the people present. [[Luca Rossi]]'s AI assistant Brian handles this automatically via a cron job.
## The rule I follow
If I create a note and don't connect it to anything within a day or two, it goes to Inbox and stays there until I organize it. The Inbox is the queue — not a dumping ground.

View File

@@ -1,37 +0,0 @@
---
title: "Keyboard Shortcuts"
type: Note
Related to: "[[Getting Started]]"
---
Essential shortcuts for navigating Laputa.
## Navigation
| Shortcut | Action |
|----------|--------|
| **Cmd+P** | Quick-open a note by title |
| **Cmd+K** | Open the command palette |
| **Cmd+N** | Create a new note |
| **Cmd+,** | Open settings |
| **Cmd+\\** | Toggle sidebar |
## Editor
| Shortcut | Action |
|----------|--------|
| **Cmd+S** | Save the current note |
| **Cmd+B** | Bold |
| **Cmd+I** | Italic |
| **Cmd+Shift+K** | Insert a code block |
| **Cmd+Shift+8** | Toggle bullet list |
| **Cmd+Shift+7** | Toggle numbered list |
## Wiki-links
Type `[[` in the editor to open the link picker. Start typing to search for a note, then press Enter to insert the link. Links are bidirectional — the linked note will show a backlink to the current note.
## Tips
- Use **Cmd+K** when you are not sure where to find something — the command palette lists every available action.
- **Cmd+P** is the fastest way to navigate between notes — it searches by title and aliases.

View File

@@ -0,0 +1,20 @@
---
title: Luca Rossi
is_a: Person
role: Founder
website: https://refactoring.fm
twitter: https://twitter.com/lucaronin
related_to:
- "[[Personal Knowledge Management]]"
- "[[Getting Started]]"
---
Creator of Laputa and founder of [Refactoring](https://refactoring.fm), a newsletter about engineering leadership and software craft for senior engineers and engineering leaders.
Luca built Laputa to solve his own problem: after years of using Notion, Roam, and Obsidian, he wanted a knowledge base that was truly his — plain files, real version control, and AI that can operate on the vault directly.
Some of his writing on how he thinks about knowledge management:
- [[How I Organize My Vault]]
- [[Why Plain Files]]
- [[Syncing Calendar Events into Laputa]]
- [[Capturing People and Meetings]]

View File

@@ -1,9 +1,8 @@
---
title: "Note"
type: Type
icon: note
color: blue
order: 1
title: Note
is_a: Type
_icon: FileText
_color: "#6366f1"
---
A Note is a general-purpose document — research notes, meeting notes, ideas, drafts, or anything that doesn't fit a more specific type.
A Note is a general-purpose document — ideas, references, meeting notes, or anything that doesn't fit a more specific type.

View File

@@ -1,12 +0,0 @@
---
title: "Luca Rossi"
type: Person
Role: Founder
Website: https://refactoring.fm
Twitter: https://twitter.com/lucaronin
Related to:
- "[[Laputa Onboarding]]"
- "[[Topic: Personal Knowledge Management]]"
---
Creator of Laputa and founder of [Refactoring](https://refactoring.fm), a newsletter about engineering leadership and software craft. Luca built Laputa to organize his own knowledge — projects, people, ideas, and goals — in a way that stays grounded in plain files and version control.

View File

@@ -1,9 +1,8 @@
---
title: "Person"
type: Type
icon: user
color: green
order: 3
title: Person
is_a: Type
_icon: UserCircle
_color: "#f59e0b"
---
A Person represents someone you interact with — a colleague, collaborator, mentor, or friend. People can own projects, attend events, and appear in relationships across your vault.
A Person is someone you interact with — colleagues, collaborators, mentors, or friends.

View File

@@ -0,0 +1,17 @@
---
title: Personal Knowledge Management
is_a: Topic
---
Personal Knowledge Management (PKM) is the practice of collecting, organizing, and connecting the information you encounter — notes, ideas, references, and people — so it becomes a durable personal asset.
Laputa is designed as a PKM tool. Unlike traditional note-taking apps, it treats your notes as a graph of interconnected entities: types give structure, wikilinks create connections, views let you slice through the graph from different angles.
## How Luca uses Laputa for PKM
These notes describe the actual system behind this vault — written by [[Luca Rossi]] as examples you can learn from and adapt:
- [[How I Organize My Vault]] — the structure: Projects, Responsibilities, Topics, People
- [[Syncing Calendar Events into Laputa]] — turning meetings into connected knowledge
- [[Capturing People and Meetings]] — building a useful network of Person notes
- [[Why Plain Files]] — why markdown + Git beats proprietary tools

View File

@@ -1,27 +0,0 @@
---
title: "Laputa Onboarding"
type: Project
Status: Active
Owner: "[[Luca Rossi]]"
Has:
- "[[Task: Explore the Editor]]"
- "[[Task: Create Your First Type]]"
- "[[Task: Set Up Git Sync]]"
- "[[Task: Try the Command Palette]]"
- "[[Task: Create a Custom View]]"
Related to: "[[Topic: Getting Started]]"
---
A hands-on project to help you discover Laputa's key features. Work through the tasks below at your own pace — each one introduces a different part of the app.
## Tasks
- [ ] [[Task: Explore the Editor]] — Get comfortable with the markdown editor and wiki-links
- [ ] [[Task: Create Your First Type]] — Define a custom type with icon and color
- [ ] [[Task: Set Up Git Sync]] — Connect your vault to a git remote for backup
- [ ] [[Task: Try the Command Palette]] — Discover actions with Cmd+K
- [ ] [[Task: Create a Custom View]] — Build a filtered view of your notes
## When you are done
Once you have completed these tasks, you will have a solid understanding of how Laputa works. From here, start creating your own types, notes, and views to build a knowledge graph that fits your workflow.

View File

@@ -1,9 +0,0 @@
---
title: "Project"
type: Type
icon: rocket-launch
color: purple
order: 4
---
A Project is a time-bounded effort with a clear goal and an eventual completion date. Projects can contain tasks, belong to areas, and link to people and topics.

View File

@@ -0,0 +1,32 @@
---
title: Sidebar and Navigation
is_a: Note
belongs_to: "[[Getting Started]]"
---
## Main sections
- **Inbox** — notes you haven't organized yet. A note leaves the Inbox when you mark it as "organized" using the ✓ button in the breadcrumb bar.
- **All Notes** — every note in your vault
- **Archive** — notes you've finished with but want to keep
- **Trash** — deleted notes, recoverable for 30 days
## Types section
Below the main sections, the sidebar lists your custom types (Note, Topic, Person, and any you create). Click a type to see all notes of that type.
Use the sliders icon next to **TYPES** to show or hide types from the sidebar. Use the **+** to create a new type.
## Favorites
Star any note to pin it to the top of the sidebar. Click the ⭐ icon in the breadcrumb bar at the top of the editor, or use **Cmd+K → Favorite**.
## Keyboard shortcuts
| Action | Shortcut |
|--------|----------|
| Quick open | Cmd+P |
| Command palette | Cmd+K |
| New note | Cmd+N |
| Settings | Cmd+, |
| Search | Cmd+F |

View File

@@ -0,0 +1,36 @@
---
title: Syncing Calendar Events into Laputa
is_a: Note
related_to: "[[Personal Knowledge Management]]"
author: "[[Luca Rossi]]"
date: 2025-02-10
---
One pattern I've found genuinely useful: every significant meeting or event gets a note in my vault.
## How it works
My AI assistant Brian runs a cron job that checks my calendar daily. For each meeting, it creates (or updates) an Event note in my vault with the relevant metadata — title, date, attendees — and links each attendee to their Person note.
The result: every person I meet has a trail of events in their backlinks. I can open [[Luca Rossi]]'s note and immediately see every meeting we've had, what was discussed, what followed.
## What an Event note looks like
```yaml
---
title: 1:1 with Matteo — Jan 10
is_a: Event
date: 2025-01-10
related_to:
- "[[Matteo Cellini]]"
- "[[Refactoring Newsletter]]"
---
```
The body holds notes from the meeting — decisions, action items, context.
## Why this matters
Without this, meetings exist only in my calendar and my memory. With it, they become searchable, connected knowledge. A year later I can search "Matteo sponsorship" and find the exact conversation where we made a decision.
You don't need a cron job to do this — you can create Event notes manually. The pattern is what matters.

View File

@@ -1,39 +0,0 @@
---
title: "Task: Create a Custom View"
type: Task
Status: Open
Belongs to: "[[Laputa Onboarding]]"
Related to: "[[Laputa Onboarding]]"
---
Views are saved filters that show a subset of your notes. This vault includes an example view — "Active Projects" — that shows all projects with status Active.
## What to try
1. Open the command palette (Cmd+K) and look for a "New View" action, or create a `.yml` file in the `views/` folder
2. Define filters — for example, show all tasks with status Open
3. Set a name, icon, and color for the view
4. Save — the view appears in the sidebar under Views
## View format
Views are YAML files in `views/`. Here is an example:
```yaml
name: Open Tasks
icon: check-square
color: orange
sort: "modified:desc"
filters:
all:
- field: type
op: equals
value: Task
- field: Status
op: equals
value: Open
```
## Tip
Views update automatically as you add or change notes. They are a powerful way to create dashboards for your projects, areas, or any slice of your vault.

View File

@@ -1,30 +0,0 @@
---
title: "Task: Create Your First Type"
type: Task
Status: Open
Belongs to: "[[Laputa Onboarding]]"
---
Types give structure to your vault. Every note has a type — Project, Person, Topic, or any custom type you define.
## What to try
1. Create a new markdown file (Cmd+N)
2. In the frontmatter, set `type: Type`
3. Add an `icon:` field — use any [Phosphor icon](https://phosphoricons.com) name in kebab-case (e.g. `book-open`, `lightning`, `house`)
4. Add a `color:` field — available colors: red, purple, blue, green, yellow, orange
5. Give it a title with a `# Heading` and a short description in the body
6. Save the file — your new type appears in the sidebar
## Example
```yaml
---
type: Type
icon: book-open
color: red
order: 6
---
```
The `order` field controls position in the sidebar (lower = higher).

View File

@@ -1,21 +0,0 @@
---
title: "Task: Explore the Editor"
type: Task
Status: Open
Belongs to: "[[Laputa Onboarding]]"
---
Get comfortable with Laputa's markdown editor.
## What to try
1. **Headings** — Type `#`, `##`, or `###` followed by a space to create headings
2. **Lists** — Use `-` for bullet lists and `1.` for numbered lists
3. **Checkboxes** — Type `- [ ]` for a checkbox, click to toggle it
4. **Formatting** — Try **bold** (Cmd+B), *italic* (Cmd+I), and `inline code`
5. **Wiki-links** — Type `[[` and start typing a note name to insert a link. Try linking to [[Getting Started]]
6. **Code blocks** — Use triple backticks or Cmd+Shift+K
## Tip
The editor saves automatically. You can also press **Cmd+S** to save explicitly.

View File

@@ -1,27 +0,0 @@
---
title: "Task: Set Up Git Sync"
type: Task
Status: Open
Belongs to: "[[Laputa Onboarding]]"
---
Your vault is a git repository. Connecting it to a remote lets you back up your notes and sync across devices.
## What to try
1. Create a new repository on GitHub, GitLab, or any git host
2. Open a terminal in your vault directory
3. Add the remote: `git remote add origin <your-repo-url>`
4. Push your vault: `git push -u origin main`
## Using the Changes view
Laputa has a built-in Changes view in the sidebar that shows modified files. From there you can:
- See which files have changed since the last commit
- Commit changes with a message
- Push to the remote
## Tip
Git sync means you can edit your vault from any device — even a plain text editor on a computer that does not have Laputa installed. The next time you open Laputa, pull the latest changes and everything updates.

View File

@@ -1,26 +0,0 @@
---
title: "Task: Try the Command Palette"
type: Task
Status: Open
Belongs to: "[[Laputa Onboarding]]"
---
The command palette is the fastest way to do anything in Laputa.
## What to try
1. Press **Cmd+K** to open the command palette
2. Type to filter the list of available actions
3. Try these commands:
- **New Note** — create a note
- **Toggle Sidebar** — show or hide the sidebar
- **Open Settings** — jump to app settings
4. Press **Escape** to close the palette
## Quick open
**Cmd+P** opens a separate quick-open dialog that searches notes by title and aliases. Use it when you know which note you want to reach.
## Tip
If you forget a shortcut, Cmd+K shows it. The command palette lists keyboard shortcuts next to each action.

View File

@@ -1,9 +0,0 @@
---
title: "Task"
type: Type
icon: check-square
color: orange
order: 5
---
A Task is a discrete, actionable item that can be completed. Tasks belong to projects and represent the smallest unit of work in your vault.

View File

@@ -1,10 +0,0 @@
---
title: "Topic: Getting Started"
type: Topic
Related to:
- "[[Getting Started]]"
- "[[What is Laputa]]"
- "[[Keyboard Shortcuts]]"
---
This topic groups all notes related to learning Laputa. Start with [[Getting Started]] for a full overview, then explore [[What is Laputa]] for the philosophy behind the app and [[Keyboard Shortcuts]] for quick navigation.

View File

@@ -1,16 +0,0 @@
---
title: "Topic: Personal Knowledge Management"
type: Topic
Related to: "[[What is Laputa]]"
---
Personal Knowledge Management (PKM) is the practice of collecting, organizing, and connecting the information you encounter — notes, ideas, references, projects, and people — so it becomes a durable personal asset rather than a list of forgotten bookmarks.
Laputa is designed as a PKM tool. Unlike traditional note-taking apps, it treats your notes as a graph of interconnected entities. Types give structure, wiki-links create connections, and views let you slice through the graph from different angles.
## Core ideas
- **Write things down** — Capture ideas, meeting notes, and references as they happen. A note that exists is infinitely more useful than a thought you forgot.
- **Connect everything** — Use `[[wiki-links]]` to link related notes. Over time, these connections reveal patterns you did not plan.
- **Use types** — Give notes a type (Project, Person, Topic) to add structure without rigid hierarchies.
- **Review regularly** — Use views and favorites to surface what matters. A vault that is never revisited is just a graveyard of text files.

View File

@@ -1,9 +1,8 @@
---
title: "Topic"
type: Type
icon: tag
color: yellow
order: 2
title: Topic
is_a: Type
_icon: Hash
_color: "#10b981"
---
A Topic is a subject area or interest category. Topics group related notes, projects, and people by theme — use them to organize knowledge across your vault.
A Topic is a subject or area of interest that groups related notes together.

View File

@@ -0,0 +1,47 @@
---
title: Types, Properties and Relationships
is_a: Note
belongs_to: "[[Getting Started]]"
---
## Types
A **type** is a category for your notes — Person, Project, Topic, Book, or anything you invent. Each type gets its own icon, color, and section in the sidebar.
Create a type by adding a markdown file with `is_a: Type` in the frontmatter:
```yaml
---
title: Book
is_a: Type
_icon: BookOpen
_color: "#8b5cf6"
---
```
Then tag any note with `is_a: Book` to classify it as a book.
## Properties
Properties are any key-value pairs in the frontmatter:
```yaml
rating: 4
status: Active
url: https://example.com
```
They appear in the **Inspector** panel on the right. Click "+ Add property" to add one.
## Relationships
Relationships are properties whose values are wikilinks to other notes:
```yaml
belongs_to: "[[Some Project]]"
related_to:
- "[[Note A]]"
- "[[Note B]]"
```
Standard relationships: `belongs_to`, `related_to`, `has`. You can define your own.

View File

@@ -0,0 +1,38 @@
---
title: Using the Editor
is_a: Note
belongs_to: "[[Getting Started]]"
---
Laputa uses a rich markdown editor. Every note has two parts: **frontmatter** and **body**.
## Frontmatter
The YAML block at the top (between `---`) stores metadata:
```yaml
---
title: My Note
is_a: Note
status: Active
belongs_to: "[[Some Project]]"
---
```
- `title` — the note's display name (no H1 needed in the body)
- `is_a` — the type of the note
- Any other key becomes a property visible in the Inspector
## Body
Write in standard markdown: headings, lists, checkboxes, code blocks, bold, italic.
## Wikilinks
Type `[[` anywhere to search and link to another note:
```
See also [[What is Laputa]] for context.
```
Wikilinks create relationships between notes and power the graph view and backlinks panel.

View File

@@ -0,0 +1,23 @@
---
title: Views and Search
is_a: Note
belongs_to: "[[Getting Started]]"
---
## Search
Press **Cmd+P** to open the Quick Open palette and jump to any note by name.
Use the search bar in the sidebar to filter notes by text.
## Command Palette
Press **Cmd+K** to open the Command Palette — your shortcut to every action in Laputa: create a note, change a type, toggle a view, run a command.
## Views
Views are saved filters that show a subset of your vault. Create a view to answer questions like "all active projects" or "notes tagged design".
Click the **+** next to VIEWS in the sidebar to create a new view. Set filters by type, status, property value, or body content.
Views are saved as `.view.json` files in your vault's `views/` folder — they travel with your vault via Git.

View File

@@ -0,0 +1,37 @@
---
title: Welcome to Laputa
is_a: Note
belongs_to: "[[Getting Started]]"
_pinned: true
---
Welcome to Laputa — your personal knowledge base, stored as plain markdown files and versioned with Git.
This vault is your starting point. It contains real notes you can read, edit, and use as a model for your own.
## Start here
**Step 1 — Learn the basics**
Read these three notes in order:
1. [[What is Laputa]] — the philosophy (2 min)
2. [[Using the Editor]] — how notes work (3 min)
3. [[Types, Properties and Relationships]] — how to structure knowledge (3 min)
**Step 2 — Explore the app**
4. [[Sidebar and Navigation]] — Inbox, Favorites, types
5. [[Views and Search]] — Cmd+K, Cmd+P, saved views
**Step 3 — See it in action**
Browse the [[Personal Knowledge Management]] topic to see how [[Luca Rossi]], Laputa's creator, actually uses the app — with real notes about his system, his workflows, and why he built it this way.
**Step 4 — Make it yours**
Try editing this note. Create a new note (Cmd+N). Add a type. Connect two notes with a `[[wikilink]]`.
---
**Step 5 — AI and Git**
Read [[AI and Git]] to learn how to use Claude Code to operate on your vault, and how to sync it with GitHub.
---
*This vault was created by [[Luca Rossi]]. You own it — edit everything.*

View File

@@ -1,25 +1,15 @@
---
title: "What is Laputa"
type: Note
Related to:
- "[[Getting Started]]"
- "[[Topic: Personal Knowledge Management]]"
title: What is Laputa
is_a: Note
belongs_to: "[[Getting Started]]"
---
Laputa is a local-first knowledge management app built on three principles:
Laputa is a personal knowledge base built on three principles:
## Your files, your data
**Plain files.** Every note is a markdown file on your filesystem. No proprietary database, no lock-in. You can open, edit, and search your notes with any text editor.
Every note is a plain markdown file on your filesystem. There is no proprietary database, no cloud lock-in, no import/export ceremony. Your vault is a folder — you can open it in any text editor, back it up however you want, and it will outlive any app.
**Git as sync.** Your vault is a Git repository. Version history, branching, and remote sync come for free. Use GitHub, GitLab, or any remote.
## The filesystem is the truth
**Structure without rigidity.** Notes have types, properties, and relationships — but they're just YAML frontmatter. The structure lives in the file, not in a schema.
Laputa reads your files and derives everything from them. Types, relationships, views, and sidebar sections are all computed from the frontmatter and folder structure. There is no hidden state. If you move a file in Finder, Laputa reflects the change. If you edit a file in VS Code, Laputa picks it up.
## Git for sync and history
Your vault is a git repository. Every change is a commit. Sync between devices by pushing and pulling to a remote. Resolve conflicts with standard git tools. Your entire edit history is preserved.
## How it fits together
A Laputa vault is a collection of markdown files with YAML frontmatter. The `type` field in the frontmatter determines what kind of entity the note represents. Wiki-links (`[[Note Title]]`) create a web of connections between notes. The app provides a visual interface on top of these files — a rich editor, sidebar navigation, inspector panel, and filtered views — but the files remain the source of truth.
Laputa is designed to grow with you. Start with a few notes, add types as patterns emerge, connect ideas with wikilinks. Over time, your vault becomes a map of how you think.

View File

@@ -0,0 +1,31 @@
---
title: Why Plain Files
is_a: Note
related_to: "[[Personal Knowledge Management]]"
author: "[[Luca Rossi]]"
date: 2024-11-05
---
I've used Notion, Roam, Bear, and Obsidian at different points. I kept switching. Here's what I eventually decided and why.
## The problem with databases
Notion stores your knowledge in a proprietary database. It's great for collaboration and structured data, but your notes are not really yours — they live in Notion's servers, in Notion's format. Export is lossy and awkward.
## The problem with sync-only tools
Obsidian keeps your files local, which I respect. But the sync story is fragile, and the plugin ecosystem means your setup is fragile too. I've lost time to broken plugins more than once.
## What I wanted
- Files I own, in a format that will be readable in 20 years
- Version history that actually works (not "version history" as a feature — real Git history)
- The ability to use AI to operate on my vault, which requires the AI to be able to read and write files
Markdown + Git gives me all three.
## Laputa's bet
Laputa is built on the same bet: your notes are files, your vault is a Git repo, and the app is just a great interface on top of that. If Laputa disappears tomorrow, your notes are still there, still readable, still version-controlled.
That's the kind of tool I wanted to build — and use.

View File

@@ -0,0 +1,13 @@
diff --git a/dist/defaultBlocks-DE5GNdJH.js b/dist/defaultBlocks-DE5GNdJH.js
index b2761001278486a8b2ac1d10c82420b4994e96d9..fbf4f2f450ed1351d64adeb16263ceacf9ee393c 100644
--- a/dist/defaultBlocks-DE5GNdJH.js
+++ b/dist/defaultBlocks-DE5GNdJH.js
@@ -2761,7 +2761,7 @@ const B = new Ze("SuggestionMenuPlugin"), _n = k(({ editor: e }) => {
if (a === s) {
const c = r.state.doc;
for (const l of t) {
- const u = l.length > 1 ? c.textBetween(a - l.length, a) + i : i;
+ const u = l.length > 1 ? c.textBetween(a - (l.length - 1), a) + i : i;
if (l === u)
return r.dispatch(r.state.tr.insertText(i)), r.dispatch(
r.state.tr.setMeta(B, {

13
pnpm-lock.yaml generated
View File

@@ -4,6 +4,11 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
patchedDependencies:
'@blocknote/core@0.46.2':
hash: 9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec
path: patches/@blocknote__core@0.46.2.patch
importers:
.:
@@ -13,7 +18,7 @@ importers:
version: 0.78.0(zod@4.3.6)
'@blocknote/core':
specifier: ^0.46.2
version: 0.46.2(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
version: 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/mantine':
specifier: ^0.46.2
version: 0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -4461,7 +4466,7 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@blocknote/core@0.46.2(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
'@blocknote/core@0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
dependencies:
'@emoji-mart/data': 1.2.1
'@handlewithcare/prosemirror-inputrules': 0.1.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)
@@ -4513,7 +4518,7 @@ snapshots:
'@blocknote/mantine@0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/react': 0.46.2(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/core': 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/hooks': 8.3.14(react@19.2.4)
@@ -4535,7 +4540,7 @@ snapshots:
'@blocknote/react@0.46.2(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@emoji-mart/data': 1.2.1
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.10

View File

@@ -3,3 +3,6 @@ packages:
ignoredBuiltDependencies:
- esbuild
patchedDependencies:
'@blocknote/core@0.46.2': patches/@blocknote__core@0.46.2.patch

View File

@@ -128,6 +128,13 @@ pub async fn git_remote_status(vault_path: String) -> Result<GitRemoteStatus, St
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(desktop)]
#[tauri::command]
pub fn git_discard_file(vault_path: String, relative_path: String) -> Result<(), String> {
let vault_path = expand_tilde(&vault_path);
crate::git::discard_file_changes(&vault_path, &relative_path)
}
#[cfg(desktop)]
#[tauri::command]
pub fn is_git_repo(vault_path: String) -> bool {
@@ -247,6 +254,12 @@ pub async fn git_remote_status(_vault_path: String) -> Result<GitRemoteStatus, S
})
}
#[cfg(mobile)]
#[tauri::command]
pub fn git_discard_file(_vault_path: String, _relative_path: String) -> Result<(), String> {
Err("Git discard is not available on mobile".into())
}
#[cfg(mobile)]
#[tauri::command]
pub fn is_git_repo(_vault_path: String) -> bool {

View File

@@ -101,6 +101,23 @@ pub fn create_vault_folder(vault_path: String, folder_name: String) -> Result<St
Ok(folder_name)
}
#[tauri::command]
pub fn create_empty_vault(target_path: String) -> Result<String, String> {
let path = expand_tilde(&target_path).into_owned();
let vault_dir = std::path::Path::new(&path);
std::fs::create_dir_all(vault_dir)
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
crate::git::init_repo(&path)?;
Ok(vault_dir
.canonicalize()
.unwrap_or_else(|_| vault_dir.to_path_buf())
.to_string_lossy()
.to_string())
}
#[tauri::command]
pub fn create_getting_started_vault(target_path: Option<String>) -> Result<String, String> {
let path = match target_path {

View File

@@ -21,7 +21,7 @@ pub use remote::{
git_pull, git_push, git_remote_status, has_remote, GitPullResult, GitPushResult,
GitRemoteStatus,
};
pub use status::{get_modified_files, ModifiedFile};
pub use status::{discard_file_changes, get_modified_files, ModifiedFile};
use serde::Serialize;

View File

@@ -63,6 +63,78 @@ pub fn get_modified_files(vault_path: &str) -> Result<Vec<ModifiedFile>, String>
Ok(files)
}
/// Discard uncommitted changes to a single file.
///
/// - **Modified / Deleted**: `git checkout -- <file>` restores the last committed version.
/// - **Untracked / Added**: the file is removed from disk.
///
/// The `relative_path` must be relative to `vault_path` (the same format
/// returned by [`get_modified_files`]).
pub fn discard_file_changes(vault_path: &str, relative_path: &str) -> Result<(), String> {
let vault = Path::new(vault_path);
let abs = vault.join(relative_path);
// Safety: ensure the resolved path stays inside the vault.
// Safety: reject any relative_path that tries to escape the vault via `..`.
for component in std::path::Path::new(relative_path).components() {
if matches!(component, std::path::Component::ParentDir) {
return Err("File path is outside the vault".into());
}
}
if abs.exists() {
let canonical_vault = vault
.canonicalize()
.map_err(|e| format!("Cannot resolve vault path: {e}"))?;
let canonical_file = abs
.canonicalize()
.map_err(|e| format!("Cannot resolve file path: {e}"))?;
if !canonical_file.starts_with(&canonical_vault) {
return Err("File path is outside the vault".into());
}
}
// Determine the file status from `git status --porcelain`.
let output = Command::new("git")
.args(["status", "--porcelain", "--", relative_path])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git status: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout.lines().find(|l| l.len() >= 4);
let status_code = line.map(|l| l[..2].trim().to_string()).unwrap_or_default();
match status_code.as_str() {
"??" => {
// Untracked — remove from disk.
std::fs::remove_file(&abs)
.map_err(|e| format!("Failed to delete untracked file: {e}"))?;
}
_ => {
// Modified, deleted, added-to-index, renamed, etc. — restore via git.
// Unstage first (ignore errors — file might not be staged).
let _ = Command::new("git")
.args(["reset", "HEAD", "--", relative_path])
.current_dir(vault)
.output();
let checkout = Command::new("git")
.args(["checkout", "--", relative_path])
.current_dir(vault)
.output()
.map_err(|e| format!("Failed to run git checkout: {e}"))?;
if !checkout.status.success() {
let stderr = String::from_utf8_lossy(&checkout.stderr);
return Err(format!("git checkout failed: {}", stderr.trim()));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -172,4 +244,86 @@ mod tests {
after
);
}
#[test]
fn test_discard_modified_file() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Original\n").unwrap();
git_commit(vp, "initial").unwrap();
// Modify the file
fs::write(vault.join("note.md"), "# Changed\n").unwrap();
assert_eq!(get_modified_files(vp).unwrap().len(), 1);
// Discard
discard_file_changes(vp, "note.md").unwrap();
let content = fs::read_to_string(vault.join("note.md")).unwrap();
assert_eq!(content, "# Original\n");
assert!(get_modified_files(vp).unwrap().is_empty());
}
#[test]
fn test_discard_untracked_file() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("init.md"), "# Init\n").unwrap();
git_commit(vp, "initial").unwrap();
// Create an untracked file
fs::write(vault.join("new.md"), "# New\n").unwrap();
assert!(vault.join("new.md").exists());
discard_file_changes(vp, "new.md").unwrap();
assert!(!vault.join("new.md").exists());
assert!(get_modified_files(vp).unwrap().is_empty());
}
#[test]
fn test_discard_deleted_file() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
fs::write(vault.join("note.md"), "# Original\n").unwrap();
git_commit(vp, "initial").unwrap();
// Delete the file
fs::remove_file(vault.join("note.md")).unwrap();
assert!(!vault.join("note.md").exists());
discard_file_changes(vp, "note.md").unwrap();
assert!(vault.join("note.md").exists());
let content = fs::read_to_string(vault.join("note.md")).unwrap();
assert_eq!(content, "# Original\n");
}
#[test]
fn test_discard_rejects_path_outside_vault() {
let dir = setup_git_repo();
let vault = dir.path();
let vp = vault.to_str().unwrap();
// Need an initial commit so git status works
fs::write(vault.join("init.md"), "# Init\n").unwrap();
git_commit(vp, "initial").unwrap();
let result = discard_file_changes(vp, "../../../etc/passwd");
assert!(
result.is_err(),
"Should reject path outside vault, got: {:?}",
result
);
assert!(
result.unwrap_err().contains("outside the vault"),
"Error should mention 'outside the vault'"
);
}
}

View File

@@ -140,6 +140,7 @@ pub fn run() {
commands::get_conflict_mode,
commands::git_resolve_conflict,
commands::git_commit_conflict_resolution,
commands::git_discard_file,
commands::is_git_repo,
commands::init_git_repo,
commands::check_claude_cli,
@@ -170,6 +171,7 @@ pub fn run() {
commands::github_device_flow_poll,
commands::github_get_user,
commands::search_vault,
commands::create_empty_vault,
commands::create_getting_started_vault,
commands::check_vault_exists,
commands::get_default_vault_path,

View File

@@ -1,5 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { VaultEntry } from './types'
import { Sidebar } from './components/Sidebar'
import { NoteList } from './components/NoteList'
import { Editor } from './components/Editor'
@@ -19,6 +18,7 @@ import { WelcomeScreen } from './components/WelcomeScreen'
import { TelemetryConsentDialog } from './components/TelemetryConsentDialog'
import { useTelemetry } from './hooks/useTelemetry'
import { useMcpStatus } from './hooks/useMcpStatus'
import { useClaudeCodeStatus } from './hooks/useClaudeCodeStatus'
import { useVaultLoader } from './hooks/useVaultLoader'
import { useSettings } from './hooks/useSettings'
import { useNoteActions } from './hooks/useNoteActions'
@@ -129,6 +129,7 @@ function App() {
}
}, [vault.entries.length, gitRepoState, resolvedPath])
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
const { status: claudeCodeStatus, version: claudeCodeVersion } = useClaudeCodeStatus()
const autoSync = useAutoSync({
vaultPath: resolvedPath,
@@ -310,6 +311,20 @@ function App() {
openNoteInNewWindow(entry.path, resolvedPath, entry.title)
}, [resolvedPath])
const handleDiscardFile = useCallback(async (relativePath: string) => {
try {
if (isTauri()) {
await invoke('git_discard_file', { vaultPath: resolvedPath, relativePath })
} else {
await mockInvoke('git_discard_file', { vaultPath: resolvedPath, relativePath })
}
await vault.loadModifiedFiles()
await vault.reloadVault()
} catch (err) {
setToastMessage(typeof err === 'string' ? err : 'Failed to discard changes')
}
}, [resolvedPath, vault, setToastMessage])
const commitFlow = useCommitFlow({ savePending: appSave.savePending, loadModifiedFiles: vault.loadModifiedFiles, commitAndPush: vault.commitAndPush, setToastMessage, onPushRejected: autoSync.handlePushRejected })
const suggestedCommitMessage = useMemo(() => generateCommitMessage(vault.modifiedFiles), [vault.modifiedFiles])
@@ -346,12 +361,7 @@ function App() {
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition })
trackEvent(editing ? 'view_updated' : 'view_created')
await vault.reloadViews()
// Update vault entries so the .yml file appears in FOLDERS immediately
const filePath = resolvedPath + '/views/' + filename
try {
const entry = await target<VaultEntry>('reload_vault_entry', { path: filePath })
if (editing) { vault.updateEntry(filePath, entry) } else { vault.addEntry(entry) }
} catch { /* non-critical — will appear after next vault reload */ }
await vault.reloadVault()
vault.reloadFolders()
setToastMessage(editing ? `View "${definition.name}" updated` : `View "${definition.name}" created`)
handleSetSelection({ kind: 'view', filename })
@@ -366,8 +376,7 @@ function App() {
const target = isTauri() ? invoke : mockInvoke
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
await vault.reloadViews()
// Remove the .yml file from vault entries so it disappears from FOLDERS immediately
vault.removeEntry(resolvedPath + '/views/' + filename)
await vault.reloadVault()
vault.reloadFolders()
if (selection.kind === 'view' && selection.filename === filename) {
handleSetSelection({ kind: 'filter', filter: 'all' })
@@ -376,7 +385,7 @@ function App() {
}, [resolvedPath, vault, selection, handleSetSelection])
const availableFields = useMemo(() => {
const builtIn = ['type', 'status', 'title', 'favorite']
const builtIn = ['type', 'status', 'title', 'favorite', 'body']
if (!vault.entries?.length) return builtIn
const customFields = new Set<string>()
for (const e of vault.entries) {
@@ -482,6 +491,8 @@ function App() {
vaultCount: vaultSwitcher.allVaults.length,
mcpStatus,
onInstallMcp: installMcp,
claudeCodeStatus: claudeCodeStatus ?? undefined,
claudeCodeVersion: claudeCodeVersion ?? undefined,
onEmptyTrash: deleteActions.handleEmptyTrash,
trashedCount: deleteActions.trashedCount,
onReloadVault: vault.reloadVault,
@@ -575,7 +586,7 @@ function App() {
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={vaultBridge.handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} views={vault.views} />
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} noteListFilter={noteListFilter} onNoteListFilterChange={setNoteListFilter} inboxPeriod={inboxPeriod} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onBulkRestore={bulkActions.handleBulkRestore} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onEmptyTrash={deleteActions.handleEmptyTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} views={vault.views} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -637,7 +648,7 @@ function App() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={commitFlow.openCommitDialog} isGitVault={!vault.modifiedFilesError} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} claudeCodeStatus={claudeCodeStatus} claudeCodeVersion={claudeCodeVersion} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
@@ -691,6 +702,7 @@ function WelcomeView({ onboarding }: { onboarding: OnboardingState }) {
missingPath={state.status === 'vault-missing' ? state.vaultPath : undefined}
defaultVaultPath={state.defaultPath}
onCreateVault={onboarding.handleCreateVault}
onCreateNewVault={onboarding.handleCreateNewVault}
onOpenFolder={onboarding.handleOpenFolder}
creating={onboarding.creating}
error={onboarding.error}

View File

@@ -93,17 +93,6 @@ describe('DynamicPropertiesPanel', () => {
expect(screen.getByText('Note')).toBeInTheDocument()
})
it('renders word count', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content="---\ntitle: Test\n---\nOne two three four"
frontmatter={{}}
/>
)
expect(screen.getByText('Words')).toBeInTheDocument()
})
it('renders status as colored pill', () => {
render(
<DynamicPropertiesPanel
@@ -391,17 +380,6 @@ describe('DynamicPropertiesPanel', () => {
})
})
it('renders modified date', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry({ modifiedAt: 1700000000 })}
content=""
frontmatter={{}}
/>
)
expect(screen.getByText('Modified')).toBeInTheDocument()
})
it('opens status dropdown on click and selects a status', () => {
render(
<DynamicPropertiesPanel
@@ -536,69 +514,6 @@ describe('DynamicPropertiesPanel', () => {
})
describe('editable vs read-only distinction', () => {
it('renders Info section header', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{}}
/>
)
expect(screen.getByText('Info')).toBeInTheDocument()
})
it('renders Modified and Words in read-only Info section', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry({ modifiedAt: 1700000000 })}
content="---\ntitle: Test\n---\nOne two three"
frontmatter={{}}
/>
)
const readOnlyRows = screen.getAllByTestId('readonly-property')
const labels = readOnlyRows.map(row => row.querySelector('span')?.textContent)
expect(labels).toContain('Modified')
expect(labels).toContain('Words')
})
it('renders Created date in Info section', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry({ createdAt: 1700000000 })}
content=""
frontmatter={{}}
/>
)
const readOnlyRows = screen.getAllByTestId('readonly-property')
const labels = readOnlyRows.map(row => row.querySelector('span')?.textContent)
expect(labels).toContain('Created')
})
it('renders file size in Info section', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry({ fileSize: 4300 })}
content=""
frontmatter={{}}
/>
)
expect(screen.getByText('Size')).toBeInTheDocument()
expect(screen.getByText('4.2 KB')).toBeInTheDocument()
})
it('shows em dash for null timestamps in Info section', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry({ modifiedAt: null, createdAt: null })}
content=""
frontmatter={{}}
/>
)
// Two em dashes for null Modified and Created
const dashes = screen.getAllByText('\u2014')
expect(dashes.length).toBeGreaterThanOrEqual(2)
})
it('editable properties have hover styling via data-testid', () => {
render(
<DynamicPropertiesPanel
@@ -616,52 +531,6 @@ describe('DynamicPropertiesPanel', () => {
expect(row.className).toContain('hover:bg-muted')
})
})
it('read-only rows do not have hover styling', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{}}
/>
)
const readOnlyRows = screen.getAllByTestId('readonly-property')
readOnlyRows.forEach(row => {
expect(row.className).not.toContain('hover:bg-muted')
})
})
it('formats file sizes correctly', () => {
// Small file — bytes
const { rerender } = render(
<DynamicPropertiesPanel
entry={makeEntry({ fileSize: 500 })}
content=""
frontmatter={{}}
/>
)
expect(screen.getByText('500 B')).toBeInTheDocument()
// KB range
rerender(
<DynamicPropertiesPanel
entry={makeEntry({ fileSize: 2048 })}
content=""
frontmatter={{}}
/>
)
expect(screen.getByText('2.0 KB')).toBeInTheDocument()
// MB range
rerender(
<DynamicPropertiesPanel
entry={makeEntry({ fileSize: 1048576 })}
content=""
frontmatter={{}}
/>
)
expect(screen.getByText('1.0 MB')).toBeInTheDocument()
})
})
describe('property row 50/50 layout', () => {
@@ -680,8 +549,54 @@ describe('DynamicPropertiesPanel', () => {
expect(row.className).toContain('grid-cols-2')
})
})
})
it('uses CSS grid with two equal columns on read-only rows', () => {
describe('suggested property slots', () => {
it('shows Status/Date/URL slots when no properties exist and onAddProperty provided', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{}}
onAddProperty={onAddProperty}
/>
)
const slots = screen.getAllByTestId('suggested-property')
expect(slots.length).toBe(3)
expect(screen.getByText('Status')).toBeInTheDocument()
expect(screen.getByText('Date')).toBeInTheDocument()
expect(screen.getByText('URL')).toBeInTheDocument()
})
it('hides Status slot when Status property already exists', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ Status: 'Active' }}
onAddProperty={onAddProperty}
onUpdateProperty={onUpdateProperty}
/>
)
const slots = screen.getAllByTestId('suggested-property')
expect(slots.length).toBe(2)
expect(screen.queryAllByText('Status').some(el => el.closest('[data-testid="suggested-property"]'))).toBe(false)
})
it('hides all slots when all suggested properties exist', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{ Status: 'Active', Date: '2024-01-01', URL: 'https://example.com' }}
onAddProperty={onAddProperty}
onUpdateProperty={onUpdateProperty}
/>
)
expect(screen.queryByTestId('suggested-property')).not.toBeInTheDocument()
})
it('does not show slots when onAddProperty is not provided', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
@@ -689,11 +604,20 @@ describe('DynamicPropertiesPanel', () => {
frontmatter={{}}
/>
)
const readOnlyRows = screen.getAllByTestId('readonly-property')
readOnlyRows.forEach(row => {
expect(row.className).toContain('grid')
expect(row.className).toContain('grid-cols-2')
})
expect(screen.queryByTestId('suggested-property')).not.toBeInTheDocument()
})
it('calls onAddProperty when clicking a suggested slot', () => {
render(
<DynamicPropertiesPanel
entry={makeEntry()}
content=""
frontmatter={{}}
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('Status'))
expect(onAddProperty).toHaveBeenCalledWith('Status', '')
})
})

View File

@@ -1,3 +1,4 @@
import { useMemo, useCallback } from 'react'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from './Inspector'
import type { ParsedFrontmatter } from '../utils/frontmatter'
@@ -6,7 +7,6 @@ import { getEffectiveDisplayMode, detectPropertyType } from '../utils/propertyTy
import { SmartPropertyValueCell, DisplayModeSelector } from './PropertyValueCells'
import { TypeSelector } from './TypeSelector'
import { AddPropertyForm } from './AddPropertyForm'
import { countWords } from '../utils/wikilinks'
import type { PropertyDisplayMode } from '../utils/propertyTypes'
function toSentenceCase(key: string): string {
@@ -22,20 +22,6 @@ export function containsWikilinks(value: FrontmatterValue): boolean {
return false
}
function formatDate(timestamp: number | null): string {
if (!timestamp) return '\u2014'
const d = new Date(timestamp * 1000)
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const kb = bytes / 1024
if (kb < 1024) return `${kb.toFixed(1)} KB`
const mb = kb / 1024
return `${mb.toFixed(1)} MB`
}
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
propKey: string; value: FrontmatterValue; editingKey: string | null
displayMode: PropertyDisplayMode; autoMode: PropertyDisplayMode
@@ -68,15 +54,6 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
)
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" data-testid="readonly-property">
<span className="min-w-0 truncate text-[12px] text-muted-foreground">{label}</span>
<span className="min-w-0 truncate text-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
</div>
)
}
function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disabled: boolean }) {
return (
<button
@@ -89,26 +66,29 @@ function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disable
)
}
function NoteInfoSection({ entry, wordCount }: { entry: VaultEntry; wordCount: number }) {
const SUGGESTED_PROPERTIES = ['Status', 'Date', 'URL'] as const
function SuggestedPropertySlot({ label, onAdd }: { label: string; onAdd: () => void }) {
return (
<div className="border-t border-border pt-3">
<h4 className="font-mono-overline mb-2 text-muted-foreground">Info</h4>
<div className="flex flex-col gap-1.5">
<InfoRow label="Modified" value={formatDate(entry.modifiedAt)} />
<InfoRow label="Created" value={formatDate(entry.createdAt)} />
<InfoRow label="Words" value={String(wordCount)} />
<InfoRow label="Size" value={formatFileSize(entry.fileSize)} />
</div>
</div>
<button
className="grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded border-none bg-transparent px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary cursor-pointer"
tabIndex={0}
onClick={onAdd}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onAdd() } }}
data-testid="suggested-property"
>
<span className="min-w-0 truncate text-[12px] text-muted-foreground/50">{label}</span>
<span className="min-w-0 truncate text-right text-[12px] text-muted-foreground/30">{'\u2014'}</span>
</button>
)
}
export function DynamicPropertiesPanel({
entry, content, frontmatter, entries,
entry, frontmatter, entries,
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,
}: {
entry: VaultEntry
content: string | null
content?: string | null
frontmatter: ParsedFrontmatter
entries?: VaultEntry[]
onUpdateProperty?: (key: string, value: FrontmatterValue) => void
@@ -122,7 +102,23 @@ export function DynamicPropertiesPanel({
handleSaveValue, handleSaveList, handleAdd, handleDisplayModeChange,
} = usePropertyPanelState({ entries, entryIsA: entry.isA, frontmatter, onUpdateProperty, onDeleteProperty, onAddProperty })
const wordCount = countWords(content ?? '')
const existingKeys = useMemo(() => {
const keys = new Set(propertyEntries.map(([k]) => k.toLowerCase()))
// Also check full frontmatter for relationship keys that are filtered out of propertyEntries
for (const k of Object.keys(frontmatter)) keys.add(k.toLowerCase())
return keys
}, [propertyEntries, frontmatter])
const missingSuggested = onAddProperty
? SUGGESTED_PROPERTIES.filter(p => !existingKeys.has(p.toLowerCase()))
: []
const handleSuggestedAdd = useCallback((key: string) => {
if (!onAddProperty) return
onAddProperty(key, '')
// Auto-focus the new property value
setTimeout(() => setEditingKey(key), 0)
}, [onAddProperty, setEditingKey])
return (
<div className="flex flex-col gap-3">
@@ -140,12 +136,14 @@ export function DynamicPropertiesPanel({
onDisplayModeChange={handleDisplayModeChange}
/>
))}
{missingSuggested.map(key => (
<SuggestedPropertySlot key={key} label={key} onAdd={() => handleSuggestedAdd(key)} />
))}
</div>
{showAddDialog
? <AddPropertyForm onAdd={handleAdd} onCancel={() => setShowAddDialog(false)} vaultStatuses={vaultStatuses} />
: <AddPropertyButton onClick={() => setShowAddDialog(true)} disabled={!onAddProperty} />
}
<NoteInfoSection entry={entry} wordCount={wordCount} />
</div>
)
}

View File

@@ -198,4 +198,16 @@ describe('FilterBuilder wikilink autocomplete', () => {
expect(input).toBeInTheDocument()
expect(input).not.toHaveAttribute('data-testid', 'filter-value-input')
})
it('shows body field in field dropdown separated from property fields', () => {
render(
<FilterBuilder
group={{ all: [{ field: 'body', op: 'contains', value: 'test' }] }}
onChange={vi.fn()}
availableFields={['type', 'status', 'body']}
/>,
)
// Body field should be selected as the current value
expect(screen.getByText('body')).toBeInTheDocument()
})
})

View File

@@ -2,7 +2,7 @@ import { useState, useRef, useMemo, useEffect, useCallback } from 'react'
import { Plus, X } from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
import type { FilterCondition, FilterOp, FilterGroup, FilterNode, VaultEntry } from '../types'
import { buildTypeEntryMap, getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { getTypeIcon } from './NoteItem'
@@ -38,12 +38,16 @@ function setGroupChildren(mode: 'all' | 'any', children: FilterNode[]): FilterGr
return mode === 'all' ? { all: children } : { any: children }
}
const CONTENT_FIELDS = new Set(['body'])
function FieldSelect({ value, fields, onChange }: {
value: string
fields: string[]
onChange: (v: string) => void
}) {
const isCustom = value !== '' && !fields.includes(value)
const propertyFields = fields.filter(f => !CONTENT_FIELDS.has(f))
const contentFields = fields.filter(f => CONTENT_FIELDS.has(f))
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger
@@ -54,9 +58,17 @@ function FieldSelect({ value, fields, onChange }: {
</SelectTrigger>
<SelectContent position="popper">
{isCustom && <SelectItem value={value}>{value}</SelectItem>}
{fields.map((f) => (
{propertyFields.map((f) => (
<SelectItem key={f} value={f}>{f}</SelectItem>
))}
{contentFields.length > 0 && (
<>
<SelectSeparator />
{contentFields.map((f) => (
<SelectItem key={f} value={f}>{f}</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
)

View File

@@ -6,7 +6,7 @@ import { SlidersHorizontal, X, Sparkle, WarningCircle, PencilSimple } from '@pho
import { Separator } from './ui/separator'
import { parseFrontmatter, detectFrontmatterState } from '../utils/frontmatter'
import { DynamicPropertiesPanel } from './DynamicPropertiesPanel'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel } from './InspectorPanels'
import { DynamicRelationshipsPanel, BacklinksPanel, ReferencedByPanel, GitHistoryPanel, InstancesPanel, NoteInfoPanel } from './InspectorPanels'
import { wikilinkTarget } from '../utils/wikilink'
import type { ReferencedByItem, BacklinkItem } from './InspectorPanels'
@@ -189,7 +189,7 @@ export function Inspector({
{fmState === 'valid' ? (
<>
<DynamicPropertiesPanel
entry={entry} content={content} frontmatter={frontmatter}
entry={entry} frontmatter={frontmatter}
entries={entries}
onUpdateProperty={onUpdateFrontmatter ? handleUpdateProperty : undefined}
onDeleteProperty={onDeleteProperty ? handleDeleteProperty : undefined}
@@ -213,6 +213,8 @@ export function Inspector({
)}
{backlinks.length > 0 && <Separator />}
<BacklinksPanel backlinks={backlinks} onNavigate={onNavigate} />
<Separator />
<NoteInfoPanel entry={entry} content={content} />
{gitHistory.length > 0 && <Separator />}
<GitHistoryPanel commits={gitHistory} onViewCommitDiff={onViewCommitDiff} />
</>

View File

@@ -139,7 +139,7 @@ describe('DynamicRelationshipsPanel', () => {
expect(screen.getByText('Luca')).toBeInTheDocument()
})
it('renders + Link existing button when onAddProperty provided', () => {
it('renders + Add relationship button when onAddProperty provided', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
@@ -149,7 +149,7 @@ describe('DynamicRelationshipsPanel', () => {
onAddProperty={onAddProperty}
/>
)
expect(screen.getByText('+ Link existing')).toBeInTheDocument()
expect(screen.getByText('+ Add relationship')).toBeInTheDocument()
})
it('opens add relationship form when button clicked', () => {
@@ -162,7 +162,7 @@ describe('DynamicRelationshipsPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Link existing'))
fireEvent.click(screen.getByText('+ Add relationship'))
expect(screen.getByPlaceholderText('Relationship name')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Note title')).toBeInTheDocument()
})
@@ -177,7 +177,7 @@ describe('DynamicRelationshipsPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Link existing'))
fireEvent.click(screen.getByText('+ Add relationship'))
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Related to' } })
fireEvent.change(screen.getByPlaceholderText('Note title'), { target: { value: 'AI' } })
fireEvent.click(screen.getByText('Add'))
@@ -194,9 +194,55 @@ describe('DynamicRelationshipsPanel', () => {
onAddProperty={onAddProperty}
/>
)
fireEvent.click(screen.getByText('+ Link existing'))
fireEvent.click(screen.getByText('+ Add relationship'))
fireEvent.click(screen.getByText('Cancel'))
expect(screen.getByText('+ Link existing')).toBeInTheDocument()
expect(screen.getByText('+ Add relationship')).toBeInTheDocument()
})
describe('suggested relationship slots', () => {
it('shows Belongs to/Related to/Has slots when no relationships exist', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{}}
entries={entries}
onNavigate={onNavigate}
onAddProperty={onAddProperty}
/>
)
const slots = screen.getAllByTestId('suggested-relationship')
expect(slots.length).toBe(3)
expect(screen.getByText('Belongs to')).toBeInTheDocument()
expect(screen.getByText('Related to')).toBeInTheDocument()
expect(screen.getByText('Has')).toBeInTheDocument()
})
it('hides slot when relationship already exists', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{ 'Belongs to': '[[Project Alpha]]' }}
entries={entries}
onNavigate={onNavigate}
onAddProperty={onAddProperty}
/>
)
const slots = screen.getAllByTestId('suggested-relationship')
expect(slots.length).toBe(2)
expect(screen.queryAllByText('Belongs to').every(el => !el.closest('[data-testid="suggested-relationship"]'))).toBe(true)
})
it('does not show slots when onAddProperty not provided', () => {
render(
<DynamicRelationshipsPanel
typeEntryMap={{}}
frontmatter={{}}
entries={entries}
onNavigate={onNavigate}
/>
)
expect(screen.queryByTestId('suggested-relationship')).not.toBeInTheDocument()
})
})
it('dims archived entries', () => {
@@ -591,7 +637,7 @@ describe('DynamicRelationshipsPanel', () => {
onCreateAndOpenNote={onCreateAndOpenNote}
/>
)
fireEvent.click(screen.getByText('+ Link existing'))
fireEvent.click(screen.getByText('+ Add relationship'))
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } })
const noteInput = screen.getByPlaceholderText('Note title')
fireEvent.focus(noteInput)
@@ -610,7 +656,7 @@ describe('DynamicRelationshipsPanel', () => {
onCreateAndOpenNote={onCreateAndOpenNote}
/>
)
fireEvent.click(screen.getByText('+ Link existing'))
fireEvent.click(screen.getByText('+ Add relationship'))
fireEvent.change(screen.getByPlaceholderText('Relationship name'), { target: { value: 'Mentions' } })
const noteInput = screen.getByPlaceholderText('Note title')
fireEvent.focus(noteInput)

View File

@@ -5,3 +5,4 @@ export { ReferencedByPanel } from './inspector/ReferencedByPanel'
export type { ReferencedByItem } from './inspector/ReferencedByPanel'
export { GitHistoryPanel } from './inspector/GitHistoryPanel'
export { InstancesPanel } from './inspector/InstancesPanel'
export { NoteInfoPanel } from './inspector/NoteInfoPanel'

View File

@@ -152,7 +152,7 @@ function getFileKindIcon(fileKind: string | undefined): ComponentType<SVGAttribu
return FileText
}
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', typeEntryMap, onClickNote, onPrefetch }: {
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', typeEntryMap, onClickNote, onPrefetch, onContextMenu }: {
entry: VaultEntry
isSelected: boolean
isMultiSelected?: boolean
@@ -161,6 +161,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
typeEntryMap: Record<string, VaultEntry>
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
onPrefetch?: (path: string) => void
onContextMenu?: (entry: VaultEntry, e: React.MouseEvent) => void
}) {
const isBinary = entry.fileKind === 'binary'
const isNonMarkdown = !!entry.fileKind && entry.fileKind !== 'markdown'
@@ -187,6 +188,7 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
)}
style={isBinary ? { padding: '14px 16px' } : noteItemStyle(isSelected, isMultiSelected, typeColor, typeLightColor)}
onClick={handleClick}
onContextMenu={onContextMenu ? (e) => onContextMenu(entry, e) : undefined}
onMouseEnter={!isBinary && onPrefetch ? () => onPrefetch(entry.path) : undefined}
data-testid={isMultiSelected ? 'multi-selected-item' : isBinary ? 'binary-file-item' : undefined}
data-highlighted={isHighlighted || undefined}

View File

@@ -1081,6 +1081,64 @@ describe('NoteList — virtual list with large datasets', () => {
)
expect(screen.queryByText(/notes? deleted/)).not.toBeInTheDocument()
})
it('shows context menu with "Discard changes" on right-click when onDiscardFile is provided', () => {
const onDiscard = vi.fn()
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onDiscardFile={onDiscard} />
)
const noteItem = screen.getByText('Build Laputa App').closest('[class*="border-b"]')!
fireEvent.contextMenu(noteItem)
expect(screen.getByTestId('changes-context-menu')).toBeInTheDocument()
expect(screen.getByTestId('discard-changes-button')).toBeInTheDocument()
})
it('does not show context menu when onDiscardFile is not provided', () => {
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} />
)
const noteItem = screen.getByText('Build Laputa App').closest('[class*="border-b"]')!
fireEvent.contextMenu(noteItem)
expect(screen.queryByTestId('changes-context-menu')).not.toBeInTheDocument()
})
it('shows confirmation dialog after clicking "Discard changes"', () => {
const onDiscard = vi.fn()
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onDiscardFile={onDiscard} />
)
const noteItem = screen.getByText('Build Laputa App').closest('[class*="border-b"]')!
fireEvent.contextMenu(noteItem)
fireEvent.click(screen.getByTestId('discard-changes-button'))
expect(screen.getByTestId('discard-confirm-dialog')).toBeInTheDocument()
// Dialog mentions the note title
const dialog = screen.getByTestId('discard-confirm-dialog')
expect(dialog.textContent).toContain('Build Laputa App')
})
it('calls onDiscardFile with relativePath when discard is confirmed', async () => {
const onDiscard = vi.fn().mockResolvedValue(undefined)
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onDiscardFile={onDiscard} />
)
const noteItem = screen.getByText('Build Laputa App').closest('[class*="border-b"]')!
fireEvent.contextMenu(noteItem)
fireEvent.click(screen.getByTestId('discard-changes-button'))
fireEvent.click(screen.getByTestId('discard-confirm-button'))
expect(onDiscard).toHaveBeenCalledWith('project/26q1-laputa-app.md')
})
it('does not call onDiscardFile when cancel is clicked', () => {
const onDiscard = vi.fn()
render(
<NoteList {...defaultFilterProps} entries={mockEntries} selection={changesSelection} selectedNote={null} modifiedFiles={modifiedFiles} onSelectNote={noopSelect} onReplaceActiveTab={noopReplace} onCreateNote={vi.fn()} onDiscardFile={onDiscard} />
)
const noteItem = screen.getByText('Build Laputa App').closest('[class*="border-b"]')!
fireEvent.contextMenu(noteItem)
fireEvent.click(screen.getByTestId('discard-changes-button'))
fireEvent.click(screen.getByText('Cancel'))
expect(onDiscard).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
import { useState, useMemo, useCallback, useEffect, useRef, memo } from 'react'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod, ViewFile } from '../types'
import type { NoteListFilter } from '../utils/noteListHelpers'
import { countByFilter, countAllByFilter } from '../utils/noteListHelpers'
@@ -16,6 +16,11 @@ import {
useTypeEntryMap, useNoteListData, useNoteListSearch,
useNoteListSort, useMultiSelectKeyboard, useModifiedFilesState,
} from './note-list/noteListHooks'
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
DialogDescription, DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
interface NoteListProps {
entries: VaultEntry[]
@@ -40,10 +45,11 @@ interface NoteListProps {
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
onOpenInNewWindow?: (entry: VaultEntry) => void
onDiscardFile?: (relativePath: string) => Promise<void>
views?: ViewFile[]
}
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, views }: NoteListProps) {
function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNoteListFilterChange, inboxPeriod = 'all', modifiedFiles, modifiedFilesError, getNoteStatus, sidebarCollapsed, onSelectNote, onReplaceActiveTab, onCreateNote, onBulkArchive, onBulkTrash, onBulkRestore, onBulkDeletePermanently, onEmptyTrash, onUpdateTypeSort, updateEntry, onOpenInNewWindow, onDiscardFile, views }: NoteListProps) {
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
const isSectionGroup = selection.kind === 'sectionGroup'
@@ -88,9 +94,41 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
const bulkTrashOrDelete = isTrashView ? handleBulkDeletePermanently : handleBulkTrash
useMultiSelectKeyboard(multiSelect, isEntityView, bulkArchiveOrRestore, bulkTrashOrDelete)
// ── Changes view: context menu + discard confirmation ──
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; entry: VaultEntry } | null>(null)
const [discardTarget, setDiscardTarget] = useState<VaultEntry | null>(null)
const ctxMenuRef = useRef<HTMLDivElement>(null)
const handleNoteContextMenu = useCallback((entry: VaultEntry, e: React.MouseEvent) => {
if (!isChangesView || !onDiscardFile) return
e.preventDefault()
e.stopPropagation()
setCtxMenu({ x: e.clientX, y: e.clientY, entry })
}, [isChangesView, onDiscardFile])
const closeCtxMenu = useCallback(() => setCtxMenu(null), [])
// Close context menu on outside click
useEffect(() => {
if (!ctxMenu) return
const handler = (e: MouseEvent) => {
if (ctxMenuRef.current && !ctxMenuRef.current.contains(e.target as Node)) closeCtxMenu()
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [ctxMenu, closeCtxMenu])
const handleDiscardConfirm = useCallback(async () => {
if (!discardTarget || !onDiscardFile) return
const mf = modifiedFiles?.find((f) => f.path === discardTarget.path)
if (!mf) return
await onDiscardFile(mf.relativePath)
setDiscardTarget(null)
}, [discardTarget, onDiscardFile, modifiedFiles])
const renderItem = useCallback((entry: VaultEntry) => (
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={prefetchNoteContent} />
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath])
<NoteItem key={entry.path} entry={entry} isSelected={selectedNote?.path === entry.path} isMultiSelected={multiSelect.selectedPaths.has(entry.path)} isHighlighted={entry.path === noteListKeyboard.highlightedPath} noteStatus={resolvedGetNoteStatus(entry.path)} typeEntryMap={typeEntryMap} onClickNote={handleClickNote} onPrefetch={prefetchNoteContent} onContextMenu={isChangesView && onDiscardFile ? handleNoteContextMenu : undefined} />
), [selectedNote?.path, handleClickNote, typeEntryMap, resolvedGetNoteStatus, multiSelect.selectedPaths, noteListKeyboard.highlightedPath, isChangesView, onDiscardFile, handleNoteContextMenu])
const handleCreateNote = useCallback(() => {
onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined)
@@ -115,6 +153,35 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
{multiSelect.isMultiSelecting && (
<BulkActionBar count={multiSelect.selectedPaths.size} isTrashView={isTrashView} isArchivedView={isArchivedView} onArchive={handleBulkArchive} onTrash={handleBulkTrash} onRestore={handleBulkRestore} onDeletePermanently={handleBulkDeletePermanently} onUnarchive={handleBulkUnarchive} onClear={multiSelect.clear} />
)}
{/* Changes view: context menu */}
{ctxMenu && (
<div ref={ctxMenuRef} className="fixed z-50 rounded-md border bg-popover p-1 shadow-md" style={{ left: ctxMenu.x, top: ctxMenu.y, minWidth: 180 }} data-testid="changes-context-menu">
<button
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left text-destructive"
onClick={() => { setDiscardTarget(ctxMenu.entry); closeCtxMenu() }}
data-testid="discard-changes-button"
>
Discard changes
</button>
</div>
)}
{/* Discard confirmation dialog */}
<Dialog open={!!discardTarget} onOpenChange={(open) => { if (!open) setDiscardTarget(null) }}>
<DialogContent showCloseButton={false} data-testid="discard-confirm-dialog">
<DialogHeader>
<DialogTitle>Discard changes</DialogTitle>
<DialogDescription>
Discard changes to <strong>{discardTarget?.title ?? 'this file'}</strong>? This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDiscardTarget(null)}>Cancel</Button>
<Button variant="destructive" onClick={handleDiscardConfirm} data-testid="discard-confirm-button">Discard</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1009,4 +1009,29 @@ describe('Sidebar', () => {
expect(onEditView).toHaveBeenCalledWith('active-projects.yml')
})
})
describe('create type button', () => {
it('renders + button in TYPES header when onCreateNewType is provided', () => {
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onCreateNewType={() => {}} />
)
expect(screen.getByTestId('create-type-btn')).toBeInTheDocument()
})
it('does not render + button when onCreateNewType is not provided', () => {
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />
)
expect(screen.queryByTestId('create-type-btn')).not.toBeInTheDocument()
})
it('calls onCreateNewType when + button is clicked', () => {
const onCreateNewType = vi.fn()
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onCreateNewType={onCreateNewType} />
)
fireEvent.click(screen.getByTestId('create-type-btn'))
expect(onCreateNewType).toHaveBeenCalledOnce()
})
})
})

View File

@@ -334,6 +334,7 @@ export const Sidebar = memo(function Sidebar({
onToggleTypeVisibility, onSelectFavorite, onReorderFavorites,
views = [], onCreateView, onEditView, onDeleteView,
folders = [], onCreateFolder, inboxCount = 0, onCollapse,
onCreateNewType,
}: SidebarProps) {
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
@@ -499,14 +500,24 @@ export const Sidebar = memo(function Sidebar({
{groupCollapsed.sections ? <CaretRight size={12} /> : <CaretDown size={12} />}
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>TYPES</span>
</div>
<span
role="button"
title="Customize sections"
aria-label="Customize sections"
onClick={(e) => { e.stopPropagation(); setShowCustomize((v) => !v) }}
>
<SlidersHorizontal size={12} className="text-muted-foreground hover:text-foreground" />
</span>
<div className="flex items-center gap-1.5">
<span
role="button"
title="Customize sections"
aria-label="Customize sections"
onClick={(e) => { e.stopPropagation(); setShowCustomize((v) => !v) }}
>
<SlidersHorizontal size={12} className="text-muted-foreground hover:text-foreground" />
</span>
{onCreateNewType && (
<Plus
size={12}
className="text-muted-foreground hover:text-foreground"
data-testid="create-type-btn"
onClick={(e) => { e.stopPropagation(); onCreateNewType() }}
/>
)}
</div>
</button>
{showCustomize && <VisibilityPopover sections={allSectionGroups} isSectionVisible={isSectionVisible} onToggle={toggleVisibility} />}
</div>

View File

@@ -352,4 +352,42 @@ describe('StatusBar', () => {
expect(screen.queryByTestId('status-commit-push')).not.toBeInTheDocument()
})
it('shows Claude Code badge when installed', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} claudeCodeStatus="installed" claudeCodeVersion="1.0.20" />)
const badge = screen.getByTestId('status-claude-code')
expect(badge).toBeInTheDocument()
expect(screen.getByText('Claude Code')).toBeInTheDocument()
expect(badge.getAttribute('title')).toBe('Claude Code 1.0.20')
})
it('shows Claude Code missing badge with warning when missing', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} claudeCodeStatus="missing" />)
const badge = screen.getByTestId('status-claude-code')
expect(badge).toBeInTheDocument()
expect(screen.getByText('Claude Code missing')).toBeInTheDocument()
expect(badge.getAttribute('title')).toBe('Claude Code not found — click to install')
})
it('opens install URL when clicking missing Claude Code badge', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} claudeCodeStatus="missing" />)
fireEvent.click(screen.getByTestId('status-claude-code'))
expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code')
})
it('opens install URL on Enter key for missing Claude Code badge', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} claudeCodeStatus="missing" />)
fireEvent.keyDown(screen.getByTestId('status-claude-code'), { key: 'Enter' })
expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code')
})
it('hides Claude Code badge when status is checking', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} claudeCodeStatus="checking" />)
expect(screen.queryByTestId('status-claude-code')).not.toBeInTheDocument()
})
it('hides Claude Code badge when no claudeCodeStatus prop provided', () => {
render(<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} />)
expect(screen.queryByTestId('status-claude-code')).not.toBeInTheDocument()
})
})

View File

@@ -1,8 +1,9 @@
import { useState, useRef, useEffect } from 'react'
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, AlertTriangle, Loader2, GitCommitHorizontal, X, Cpu, ArrowDown, GitBranch } from 'lucide-react'
import { Package, RefreshCw, FileText, Bell, Settings, FolderOpen, Check, Github, AlertTriangle, Loader2, GitCommitHorizontal, X, Cpu, ArrowDown, GitBranch, Terminal } from 'lucide-react'
import { GitDiff, Pulse } from '@phosphor-icons/react'
import type { GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types'
import type { McpStatus } from '../hooks/useMcpStatus'
import type { ClaudeCodeStatus } from '../hooks/useClaudeCodeStatus'
import { openExternalUrl } from '../utils/url'
export interface VaultOption {
@@ -40,6 +41,8 @@ interface StatusBarProps {
onRemoveVault?: (path: string) => void
mcpStatus?: McpStatus
onInstallMcp?: () => void
claudeCodeStatus?: ClaudeCodeStatus
claudeCodeVersion?: string | null
}
function VaultMenuIcon({ isActive, unavailable }: { isActive: boolean; unavailable: boolean }) {
@@ -436,7 +439,44 @@ function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?: () =>
)
}
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, onClickPulse, onCommitPush, isGitVault = false, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
const CLAUDE_INSTALL_URL = 'https://docs.anthropic.com/en/docs/claude-code'
function ClaudeCodeBadge({ status, version }: { status: ClaudeCodeStatus; version?: string | null }) {
if (status === 'checking') return null
const isMissing = status === 'missing'
const tooltip = isMissing
? 'Claude Code not found — click to install'
: `Claude Code${version ? ` ${version}` : ''}`
return (
<>
<span style={SEP_STYLE}>|</span>
<span
role={isMissing ? 'button' : undefined}
tabIndex={isMissing ? 0 : undefined}
onClick={isMissing ? () => openExternalUrl(CLAUDE_INSTALL_URL) : undefined}
onKeyDown={isMissing ? (e) => { if (e.key === 'Enter') openExternalUrl(CLAUDE_INSTALL_URL) } : undefined}
style={{
...ICON_STYLE,
color: isMissing ? 'var(--accent-orange)' : 'var(--muted-foreground)',
cursor: isMissing ? 'pointer' : 'default',
padding: '2px 4px',
borderRadius: 3,
background: 'transparent',
}}
title={tooltip}
data-testid="status-claude-code"
onMouseEnter={isMissing ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
onMouseLeave={isMissing ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
>
<Terminal size={13} />
{isMissing ? 'Claude Code missing' : 'Claude Code'}
{isMissing && <AlertTriangle size={10} style={{ marginLeft: 2 }} />}
</span>
</>
)
}
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, onClickPulse, onCommitPush, isGitVault = false, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, remoteStatus, onTriggerSync, onPullAndPush, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, onRemoveVault, mcpStatus, onInstallMcp, claudeCodeStatus, claudeCodeVersion }: StatusBarProps) {
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 30_000)
@@ -464,6 +504,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
<PulseBadge onClick={onClickPulse} disabled={!isGitVault} />
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
{claudeCodeStatus && <ClaudeCodeBadge status={claudeCodeStatus} version={claudeCodeVersion} />}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<span style={ICON_STYLE}><FileText size={13} />{noteCount.toLocaleString()} notes</span>

View File

@@ -6,6 +6,7 @@ const defaultProps = {
mode: 'welcome' as const,
defaultVaultPath: '~/Documents/Laputa',
onCreateVault: vi.fn(),
onCreateNewVault: vi.fn(),
onOpenFolder: vi.fn(),
creating: false,
error: null,
@@ -19,19 +20,26 @@ describe('WelcomeScreen', () => {
expect(screen.getByText(/Wiki-linked knowledge management/)).toBeInTheDocument()
})
it('shows create vault and open folder buttons', () => {
it('shows all three option buttons', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Create Getting Started vault')
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open an existing folder')
expect(screen.getByTestId('welcome-create-new')).toHaveTextContent('Create a new vault')
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Get started with a template')
})
it('shows default vault path hint', () => {
it('shows default vault path in template option description', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByText(/will be created in/)).toBeInTheDocument()
expect(screen.getByText(/~\/Documents\/Laputa/)).toBeInTheDocument()
})
it('calls onCreateVault when create button is clicked', () => {
it('calls onCreateNewVault when create new button is clicked', () => {
const onCreateNewVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateNewVault={onCreateNewVault} />)
fireEvent.click(screen.getByTestId('welcome-create-new'))
expect(onCreateNewVault).toHaveBeenCalledOnce()
})
it('calls onCreateVault when template button is clicked', () => {
const onCreateVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateVault={onCreateVault} />)
fireEvent.click(screen.getByTestId('welcome-create-vault'))
@@ -45,15 +53,16 @@ describe('WelcomeScreen', () => {
expect(onOpenFolder).toHaveBeenCalledOnce()
})
it('disables buttons while creating', () => {
it('disables all buttons while creating', () => {
render(<WelcomeScreen {...defaultProps} creating={true} />)
expect(screen.getByTestId('welcome-create-vault')).toBeDisabled()
expect(screen.getByTestId('welcome-create-new')).toBeDisabled()
expect(screen.getByTestId('welcome-open-folder')).toBeDisabled()
expect(screen.getByTestId('welcome-create-vault')).toBeDisabled()
})
it('shows loading text while creating', () => {
it('shows loading text on template button while creating', () => {
render(<WelcomeScreen {...defaultProps} creating={true} />)
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Creating vault…')
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent(/Creating vault/)
})
it('shows error message when error is set', () => {
@@ -90,15 +99,10 @@ describe('WelcomeScreen', () => {
expect(screen.getByText('~/Laputa')).toBeInTheDocument()
})
it('shows "Choose a different folder" instead of "Open an existing folder"', () => {
it('shows "Choose a different folder" instead of "Open existing vault"', () => {
render(<WelcomeScreen {...missingProps} />)
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Choose a different folder')
})
it('does not show vault path hint in vault-missing mode', () => {
render(<WelcomeScreen {...missingProps} />)
expect(screen.queryByText(/will be created in/)).not.toBeInTheDocument()
})
})
describe('data-testid', () => {

View File

@@ -1,11 +1,12 @@
import { useState } from 'react'
import { FolderOpen, Plus, AlertTriangle, Loader2 } from 'lucide-react'
import { FolderOpen, Plus, AlertTriangle, Loader2, Rocket } from 'lucide-react'
interface WelcomeScreenProps {
mode: 'welcome' | 'vault-missing'
missingPath?: string
defaultVaultPath: string
onCreateVault: () => void
onCreateNewVault: () => void
onOpenFolder: () => void
creating: boolean
error: string | null
@@ -64,43 +65,42 @@ const DIVIDER_STYLE: React.CSSProperties = {
background: 'var(--border)',
}
const PRIMARY_BTN_STYLE: React.CSSProperties = {
const OPTION_BTN_STYLE: React.CSSProperties = {
width: '100%',
height: 44,
borderRadius: 8,
border: 'none',
background: 'var(--primary)',
color: 'white',
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
}
const SECONDARY_BTN_STYLE: React.CSSProperties = {
width: '100%',
height: 44,
borderRadius: 8,
border: '1px solid var(--border)',
background: 'var(--background)',
color: 'var(--foreground)',
fontSize: 14,
fontWeight: 500,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
gap: 14,
padding: '14px 16px',
textAlign: 'left',
transition: 'background 0.15s',
}
const HINT_STYLE: React.CSSProperties = {
const OPTION_ICON_STYLE: React.CSSProperties = {
width: 36,
height: 36,
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}
const OPTION_LABEL_STYLE: React.CSSProperties = {
fontSize: 14,
fontWeight: 600,
color: 'var(--foreground)',
margin: 0,
}
const OPTION_DESC_STYLE: React.CSSProperties = {
fontSize: 12,
color: 'var(--muted-foreground)',
textAlign: 'center',
margin: 0,
marginTop: 2,
}
const PATH_BADGE_STYLE: React.CSSProperties = {
@@ -118,10 +118,44 @@ const ERROR_STYLE: React.CSSProperties = {
margin: 0,
}
export function WelcomeScreen({ mode, missingPath, defaultVaultPath, onCreateVault, onOpenFolder, creating, error }: WelcomeScreenProps) {
const [hoverPrimary, setHoverPrimary] = useState(false)
const [hoverSecondary, setHoverSecondary] = useState(false)
interface OptionButtonProps {
icon: React.ReactNode
iconBg: string
label: string
description: string
onClick: () => void
disabled: boolean
loading?: boolean
testId: string
}
function OptionButton({ icon, iconBg, label, description, onClick, disabled, loading, testId }: OptionButtonProps) {
const [hover, setHover] = useState(false)
return (
<button
style={{
...OPTION_BTN_STYLE,
background: hover ? 'var(--sidebar)' : 'var(--background)',
opacity: disabled ? 0.7 : 1,
}}
onClick={onClick}
disabled={disabled}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
data-testid={testId}
>
<div style={{ ...OPTION_ICON_STYLE, background: iconBg }}>
{loading ? <Loader2 size={18} className="animate-spin" style={{ color: 'var(--muted-foreground)' }} /> : icon}
</div>
<div>
<p style={OPTION_LABEL_STYLE}>{loading ? 'Creating vault\u2026' : label}</p>
<p style={OPTION_DESC_STYLE}>{description}</p>
</div>
</button>
)
}
export function WelcomeScreen({ mode, missingPath, defaultVaultPath, onCreateVault, onCreateNewVault, onOpenFolder, creating, error }: WelcomeScreenProps) {
const isWelcome = mode === 'welcome'
return (
@@ -134,7 +168,7 @@ export function WelcomeScreen({ mode, missingPath, defaultVaultPath, onCreateVau
}}
>
{isWelcome
? <span style={{ fontSize: 28, color: 'var(--accent-blue)' }}></span>
? <span style={{ fontSize: 28, color: 'var(--accent-blue)' }}>&#10022;</span>
: <AlertTriangle size={28} style={{ color: 'var(--accent-orange)' }} />
}
</div>
@@ -161,45 +195,40 @@ export function WelcomeScreen({ mode, missingPath, defaultVaultPath, onCreateVau
<div style={DIVIDER_STYLE} />
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<button
style={{
...PRIMARY_BTN_STYLE,
opacity: creating ? 0.7 : hoverPrimary ? 0.9 : 1,
}}
onClick={onCreateVault}
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 10 }}>
<OptionButton
icon={<Plus size={18} style={{ color: 'var(--accent-blue)' }} />}
iconBg="var(--accent-blue-light, #EBF4FF)"
label="Create a new vault"
description="Start fresh in a folder you choose"
onClick={onCreateNewVault}
disabled={creating}
onMouseEnter={() => setHoverPrimary(true)}
onMouseLeave={() => setHoverPrimary(false)}
data-testid="welcome-create-vault"
>
{creating ? <Loader2 size={16} className="animate-spin" /> : <Plus size={16} />}
{creating ? 'Creating vault…' : 'Create Getting Started vault'}
</button>
testId="welcome-create-new"
/>
<button
style={{
...SECONDARY_BTN_STYLE,
background: hoverSecondary ? 'var(--sidebar)' : 'var(--background)',
}}
<OptionButton
icon={<FolderOpen size={18} style={{ color: 'var(--accent-green)' }} />}
iconBg="var(--accent-green-light, #E8F5E9)"
label={isWelcome ? 'Open existing vault' : 'Choose a different folder'}
description="Point to a folder you already have"
onClick={onOpenFolder}
disabled={creating}
onMouseEnter={() => setHoverSecondary(true)}
onMouseLeave={() => setHoverSecondary(false)}
data-testid="welcome-open-folder"
>
<FolderOpen size={16} />
{isWelcome ? 'Open an existing folder' : 'Choose a different folder'}
</button>
testId="welcome-open-folder"
/>
<OptionButton
icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
iconBg="var(--accent-purple-light, #F3E8FF)"
label="Get started with a template"
description={`A ready-made vault to explore first \u2014 ${defaultVaultPath}`}
onClick={onCreateVault}
disabled={creating}
loading={creating}
testId="welcome-create-vault"
/>
</div>
{error && <p style={ERROR_STYLE} data-testid="welcome-error">{error}</p>}
{isWelcome && !error && (
<p style={HINT_STYLE}>
The Getting Started vault will be created in {defaultVaultPath}
</p>
)}
</div>
</div>
)

View File

@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { NoteInfoPanel } from './NoteInfoPanel'
import type { VaultEntry } from '../../types'
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
return {
path: '/vault/test.md', filename: 'test.md', title: 'Test', isA: 'Note',
aliases: [], outgoingLinks: [], relationships: {}, tags: [],
modifiedAt: 1700000000, createdAt: 1700000000, fileSize: 1024,
icon: null, color: null, archived: false, trashed: false, favorite: false,
...overrides,
}
}
describe('NoteInfoPanel', () => {
it('renders Info section header with icon', () => {
render(<NoteInfoPanel entry={makeEntry()} content="" />)
expect(screen.getByText('Info')).toBeInTheDocument()
})
it('renders Modified and Words in read-only Info section', () => {
render(<NoteInfoPanel entry={makeEntry({ modifiedAt: 1700000000 })} content="---\ntitle: Test\n---\nOne two three" />)
const readOnlyRows = screen.getAllByTestId('readonly-property')
const labels = readOnlyRows.map(row => row.querySelector('span')?.textContent)
expect(labels).toContain('Modified')
expect(labels).toContain('Words')
})
it('renders Created date', () => {
render(<NoteInfoPanel entry={makeEntry({ createdAt: 1700000000 })} content="" />)
const readOnlyRows = screen.getAllByTestId('readonly-property')
const labels = readOnlyRows.map(row => row.querySelector('span')?.textContent)
expect(labels).toContain('Created')
})
it('renders file size', () => {
render(<NoteInfoPanel entry={makeEntry({ fileSize: 4300 })} content="" />)
expect(screen.getByText('Size')).toBeInTheDocument()
expect(screen.getByText('4.2 KB')).toBeInTheDocument()
})
it('shows em dash for null timestamps', () => {
render(<NoteInfoPanel entry={makeEntry({ modifiedAt: null, createdAt: null })} content="" />)
const dashes = screen.getAllByText('\u2014')
expect(dashes.length).toBeGreaterThanOrEqual(2)
})
it('read-only rows do not have hover styling', () => {
render(<NoteInfoPanel entry={makeEntry()} content="" />)
const readOnlyRows = screen.getAllByTestId('readonly-property')
readOnlyRows.forEach(row => {
expect(row.className).not.toContain('hover:bg-muted')
})
})
it('formats file sizes correctly', () => {
const { rerender } = render(<NoteInfoPanel entry={makeEntry({ fileSize: 500 })} content="" />)
expect(screen.getByText('500 B')).toBeInTheDocument()
rerender(<NoteInfoPanel entry={makeEntry({ fileSize: 2048 })} content="" />)
expect(screen.getByText('2.0 KB')).toBeInTheDocument()
rerender(<NoteInfoPanel entry={makeEntry({ fileSize: 1048576 })} content="" />)
expect(screen.getByText('1.0 MB')).toBeInTheDocument()
})
it('uses CSS grid with two equal columns on read-only rows', () => {
render(<NoteInfoPanel entry={makeEntry()} content="" />)
const readOnlyRows = screen.getAllByTestId('readonly-property')
readOnlyRows.forEach(row => {
expect(row.className).toContain('grid')
expect(row.className).toContain('grid-cols-2')
})
})
it('renders word count from content', () => {
render(<NoteInfoPanel entry={makeEntry()} content="---\ntitle: Test\n---\nOne two three four" />)
expect(screen.getByText('Words')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,44 @@
import type { VaultEntry } from '../../types'
import { Info } from '@phosphor-icons/react'
import { countWords } from '../../utils/wikilinks'
function formatDate(timestamp: number | null): string {
if (!timestamp) return '\u2014'
const d = new Date(timestamp * 1000)
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
const kb = bytes / 1024
if (kb < 1024) return `${kb.toFixed(1)} KB`
const mb = kb / 1024
return `${mb.toFixed(1)} MB`
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" data-testid="readonly-property">
<span className="min-w-0 truncate text-[12px] text-muted-foreground">{label}</span>
<span className="min-w-0 truncate text-right text-[12px]" style={{ color: 'var(--text-muted)' }}>{value}</span>
</div>
)
}
export function NoteInfoPanel({ entry, content }: { entry: VaultEntry; content: string | null }) {
const wordCount = countWords(content ?? '')
return (
<div>
<h4 className="font-mono-overline mb-2 flex items-center gap-1 text-muted-foreground">
<Info size={12} className="shrink-0" />
Info
</h4>
<div className="flex flex-col gap-1.5">
<InfoRow label="Modified" value={formatDate(entry.modifiedAt)} />
<InfoRow label="Created" value={formatDate(entry.createdAt)} />
<InfoRow label="Words" value={String(wordCount)} />
<InfoRow label="Size" value={formatFileSize(entry.fileSize)} />
</div>
</div>
)
}

View File

@@ -342,7 +342,7 @@ function AddRelationshipForm({ entries, onAddProperty, onCreateAndOpenNote }: {
if (!showForm) {
return (
<button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, cursor: 'pointer' }} onClick={() => { setShowForm(true); setTimeout(() => keyInputRef.current?.focus(), 0) }}>+ Link existing</button>
<button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, cursor: 'pointer' }} onClick={() => { setShowForm(true); setTimeout(() => keyInputRef.current?.focus(), 0) }}>+ Add relationship</button>
)
}
@@ -390,7 +390,44 @@ function updateRefsForAddition(refs: string[], noteTitle: string): FrontmatterVa
function DisabledLinkButton() {
return (
<button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.5, cursor: 'not-allowed' }} disabled>+ Link existing</button>
<button className="mt-2 w-full border border-border bg-transparent text-center text-muted-foreground" style={{ borderRadius: 6, padding: '6px 12px', fontSize: 12, opacity: 0.5, cursor: 'not-allowed' }} disabled>+ Add relationship</button>
)
}
const SUGGESTED_RELATIONSHIPS = ['Belongs to', 'Related to', 'Has'] as const
function SuggestedRelationshipSlot({ label, entries, onAdd, onCreateAndOpenNote }: {
label: string
entries: VaultEntry[]
onAdd: (noteTitle: string) => void
onCreateAndOpenNote?: (title: string) => Promise<boolean>
}) {
const [active, setActive] = useState(false)
if (active) {
return (
<div className="mb-2.5">
<span className="font-mono-overline mb-1 block text-muted-foreground/50">{label}</span>
<InlineAddNote
entries={entries}
onAdd={(noteTitle) => { onAdd(noteTitle); setActive(false) }}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
</div>
)
}
return (
<button
className="mb-2.5 flex w-full cursor-pointer items-center gap-2 rounded border-none bg-transparent px-0 py-1 text-left outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary"
tabIndex={0}
onClick={() => setActive(true)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setActive(true) } }}
data-testid="suggested-relationship"
>
<span className="font-mono-overline text-muted-foreground/50">{label}</span>
<span className="text-[12px] text-muted-foreground/30">{'\u2014'}</span>
</button>
)
}
@@ -422,6 +459,15 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
const canEdit = !!onUpdateProperty && !!onDeleteProperty
const existingRelKeys = useMemo(
() => new Set(relationshipEntries.map(g => g.key.toLowerCase())),
[relationshipEntries],
)
const missingSuggestedRels = onAddProperty
? SUGGESTED_RELATIONSHIPS.filter(r => !existingRelKeys.has(r.toLowerCase()))
: []
return (
<div>
{relationshipEntries.map(({ key, refs }) => (
@@ -432,6 +478,15 @@ export function DynamicRelationshipsPanel({ frontmatter, entries, typeEntryMap,
onCreateAndOpenNote={canEdit ? onCreateAndOpenNote : undefined}
/>
))}
{missingSuggestedRels.map(label => (
<SuggestedRelationshipSlot
key={label}
label={label}
entries={entries}
onAdd={(noteTitle) => onAddProperty!(label, `[[${noteTitle}]]`)}
onCreateAndOpenNote={onCreateAndOpenNote}
/>
))}
{onAddProperty
? <AddRelationshipForm entries={entries} onAddProperty={onAddProperty} onCreateAndOpenNote={onCreateAndOpenNote} />
: <DisabledLinkButton />

View File

@@ -18,7 +18,12 @@ export function pluralizeType(type: string): string {
export function extractVaultTypes(entries: VaultEntry[]): string[] {
const typeSet = new Set<string>()
for (const e of entries) {
if (e.isA && e.isA !== 'Type' && !e.trashed) typeSet.add(e.isA)
if (e.trashed) continue
if (e.isA === 'Type' && e.title) {
typeSet.add(e.title)
} else if (e.isA && e.isA !== 'Type') {
typeSet.add(e.isA)
}
}
if (typeSet.size === 0) return DEFAULT_TYPES
return Array.from(typeSet).sort()

View File

@@ -55,6 +55,8 @@ interface AppCommandsConfig {
vaultCount?: number
mcpStatus?: string
onInstallMcp?: () => void
claudeCodeStatus?: string
claudeCodeVersion?: string
onEmptyTrash?: () => void
trashedCount?: number
onReloadVault?: () => void

View File

@@ -0,0 +1,63 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'
import { useClaudeCodeStatus } from './useClaudeCodeStatus'
vi.mock('@tauri-apps/api/core', () => ({
invoke: vi.fn(),
}))
vi.mock('../mock-tauri', () => ({
isTauri: () => false,
mockInvoke: vi.fn(),
}))
const { mockInvoke } = await import('../mock-tauri') as { mockInvoke: ReturnType<typeof vi.fn> }
describe('useClaudeCodeStatus', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts in checking state and resolves to installed', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_claude_cli') return Promise.resolve({ installed: true, version: '1.0.20' })
return Promise.resolve(null)
})
const { result } = renderHook(() => useClaudeCodeStatus())
expect(result.current.status).toBe('checking')
await waitFor(() => {
expect(result.current.status).toBe('installed')
expect(result.current.version).toBe('1.0.20')
})
})
it('resolves to missing when claude is not installed', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_claude_cli') return Promise.resolve({ installed: false, version: null })
return Promise.resolve(null)
})
const { result } = renderHook(() => useClaudeCodeStatus())
await waitFor(() => {
expect(result.current.status).toBe('missing')
expect(result.current.version).toBeNull()
})
})
it('resolves to missing on error', async () => {
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'check_claude_cli') return Promise.reject(new Error('failed'))
return Promise.resolve(null)
})
const { result } = renderHook(() => useClaudeCodeStatus())
await waitFor(() => {
expect(result.current.status).toBe('missing')
})
})
})

View File

@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from '../mock-tauri'
export type ClaudeCodeStatus = 'checking' | 'installed' | 'missing'
interface ClaudeCliResult {
installed: boolean
version: string | null
}
function tauriCall<T>(command: string): Promise<T> {
return isTauri() ? invoke<T>(command) : mockInvoke<T>(command)
}
/**
* Checks once on mount whether the `claude` CLI binary is available.
* Returns a status suitable for the status bar badge.
*/
export function useClaudeCodeStatus(): { status: ClaudeCodeStatus; version: string | null } {
const [status, setStatus] = useState<ClaudeCodeStatus>('checking')
const [version, setVersion] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
tauriCall<ClaudeCliResult>('check_claude_cli')
.then((result) => {
if (cancelled) return
setStatus(result.installed ? 'installed' : 'missing')
setVersion(result.version ?? null)
})
.catch(() => {
if (!cancelled) setStatus('missing')
})
return () => { cancelled = true }
}, [])
return { status, version }
}

View File

@@ -192,6 +192,33 @@ describe('extractVaultTypes', () => {
] as never[]
expect(extractVaultTypes(entries)).toEqual(['Event', 'Person', 'Project', 'Note'])
})
it('includes types from Type definition entries', () => {
const entries = [
{ path: '/book.md', title: 'Book', isA: 'Type', trashed: false },
] as never[]
const types = extractVaultTypes(entries)
expect(types).toContain('Book')
})
it('includes types from both definitions and instances', () => {
const entries = [
{ path: '/book.md', title: 'Book', isA: 'Type', trashed: false },
{ path: '/hp.md', title: 'Harry Potter', isA: 'Book', trashed: false },
{ path: '/person.md', title: 'Person', isA: 'Type', trashed: false },
] as never[]
const types = extractVaultTypes(entries)
expect(types).toContain('Book')
expect(types).toContain('Person')
expect(types).toHaveLength(2)
})
it('excludes trashed Type definition entries', () => {
const entries = [
{ path: '/book.md', title: 'Book', isA: 'Type', trashed: true },
] as never[]
expect(extractVaultTypes(entries)).toEqual(['Event', 'Person', 'Project', 'Note'])
})
})
describe('groupSortKey', () => {

View File

@@ -131,6 +131,50 @@ describe('useOnboarding', () => {
expect(result.current.state.status).toBe('welcome')
})
it('handleCreateNewVault picks folder, creates empty vault, and transitions to ready', async () => {
mockInvokeFn.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
if (cmd === 'create_empty_vault') return (args as { targetPath: string }).targetPath
return null
})
vi.mocked(pickFolder).mockResolvedValue('/new/vault')
const { result } = renderHook(() => useOnboarding('/vault/missing'))
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
await act(async () => {
await result.current.handleCreateNewVault()
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/new/vault' })
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
})
it('handleCreateNewVault does nothing when picker is cancelled', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
if (cmd === 'check_vault_exists') return false
return null
})
vi.mocked(pickFolder).mockResolvedValue(null)
const { result } = renderHook(() => useOnboarding('/vault/missing'))
await waitFor(() => {
expect(result.current.state.status).toBe('welcome')
})
await act(async () => {
await result.current.handleCreateNewVault()
})
expect(result.current.state.status).toBe('welcome')
})
it('handleOpenFolder opens folder picker and transitions to ready', async () => {
mockInvokeFn.mockImplementation(async (cmd: string) => {
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'

View File

@@ -78,6 +78,22 @@ export function useOnboarding(initialVaultPath: string) {
}
}, [])
const handleCreateNewVault = useCallback(async () => {
try {
const path = await pickFolder('Choose where to create your vault')
if (!path) return
setCreating(true)
setError(null)
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath: path })
markDismissed()
setState({ status: 'ready', vaultPath })
} catch (err) {
setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`)
} finally {
setCreating(false)
}
}, [])
const handleOpenFolder = useCallback(async () => {
try {
const path = await pickFolder('Open vault folder')
@@ -94,5 +110,5 @@ export function useOnboarding(initialVaultPath: string) {
setState({ status: 'ready', vaultPath: initialVaultPath })
}, [initialVaultPath])
return { state, creating, error, handleCreateVault, handleOpenFolder, handleDismiss }
return { state, creating, error, handleCreateVault, handleCreateNewVault, handleOpenFolder, handleDismiss }
}

View File

@@ -155,6 +155,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
},
get_file_diff: (args: { path: string }) => mockFileDiff(args.path),
get_file_diff_at_commit: (args: { path: string; commitHash: string }) => mockFileDiffAtCommit(args.path, args.commitHash),
git_discard_file: () => {},
git_commit: (args: { message: string }) => {
const count = (mockHasChanges ? mockModifiedFiles().length : 0) + mockSavedSinceCommit.size
mockHasChanges = false
@@ -274,6 +275,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
// In mock mode, the demo-vault-v2 path always "exists"
return args.path.includes('demo-vault-v2')
},
create_empty_vault: (args: { target_path: string }) => args.target_path || '/Users/mock/Documents/My Vault',
create_getting_started_vault: () => '/Users/mock/Documents/Getting Started',
register_mcp_tools: () => 'registered',
check_mcp_status: () => 'installed',

View File

@@ -244,4 +244,61 @@ describe('evaluateView', () => {
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Match'])
})
it('body contains filters on snippet text (case-insensitive)', () => {
const view: ViewDefinition = {
name: 'Body search', icon: null, color: null, sort: null,
filters: { all: [{ field: 'body', op: 'contains', value: 'quarterly' }] },
}
const entries = [
makeEntry({ title: 'Match', snippet: 'This is the quarterly review summary' }),
makeEntry({ title: 'No match', snippet: 'Daily standup notes' }),
makeEntry({ title: 'Case match', snippet: 'QUARTERLY PLANNING session' }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Match', 'Case match'])
})
it('body not_contains excludes matching notes', () => {
const view: ViewDefinition = {
name: 'Body exclude', icon: null, color: null, sort: null,
filters: { all: [{ field: 'body', op: 'not_contains', value: 'draft' }] },
}
const entries = [
makeEntry({ title: 'Final', snippet: 'Final version of the document' }),
makeEntry({ title: 'Draft', snippet: 'This is a draft version' }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Final'])
})
it('body filter combines with property filters (AND)', () => {
const view: ViewDefinition = {
name: 'Combined', icon: null, color: null, sort: null,
filters: { all: [
{ field: 'type', op: 'equals', value: 'Note' },
{ field: 'body', op: 'contains', value: 'important' },
] },
}
const entries = [
makeEntry({ title: 'Yes', isA: 'Note', snippet: 'This is important content' }),
makeEntry({ title: 'Wrong type', isA: 'Project', snippet: 'This is important content' }),
makeEntry({ title: 'No match', isA: 'Note', snippet: 'Regular content' }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Yes'])
})
it('body is_empty matches notes with empty snippet', () => {
const view: ViewDefinition = {
name: 'Empty body', icon: null, color: null, sort: null,
filters: { all: [{ field: 'body', op: 'is_empty' }] },
}
const entries = [
makeEntry({ title: 'Empty', snippet: '' }),
makeEntry({ title: 'Has content', snippet: 'Some text here' }),
]
const result = evaluateView(view, entries)
expect(result.map((e) => e.title)).toEqual(['Empty'])
})
})

View File

@@ -29,6 +29,7 @@ function resolveField(entry: VaultEntry, field: string): { scalar?: string | num
if (lower === 'archived') return { scalar: entry.archived }
if (lower === 'trashed') return { scalar: entry.trashed }
if (lower === 'favorite') return { scalar: entry.favorite }
if (lower === 'body') return { scalar: entry.snippet }
// Check relationships first (returns string[])
const relKey = Object.keys(entry.relationships).find((k) => k.toLowerCase() === lower)