Compare commits
17 Commits
v0.2026040
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e47c653b8b | ||
|
|
60ecc4dd0e | ||
|
|
5323ec6ede | ||
|
|
a007b37089 | ||
|
|
3c4f6ccabd | ||
|
|
313b11fb0e | ||
|
|
df1cff97b9 | ||
|
|
2b419b48a0 | ||
|
|
f8d080e678 | ||
|
|
9f905169cb | ||
|
|
ef0288268c | ||
|
|
1d3927ca2b | ||
|
|
2feff35764 | ||
|
|
6f66cf0d9c | ||
|
|
aaa46f8d20 | ||
|
|
f384b6fad3 | ||
|
|
de122ad045 |
13
getting-started-vault/.gitignore
vendored
13
getting-started-vault/.gitignore
vendored
@@ -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
|
||||
|
||||
89
getting-started-vault/CLAUDE.md
Normal file
89
getting-started-vault/CLAUDE.md
Normal 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.
|
||||
27
getting-started-vault/ai-and-git.md
Normal file
27
getting-started-vault/ai-and-git.md
Normal 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.
|
||||
38
getting-started-vault/capturing-people-and-meetings.md
Normal file
38
getting-started-vault/capturing-people-and-meetings.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
31
getting-started-vault/how-i-organize-my-vault.md
Normal file
31
getting-started-vault/how-i-organize-my-vault.md
Normal 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.
|
||||
@@ -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.
|
||||
20
getting-started-vault/luca-rossi.md
Normal file
20
getting-started-vault/luca-rossi.md
Normal 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]]
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
17
getting-started-vault/personal-knowledge-management.md
Normal file
17
getting-started-vault/personal-knowledge-management.md
Normal 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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
32
getting-started-vault/sidebar-and-navigation.md
Normal file
32
getting-started-vault/sidebar-and-navigation.md
Normal 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 |
|
||||
36
getting-started-vault/syncing-calendar-events.md
Normal file
36
getting-started-vault/syncing-calendar-events.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
47
getting-started-vault/types-properties-relationships.md
Normal file
47
getting-started-vault/types-properties-relationships.md
Normal 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.
|
||||
38
getting-started-vault/using-the-editor.md
Normal file
38
getting-started-vault/using-the-editor.md
Normal 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.
|
||||
23
getting-started-vault/views-and-search.md
Normal file
23
getting-started-vault/views-and-search.md
Normal 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.
|
||||
37
getting-started-vault/welcome.md
Normal file
37
getting-started-vault/welcome.md
Normal 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.*
|
||||
@@ -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.
|
||||
|
||||
31
getting-started-vault/why-plain-files.md
Normal file
31
getting-started-vault/why-plain-files.md
Normal 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.
|
||||
13
patches/@blocknote__core@0.46.2.patch
Normal file
13
patches/@blocknote__core@0.46.2.patch
Normal 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
13
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -3,3 +3,6 @@ packages:
|
||||
|
||||
ignoredBuiltDependencies:
|
||||
- esbuild
|
||||
|
||||
patchedDependencies:
|
||||
'@blocknote/core@0.46.2': patches/@blocknote__core@0.46.2.patch
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
36
src/App.tsx
36
src/App.tsx
@@ -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}
|
||||
|
||||
@@ -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', '')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)' }}>✦</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>
|
||||
)
|
||||
|
||||
81
src/components/inspector/NoteInfoPanel.test.tsx
Normal file
81
src/components/inspector/NoteInfoPanel.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
44
src/components/inspector/NoteInfoPanel.tsx
Normal file
44
src/components/inspector/NoteInfoPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -55,6 +55,8 @@ interface AppCommandsConfig {
|
||||
vaultCount?: number
|
||||
mcpStatus?: string
|
||||
onInstallMcp?: () => void
|
||||
claudeCodeStatus?: string
|
||||
claudeCodeVersion?: string
|
||||
onEmptyTrash?: () => void
|
||||
trashedCount?: number
|
||||
onReloadVault?: () => void
|
||||
|
||||
63
src/hooks/useClaudeCodeStatus.test.ts
Normal file
63
src/hooks/useClaudeCodeStatus.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
41
src/hooks/useClaudeCodeStatus.ts
Normal file
41
src/hooks/useClaudeCodeStatus.ts
Normal 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 }
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user