Compare commits
44 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 | ||
|
|
c0a64e181b | ||
|
|
fe85d1f679 | ||
|
|
9db5f5cbed | ||
|
|
13c0802395 | ||
|
|
4c44f8e966 | ||
|
|
2b135d208e | ||
|
|
076860632c | ||
|
|
520ec504fe | ||
|
|
cdb3eeea4c | ||
|
|
f68436c2e0 | ||
|
|
7c03c50e25 | ||
|
|
24911b6bb7 | ||
|
|
51914db534 | ||
|
|
e4ffeeccc0 | ||
|
|
8ec33b99b5 | ||
|
|
638678184e | ||
|
|
62af7a3d6e | ||
|
|
ef9f3ec21f | ||
|
|
0d91e29e82 | ||
|
|
77d59e3ee0 | ||
|
|
f37de38d21 | ||
|
|
51ce27f781 | ||
|
|
4a48833dbc | ||
|
|
d21eef8aa6 | ||
|
|
13f503691e | ||
|
|
17a327173b | ||
|
|
6eb4963952 |
@@ -7,8 +7,12 @@ Priority order: **To Rework** first, then **Open** (sorted by Todoist priority p
|
||||
## Steps
|
||||
|
||||
1. Fetch tasks from To Rework (`6g6QqvR9rRpvJWvv`), then Open (`6g3XjWR832hVHhCM`)
|
||||
2. Sort each section by priority (p1 highest)
|
||||
3. Take the first available task
|
||||
2. **Sort by priority — this is mandatory.** Todoist returns tasks in arbitrary order. You must sort them yourself:
|
||||
- Todoist priority field: `4` = p1 (urgent), `3` = p2, `2` = p3, `1` = p4
|
||||
- Sort descending by `priority` field (4 first, 1 last)
|
||||
- To Rework tasks always come before Open tasks regardless of priority
|
||||
- **Never pick a p3/p4 task if a p1/p2 task exists in the same section**
|
||||
3. Take the first task from the sorted list
|
||||
4. Move it to In Progress (`6g3XjWjfmJFcGgHM`) via Todoist API:
|
||||
|
||||
```bash
|
||||
|
||||
21
CLAUDE.md
21
CLAUDE.md
@@ -107,6 +107,27 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never co
|
||||
2. Design in light mode. Create `design/<slug>.pen` for the task
|
||||
3. On completion: merge frames into `ui-design.pen`, delete `design/<slug>.pen`
|
||||
|
||||
### UI components — mandatory rules
|
||||
|
||||
**Always use shadcn/ui components.** Never use raw HTML form elements (`<input>`, `<select>`, `<button>`, native `<input type="date">`, etc.) for user-facing UI. Every interactive element must use the shadcn/ui equivalent:
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| Text input | `Input` from shadcn/ui |
|
||||
| Dropdown/select | `Select` from shadcn/ui |
|
||||
| Date picker | `Calendar` + `Popover` from shadcn/ui (NOT native `<input type="date">`) |
|
||||
| Button | `Button` from shadcn/ui |
|
||||
| Autocomplete/combobox | Reuse existing combobox components from the app (check `src/components/`) |
|
||||
| Wikilink picker | Reuse the wikilink autocomplete component already used in the editor and Properties panel |
|
||||
| Emoji picker | Reuse the emoji picker component already used for note/type icons |
|
||||
| Color picker | Reuse the color swatch picker used for type customization |
|
||||
| Toggle/switch | `Switch` or `ToggleGroup` from shadcn/ui |
|
||||
| Dialog/modal | `Dialog` from shadcn/ui |
|
||||
|
||||
**When in doubt:** search `src/components/` for an existing component that does what you need before building a new one. The app already has many reusable pieces — use them.
|
||||
|
||||
**Visual language:** all new UI must feel native to Laputa. Take inspiration from `ui-design.pen` and existing components. If something looks like a browser default, it's wrong.
|
||||
|
||||
---
|
||||
|
||||
## 4. Reference
|
||||
|
||||
@@ -551,15 +551,11 @@ Managed by `useSettings` hook and `SettingsPanel` component.
|
||||
|
||||
---
|
||||
|
||||
## Update Channels & Feature Flags
|
||||
|
||||
### Settings
|
||||
- **`update_channel`** — `"stable"` (default/null) or `"canary"`. Stored in `Settings` struct, configurable in Settings panel under "Updates" section.
|
||||
## Updates & Feature Flags
|
||||
|
||||
### Hooks
|
||||
- **`useUpdater(channel?)`** — Checks for updates. For stable: uses Tauri updater plugin. For canary: fetches `latest-canary.json` and opens release page for download.
|
||||
- **`useUpdater()`** — Checks for updates using the Tauri updater plugin. Automatic download and install.
|
||||
- **`useFeatureFlag(flag)`** — Returns boolean for a named feature flag. Checks `localStorage` override (`ff_<name>`), then falls back to compile-time default. Type-safe via `FeatureFlagName` union.
|
||||
|
||||
### CI/CD
|
||||
- **`.github/workflows/release.yml`** — Stable builds from `main`. Produces `latest.json` on GitHub Pages.
|
||||
- **`.github/workflows/release-canary.yml`** — Canary builds from `canary` branch. Produces `latest-canary.json` on GitHub Pages. Releases are marked as prerelease.
|
||||
|
||||
@@ -802,29 +802,13 @@ sequenceDiagram
|
||||
- **Settings:** `telemetry_consent`, `crash_reporting_enabled`, `analytics_enabled`, `anonymous_id` in `Settings` struct
|
||||
- **Consent:** `TelemetryConsentDialog` shown when `telemetry_consent === null`
|
||||
|
||||
### Update Channels (Stable / Canary)
|
||||
### Updates
|
||||
|
||||
Laputa supports two release channels:
|
||||
Laputa uses the Tauri updater plugin for automatic updates:
|
||||
|
||||
- **Stable** (default): builds from `main` branch, published as full GitHub Releases
|
||||
- **Canary**: builds from `canary` branch, published as pre-release GitHub Releases
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
main["main branch"] -->|push| stable["Stable build<br/>latest.json"]
|
||||
canary["canary branch"] -->|push| canaryBuild["Canary build<br/>latest-canary.json"]
|
||||
stable --> ghPages["GitHub Pages"]
|
||||
canaryBuild --> ghPages
|
||||
ghPages -->|"update_channel = stable"| stableUsers["Stable users<br/>(auto-update via plugin)"]
|
||||
ghPages -->|"update_channel = canary"| canaryUsers["Canary users<br/>(fetch + manual download)"]
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- Both channels publish to GitHub Pages: `latest.json` (stable) and `latest-canary.json` (canary)
|
||||
- `update_channel` is stored in `Settings` (`settings.json`), configurable in Settings panel
|
||||
- **Stable**: uses the Tauri updater plugin with automatic download and install
|
||||
- **Canary**: `useUpdater` hook fetches `latest-canary.json` via HTTP, compares versions, and opens the GitHub release page for manual download
|
||||
- Canary versions use semver prerelease: `0.YYYYMMDD.N-canary`
|
||||
- Builds from `main` branch are published as GitHub Releases
|
||||
- `latest.json` is published to GitHub Pages for the updater plugin
|
||||
- `useUpdater()` hook checks for updates automatically and supports download + install
|
||||
|
||||
### Feature Flags (PostHog + Release Channels)
|
||||
|
||||
|
||||
3
getting-started-vault/.gitignore
vendored
Normal file
3
getting-started-vault/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
*.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.
|
||||
6
getting-started-vault/getting-started.md
Normal file
6
getting-started-vault/getting-started.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
title: Getting Started
|
||||
is_a: Topic
|
||||
---
|
||||
|
||||
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.
|
||||
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]]
|
||||
8
getting-started-vault/note.md
Normal file
8
getting-started-vault/note.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Note
|
||||
is_a: Type
|
||||
_icon: FileText
|
||||
_color: "#6366f1"
|
||||
---
|
||||
|
||||
A Note is a general-purpose document — ideas, references, meeting notes, or anything that doesn't fit a more specific type.
|
||||
8
getting-started-vault/person.md
Normal file
8
getting-started-vault/person.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Person
|
||||
is_a: Type
|
||||
_icon: UserCircle
|
||||
_color: "#f59e0b"
|
||||
---
|
||||
|
||||
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
|
||||
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.
|
||||
8
getting-started-vault/topic.md
Normal file
8
getting-started-vault/topic.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Topic
|
||||
is_a: Type
|
||||
_icon: Hash
|
||||
_color: "#10b981"
|
||||
---
|
||||
|
||||
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.
|
||||
12
getting-started-vault/views/active-projects.yml
Normal file
12
getting-started-vault/views/active-projects.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
name: Active Projects
|
||||
icon: rocket-launch
|
||||
color: purple
|
||||
sort: "modified:desc"
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
- field: Status
|
||||
op: equals
|
||||
value: Active
|
||||
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.*
|
||||
15
getting-started-vault/what-is-laputa.md
Normal file
15
getting-started-vault/what-is-laputa.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
title: What is Laputa
|
||||
is_a: Note
|
||||
belongs_to: "[[Getting Started]]"
|
||||
---
|
||||
|
||||
Laputa is a personal knowledge base built on three principles:
|
||||
|
||||
**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.
|
||||
|
||||
**Git as sync.** Your vault is a Git repository. Version history, branching, and remote sync come for free. Use GitHub, GitLab, or any remote.
|
||||
|
||||
**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 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,
|
||||
|
||||
@@ -13,7 +13,6 @@ pub struct Settings {
|
||||
pub crash_reporting_enabled: Option<bool>,
|
||||
pub analytics_enabled: Option<bool>,
|
||||
pub anonymous_id: Option<String>,
|
||||
pub update_channel: Option<String>,
|
||||
pub release_channel: Option<String>,
|
||||
}
|
||||
|
||||
@@ -64,10 +63,6 @@ fn save_settings_at(path: &PathBuf, settings: Settings) -> Result<(), String> {
|
||||
.anonymous_id
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
update_channel: settings
|
||||
.update_channel
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
release_channel: settings
|
||||
.release_channel
|
||||
.map(|k| k.trim().to_string())
|
||||
@@ -141,7 +136,6 @@ mod tests {
|
||||
assert!(s.crash_reporting_enabled.is_none());
|
||||
assert!(s.analytics_enabled.is_none());
|
||||
assert!(s.anonymous_id.is_none());
|
||||
assert!(s.update_channel.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -75,6 +75,10 @@ pub struct VaultEntry {
|
||||
/// Only includes strings, numbers, and booleans — arrays/objects are excluded.
|
||||
#[serde(default)]
|
||||
pub properties: HashMap<String, serde_json::Value>,
|
||||
/// Properties to display as chips in the note list for this Type's notes.
|
||||
/// Configured via `_list_properties_display` in the type file's frontmatter.
|
||||
#[serde(rename = "listPropertiesDisplay", default)]
|
||||
pub list_properties_display: Vec<String>,
|
||||
/// File kind: "markdown", "text", or "binary".
|
||||
/// Determines how the frontend renders and opens the file.
|
||||
#[serde(rename = "fileKind", default = "default_file_kind")]
|
||||
|
||||
@@ -55,6 +55,8 @@ pub(crate) struct Frontmatter {
|
||||
pub favorite: Option<bool>,
|
||||
#[serde(rename = "_favorite_index", default)]
|
||||
pub favorite_index: Option<i64>,
|
||||
#[serde(rename = "_list_properties_display", default)]
|
||||
pub list_properties_display: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Custom deserializer for boolean fields that may arrive as strings.
|
||||
@@ -169,6 +171,7 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
"status",
|
||||
"_favorite",
|
||||
"_favorite_index",
|
||||
"_list_properties_display",
|
||||
];
|
||||
let filtered: serde_json::Map<String, serde_json::Value> = data
|
||||
.iter()
|
||||
@@ -205,6 +208,7 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"status",
|
||||
"_favorite",
|
||||
"_favorite_index",
|
||||
"_list_properties_display",
|
||||
];
|
||||
|
||||
/// Extract all wikilink-containing fields from raw YAML frontmatter.
|
||||
|
||||
@@ -117,6 +117,7 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
|
||||
visible: frontmatter.visible,
|
||||
favorite: frontmatter.favorite.unwrap_or(false),
|
||||
favorite_index: frontmatter.favorite_index,
|
||||
list_properties_display: frontmatter.list_properties_display.unwrap_or_default(),
|
||||
word_count,
|
||||
outgoing_links,
|
||||
properties,
|
||||
|
||||
@@ -1457,6 +1457,40 @@ fn test_scan_vault_folders_flat_vault() {
|
||||
assert!(folders.is_empty(), "flat vault has no visible folders");
|
||||
}
|
||||
|
||||
// --- list_properties_display tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_list_properties_display() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content =
|
||||
"---\ntype: Type\n_list_properties_display:\n - rating\n - genre\n---\n# Movies\n";
|
||||
let entry = parse_test_entry(&dir, "movies.md", content);
|
||||
assert_eq!(entry.list_properties_display, vec!["rating", "genre"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_list_properties_display_absent_defaults_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\n---\n# Books\n";
|
||||
let entry = parse_test_entry(&dir, "books.md", content);
|
||||
assert!(entry.list_properties_display.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_properties_display_not_in_properties_or_relationships() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Type\n_list_properties_display:\n - rating\n---\n# Movies\n";
|
||||
let entry = parse_test_entry(&dir, "movies.md", content);
|
||||
assert!(
|
||||
!entry.properties.contains_key("_list_properties_display"),
|
||||
"_list_properties_display must not leak into properties map"
|
||||
);
|
||||
assert!(
|
||||
!entry.relationships.contains_key("_list_properties_display"),
|
||||
"_list_properties_display must not leak into relationships map"
|
||||
);
|
||||
}
|
||||
|
||||
// Frontmatter update/delete tests are in frontmatter.rs
|
||||
// save_image tests are in vault/image.rs
|
||||
// purge_trash tests are in vault/trash.rs
|
||||
|
||||
@@ -142,9 +142,58 @@ pub struct ViewFile {
|
||||
pub definition: ViewDefinition,
|
||||
}
|
||||
|
||||
/// Scan all `.yml` files from `vault_path/.laputa/views/` and return parsed views.
|
||||
/// Migrate views from `.laputa/views/` to `views/` in the vault root (one-time).
|
||||
pub fn migrate_views(vault_path: &Path) {
|
||||
let old_dir = vault_path.join(".laputa").join("views");
|
||||
if !old_dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
let entries = match fs::read_dir(&old_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let yml_files: Vec<_> = entries
|
||||
.flatten()
|
||||
.filter(|e| e.path().extension().and_then(|ext| ext.to_str()) == Some("yml"))
|
||||
.collect();
|
||||
|
||||
if yml_files.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let new_dir = vault_path.join("views");
|
||||
if fs::create_dir_all(&new_dir).is_err() {
|
||||
log::warn!("Failed to create views/ directory for migration");
|
||||
return;
|
||||
}
|
||||
|
||||
for entry in yml_files {
|
||||
let src = entry.path();
|
||||
let dst = new_dir.join(entry.file_name());
|
||||
if !dst.exists() {
|
||||
if let Err(e) = fs::rename(&src, &dst) {
|
||||
log::warn!("Failed to migrate view {:?}: {}", src, e);
|
||||
} else {
|
||||
log::info!("Migrated view {:?} → {:?}", src, dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old directory if empty
|
||||
if fs::read_dir(&old_dir)
|
||||
.map(|mut d| d.next().is_none())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let _ = fs::remove_dir(&old_dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan all `.yml` files from `vault_path/views/` and return parsed views.
|
||||
pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
|
||||
let views_dir = vault_path.join(".laputa").join("views");
|
||||
migrate_views(vault_path);
|
||||
let views_dir = vault_path.join("views");
|
||||
if !views_dir.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -180,7 +229,7 @@ pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
|
||||
views
|
||||
}
|
||||
|
||||
/// Save a view definition as YAML to `vault_path/.laputa/views/{filename}`.
|
||||
/// Save a view definition as YAML to `vault_path/views/{filename}`.
|
||||
pub fn save_view(
|
||||
vault_path: &Path,
|
||||
filename: &str,
|
||||
@@ -189,7 +238,7 @@ pub fn save_view(
|
||||
if !filename.ends_with(".yml") {
|
||||
return Err("Filename must end with .yml".to_string());
|
||||
}
|
||||
let views_dir = vault_path.join(".laputa").join("views");
|
||||
let views_dir = vault_path.join("views");
|
||||
fs::create_dir_all(&views_dir)
|
||||
.map_err(|e| format!("Failed to create views directory: {}", e))?;
|
||||
let yaml = serde_yaml::to_string(definition)
|
||||
@@ -198,9 +247,9 @@ pub fn save_view(
|
||||
.map_err(|e| format!("Failed to write view file: {}", e))
|
||||
}
|
||||
|
||||
/// Delete a view file at `vault_path/.laputa/views/{filename}`.
|
||||
/// Delete a view file at `vault_path/views/{filename}`.
|
||||
pub fn delete_view(vault_path: &Path, filename: &str) -> Result<(), String> {
|
||||
let path = vault_path.join(".laputa").join("views").join(filename);
|
||||
let path = vault_path.join("views").join(filename);
|
||||
fs::remove_file(&path).map_err(|e| format!("Failed to delete view: {}", e))
|
||||
}
|
||||
|
||||
@@ -599,7 +648,7 @@ filters:
|
||||
#[test]
|
||||
fn test_scan_views_reads_yml_files() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let views_dir = dir.path().join(".laputa").join("views");
|
||||
let views_dir = dir.path().join("views");
|
||||
fs::create_dir_all(&views_dir).unwrap();
|
||||
|
||||
let yaml_a = "name: Alpha\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
|
||||
@@ -617,6 +666,26 @@ filters:
|
||||
assert_eq!(views[1].definition.name, "Beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_views_from_old_location() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let old_dir = dir.path().join(".laputa").join("views");
|
||||
fs::create_dir_all(&old_dir).unwrap();
|
||||
|
||||
let yaml = "name: Migrated\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
|
||||
fs::write(old_dir.join("test.yml"), yaml).unwrap();
|
||||
|
||||
// scan_views should trigger migration and find the view
|
||||
let views = scan_views(dir.path());
|
||||
assert_eq!(views.len(), 1);
|
||||
assert_eq!(views[0].definition.name, "Migrated");
|
||||
|
||||
// File should now be in new location
|
||||
assert!(dir.path().join("views").join("test.yml").exists());
|
||||
// Old file should be gone
|
||||
assert!(!old_dir.join("test.yml").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_read_view() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
@@ -645,6 +714,30 @@ filters:
|
||||
assert_eq!(views.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_read_view_with_emoji_icon() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let def = ViewDefinition {
|
||||
name: "Monday".to_string(),
|
||||
icon: Some("🗂️".to_string()),
|
||||
color: None,
|
||||
sort: None,
|
||||
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
|
||||
field: "type".to_string(),
|
||||
op: FilterOp::Equals,
|
||||
value: Some(serde_yaml::Value::String("Project".to_string())),
|
||||
})]),
|
||||
};
|
||||
|
||||
save_view(dir.path(), "monday.yml", &def).unwrap();
|
||||
|
||||
let views = scan_views(dir.path());
|
||||
assert_eq!(views.len(), 1);
|
||||
assert_eq!(views[0].definition.name, "Monday");
|
||||
assert_eq!(views[0].definition.icon.as_deref(), Some("🗂️"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wikilink_stem_matching() {
|
||||
let yaml = r#"
|
||||
|
||||
@@ -69,7 +69,7 @@ const mockCommandResults: Record<string, unknown> = {
|
||||
get_modified_files: [],
|
||||
get_note_content: mockAllContent['/vault/project/test.md'] || '',
|
||||
get_file_history: [],
|
||||
get_settings: { openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, update_channel: null },
|
||||
get_settings: { openai_key: null, google_key: null, github_token: null, github_username: null, auto_pull_interval_minutes: null, telemetry_consent: true, crash_reporting_enabled: null, analytics_enabled: null, anonymous_id: null, release_channel: null },
|
||||
git_pull: { status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] },
|
||||
save_settings: null,
|
||||
check_vault_exists: true,
|
||||
|
||||
55
src/App.tsx
55
src/App.tsx
@@ -18,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'
|
||||
@@ -76,7 +77,7 @@ function App() {
|
||||
const noteWindowParams = useMemo(() => isNoteWindow() ? getNoteWindowParams() : null, [])
|
||||
const [selection, setSelection] = useState<SidebarSelection>(DEFAULT_SELECTION)
|
||||
const [noteListFilter, setNoteListFilter] = useState<NoteListFilter>('open')
|
||||
const [inboxPeriod, setInboxPeriod] = useState<InboxPeriod>('month')
|
||||
const inboxPeriod: InboxPeriod = 'all'
|
||||
const handleSetSelection = useCallback((sel: SidebarSelection) => {
|
||||
setSelection(sel)
|
||||
setNoteListFilter('open')
|
||||
@@ -128,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,
|
||||
@@ -309,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])
|
||||
|
||||
@@ -336,20 +352,32 @@ function App() {
|
||||
setToastMessage(`Type "${name}" created`)
|
||||
}, [notes])
|
||||
|
||||
const handleCreateView = useCallback(async (definition: import('./types').ViewDefinition) => {
|
||||
const filename = definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml'
|
||||
const handleCreateOrUpdateView = useCallback(async (definition: import('./types').ViewDefinition) => {
|
||||
const editing = dialogs.editingView
|
||||
const filename = editing
|
||||
? editing.filename
|
||||
: definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml'
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition })
|
||||
trackEvent('view_created')
|
||||
trackEvent(editing ? 'view_updated' : 'view_created')
|
||||
await vault.reloadViews()
|
||||
setToastMessage(`View "${definition.name}" created`)
|
||||
await vault.reloadVault()
|
||||
vault.reloadFolders()
|
||||
setToastMessage(editing ? `View "${definition.name}" updated` : `View "${definition.name}" created`)
|
||||
handleSetSelection({ kind: 'view', filename })
|
||||
}, [resolvedPath, vault, handleSetSelection])
|
||||
}, [resolvedPath, vault, handleSetSelection, dialogs.editingView])
|
||||
|
||||
const handleEditView = useCallback((filename: string) => {
|
||||
const view = vault.views.find((v) => v.filename === filename)
|
||||
if (view) dialogs.openEditView(filename, view.definition)
|
||||
}, [vault.views, dialogs])
|
||||
|
||||
const handleDeleteView = useCallback(async (filename: string) => {
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
|
||||
await vault.reloadViews()
|
||||
await vault.reloadVault()
|
||||
vault.reloadFolders()
|
||||
if (selection.kind === 'view' && selection.filename === filename) {
|
||||
handleSetSelection({ kind: 'filter', filter: 'all' })
|
||||
}
|
||||
@@ -357,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) {
|
||||
@@ -393,7 +421,7 @@ function App() {
|
||||
const zoom = useZoom()
|
||||
const buildNumber = useBuildNumber()
|
||||
|
||||
const { status: updateStatus, actions: updateActions } = useUpdater(settings.update_channel)
|
||||
const { status: updateStatus, actions: updateActions } = useUpdater()
|
||||
|
||||
const handleCheckForUpdates = useCallback(async () => {
|
||||
if (updateStatus.state === 'downloading') {
|
||||
@@ -463,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,
|
||||
@@ -545,7 +575,7 @@ function App() {
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={notes.handleSelectNote} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onCreateView={dialogs.openCreateView} onDeleteView={handleDeleteView} inboxCount={inboxCount} />
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={notes.handleSelectNote} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} inboxCount={inboxCount} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -556,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} onInboxPeriodChange={setInboxPeriod} 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} />
|
||||
@@ -618,13 +648,13 @@ 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} />
|
||||
<SearchPanel open={dialogs.showSearch} vaultPath={resolvedPath} entries={vault.entries} onSelectNote={notes.handleSelectNote} onClose={dialogs.closeSearch} />
|
||||
<CreateTypeDialog open={dialogs.showCreateTypeDialog} onClose={dialogs.closeCreateType} onCreate={handleCreateType} />
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateView} availableFields={availableFields} valueSuggestions={valueSuggestionsForField} />
|
||||
<CreateViewDialog open={dialogs.showCreateViewDialog} onClose={dialogs.closeCreateView} onCreate={handleCreateOrUpdateView} availableFields={availableFields} valueSuggestions={valueSuggestionsForField} entries={vault.entries} editingView={dialogs.editingView?.definition ?? null} />
|
||||
<CommitDialog open={commitFlow.showCommitDialog} modifiedCount={vault.modifiedFiles.length} suggestedMessage={suggestedCommitMessage} onCommit={commitFlow.handleCommitPush} onClose={commitFlow.closeCommitDialog} />
|
||||
<ConflictResolverModal
|
||||
open={dialogs.showConflictResolver}
|
||||
@@ -672,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}
|
||||
|
||||
92
src/components/CreateViewDialog.test.tsx
Normal file
92
src/components/CreateViewDialog.test.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { CreateViewDialog } from './CreateViewDialog'
|
||||
import type { ViewDefinition } from '../types'
|
||||
|
||||
describe('CreateViewDialog', () => {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onCreate: vi.fn(),
|
||||
availableFields: ['type', 'status', 'title'],
|
||||
}
|
||||
|
||||
it('shows "Create View" title in create mode', () => {
|
||||
render(<CreateViewDialog {...defaultProps} />)
|
||||
expect(screen.getByText('Create View')).toBeInTheDocument()
|
||||
expect(screen.getByText('Create')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Edit View" title when editingView is provided', () => {
|
||||
const editingView: ViewDefinition = {
|
||||
name: 'Active Projects',
|
||||
icon: '🚀',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
}
|
||||
render(<CreateViewDialog {...defaultProps} editingView={editingView} />)
|
||||
expect(screen.getByText('Edit View')).toBeInTheDocument()
|
||||
expect(screen.getByText('Save')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('pre-populates name field in edit mode', () => {
|
||||
const editingView: ViewDefinition = {
|
||||
name: 'Active Projects',
|
||||
icon: '🚀',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
}
|
||||
render(<CreateViewDialog {...defaultProps} editingView={editingView} />)
|
||||
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
|
||||
expect(input).toHaveValue('Active Projects')
|
||||
})
|
||||
|
||||
it('preserves emoji icon when editing a view', () => {
|
||||
const onCreate = vi.fn()
|
||||
const editingView: ViewDefinition = {
|
||||
name: 'Monday',
|
||||
icon: '🗂️',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
}
|
||||
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} editingView={editingView} />)
|
||||
// Submit the form without changing anything
|
||||
fireEvent.submit(screen.getByText('Save').closest('form')!)
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ icon: '🗂️' })
|
||||
)
|
||||
})
|
||||
|
||||
it('passes selected emoji icon when creating a view', () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} />)
|
||||
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
|
||||
fireEvent.change(input, { target: { value: 'Test View' } })
|
||||
// Open emoji picker and select an emoji
|
||||
fireEvent.click(screen.getByTitle('Pick icon'))
|
||||
expect(screen.getByTestId('emoji-picker')).toBeInTheDocument()
|
||||
const emojiButtons = screen.getAllByTestId('emoji-option')
|
||||
fireEvent.click(emojiButtons[0])
|
||||
// Submit the form
|
||||
fireEvent.click(screen.getByText('Create'))
|
||||
expect(onCreate).toHaveBeenCalledTimes(1)
|
||||
const definition = onCreate.mock.calls[0][0] as ViewDefinition
|
||||
expect(definition.icon).not.toBeNull()
|
||||
expect(typeof definition.icon).toBe('string')
|
||||
expect(definition.icon!.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('passes null icon when no emoji is selected', () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} />)
|
||||
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
|
||||
fireEvent.change(input, { target: { value: 'No Icon View' } })
|
||||
fireEvent.submit(screen.getByText('Create').closest('form')!)
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ icon: null })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { FilterBuilder } from './FilterBuilder'
|
||||
import { EmojiPicker } from './EmojiPicker'
|
||||
import type { FilterGroup, ViewDefinition } from '../types'
|
||||
import type { FilterGroup, ViewDefinition, VaultEntry } from '../types'
|
||||
|
||||
interface CreateViewDialogProps {
|
||||
open: boolean
|
||||
@@ -13,9 +13,13 @@ interface CreateViewDialogProps {
|
||||
availableFields: string[]
|
||||
/** Returns known values for a given field (for autocomplete). */
|
||||
valueSuggestions?: (field: string) => string[]
|
||||
/** Vault entries for wikilink autocomplete in filter value fields. */
|
||||
entries?: VaultEntry[]
|
||||
/** When provided, the dialog operates in edit mode with pre-populated fields. */
|
||||
editingView?: ViewDefinition | null
|
||||
}
|
||||
|
||||
export function CreateViewDialog({ open, onClose, onCreate, availableFields, valueSuggestions }: CreateViewDialogProps) {
|
||||
export function CreateViewDialog({ open, onClose, onCreate, availableFields, valueSuggestions, entries, editingView }: CreateViewDialogProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [icon, setIcon] = useState('')
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
@@ -23,16 +27,23 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, val
|
||||
all: [{ field: 'type', op: 'equals', value: '' }],
|
||||
})
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const isEditing = !!editingView
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setIcon('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
if (editingView) {
|
||||
setName(editingView.name) // eslint-disable-line react-hooks/set-state-in-effect -- populate on dialog open
|
||||
setIcon(editingView.icon ?? '') // eslint-disable-line react-hooks/set-state-in-effect -- populate on dialog open
|
||||
setFilters(editingView.filters) // eslint-disable-line react-hooks/set-state-in-effect -- populate on dialog open
|
||||
} else {
|
||||
setName('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setIcon('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setFilters({ all: [{ field: availableFields[0] ?? 'type', op: 'equals', value: '' }] }) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
}
|
||||
setShowEmojiPicker(false) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setFilters({ all: [{ field: availableFields[0] ?? 'type', op: 'equals', value: '' }] }) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open, availableFields])
|
||||
}, [open, availableFields, editingView])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -60,11 +71,11 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, val
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
|
||||
<DialogContent showCloseButton={false} className="sm:max-w-[520px]">
|
||||
<DialogContent showCloseButton={false} className="flex max-h-[80vh] flex-col sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create View</DialogTitle>
|
||||
<DialogTitle>{isEditing ? 'Edit View' : 'Create View'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-16 space-y-1.5 relative">
|
||||
<label className="text-xs font-medium text-muted-foreground">Icon</label>
|
||||
@@ -90,18 +101,19 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, val
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto">
|
||||
<label className="text-xs font-medium text-muted-foreground">Filters</label>
|
||||
<FilterBuilder
|
||||
group={filters}
|
||||
onChange={setFilters}
|
||||
availableFields={availableFields}
|
||||
valueSuggestions={valueSuggestions}
|
||||
entries={entries}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={!name.trim()}>Create</Button>
|
||||
<Button type="submit" disabled={!name.trim()}>{isEditing ? 'Save' : 'Create'}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
|
||||
<div className="grid grid-cols-8 gap-0.5">
|
||||
{searchResults.map(entry => (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.emoji}
|
||||
className="flex h-8 w-8 items-center justify-center rounded text-xl transition-colors hover:bg-accent"
|
||||
onClick={() => handleSelect(entry.emoji)}
|
||||
@@ -98,6 +99,7 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
|
||||
<div className="grid grid-cols-8 gap-0.5">
|
||||
{emojis.map(entry => (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.emoji}
|
||||
className="flex h-8 w-8 items-center justify-center rounded text-xl transition-colors hover:bg-accent"
|
||||
onClick={() => handleSelect(entry.emoji)}
|
||||
|
||||
213
src/components/FilterBuilder.test.tsx
Normal file
213
src/components/FilterBuilder.test.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { FilterBuilder } from './FilterBuilder'
|
||||
import type { FilterGroup, VaultEntry } from '../types'
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/note/test.md',
|
||||
filename: 'test.md',
|
||||
title: 'Test Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const entries: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/project/alpha.md', filename: 'alpha.md', title: 'Alpha Project', isA: 'Project' }),
|
||||
makeEntry({ path: '/vault/person/luca.md', filename: 'luca.md', title: 'Luca', isA: 'Person' }),
|
||||
makeEntry({ path: '/vault/topic/ai.md', filename: 'ai.md', title: 'AI Research', isA: 'Topic' }),
|
||||
makeEntry({ path: '/vault/note/plain.md', filename: 'plain.md', title: 'Plain Note', isA: null }),
|
||||
makeEntry({ path: '/vault/person/alice.md', filename: 'alice.md', title: 'Alice Smith', isA: 'Person', aliases: ['Alice'] }),
|
||||
makeEntry({ path: '/vault/trashed.md', filename: 'trashed.md', title: 'Trashed Note', isA: null, trashed: true }),
|
||||
]
|
||||
|
||||
describe('FilterBuilder wikilink autocomplete', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function renderWithEntries(group?: FilterGroup) {
|
||||
const defaultGroup: FilterGroup = {
|
||||
all: [{ field: 'title', op: 'contains', value: '' }],
|
||||
}
|
||||
return render(
|
||||
<FilterBuilder
|
||||
group={group ?? defaultGroup}
|
||||
onChange={onChange}
|
||||
availableFields={['type', 'status', 'title']}
|
||||
entries={entries}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
it('renders value input with wikilink support when entries are provided', () => {
|
||||
renderWithEntries()
|
||||
expect(screen.getByTestId('filter-value-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show dropdown for plain text input', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: 'hello' }],
|
||||
})
|
||||
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows dropdown when value starts with [[', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Al' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByTestId('wikilink-dropdown')).toBeInTheDocument()
|
||||
expect(screen.getByText('Alpha Project')).toBeInTheDocument()
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show dropdown for short queries after [[', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[A' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('inserts [[note-title]] when a note is selected', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Alpha' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
fireEvent.click(screen.getByText('Alpha Project'))
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Alpha Project]]' }],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('navigates dropdown with arrow keys and selects with Enter', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Al' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
const selected = document.querySelector('.wikilink-menu__item--selected')
|
||||
expect(selected).toBeTruthy()
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes dropdown on Escape', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Al' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByTestId('wikilink-dropdown')).toBeInTheDocument()
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('excludes trashed notes from autocomplete', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Trashed' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.queryByText('Trashed Note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('matches on aliases', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Alice' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows type badge for typed entries', () => {
|
||||
const personType = makeEntry({
|
||||
path: '/vault/person.md', filename: 'person.md', title: 'Person',
|
||||
isA: 'Type', color: 'yellow', icon: 'user',
|
||||
})
|
||||
const entriesWithType = [...entries, personType]
|
||||
render(
|
||||
<FilterBuilder
|
||||
group={{ all: [{ field: 'title', op: 'contains', value: '[[Luca' }] }}
|
||||
onChange={onChange}
|
||||
availableFields={['type', 'status', 'title']}
|
||||
entries={entriesWithType}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByText('Person')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens dropdown on typing [[ in input', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Al' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
// Simulate the user typing [[ — dropdown opens when value starts with [[
|
||||
fireEvent.change(input, { target: { value: '[[Al' } })
|
||||
// The internal open state is set by onChange, verified via focus re-trigger
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByTestId('wikilink-dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('plain text without [[ still works as regular input', () => {
|
||||
renderWithEntries()
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.change(input, { target: { value: 'some text' } })
|
||||
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to plain input when no entries are provided', () => {
|
||||
render(
|
||||
<FilterBuilder
|
||||
group={{ all: [{ field: 'title', op: 'contains', value: '' }] }}
|
||||
onChange={onChange}
|
||||
availableFields={['type', 'status', 'title']}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByPlaceholderText('value')
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useId } from 'react'
|
||||
import { useState, useRef, useMemo, useEffect, useCallback } from 'react'
|
||||
import { Plus, X } from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
|
||||
import { Input } from '@/components/ui/input'
|
||||
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'
|
||||
import './WikilinkSuggestionMenu.css'
|
||||
|
||||
const OPERATORS: { value: FilterOp; label: string }[] = [
|
||||
{ value: 'equals', label: 'equals' },
|
||||
@@ -15,6 +20,7 @@ const OPERATORS: { value: FilterOp; label: string }[] = [
|
||||
]
|
||||
|
||||
const NO_VALUE_OPS = new Set<FilterOp>(['is_empty', 'is_not_empty'])
|
||||
const DATE_OPS = new Set<FilterOp>(['before', 'after'])
|
||||
|
||||
function isFilterGroup(node: FilterNode): node is FilterGroup {
|
||||
return 'all' in node || 'any' in node
|
||||
@@ -32,98 +38,351 @@ function setGroupChildren(mode: 'all' | 'any', children: FilterNode[]): FilterGr
|
||||
return mode === 'all' ? { all: children } : { any: children }
|
||||
}
|
||||
|
||||
/** Combobox-style field input with datalist autocomplete. */
|
||||
function FieldInput({ value, fields, onChange }: {
|
||||
const CONTENT_FIELDS = new Set(['body'])
|
||||
|
||||
function FieldSelect({ value, fields, onChange }: {
|
||||
value: string
|
||||
fields: string[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
const id = useId()
|
||||
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 (
|
||||
<>
|
||||
<input
|
||||
list={id}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="field"
|
||||
/>
|
||||
<datalist id={id}>
|
||||
{fields.map((f) => <option key={f} value={f} />)}
|
||||
</datalist>
|
||||
</>
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-8 min-w-[100px] flex-1 gap-1 border-input bg-background px-2 text-sm shadow-none"
|
||||
>
|
||||
<SelectValue placeholder="field" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{isCustom && <SelectItem value={value}>{value}</SelectItem>}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
/** Combobox-style value input with autocomplete for known values. */
|
||||
function ValueInput({ value, suggestions, onChange }: {
|
||||
function OperatorSelect({ value, onChange }: {
|
||||
value: FilterOp
|
||||
onChange: (v: FilterOp) => void
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={(v) => onChange(v as FilterOp)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-8 shrink-0 gap-1 border-input bg-background px-2 text-sm shadow-none"
|
||||
style={{ minWidth: 120 }}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{OPERATORS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
const MAX_WIKILINK_RESULTS = 10
|
||||
const MIN_WIKILINK_QUERY = 2
|
||||
|
||||
function entryMatchesQuery(e: VaultEntry, lowerQuery: string): boolean {
|
||||
return e.title.toLowerCase().includes(lowerQuery) ||
|
||||
e.aliases.some(a => a.toLowerCase().includes(lowerQuery))
|
||||
}
|
||||
|
||||
function toWikilinkMatch(e: VaultEntry, typeEntryMap: Record<string, VaultEntry>) {
|
||||
const isA = e.isA
|
||||
const te = typeEntryMap[isA ?? '']
|
||||
const noteType = isA || undefined
|
||||
return {
|
||||
title: e.title,
|
||||
noteType,
|
||||
typeColor: noteType ? getTypeColor(isA, te?.color) : undefined,
|
||||
typeLightColor: noteType ? getTypeLightColor(isA, te?.color) : undefined,
|
||||
TypeIcon: noteType ? getTypeIcon(isA, te?.icon) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function matchWikilinkEntries(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>, query: string) {
|
||||
if (query.length < MIN_WIKILINK_QUERY) return []
|
||||
const lowerQuery = query.toLowerCase()
|
||||
return entries
|
||||
.filter(e => !e.trashed && entryMatchesQuery(e, lowerQuery))
|
||||
.slice(0, MAX_WIKILINK_RESULTS)
|
||||
.map(e => toWikilinkMatch(e, typeEntryMap))
|
||||
}
|
||||
|
||||
type WikilinkMatch = ReturnType<typeof toWikilinkMatch>
|
||||
|
||||
function extractWikilinkQuery(value: string): string | null {
|
||||
return value.startsWith('[[') ? value.slice(2).replace(/]]$/, '') : null
|
||||
}
|
||||
|
||||
function useOutsideClick(refs: React.RefObject<HTMLElement | null>[], onClose: () => void) {
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (refs.every(r => !r.current?.contains(target))) onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [refs, onClose])
|
||||
}
|
||||
|
||||
function WikilinkDropdown({ matches, selectedIndex, onSelect, onHover, menuRef }: {
|
||||
matches: WikilinkMatch[]
|
||||
selectedIndex: number
|
||||
onSelect: (title: string) => void
|
||||
onHover: (index: number) => void
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="wikilink-menu"
|
||||
ref={menuRef}
|
||||
style={{ position: 'absolute', top: '100%', left: 0, right: 0, marginTop: 2, zIndex: 50 }}
|
||||
data-testid="wikilink-dropdown"
|
||||
>
|
||||
{matches.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className={`wikilink-menu__item${index === selectedIndex ? ' wikilink-menu__item--selected' : ''}`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => onSelect(item.title)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
>
|
||||
<span className="wikilink-menu__title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{item.TypeIcon && <item.TypeIcon width={14} height={14} style={{ color: item.typeColor, flexShrink: 0 }} />}
|
||||
{item.title}
|
||||
</span>
|
||||
{item.noteType && (
|
||||
<span className="wikilink-menu__type" style={{ color: item.typeColor, backgroundColor: item.typeLightColor, borderRadius: 9999, padding: '1px 8px' }}>
|
||||
{item.noteType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useWikilinkMatches(entries: VaultEntry[], value: string, open: boolean) {
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const wikilinkQuery = extractWikilinkQuery(value)
|
||||
return useMemo(
|
||||
() => (open && wikilinkQuery !== null) ? matchWikilinkEntries(entries, typeEntryMap, wikilinkQuery) : [],
|
||||
[entries, typeEntryMap, wikilinkQuery, open],
|
||||
)
|
||||
}
|
||||
|
||||
function useScrollSelectedIntoView(menuRef: React.RefObject<HTMLDivElement | null>, selectedIndex: number) {
|
||||
useEffect(() => {
|
||||
if (selectedIndex < 0 || !menuRef.current) return
|
||||
const el = menuRef.current.children[selectedIndex] as HTMLElement | undefined
|
||||
el?.scrollIntoView?.({ block: 'nearest' })
|
||||
}, [selectedIndex, menuRef])
|
||||
}
|
||||
|
||||
function useDropdownKeyboard(
|
||||
matches: WikilinkMatch[],
|
||||
open: boolean,
|
||||
onSelect: (title: string) => void,
|
||||
onClose: () => void,
|
||||
) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
|
||||
const resetIndex = useCallback(() => setSelectedIndex(-1), [])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!open || matches.length === 0) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => (i + 1) % matches.length)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => (i <= 0 ? matches.length - 1 : i - 1))
|
||||
} else if (e.key === 'Enter' && selectedIndex >= 0) {
|
||||
e.preventDefault()
|
||||
onSelect(matches[selectedIndex].title)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}, [open, matches, selectedIndex, onSelect, onClose])
|
||||
|
||||
return { selectedIndex, setSelectedIndex, resetIndex, handleKeyDown }
|
||||
}
|
||||
|
||||
function WikilinkValueInput({ value, entries, onChange }: {
|
||||
value: string
|
||||
suggestions: string[]
|
||||
entries: VaultEntry[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
const id = useId()
|
||||
const [open, setOpen] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const matches = useWikilinkMatches(entries, value, open)
|
||||
|
||||
const handleSelect = useCallback((title: string) => {
|
||||
onChange(`[[${title}]]`)
|
||||
setOpen(false)
|
||||
}, [onChange])
|
||||
|
||||
const closeMenu = useCallback(() => setOpen(false), [])
|
||||
useOutsideClick([inputRef, menuRef], closeMenu)
|
||||
|
||||
const { selectedIndex, setSelectedIndex, resetIndex, handleKeyDown } =
|
||||
useDropdownKeyboard(matches, open, handleSelect, closeMenu)
|
||||
|
||||
useScrollSelectedIntoView(menuRef, selectedIndex)
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value)
|
||||
setOpen(e.target.value.startsWith('[['))
|
||||
resetIndex()
|
||||
}, [onChange, resetIndex])
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
list={id}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm flex-1 min-w-0"
|
||||
<div style={{ position: 'relative' }} className="flex-1 min-w-0">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="h-8 w-full text-sm"
|
||||
placeholder="value"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onChange={handleChange}
|
||||
onFocus={() => { if (value.startsWith('[[')) setOpen(true) }}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="filter-value-input"
|
||||
/>
|
||||
<datalist id={id}>
|
||||
{suggestions.map((s) => <option key={s} value={s} />)}
|
||||
</datalist>
|
||||
</>
|
||||
{open && matches.length > 0 && (
|
||||
<WikilinkDropdown
|
||||
matches={matches}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={handleSelect}
|
||||
onHover={setSelectedIndex}
|
||||
menuRef={menuRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterRow({ condition, fields, valueSuggestions, onUpdate, onRemove }: {
|
||||
function ValueInput({ value, suggestions, isDateOp, entries, onChange }: {
|
||||
value: string
|
||||
suggestions: string[]
|
||||
isDateOp: boolean
|
||||
entries: VaultEntry[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
if (isDateOp) {
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-8 flex-1 min-w-0 gap-1 border-input bg-background px-2 text-sm shadow-none"
|
||||
>
|
||||
<SelectValue placeholder="value" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{value !== '' && !suggestions.includes(value) && (
|
||||
<SelectItem value={value}>{value}</SelectItem>
|
||||
)}
|
||||
{suggestions.map((s) => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
return <WikilinkValueInput value={value} entries={entries} onChange={onChange} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
placeholder="value"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterRow({ condition, fields, entries, valueSuggestions, onUpdate, onRemove }: {
|
||||
condition: FilterCondition
|
||||
fields: string[]
|
||||
entries: VaultEntry[]
|
||||
valueSuggestions: (field: string) => string[]
|
||||
onUpdate: (c: FilterCondition) => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const suggestions = valueSuggestions(condition.field)
|
||||
const isDateOp = DATE_OPS.has(condition.op)
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FieldInput
|
||||
<FieldSelect
|
||||
value={condition.field}
|
||||
fields={fields}
|
||||
onChange={(v) => onUpdate({ ...condition, field: v })}
|
||||
/>
|
||||
<select
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-sm shrink-0"
|
||||
<OperatorSelect
|
||||
value={condition.op}
|
||||
onChange={(e) => onUpdate({ ...condition, op: e.target.value as FilterOp })}
|
||||
>
|
||||
{OPERATORS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
onChange={(op) => onUpdate({ ...condition, op })}
|
||||
/>
|
||||
{!NO_VALUE_OPS.has(condition.op) && (
|
||||
<ValueInput
|
||||
value={String(condition.value ?? '')}
|
||||
suggestions={suggestions}
|
||||
isDateOp={isDateOp}
|
||||
entries={entries}
|
||||
onChange={(v) => onUpdate({ ...condition, value: v })}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="flex-shrink-0 rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 shrink-0 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onRemove}
|
||||
title="Remove filter"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterGroupView({ group, fields, valueSuggestions, depth, onChange, onRemove }: {
|
||||
function FilterGroupView({ group, fields, entries, valueSuggestions, depth, onChange, onRemove }: {
|
||||
group: FilterGroup
|
||||
fields: string[]
|
||||
entries: VaultEntry[]
|
||||
valueSuggestions: (field: string) => string[]
|
||||
depth: number
|
||||
onChange: (g: FilterGroup) => void
|
||||
@@ -159,26 +418,30 @@ function FilterGroupView({ group, fields, valueSuggestions, depth, onChange, onR
|
||||
return (
|
||||
<div className={depth > 0 ? 'ml-3 border-l-2 border-border pl-3 py-1' : ''}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="rounded-full border border-input bg-muted px-2.5 py-0.5 text-[11px] font-medium text-foreground cursor-pointer hover:bg-accent transition-colors"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 rounded-full px-2.5 text-[11px] font-medium"
|
||||
onClick={toggleMode}
|
||||
title={`Switch to ${mode === 'all' ? 'OR' : 'AND'}`}
|
||||
>
|
||||
{mode === 'all' ? 'AND' : 'OR'}
|
||||
</button>
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{mode === 'all' ? 'Match all conditions' : 'Match any condition'}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="ml-auto rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onRemove}
|
||||
title="Remove group"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
@@ -188,6 +451,7 @@ function FilterGroupView({ group, fields, valueSuggestions, depth, onChange, onR
|
||||
key={i}
|
||||
group={child}
|
||||
fields={fields}
|
||||
entries={entries}
|
||||
valueSuggestions={valueSuggestions}
|
||||
depth={depth + 1}
|
||||
onChange={(g) => updateChild(i, g)}
|
||||
@@ -198,6 +462,7 @@ function FilterGroupView({ group, fields, valueSuggestions, depth, onChange, onR
|
||||
key={i}
|
||||
condition={child}
|
||||
fields={fields}
|
||||
entries={entries}
|
||||
valueSuggestions={valueSuggestions}
|
||||
onUpdate={(c) => updateChild(i, c)}
|
||||
onRemove={() => removeChild(i)}
|
||||
@@ -223,16 +488,19 @@ export interface FilterBuilderProps {
|
||||
availableFields: string[]
|
||||
/** Returns known values for a given field (for autocomplete). */
|
||||
valueSuggestions?: (field: string) => string[]
|
||||
/** Vault entries for wikilink autocomplete in value fields. */
|
||||
entries?: VaultEntry[]
|
||||
}
|
||||
|
||||
const defaultSuggestions = () => [] as string[]
|
||||
|
||||
export function FilterBuilder({ group, onChange, availableFields, valueSuggestions }: FilterBuilderProps) {
|
||||
export function FilterBuilder({ group, onChange, availableFields, valueSuggestions, entries }: FilterBuilderProps) {
|
||||
const fields = availableFields.length > 0 ? availableFields : ['type']
|
||||
return (
|
||||
<FilterGroupView
|
||||
group={group}
|
||||
fields={fields}
|
||||
entries={entries ?? []}
|
||||
valueSuggestions={valueSuggestions ?? defaultSuggestions}
|
||||
depth={0}
|
||||
onChange={onChange}
|
||||
|
||||
@@ -102,7 +102,7 @@ export const FolderTree = memo(function FolderTree({ folders, selection, onSelec
|
||||
if (folders.length === 0 && !isCreating) return null
|
||||
|
||||
return (
|
||||
<div style={{ padding: '4px 6px 0' }}>
|
||||
<div style={{ padding: '4px 6px' }}>
|
||||
{/* Header */}
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
|
||||
|
||||
@@ -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'
|
||||
|
||||
118
src/components/NoteItem.test.tsx
Normal file
118
src/components/NoteItem.test.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { NoteItem } from './NoteItem'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
vi.mock('../mock-tauri', () => ({ isTauri: () => false, mockInvoke: vi.fn() }))
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/test.md', filename: 'test.md', title: 'Test Note',
|
||||
isA: 'Movie', aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: 1700000000, createdAt: null, fileSize: 100,
|
||||
snippet: 'A snippet', wordCount: 50,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null,
|
||||
visible: null, favorite: false, favoriteIndex: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
listPropertiesDisplay: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeTypeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return makeEntry({
|
||||
path: '/vault/movie.md', filename: 'movie.md', title: 'Movie',
|
||||
isA: 'Type', listPropertiesDisplay: [],
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
const noop = vi.fn()
|
||||
|
||||
describe('NoteItem property chips', () => {
|
||||
it('renders property chips when type has listPropertiesDisplay', () => {
|
||||
const entry = makeEntry({
|
||||
properties: { rating: 4, genre: 'Drama' },
|
||||
})
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating', 'genre'] })
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('property-chips')).toBeInTheDocument()
|
||||
expect(screen.getByText('4')).toBeInTheDocument()
|
||||
expect(screen.getByText('Drama')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render chips when listPropertiesDisplay is empty', () => {
|
||||
const entry = makeEntry({ properties: { rating: 4 } })
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: [] })
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('property-chips')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skips chips for missing properties', () => {
|
||||
const entry = makeEntry({ properties: { rating: 4 } })
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating', 'genre'] })
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('4')).toBeInTheDocument()
|
||||
expect(screen.queryByText('genre')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders relationship values as display labels', () => {
|
||||
const entry = makeEntry({
|
||||
relationships: { Director: ['[[spielberg|Steven Spielberg]]'] },
|
||||
})
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['Director'] })
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Steven Spielberg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows hostname for URL properties', () => {
|
||||
const entry = makeEntry({
|
||||
properties: { url: 'https://www.example.com/page/123' },
|
||||
})
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['url'] })
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('www.example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render chips for binary files', () => {
|
||||
const entry = makeEntry({
|
||||
fileKind: 'binary',
|
||||
properties: { rating: 4 },
|
||||
})
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating'] })
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('property-chips')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -10,6 +10,7 @@ import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { resolveIcon } from '../utils/iconRegistry'
|
||||
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
import { wikilinkDisplay } from '../utils/wikilink'
|
||||
|
||||
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
|
||||
Project: Wrench,
|
||||
@@ -85,6 +86,59 @@ function StateBadge({ archived, trashed }: { archived: boolean; trashed: boolean
|
||||
return null
|
||||
}
|
||||
|
||||
function formatChipValue(value: unknown): string | null {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
const s = String(value)
|
||||
// URL: show only hostname
|
||||
try {
|
||||
if (s.startsWith('http://') || s.startsWith('https://')) return new URL(s).hostname
|
||||
} catch { /* not a URL */ }
|
||||
return s.length > 40 ? s.slice(0, 37) + '…' : s
|
||||
}
|
||||
|
||||
function resolveChipValues(entry: VaultEntry, propName: string): string[] {
|
||||
// Check relationships first (wikilink values)
|
||||
const relKey = Object.keys(entry.relationships).find((k) => k.toLowerCase() === propName.toLowerCase())
|
||||
if (relKey) {
|
||||
return entry.relationships[relKey].map((ref) => wikilinkDisplay(ref)).filter(Boolean)
|
||||
}
|
||||
// Check scalar properties
|
||||
const propKey = Object.keys(entry.properties).find((k) => k.toLowerCase() === propName.toLowerCase())
|
||||
if (!propKey) return []
|
||||
const val = entry.properties[propKey]
|
||||
if (Array.isArray(val)) return val.map((v) => formatChipValue(v)).filter((v): v is string => v !== null)
|
||||
const formatted = formatChipValue(val)
|
||||
return formatted ? [formatted] : []
|
||||
}
|
||||
|
||||
function PropertyChips({ entry, displayProps }: { entry: VaultEntry; displayProps: string[] }) {
|
||||
const chips = useMemo(() => {
|
||||
const result: { key: string; values: string[] }[] = []
|
||||
for (const prop of displayProps) {
|
||||
const values = resolveChipValues(entry, prop)
|
||||
if (values.length > 0) result.push({ key: prop, values })
|
||||
}
|
||||
return result
|
||||
}, [entry, displayProps])
|
||||
|
||||
if (chips.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="mt-1 flex flex-wrap gap-1" data-testid="property-chips">
|
||||
{chips.map(({ key, values }) =>
|
||||
values.map((v, i) => (
|
||||
<span
|
||||
key={`${key}-${i}`}
|
||||
className="inline-block max-w-full truncate rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
{v}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function noteItemStyle(isSelected: boolean, isMultiSelected: boolean, typeColor: string, typeLightColor: string): React.CSSProperties {
|
||||
const base: React.CSSProperties = { padding: isSelected && !isMultiSelected ? '14px 16px 14px 13px' : '14px 16px' }
|
||||
if (isMultiSelected) base.backgroundColor = 'color-mix(in srgb, var(--accent-blue) 10%, transparent)'
|
||||
@@ -98,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
|
||||
@@ -107,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'
|
||||
@@ -133,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}
|
||||
@@ -153,6 +209,9 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
|
||||
{entry.snippet}
|
||||
</div>
|
||||
)}
|
||||
{!isBinary && te?.listPropertiesDisplay && te.listPropertiesDisplay.length > 0 && (
|
||||
<PropertyChips entry={entry} displayProps={te.listPropertiesDisplay} />
|
||||
)}
|
||||
{!isBinary && (entry.trashed && entry.trashedAt
|
||||
? <TrashDateLine entry={entry} />
|
||||
: <div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
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, countInboxByPeriod } from '../utils/noteListHelpers'
|
||||
import { countByFilter, countAllByFilter } from '../utils/noteListHelpers'
|
||||
import { NoteItem } from './NoteItem'
|
||||
import { prefetchNoteContent } from '../hooks/useTabManagement'
|
||||
import { BulkActionBar } from './BulkActionBar'
|
||||
@@ -9,7 +9,6 @@ import { useMultiSelect } from '../hooks/useMultiSelect'
|
||||
import { useNoteListKeyboard } from '../hooks/useNoteListKeyboard'
|
||||
import { NoteListHeader } from './note-list/NoteListHeader'
|
||||
import { FilterPills } from './note-list/FilterPills'
|
||||
import { InboxFilterPills } from './note-list/InboxFilterPills'
|
||||
import { EntityView, ListView } from './note-list/NoteListViews'
|
||||
import { DeletedNotesBanner } from './note-list/TrashWarningBanner'
|
||||
import { routeNoteClick, toggleSetMember, resolveHeaderTitle } from './note-list/noteListUtils'
|
||||
@@ -17,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[]
|
||||
@@ -41,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 = 'month', onInboxPeriodChange, 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'
|
||||
@@ -59,11 +64,6 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
[entries, isSectionGroup, isAllNotesView, isFolderView, selection],
|
||||
)
|
||||
|
||||
const inboxCounts = useMemo(
|
||||
() => isInboxView ? countInboxByPeriod(entries) : { week: 0, month: 0, quarter: 0, all: 0 },
|
||||
[entries, isInboxView],
|
||||
)
|
||||
|
||||
const { listSort, listDirection, customProperties, handleSortChange, sortPrefs, typeDocument } = useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, onUpdateTypeSort, updateEntry })
|
||||
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
@@ -94,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)
|
||||
@@ -106,7 +138,7 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
|
||||
return (
|
||||
<div className="flex flex-col select-none overflow-hidden border-r border-border bg-card text-foreground" style={{ height: '100%' }}>
|
||||
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} onSortChange={handleSortChange} onCreateNote={handleCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} />
|
||||
<NoteListHeader title={title} typeDocument={typeDocument} isEntityView={isEntityView} isTrashView={isTrashView} trashCount={searched.length} listSort={listSort} listDirection={listDirection} customProperties={customProperties} sidebarCollapsed={sidebarCollapsed} searchVisible={searchVisible} search={search} isSectionGroup={isSectionGroup} entries={entries} onSortChange={handleSortChange} onCreateNote={handleCreateNote} onOpenType={onReplaceActiveTab} onToggleSearch={toggleSearch} onSearchChange={setSearch} onEmptyTrash={onEmptyTrash} onUpdateTypeProperty={onUpdateTypeSort} />
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden outline-none" style={{ minHeight: 0 }} tabIndex={0} onKeyDown={noteListKeyboard.handleKeyDown} onFocus={noteListKeyboard.handleFocus} data-testid="note-list-container">
|
||||
<div className="flex-1 overflow-hidden" style={{ minHeight: 0 }}>
|
||||
{entitySelection ? (
|
||||
@@ -117,11 +149,39 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
</div>
|
||||
{isChangesView && deletedCount > 0 && <DeletedNotesBanner count={deletedCount} />}
|
||||
{showFilterPills && <FilterPills active={noteListFilter} counts={filterCounts} onChange={onNoteListFilterChange} position="bottom" />}
|
||||
{isInboxView && onInboxPeriodChange && <InboxFilterPills active={inboxPeriod} counts={inboxCounts} onChange={onInboxPeriodChange} position="bottom" />}
|
||||
</div>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ const emptySettings: Settings = {
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
const populatedSettings: Settings = {
|
||||
@@ -41,7 +41,7 @@ const populatedSettings: Settings = {
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
describe('SettingsPanel', () => {
|
||||
@@ -405,56 +405,6 @@ describe('SettingsPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Update channel section', () => {
|
||||
it('renders the update channel dropdown', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
expect(screen.getByTestId('settings-update-channel')).toBeInTheDocument()
|
||||
expect(screen.getByText('Updates')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('defaults to stable when update_channel is null', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
const select = screen.getByTestId('settings-update-channel') as HTMLSelectElement
|
||||
expect(select.value).toBe('stable')
|
||||
})
|
||||
|
||||
it('reflects canary setting', () => {
|
||||
const canarySettings: Settings = { ...emptySettings, update_channel: 'canary' }
|
||||
render(
|
||||
<SettingsPanel open={true} settings={canarySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
const select = screen.getByTestId('settings-update-channel') as HTMLSelectElement
|
||||
expect(select.value).toBe('canary')
|
||||
})
|
||||
|
||||
it('saves update_channel when changed to canary', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
fireEvent.change(screen.getByTestId('settings-update-channel'), { target: { value: 'canary' } })
|
||||
fireEvent.click(screen.getByTestId('settings-save'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
update_channel: 'canary',
|
||||
}))
|
||||
})
|
||||
|
||||
it('saves null when channel is stable (default)', () => {
|
||||
render(
|
||||
<SettingsPanel open={true} settings={emptySettings} onSave={onSave} onClose={onClose} />
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('settings-save'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
|
||||
update_channel: null,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Privacy & Telemetry section', () => {
|
||||
it('renders crash reporting and analytics toggles', () => {
|
||||
render(
|
||||
|
||||
@@ -125,7 +125,6 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
const [githubToken, setGithubToken] = useState(settings.github_token)
|
||||
const [githubUsername, setGithubUsername] = useState(settings.github_username)
|
||||
const [pullInterval, setPullInterval] = useState(settings.auto_pull_interval_minutes ?? 5)
|
||||
const [updateChannel, setUpdateChannel] = useState(settings.update_channel ?? 'stable')
|
||||
const [releaseChannel, setReleaseChannel] = useState(settings.release_channel ?? 'stable')
|
||||
const [crashReporting, setCrashReporting] = useState(settings.crash_reporting_enabled ?? false)
|
||||
const [analytics, setAnalytics] = useState(settings.analytics_enabled ?? false)
|
||||
@@ -150,9 +149,8 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
crash_reporting_enabled: crashReporting,
|
||||
analytics_enabled: analytics,
|
||||
anonymous_id: (crashReporting || analytics) ? (settings.anonymous_id ?? crypto.randomUUID()) : settings.anonymous_id,
|
||||
update_channel: updateChannel === 'stable' ? null : updateChannel,
|
||||
release_channel: releaseChannel === 'stable' ? null : releaseChannel,
|
||||
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval, updateChannel, releaseChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
|
||||
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval, releaseChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
|
||||
|
||||
const handleSave = () => {
|
||||
const prevAnalytics = settings.analytics_enabled ?? false
|
||||
@@ -206,7 +204,6 @@ function SettingsPanelInner({ settings, onSave, onClose }: Omit<SettingsPanelPro
|
||||
githubToken={githubToken ?? null} githubUsername={githubUsername ?? null}
|
||||
onGitHubConnected={handleGitHubConnected} onGitHubDisconnect={handleGitHubDisconnect}
|
||||
pullInterval={pullInterval} setPullInterval={setPullInterval}
|
||||
updateChannel={updateChannel} setUpdateChannel={setUpdateChannel}
|
||||
releaseChannel={releaseChannel} setReleaseChannel={setReleaseChannel}
|
||||
crashReporting={crashReporting} setCrashReporting={setCrashReporting}
|
||||
analytics={analytics} setAnalytics={setAnalytics}
|
||||
@@ -242,7 +239,6 @@ interface SettingsBodyProps {
|
||||
onGitHubConnected: (token: string, username: string) => void
|
||||
onGitHubDisconnect: () => void
|
||||
pullInterval: number; setPullInterval: (v: number) => void
|
||||
updateChannel: string; setUpdateChannel: (v: string) => void
|
||||
releaseChannel: string; setReleaseChannel: (v: string) => void
|
||||
crashReporting: boolean; setCrashReporting: (v: boolean) => void
|
||||
analytics: boolean; setAnalytics: (v: boolean) => void
|
||||
@@ -307,26 +303,12 @@ function SettingsBody(props: SettingsBodyProps) {
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Updates</div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--foreground)', marginBottom: 4 }}>Release Channel</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted-foreground)', lineHeight: 1.5 }}>
|
||||
Canary builds include the latest features but may be less stable. Restart required after changing.
|
||||
Controls which features are visible. Alpha users see all features. Beta/Stable see features as they are promoted.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }}>Update channel</label>
|
||||
<select
|
||||
value={props.updateChannel}
|
||||
onChange={(e) => props.setUpdateChannel(e.target.value)}
|
||||
className="border border-border bg-transparent text-foreground rounded"
|
||||
style={{ fontSize: 13, padding: '8px 10px', outline: 'none', fontFamily: 'inherit' }}
|
||||
data-testid="settings-update-channel"
|
||||
>
|
||||
<option value="stable">Stable</option>
|
||||
<option value="canary">Canary (pre-release)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }}>Release channel</label>
|
||||
<select
|
||||
@@ -340,9 +322,6 @@ function SettingsBody(props: SettingsBodyProps) {
|
||||
<option value="beta">Beta</option>
|
||||
<option value="alpha">Alpha (bleeding edge)</option>
|
||||
</select>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted-foreground)', lineHeight: 1.4 }}>
|
||||
Alpha users see all features. Beta/Stable see features as they are promoted.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: 1, background: 'var(--border)' }} />
|
||||
|
||||
@@ -958,4 +958,80 @@ describe('Sidebar', () => {
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'favorites' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('group separators', () => {
|
||||
it('TYPES header and its entries share the same border-b container (no separator inside group)', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const sectionsHeader = screen.getByText('TYPES')
|
||||
const projectsSection = screen.getByText('Projects')
|
||||
// Walk up from TYPES header to find the border-b container
|
||||
const borderContainer = sectionsHeader.closest('.border-b')
|
||||
expect(borderContainer).not.toBeNull()
|
||||
// The section entry should be inside the same border-b container
|
||||
expect(borderContainer!.contains(projectsSection)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('view edit button', () => {
|
||||
const mockViews = [
|
||||
{
|
||||
filename: 'active-projects.yml',
|
||||
definition: {
|
||||
name: 'Active Projects',
|
||||
icon: '🚀',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Project' }] },
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
it('renders edit button for each view item when onEditView is provided', () => {
|
||||
render(
|
||||
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} onEditView={() => {}} onDeleteView={() => {}} />
|
||||
)
|
||||
expect(screen.getByTitle('Edit view')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render edit button when onEditView is not provided', () => {
|
||||
render(
|
||||
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} onDeleteView={() => {}} />
|
||||
)
|
||||
expect(screen.queryByTitle('Edit view')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onEditView with correct filename when clicked', () => {
|
||||
const onEditView = vi.fn()
|
||||
render(
|
||||
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} onEditView={onEditView} onDeleteView={() => {}} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Edit view'))
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
FileText, Trash, Archive, CaretLeft, Tray, CaretRight, CaretDown, Plus, Funnel,
|
||||
FileText, Trash, Archive, CaretLeft, Tray, CaretRight, CaretDown, Plus, Funnel, PencilSimple,
|
||||
} from '@phosphor-icons/react'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
import { arrayMove } from '@dnd-kit/sortable'
|
||||
@@ -40,6 +40,7 @@ interface SidebarProps {
|
||||
onReorderFavorites?: (orderedPaths: string[]) => void
|
||||
views?: ViewFile[]
|
||||
onCreateView?: () => void
|
||||
onEditView?: (filename: string) => void
|
||||
onDeleteView?: (filename: string) => void
|
||||
folders?: FolderNode[]
|
||||
onCreateFolder?: (name: string) => void
|
||||
@@ -223,10 +224,10 @@ function FavoritesSection({ entries, selection, onSelect, onSelectNote, onReorde
|
||||
if (favorites.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ padding: '4px 6px 0' }}>
|
||||
<div style={{ padding: '0 6px' }}>
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
|
||||
style={{ padding: '6px 14px 6px 16px' }}
|
||||
style={{ padding: '8px 14px 8px 16px' }}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -331,8 +332,9 @@ export const Sidebar = memo(function Sidebar({
|
||||
entries, selection, onSelect,
|
||||
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
|
||||
onToggleTypeVisibility, onSelectFavorite, onReorderFavorites,
|
||||
views = [], onCreateView, onDeleteView,
|
||||
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)
|
||||
@@ -430,10 +432,10 @@ export const Sidebar = memo(function Sidebar({
|
||||
|
||||
{/* Views */}
|
||||
{hasViews && (
|
||||
<div className="border-b border-border" style={{ padding: '4px 6px 0' }}>
|
||||
<div className="border-b border-border" style={{ padding: '0 6px' }}>
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
|
||||
style={{ padding: '6px 14px 6px 16px' }}
|
||||
style={{ padding: '8px 14px 8px 16px' }}
|
||||
onClick={() => toggleGroup('views')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -454,20 +456,31 @@ export const Sidebar = memo(function Sidebar({
|
||||
<div key={v.filename} className="group relative">
|
||||
<NavItem
|
||||
icon={Funnel}
|
||||
emoji={v.definition.icon}
|
||||
label={v.definition.name}
|
||||
isActive={isSelectionActive(selection, { kind: 'view', filename: v.filename })}
|
||||
onClick={() => onSelect({ kind: 'view', filename: v.filename })}
|
||||
compact
|
||||
/>
|
||||
{onDeleteView && (
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
|
||||
onClick={(e) => { e.stopPropagation(); onDeleteView(v.filename) }}
|
||||
title="Delete view"
|
||||
>
|
||||
<Trash size={12} />
|
||||
</button>
|
||||
)}
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{onEditView && (
|
||||
<button
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); onEditView(v.filename) }}
|
||||
title="Edit view"
|
||||
>
|
||||
<PencilSimple size={12} />
|
||||
</button>
|
||||
)}
|
||||
{onDeleteView && (
|
||||
<button
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => { e.stopPropagation(); onDeleteView(v.filename) }}
|
||||
title="Delete view"
|
||||
>
|
||||
<Trash size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -475,39 +488,51 @@ export const Sidebar = memo(function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sections header + visibility popover */}
|
||||
<div ref={customizeRef} className="border-b border-border" style={{ position: 'relative', padding: '4px 6px 0' }}>
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
|
||||
style={{ padding: '6px 14px 6px 16px' }}
|
||||
onClick={() => toggleGroup('sections')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{groupCollapsed.sections ? <CaretRight size={12} /> : <CaretDown size={12} />}
|
||||
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>SECTIONS</span>
|
||||
</div>
|
||||
<span
|
||||
role="button"
|
||||
title="Customize sections"
|
||||
aria-label="Customize sections"
|
||||
onClick={(e) => { e.stopPropagation(); setShowCustomize((v) => !v) }}
|
||||
{/* Sections header + entries */}
|
||||
<div className="border-b border-border">
|
||||
<div ref={customizeRef} style={{ position: 'relative', padding: '0 6px' }}>
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
|
||||
style={{ padding: '8px 14px 8px 16px' }}
|
||||
onClick={() => toggleGroup('sections')}
|
||||
>
|
||||
<SlidersHorizontal size={12} className="text-muted-foreground hover:text-foreground" />
|
||||
</span>
|
||||
</button>
|
||||
{showCustomize && <VisibilityPopover sections={allSectionGroups} isSectionVisible={isSectionVisible} onToggle={toggleVisibility} />}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{groupCollapsed.sections ? <CaretRight size={12} /> : <CaretDown size={12} />}
|
||||
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>TYPES</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* Sortable section groups */}
|
||||
{!groupCollapsed.sections && (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
|
||||
{visibleSections.map((g) => (
|
||||
<SortableSection key={g.type} group={g} sectionProps={sectionProps} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
{/* Sortable section groups */}
|
||||
{!groupCollapsed.sections && (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
|
||||
{visibleSections.map((g) => (
|
||||
<SortableSection key={g.type} group={g} sectionProps={sectionProps} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Folder tree */}
|
||||
<FolderTree folders={folders} selection={selection} onSelect={onSelect} onCreateFolder={onCreateFolder} collapsed={groupCollapsed.folders} onToggle={() => toggleGroup('folders')} />
|
||||
|
||||
@@ -26,8 +26,9 @@ export function isSelectionActive(current: SidebarSelection, check: SidebarSelec
|
||||
|
||||
// --- NavItem ---
|
||||
|
||||
export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, activeBadgeClassName, activeBadgeStyle, onClick, disabled, disabledTooltip, compact }: {
|
||||
export function NavItem({ icon: Icon, emoji, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, activeBadgeClassName, activeBadgeStyle, onClick, disabled, disabledTooltip, compact }: {
|
||||
icon: ComponentType<IconProps>
|
||||
emoji?: string | null
|
||||
label: string
|
||||
count?: number
|
||||
isActive?: boolean
|
||||
@@ -46,11 +47,14 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
|
||||
const padding = compact ? '4px 16px' : '6px 16px'
|
||||
const resolvedBadgeClass = isActive && activeBadgeClassName ? activeBadgeClassName : badgeClassName
|
||||
const resolvedBadgeStyle = isActive && activeBadgeClassName ? activeBadgeStyle : badgeStyle
|
||||
const iconEl = emoji
|
||||
? <span style={{ fontSize: iconSize, lineHeight: 1, width: iconSize, textAlign: 'center' }}>{emoji}</span>
|
||||
: <Icon size={iconSize} weight={isActive ? 'fill' : 'regular'} />
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<div className="flex select-none items-center gap-2 rounded text-foreground" style={{ padding, borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }} title={disabledTooltip ?? "Coming soon"}>
|
||||
<Icon size={iconSize} />
|
||||
{iconEl}
|
||||
<span className={cn("flex-1 font-medium", textClass)}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -61,7 +65,7 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
|
||||
style={{ padding, borderRadius: 4 }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon size={iconSize} weight={isActive ? 'fill' : 'regular'} />
|
||||
{iconEl}
|
||||
<span className={cn("flex-1 font-medium", textClass)}>{label}</span>
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className={cn("flex items-center justify-center", resolvedBadgeClass)} style={{ height: compact ? 18 : 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, ...resolvedBadgeStyle }}>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -11,7 +11,6 @@ interface InboxFilterPillsProps {
|
||||
const PILLS: { value: InboxPeriod; label: string }[] = [
|
||||
{ value: 'week', label: 'Week' },
|
||||
{ value: 'month', label: 'Month' },
|
||||
{ value: 'quarter', label: 'Quarter' },
|
||||
{ value: 'all', label: 'All' },
|
||||
]
|
||||
|
||||
|
||||
142
src/components/note-list/ListPropertiesPopover.tsx
Normal file
142
src/components/note-list/ListPropertiesPopover.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { SlidersHorizontal, DotsSixVertical } from '@phosphor-icons/react'
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import {
|
||||
DndContext, closestCenter, KeyboardSensor, PointerSensor,
|
||||
useSensor, useSensors, type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
|
||||
interface ListPropertiesPopoverProps {
|
||||
typeDocument: VaultEntry
|
||||
entries: VaultEntry[]
|
||||
onSave: (path: string, key: string, value: string[] | null) => void
|
||||
}
|
||||
|
||||
/** Collect all available property/relationship keys from notes of this type. */
|
||||
function collectAvailableProperties(entries: VaultEntry[], typeName: string): string[] {
|
||||
const keys = new Set<string>()
|
||||
for (const entry of entries) {
|
||||
if (entry.isA !== typeName) continue
|
||||
for (const k of Object.keys(entry.properties)) keys.add(k)
|
||||
for (const k of Object.keys(entry.relationships)) keys.add(k)
|
||||
}
|
||||
// Sort alphabetically for stable ordering
|
||||
return [...keys].sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
function SortablePropertyItem({ id, checked, onToggle }: { id: string; checked: boolean; onToggle: (key: string) => void }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id })
|
||||
const style = { transform: CSS.Transform.toString(transform), transition }
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="flex items-center gap-2 rounded px-1 py-1 hover:bg-muted"
|
||||
data-testid={`list-prop-item-${id}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 cursor-grab items-center text-muted-foreground active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<DotsSixVertical size={14} />
|
||||
</button>
|
||||
<label className="flex flex-1 cursor-pointer items-center gap-2 text-[13px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(id)}
|
||||
className="accent-primary"
|
||||
style={{ width: 14, height: 14 }}
|
||||
/>
|
||||
<span className="truncate">{id}</span>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListPropertiesPopover({ typeDocument, entries, onSave }: ListPropertiesPopoverProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const currentDisplay = typeDocument.listPropertiesDisplay ?? []
|
||||
|
||||
const availableProperties = useMemo(
|
||||
() => collectAvailableProperties(entries, typeDocument.title),
|
||||
[entries, typeDocument.title],
|
||||
)
|
||||
|
||||
// Merge: selected props first (in order), then unselected alphabetically
|
||||
const orderedItems = useMemo(() => {
|
||||
const selected = currentDisplay.filter((p) => availableProperties.includes(p))
|
||||
const unselected = availableProperties.filter((p) => !selected.includes(p))
|
||||
return [...selected, ...unselected]
|
||||
}, [currentDisplay, availableProperties])
|
||||
|
||||
const selectedSet = useMemo(() => new Set(currentDisplay), [currentDisplay])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
const handleToggle = useCallback((key: string) => {
|
||||
const newSelected = selectedSet.has(key)
|
||||
? currentDisplay.filter((k) => k !== key)
|
||||
: [...currentDisplay, key]
|
||||
onSave(typeDocument.path, '_list_properties_display', newSelected.length > 0 ? newSelected : null)
|
||||
}, [selectedSet, currentDisplay, typeDocument.path, onSave])
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
// Only reorder within selected items
|
||||
const selected = currentDisplay.filter((p) => availableProperties.includes(p))
|
||||
const oldIndex = selected.indexOf(String(active.id))
|
||||
const newIndex = selected.indexOf(String(over.id))
|
||||
if (oldIndex === -1 || newIndex === -1) return
|
||||
|
||||
const reordered = arrayMove(selected, oldIndex, newIndex)
|
||||
onSave(typeDocument.path, '_list_properties_display', reordered)
|
||||
}, [currentDisplay, availableProperties, typeDocument.path, onSave])
|
||||
|
||||
if (availableProperties.length === 0) return null
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex items-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Configure list properties"
|
||||
data-testid="list-properties-btn"
|
||||
>
|
||||
<SlidersHorizontal size={16} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-56 p-2" data-testid="list-properties-popover">
|
||||
<div className="mb-2 px-1 text-[11px] font-medium text-muted-foreground">
|
||||
Show in note list
|
||||
</div>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={orderedItems} strategy={verticalListSortingStrategy}>
|
||||
{orderedItems.map((key) => (
|
||||
<SortablePropertyItem
|
||||
key={key}
|
||||
id={key}
|
||||
checked={selectedSet.has(key)}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import type { SortOption, SortDirection } from '../../utils/noteListHelpers'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useDragRegion } from '../../hooks/useDragRegion'
|
||||
import { SortDropdown } from '../SortDropdown'
|
||||
import { ListPropertiesPopover } from './ListPropertiesPopover'
|
||||
|
||||
export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView, trashCount, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onEmptyTrash }: {
|
||||
export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView, trashCount, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, isSectionGroup, entries, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onEmptyTrash, onUpdateTypeProperty }: {
|
||||
title: string
|
||||
typeDocument: VaultEntry | null
|
||||
isEntityView: boolean
|
||||
@@ -17,12 +18,15 @@ export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView,
|
||||
sidebarCollapsed?: boolean
|
||||
searchVisible: boolean
|
||||
search: string
|
||||
isSectionGroup?: boolean
|
||||
entries?: VaultEntry[]
|
||||
onSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
|
||||
onCreateNote: () => void
|
||||
onOpenType: (entry: VaultEntry) => void
|
||||
onToggleSearch: () => void
|
||||
onSearchChange: (value: string) => void
|
||||
onEmptyTrash?: () => void
|
||||
onUpdateTypeProperty?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
||||
}) {
|
||||
const { onMouseDown: onDragMouseDown } = useDragRegion()
|
||||
return (
|
||||
@@ -41,6 +45,9 @@ export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView,
|
||||
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={onToggleSearch} title="Search notes">
|
||||
<MagnifyingGlass size={16} />
|
||||
</button>
|
||||
{isSectionGroup && typeDocument && entries && onUpdateTypeProperty && (
|
||||
<ListPropertiesPopover typeDocument={typeDocument} entries={entries} onSave={onUpdateTypeProperty} />
|
||||
)}
|
||||
{isTrashView && trashCount > 0 && (
|
||||
<button
|
||||
className="flex items-center text-destructive transition-colors hover:text-destructive/80"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -15,6 +15,7 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
||||
order: { order: null },
|
||||
template: { template: null }, sort: { sort: null }, visible: { visible: null },
|
||||
_favorite: { favorite: false }, _favorite_index: { favoriteIndex: null },
|
||||
_list_properties_display: { listPropertiesDisplay: [] },
|
||||
}
|
||||
|
||||
/** Check if a string contains a wikilink pattern `[[...]]`. */
|
||||
@@ -64,6 +65,7 @@ export function frontmatterToEntryPatch(
|
||||
visible: { visible: value === false ? false : null },
|
||||
_favorite: { favorite: Boolean(value) },
|
||||
_favorite_index: { favoriteIndex: typeof value === 'number' ? value : null },
|
||||
_list_properties_display: { listPropertiesDisplay: Array.isArray(value) ? value.map(String) : [] },
|
||||
}
|
||||
// Also update the relationships map for wikilink-containing values
|
||||
const wikilinks = value != null ? extractWikilinks(value) : []
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { ViewDefinition } from '../types'
|
||||
|
||||
export function useDialogs() {
|
||||
const [showCreateTypeDialog, setShowCreateTypeDialog] = useState(false)
|
||||
@@ -10,6 +11,7 @@ export function useDialogs() {
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const [showConflictResolver, setShowConflictResolver] = useState(false)
|
||||
const [showCreateViewDialog, setShowCreateViewDialog] = useState(false)
|
||||
const [editingView, setEditingView] = useState<{ filename: string; definition: ViewDefinition } | null>(null)
|
||||
|
||||
const openCreateType = useCallback(() => setShowCreateTypeDialog(true), [])
|
||||
const closeCreateType = useCallback(() => setShowCreateTypeDialog(false), [])
|
||||
@@ -26,8 +28,12 @@ export function useDialogs() {
|
||||
const closeSearch = useCallback(() => setShowSearch(false), [])
|
||||
const openConflictResolver = useCallback(() => setShowConflictResolver(true), [])
|
||||
const closeConflictResolver = useCallback(() => setShowConflictResolver(false), [])
|
||||
const openCreateView = useCallback(() => setShowCreateViewDialog(true), [])
|
||||
const closeCreateView = useCallback(() => setShowCreateViewDialog(false), [])
|
||||
const openCreateView = useCallback(() => { setEditingView(null); setShowCreateViewDialog(true) }, [])
|
||||
const closeCreateView = useCallback(() => { setShowCreateViewDialog(false); setEditingView(null) }, [])
|
||||
const openEditView = useCallback((filename: string, definition: ViewDefinition) => {
|
||||
setEditingView({ filename, definition })
|
||||
setShowCreateViewDialog(true)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
showCreateTypeDialog, openCreateType, closeCreateType,
|
||||
@@ -38,6 +44,6 @@ export function useDialogs() {
|
||||
showGitHubVault, openGitHubVault, closeGitHubVault,
|
||||
showSearch, openSearch, closeSearch,
|
||||
showConflictResolver, openConflictResolver, closeConflictResolver,
|
||||
showCreateViewDialog, openCreateView, closeCreateView,
|
||||
showCreateViewDialog, openCreateView, closeCreateView, editingView, openEditView,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +401,16 @@ describe('frontmatterToEntryPatch', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it('maps _list_properties_display update to listPropertiesDisplay array', () => {
|
||||
const result = frontmatterToEntryPatch('update', '_list_properties_display', ['rating', 'genre'])
|
||||
expect(result.patch).toEqual({ listPropertiesDisplay: ['rating', 'genre'] })
|
||||
})
|
||||
|
||||
it('maps _list_properties_display delete to empty array', () => {
|
||||
const result = frontmatterToEntryPatch('delete', '_list_properties_display')
|
||||
expect(result.patch).toEqual({ listPropertiesDisplay: [] })
|
||||
})
|
||||
|
||||
it('returns empty patch for unknown key on delete, with relationship removal', () => {
|
||||
const result = frontmatterToEntryPatch('delete', 'unknown_key')
|
||||
expect(result.patch).toEqual({})
|
||||
|
||||
@@ -20,7 +20,7 @@ export function buildNewEntry({ path, slug, title, type, status }: NewEntryParam
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: now, createdAt: now, fileSize: 0,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, visible: null, properties: {}, favorite: false, favoriteIndex: null,
|
||||
snippet: '', wordCount: 0, relationships: {}, icon: null, color: null, order: null, outgoingLinks: [], sidebarLabel: null, template: null, sort: null, view: null, visible: null, properties: {}, favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ const defaultSettings: Settings = {
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
@@ -28,7 +27,6 @@ const savedSettings: Settings = {
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
@@ -93,7 +91,7 @@ describe('useSettings', () => {
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
|
||||
@@ -17,7 +17,6 @@ const EMPTY_SETTINGS: Settings = {
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ const baseSettings: Settings = {
|
||||
openai_key: null, google_key: null,
|
||||
github_token: null, github_username: null, auto_pull_interval_minutes: null,
|
||||
telemetry_consent: null, crash_reporting_enabled: null,
|
||||
analytics_enabled: null, anonymous_id: null, update_channel: null, release_channel: null,
|
||||
analytics_enabled: null, anonymous_id: null, release_channel: null,
|
||||
}
|
||||
|
||||
describe('useTelemetry', () => {
|
||||
|
||||
@@ -25,11 +25,6 @@ vi.mock('@tauri-apps/plugin-process', () => ({
|
||||
relaunch: (...args: unknown[]) => mockRelaunch(...args),
|
||||
}))
|
||||
|
||||
const mockGetVersion = vi.fn().mockResolvedValue('0.20260101.1')
|
||||
vi.mock('@tauri-apps/api/app', () => ({
|
||||
getVersion: () => mockGetVersion(),
|
||||
}))
|
||||
|
||||
import { isTauri } from '../mock-tauri'
|
||||
|
||||
describe('useUpdater', () => {
|
||||
@@ -205,42 +200,6 @@ describe('useUpdater', () => {
|
||||
expect(mockDownload).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('canary channel', () => {
|
||||
it('fetches latest-canary.json when channel is canary', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
mockGetVersion.mockResolvedValue('0.20260101.1')
|
||||
|
||||
const mockFetch = vi.mocked(globalThis.fetch)
|
||||
mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
version: '0.20260325.99-canary',
|
||||
notes: 'Canary build',
|
||||
platforms: {
|
||||
'darwin-aarch64': {
|
||||
url: 'https://github.com/refactoringhq/laputa-app/releases/download/v0.20260325.99-canary/laputa.app.tar.gz',
|
||||
signature: 'sig123',
|
||||
},
|
||||
},
|
||||
}), { status: 200 }))
|
||||
|
||||
const { result } = renderHook(() => useUpdater('canary'))
|
||||
|
||||
let checkResult: string | undefined
|
||||
await act(async () => {
|
||||
checkResult = await result.current.actions.checkForUpdates()
|
||||
})
|
||||
|
||||
expect(checkResult).toBe('available')
|
||||
expect(result.current.status).toEqual({
|
||||
state: 'available',
|
||||
version: '0.20260325.99-canary',
|
||||
notes: 'Canary build',
|
||||
})
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'https://refactoringhq.github.io/laputa-app/latest-canary.json'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkForUpdates (manual)', () => {
|
||||
it('returns up-to-date when no update is available', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { isTauri } from '../mock-tauri'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
|
||||
const RELEASE_NOTES_URL = 'https://refactoringhq.github.io/laputa-app/'
|
||||
const CANARY_ENDPOINT = 'https://refactoringhq.github.io/laputa-app/latest-canary.json'
|
||||
|
||||
export type UpdateStatus =
|
||||
| { state: 'idle' }
|
||||
@@ -21,45 +20,14 @@ export interface UpdateActions {
|
||||
dismiss: () => void
|
||||
}
|
||||
|
||||
interface CanaryRelease {
|
||||
version: string
|
||||
notes: string
|
||||
platforms: Record<string, { url: string; signature: string }>
|
||||
}
|
||||
|
||||
async function checkCanaryUpdate(): Promise<{ version: string; notes: string; downloadUrl: string } | null> {
|
||||
const response = await fetch(CANARY_ENDPOINT)
|
||||
if (!response.ok) return null
|
||||
|
||||
const data = await response.json() as CanaryRelease
|
||||
const { getVersion } = await import('@tauri-apps/api/app')
|
||||
const currentVersion = await getVersion()
|
||||
|
||||
if (data.version === currentVersion) return null
|
||||
|
||||
const platform = data.platforms['darwin-aarch64']
|
||||
const downloadUrl = platform?.url?.replace(/\.tar\.gz$/, '').replace(/\.app$/, '') ?? ''
|
||||
|
||||
return { version: data.version, notes: data.notes, downloadUrl }
|
||||
}
|
||||
|
||||
export function useUpdater(channel: string | null = null): { status: UpdateStatus; actions: UpdateActions } {
|
||||
export function useUpdater(): { status: UpdateStatus; actions: UpdateActions } {
|
||||
const [status, setStatus] = useState<UpdateStatus>({ state: 'idle' })
|
||||
const updateRef = useRef<unknown>(null)
|
||||
const canaryUrlRef = useRef<string | null>(null)
|
||||
|
||||
const checkForUpdates = useCallback(async (): Promise<UpdateCheckResult> => {
|
||||
if (!isTauri()) return 'up-to-date'
|
||||
|
||||
try {
|
||||
if (channel === 'canary') {
|
||||
const canary = await checkCanaryUpdate()
|
||||
if (!canary) return 'up-to-date'
|
||||
canaryUrlRef.current = canary.downloadUrl
|
||||
setStatus({ state: 'available', version: canary.version, notes: canary.notes })
|
||||
return 'available'
|
||||
}
|
||||
|
||||
const { check } = await import('@tauri-apps/plugin-updater')
|
||||
const update = await check()
|
||||
if (!update) return 'up-to-date'
|
||||
@@ -75,7 +43,7 @@ export function useUpdater(channel: string | null = null): { status: UpdateStatu
|
||||
console.warn('[updater] Failed to check for updates')
|
||||
return 'error'
|
||||
}
|
||||
}, [channel])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTauri()) return
|
||||
@@ -84,12 +52,6 @@ export function useUpdater(channel: string | null = null): { status: UpdateStatu
|
||||
}, [checkForUpdates])
|
||||
|
||||
const startDownload = useCallback(async () => {
|
||||
// Canary: open the GitHub release page for manual download
|
||||
if (canaryUrlRef.current) {
|
||||
openExternalUrl(canaryUrlRef.current)
|
||||
return
|
||||
}
|
||||
|
||||
const update = updateRef.current as {
|
||||
version: string
|
||||
downloadAndInstall: (cb: (event: { event: string; data?: { contentLength?: number; chunkLength?: number } }) => void) => Promise<void>
|
||||
|
||||
@@ -353,6 +353,28 @@ describe('useVaultSwitcher', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('default vault path', () => {
|
||||
it('does not contain CI runner paths', () => {
|
||||
// Regression: production builds must never bake in the CI runner's absolute path
|
||||
expect(DEFAULT_VAULTS[0].path).not.toContain('/Users/runner/')
|
||||
expect(DEFAULT_VAULTS[0].path).not.toContain('/home/runner/')
|
||||
})
|
||||
|
||||
it('keeps persisted active vault when one exists', async () => {
|
||||
const persistedPath = '/Users/luca/MyVault'
|
||||
mockVaultListStore = {
|
||||
vaults: [{ label: 'My Vault', path: persistedPath }],
|
||||
active_vault: persistedPath,
|
||||
hidden_defaults: [],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
await waitFor(() => { expect(result.current.loaded).toBe(true) })
|
||||
|
||||
expect(result.current.vaultPath).toBe(persistedPath)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGettingStartedHidden', () => {
|
||||
it('is false by default', async () => {
|
||||
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
|
||||
|
||||
@@ -10,10 +10,14 @@ export type { PersistedVaultList } from '../utils/vaultListStore'
|
||||
|
||||
export const GETTING_STARTED_LABEL = 'Getting Started'
|
||||
|
||||
declare const __DEMO_VAULT_PATH__: string
|
||||
declare const __DEMO_VAULT_PATH__: string | undefined
|
||||
|
||||
/** Build-time demo vault path (dev only). In production Tauri builds this is
|
||||
* undefined and the real path is resolved at runtime via get_default_vault_path. */
|
||||
const STATIC_DEFAULT_PATH = typeof __DEMO_VAULT_PATH__ !== 'undefined' ? __DEMO_VAULT_PATH__ : ''
|
||||
|
||||
export const DEFAULT_VAULTS: VaultOption[] = [
|
||||
{ label: GETTING_STARTED_LABEL, path: typeof __DEMO_VAULT_PATH__ !== 'undefined' ? __DEMO_VAULT_PATH__ : '/Users/luca/Workspace/laputa-app/demo-vault-v2' },
|
||||
{ label: GETTING_STARTED_LABEL, path: STATIC_DEFAULT_PATH },
|
||||
]
|
||||
|
||||
interface UseVaultSwitcherOptions {
|
||||
@@ -32,14 +36,20 @@ function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T
|
||||
/** Manages vault path, extra vaults, switching, cloning, and local folder opening.
|
||||
* Vault list and active vault are persisted via Tauri backend to survive app updates. */
|
||||
export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions) {
|
||||
const [vaultPath, setVaultPath] = useState(DEFAULT_VAULTS[0].path)
|
||||
const [vaultPath, setVaultPath] = useState(STATIC_DEFAULT_PATH)
|
||||
const [extraVaults, setExtraVaults] = useState<VaultOption[]>([])
|
||||
const [hiddenDefaults, setHiddenDefaults] = useState<string[]>([])
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [defaultPath, setDefaultPath] = useState(STATIC_DEFAULT_PATH)
|
||||
|
||||
const defaultVaults: VaultOption[] = useMemo(
|
||||
() => [{ label: GETTING_STARTED_LABEL, path: defaultPath }],
|
||||
[defaultPath],
|
||||
)
|
||||
|
||||
const visibleDefaults = useMemo(
|
||||
() => DEFAULT_VAULTS.filter(v => !hiddenDefaults.includes(v.path)),
|
||||
[hiddenDefaults],
|
||||
() => defaultVaults.filter(v => !hiddenDefaults.includes(v.path)),
|
||||
[defaultVaults, hiddenDefaults],
|
||||
)
|
||||
const allVaults = useMemo(
|
||||
() => [...visibleDefaults, ...extraVaults],
|
||||
@@ -47,8 +57,8 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
)
|
||||
|
||||
const isGettingStartedHidden = useMemo(
|
||||
() => hiddenDefaults.includes(DEFAULT_VAULTS[0].path),
|
||||
[hiddenDefaults],
|
||||
() => hiddenDefaults.includes(defaultPath),
|
||||
[hiddenDefaults, defaultPath],
|
||||
)
|
||||
|
||||
const onSwitchRef = useRef(onSwitch)
|
||||
@@ -60,13 +70,26 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
loadVaultList()
|
||||
.then(({ vaults, activeVault, hiddenDefaults: hidden }) => {
|
||||
.then(async ({ vaults, activeVault, hiddenDefaults: hidden }) => {
|
||||
if (cancelled) return
|
||||
setExtraVaults(vaults)
|
||||
setHiddenDefaults(hidden)
|
||||
if (activeVault) {
|
||||
setVaultPath(activeVault)
|
||||
onSwitchRef.current()
|
||||
} else if (!STATIC_DEFAULT_PATH) {
|
||||
// Production build: resolve the Getting Started path at runtime
|
||||
try {
|
||||
const runtimePath = await tauriCall<string>('get_default_vault_path', {})
|
||||
if (!cancelled && runtimePath) {
|
||||
setDefaultPath(runtimePath)
|
||||
setVaultPath(runtimePath)
|
||||
// Keep the module-level export in sync for external consumers
|
||||
DEFAULT_VAULTS[0] = { label: GETTING_STARTED_LABEL, path: runtimePath }
|
||||
}
|
||||
} catch {
|
||||
// In mock/test mode, command may not exist
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => console.warn('Failed to load vault list:', err))
|
||||
@@ -116,7 +139,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
}, [addAndSwitch])
|
||||
|
||||
const removeVault = useCallback((path: string) => {
|
||||
const isDefault = DEFAULT_VAULTS.some(v => v.path === path)
|
||||
const isDefault = defaultVaults.some(v => v.path === path)
|
||||
if (isDefault) {
|
||||
setHiddenDefaults(prev => prev.includes(path) ? prev : [...prev, path])
|
||||
} else {
|
||||
@@ -127,7 +150,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
setVaultPath(currentPath => {
|
||||
if (currentPath !== path) return currentPath
|
||||
const remaining = [
|
||||
...DEFAULT_VAULTS.filter(v => v.path !== path && !(isDefault ? [] : hiddenDefaults).includes(v.path)),
|
||||
...defaultVaults.filter(v => v.path !== path && !(isDefault ? [] : hiddenDefaults).includes(v.path)),
|
||||
...extraVaults.filter(v => v.path !== path),
|
||||
]
|
||||
if (remaining.length > 0) {
|
||||
@@ -137,26 +160,26 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
|
||||
return currentPath
|
||||
})
|
||||
|
||||
const vault = [...DEFAULT_VAULTS, ...extraVaults].find(v => v.path === path)
|
||||
const vault = [...defaultVaults, ...extraVaults].find(v => v.path === path)
|
||||
onToastRef.current(`Vault "${vault?.label ?? labelFromPath(path)}" removed from list`)
|
||||
}, [extraVaults, hiddenDefaults])
|
||||
}, [defaultVaults, extraVaults, hiddenDefaults])
|
||||
|
||||
const restoreGettingStarted = useCallback(async () => {
|
||||
const defaultPath = DEFAULT_VAULTS[0].path
|
||||
const gsPath = defaultPath
|
||||
// Un-hide the Getting Started vault
|
||||
setHiddenDefaults(prev => prev.filter(p => p !== defaultPath))
|
||||
setHiddenDefaults(prev => prev.filter(p => p !== gsPath))
|
||||
// Try to create the vault if it doesn't exist on disk
|
||||
try {
|
||||
const exists = await tauriCall<boolean>('check_vault_exists', { path: defaultPath })
|
||||
const exists = await tauriCall<boolean>('check_vault_exists', { path: gsPath })
|
||||
if (!exists) {
|
||||
await tauriCall<string>('create_getting_started_vault', { targetPath: defaultPath })
|
||||
await tauriCall<string>('create_getting_started_vault', { targetPath: gsPath })
|
||||
}
|
||||
} catch {
|
||||
// In mock/test mode, creation may fail — that's fine
|
||||
}
|
||||
switchVault(defaultPath)
|
||||
switchVault(gsPath)
|
||||
onToastRef.current('Getting Started vault restored')
|
||||
}, [switchVault])
|
||||
}, [defaultPath, switchVault])
|
||||
|
||||
return {
|
||||
vaultPath, allVaults, switchVault, handleVaultCloned, handleOpenLocalFolder, loaded,
|
||||
|
||||
@@ -37,7 +37,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['q1-2026', 'software-development', 'matteo-cellini', 'maria-bianchi', 'marco-verdi'],
|
||||
properties: { Priority: 'High', 'Due date': '2026-06-15', Owner: 'Luca Rossi' },
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/grow-newsletter.md',
|
||||
@@ -73,7 +73,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['on-writing-well', 'engineering-leadership-101', 'ai-agents-primer', 'growth', 'writing'],
|
||||
properties: { Priority: 'High', Rating: 5, Cadence: 'Weekly', Owner: 'Luca Rossi' },
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/manage-sponsorships.md',
|
||||
@@ -103,7 +103,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['matteo-cellini'],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/write-weekly-essays.md',
|
||||
@@ -133,7 +133,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['grow-newsletter'],
|
||||
properties: { Owner: 'Luca Rossi', Cadence: 'Weekly' },
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/run-sponsorships.md',
|
||||
@@ -163,7 +163,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['manage-sponsorships'],
|
||||
properties: { Owner: 'Matteo Cellini', Cadence: 'Weekly' },
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/stock-screener.md',
|
||||
@@ -194,7 +194,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['trading', 'algorithmic-trading', 'ema200-backtest-results'],
|
||||
properties: { Priority: 'Low', 'Due date': '2026-03-01', Owner: 'Luca Rossi' },
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/facebook-ads-strategy.md',
|
||||
@@ -225,7 +225,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['26q1-laputa-app', 'growth', 'ads'],
|
||||
properties: { Priority: 'Medium', Rating: 4 },
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/budget-allocation.md',
|
||||
@@ -255,7 +255,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['26q1-laputa-app'],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/matteo-cellini.md',
|
||||
@@ -284,7 +284,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: { Company: 'Acme Corp', Role: 'Engineering Lead' },
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/maria-bianchi.md',
|
||||
@@ -313,7 +313,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: { Company: 'TechStart', Role: 'Product Manager' },
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/marco-verdi.md',
|
||||
@@ -342,7 +342,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/elena-russo.md',
|
||||
@@ -371,7 +371,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/2026-02-14-laputa-app-kickoff.md',
|
||||
@@ -401,7 +401,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['26q1-laputa-app', 'matteo-cellini'],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/software-development.md',
|
||||
@@ -431,7 +431,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/trading.md',
|
||||
@@ -461,7 +461,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/on-writing-well.md',
|
||||
@@ -491,7 +491,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['grow-newsletter'],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/engineering-leadership-101.md',
|
||||
@@ -522,7 +522,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['grow-newsletter', 'software-development'],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/ai-agents-primer.md',
|
||||
@@ -552,7 +552,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: ['grow-newsletter'],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
// --- Type documents ---
|
||||
{
|
||||
@@ -580,7 +580,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/responsibility.md',
|
||||
@@ -607,7 +607,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/procedure.md',
|
||||
@@ -634,7 +634,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/experiment.md',
|
||||
@@ -661,7 +661,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: false,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/person.md',
|
||||
@@ -688,7 +688,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/event.md',
|
||||
@@ -715,7 +715,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/topic.md',
|
||||
@@ -742,7 +742,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/essay.md',
|
||||
@@ -769,7 +769,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/note.md',
|
||||
@@ -796,7 +796,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
// --- Custom type documents ---
|
||||
{
|
||||
@@ -824,7 +824,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/recipe.md',
|
||||
@@ -851,7 +851,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/book.md',
|
||||
@@ -878,7 +878,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
// --- Instances of custom types ---
|
||||
{
|
||||
@@ -908,7 +908,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/pasta-carbonara.md',
|
||||
@@ -937,7 +937,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: { Difficulty: 'Easy', 'Prep time': '30 min', Servings: 4 },
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/designing-data-intensive-applications.md',
|
||||
@@ -966,7 +966,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: { Author: 'Martin Kleppmann', Rating: 5, 'Year published': 2017 },
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
// --- Trashed entries ---
|
||||
{
|
||||
@@ -997,7 +997,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/deprecated-api-notes.md',
|
||||
@@ -1026,7 +1026,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/failed-seo-experiment.md',
|
||||
@@ -1056,7 +1056,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: { Owner: 'Luca Rossi' },
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
// --- Archived entries ---
|
||||
{
|
||||
@@ -1087,7 +1087,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
'Belongs to': ['[[q3-2025]]'],
|
||||
'Type': ['[[project]]'],
|
||||
},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/twitter-thread-experiment.md',
|
||||
@@ -1117,7 +1117,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
'Related to': ['[[grow-newsletter]]'],
|
||||
'Type': ['[[experiment]]'],
|
||||
},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
// --- Refactoring entries for exact-match search testing ---
|
||||
{
|
||||
@@ -1145,7 +1145,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/refactoring-ideas.md',
|
||||
@@ -1172,7 +1172,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/refactoring-key-ideas.md',
|
||||
@@ -1199,7 +1199,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/refactoring-patterns.md',
|
||||
@@ -1226,7 +1226,7 @@ export const MOCK_ENTRIES: VaultEntry[] = [
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1279,7 +1279,7 @@ function generateBulkEntries(count: number): VaultEntry[] {
|
||||
sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
properties: {},
|
||||
favorite: false, favoriteIndex: null,
|
||||
favorite: false, favoriteIndex: null, listPropertiesDisplay: [],
|
||||
})
|
||||
}
|
||||
return entries
|
||||
|
||||
@@ -84,7 +84,6 @@ let mockSettings: Settings = {
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
update_channel: null,
|
||||
release_channel: null,
|
||||
}
|
||||
|
||||
@@ -156,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
|
||||
@@ -210,7 +210,6 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
crash_reporting_enabled: s.crash_reporting_enabled,
|
||||
analytics_enabled: s.analytics_enabled,
|
||||
anonymous_id: s.anonymous_id,
|
||||
update_channel: s.update_channel,
|
||||
release_channel: s.release_channel,
|
||||
}
|
||||
return null
|
||||
@@ -276,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',
|
||||
|
||||
@@ -39,6 +39,8 @@ export interface VaultEntry {
|
||||
favorite: boolean
|
||||
/** Display order within the FAVORITES section (lower = higher). */
|
||||
favoriteIndex: number | null
|
||||
/** Properties to display as chips in the note list for this Type's notes. */
|
||||
listPropertiesDisplay: string[]
|
||||
/** All wikilink targets found in the note content. Extracted from [[target]] patterns. */
|
||||
outgoingLinks: string[]
|
||||
/** Custom scalar frontmatter properties (non-relationship, non-structural). */
|
||||
@@ -79,7 +81,6 @@ export interface Settings {
|
||||
crash_reporting_enabled: boolean | null
|
||||
analytics_enabled: boolean | null
|
||||
anonymous_id: string | null
|
||||
update_channel: string | null
|
||||
release_channel: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ function makeEntry(overrides: Partial<VaultEntry>): VaultEntry {
|
||||
wordCount: 0, relationships: {}, icon: null, color: null,
|
||||
order: null, sidebarLabel: null, template: null, sort: null, view: null,
|
||||
visible: null, favorite: false, favoriteIndex: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
outgoingLinks: [], properties: {}, listPropertiesDisplay: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
@@ -136,4 +136,169 @@ describe('evaluateView', () => {
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('contains on relationship uses substring match for plain text', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'Monday', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'belongs to', op: 'contains', value: 'Monday' }] },
|
||||
}
|
||||
const entries = [
|
||||
makeEntry({ title: 'A', relationships: { 'belongs to': ['[[Monday Ideas]]'] } }),
|
||||
makeEntry({ title: 'B', relationships: { 'belongs to': ['[[Monday Recap]]'] } }),
|
||||
makeEntry({ title: 'C', relationships: { 'belongs to': ['[[Tuesday Ideas]]'] } }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('not_contains on relationship uses substring match for plain text', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'Not Monday', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'belongs to', op: 'not_contains', value: 'Monday' }] },
|
||||
}
|
||||
const entries = [
|
||||
makeEntry({ title: 'A', relationships: { 'belongs to': ['[[Monday Ideas]]'] } }),
|
||||
makeEntry({ title: 'B', relationships: { 'belongs to': ['[[Tuesday Ideas]]'] } }),
|
||||
makeEntry({ title: 'C', relationships: { 'belongs to': [] } }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['B', 'C'])
|
||||
})
|
||||
|
||||
it('contains on relationship uses exact match for wikilink syntax', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'Exact', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'belongs to', op: 'contains', value: '[[Monday Ideas]]' }] },
|
||||
}
|
||||
const entries = [
|
||||
makeEntry({ title: 'A', relationships: { 'belongs to': ['[[Monday Ideas]]'] } }),
|
||||
makeEntry({ title: 'B', relationships: { 'belongs to': ['[[Monday Recap]]'] } }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['A'])
|
||||
})
|
||||
|
||||
it('any_of / none_of on relationship always use exact stem match', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'Exact list', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'belongs to', op: 'any_of', value: ['[[Monday]]'] }] },
|
||||
}
|
||||
const entries = [
|
||||
makeEntry({ title: 'Exact', relationships: { 'belongs to': ['[[Monday]]'] } }),
|
||||
makeEntry({ title: 'Partial', relationships: { 'belongs to': ['[[Monday Ideas]]'] } }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Exact'])
|
||||
})
|
||||
|
||||
it('before operator works with ISO date strings in properties', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'Before', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'Date', op: 'before', value: '2024-06-01' }] },
|
||||
}
|
||||
const entries = [
|
||||
makeEntry({ title: 'Early', properties: { Date: '2024-03-15' } }),
|
||||
makeEntry({ title: 'Late', properties: { Date: '2024-09-01' } }),
|
||||
makeEntry({ title: 'NoDate', properties: {} }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Early'])
|
||||
})
|
||||
|
||||
it('after operator works with ISO date strings in properties', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'After', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'Date', op: 'after', value: '2024-06-01' }] },
|
||||
}
|
||||
const entries = [
|
||||
makeEntry({ title: 'Early', properties: { Date: '2024-03-15' } }),
|
||||
makeEntry({ title: 'Late', properties: { Date: '2024-09-01' } }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Late'])
|
||||
})
|
||||
|
||||
it('before/after works with ISO datetime strings', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'Before datetime', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'Date', op: 'before', value: '2024-03-15T12:00:00' }] },
|
||||
}
|
||||
const entries = [
|
||||
makeEntry({ title: 'Morning', properties: { Date: '2024-03-15T08:00:00' } }),
|
||||
makeEntry({ title: 'Evening', properties: { Date: '2024-03-15T18:00:00' } }),
|
||||
]
|
||||
const result = evaluateView(view, entries)
|
||||
expect(result.map((e) => e.title)).toEqual(['Morning'])
|
||||
})
|
||||
|
||||
it('before/after works with numeric Unix timestamps', () => {
|
||||
const view: ViewDefinition = {
|
||||
name: 'After ts', icon: null, color: null, sort: null,
|
||||
filters: { all: [{ field: 'Date', op: 'after', value: '2024-01-01' }] },
|
||||
}
|
||||
// Unix timestamp for 2024-06-15 in seconds
|
||||
const ts = Math.floor(new Date('2024-06-15').getTime() / 1000)
|
||||
const entries = [
|
||||
makeEntry({ title: 'Match', properties: { Date: ts } }),
|
||||
]
|
||||
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)
|
||||
@@ -75,7 +76,10 @@ function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean {
|
||||
|
||||
if (resolved.array) {
|
||||
const stem = wikilinkStem(condVal)
|
||||
const arrayMatch = (arr: string[]) => arr.some((item) => wikilinkStem(item) === stem)
|
||||
const isWikilink = condVal.trim().startsWith('[[')
|
||||
const arrayMatch = (arr: string[]) => arr.some((item) =>
|
||||
isWikilink ? wikilinkStem(item) === stem : wikilinkStem(item).includes(stem)
|
||||
)
|
||||
if (op === 'contains') return arrayMatch(resolved.array)
|
||||
if (op === 'not_contains') return !arrayMatch(resolved.array)
|
||||
if (op === 'any_of' && Array.isArray(value)) {
|
||||
@@ -101,11 +105,17 @@ function evaluateCondition(cond: FilterCondition, entry: VaultEntry): boolean {
|
||||
|
||||
// Date comparisons
|
||||
if (op === 'before' || op === 'after') {
|
||||
const ts = typeof resolved.scalar === 'number' ? resolved.scalar : null
|
||||
if (!ts) return false
|
||||
const target = Date.parse(condVal) / 1000
|
||||
let tsMs: number | null = null
|
||||
if (typeof resolved.scalar === 'number') {
|
||||
tsMs = resolved.scalar * 1000 // Unix timestamp (seconds) → milliseconds
|
||||
} else if (typeof resolved.scalar === 'string') {
|
||||
const parsed = Date.parse(resolved.scalar)
|
||||
tsMs = isNaN(parsed) ? null : parsed
|
||||
}
|
||||
if (tsMs == null) return false
|
||||
const target = Date.parse(condVal)
|
||||
if (isNaN(target)) return false
|
||||
return op === 'before' ? ts < target : ts > target
|
||||
return op === 'before' ? tsMs < target : tsMs > target
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
@@ -9,32 +9,34 @@ async function openSettings(page: import('@playwright/test').Page) {
|
||||
return panel
|
||||
}
|
||||
|
||||
test.describe('Canary release channel + feature flags', () => {
|
||||
test.describe('Release channel settings', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('Settings panel shows Update channel dropdown defaulting to Stable', async ({ page }) => {
|
||||
test('Settings panel shows Release channel dropdown defaulting to Stable', async ({ page }) => {
|
||||
await openSettings(page)
|
||||
|
||||
// Check the Updates section exists
|
||||
await expect(page.getByText('Updates')).toBeVisible()
|
||||
await expect(page.getByText('Canary builds include')).toBeVisible()
|
||||
// Check the Release Channel section exists
|
||||
await expect(page.getByText('Release Channel')).toBeVisible()
|
||||
|
||||
// Check the dropdown defaults to stable
|
||||
const select = page.locator('[data-testid="settings-update-channel"]')
|
||||
const select = page.locator('[data-testid="settings-release-channel"]')
|
||||
await expect(select).toBeVisible()
|
||||
await expect(select).toHaveValue('stable')
|
||||
|
||||
// Update channel should NOT be present
|
||||
await expect(page.locator('[data-testid="settings-update-channel"]')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('Update channel can be changed to canary and saved', async ({ page }) => {
|
||||
test('Release channel can be changed to alpha and saved', async ({ page }) => {
|
||||
await openSettings(page)
|
||||
|
||||
// Change to canary
|
||||
const select = page.locator('[data-testid="settings-update-channel"]')
|
||||
await select.selectOption('canary')
|
||||
await expect(select).toHaveValue('canary')
|
||||
// Change to alpha
|
||||
const select = page.locator('[data-testid="settings-release-channel"]')
|
||||
await select.selectOption('alpha')
|
||||
await expect(select).toHaveValue('alpha')
|
||||
|
||||
// Save (closes the panel)
|
||||
await page.click('[data-testid="settings-save"]')
|
||||
@@ -42,7 +44,7 @@ test.describe('Canary release channel + feature flags', () => {
|
||||
|
||||
// Reopen settings and verify the value persisted
|
||||
await openSettings(page)
|
||||
const reopenedSelect = page.locator('[data-testid="settings-update-channel"]')
|
||||
await expect(reopenedSelect).toHaveValue('canary')
|
||||
const reopenedSelect = page.locator('[data-testid="settings-release-channel"]')
|
||||
await expect(reopenedSelect).toHaveValue('alpha')
|
||||
})
|
||||
})
|
||||
|
||||
75
tests/smoke/filter-wikilink-autocomplete.spec.ts
Normal file
75
tests/smoke/filter-wikilink-autocomplete.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
async function openCreateViewDialog(page: Page) {
|
||||
// The VIEWS header has a small + icon. Find the header button containing "VIEWS" text
|
||||
// and the + SVG icon next to it
|
||||
const viewsHeader = page.locator('button:has(span:text("VIEWS"))')
|
||||
await viewsHeader.waitFor({ timeout: 5000 })
|
||||
// The Plus icon is rendered as an SVG inside the same container
|
||||
// Click the SVG child of the VIEWS button container (the + icon)
|
||||
const plusSvg = viewsHeader.locator('svg').last()
|
||||
await plusSvg.click({ force: true })
|
||||
await expect(page.locator('text=Create View')).toBeVisible({ timeout: 5000 })
|
||||
}
|
||||
|
||||
test.describe('Filter wikilink autocomplete', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('typing [[ in filter value field shows wikilink autocomplete', async ({ page }) => {
|
||||
await openCreateViewDialog(page)
|
||||
|
||||
// Default field is 'type' which has valueSuggestions (shows Select, not text input).
|
||||
// Change to 'title' field which has no suggestions → renders WikilinkValueInput.
|
||||
const fieldSelect = page.locator('button:has-text("type")').first()
|
||||
await fieldSelect.click()
|
||||
await page.locator('[role="option"]:has-text("title")').click()
|
||||
|
||||
// The filter value input has data-testid="filter-value-input"
|
||||
const valueInput = page.getByTestId('filter-value-input')
|
||||
await expect(valueInput).toBeVisible()
|
||||
|
||||
// Type [[ without enough chars - dropdown should not appear
|
||||
await valueInput.fill('[[')
|
||||
await expect(page.getByTestId('wikilink-dropdown')).not.toBeVisible()
|
||||
|
||||
// Type enough characters to trigger the dropdown
|
||||
await valueInput.fill('[[un')
|
||||
await expect(page.getByTestId('wikilink-dropdown')).toBeVisible({ timeout: 2000 })
|
||||
|
||||
// Verify dropdown contains note suggestions
|
||||
const dropdownItems = page.locator('.wikilink-menu__item')
|
||||
const count = await dropdownItems.count()
|
||||
expect(count).toBeGreaterThan(0)
|
||||
|
||||
// Click a suggestion to select it
|
||||
const firstItem = dropdownItems.first()
|
||||
const itemText = await firstItem.locator('.wikilink-menu__title').textContent()
|
||||
await firstItem.click()
|
||||
|
||||
// Verify the value was set to [[note-title]]
|
||||
const inputValue = await valueInput.inputValue()
|
||||
expect(inputValue).toMatch(/^\[\[.+\]\]$/)
|
||||
expect(inputValue).toContain(itemText?.trim() ?? '')
|
||||
|
||||
// Verify dropdown closed after selection
|
||||
await expect(page.getByTestId('wikilink-dropdown')).not.toBeVisible()
|
||||
})
|
||||
|
||||
test('plain text in filter value does not trigger autocomplete', async ({ page }) => {
|
||||
await openCreateViewDialog(page)
|
||||
|
||||
// Change field to 'title' (no suggestions → WikilinkValueInput)
|
||||
const fieldSelect = page.locator('button:has-text("type")').first()
|
||||
await fieldSelect.click()
|
||||
await page.locator('[role="option"]:has-text("title")').click()
|
||||
|
||||
const valueInput = page.getByTestId('filter-value-input')
|
||||
await valueInput.fill('some plain text')
|
||||
|
||||
// No dropdown should appear
|
||||
await expect(page.getByTestId('wikilink-dropdown')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -414,9 +414,13 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
|
||||
// Inject the demo-vault-v2 path so browser code resolves it relative to the project root
|
||||
// Inject the demo-vault-v2 path in dev mode only — production Tauri builds must
|
||||
// resolve the default vault path at runtime via the backend to avoid baking
|
||||
// the CI runner's absolute path into the distributed bundle.
|
||||
define: {
|
||||
__DEMO_VAULT_PATH__: JSON.stringify(path.resolve(__dirname, 'demo-vault-v2')),
|
||||
...(process.env.TAURI_PLATFORM && !process.env.TAURI_DEBUG
|
||||
? {}
|
||||
: { __DEMO_VAULT_PATH__: JSON.stringify(path.resolve(__dirname, 'demo-vault-v2')) }),
|
||||
},
|
||||
|
||||
// Prevent vite from obscuring Rust errors
|
||||
|
||||
Reference in New Issue
Block a user