Compare commits
17 Commits
v0.2026040
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0a64e181b | ||
|
|
fe85d1f679 | ||
|
|
9db5f5cbed | ||
|
|
13c0802395 | ||
|
|
4c44f8e966 | ||
|
|
2b135d208e | ||
|
|
076860632c | ||
|
|
520ec504fe | ||
|
|
cdb3eeea4c | ||
|
|
f68436c2e0 | ||
|
|
7c03c50e25 | ||
|
|
24911b6bb7 | ||
|
|
51914db534 | ||
|
|
e4ffeeccc0 | ||
|
|
8ec33b99b5 | ||
|
|
638678184e | ||
|
|
62af7a3d6e |
@@ -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)
|
||||
|
||||
|
||||
16
getting-started-vault/.gitignore
vendored
Normal file
16
getting-started-vault/.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Laputa app files (machine-specific, never commit)
|
||||
.laputa/settings.json
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
61
getting-started-vault/getting-started.md
Normal file
61
getting-started-vault/getting-started.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: "Getting Started"
|
||||
type: Note
|
||||
Related to:
|
||||
- "[[What is Laputa]]"
|
||||
- "[[Keyboard Shortcuts]]"
|
||||
- "[[Laputa Onboarding]]"
|
||||
---
|
||||
|
||||
Welcome to your Laputa vault! This note walks you through the key features so you can start building your personal knowledge graph.
|
||||
|
||||
## Editor
|
||||
|
||||
Laputa uses a rich markdown editor. Write in plain markdown with headings, lists, checkboxes, code blocks, and blockquotes. Every note has YAML frontmatter at the top (between `---` delimiters) that stores metadata like type, status, and relationships.
|
||||
|
||||
Wiki-links connect notes together: type `[[` in the editor to search and link to any note in your vault.
|
||||
|
||||
## Types
|
||||
|
||||
Types define the kind of entity a note represents — Note, Project, Person, Topic, Task, or any custom type you create. Each type gets its own icon, color, and sidebar section. To create a new type, add a markdown file with `type: Type` in the frontmatter.
|
||||
|
||||
## Sidebar
|
||||
|
||||
The left sidebar organizes your vault by type. Each type gets its own collapsible section. Special sections include:
|
||||
|
||||
- **Inbox** — notes without a type
|
||||
- **All Notes** — every note in the vault
|
||||
- **Archive** — archived notes
|
||||
- **Trash** — deleted notes (recoverable for 30 days)
|
||||
|
||||
## Properties
|
||||
|
||||
Open the **Inspector** panel (right side) to view and edit a note's properties. Click any value to change it. Use the **+ Add property** button to add custom fields. Properties containing `[[wiki-links]]` become navigable relationships.
|
||||
|
||||
## Relationships
|
||||
|
||||
Connect notes through frontmatter fields like `Belongs to`, `Related to`, and `Has`. These appear as clickable pills in the Inspector. Backlinks are computed automatically — linking A to B makes B show a backlink to A.
|
||||
|
||||
## Views
|
||||
|
||||
Views are saved filters that show a subset of your notes. Create a view to see, for example, all active projects or all tasks belonging to a specific project. Views live in the `views/` folder as YAML files. This vault includes an "Active Projects" view that filters for projects with status Active.
|
||||
|
||||
## Favorites
|
||||
|
||||
Pin frequently used notes to the Favorites section at the top of the sidebar. Toggle a note's favorite status from the Inspector or the command palette.
|
||||
|
||||
## Search
|
||||
|
||||
Press **Cmd+P** to quick-open any note by title. Use the search bar in the sidebar for full-text search across all notes.
|
||||
|
||||
## Command palette
|
||||
|
||||
Press **Cmd+K** to open the command palette. From here you can create notes, switch types, toggle views, and access every action in the app.
|
||||
|
||||
## AI
|
||||
|
||||
Laputa integrates with Claude Code. When Claude Code is running in your vault directory, the status bar shows a green badge. You can use AI to help organize, summarize, and connect your notes.
|
||||
|
||||
## Git sync
|
||||
|
||||
Your vault is a standard git repository. Use the **Changes** view in the sidebar to see modified files, commit changes, and push to a remote. This means your vault works with any git hosting service for backup and sync across devices.
|
||||
37
getting-started-vault/keyboard-shortcuts.md
Normal file
37
getting-started-vault/keyboard-shortcuts.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: "Keyboard Shortcuts"
|
||||
type: Note
|
||||
Related to: "[[Getting Started]]"
|
||||
---
|
||||
|
||||
Essential shortcuts for navigating Laputa.
|
||||
|
||||
## Navigation
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| **Cmd+P** | Quick-open a note by title |
|
||||
| **Cmd+K** | Open the command palette |
|
||||
| **Cmd+N** | Create a new note |
|
||||
| **Cmd+,** | Open settings |
|
||||
| **Cmd+\\** | Toggle sidebar |
|
||||
|
||||
## Editor
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| **Cmd+S** | Save the current note |
|
||||
| **Cmd+B** | Bold |
|
||||
| **Cmd+I** | Italic |
|
||||
| **Cmd+Shift+K** | Insert a code block |
|
||||
| **Cmd+Shift+8** | Toggle bullet list |
|
||||
| **Cmd+Shift+7** | Toggle numbered list |
|
||||
|
||||
## Wiki-links
|
||||
|
||||
Type `[[` in the editor to open the link picker. Start typing to search for a note, then press Enter to insert the link. Links are bidirectional — the linked note will show a backlink to the current note.
|
||||
|
||||
## Tips
|
||||
|
||||
- Use **Cmd+K** when you are not sure where to find something — the command palette lists every available action.
|
||||
- **Cmd+P** is the fastest way to navigate between notes — it searches by title and aliases.
|
||||
9
getting-started-vault/note.md
Normal file
9
getting-started-vault/note.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Note"
|
||||
type: Type
|
||||
icon: note
|
||||
color: blue
|
||||
order: 1
|
||||
---
|
||||
|
||||
A Note is a general-purpose document — research notes, meeting notes, ideas, drafts, or anything that doesn't fit a more specific type.
|
||||
12
getting-started-vault/person-luca-rossi.md
Normal file
12
getting-started-vault/person-luca-rossi.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: "Luca Rossi"
|
||||
type: Person
|
||||
Role: Founder
|
||||
Website: https://refactoring.fm
|
||||
Twitter: https://twitter.com/lucaronin
|
||||
Related to:
|
||||
- "[[Laputa Onboarding]]"
|
||||
- "[[Topic: Personal Knowledge Management]]"
|
||||
---
|
||||
|
||||
Creator of Laputa and founder of [Refactoring](https://refactoring.fm), a newsletter about engineering leadership and software craft. Luca built Laputa to organize his own knowledge — projects, people, ideas, and goals — in a way that stays grounded in plain files and version control.
|
||||
9
getting-started-vault/person.md
Normal file
9
getting-started-vault/person.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Person"
|
||||
type: Type
|
||||
icon: user
|
||||
color: green
|
||||
order: 3
|
||||
---
|
||||
|
||||
A Person represents someone you interact with — a colleague, collaborator, mentor, or friend. People can own projects, attend events, and appear in relationships across your vault.
|
||||
27
getting-started-vault/project-laputa-onboarding.md
Normal file
27
getting-started-vault/project-laputa-onboarding.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: "Laputa Onboarding"
|
||||
type: Project
|
||||
Status: Active
|
||||
Owner: "[[Luca Rossi]]"
|
||||
Has:
|
||||
- "[[Task: Explore the Editor]]"
|
||||
- "[[Task: Create Your First Type]]"
|
||||
- "[[Task: Set Up Git Sync]]"
|
||||
- "[[Task: Try the Command Palette]]"
|
||||
- "[[Task: Create a Custom View]]"
|
||||
Related to: "[[Topic: Getting Started]]"
|
||||
---
|
||||
|
||||
A hands-on project to help you discover Laputa's key features. Work through the tasks below at your own pace — each one introduces a different part of the app.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] [[Task: Explore the Editor]] — Get comfortable with the markdown editor and wiki-links
|
||||
- [ ] [[Task: Create Your First Type]] — Define a custom type with icon and color
|
||||
- [ ] [[Task: Set Up Git Sync]] — Connect your vault to a git remote for backup
|
||||
- [ ] [[Task: Try the Command Palette]] — Discover actions with Cmd+K
|
||||
- [ ] [[Task: Create a Custom View]] — Build a filtered view of your notes
|
||||
|
||||
## When you are done
|
||||
|
||||
Once you have completed these tasks, you will have a solid understanding of how Laputa works. From here, start creating your own types, notes, and views to build a knowledge graph that fits your workflow.
|
||||
9
getting-started-vault/project.md
Normal file
9
getting-started-vault/project.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Project"
|
||||
type: Type
|
||||
icon: rocket-launch
|
||||
color: purple
|
||||
order: 4
|
||||
---
|
||||
|
||||
A Project is a time-bounded effort with a clear goal and an eventual completion date. Projects can contain tasks, belong to areas, and link to people and topics.
|
||||
39
getting-started-vault/task-create-a-custom-view.md
Normal file
39
getting-started-vault/task-create-a-custom-view.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: "Task: Create a Custom View"
|
||||
type: Task
|
||||
Status: Open
|
||||
Belongs to: "[[Laputa Onboarding]]"
|
||||
Related to: "[[Laputa Onboarding]]"
|
||||
---
|
||||
|
||||
Views are saved filters that show a subset of your notes. This vault includes an example view — "Active Projects" — that shows all projects with status Active.
|
||||
|
||||
## What to try
|
||||
|
||||
1. Open the command palette (Cmd+K) and look for a "New View" action, or create a `.yml` file in the `views/` folder
|
||||
2. Define filters — for example, show all tasks with status Open
|
||||
3. Set a name, icon, and color for the view
|
||||
4. Save — the view appears in the sidebar under Views
|
||||
|
||||
## View format
|
||||
|
||||
Views are YAML files in `views/`. Here is an example:
|
||||
|
||||
```yaml
|
||||
name: Open Tasks
|
||||
icon: check-square
|
||||
color: orange
|
||||
sort: "modified:desc"
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Task
|
||||
- field: Status
|
||||
op: equals
|
||||
value: Open
|
||||
```
|
||||
|
||||
## Tip
|
||||
|
||||
Views update automatically as you add or change notes. They are a powerful way to create dashboards for your projects, areas, or any slice of your vault.
|
||||
30
getting-started-vault/task-create-your-first-type.md
Normal file
30
getting-started-vault/task-create-your-first-type.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: "Task: Create Your First Type"
|
||||
type: Task
|
||||
Status: Open
|
||||
Belongs to: "[[Laputa Onboarding]]"
|
||||
---
|
||||
|
||||
Types give structure to your vault. Every note has a type — Project, Person, Topic, or any custom type you define.
|
||||
|
||||
## What to try
|
||||
|
||||
1. Create a new markdown file (Cmd+N)
|
||||
2. In the frontmatter, set `type: Type`
|
||||
3. Add an `icon:` field — use any [Phosphor icon](https://phosphoricons.com) name in kebab-case (e.g. `book-open`, `lightning`, `house`)
|
||||
4. Add a `color:` field — available colors: red, purple, blue, green, yellow, orange
|
||||
5. Give it a title with a `# Heading` and a short description in the body
|
||||
6. Save the file — your new type appears in the sidebar
|
||||
|
||||
## Example
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Type
|
||||
icon: book-open
|
||||
color: red
|
||||
order: 6
|
||||
---
|
||||
```
|
||||
|
||||
The `order` field controls position in the sidebar (lower = higher).
|
||||
21
getting-started-vault/task-explore-the-editor.md
Normal file
21
getting-started-vault/task-explore-the-editor.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: "Task: Explore the Editor"
|
||||
type: Task
|
||||
Status: Open
|
||||
Belongs to: "[[Laputa Onboarding]]"
|
||||
---
|
||||
|
||||
Get comfortable with Laputa's markdown editor.
|
||||
|
||||
## What to try
|
||||
|
||||
1. **Headings** — Type `#`, `##`, or `###` followed by a space to create headings
|
||||
2. **Lists** — Use `-` for bullet lists and `1.` for numbered lists
|
||||
3. **Checkboxes** — Type `- [ ]` for a checkbox, click to toggle it
|
||||
4. **Formatting** — Try **bold** (Cmd+B), *italic* (Cmd+I), and `inline code`
|
||||
5. **Wiki-links** — Type `[[` and start typing a note name to insert a link. Try linking to [[Getting Started]]
|
||||
6. **Code blocks** — Use triple backticks or Cmd+Shift+K
|
||||
|
||||
## Tip
|
||||
|
||||
The editor saves automatically. You can also press **Cmd+S** to save explicitly.
|
||||
27
getting-started-vault/task-set-up-git-sync.md
Normal file
27
getting-started-vault/task-set-up-git-sync.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: "Task: Set Up Git Sync"
|
||||
type: Task
|
||||
Status: Open
|
||||
Belongs to: "[[Laputa Onboarding]]"
|
||||
---
|
||||
|
||||
Your vault is a git repository. Connecting it to a remote lets you back up your notes and sync across devices.
|
||||
|
||||
## What to try
|
||||
|
||||
1. Create a new repository on GitHub, GitLab, or any git host
|
||||
2. Open a terminal in your vault directory
|
||||
3. Add the remote: `git remote add origin <your-repo-url>`
|
||||
4. Push your vault: `git push -u origin main`
|
||||
|
||||
## Using the Changes view
|
||||
|
||||
Laputa has a built-in Changes view in the sidebar that shows modified files. From there you can:
|
||||
|
||||
- See which files have changed since the last commit
|
||||
- Commit changes with a message
|
||||
- Push to the remote
|
||||
|
||||
## Tip
|
||||
|
||||
Git sync means you can edit your vault from any device — even a plain text editor on a computer that does not have Laputa installed. The next time you open Laputa, pull the latest changes and everything updates.
|
||||
26
getting-started-vault/task-try-the-command-palette.md
Normal file
26
getting-started-vault/task-try-the-command-palette.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: "Task: Try the Command Palette"
|
||||
type: Task
|
||||
Status: Open
|
||||
Belongs to: "[[Laputa Onboarding]]"
|
||||
---
|
||||
|
||||
The command palette is the fastest way to do anything in Laputa.
|
||||
|
||||
## What to try
|
||||
|
||||
1. Press **Cmd+K** to open the command palette
|
||||
2. Type to filter the list of available actions
|
||||
3. Try these commands:
|
||||
- **New Note** — create a note
|
||||
- **Toggle Sidebar** — show or hide the sidebar
|
||||
- **Open Settings** — jump to app settings
|
||||
4. Press **Escape** to close the palette
|
||||
|
||||
## Quick open
|
||||
|
||||
**Cmd+P** opens a separate quick-open dialog that searches notes by title and aliases. Use it when you know which note you want to reach.
|
||||
|
||||
## Tip
|
||||
|
||||
If you forget a shortcut, Cmd+K shows it. The command palette lists keyboard shortcuts next to each action.
|
||||
9
getting-started-vault/task.md
Normal file
9
getting-started-vault/task.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Task"
|
||||
type: Type
|
||||
icon: check-square
|
||||
color: orange
|
||||
order: 5
|
||||
---
|
||||
|
||||
A Task is a discrete, actionable item that can be completed. Tasks belong to projects and represent the smallest unit of work in your vault.
|
||||
10
getting-started-vault/topic-getting-started.md
Normal file
10
getting-started-vault/topic-getting-started.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: "Topic: Getting Started"
|
||||
type: Topic
|
||||
Related to:
|
||||
- "[[Getting Started]]"
|
||||
- "[[What is Laputa]]"
|
||||
- "[[Keyboard Shortcuts]]"
|
||||
---
|
||||
|
||||
This topic groups all notes related to learning Laputa. Start with [[Getting Started]] for a full overview, then explore [[What is Laputa]] for the philosophy behind the app and [[Keyboard Shortcuts]] for quick navigation.
|
||||
16
getting-started-vault/topic-personal-knowledge-management.md
Normal file
16
getting-started-vault/topic-personal-knowledge-management.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: "Topic: Personal Knowledge Management"
|
||||
type: Topic
|
||||
Related to: "[[What is Laputa]]"
|
||||
---
|
||||
|
||||
Personal Knowledge Management (PKM) is the practice of collecting, organizing, and connecting the information you encounter — notes, ideas, references, projects, and people — so it becomes a durable personal asset rather than a list of forgotten bookmarks.
|
||||
|
||||
Laputa is designed as a PKM tool. Unlike traditional note-taking apps, it treats your notes as a graph of interconnected entities. Types give structure, wiki-links create connections, and views let you slice through the graph from different angles.
|
||||
|
||||
## Core ideas
|
||||
|
||||
- **Write things down** — Capture ideas, meeting notes, and references as they happen. A note that exists is infinitely more useful than a thought you forgot.
|
||||
- **Connect everything** — Use `[[wiki-links]]` to link related notes. Over time, these connections reveal patterns you did not plan.
|
||||
- **Use types** — Give notes a type (Project, Person, Topic) to add structure without rigid hierarchies.
|
||||
- **Review regularly** — Use views and favorites to surface what matters. A vault that is never revisited is just a graveyard of text files.
|
||||
9
getting-started-vault/topic.md
Normal file
9
getting-started-vault/topic.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Topic"
|
||||
type: Type
|
||||
icon: tag
|
||||
color: yellow
|
||||
order: 2
|
||||
---
|
||||
|
||||
A Topic is a subject area or interest category. Topics group related notes, projects, and people by theme — use them to organize knowledge across your vault.
|
||||
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
|
||||
25
getting-started-vault/what-is-laputa.md
Normal file
25
getting-started-vault/what-is-laputa.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: "What is Laputa"
|
||||
type: Note
|
||||
Related to:
|
||||
- "[[Getting Started]]"
|
||||
- "[[Topic: Personal Knowledge Management]]"
|
||||
---
|
||||
|
||||
Laputa is a local-first knowledge management app built on three principles:
|
||||
|
||||
## Your files, your data
|
||||
|
||||
Every note is a plain markdown file on your filesystem. There is no proprietary database, no cloud lock-in, no import/export ceremony. Your vault is a folder — you can open it in any text editor, back it up however you want, and it will outlive any app.
|
||||
|
||||
## The filesystem is the truth
|
||||
|
||||
Laputa reads your files and derives everything from them. Types, relationships, views, and sidebar sections are all computed from the frontmatter and folder structure. There is no hidden state. If you move a file in Finder, Laputa reflects the change. If you edit a file in VS Code, Laputa picks it up.
|
||||
|
||||
## Git for sync and history
|
||||
|
||||
Your vault is a git repository. Every change is a commit. Sync between devices by pushing and pulling to a remote. Resolve conflicts with standard git tools. Your entire edit history is preserved.
|
||||
|
||||
## How it fits together
|
||||
|
||||
A Laputa vault is a collection of markdown files with YAML frontmatter. The `type` field in the frontmatter determines what kind of entity the note represents. Wiki-links (`[[Note Title]]`) create a web of connections between notes. The app provides a visual interface on top of these files — a rich editor, sidebar navigation, inspector panel, and filtered views — but the files remain the source of truth.
|
||||
@@ -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
|
||||
|
||||
@@ -714,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,
|
||||
|
||||
19
src/App.tsx
19
src/App.tsx
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { VaultEntry } from './types'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { NoteList } from './components/NoteList'
|
||||
import { Editor } from './components/Editor'
|
||||
@@ -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')
|
||||
@@ -345,6 +346,13 @@ function App() {
|
||||
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition })
|
||||
trackEvent(editing ? 'view_updated' : 'view_created')
|
||||
await vault.reloadViews()
|
||||
// Update vault entries so the .yml file appears in FOLDERS immediately
|
||||
const filePath = resolvedPath + '/views/' + filename
|
||||
try {
|
||||
const entry = await target<VaultEntry>('reload_vault_entry', { path: filePath })
|
||||
if (editing) { vault.updateEntry(filePath, entry) } else { vault.addEntry(entry) }
|
||||
} catch { /* non-critical — will appear after next vault reload */ }
|
||||
vault.reloadFolders()
|
||||
setToastMessage(editing ? `View "${definition.name}" updated` : `View "${definition.name}" created`)
|
||||
handleSetSelection({ kind: 'view', filename })
|
||||
}, [resolvedPath, vault, handleSetSelection, dialogs.editingView])
|
||||
@@ -358,6 +366,9 @@ function App() {
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
|
||||
await vault.reloadViews()
|
||||
// Remove the .yml file from vault entries so it disappears from FOLDERS immediately
|
||||
vault.removeEntry(resolvedPath + '/views/' + filename)
|
||||
vault.reloadFolders()
|
||||
if (selection.kind === 'view' && selection.filename === filename) {
|
||||
handleSetSelection({ kind: 'filter', filter: 'all' })
|
||||
}
|
||||
@@ -401,7 +412,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') {
|
||||
@@ -564,7 +575,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} views={vault.views} />
|
||||
)}
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleNoteListResize} />
|
||||
@@ -632,7 +643,7 @@ function App() {
|
||||
<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={handleCreateOrUpdateView} availableFields={availableFields} valueSuggestions={valueSuggestionsForField} editingView={dialogs.editingView?.definition ?? null} />
|
||||
<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}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { CreateViewDialog } from './CreateViewDialog'
|
||||
import type { ViewDefinition } from '../types'
|
||||
@@ -42,4 +42,51 @@ describe('CreateViewDialog', () => {
|
||||
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,11 +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, editingView }: 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)
|
||||
@@ -106,6 +108,7 @@ export function CreateViewDialog({ open, onClose, onCreate, availableFields, val
|
||||
onChange={setFilters}
|
||||
availableFields={availableFields}
|
||||
valueSuggestions={valueSuggestions}
|
||||
entries={entries}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
201
src/components/FilterBuilder.test.tsx
Normal file
201
src/components/FilterBuilder.test.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState, useRef, useMemo, useEffect, useCallback } from 'react'
|
||||
import { Plus, X } from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
|
||||
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' },
|
||||
@@ -80,10 +84,197 @@ function OperatorSelect({ value, onChange }: {
|
||||
)
|
||||
}
|
||||
|
||||
function ValueInput({ value, suggestions, isDateOp, onChange }: {
|
||||
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
|
||||
entries: VaultEntry[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
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 (
|
||||
<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={handleChange}
|
||||
onFocus={() => { if (value.startsWith('[[')) setOpen(true) }}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid="filter-value-input"
|
||||
/>
|
||||
{open && matches.length > 0 && (
|
||||
<WikilinkDropdown
|
||||
matches={matches}
|
||||
selectedIndex={selectedIndex}
|
||||
onSelect={handleSelect}
|
||||
onHover={setSelectedIndex}
|
||||
menuRef={menuRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ValueInput({ value, suggestions, isDateOp, entries, onChange }: {
|
||||
value: string
|
||||
suggestions: string[]
|
||||
isDateOp: boolean
|
||||
entries: VaultEntry[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
if (isDateOp) {
|
||||
@@ -118,6 +309,10 @@ function ValueInput({ value, suggestions, isDateOp, onChange }: {
|
||||
)
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
return <WikilinkValueInput value={value} entries={entries} onChange={onChange} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
@@ -128,9 +323,10 @@ function ValueInput({ value, suggestions, isDateOp, onChange }: {
|
||||
)
|
||||
}
|
||||
|
||||
function FilterRow({ condition, fields, valueSuggestions, onUpdate, onRemove }: {
|
||||
function FilterRow({ condition, fields, entries, valueSuggestions, onUpdate, onRemove }: {
|
||||
condition: FilterCondition
|
||||
fields: string[]
|
||||
entries: VaultEntry[]
|
||||
valueSuggestions: (field: string) => string[]
|
||||
onUpdate: (c: FilterCondition) => void
|
||||
onRemove: () => void
|
||||
@@ -153,6 +349,7 @@ function FilterRow({ condition, fields, valueSuggestions, onUpdate, onRemove }:
|
||||
value={String(condition.value ?? '')}
|
||||
suggestions={suggestions}
|
||||
isDateOp={isDateOp}
|
||||
entries={entries}
|
||||
onChange={(v) => onUpdate({ ...condition, value: v })}
|
||||
/>
|
||||
)}
|
||||
@@ -170,9 +367,10 @@ function FilterRow({ condition, fields, valueSuggestions, onUpdate, onRemove }:
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -241,6 +439,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)}
|
||||
@@ -251,6 +450,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)}
|
||||
@@ -276,16 +476,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}
|
||||
|
||||
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)'
|
||||
@@ -153,6 +207,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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useCallback, useEffect, 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'
|
||||
@@ -44,7 +43,7 @@ interface NoteListProps {
|
||||
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, views }: NoteListProps) {
|
||||
const { modifiedPathSet, modifiedSuffixes, resolvedGetNoteStatus } = useModifiedFilesState(modifiedFiles, getNoteStatus)
|
||||
|
||||
const isSectionGroup = selection.kind === 'sectionGroup'
|
||||
@@ -59,11 +58,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())
|
||||
@@ -106,7 +100,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,7 +111,6 @@ 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} />
|
||||
|
||||
@@ -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)' }} />
|
||||
|
||||
@@ -960,11 +960,11 @@ describe('Sidebar', () => {
|
||||
})
|
||||
|
||||
describe('group separators', () => {
|
||||
it('SECTIONS header and its entries share the same border-b container (no separator inside group)', () => {
|
||||
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('SECTIONS')
|
||||
const sectionsHeader = screen.getByText('TYPES')
|
||||
const projectsSection = screen.getByText('Projects')
|
||||
// Walk up from SECTIONS header to find the border-b container
|
||||
// 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
|
||||
|
||||
@@ -224,10 +224,10 @@ function FavoritesSection({ entries, selection, onSelect, onSelectNote, onReorde
|
||||
if (favorites.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ padding: '4px 6px' }}>
|
||||
<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">
|
||||
@@ -431,10 +431,10 @@ export const Sidebar = memo(function Sidebar({
|
||||
|
||||
{/* Views */}
|
||||
{hasViews && (
|
||||
<div className="border-b border-border" style={{ padding: '4px 6px' }}>
|
||||
<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">
|
||||
@@ -489,15 +489,15 @@ export const Sidebar = memo(function Sidebar({
|
||||
|
||||
{/* Sections header + entries */}
|
||||
<div className="border-b border-border">
|
||||
<div ref={customizeRef} style={{ position: 'relative', padding: '4px 6px' }}>
|
||||
<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: '6px 14px 6px 16px' }}
|
||||
style={{ padding: '8px 14px 8px 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>
|
||||
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>TYPES</span>
|
||||
</div>
|
||||
<span
|
||||
role="button"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) : []
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -210,7 +209,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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -190,4 +190,58 @@ describe('evaluateView', () => {
|
||||
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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -104,11 +104,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