Compare commits

...

17 Commits

Author SHA1 Message Date
Test
c0a64e181b fix: apply rustfmt formatting to vault module
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:32:43 +02:00
Test
fe85d1f679 feat: add getting-started-vault with onboarding content
Minimalist vault separate from demo-vault-v2, designed to introduce
new users to Laputa's key features: types, relationships, views,
wiki-links, and git sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:30:22 +02:00
Test
9db5f5cbed fix: add listPropertiesDisplay to remaining VaultEntry literals
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 13:05:39 +02:00
Test
13c0802395 fix: add listPropertiesDisplay to mock entries for type check
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 12:59:18 +02:00
Test
4c44f8e966 feat: add type-specific property chips in note list
Parse _list_properties_display from type file frontmatter and render
property/relationship values as chips below note snippets. Add a
SlidersHorizontal config popover in the note list header (sectionGroup
views only) with checkboxes and drag-to-reorder via dnd-kit. Changes
are saved back to the type file's frontmatter immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 12:52:54 +02:00
Test
2b135d208e fix: remove Update channel from Settings, keep only Release channel
Update channel (Stable/Canary) was redundant since builds are flat.
Removed the UI dropdown, canary update logic in useUpdater, and
update_channel field from Settings type (TS + Rust). Release channel
(Alpha/Beta/Stable) remains as the sole selector.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 12:11:26 +02:00
Test
076860632c fix: rename SECTIONS to TYPES in sidebar header 2026-04-04 12:02:32 +02:00
Test
520ec504fe fix: enforce priority sort in laputa-next-task (p1 before p3/p4) 2026-04-04 12:00:02 +02:00
Test
cdb3eeea4c fix: update vault entries immediately after view create/delete
After saving a view, the .yml file wasn't appearing in the FOLDERS note
list until a full vault reload. Now we call reload_vault_entry to get a
fresh VaultEntry, add/update it in the entries state, and reload the
folder tree — making the file visible immediately in FOLDERS.

Same for delete: removeEntry + reloadFolders ensures the file disappears
from FOLDERS without requiring a vault reload.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:19:44 +02:00
Test
f68436c2e0 fix: remove Inbox period filter pills, show all notes by default 2026-04-04 11:03:50 +02:00
Test
7c03c50e25 fix: prevent CI runner vault path from leaking into distributed builds
The demo vault path was resolved at build time via Vite's `define` config,
baking the CI runner's absolute path (/Users/runner/work/...) into the
JavaScript bundle. Fresh installs showed "Vault not found" with that path.

Now __DEMO_VAULT_PATH__ is only injected in dev mode. Production Tauri builds
resolve the Getting Started vault path at runtime via get_default_vault_path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:53:29 +02:00
Test
24911b6bb7 fix: remove Quarter filter pill from Inbox, keep Week/Month/All 2026-04-04 10:48:46 +02:00
Test
51914db534 test: add Playwright smoke test for filter wikilink autocomplete
Verifies typing [[ in a view filter value field triggers the wikilink
autocomplete dropdown and that selecting a note inserts [[note-title]].

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:12:21 +02:00
Test
e4ffeeccc0 feat: add wikilink autocomplete on [[ in view filter value field
Typing [[ in a filter value input now shows a note autocomplete dropdown,
matching the same visual style used in the editor and Properties panel.
Selecting a note inserts [[note-title]] as the filter value.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:04:51 +02:00
Test
8ec33b99b5 docs: add mandatory UI components rules to CLAUDE.md — always use shadcn/ui, never raw HTML 2026-04-04 09:56:30 +02:00
Test
638678184e fix: add type="button" to EmojiPicker buttons to prevent form submission
Emoji buttons inside the EmojiPicker defaulted to type="submit", causing
premature form submission when selecting an emoji inside the Create/Edit
View dialog. The form submitted with the stale (empty) icon state before
the emoji selection could update it, resulting in views always saving
with icon: null regardless of the user's emoji choice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 04:59:18 +02:00
Test
62af7a3d6e fix: parse date strings in view filter before/after operators
Date properties are stored as YAML strings (e.g. "2024-03-15"), not
Unix timestamps. Parse string values via Date.parse so before/after
operators work correctly. Also handles numeric Unix timestamps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 04:07:11 +02:00
64 changed files with 1614 additions and 317 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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
View 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

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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).

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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

View 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.

View File

@@ -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]

View File

@@ -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")]

View File

@@ -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.

View File

@@ -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,

View File

@@ -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

View File

@@ -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#"

View File

@@ -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,

View File

@@ -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}

View File

@@ -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 })
)
})
})

View File

@@ -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>

View File

@@ -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)}

View 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')
})
})

View File

@@ -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}

View 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()
})
})

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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(

View File

@@ -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)' }} />

View File

@@ -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

View File

@@ -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"

View File

@@ -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' },
]

View 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>
)
}

View File

@@ -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"

View File

@@ -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) : []

View File

@@ -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({})

View File

@@ -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: [],
}
}

View File

@@ -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 () => {

View File

@@ -17,7 +17,6 @@ const EMPTY_SETTINGS: Settings = {
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
update_channel: null,
release_channel: null,
}

View File

@@ -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', () => {

View File

@@ -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)

View File

@@ -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>

View File

@@ -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 }))

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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'])
})
})

View File

@@ -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

View File

@@ -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')
})
})

View 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()
})
})

View File

@@ -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