Compare commits
69 Commits
v0.2026040
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0a64e181b | ||
|
|
fe85d1f679 | ||
|
|
9db5f5cbed | ||
|
|
13c0802395 | ||
|
|
4c44f8e966 | ||
|
|
2b135d208e | ||
|
|
076860632c | ||
|
|
520ec504fe | ||
|
|
cdb3eeea4c | ||
|
|
f68436c2e0 | ||
|
|
7c03c50e25 | ||
|
|
24911b6bb7 | ||
|
|
51914db534 | ||
|
|
e4ffeeccc0 | ||
|
|
8ec33b99b5 | ||
|
|
638678184e | ||
|
|
62af7a3d6e | ||
|
|
ef9f3ec21f | ||
|
|
0d91e29e82 | ||
|
|
77d59e3ee0 | ||
|
|
f37de38d21 | ||
|
|
51ce27f781 | ||
|
|
4a48833dbc | ||
|
|
d21eef8aa6 | ||
|
|
13f503691e | ||
|
|
17a327173b | ||
|
|
6eb4963952 | ||
|
|
b51b464605 | ||
|
|
a3b5b2244e | ||
|
|
38f83b55e0 | ||
|
|
a5652b3f4d | ||
|
|
56faffdcc8 | ||
|
|
c01f340851 | ||
|
|
a0fc97e5cf | ||
|
|
3172425a16 | ||
|
|
9b93a17cec | ||
|
|
aec4e9e992 | ||
|
|
1bca32a263 | ||
|
|
d5c3e1858e | ||
|
|
ab3de7eecd | ||
|
|
188cd7af8b | ||
|
|
248ec02dee | ||
|
|
9f2bd669fe | ||
|
|
b786b2a4cb | ||
|
|
83dad79692 | ||
|
|
e7ea808f2b | ||
|
|
96df0e6796 | ||
|
|
2bec65a445 | ||
|
|
4fe6f15aa1 | ||
|
|
a093ff4631 | ||
|
|
3ed1fb4ec4 | ||
|
|
7ed0787990 | ||
|
|
56ddaba105 | ||
|
|
6e5652487d | ||
|
|
dab5f3bc41 | ||
|
|
c662b541c7 | ||
|
|
b467fd8446 | ||
|
|
4d6f713a59 | ||
|
|
284af17483 | ||
|
|
2a21dc4b08 | ||
|
|
6946aad139 | ||
|
|
4ae07446a8 | ||
|
|
88a7926a48 | ||
|
|
fd642889c8 | ||
|
|
e2d0a46608 | ||
|
|
990470e5b4 | ||
|
|
33db64822d | ||
|
|
931fed879b | ||
|
|
f71e899b90 |
@@ -1,6 +1,6 @@
|
||||
# /laputa-done <task_id>
|
||||
|
||||
Mark a Laputa task as done: add completion comment, move to In Review, notify Brian, then self-dispatch the next task.
|
||||
Mark a Laputa task as done: add completion comment, move to In Review, then self-dispatch the next task.
|
||||
|
||||
Run this after Phase 1 (Playwright) and Phase 2 (native app QA) both pass **and `git push origin main` has succeeded**.
|
||||
|
||||
@@ -35,16 +35,8 @@ curl -s -X POST "https://api.todoist.com/api/v1/tasks/$ARGUMENTS/move" \
|
||||
-d '{"section_id": "6g3XjX33FF4Vj86M"}'
|
||||
```
|
||||
|
||||
**3. Notify Luca (informational only — no action needed from him)**
|
||||
|
||||
```bash
|
||||
openclaw system event --text "laputa-task-done:$ARGUMENTS" --mode now
|
||||
```
|
||||
|
||||
This is a passive notification. Luca reviews tasks in his own time. Do NOT wait for approval — proceed immediately to step 4.
|
||||
|
||||
**4. Pick the next task**
|
||||
**3. Pick the next task**
|
||||
|
||||
Run `/laputa-next-task` to get the next task and start working on it immediately.
|
||||
|
||||
If `/laputa-next-task` returns `NO_TASKS` → exit cleanly. The hourly watchdog will restart you when new tasks arrive.
|
||||
If there are no tasks, `/laputa-next-task` will wait 10 minutes and retry automatically. Do NOT exit — stay alive and let it loop.
|
||||
|
||||
@@ -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
|
||||
@@ -40,4 +44,17 @@ curl -s "https://api.todoist.com/api/v1/comments?task_id=<task_id>" \
|
||||
6. For To Rework tasks: read the ❌ QA failed comment — it tells you exactly what to fix
|
||||
7. Output: task ID, title, and full description so you can start working immediately
|
||||
|
||||
If no tasks are available in either section → output `NO_TASKS` and exit cleanly.
|
||||
If no tasks are available in either section → wait 10 minutes and try again (loop forever):
|
||||
|
||||
```bash
|
||||
while true; do
|
||||
# ... check tasks ...
|
||||
if no_tasks; then
|
||||
sleep 600 # 10 minutes
|
||||
else
|
||||
break # got a task, proceed
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
Do NOT exit when there are no tasks. Keep looping until a task appears. This keeps Claude Code alive permanently — the watchdog is a safety net only, not the primary dispatcher.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.6
|
||||
AVERAGE_THRESHOLD=9.37
|
||||
HOTSPOT_THRESHOLD=9.56
|
||||
AVERAGE_THRESHOLD=9.33
|
||||
|
||||
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# Copy to .env.local and fill in real values
|
||||
# These are never committed — .env.local is gitignored
|
||||
|
||||
# Sentry DSN (https://sentry.io → Project → Settings → Client Keys)
|
||||
VITE_SENTRY_DSN=
|
||||
|
||||
# PostHog (https://posthog.com → Project → Settings → Project API Key)
|
||||
VITE_POSTHOG_KEY=
|
||||
VITE_POSTHOG_HOST=https://eu.i.posthog.com
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -67,3 +67,7 @@ CODE-HEALTH-REPORT.md
|
||||
# Tauri signing keys (never commit private keys)
|
||||
*.key
|
||||
*.key.pub
|
||||
|
||||
# Local environment variables (never commit)
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
21
CLAUDE.md
21
CLAUDE.md
@@ -107,6 +107,27 @@ Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never co
|
||||
2. Design in light mode. Create `design/<slug>.pen` for the task
|
||||
3. On completion: merge frames into `ui-design.pen`, delete `design/<slug>.pen`
|
||||
|
||||
### UI components — mandatory rules
|
||||
|
||||
**Always use shadcn/ui components.** Never use raw HTML form elements (`<input>`, `<select>`, `<button>`, native `<input type="date">`, etc.) for user-facing UI. Every interactive element must use the shadcn/ui equivalent:
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| Text input | `Input` from shadcn/ui |
|
||||
| Dropdown/select | `Select` from shadcn/ui |
|
||||
| Date picker | `Calendar` + `Popover` from shadcn/ui (NOT native `<input type="date">`) |
|
||||
| Button | `Button` from shadcn/ui |
|
||||
| Autocomplete/combobox | Reuse existing combobox components from the app (check `src/components/`) |
|
||||
| Wikilink picker | Reuse the wikilink autocomplete component already used in the editor and Properties panel |
|
||||
| Emoji picker | Reuse the emoji picker component already used for note/type icons |
|
||||
| Color picker | Reuse the color swatch picker used for type customization |
|
||||
| Toggle/switch | `Switch` or `ToggleGroup` from shadcn/ui |
|
||||
| Dialog/modal | `Dialog` from shadcn/ui |
|
||||
|
||||
**When in doubt:** search `src/components/` for an existing component that does what you need before building a new one. The app already has many reusable pieces — use them.
|
||||
|
||||
**Visual language:** all new UI must feel native to Laputa. Take inspiration from `ui-design.pen` and existing components. If something looks like a browser default, it's wrong.
|
||||
|
||||
---
|
||||
|
||||
## 4. Reference
|
||||
|
||||
@@ -551,15 +551,11 @@ Managed by `useSettings` hook and `SettingsPanel` component.
|
||||
|
||||
---
|
||||
|
||||
## Update Channels & Feature Flags
|
||||
|
||||
### Settings
|
||||
- **`update_channel`** — `"stable"` (default/null) or `"canary"`. Stored in `Settings` struct, configurable in Settings panel under "Updates" section.
|
||||
## Updates & Feature Flags
|
||||
|
||||
### Hooks
|
||||
- **`useUpdater(channel?)`** — Checks for updates. For stable: uses Tauri updater plugin. For canary: fetches `latest-canary.json` and opens release page for download.
|
||||
- **`useUpdater()`** — Checks for updates using the Tauri updater plugin. Automatic download and install.
|
||||
- **`useFeatureFlag(flag)`** — Returns boolean for a named feature flag. Checks `localStorage` override (`ff_<name>`), then falls back to compile-time default. Type-safe via `FeatureFlagName` union.
|
||||
|
||||
### CI/CD
|
||||
- **`.github/workflows/release.yml`** — Stable builds from `main`. Produces `latest.json` on GitHub Pages.
|
||||
- **`.github/workflows/release-canary.yml`** — Canary builds from `canary` branch. Produces `latest-canary.json` on GitHub Pages. Releases are marked as prerelease.
|
||||
|
||||
@@ -798,37 +798,25 @@ sequenceDiagram
|
||||
|
||||
**Architecture:**
|
||||
- **Rust:** `sentry` crate initialized in `lib.rs::setup()` via `telemetry::init_sentry_from_settings()`
|
||||
- **JS:** `@sentry/browser` + `posthog-js` initialized lazily by `useTelemetry` hook
|
||||
- **JS:** `@sentry/react` + `posthog-js` initialized lazily by `useTelemetry` hook
|
||||
- **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
|
||||
- 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
|
||||
|
||||
```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)"]
|
||||
```
|
||||
### Feature Flags (PostHog + Release Channels)
|
||||
|
||||
**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`
|
||||
Feature flags are backed by PostHog and evaluated per release channel:
|
||||
|
||||
### Feature Flags (Local V1)
|
||||
|
||||
Feature flags use a local-only system with no external dependencies:
|
||||
- **Alpha**: all features always enabled (no PostHog lookup)
|
||||
- **Beta**: sees features where the PostHog flag targets `release_channel = beta`
|
||||
- **Stable** (default): sees features where the flag targets `release_channel = stable`
|
||||
|
||||
```typescript
|
||||
import { useFeatureFlag } from './hooks/useFeatureFlag'
|
||||
@@ -838,18 +826,14 @@ const enabled = useFeatureFlag('example_flag') // boolean
|
||||
|
||||
**Resolution order:**
|
||||
1. `localStorage` override: key `ff_<name>` with value `"true"` or `"false"`
|
||||
2. Compile-time default in `FLAG_DEFAULTS` map
|
||||
2. `isFeatureEnabled(flag)` in `telemetry.ts` → checks release channel, then PostHog, then hardcoded defaults
|
||||
|
||||
**How to add a new flag:**
|
||||
1. Add the flag name to the `FeatureFlagName` union type in `src/hooks/useFeatureFlag.ts`
|
||||
2. Set its default in the `FLAG_DEFAULTS` record
|
||||
2. Create the flag on PostHog dashboard with rollout rules per channel
|
||||
3. Use `useFeatureFlag('your_flag')` in components
|
||||
|
||||
**Design decisions:**
|
||||
- No remote fetching, no PostHog dependency — zero privacy concerns
|
||||
- `localStorage` overrides allow dev/QA testing without rebuilding
|
||||
- Type-safe flag names via TypeScript union type
|
||||
- API surface is compatible with future migration to remote flags
|
||||
Release channel is selectable in Settings (alpha / beta / stable) and passed to PostHog as a person property via `identify()`. See ADR-0042.
|
||||
|
||||
## Platform Support — iOS / iPadOS (Prototype)
|
||||
|
||||
|
||||
@@ -27,3 +27,16 @@ As Laputa added more features that store configuration in note frontmatter (pinn
|
||||
- Power users can still access and edit system properties via the raw editor.
|
||||
- Type documents use `_icon`, `_color`, `_order`, `_sidebar_label`, `_pinned_properties`.
|
||||
- Re-evaluation trigger: if the number of system properties grows large enough to warrant a structured sub-object.
|
||||
|
||||
## Normalized system properties
|
||||
|
||||
| Canonical key | Old keys (read with fallback) | Written by |
|
||||
|---|---|---|
|
||||
| `_archived` | `Archived`, `archived` | Archive action |
|
||||
| `_trashed` | `Trashed`, `trashed` | Trash action |
|
||||
| `_trashed_at` | `Trashed at`, `trashed_at` | Trash action |
|
||||
| `_favorite` | — | Favorite toggle |
|
||||
| `_favorite_index` | — | Favorite reorder |
|
||||
|
||||
**Write rule**: always use the canonical `_`-prefixed key.
|
||||
**Read rule**: accept both canonical and legacy keys (case-insensitive). Do NOT rewrite on read — migration is a separate concern.
|
||||
|
||||
32
docs/adr/0038-frontmatter-backed-favorites.md
Normal file
32
docs/adr/0038-frontmatter-backed-favorites.md
Normal file
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0038"
|
||||
title: "Frontmatter-backed favorites with _favorite and _favorite_index"
|
||||
status: active
|
||||
date: 2026-04-02
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Users want to pin frequently-accessed notes to a dedicated FAVORITES section in the sidebar for quick navigation. The app needs a persistence mechanism for which notes are favorited and their display order.
|
||||
|
||||
## Decision
|
||||
|
||||
**Favorites are stored as two system properties in each note's YAML frontmatter: `_favorite: true` and `_favorite_index: <integer>`.**
|
||||
|
||||
- `_favorite`: boolean. Present and `true` = favorited. Absent = not favorited. Toggling off deletes the key entirely (no `_favorite: false`).
|
||||
- `_favorite_index`: integer. Controls display order in the FAVORITES sidebar section (lower = higher). Assigned automatically on favorite, updated on drag-to-reorder.
|
||||
- Both use the `_` prefix convention (ADR 0008) — they are system-owned and hidden from the Properties panel.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Frontmatter per-note (chosen)**: Each note carries its own favorite state. Portable across devices (synced via git). No separate metadata file. Cons: two extra frontmatter writes on reorder.
|
||||
- **Separate `.laputa/favorites.json` file**: Central list of favorite paths. Simpler reorder (one file write). Cons: not portable if `.laputa/` is gitignored; path references break on rename.
|
||||
- **SQLite/app-level metadata**: Fast queries. Cons: not synced via git; diverges from frontmatter-first data model established in ADR 0008.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Favorites survive vault sync via git — any client that reads frontmatter sees them.
|
||||
- Reorder writes `_favorite_index` to N files (one per affected note). Acceptable for typical favorites lists (< 20 items).
|
||||
- If `_favorite: true` exists but `_favorite_index` is absent, the note is appended to the end of the list.
|
||||
- Re-evaluate if favorites list exceeds ~50 items and reorder writes become a performance concern.
|
||||
40
docs/adr/0039-git-history-for-note-dates.md
Normal file
40
docs/adr/0039-git-history-for-note-dates.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0039"
|
||||
title: "Use git history for note creation and modification dates"
|
||||
status: active
|
||||
date: 2026-04-02
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Filesystem metadata (`ctime`/`mtime`) is unreliable for a git-backed vault. After `git clone`, `git pull`, or iCloud sync, files appear "newly created" even when they have years of history. This causes incorrect sort ordering in the note list and wrong dates in the inspector panel.
|
||||
|
||||
## Decision
|
||||
|
||||
**Use `git log` to determine the true creation and modification dates for notes.** A single batch `git log --format="COMMIT %aI" --name-only` command walks the full commit history and extracts:
|
||||
|
||||
- **modified_at** = author date of the most recent commit that touched the file
|
||||
- **created_at** = author date of the oldest commit that touched the file
|
||||
|
||||
The batch approach runs once per vault scan (not per-file), parsing the log output in a single linear pass. Results are stored in a `HashMap<String, GitDates>` keyed by vault-relative path and threaded through the existing `parse_md_file` / `scan_vault` / `scan_vault_cached` pipeline.
|
||||
|
||||
### Fallback to filesystem dates
|
||||
|
||||
- **Non-git vaults** (no `.git` directory): all notes use filesystem `mtime`/`ctime`.
|
||||
- **Uncommitted new files**: not in git log output, so filesystem dates are used automatically.
|
||||
- **Single-file reloads** (`reload_entry`): use filesystem dates since the file was just saved and the most accurate timestamp is the filesystem one.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Per-file `git log`**: Correct but O(n) subprocesses. Too slow for vaults with 500+ notes.
|
||||
- **Frontmatter dates** (e.g., `created: 2025-01-15`): Requires user discipline. Not automatic. Breaks when users forget to set them.
|
||||
- **Filesystem metadata** (current): Unreliable across clones, pulls, and cloud sync.
|
||||
- **Single batch `git log`** (chosen): One subprocess, O(n) parsing, correct dates for all committed files.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Note sort-by-created and sort-by-modified now reflect true git history, stable across clones and machines.
|
||||
- First vault scan runs one `git log` over the full history. For a vault with 1000 files and 500 commits, output is ~100KB and parses in <100ms.
|
||||
- Renamed files get `created_at` set to the rename commit date (not the original creation). Acceptable trade-off vs. the complexity of rename tracking.
|
||||
- `CACHE_VERSION` bumped from 9 to 10 to force a full rescan with git dates on upgrade.
|
||||
59
docs/adr/0040-custom-views-yml-filter-engine.md
Normal file
59
docs/adr/0040-custom-views-yml-filter-engine.md
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0040"
|
||||
title: "Custom views as .yml files with client-side filter engine"
|
||||
status: active
|
||||
date: 2026-04-02
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Users want to save reusable filtered note lists (e.g., "Active Projects", "This Week's Events") as named sidebar items. These views need to persist across sessions, sync via git, and support arbitrary frontmatter conditions.
|
||||
|
||||
## Decision
|
||||
|
||||
**Custom views are stored as `.yml` files in `.laputa/views/` within the vault root.** Each file defines a named view with filter conditions, optional icon/color, and sort preferences.
|
||||
|
||||
### File format
|
||||
|
||||
```yaml
|
||||
name: Active Projects
|
||||
icon: rocket
|
||||
color: blue
|
||||
sort: "modified:desc"
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
- field: status
|
||||
op: not_equals
|
||||
value: done
|
||||
```
|
||||
|
||||
### Filter engine
|
||||
|
||||
Filters use a tree of AND/OR groups (`all`/`any`) containing conditions. Each condition specifies a `field`, `op` (operator), and optional `value`. Supported operators: `equals`, `not_equals`, `contains`, `not_contains`, `any_of`, `none_of`, `is_empty`, `is_not_empty`, `before`, `after`.
|
||||
|
||||
Field resolution: built-in fields (`type`, `status`, `title`, `archived`, `trashed`, `favorite`) map to VaultEntry struct fields. Unknown fields fall back to `entry.properties`, then `entry.relationships`.
|
||||
|
||||
Wikilink values like `[[target|Alias]]` are matched by stem (stripping brackets and pipe+alias).
|
||||
|
||||
### Architecture
|
||||
|
||||
- **Rust backend** (`vault/views.rs`): YAML parsing via `serde_yaml`, filter evaluation, file CRUD. Three Tauri commands: `list_views`, `save_view_cmd`, `delete_view_cmd`.
|
||||
- **Frontend**: Client-side filter evaluation against the already-loaded `VaultEntry[]` array. The Rust `evaluate_view` exists for MCP/CLI access but is not the primary UI path.
|
||||
- **Sidebar**: VIEWS section between Favorites and Types, hidden when no views exist.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **SQLite views table**: Fast queries, but not portable via git and diverges from the file-first data model.
|
||||
- **Frontmatter on a special `.md` file**: Overloads the note format for a non-note concept.
|
||||
- **Standalone `.yml` files (chosen)**: Portable (synced via git), editable by hand or UI, naturally separated from note content.
|
||||
|
||||
## Consequences
|
||||
|
||||
- New dependency: `serde_yaml` crate for YAML parsing.
|
||||
- `.laputa/views/` directory auto-created on first view save. Already excluded from vault scanning via `HIDDEN_DIRS`.
|
||||
- Views sync across devices via git. Conflicts resolved by standard git merge (YAML is line-based, merges well).
|
||||
- Sort persistence: changing sort while a view is selected writes `sort` back to the `.yml` file.
|
||||
39
docs/adr/0041-filekind-all-files-in-vault-scanner.md
Normal file
39
docs/adr/0041-filekind-all-files-in-vault-scanner.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0041"
|
||||
title: "fileKind field — scan all vault files, not just markdown"
|
||||
status: active
|
||||
date: 2026-04-02
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
Laputa vaults often contain non-markdown files alongside notes: images, PDFs, YAML configs, JSON exports, scripts, etc. Previously the vault scanner only indexed `.md` files — all other files were invisible to the app. This made the Folder view incomplete: navigating a folder containing a `config.yml` or `photo.png` showed nothing, even though the file was physically there.
|
||||
|
||||
The need arose when adding a Folder tree view that is meant to mirror the actual filesystem structure. Users expect to see all files in a folder, as any file manager would show.
|
||||
|
||||
## Decision
|
||||
|
||||
**The vault scanner now indexes all files (not just `.md`). Every `VaultEntry` carries a `fileKind` field (`"markdown"`, `"text"`, or `"binary"`) that controls how the frontend renders and opens it.**
|
||||
|
||||
- **`"markdown"`**: full Laputa behavior — frontmatter parsing, BlockNote editor, title sync, type system.
|
||||
- **`"text"`**: filename as title, no frontmatter, opens in raw CodeMirror editor. Covers `.yml`, `.json`, `.ts`, `.py`, `.sh`, etc.
|
||||
- **`"binary"`**: filename as title, grayed out, non-clickable. Covers images, PDFs, binaries.
|
||||
- **Hidden files** (starting with `.`) are skipped regardless of extension.
|
||||
- **Non-folder views** (All Notes, type sections, Custom Views) still show only `"markdown"` entries.
|
||||
- **Folder view** shows all file kinds.
|
||||
|
||||
## Options considered
|
||||
|
||||
- **Option A** (chosen): Single `VaultEntry` model with a `fileKind` discriminator. All files go through the same pipeline; rendering is gated by `fileKind`. Simple, incremental — existing code paths untouched for markdown files.
|
||||
- **Option B**: Separate data model for non-markdown files (e.g. `AssetEntry`). Cleaner type hierarchy, but requires duplicating list/filter/sort logic for two types across the codebase.
|
||||
- **Option C**: Only scan `.md` + explicitly listed extensions (e.g. `.yml`, `.json`). Simpler initial implementation, but requires ongoing maintenance of an allowlist and still misses user files. Abandoned in favor of a deny-list approach (only `.`-prefixed hidden files are excluded).
|
||||
|
||||
## Consequences
|
||||
|
||||
- Non-markdown files are visible in Folder view — the app now behaves like a file manager in that context.
|
||||
- All views except Folder view continue to show only markdown files (the `isMarkdown` guard in `filterEntries`).
|
||||
- `countByFilter` / `countAllByFilter` exclude non-markdown entries to keep sidebar counters accurate.
|
||||
- The vault cache version was bumped to `11` to force a full rescan after this change.
|
||||
- Binary files have no click action — clicking does nothing (no editor opened).
|
||||
- Re-evaluation trigger: if users need to preview or edit binary files (e.g. images), a dedicated preview pane would need a separate ADR.
|
||||
37
docs/adr/0042-posthog-release-channels-feature-flags.md
Normal file
37
docs/adr/0042-posthog-release-channels-feature-flags.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: ADR
|
||||
id: "0042"
|
||||
title: "PostHog-based release channels and feature flags"
|
||||
status: active
|
||||
date: 2026-04-03
|
||||
supersedes: "0017"
|
||||
---
|
||||
## Context
|
||||
|
||||
ADR-0017 introduced canary/stable update channels with localStorage-based feature flags. This worked for local development but lacked remote flag management — promoting a feature from beta to stable required a code change and rebuild.
|
||||
|
||||
## Decision
|
||||
|
||||
**Replace localStorage feature flags with PostHog-based feature flags, evaluated per release channel (alpha/beta/stable). The release channel is a user-selectable setting; PostHog flag rules determine which features are visible for each channel.**
|
||||
|
||||
- **Alpha**: all features always enabled (no PostHog lookup needed, works offline)
|
||||
- **Beta**: sees features where the PostHog flag targets `release_channel = beta`
|
||||
- **Stable** (default): sees features where the PostHog flag targets `release_channel = stable`
|
||||
- Promotion = flipping a PostHog flag on the dashboard. Zero code changes, zero rebuilds.
|
||||
- `isFeatureEnabled(flagKey)` in `telemetry.ts` is the single evaluation point.
|
||||
- localStorage overrides (`ff_<name>`) still work for dev/QA testing (checked first).
|
||||
- Offline: PostHog caches flags in localStorage; alpha always works; first-launch-no-network falls back to hardcoded defaults.
|
||||
|
||||
## Options considered
|
||||
|
||||
* **Option A**: Keep localStorage-only flags (ADR-0017) — no server dependency, but no remote management.
|
||||
* **Option B** (chosen): PostHog feature flags — we already use PostHog for analytics, so no new dependency. Remote flag management, per-channel targeting, gradual rollouts via PostHog dashboard.
|
||||
* **Option C**: Dedicated feature flag service (LaunchDarkly, Unleash) — more powerful but adds a new vendor dependency.
|
||||
|
||||
## Consequences
|
||||
|
||||
* `release_channel` added to Settings (persisted via Tauri backend, not vault).
|
||||
* `useTelemetry` passes `release_channel` as a PostHog person property on identify.
|
||||
* `isFeatureEnabled()` checks channel → PostHog → hardcoded defaults.
|
||||
* `useFeatureFlag` hook updated to delegate to `isFeatureEnabled` (after localStorage override check).
|
||||
* ADR-0017 is superseded — the canary update channel remains, but feature gating moves from localStorage to PostHog.
|
||||
@@ -93,3 +93,7 @@ proposed → active → superseded
|
||||
| [0035](0035-path-suffix-wikilink-resolution.md) | Path-suffix wikilink resolution for subfolder vaults | active |
|
||||
| [0036](0036-external-rename-detection-via-git-diff.md) | External rename detection via git diff on focus regain | active |
|
||||
| [0037](0037-codemirror-language-markdown-highlighting.md) | Language-based markdown syntax highlighting in raw editor | active |
|
||||
| [0038](0038-frontmatter-backed-favorites.md) | Frontmatter-backed favorites (_favorite, _favorite_index) | active |
|
||||
| [0039](0039-git-history-for-note-dates.md) | Git history as source of truth for note creation/modification dates | active |
|
||||
| [0040](0040-custom-views-yml-filter-engine.md) | Custom Views — .laputa/views/*.yml with YAML filter engine | active |
|
||||
| [0041](0041-filekind-all-files-in-vault-scanner.md) | fileKind field — scan all vault files, not just markdown | active |
|
||||
|
||||
16
getting-started-vault/.gitignore
vendored
Normal file
16
getting-started-vault/.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Laputa app files (machine-specific, never commit)
|
||||
.laputa/settings.json
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
61
getting-started-vault/getting-started.md
Normal file
61
getting-started-vault/getting-started.md
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
title: "Getting Started"
|
||||
type: Note
|
||||
Related to:
|
||||
- "[[What is Laputa]]"
|
||||
- "[[Keyboard Shortcuts]]"
|
||||
- "[[Laputa Onboarding]]"
|
||||
---
|
||||
|
||||
Welcome to your Laputa vault! This note walks you through the key features so you can start building your personal knowledge graph.
|
||||
|
||||
## Editor
|
||||
|
||||
Laputa uses a rich markdown editor. Write in plain markdown with headings, lists, checkboxes, code blocks, and blockquotes. Every note has YAML frontmatter at the top (between `---` delimiters) that stores metadata like type, status, and relationships.
|
||||
|
||||
Wiki-links connect notes together: type `[[` in the editor to search and link to any note in your vault.
|
||||
|
||||
## Types
|
||||
|
||||
Types define the kind of entity a note represents — Note, Project, Person, Topic, Task, or any custom type you create. Each type gets its own icon, color, and sidebar section. To create a new type, add a markdown file with `type: Type` in the frontmatter.
|
||||
|
||||
## Sidebar
|
||||
|
||||
The left sidebar organizes your vault by type. Each type gets its own collapsible section. Special sections include:
|
||||
|
||||
- **Inbox** — notes without a type
|
||||
- **All Notes** — every note in the vault
|
||||
- **Archive** — archived notes
|
||||
- **Trash** — deleted notes (recoverable for 30 days)
|
||||
|
||||
## Properties
|
||||
|
||||
Open the **Inspector** panel (right side) to view and edit a note's properties. Click any value to change it. Use the **+ Add property** button to add custom fields. Properties containing `[[wiki-links]]` become navigable relationships.
|
||||
|
||||
## Relationships
|
||||
|
||||
Connect notes through frontmatter fields like `Belongs to`, `Related to`, and `Has`. These appear as clickable pills in the Inspector. Backlinks are computed automatically — linking A to B makes B show a backlink to A.
|
||||
|
||||
## Views
|
||||
|
||||
Views are saved filters that show a subset of your notes. Create a view to see, for example, all active projects or all tasks belonging to a specific project. Views live in the `views/` folder as YAML files. This vault includes an "Active Projects" view that filters for projects with status Active.
|
||||
|
||||
## Favorites
|
||||
|
||||
Pin frequently used notes to the Favorites section at the top of the sidebar. Toggle a note's favorite status from the Inspector or the command palette.
|
||||
|
||||
## Search
|
||||
|
||||
Press **Cmd+P** to quick-open any note by title. Use the search bar in the sidebar for full-text search across all notes.
|
||||
|
||||
## Command palette
|
||||
|
||||
Press **Cmd+K** to open the command palette. From here you can create notes, switch types, toggle views, and access every action in the app.
|
||||
|
||||
## AI
|
||||
|
||||
Laputa integrates with Claude Code. When Claude Code is running in your vault directory, the status bar shows a green badge. You can use AI to help organize, summarize, and connect your notes.
|
||||
|
||||
## Git sync
|
||||
|
||||
Your vault is a standard git repository. Use the **Changes** view in the sidebar to see modified files, commit changes, and push to a remote. This means your vault works with any git hosting service for backup and sync across devices.
|
||||
37
getting-started-vault/keyboard-shortcuts.md
Normal file
37
getting-started-vault/keyboard-shortcuts.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
title: "Keyboard Shortcuts"
|
||||
type: Note
|
||||
Related to: "[[Getting Started]]"
|
||||
---
|
||||
|
||||
Essential shortcuts for navigating Laputa.
|
||||
|
||||
## Navigation
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| **Cmd+P** | Quick-open a note by title |
|
||||
| **Cmd+K** | Open the command palette |
|
||||
| **Cmd+N** | Create a new note |
|
||||
| **Cmd+,** | Open settings |
|
||||
| **Cmd+\\** | Toggle sidebar |
|
||||
|
||||
## Editor
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| **Cmd+S** | Save the current note |
|
||||
| **Cmd+B** | Bold |
|
||||
| **Cmd+I** | Italic |
|
||||
| **Cmd+Shift+K** | Insert a code block |
|
||||
| **Cmd+Shift+8** | Toggle bullet list |
|
||||
| **Cmd+Shift+7** | Toggle numbered list |
|
||||
|
||||
## Wiki-links
|
||||
|
||||
Type `[[` in the editor to open the link picker. Start typing to search for a note, then press Enter to insert the link. Links are bidirectional — the linked note will show a backlink to the current note.
|
||||
|
||||
## Tips
|
||||
|
||||
- Use **Cmd+K** when you are not sure where to find something — the command palette lists every available action.
|
||||
- **Cmd+P** is the fastest way to navigate between notes — it searches by title and aliases.
|
||||
9
getting-started-vault/note.md
Normal file
9
getting-started-vault/note.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Note"
|
||||
type: Type
|
||||
icon: note
|
||||
color: blue
|
||||
order: 1
|
||||
---
|
||||
|
||||
A Note is a general-purpose document — research notes, meeting notes, ideas, drafts, or anything that doesn't fit a more specific type.
|
||||
12
getting-started-vault/person-luca-rossi.md
Normal file
12
getting-started-vault/person-luca-rossi.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: "Luca Rossi"
|
||||
type: Person
|
||||
Role: Founder
|
||||
Website: https://refactoring.fm
|
||||
Twitter: https://twitter.com/lucaronin
|
||||
Related to:
|
||||
- "[[Laputa Onboarding]]"
|
||||
- "[[Topic: Personal Knowledge Management]]"
|
||||
---
|
||||
|
||||
Creator of Laputa and founder of [Refactoring](https://refactoring.fm), a newsletter about engineering leadership and software craft. Luca built Laputa to organize his own knowledge — projects, people, ideas, and goals — in a way that stays grounded in plain files and version control.
|
||||
9
getting-started-vault/person.md
Normal file
9
getting-started-vault/person.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Person"
|
||||
type: Type
|
||||
icon: user
|
||||
color: green
|
||||
order: 3
|
||||
---
|
||||
|
||||
A Person represents someone you interact with — a colleague, collaborator, mentor, or friend. People can own projects, attend events, and appear in relationships across your vault.
|
||||
27
getting-started-vault/project-laputa-onboarding.md
Normal file
27
getting-started-vault/project-laputa-onboarding.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: "Laputa Onboarding"
|
||||
type: Project
|
||||
Status: Active
|
||||
Owner: "[[Luca Rossi]]"
|
||||
Has:
|
||||
- "[[Task: Explore the Editor]]"
|
||||
- "[[Task: Create Your First Type]]"
|
||||
- "[[Task: Set Up Git Sync]]"
|
||||
- "[[Task: Try the Command Palette]]"
|
||||
- "[[Task: Create a Custom View]]"
|
||||
Related to: "[[Topic: Getting Started]]"
|
||||
---
|
||||
|
||||
A hands-on project to help you discover Laputa's key features. Work through the tasks below at your own pace — each one introduces a different part of the app.
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] [[Task: Explore the Editor]] — Get comfortable with the markdown editor and wiki-links
|
||||
- [ ] [[Task: Create Your First Type]] — Define a custom type with icon and color
|
||||
- [ ] [[Task: Set Up Git Sync]] — Connect your vault to a git remote for backup
|
||||
- [ ] [[Task: Try the Command Palette]] — Discover actions with Cmd+K
|
||||
- [ ] [[Task: Create a Custom View]] — Build a filtered view of your notes
|
||||
|
||||
## When you are done
|
||||
|
||||
Once you have completed these tasks, you will have a solid understanding of how Laputa works. From here, start creating your own types, notes, and views to build a knowledge graph that fits your workflow.
|
||||
9
getting-started-vault/project.md
Normal file
9
getting-started-vault/project.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Project"
|
||||
type: Type
|
||||
icon: rocket-launch
|
||||
color: purple
|
||||
order: 4
|
||||
---
|
||||
|
||||
A Project is a time-bounded effort with a clear goal and an eventual completion date. Projects can contain tasks, belong to areas, and link to people and topics.
|
||||
39
getting-started-vault/task-create-a-custom-view.md
Normal file
39
getting-started-vault/task-create-a-custom-view.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: "Task: Create a Custom View"
|
||||
type: Task
|
||||
Status: Open
|
||||
Belongs to: "[[Laputa Onboarding]]"
|
||||
Related to: "[[Laputa Onboarding]]"
|
||||
---
|
||||
|
||||
Views are saved filters that show a subset of your notes. This vault includes an example view — "Active Projects" — that shows all projects with status Active.
|
||||
|
||||
## What to try
|
||||
|
||||
1. Open the command palette (Cmd+K) and look for a "New View" action, or create a `.yml` file in the `views/` folder
|
||||
2. Define filters — for example, show all tasks with status Open
|
||||
3. Set a name, icon, and color for the view
|
||||
4. Save — the view appears in the sidebar under Views
|
||||
|
||||
## View format
|
||||
|
||||
Views are YAML files in `views/`. Here is an example:
|
||||
|
||||
```yaml
|
||||
name: Open Tasks
|
||||
icon: check-square
|
||||
color: orange
|
||||
sort: "modified:desc"
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Task
|
||||
- field: Status
|
||||
op: equals
|
||||
value: Open
|
||||
```
|
||||
|
||||
## Tip
|
||||
|
||||
Views update automatically as you add or change notes. They are a powerful way to create dashboards for your projects, areas, or any slice of your vault.
|
||||
30
getting-started-vault/task-create-your-first-type.md
Normal file
30
getting-started-vault/task-create-your-first-type.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
title: "Task: Create Your First Type"
|
||||
type: Task
|
||||
Status: Open
|
||||
Belongs to: "[[Laputa Onboarding]]"
|
||||
---
|
||||
|
||||
Types give structure to your vault. Every note has a type — Project, Person, Topic, or any custom type you define.
|
||||
|
||||
## What to try
|
||||
|
||||
1. Create a new markdown file (Cmd+N)
|
||||
2. In the frontmatter, set `type: Type`
|
||||
3. Add an `icon:` field — use any [Phosphor icon](https://phosphoricons.com) name in kebab-case (e.g. `book-open`, `lightning`, `house`)
|
||||
4. Add a `color:` field — available colors: red, purple, blue, green, yellow, orange
|
||||
5. Give it a title with a `# Heading` and a short description in the body
|
||||
6. Save the file — your new type appears in the sidebar
|
||||
|
||||
## Example
|
||||
|
||||
```yaml
|
||||
---
|
||||
type: Type
|
||||
icon: book-open
|
||||
color: red
|
||||
order: 6
|
||||
---
|
||||
```
|
||||
|
||||
The `order` field controls position in the sidebar (lower = higher).
|
||||
21
getting-started-vault/task-explore-the-editor.md
Normal file
21
getting-started-vault/task-explore-the-editor.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
title: "Task: Explore the Editor"
|
||||
type: Task
|
||||
Status: Open
|
||||
Belongs to: "[[Laputa Onboarding]]"
|
||||
---
|
||||
|
||||
Get comfortable with Laputa's markdown editor.
|
||||
|
||||
## What to try
|
||||
|
||||
1. **Headings** — Type `#`, `##`, or `###` followed by a space to create headings
|
||||
2. **Lists** — Use `-` for bullet lists and `1.` for numbered lists
|
||||
3. **Checkboxes** — Type `- [ ]` for a checkbox, click to toggle it
|
||||
4. **Formatting** — Try **bold** (Cmd+B), *italic* (Cmd+I), and `inline code`
|
||||
5. **Wiki-links** — Type `[[` and start typing a note name to insert a link. Try linking to [[Getting Started]]
|
||||
6. **Code blocks** — Use triple backticks or Cmd+Shift+K
|
||||
|
||||
## Tip
|
||||
|
||||
The editor saves automatically. You can also press **Cmd+S** to save explicitly.
|
||||
27
getting-started-vault/task-set-up-git-sync.md
Normal file
27
getting-started-vault/task-set-up-git-sync.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: "Task: Set Up Git Sync"
|
||||
type: Task
|
||||
Status: Open
|
||||
Belongs to: "[[Laputa Onboarding]]"
|
||||
---
|
||||
|
||||
Your vault is a git repository. Connecting it to a remote lets you back up your notes and sync across devices.
|
||||
|
||||
## What to try
|
||||
|
||||
1. Create a new repository on GitHub, GitLab, or any git host
|
||||
2. Open a terminal in your vault directory
|
||||
3. Add the remote: `git remote add origin <your-repo-url>`
|
||||
4. Push your vault: `git push -u origin main`
|
||||
|
||||
## Using the Changes view
|
||||
|
||||
Laputa has a built-in Changes view in the sidebar that shows modified files. From there you can:
|
||||
|
||||
- See which files have changed since the last commit
|
||||
- Commit changes with a message
|
||||
- Push to the remote
|
||||
|
||||
## Tip
|
||||
|
||||
Git sync means you can edit your vault from any device — even a plain text editor on a computer that does not have Laputa installed. The next time you open Laputa, pull the latest changes and everything updates.
|
||||
26
getting-started-vault/task-try-the-command-palette.md
Normal file
26
getting-started-vault/task-try-the-command-palette.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
title: "Task: Try the Command Palette"
|
||||
type: Task
|
||||
Status: Open
|
||||
Belongs to: "[[Laputa Onboarding]]"
|
||||
---
|
||||
|
||||
The command palette is the fastest way to do anything in Laputa.
|
||||
|
||||
## What to try
|
||||
|
||||
1. Press **Cmd+K** to open the command palette
|
||||
2. Type to filter the list of available actions
|
||||
3. Try these commands:
|
||||
- **New Note** — create a note
|
||||
- **Toggle Sidebar** — show or hide the sidebar
|
||||
- **Open Settings** — jump to app settings
|
||||
4. Press **Escape** to close the palette
|
||||
|
||||
## Quick open
|
||||
|
||||
**Cmd+P** opens a separate quick-open dialog that searches notes by title and aliases. Use it when you know which note you want to reach.
|
||||
|
||||
## Tip
|
||||
|
||||
If you forget a shortcut, Cmd+K shows it. The command palette lists keyboard shortcuts next to each action.
|
||||
9
getting-started-vault/task.md
Normal file
9
getting-started-vault/task.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Task"
|
||||
type: Type
|
||||
icon: check-square
|
||||
color: orange
|
||||
order: 5
|
||||
---
|
||||
|
||||
A Task is a discrete, actionable item that can be completed. Tasks belong to projects and represent the smallest unit of work in your vault.
|
||||
10
getting-started-vault/topic-getting-started.md
Normal file
10
getting-started-vault/topic-getting-started.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: "Topic: Getting Started"
|
||||
type: Topic
|
||||
Related to:
|
||||
- "[[Getting Started]]"
|
||||
- "[[What is Laputa]]"
|
||||
- "[[Keyboard Shortcuts]]"
|
||||
---
|
||||
|
||||
This topic groups all notes related to learning Laputa. Start with [[Getting Started]] for a full overview, then explore [[What is Laputa]] for the philosophy behind the app and [[Keyboard Shortcuts]] for quick navigation.
|
||||
16
getting-started-vault/topic-personal-knowledge-management.md
Normal file
16
getting-started-vault/topic-personal-knowledge-management.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
title: "Topic: Personal Knowledge Management"
|
||||
type: Topic
|
||||
Related to: "[[What is Laputa]]"
|
||||
---
|
||||
|
||||
Personal Knowledge Management (PKM) is the practice of collecting, organizing, and connecting the information you encounter — notes, ideas, references, projects, and people — so it becomes a durable personal asset rather than a list of forgotten bookmarks.
|
||||
|
||||
Laputa is designed as a PKM tool. Unlike traditional note-taking apps, it treats your notes as a graph of interconnected entities. Types give structure, wiki-links create connections, and views let you slice through the graph from different angles.
|
||||
|
||||
## Core ideas
|
||||
|
||||
- **Write things down** — Capture ideas, meeting notes, and references as they happen. A note that exists is infinitely more useful than a thought you forgot.
|
||||
- **Connect everything** — Use `[[wiki-links]]` to link related notes. Over time, these connections reveal patterns you did not plan.
|
||||
- **Use types** — Give notes a type (Project, Person, Topic) to add structure without rigid hierarchies.
|
||||
- **Review regularly** — Use views and favorites to surface what matters. A vault that is never revisited is just a graveyard of text files.
|
||||
9
getting-started-vault/topic.md
Normal file
9
getting-started-vault/topic.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Topic"
|
||||
type: Type
|
||||
icon: tag
|
||||
color: yellow
|
||||
order: 2
|
||||
---
|
||||
|
||||
A Topic is a subject area or interest category. Topics group related notes, projects, and people by theme — use them to organize knowledge across your vault.
|
||||
12
getting-started-vault/views/active-projects.yml
Normal file
12
getting-started-vault/views/active-projects.yml
Normal file
@@ -0,0 +1,12 @@
|
||||
name: Active Projects
|
||||
icon: rocket-launch
|
||||
color: purple
|
||||
sort: "modified:desc"
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
- field: Status
|
||||
op: equals
|
||||
value: Active
|
||||
25
getting-started-vault/what-is-laputa.md
Normal file
25
getting-started-vault/what-is-laputa.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: "What is Laputa"
|
||||
type: Note
|
||||
Related to:
|
||||
- "[[Getting Started]]"
|
||||
- "[[Topic: Personal Knowledge Management]]"
|
||||
---
|
||||
|
||||
Laputa is a local-first knowledge management app built on three principles:
|
||||
|
||||
## Your files, your data
|
||||
|
||||
Every note is a plain markdown file on your filesystem. There is no proprietary database, no cloud lock-in, no import/export ceremony. Your vault is a folder — you can open it in any text editor, back it up however you want, and it will outlive any app.
|
||||
|
||||
## The filesystem is the truth
|
||||
|
||||
Laputa reads your files and derives everything from them. Types, relationships, views, and sidebar sections are all computed from the frontmatter and folder structure. There is no hidden state. If you move a file in Finder, Laputa reflects the change. If you edit a file in VS Code, Laputa picks it up.
|
||||
|
||||
## Git for sync and history
|
||||
|
||||
Your vault is a git repository. Every change is a commit. Sync between devices by pushing and pulling to a remote. Resolve conflicts with standard git tools. Your entire edit history is preserved.
|
||||
|
||||
## How it fits together
|
||||
|
||||
A Laputa vault is a collection of markdown files with YAML frontmatter. The `type` field in the frontmatter determines what kind of entity the note represents. Wiki-links (`[[Note Title]]`) create a web of connections between notes. The app provides a visual interface on top of these files — a rich editor, sidebar navigation, inspector panel, and filtered views — but the files remain the source of truth.
|
||||
@@ -42,7 +42,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/browser": "^10.45.0",
|
||||
"@sentry/react": "^10.47.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
|
||||
76
pnpm-lock.yaml
generated
76
pnpm-lock.yaml
generated
@@ -77,9 +77,9 @@ importers:
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.8
|
||||
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
'@sentry/browser':
|
||||
specifier: ^10.45.0
|
||||
version: 10.45.0
|
||||
'@sentry/react':
|
||||
specifier: ^10.47.0
|
||||
version: 10.47.0(react@19.2.4)
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.18
|
||||
version: 4.1.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
@@ -1785,30 +1785,36 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@sentry-internal/browser-utils@10.45.0':
|
||||
resolution: {integrity: sha512-ZPZpeIarXKScvquGx2AfNKcYiVNDA4wegMmjyGVsTA2JPmP0TrJoO3UybJS6KGDeee8V3I3EfD/ruauMm7jOFQ==}
|
||||
'@sentry-internal/browser-utils@10.47.0':
|
||||
resolution: {integrity: sha512-bVFRAeJWMBcBCvJKIFCMJ1/yQToL4vPGqfmlnDZeypcxkqUDKQ/Y3ziLHXoDL2sx0lagcgU2vH1QhCQ67Aujjw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/feedback@10.45.0':
|
||||
resolution: {integrity: sha512-vCSurazFVq7RUeYiM5X326jA5gOVrWYD6lYX2fbjBOMcyCEhDnveNxMT62zKkZDyNT/jyD194nz/cjntBUkyWA==}
|
||||
'@sentry-internal/feedback@10.47.0':
|
||||
resolution: {integrity: sha512-pdvMmi4dQpX5S/vAAzrhHPIw3T3HjUgDNgUiCBrlp7N9/6zGO2gNPhUnNekP+CjgI/z0rvf49RLqlDenpNrMOg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/replay-canvas@10.45.0':
|
||||
resolution: {integrity: sha512-nvq/AocdZTuD7y0KSiWi3gVaY0s5HOFy86mC/v1kDZmT/jsBAzN5LDkk/f1FvsWma1peqQmpUqxvhC+YIW294Q==}
|
||||
'@sentry-internal/replay-canvas@10.47.0':
|
||||
resolution: {integrity: sha512-A5OY8friSe6g8WAK4L8IeOPiEd9D3Ps40DzRH5j2f6SUja0t90mKMvHRcRf8zq0d4BkdB+JM7tjOkwxpuv8heA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry-internal/replay@10.45.0':
|
||||
resolution: {integrity: sha512-vjosRoGA1bzhVAEO1oce+CsRdd70quzBeo7WvYqpcUnoLe/Rv8qpOMqWX3j26z7XfFHMExWQNQeLxmtYOArvlw==}
|
||||
'@sentry-internal/replay@10.47.0':
|
||||
resolution: {integrity: sha512-ScdovxP7hJxgMt70+7hFvwT02GIaIUAxdEM/YPsayZBeCoAukPW8WiwztJfoKtsfPyKJ5A6f0H3PIxTPcA9Row==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/browser@10.45.0':
|
||||
resolution: {integrity: sha512-e/a8UMiQhqqv706McSIcG6XK+AoQf9INthi2pD+giZfNRTzXTdqHzUT5OIO5hg8Am6eF63nDJc+vrYNPhzs51Q==}
|
||||
'@sentry/browser@10.47.0':
|
||||
resolution: {integrity: sha512-rC0agZdxKA5XWfL4VwPOr/rJMogXDqZgnVzr93YWpFn9DMZT/7LzxSJVPIJwRUjx3bFEby3PcTa3YaX7pxm1AA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/core@10.45.0':
|
||||
resolution: {integrity: sha512-s69UXxvefeQxuZ5nY7/THtTrIEvJxNVCp3ns4kwoCw1qMpgpvn/296WCKVmM7MiwnaAdzEKnAvLAwaxZc2nM7Q==}
|
||||
'@sentry/core@10.47.0':
|
||||
resolution: {integrity: sha512-nsYRAx3EWezDut+Zl+UwwP07thh9uY7CfSAi2whTdcJl5hu1nSp2z8bba7Vq/MGbNLnazkd3A+GITBEML924JA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@sentry/react@10.47.0':
|
||||
resolution: {integrity: sha512-ZtJV6xxF8jUVE9e3YQUG3Do0XapG1GjniyLyqMPgN6cNvs/HaRJODf7m60By+VGqcl5XArEjEPTvx8CdPUXDfA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^16.14.0 || 17.x || 18.x || 19.x
|
||||
|
||||
'@shikijs/types@3.22.0':
|
||||
resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==}
|
||||
|
||||
@@ -5941,33 +5947,39 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.57.1':
|
||||
optional: true
|
||||
|
||||
'@sentry-internal/browser-utils@10.45.0':
|
||||
'@sentry-internal/browser-utils@10.47.0':
|
||||
dependencies:
|
||||
'@sentry/core': 10.45.0
|
||||
'@sentry/core': 10.47.0
|
||||
|
||||
'@sentry-internal/feedback@10.45.0':
|
||||
'@sentry-internal/feedback@10.47.0':
|
||||
dependencies:
|
||||
'@sentry/core': 10.45.0
|
||||
'@sentry/core': 10.47.0
|
||||
|
||||
'@sentry-internal/replay-canvas@10.45.0':
|
||||
'@sentry-internal/replay-canvas@10.47.0':
|
||||
dependencies:
|
||||
'@sentry-internal/replay': 10.45.0
|
||||
'@sentry/core': 10.45.0
|
||||
'@sentry-internal/replay': 10.47.0
|
||||
'@sentry/core': 10.47.0
|
||||
|
||||
'@sentry-internal/replay@10.45.0':
|
||||
'@sentry-internal/replay@10.47.0':
|
||||
dependencies:
|
||||
'@sentry-internal/browser-utils': 10.45.0
|
||||
'@sentry/core': 10.45.0
|
||||
'@sentry-internal/browser-utils': 10.47.0
|
||||
'@sentry/core': 10.47.0
|
||||
|
||||
'@sentry/browser@10.45.0':
|
||||
'@sentry/browser@10.47.0':
|
||||
dependencies:
|
||||
'@sentry-internal/browser-utils': 10.45.0
|
||||
'@sentry-internal/feedback': 10.45.0
|
||||
'@sentry-internal/replay': 10.45.0
|
||||
'@sentry-internal/replay-canvas': 10.45.0
|
||||
'@sentry/core': 10.45.0
|
||||
'@sentry-internal/browser-utils': 10.47.0
|
||||
'@sentry-internal/feedback': 10.47.0
|
||||
'@sentry-internal/replay': 10.47.0
|
||||
'@sentry-internal/replay-canvas': 10.47.0
|
||||
'@sentry/core': 10.47.0
|
||||
|
||||
'@sentry/core@10.45.0': {}
|
||||
'@sentry/core@10.47.0': {}
|
||||
|
||||
'@sentry/react@10.47.0(react@19.2.4)':
|
||||
dependencies:
|
||||
'@sentry/browser': 10.47.0
|
||||
'@sentry/core': 10.47.0
|
||||
react: 19.2.4
|
||||
|
||||
'@shikijs/types@3.22.0':
|
||||
dependencies:
|
||||
|
||||
20
src-tauri/Cargo.lock
generated
20
src-tauri/Cargo.lock
generated
@@ -2272,6 +2272,7 @@ dependencies = [
|
||||
"sentry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
@@ -4294,6 +4295,19 @@ dependencies = [
|
||||
"syn 2.0.115",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialize-to-javascript"
|
||||
version = "0.1.2"
|
||||
@@ -5509,6 +5523,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
|
||||
@@ -18,6 +18,7 @@ tauri-build = { version = "2.5.4", features = [] }
|
||||
[dependencies]
|
||||
serde_json = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
log = "0.4"
|
||||
tauri = { version = "2.10.0", features = ["protocol-asset", "devtools"] }
|
||||
tauri-plugin-log = "2"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use crate::frontmatter::FrontmatterValue;
|
||||
use crate::search::SearchResponse;
|
||||
use crate::vault::{DetectedRename, FolderNode, RenameResult, VaultEntry};
|
||||
use crate::vault::{
|
||||
DetectedRename, FolderNode, RenameResult, VaultEntry, ViewDefinition, ViewFile,
|
||||
};
|
||||
use crate::{frontmatter, git, search, vault};
|
||||
|
||||
use super::expand_tilde;
|
||||
@@ -158,6 +160,30 @@ pub fn copy_image_to_vault(vault_path: String, source_path: String) -> Result<St
|
||||
vault::copy_image_to_vault(&vault_path, &source_path)
|
||||
}
|
||||
|
||||
// ── View commands ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_views(vault_path: String) -> Vec<ViewFile> {
|
||||
let path = expand_tilde(&vault_path);
|
||||
vault::scan_views(std::path::Path::new(path.as_ref()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn save_view_cmd(
|
||||
vault_path: String,
|
||||
filename: String,
|
||||
definition: ViewDefinition,
|
||||
) -> Result<(), String> {
|
||||
let path = expand_tilde(&vault_path);
|
||||
vault::save_view(std::path::Path::new(path.as_ref()), &filename, &definition)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_view_cmd(vault_path: String, filename: String) -> Result<(), String> {
|
||||
let path = expand_tilde(&vault_path);
|
||||
vault::delete_view(std::path::Path::new(path.as_ref()), &filename)
|
||||
}
|
||||
|
||||
// ── Frontmatter commands ────────────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
@@ -181,7 +207,7 @@ pub fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
let mut count = 0;
|
||||
for path in &paths {
|
||||
let path = expand_tilde(path);
|
||||
frontmatter::update_frontmatter(&path, "Archived", FrontmatterValue::Bool(true))?;
|
||||
frontmatter::update_frontmatter(&path, "_archived", FrontmatterValue::Bool(true))?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(count)
|
||||
@@ -193,10 +219,10 @@ pub fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
let mut count = 0;
|
||||
for path in &paths {
|
||||
let path = expand_tilde(path);
|
||||
frontmatter::update_frontmatter(&path, "Trashed", FrontmatterValue::Bool(true))?;
|
||||
frontmatter::update_frontmatter(&path, "_trashed", FrontmatterValue::Bool(true))?;
|
||||
frontmatter::update_frontmatter(
|
||||
&path,
|
||||
"Trashed at",
|
||||
"_trashed_at",
|
||||
FrontmatterValue::String(now.clone()),
|
||||
)?;
|
||||
count += 1;
|
||||
@@ -250,7 +276,7 @@ mod tests {
|
||||
1
|
||||
);
|
||||
let content = std::fs::read_to_string(¬e).unwrap();
|
||||
assert!(content.contains("Archived: true"));
|
||||
assert!(content.contains("_archived: true"));
|
||||
assert!(content.contains("Status: Active"));
|
||||
}
|
||||
|
||||
@@ -262,8 +288,8 @@ mod tests {
|
||||
1
|
||||
);
|
||||
let content = std::fs::read_to_string(¬e).unwrap();
|
||||
assert!(content.contains("Trashed: true"));
|
||||
assert!(content.contains("Trashed at"));
|
||||
assert!(content.contains("_trashed: true"));
|
||||
assert!(content.contains("_trashed_at"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
230
src-tauri/src/git/dates.rs
Normal file
230
src-tauri/src/git/dates.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use chrono::DateTime;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Git-derived creation and modification timestamps for a file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitDates {
|
||||
pub created_at: u64,
|
||||
pub modified_at: u64,
|
||||
}
|
||||
|
||||
/// Run a single `git log` to collect creation and modification dates for all
|
||||
/// tracked files in the repository. Returns a map from relative path to dates.
|
||||
///
|
||||
/// - **modified_at** = author date of the most recent commit touching the file
|
||||
/// - **created_at** = author date of the oldest commit touching the file
|
||||
///
|
||||
/// Files not yet committed (untracked / only staged) will not appear in the map;
|
||||
/// callers should fall back to filesystem metadata for those.
|
||||
pub fn get_all_file_dates(vault_path: &Path) -> HashMap<String, GitDates> {
|
||||
let output = match Command::new("git")
|
||||
.args(["log", "--format=COMMIT %aI", "--name-only"])
|
||||
.current_dir(vault_path)
|
||||
.output()
|
||||
{
|
||||
Ok(o) if o.status.success() => o,
|
||||
_ => return HashMap::new(),
|
||||
};
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
parse_git_log_output(&stdout)
|
||||
}
|
||||
|
||||
/// Parse the output of `git log --format="COMMIT %aI" --name-only`.
|
||||
///
|
||||
/// Output looks like:
|
||||
/// ```text
|
||||
/// COMMIT 2026-03-15T10:00:00+02:00
|
||||
///
|
||||
/// file-a.md
|
||||
/// file-b.md
|
||||
///
|
||||
/// COMMIT 2026-03-10T08:00:00+02:00
|
||||
///
|
||||
/// file-a.md
|
||||
/// ```
|
||||
///
|
||||
/// Commits are ordered newest-first. For each file:
|
||||
/// - First occurrence → sets `modified_at`
|
||||
/// - Every subsequent occurrence overwrites `created_at` (last one = oldest commit wins)
|
||||
fn parse_git_log_output(stdout: &str) -> HashMap<String, GitDates> {
|
||||
let mut map: HashMap<String, GitDates> = HashMap::new();
|
||||
let mut current_ts: Option<u64> = None;
|
||||
|
||||
for line in stdout.lines() {
|
||||
if let Some(date_str) = line.strip_prefix("COMMIT ") {
|
||||
current_ts = parse_author_date(date_str);
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = line.trim();
|
||||
if path.is_empty() || current_ts.is_none() {
|
||||
continue;
|
||||
}
|
||||
// Only process .md files
|
||||
if !path.ends_with(".md") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ts = current_ts.unwrap();
|
||||
map.entry(path.to_string())
|
||||
.and_modify(|d| d.created_at = ts)
|
||||
.or_insert(GitDates {
|
||||
created_at: ts,
|
||||
modified_at: ts,
|
||||
});
|
||||
}
|
||||
|
||||
map
|
||||
}
|
||||
|
||||
fn parse_author_date(s: &str) -> Option<u64> {
|
||||
DateTime::parse_from_rfc3339(s.trim())
|
||||
.ok()
|
||||
.map(|dt| dt.timestamp() as u64)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_log_single_commit() {
|
||||
let output = "\
|
||||
COMMIT 2026-03-15T10:00:00+00:00
|
||||
|
||||
file-a.md
|
||||
file-b.md
|
||||
";
|
||||
let map = parse_git_log_output(output);
|
||||
assert_eq!(map.len(), 2);
|
||||
assert_eq!(map["file-a.md"].created_at, 1773568800);
|
||||
assert_eq!(map["file-a.md"].modified_at, 1773568800);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_log_multiple_commits() {
|
||||
let output = "\
|
||||
COMMIT 2026-03-15T10:00:00+00:00
|
||||
|
||||
file-a.md
|
||||
|
||||
COMMIT 2026-03-10T08:00:00+00:00
|
||||
|
||||
file-a.md
|
||||
file-b.md
|
||||
";
|
||||
let map = parse_git_log_output(output);
|
||||
assert_eq!(map.len(), 2);
|
||||
// file-a: modified = newest (2026-03-15), created = oldest (2026-03-10)
|
||||
assert_eq!(map["file-a.md"].modified_at, 1773568800);
|
||||
assert_eq!(map["file-a.md"].created_at, 1773129600);
|
||||
// file-b: only in second commit
|
||||
assert_eq!(map["file-b.md"].modified_at, 1773129600);
|
||||
assert_eq!(map["file-b.md"].created_at, 1773129600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_md_files_filtered_out() {
|
||||
let output = "\
|
||||
COMMIT 2026-03-15T10:00:00+00:00
|
||||
|
||||
README.txt
|
||||
note.md
|
||||
image.png
|
||||
";
|
||||
let map = parse_git_log_output(output);
|
||||
assert_eq!(map.len(), 1);
|
||||
assert!(map.contains_key("note.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_output() {
|
||||
let map = parse_git_log_output("");
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subdirectory_paths() {
|
||||
let output = "\
|
||||
COMMIT 2026-03-15T10:00:00+00:00
|
||||
|
||||
docs/adr/0001-stack.md
|
||||
notes/daily.md
|
||||
";
|
||||
let map = parse_git_log_output(output);
|
||||
assert_eq!(map.len(), 2);
|
||||
assert!(map.contains_key("docs/adr/0001-stack.md"));
|
||||
assert!(map.contains_key("notes/daily.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_all_file_dates_in_real_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
|
||||
// Init repo
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// First commit with one file
|
||||
std::fs::write(vault.join("first.md"), "# First\n").unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "first"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Second commit with another file + modify first
|
||||
std::fs::write(vault.join("first.md"), "# First\nUpdated.\n").unwrap();
|
||||
std::fs::write(vault.join("second.md"), "# Second\n").unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "second"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let map = get_all_file_dates(vault);
|
||||
assert_eq!(map.len(), 2);
|
||||
assert!(map.contains_key("first.md"));
|
||||
assert!(map.contains_key("second.md"));
|
||||
|
||||
// first.md: created in commit 1, modified in commit 2
|
||||
// So modified_at > created_at (or equal if commits are same second)
|
||||
assert!(map["first.md"].modified_at >= map["first.md"].created_at);
|
||||
// second.md: only in commit 2
|
||||
assert_eq!(map["second.md"].modified_at, map["second.md"].created_at);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_all_file_dates_no_git_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let map = get_all_file_dates(dir.path());
|
||||
assert!(map.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
mod commit;
|
||||
mod conflict;
|
||||
mod dates;
|
||||
mod history;
|
||||
mod pulse;
|
||||
mod remote;
|
||||
@@ -13,6 +14,7 @@ pub use conflict::{
|
||||
get_conflict_files, get_conflict_mode, git_commit_conflict_resolution, git_resolve_conflict,
|
||||
is_merge_in_progress, is_rebase_in_progress,
|
||||
};
|
||||
pub use dates::{get_all_file_dates, GitDates};
|
||||
pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history};
|
||||
pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile};
|
||||
pub use remote::{
|
||||
|
||||
@@ -176,7 +176,10 @@ pub fn run() {
|
||||
commands::register_mcp_tools,
|
||||
commands::check_mcp_status,
|
||||
commands::repair_vault,
|
||||
commands::reinit_telemetry
|
||||
commands::reinit_telemetry,
|
||||
commands::list_views,
|
||||
commands::save_view_cmd,
|
||||
commands::delete_view_cmd
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
@@ -13,7 +13,7 @@ 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>,
|
||||
}
|
||||
|
||||
fn settings_path() -> Result<PathBuf, String> {
|
||||
@@ -63,8 +63,8 @@ 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
|
||||
release_channel: settings
|
||||
.release_channel
|
||||
.map(|k| k.trim().to_string())
|
||||
.filter(|k| !k.is_empty()),
|
||||
};
|
||||
@@ -136,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]
|
||||
|
||||
@@ -3,12 +3,15 @@ use std::fs;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{parse_md_file, scan_vault, VaultEntry};
|
||||
use crate::git::{get_all_file_dates, GitDates};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{is_md_file, parse_md_file, parse_non_md_file, scan_vault, VaultEntry};
|
||||
|
||||
// --- Vault Cache ---
|
||||
|
||||
/// Bump this when VaultEntry fields change to force a full rescan.
|
||||
const CACHE_VERSION: u32 = 9;
|
||||
const CACHE_VERSION: u32 = 11;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct VaultCache {
|
||||
@@ -172,14 +175,26 @@ fn to_relative_path(abs_path: &str, vault: &Path) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Parse .md files from a list of relative paths, skipping any that don't exist.
|
||||
fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
|
||||
/// Parse files from a list of relative paths, skipping any that don't exist.
|
||||
/// Dispatches to the appropriate parser based on file extension.
|
||||
fn parse_files_at(
|
||||
vault: &Path,
|
||||
rel_paths: &[String],
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
) -> Vec<VaultEntry> {
|
||||
rel_paths
|
||||
.iter()
|
||||
.filter_map(|rel| {
|
||||
let abs = vault.join(rel);
|
||||
if abs.is_file() {
|
||||
parse_md_file(&abs).ok()
|
||||
let dates = git_dates
|
||||
.get(rel.as_str())
|
||||
.map(|d| (d.modified_at, d.created_at));
|
||||
if is_md_file(&abs) {
|
||||
parse_md_file(&abs, dates).ok()
|
||||
} else {
|
||||
parse_non_md_file(&abs, dates).ok()
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -264,13 +279,17 @@ fn finalize_and_cache(vault: &Path, mut entries: Vec<VaultEntry>, hash: String)
|
||||
/// Handle same-commit cache hit: re-parse any uncommitted changes (new or modified files).
|
||||
/// Always prunes stale entries even when git reports no changes, so that files
|
||||
/// deleted outside git (e.g., via Finder) are removed from the cache on vault open.
|
||||
fn update_same_commit(vault: &Path, cache: VaultCache) -> Vec<VaultEntry> {
|
||||
fn update_same_commit(
|
||||
vault: &Path,
|
||||
cache: VaultCache,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
) -> Vec<VaultEntry> {
|
||||
let changed = git_uncommitted_files(vault);
|
||||
let mut entries = cache.entries;
|
||||
if !changed.is_empty() {
|
||||
let changed_set: std::collections::HashSet<String> = changed.iter().cloned().collect();
|
||||
entries.retain(|e| !changed_set.contains(&to_relative_path(&e.path, vault)));
|
||||
entries.extend(parse_files_at(vault, &changed));
|
||||
entries.extend(parse_files_at(vault, &changed, git_dates));
|
||||
}
|
||||
// Always finalize: prune_stale_entries inside finalize_and_cache removes
|
||||
// entries for files deleted outside git (e.g., via Finder or another app).
|
||||
@@ -282,6 +301,7 @@ fn update_different_commit(
|
||||
vault: &Path,
|
||||
cache: VaultCache,
|
||||
current_hash: String,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
) -> Vec<VaultEntry> {
|
||||
let changed_files = git_changed_files(vault, &cache.commit_hash, ¤t_hash);
|
||||
let changed_set: std::collections::HashSet<String> = changed_files.iter().cloned().collect();
|
||||
@@ -291,7 +311,7 @@ fn update_different_commit(
|
||||
.into_iter()
|
||||
.filter(|e| !changed_set.contains(&to_relative_path(&e.path, vault)))
|
||||
.collect();
|
||||
entries.extend(parse_files_at(vault, &changed_files));
|
||||
entries.extend(parse_files_at(vault, &changed_files, git_dates));
|
||||
|
||||
finalize_and_cache(vault, entries, current_hash)
|
||||
}
|
||||
@@ -319,26 +339,34 @@ pub fn scan_vault_cached(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
|
||||
let current_hash = match git_head_hash(vault_path) {
|
||||
Some(h) => h,
|
||||
None => return scan_vault(vault_path),
|
||||
None => return scan_vault(vault_path, &HashMap::new()),
|
||||
};
|
||||
|
||||
// Build git dates map once — used by all code paths below
|
||||
let git_dates = get_all_file_dates(vault_path);
|
||||
|
||||
if let Some(cache) = load_cache(vault_path) {
|
||||
let current_vault_str = vault_path.to_string_lossy();
|
||||
let cache_stale = cache.version != CACHE_VERSION
|
||||
|| (!cache.vault_path.is_empty() && cache.vault_path != current_vault_str.as_ref());
|
||||
if cache_stale {
|
||||
let entries = scan_vault(vault_path)?;
|
||||
let entries = scan_vault(vault_path, &git_dates)?;
|
||||
return Ok(finalize_and_cache(vault_path, entries, current_hash));
|
||||
}
|
||||
return if cache.commit_hash == current_hash {
|
||||
Ok(update_same_commit(vault_path, cache))
|
||||
Ok(update_same_commit(vault_path, cache, &git_dates))
|
||||
} else {
|
||||
Ok(update_different_commit(vault_path, cache, current_hash))
|
||||
Ok(update_different_commit(
|
||||
vault_path,
|
||||
cache,
|
||||
current_hash,
|
||||
&git_dates,
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
// No cache — full scan and write cache
|
||||
let entries = scan_vault(vault_path)?;
|
||||
let entries = scan_vault(vault_path, &git_dates)?;
|
||||
Ok(finalize_and_cache(vault_path, entries, current_hash))
|
||||
}
|
||||
|
||||
@@ -930,7 +958,7 @@ mod tests {
|
||||
|
||||
// Simulate a stale cache written by old code that parsed Archived: Yes as false
|
||||
let stale_entry = {
|
||||
let mut e = parse_md_file(&vault.join("note.md")).unwrap();
|
||||
let mut e = parse_md_file(&vault.join("note.md"), None).unwrap();
|
||||
e.archived = false; // simulate old parser behavior
|
||||
e
|
||||
};
|
||||
|
||||
@@ -59,6 +59,11 @@ pub struct VaultEntry {
|
||||
pub view: Option<String>,
|
||||
/// Whether this Type is visible in the sidebar. Defaults to true when absent.
|
||||
pub visible: Option<bool>,
|
||||
/// Whether this note is a user favorite (shown in FAVORITES sidebar section).
|
||||
pub favorite: bool,
|
||||
/// Display order within the FAVORITES section (lower = higher).
|
||||
#[serde(rename = "favoriteIndex")]
|
||||
pub favorite_index: Option<i64>,
|
||||
/// Word count of the note body (excludes frontmatter and H1 title).
|
||||
#[serde(rename = "wordCount")]
|
||||
pub word_count: u32,
|
||||
@@ -70,4 +75,16 @@ 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")]
|
||||
pub file_kind: String,
|
||||
}
|
||||
|
||||
fn default_file_kind() -> String {
|
||||
"markdown".to_string()
|
||||
}
|
||||
|
||||
@@ -12,14 +12,16 @@ pub(crate) struct Frontmatter {
|
||||
#[serde(default)]
|
||||
pub aliases: Option<StringOrList>,
|
||||
#[serde(
|
||||
rename = "Archived",
|
||||
rename = "_archived",
|
||||
alias = "Archived",
|
||||
alias = "archived",
|
||||
default,
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
)]
|
||||
pub archived: Option<bool>,
|
||||
#[serde(
|
||||
rename = "Trashed",
|
||||
rename = "_trashed",
|
||||
alias = "Trashed",
|
||||
alias = "trashed",
|
||||
default,
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
@@ -27,7 +29,7 @@ pub(crate) struct Frontmatter {
|
||||
pub trashed: Option<bool>,
|
||||
#[serde(rename = "Status", alias = "status", default)]
|
||||
pub status: Option<StringOrList>,
|
||||
#[serde(rename = "Trashed at", alias = "trashed_at")]
|
||||
#[serde(rename = "_trashed_at", alias = "Trashed at", alias = "trashed_at")]
|
||||
pub trashed_at: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub icon: Option<StringOrList>,
|
||||
@@ -45,6 +47,16 @@ pub(crate) struct Frontmatter {
|
||||
pub view: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub visible: Option<bool>,
|
||||
#[serde(
|
||||
rename = "_favorite",
|
||||
default,
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
)]
|
||||
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.
|
||||
@@ -137,10 +149,13 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
"Is A",
|
||||
"is_a",
|
||||
"aliases",
|
||||
"_archived",
|
||||
"Archived",
|
||||
"archived",
|
||||
"_trashed",
|
||||
"Trashed",
|
||||
"trashed",
|
||||
"_trashed_at",
|
||||
"Trashed at",
|
||||
"trashed_at",
|
||||
"icon",
|
||||
@@ -154,6 +169,9 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
"notion_id",
|
||||
"Status",
|
||||
"status",
|
||||
"_favorite",
|
||||
"_favorite_index",
|
||||
"_list_properties_display",
|
||||
];
|
||||
let filtered: serde_json::Map<String, serde_json::Value> = data
|
||||
.iter()
|
||||
@@ -172,9 +190,13 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"is a",
|
||||
"type",
|
||||
"aliases",
|
||||
"_archived",
|
||||
"archived",
|
||||
"_trashed",
|
||||
"trashed",
|
||||
"_trashed_at",
|
||||
"trashed at",
|
||||
"trashed_at",
|
||||
"icon",
|
||||
"color",
|
||||
"order",
|
||||
@@ -184,6 +206,9 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"view",
|
||||
"visible",
|
||||
"status",
|
||||
"_favorite",
|
||||
"_favorite_index",
|
||||
"_list_properties_display",
|
||||
];
|
||||
|
||||
/// Extract all wikilink-containing fields from raw YAML frontmatter.
|
||||
|
||||
@@ -503,7 +503,8 @@ mod tests {
|
||||
let vault_path = dir.path().join("parse-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entries = crate::vault::scan_vault(&vault_path).unwrap();
|
||||
let entries =
|
||||
crate::vault::scan_vault(&vault_path, &std::collections::HashMap::new()).unwrap();
|
||||
// SAMPLE_FILES + AGENTS.md
|
||||
assert_eq!(entries.len(), SAMPLE_FILES.len() + 1);
|
||||
}
|
||||
@@ -537,7 +538,7 @@ mod tests {
|
||||
let vault_path = dir.path().join("agents-parse-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md")).unwrap();
|
||||
let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md"), None).unwrap();
|
||||
// No frontmatter title → derived from filename slug (H1 is body content)
|
||||
assert_eq!(entry.title, "AGENTS");
|
||||
// Config files have no frontmatter type field — type is None
|
||||
|
||||
@@ -10,6 +10,7 @@ mod parsing;
|
||||
mod rename;
|
||||
mod title_sync;
|
||||
mod trash;
|
||||
mod views;
|
||||
|
||||
pub use cache::{invalidate_cache, scan_vault_cached};
|
||||
pub use config_seed::{migrate_agents_md, repair_config_files, seed_config_files};
|
||||
@@ -23,6 +24,10 @@ pub use rename::{
|
||||
};
|
||||
pub use title_sync::{sync_title_on_open, SyncAction};
|
||||
pub use trash::{batch_delete_notes, delete_note, empty_trash, is_file_trashed, purge_trash};
|
||||
pub use views::{
|
||||
delete_view, evaluate_view, save_view, scan_views, FilterCondition, FilterGroup, FilterNode,
|
||||
FilterOp, ViewDefinition, ViewFile,
|
||||
};
|
||||
|
||||
use file::read_file_metadata;
|
||||
use frontmatter::{extract_fm_and_rels, resolve_is_a};
|
||||
@@ -35,7 +40,11 @@ use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Parse a single markdown file into a VaultEntry.
|
||||
pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
///
|
||||
/// If `git_dates` is provided, those timestamps override filesystem metadata
|
||||
/// for `modified_at` and `created_at`. Pass `None` to use filesystem dates
|
||||
/// (appropriate for newly-saved files not yet committed, or non-git vaults).
|
||||
pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<VaultEntry, String> {
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
|
||||
let filename = path
|
||||
@@ -51,7 +60,11 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
let snippet = extract_snippet(&content);
|
||||
let word_count = count_body_words(&content);
|
||||
let outgoing_links = extract_outgoing_links(&parsed.content);
|
||||
let (modified_at, created_at, file_size) = read_file_metadata(path)?;
|
||||
let (fs_modified, fs_created, file_size) = read_file_metadata(path)?;
|
||||
let (modified_at, created_at) = match git_dates {
|
||||
Some((git_mod, git_create)) => (Some(git_mod), Some(git_create)),
|
||||
None => (fs_modified, fs_created),
|
||||
};
|
||||
let is_a = resolve_is_a(frontmatter.is_a);
|
||||
|
||||
// Add "Type" relationship: isA becomes a navigable link to the type document.
|
||||
@@ -102,18 +115,56 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
sort: frontmatter.sort.and_then(|v| v.into_scalar()),
|
||||
view: frontmatter.view.and_then(|v| v.into_scalar()),
|
||||
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,
|
||||
file_kind: "markdown".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a non-markdown file into a minimal VaultEntry.
|
||||
/// Uses filename as title, no frontmatter extraction.
|
||||
pub(crate) fn parse_non_md_file(
|
||||
path: &Path,
|
||||
git_dates: Option<(u64, u64)>,
|
||||
) -> Result<VaultEntry, String> {
|
||||
let filename = path
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let (fs_modified, fs_created, file_size) = read_file_metadata(path)?;
|
||||
let (modified_at, created_at) = match git_dates {
|
||||
Some((git_mod, git_create)) => (Some(git_mod), Some(git_create)),
|
||||
None => (fs_modified, fs_created),
|
||||
};
|
||||
let file_kind = classify_file_kind(path).to_string();
|
||||
|
||||
Ok(VaultEntry {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
filename: filename.clone(),
|
||||
title: filename,
|
||||
file_kind,
|
||||
modified_at,
|
||||
created_at,
|
||||
file_size,
|
||||
..VaultEntry::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-read a single file from disk and return a fresh VaultEntry.
|
||||
/// Uses filesystem dates (no git lookup) since the file was likely just saved.
|
||||
pub fn reload_entry(path: &Path) -> Result<VaultEntry, String> {
|
||||
if !path.exists() {
|
||||
return Err(format!("File does not exist: {}", path.display()));
|
||||
}
|
||||
parse_md_file(path)
|
||||
if is_md_file(path) {
|
||||
parse_md_file(path, None)
|
||||
} else {
|
||||
parse_non_md_file(path, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Directories that are never shown in the folder tree or scanned for notes.
|
||||
@@ -123,20 +174,159 @@ fn is_hidden_dir(name: &str) -> bool {
|
||||
name.starts_with('.') || HIDDEN_DIRS.contains(&name)
|
||||
}
|
||||
|
||||
fn is_md_file(path: &Path) -> bool {
|
||||
pub(crate) fn is_md_file(path: &Path) -> bool {
|
||||
path.is_file() && path.extension().is_some_and(|ext| ext == "md")
|
||||
}
|
||||
|
||||
fn try_parse_md(path: &Path, entries: &mut Vec<VaultEntry>) {
|
||||
match parse_md_file(path) {
|
||||
/// Extensions recognized as editable text files (opened in raw editor).
|
||||
const TEXT_EXTENSIONS: &[&str] = &[
|
||||
"yml",
|
||||
"yaml",
|
||||
"json",
|
||||
"txt",
|
||||
"toml",
|
||||
"csv",
|
||||
"xml",
|
||||
"html",
|
||||
"htm",
|
||||
"css",
|
||||
"scss",
|
||||
"less",
|
||||
"ts",
|
||||
"tsx",
|
||||
"js",
|
||||
"jsx",
|
||||
"py",
|
||||
"rs",
|
||||
"sh",
|
||||
"bash",
|
||||
"zsh",
|
||||
"fish",
|
||||
"rb",
|
||||
"go",
|
||||
"java",
|
||||
"kt",
|
||||
"c",
|
||||
"cpp",
|
||||
"h",
|
||||
"hpp",
|
||||
"swift",
|
||||
"lua",
|
||||
"sql",
|
||||
"graphql",
|
||||
"env",
|
||||
"ini",
|
||||
"cfg",
|
||||
"conf",
|
||||
"properties",
|
||||
"makefile",
|
||||
"dockerfile",
|
||||
"gitignore",
|
||||
"editorconfig",
|
||||
"mdx",
|
||||
"svelte",
|
||||
"vue",
|
||||
"astro",
|
||||
"tf",
|
||||
"hcl",
|
||||
"nix",
|
||||
"zig",
|
||||
"hs",
|
||||
"ml",
|
||||
"ex",
|
||||
"exs",
|
||||
"erl",
|
||||
"clj",
|
||||
"lisp",
|
||||
"el",
|
||||
"vim",
|
||||
"r",
|
||||
"jl",
|
||||
"ps1",
|
||||
"bat",
|
||||
"cmd",
|
||||
];
|
||||
|
||||
/// Classify a file extension into "markdown", "text", or "binary".
|
||||
pub(crate) fn classify_file_kind(path: &Path) -> &'static str {
|
||||
let ext = match path.extension() {
|
||||
Some(e) => e.to_string_lossy().to_lowercase(),
|
||||
None => {
|
||||
// Files without extension: check if name itself is a known text file
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().to_lowercase())
|
||||
.unwrap_or_default();
|
||||
return if [
|
||||
"makefile",
|
||||
"dockerfile",
|
||||
"rakefile",
|
||||
"gemfile",
|
||||
"procfile",
|
||||
"brewfile",
|
||||
".gitignore",
|
||||
".gitattributes",
|
||||
".editorconfig",
|
||||
".env",
|
||||
]
|
||||
.contains(&name.as_str())
|
||||
{
|
||||
"text"
|
||||
} else {
|
||||
"binary"
|
||||
};
|
||||
}
|
||||
};
|
||||
if ext == "md" || ext == "markdown" {
|
||||
"markdown"
|
||||
} else if TEXT_EXTENSIONS.contains(&ext.as_str()) {
|
||||
"text"
|
||||
} else {
|
||||
"binary"
|
||||
}
|
||||
}
|
||||
|
||||
use crate::git::GitDates;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn lookup_git_dates(
|
||||
path: &Path,
|
||||
vault_path: &Path,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
) -> Option<(u64, u64)> {
|
||||
let rel = path
|
||||
.strip_prefix(vault_path)
|
||||
.ok()?
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
git_dates.get(&rel).map(|d| (d.modified_at, d.created_at))
|
||||
}
|
||||
|
||||
fn try_parse_file(
|
||||
path: &Path,
|
||||
vault_path: &Path,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
entries: &mut Vec<VaultEntry>,
|
||||
) {
|
||||
let dates = lookup_git_dates(path, vault_path, git_dates);
|
||||
let result = if is_md_file(path) {
|
||||
parse_md_file(path, dates)
|
||||
} else {
|
||||
parse_non_md_file(path, dates)
|
||||
};
|
||||
match result {
|
||||
Ok(vault_entry) => entries.push(vault_entry),
|
||||
Err(e) => log::warn!("Skipping file: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan all .md files in the vault, including subdirectories.
|
||||
/// Scan all files in the vault, including subdirectories.
|
||||
/// Hidden directories (starting with `.`) are excluded.
|
||||
fn scan_all_md_files(vault_path: &Path, entries: &mut Vec<VaultEntry>) {
|
||||
fn scan_all_files(
|
||||
vault_path: &Path,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
entries: &mut Vec<VaultEntry>,
|
||||
) {
|
||||
let walker = WalkDir::new(vault_path)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
@@ -152,14 +342,23 @@ fn scan_all_md_files(vault_path: &Path, entries: &mut Vec<VaultEntry>) {
|
||||
true
|
||||
});
|
||||
for entry in walker.filter_map(|e| e.ok()) {
|
||||
if is_md_file(entry.path()) {
|
||||
try_parse_md(entry.path(), entries);
|
||||
if entry.path().is_file() {
|
||||
// Skip hidden files (starting with '.') — e.g. .gitignore, .DS_Store
|
||||
let fname = entry.file_name().to_string_lossy();
|
||||
if fname.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
try_parse_file(entry.path(), vault_path, git_dates, entries);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a directory recursively for .md files and return VaultEntry for each.
|
||||
pub fn scan_vault(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
/// Scan a directory recursively for all files and return VaultEntry for each.
|
||||
/// Pass an empty map for `git_dates` to use filesystem dates only.
|
||||
pub fn scan_vault(
|
||||
vault_path: &Path,
|
||||
git_dates: &HashMap<String, GitDates>,
|
||||
) -> Result<Vec<VaultEntry>, String> {
|
||||
if !vault_path.exists() {
|
||||
return Err(format!(
|
||||
"Vault path does not exist: {}",
|
||||
@@ -174,7 +373,7 @@ pub fn scan_vault(vault_path: &Path) -> Result<Vec<VaultEntry>, String> {
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
scan_all_md_files(vault_path, &mut entries);
|
||||
scan_all_files(vault_path, git_dates, &mut entries);
|
||||
|
||||
entries.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
||||
Ok(entries)
|
||||
|
||||
@@ -15,7 +15,7 @@ fn create_test_file(dir: &Path, name: &str, content: &str) {
|
||||
|
||||
fn parse_test_entry(dir: &TempDir, name: &str, content: &str) -> VaultEntry {
|
||||
create_test_file(dir.path(), name, content);
|
||||
parse_md_file(&dir.path().join(name)).unwrap()
|
||||
parse_md_file(&dir.path().join(name), None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -108,7 +108,7 @@ fn test_parse_no_frontmatter() {
|
||||
let content = "# A Note Without Frontmatter\n\nJust markdown.";
|
||||
create_test_file(dir.path(), "a-note-without-frontmatter.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("a-note-without-frontmatter.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("a-note-without-frontmatter.md"), None).unwrap();
|
||||
// No title in frontmatter → derived from filename
|
||||
assert_eq!(entry.title, "A Note Without Frontmatter");
|
||||
}
|
||||
@@ -119,7 +119,7 @@ fn test_parse_single_string_aliases() {
|
||||
let content = "---\naliases: SingleAlias\n---\n# Test\n";
|
||||
create_test_file(dir.path(), "single-alias.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("single-alias.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("single-alias.md"), None).unwrap();
|
||||
assert_eq!(entry.aliases, vec!["SingleAlias"]);
|
||||
}
|
||||
|
||||
@@ -133,15 +133,27 @@ fn test_scan_vault_root_and_protected_folders() {
|
||||
"---\ntype: Type\n---\n# Project\n",
|
||||
);
|
||||
create_test_file(dir.path(), "attachments/notes.md", "# Attachment note\n");
|
||||
create_test_file(dir.path(), "not-markdown.txt", "This should be ignored");
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"not-markdown.txt",
|
||||
"This should be included as text",
|
||||
);
|
||||
|
||||
let entries = scan_vault(dir.path()).unwrap();
|
||||
assert_eq!(entries.len(), 3);
|
||||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||||
assert_eq!(entries.len(), 4);
|
||||
|
||||
let filenames: Vec<&str> = entries.iter().map(|e| e.filename.as_str()).collect();
|
||||
assert!(filenames.contains(&"root.md"));
|
||||
assert!(filenames.contains(&"project.md"));
|
||||
assert!(filenames.contains(&"notes.md"));
|
||||
assert!(filenames.contains(&"not-markdown.txt"));
|
||||
|
||||
let txt_entry = entries
|
||||
.iter()
|
||||
.find(|e| e.filename == "not-markdown.txt")
|
||||
.unwrap();
|
||||
assert_eq!(txt_entry.file_kind, "text");
|
||||
assert_eq!(txt_entry.title, "not-markdown.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -159,7 +171,7 @@ fn test_scan_vault_includes_subdirectory_notes() {
|
||||
"---\ntype: Project\n---\n# Old\n",
|
||||
);
|
||||
|
||||
let entries = scan_vault(dir.path()).unwrap();
|
||||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||||
assert_eq!(
|
||||
entries.len(),
|
||||
3,
|
||||
@@ -178,7 +190,7 @@ fn test_scan_vault_includes_all_protected_folders() {
|
||||
create_test_file(dir.path(), "attachments/notes.md", "# Attachment note\n");
|
||||
create_test_file(dir.path(), "assets/image.md", "# Asset\n");
|
||||
|
||||
let entries = scan_vault(dir.path()).unwrap();
|
||||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||||
assert_eq!(entries.len(), 3);
|
||||
}
|
||||
|
||||
@@ -189,14 +201,17 @@ fn test_scan_vault_skips_hidden_folders() {
|
||||
create_test_file(dir.path(), ".laputa/cache.md", "# Cache\n");
|
||||
create_test_file(dir.path(), ".git/objects.md", "# Git\n");
|
||||
|
||||
let entries = scan_vault(dir.path()).unwrap();
|
||||
let entries = scan_vault(dir.path(), &HashMap::new()).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].filename, "root.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_vault_nonexistent_path() {
|
||||
let result = scan_vault(Path::new("/nonexistent/path/that/does/not/exist"));
|
||||
let result = scan_vault(
|
||||
Path::new("/nonexistent/path/that/does/not/exist"),
|
||||
&HashMap::new(),
|
||||
);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -207,7 +222,7 @@ fn test_parse_malformed_yaml() {
|
||||
let content = "---\nIs A: [unclosed bracket\n---\n# Malformed\n";
|
||||
create_test_file(dir.path(), "malformed.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("malformed.md"));
|
||||
let entry = parse_md_file(&dir.path().join("malformed.md"), None);
|
||||
// Should still succeed — gray_matter may parse partially or skip
|
||||
assert!(entry.is_ok());
|
||||
}
|
||||
@@ -236,7 +251,7 @@ fn test_parse_md_file_has_snippet() {
|
||||
let content = "---\nIs A: Note\n---\n# Test Note\n\nHello, world! This is a snippet.";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
assert_eq!(entry.snippet, "Hello, world! This is a snippet.");
|
||||
}
|
||||
|
||||
@@ -247,7 +262,7 @@ fn test_parse_md_file_has_word_count() {
|
||||
"---\nIs A: Note\n---\n# Test Note\n\nHello world. This is a test with seven words.";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
assert_eq!(entry.word_count, 9);
|
||||
}
|
||||
|
||||
@@ -257,7 +272,7 @@ fn test_parse_md_file_word_count_empty_body() {
|
||||
let content = "---\nIs A: Note\n---\n# Empty Note\n";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
assert_eq!(entry.word_count, 0);
|
||||
}
|
||||
|
||||
@@ -278,7 +293,7 @@ Status: Active
|
||||
"#;
|
||||
create_test_file(dir.path(), "publish-essays.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("publish-essays.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("publish-essays.md"), None).unwrap();
|
||||
assert_eq!(entry.relationships.len(), 3); // Has, Topics, Type
|
||||
assert_eq!(
|
||||
entry.relationships.get("Has").unwrap(),
|
||||
@@ -310,7 +325,7 @@ Belongs to:
|
||||
"#;
|
||||
create_test_file(dir.path(), "some-project.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("some-project.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("some-project.md"), None).unwrap();
|
||||
|
||||
// Owner with wikilink should appear in relationships
|
||||
assert!(entry.relationships.get("Owner").is_some());
|
||||
@@ -342,7 +357,7 @@ Custom Field: just a plain string
|
||||
"#;
|
||||
create_test_file(dir.path(), "plain-note.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("plain-note.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("plain-note.md"), None).unwrap();
|
||||
// Tags and Custom Field don't contain wikilinks — only the auto-generated "Type" relationship
|
||||
assert_eq!(entry.relationships.len(), 1);
|
||||
assert_eq!(
|
||||
@@ -404,7 +419,7 @@ Context: "[[area/research]]"
|
||||
"#;
|
||||
create_test_file(dir.path(), "single-vs-array.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("single-vs-array.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("single-vs-array.md"), None).unwrap();
|
||||
|
||||
// Single string → Vec with one element
|
||||
assert_eq!(
|
||||
@@ -481,7 +496,7 @@ References:
|
||||
"#;
|
||||
create_test_file(dir.path(), "mixed-array.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("mixed-array.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("mixed-array.md"), None).unwrap();
|
||||
|
||||
// Only the wikilink entries should be captured
|
||||
assert_eq!(
|
||||
@@ -545,7 +560,7 @@ title: No Code
|
||||
# No Code
|
||||
"#;
|
||||
create_test_file(dir.path(), "no-code.md", content);
|
||||
let entry = parse_md_file(&dir.path().join("no-code.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("no-code.md"), None).unwrap();
|
||||
|
||||
let notes = entry
|
||||
.relationships
|
||||
@@ -572,7 +587,7 @@ title: No Code
|
||||
fn test_type_from_frontmatter_only() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "test.md", "---\ntype: Custom\n---\n# Test\n");
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
assert_eq!(entry.is_a, Some("Custom".to_string()));
|
||||
}
|
||||
|
||||
@@ -580,7 +595,7 @@ fn test_type_from_frontmatter_only() {
|
||||
fn test_no_type_when_frontmatter_missing() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "note/test.md", "# Test\n");
|
||||
let entry = parse_md_file(&dir.path().join("note/test.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("note/test.md"), None).unwrap();
|
||||
assert_eq!(entry.is_a, None, "type should not be inferred from folder");
|
||||
}
|
||||
|
||||
@@ -592,7 +607,7 @@ fn test_created_at_from_filesystem() {
|
||||
let content = "---\nIs A: Note\n---\n# Test\n";
|
||||
create_test_file(dir.path(), "test.md", content);
|
||||
|
||||
let entry = parse_md_file(&dir.path().join("test.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("test.md"), None).unwrap();
|
||||
// created_at should be set from filesystem metadata (not None)
|
||||
assert!(
|
||||
entry.created_at.is_some(),
|
||||
@@ -1077,6 +1092,54 @@ fn test_parse_trashed_no() {
|
||||
assert!(!entry.trashed, "'Trashed: No' must be parsed as false");
|
||||
}
|
||||
|
||||
// --- new canonical underscore-prefixed keys ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_underscore_trashed_canonical() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\n_trashed: true\n_trashed_at: \"2026-03-15\"\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone-new.md", content);
|
||||
assert!(entry.trashed, "'_trashed: true' must be parsed as trashed");
|
||||
assert!(
|
||||
entry.trashed_at.is_some(),
|
||||
"'_trashed_at' must be parsed as trashed_at"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_underscore_archived_canonical() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\n_archived: true\n---\n# Old\n";
|
||||
let entry = parse_test_entry(&dir, "old-new.md", content);
|
||||
assert!(
|
||||
entry.archived,
|
||||
"'_archived: true' must be parsed as archived"
|
||||
);
|
||||
}
|
||||
|
||||
// --- favorite field tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_favorite_true() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\n_favorite: true\n_favorite_index: 3\n---\n# Fav\n";
|
||||
let entry = parse_test_entry(&dir, "fav.md", content);
|
||||
assert!(
|
||||
entry.favorite,
|
||||
"'_favorite: true' must be parsed as favorite"
|
||||
);
|
||||
assert_eq!(entry.favorite_index, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_favorite_absent_defaults_false() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Note\n---\n# Not Fav\n";
|
||||
let entry = parse_test_entry(&dir, "not-fav.md", content);
|
||||
assert!(!entry.favorite, "absent _favorite must default to false");
|
||||
assert_eq!(entry.favorite_index, None);
|
||||
}
|
||||
|
||||
// --- visible field tests ---
|
||||
|
||||
#[test]
|
||||
@@ -1168,7 +1231,7 @@ fn test_parse_real_engineering_management_file() {
|
||||
if !path.exists() {
|
||||
return; // Skip when the Laputa vault is not available
|
||||
}
|
||||
let entry = parse_md_file(path).unwrap();
|
||||
let entry = parse_md_file(path, None).unwrap();
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"engineering-management.md must be trashed (has Trashed: true in frontmatter)"
|
||||
@@ -1204,7 +1267,7 @@ fn test_fallback_parser_extracts_trashed_from_malformed_yaml() {
|
||||
];
|
||||
let content = fm.join("\n");
|
||||
create_test_file(dir.path(), "eng-mgmt.md", &content);
|
||||
let entry = parse_md_file(&dir.path().join("eng-mgmt.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("eng-mgmt.md"), None).unwrap();
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even when YAML is malformed (fallback parser)"
|
||||
@@ -1231,7 +1294,7 @@ fn test_fallback_parser_extracts_archived_from_malformed_yaml() {
|
||||
];
|
||||
let content = fm.join("\n");
|
||||
create_test_file(dir.path(), "archived-essay.md", &content);
|
||||
let entry = parse_md_file(&dir.path().join("archived-essay.md")).unwrap();
|
||||
let entry = parse_md_file(&dir.path().join("archived-essay.md"), None).unwrap();
|
||||
assert!(
|
||||
entry.archived,
|
||||
"Archived must be true even when YAML is malformed"
|
||||
@@ -1394,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
|
||||
|
||||
@@ -14,7 +14,10 @@ fn extract_trashed_at_string(data: &Option<gray_matter::Pod>) -> Option<String>
|
||||
let gray_matter::Pod::Hash(ref map) = data.as_ref()? else {
|
||||
return None;
|
||||
};
|
||||
let pod = map.get("Trashed at").or_else(|| map.get("trashed_at"))?;
|
||||
let pod = map
|
||||
.get("_trashed_at")
|
||||
.or_else(|| map.get("Trashed at"))
|
||||
.or_else(|| map.get("trashed_at"))?;
|
||||
match pod {
|
||||
gray_matter::Pod::String(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
@@ -74,7 +77,11 @@ pub fn is_file_trashed(path: &Path) -> bool {
|
||||
|
||||
// Check for "Trashed: true"
|
||||
if let Some(gray_matter::Pod::Hash(ref map)) = parsed.data {
|
||||
if let Some(pod) = map.get("Trashed").or_else(|| map.get("trashed")) {
|
||||
if let Some(pod) = map
|
||||
.get("_trashed")
|
||||
.or_else(|| map.get("Trashed"))
|
||||
.or_else(|| map.get("trashed"))
|
||||
{
|
||||
return match pod {
|
||||
gray_matter::Pod::Boolean(b) => *b,
|
||||
gray_matter::Pod::String(s) => {
|
||||
@@ -373,6 +380,28 @@ mod tests {
|
||||
assert!(!is_file_trashed(Path::new("/nonexistent/path.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_underscore_trashed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\n_trashed: true\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_underscore_trashed_at() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\n_trashed_at: \"2026-03-15\"\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_false() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
767
src-tauri/src/vault/views.rs
Normal file
767
src-tauri/src/vault/views.rs
Normal file
@@ -0,0 +1,767 @@
|
||||
use serde::de::{self, MapAccess, Visitor};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::VaultEntry;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ViewDefinition {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
#[serde(default)]
|
||||
pub color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sort: Option<String>,
|
||||
pub filters: FilterGroup,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FilterGroup {
|
||||
All(Vec<FilterNode>),
|
||||
Any(Vec<FilterNode>),
|
||||
}
|
||||
|
||||
impl Serialize for FilterGroup {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
use serde::ser::SerializeMap;
|
||||
let mut map = serializer.serialize_map(Some(1))?;
|
||||
match self {
|
||||
FilterGroup::All(nodes) => map.serialize_entry("all", nodes)?,
|
||||
FilterGroup::Any(nodes) => map.serialize_entry("any", nodes)?,
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FilterGroup {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
struct FilterGroupVisitor;
|
||||
|
||||
impl<'de> Visitor<'de> for FilterGroupVisitor {
|
||||
type Value = FilterGroup;
|
||||
|
||||
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.write_str("a map with key 'all' or 'any'")
|
||||
}
|
||||
|
||||
fn visit_map<M: MapAccess<'de>>(self, mut map: M) -> Result<FilterGroup, M::Error> {
|
||||
let key: String = map
|
||||
.next_key()?
|
||||
.ok_or_else(|| de::Error::custom("expected 'all' or 'any' key"))?;
|
||||
match key.as_str() {
|
||||
"all" => {
|
||||
let nodes: Vec<FilterNode> = map.next_value()?;
|
||||
Ok(FilterGroup::All(nodes))
|
||||
}
|
||||
"any" => {
|
||||
let nodes: Vec<FilterNode> = map.next_value()?;
|
||||
Ok(FilterGroup::Any(nodes))
|
||||
}
|
||||
other => Err(de::Error::unknown_field(other, &["all", "any"])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deserializer.deserialize_map(FilterGroupVisitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum FilterNode {
|
||||
Condition(FilterCondition),
|
||||
Group(FilterGroup),
|
||||
}
|
||||
|
||||
impl Serialize for FilterNode {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
match self {
|
||||
FilterNode::Condition(c) => c.serialize(serializer),
|
||||
FilterNode::Group(g) => g.serialize(serializer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for FilterNode {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
// Deserialize into a generic YAML value, then try group first, then condition
|
||||
let value = serde_yaml::Value::deserialize(deserializer)?;
|
||||
if let serde_yaml::Value::Mapping(ref m) = value {
|
||||
// If the map has an "all" or "any" key, it's a group
|
||||
let all_key = serde_yaml::Value::String("all".to_string());
|
||||
let any_key = serde_yaml::Value::String("any".to_string());
|
||||
if m.contains_key(&all_key) || m.contains_key(&any_key) {
|
||||
let group: FilterGroup =
|
||||
serde_yaml::from_value(value).map_err(de::Error::custom)?;
|
||||
return Ok(FilterNode::Group(group));
|
||||
}
|
||||
}
|
||||
let cond: FilterCondition = serde_yaml::from_value(value).map_err(de::Error::custom)?;
|
||||
Ok(FilterNode::Condition(cond))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct FilterCondition {
|
||||
pub field: String,
|
||||
pub op: FilterOp,
|
||||
#[serde(default)]
|
||||
pub value: Option<serde_yaml::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub enum FilterOp {
|
||||
#[serde(rename = "equals")]
|
||||
Equals,
|
||||
#[serde(rename = "not_equals")]
|
||||
NotEquals,
|
||||
#[serde(rename = "contains")]
|
||||
Contains,
|
||||
#[serde(rename = "not_contains")]
|
||||
NotContains,
|
||||
#[serde(rename = "any_of")]
|
||||
AnyOf,
|
||||
#[serde(rename = "none_of")]
|
||||
NoneOf,
|
||||
#[serde(rename = "is_empty")]
|
||||
IsEmpty,
|
||||
#[serde(rename = "is_not_empty")]
|
||||
IsNotEmpty,
|
||||
#[serde(rename = "before")]
|
||||
Before,
|
||||
#[serde(rename = "after")]
|
||||
After,
|
||||
}
|
||||
|
||||
/// A view file on disk: filename + parsed definition.
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct ViewFile {
|
||||
pub filename: String,
|
||||
pub definition: ViewDefinition,
|
||||
}
|
||||
|
||||
/// Migrate views from `.laputa/views/` to `views/` in the vault root (one-time).
|
||||
pub fn migrate_views(vault_path: &Path) {
|
||||
let old_dir = vault_path.join(".laputa").join("views");
|
||||
if !old_dir.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
let entries = match fs::read_dir(&old_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let yml_files: Vec<_> = entries
|
||||
.flatten()
|
||||
.filter(|e| e.path().extension().and_then(|ext| ext.to_str()) == Some("yml"))
|
||||
.collect();
|
||||
|
||||
if yml_files.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let new_dir = vault_path.join("views");
|
||||
if fs::create_dir_all(&new_dir).is_err() {
|
||||
log::warn!("Failed to create views/ directory for migration");
|
||||
return;
|
||||
}
|
||||
|
||||
for entry in yml_files {
|
||||
let src = entry.path();
|
||||
let dst = new_dir.join(entry.file_name());
|
||||
if !dst.exists() {
|
||||
if let Err(e) = fs::rename(&src, &dst) {
|
||||
log::warn!("Failed to migrate view {:?}: {}", src, e);
|
||||
} else {
|
||||
log::info!("Migrated view {:?} → {:?}", src, dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up old directory if empty
|
||||
if fs::read_dir(&old_dir)
|
||||
.map(|mut d| d.next().is_none())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let _ = fs::remove_dir(&old_dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan all `.yml` files from `vault_path/views/` and return parsed views.
|
||||
pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
|
||||
migrate_views(vault_path);
|
||||
let views_dir = vault_path.join("views");
|
||||
if !views_dir.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut views = Vec::new();
|
||||
let entries = match fs::read_dir(&views_dir) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read views directory: {}", e);
|
||||
return Vec::new();
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("yml") {
|
||||
continue;
|
||||
}
|
||||
let filename = entry.file_name().to_string_lossy().to_string();
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(content) => match serde_yaml::from_str::<ViewDefinition>(&content) {
|
||||
Ok(definition) => views.push(ViewFile {
|
||||
filename,
|
||||
definition,
|
||||
}),
|
||||
Err(e) => log::warn!("Failed to parse view {}: {}", filename, e),
|
||||
},
|
||||
Err(e) => log::warn!("Failed to read view file {}: {}", filename, e),
|
||||
}
|
||||
}
|
||||
|
||||
views.sort_by(|a, b| a.filename.cmp(&b.filename));
|
||||
views
|
||||
}
|
||||
|
||||
/// Save a view definition as YAML to `vault_path/views/{filename}`.
|
||||
pub fn save_view(
|
||||
vault_path: &Path,
|
||||
filename: &str,
|
||||
definition: &ViewDefinition,
|
||||
) -> Result<(), String> {
|
||||
if !filename.ends_with(".yml") {
|
||||
return Err("Filename must end with .yml".to_string());
|
||||
}
|
||||
let views_dir = vault_path.join("views");
|
||||
fs::create_dir_all(&views_dir)
|
||||
.map_err(|e| format!("Failed to create views directory: {}", e))?;
|
||||
let yaml = serde_yaml::to_string(definition)
|
||||
.map_err(|e| format!("Failed to serialize view: {}", e))?;
|
||||
fs::write(views_dir.join(filename), yaml)
|
||||
.map_err(|e| format!("Failed to write view file: {}", e))
|
||||
}
|
||||
|
||||
/// Delete a view file at `vault_path/views/{filename}`.
|
||||
pub fn delete_view(vault_path: &Path, filename: &str) -> Result<(), String> {
|
||||
let path = vault_path.join("views").join(filename);
|
||||
fs::remove_file(&path).map_err(|e| format!("Failed to delete view: {}", e))
|
||||
}
|
||||
|
||||
/// Evaluate a view definition against vault entries, returning indices of matching entries.
|
||||
pub fn evaluate_view(definition: &ViewDefinition, entries: &[VaultEntry]) -> Vec<usize> {
|
||||
entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, entry)| evaluate_group(&definition.filters, entry))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn evaluate_group(group: &FilterGroup, entry: &VaultEntry) -> bool {
|
||||
match group {
|
||||
FilterGroup::All(nodes) => nodes.iter().all(|n| evaluate_node(n, entry)),
|
||||
FilterGroup::Any(nodes) => nodes.iter().any(|n| evaluate_node(n, entry)),
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_node(node: &FilterNode, entry: &VaultEntry) -> bool {
|
||||
match node {
|
||||
FilterNode::Condition(cond) => evaluate_condition(cond, entry),
|
||||
FilterNode::Group(group) => evaluate_group(group, entry),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the stem from a wikilink: `[[target|Alias]]` -> `target`, `[[target]]` -> `target`.
|
||||
fn wikilink_stem(link: &str) -> &str {
|
||||
let s = link
|
||||
.strip_prefix("[[")
|
||||
.unwrap_or(link)
|
||||
.strip_suffix("]]")
|
||||
.unwrap_or(link);
|
||||
match s.split_once('|') {
|
||||
Some((stem, _)) => stem,
|
||||
None => s,
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
|
||||
let field = cond.field.as_str();
|
||||
|
||||
// Boolean fields
|
||||
match field {
|
||||
"archived" => return evaluate_bool_field(entry.archived, &cond.op, &cond.value),
|
||||
"trashed" => return evaluate_bool_field(entry.trashed, &cond.op, &cond.value),
|
||||
"favorite" => return evaluate_bool_field(entry.favorite, &cond.op, &cond.value),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// String/option fields
|
||||
let field_value: Option<String> = match field {
|
||||
"type" | "isA" => entry.is_a.clone(),
|
||||
"status" => entry.status.clone(),
|
||||
"title" => Some(entry.title.clone()),
|
||||
_ => {
|
||||
// Check properties first, then relationships
|
||||
if let Some(prop) = entry.properties.get(field) {
|
||||
match prop {
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
serde_json::Value::Number(n) => Some(n.to_string()),
|
||||
serde_json::Value::Bool(b) => Some(b.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
} else if let Some(rels) = entry.relationships.get(field) {
|
||||
// For relationship fields, handle specially in contains/any_of etc.
|
||||
return evaluate_relationship_op(&cond.op, rels, &cond.value);
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let cond_value = cond.value.as_ref().and_then(yaml_value_to_string);
|
||||
|
||||
match cond.op {
|
||||
FilterOp::Equals => match (&field_value, &cond_value) {
|
||||
(Some(f), Some(v)) => f.eq_ignore_ascii_case(v),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
},
|
||||
FilterOp::NotEquals => match (&field_value, &cond_value) {
|
||||
(Some(f), Some(v)) => !f.eq_ignore_ascii_case(v),
|
||||
(None, None) => false,
|
||||
_ => true,
|
||||
},
|
||||
FilterOp::Contains => match (&field_value, &cond_value) {
|
||||
(Some(f), Some(v)) => f.to_lowercase().contains(&v.to_lowercase()),
|
||||
_ => false,
|
||||
},
|
||||
FilterOp::NotContains => match (&field_value, &cond_value) {
|
||||
(Some(f), Some(v)) => !f.to_lowercase().contains(&v.to_lowercase()),
|
||||
(None, _) => true,
|
||||
_ => true,
|
||||
},
|
||||
FilterOp::AnyOf => {
|
||||
let values = cond
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(yaml_value_to_string_vec)
|
||||
.unwrap_or_default();
|
||||
match &field_value {
|
||||
Some(f) => values.iter().any(|v| f.eq_ignore_ascii_case(v)),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
FilterOp::NoneOf => {
|
||||
let values = cond
|
||||
.value
|
||||
.as_ref()
|
||||
.and_then(yaml_value_to_string_vec)
|
||||
.unwrap_or_default();
|
||||
match &field_value {
|
||||
Some(f) => !values.iter().any(|v| f.eq_ignore_ascii_case(v)),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
FilterOp::IsEmpty => field_value.as_deref().map_or(true, |s| s.is_empty()),
|
||||
FilterOp::IsNotEmpty => field_value.as_deref().is_some_and(|s| !s.is_empty()),
|
||||
FilterOp::Before => match (&field_value, &cond_value) {
|
||||
(Some(f), Some(v)) => f < v,
|
||||
_ => false,
|
||||
},
|
||||
FilterOp::After => match (&field_value, &cond_value) {
|
||||
(Some(f), Some(v)) => f > v,
|
||||
_ => false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_bool_field(field_val: bool, op: &FilterOp, value: &Option<serde_yaml::Value>) -> bool {
|
||||
match op {
|
||||
FilterOp::Equals => {
|
||||
let expected = value.as_ref().and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
field_val == expected
|
||||
}
|
||||
FilterOp::NotEquals => {
|
||||
let expected = value.as_ref().and_then(|v| v.as_bool()).unwrap_or(true);
|
||||
field_val != expected
|
||||
}
|
||||
FilterOp::IsEmpty => !field_val,
|
||||
FilterOp::IsNotEmpty => field_val,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn evaluate_relationship_op(
|
||||
op: &FilterOp,
|
||||
rels: &[String],
|
||||
value: &Option<serde_yaml::Value>,
|
||||
) -> bool {
|
||||
match op {
|
||||
FilterOp::Contains => {
|
||||
let target = value.as_ref().and_then(yaml_value_to_string);
|
||||
match target {
|
||||
Some(t) => {
|
||||
let t_stem = wikilink_stem(&t).to_lowercase();
|
||||
rels.iter()
|
||||
.any(|r| wikilink_stem(r).to_lowercase() == t_stem)
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
FilterOp::NotContains => {
|
||||
let target = value.as_ref().and_then(yaml_value_to_string);
|
||||
match target {
|
||||
Some(t) => {
|
||||
let t_stem = wikilink_stem(&t).to_lowercase();
|
||||
!rels
|
||||
.iter()
|
||||
.any(|r| wikilink_stem(r).to_lowercase() == t_stem)
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
FilterOp::AnyOf => {
|
||||
let values = value
|
||||
.as_ref()
|
||||
.and_then(yaml_value_to_string_vec)
|
||||
.unwrap_or_default();
|
||||
rels.iter().any(|r| {
|
||||
let r_stem = wikilink_stem(r).to_lowercase();
|
||||
values
|
||||
.iter()
|
||||
.any(|v| wikilink_stem(v).to_lowercase() == r_stem)
|
||||
})
|
||||
}
|
||||
FilterOp::NoneOf => {
|
||||
let values = value
|
||||
.as_ref()
|
||||
.and_then(yaml_value_to_string_vec)
|
||||
.unwrap_or_default();
|
||||
!rels.iter().any(|r| {
|
||||
let r_stem = wikilink_stem(r).to_lowercase();
|
||||
values
|
||||
.iter()
|
||||
.any(|v| wikilink_stem(v).to_lowercase() == r_stem)
|
||||
})
|
||||
}
|
||||
FilterOp::IsEmpty => rels.is_empty(),
|
||||
FilterOp::IsNotEmpty => !rels.is_empty(),
|
||||
FilterOp::Equals => {
|
||||
let target = value.as_ref().and_then(yaml_value_to_string);
|
||||
match target {
|
||||
Some(t) => {
|
||||
rels.len() == 1
|
||||
&& wikilink_stem(&rels[0]).to_lowercase()
|
||||
== wikilink_stem(&t).to_lowercase()
|
||||
}
|
||||
None => rels.is_empty(),
|
||||
}
|
||||
}
|
||||
FilterOp::NotEquals => {
|
||||
let target = value.as_ref().and_then(yaml_value_to_string);
|
||||
match target {
|
||||
Some(t) => {
|
||||
rels.len() != 1
|
||||
|| wikilink_stem(&rels[0]).to_lowercase()
|
||||
!= wikilink_stem(&t).to_lowercase()
|
||||
}
|
||||
None => !rels.is_empty(),
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn yaml_value_to_string(v: &serde_yaml::Value) -> Option<String> {
|
||||
match v {
|
||||
serde_yaml::Value::String(s) => Some(s.clone()),
|
||||
serde_yaml::Value::Number(n) => Some(n.to_string()),
|
||||
serde_yaml::Value::Bool(b) => Some(b.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn yaml_value_to_string_vec(v: &serde_yaml::Value) -> Option<Vec<String>> {
|
||||
v.as_sequence()
|
||||
.map(|seq| seq.iter().filter_map(yaml_value_to_string).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn make_entry(overrides: impl FnOnce(&mut VaultEntry)) -> VaultEntry {
|
||||
let mut entry = VaultEntry::default();
|
||||
overrides(&mut entry);
|
||||
entry
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_simple_view() {
|
||||
let yaml = r#"
|
||||
name: Active Projects
|
||||
icon: rocket
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(def.name, "Active Projects");
|
||||
assert_eq!(def.icon.as_deref(), Some("rocket"));
|
||||
match &def.filters {
|
||||
FilterGroup::All(nodes) => {
|
||||
assert_eq!(nodes.len(), 1);
|
||||
match &nodes[0] {
|
||||
FilterNode::Condition(c) => {
|
||||
assert_eq!(c.field, "type");
|
||||
}
|
||||
_ => panic!("Expected condition"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected All group"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_equals() {
|
||||
let yaml = r#"
|
||||
name: Projects
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let matching = make_entry(|e| e.is_a = Some("Project".to_string()));
|
||||
let non_matching = make_entry(|e| e.is_a = Some("Note".to_string()));
|
||||
let entries = vec![matching, non_matching];
|
||||
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_contains_relationship() {
|
||||
let yaml = r#"
|
||||
name: Related to Target
|
||||
filters:
|
||||
all:
|
||||
- field: Related to
|
||||
op: contains
|
||||
value: "[[target]]"
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let mut rels = HashMap::new();
|
||||
rels.insert(
|
||||
"Related to".to_string(),
|
||||
vec!["[[target]]".to_string(), "[[other]]".to_string()],
|
||||
);
|
||||
let matching = make_entry(|e| e.relationships = rels);
|
||||
|
||||
let non_matching = make_entry(|_| {});
|
||||
let entries = vec![matching, non_matching];
|
||||
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_nested_and_or() {
|
||||
let yaml = r#"
|
||||
name: Complex
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
- any:
|
||||
- field: status
|
||||
op: equals
|
||||
value: Active
|
||||
- field: status
|
||||
op: equals
|
||||
value: Planning
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
let active_project = make_entry(|e| {
|
||||
e.is_a = Some("Project".to_string());
|
||||
e.status = Some("Active".to_string());
|
||||
});
|
||||
let planning_project = make_entry(|e| {
|
||||
e.is_a = Some("Project".to_string());
|
||||
e.status = Some("Planning".to_string());
|
||||
});
|
||||
let done_project = make_entry(|e| {
|
||||
e.is_a = Some("Project".to_string());
|
||||
e.status = Some("Done".to_string());
|
||||
});
|
||||
let active_note = make_entry(|e| {
|
||||
e.is_a = Some("Note".to_string());
|
||||
e.status = Some("Active".to_string());
|
||||
});
|
||||
|
||||
let entries = vec![active_project, planning_project, done_project, active_note];
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_evaluate_is_empty() {
|
||||
let yaml_empty = r#"
|
||||
name: No Status
|
||||
filters:
|
||||
all:
|
||||
- field: status
|
||||
op: is_empty
|
||||
"#;
|
||||
let yaml_not_empty = r#"
|
||||
name: Has Status
|
||||
filters:
|
||||
all:
|
||||
- field: status
|
||||
op: is_not_empty
|
||||
"#;
|
||||
let def_empty: ViewDefinition = serde_yaml::from_str(yaml_empty).unwrap();
|
||||
let def_not_empty: ViewDefinition = serde_yaml::from_str(yaml_not_empty).unwrap();
|
||||
|
||||
let with_status = make_entry(|e| e.status = Some("Active".to_string()));
|
||||
let without_status = make_entry(|_| {});
|
||||
let entries = vec![with_status, without_status];
|
||||
|
||||
assert_eq!(evaluate_view(&def_empty, &entries), vec![1]);
|
||||
assert_eq!(evaluate_view(&def_not_empty, &entries), vec![0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_views_reads_yml_files() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let views_dir = dir.path().join("views");
|
||||
fs::create_dir_all(&views_dir).unwrap();
|
||||
|
||||
let yaml_a = "name: Alpha\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
|
||||
let yaml_b = "name: Beta\nfilters:\n any:\n - field: status\n op: equals\n value: Active\n";
|
||||
fs::write(views_dir.join("a-view.yml"), yaml_a).unwrap();
|
||||
fs::write(views_dir.join("b-view.yml"), yaml_b).unwrap();
|
||||
// Non-yml file should be ignored
|
||||
fs::write(views_dir.join("readme.txt"), "ignore me").unwrap();
|
||||
|
||||
let views = scan_views(dir.path());
|
||||
assert_eq!(views.len(), 2);
|
||||
assert_eq!(views[0].filename, "a-view.yml");
|
||||
assert_eq!(views[0].definition.name, "Alpha");
|
||||
assert_eq!(views[1].filename, "b-view.yml");
|
||||
assert_eq!(views[1].definition.name, "Beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_views_from_old_location() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let old_dir = dir.path().join(".laputa").join("views");
|
||||
fs::create_dir_all(&old_dir).unwrap();
|
||||
|
||||
let yaml = "name: Migrated\nfilters:\n all:\n - field: type\n op: equals\n value: Note\n";
|
||||
fs::write(old_dir.join("test.yml"), yaml).unwrap();
|
||||
|
||||
// scan_views should trigger migration and find the view
|
||||
let views = scan_views(dir.path());
|
||||
assert_eq!(views.len(), 1);
|
||||
assert_eq!(views[0].definition.name, "Migrated");
|
||||
|
||||
// File should now be in new location
|
||||
assert!(dir.path().join("views").join("test.yml").exists());
|
||||
// Old file should be gone
|
||||
assert!(!old_dir.join("test.yml").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_read_view() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let def = ViewDefinition {
|
||||
name: "Test View".to_string(),
|
||||
icon: Some("star".to_string()),
|
||||
color: None,
|
||||
sort: Some("modified:desc".to_string()),
|
||||
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(), "test.yml", &def).unwrap();
|
||||
|
||||
let views = scan_views(dir.path());
|
||||
assert_eq!(views.len(), 1);
|
||||
assert_eq!(views[0].definition.name, "Test View");
|
||||
assert_eq!(views[0].definition.icon.as_deref(), Some("star"));
|
||||
|
||||
delete_view(dir.path(), "test.yml").unwrap();
|
||||
let views = scan_views(dir.path());
|
||||
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#"
|
||||
name: Linked
|
||||
filters:
|
||||
all:
|
||||
- field: Topics
|
||||
op: contains
|
||||
value: "[[target]]"
|
||||
"#;
|
||||
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
|
||||
|
||||
// Entry with aliased wikilink
|
||||
let mut rels = HashMap::new();
|
||||
rels.insert("Topics".to_string(), vec!["[[target|Alias]]".to_string()]);
|
||||
let matching = make_entry(|e| e.relationships = rels);
|
||||
|
||||
// Entry with different target
|
||||
let mut rels2 = HashMap::new();
|
||||
rels2.insert("Topics".to_string(), vec!["[[other|Alias]]".to_string()]);
|
||||
let non_matching = make_entry(|e| e.relationships = rels2);
|
||||
|
||||
let entries = vec![matching, non_matching];
|
||||
let result = evaluate_view(&def, &entries);
|
||||
assert_eq!(result, vec![0]);
|
||||
}
|
||||
}
|
||||
@@ -64,11 +64,12 @@ const mockAllContent: Record<string, string> = {
|
||||
const mockCommandResults: Record<string, unknown> = {
|
||||
list_vault: mockEntries,
|
||||
list_vault_folders: [],
|
||||
list_views: [],
|
||||
get_all_content: mockAllContent,
|
||||
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,
|
||||
|
||||
90
src/App.tsx
90
src/App.tsx
@@ -1,9 +1,11 @@
|
||||
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'
|
||||
import { ResizeHandle } from './components/ResizeHandle'
|
||||
import { CreateTypeDialog } from './components/CreateTypeDialog'
|
||||
import { CreateViewDialog } from './components/CreateViewDialog'
|
||||
import { QuickOpenPalette } from './components/QuickOpenPalette'
|
||||
import { CommandPalette } from './components/CommandPalette'
|
||||
import { SearchPanel } from './components/SearchPanel'
|
||||
@@ -56,6 +58,7 @@ import { openNoteInNewWindow } from './utils/openNoteWindow'
|
||||
import { isNoteWindow, getNoteWindowParams } from './utils/windowMode'
|
||||
import { GitRequiredModal } from './components/GitRequiredModal'
|
||||
import { RenameDetectedBanner, type DetectedRename } from './components/RenameDetectedBanner'
|
||||
import { trackEvent } from './lib/telemetry'
|
||||
import './App.css'
|
||||
|
||||
// Type declarations for mock content storage and test overrides
|
||||
@@ -74,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')
|
||||
@@ -117,6 +120,14 @@ function App() {
|
||||
useVaultConfig(resolvedPath)
|
||||
const { settings, loaded: settingsLoaded, saveSettings } = useSettings()
|
||||
useTelemetry(settings, settingsLoaded)
|
||||
|
||||
const vaultOpenedRef = useRef('')
|
||||
useEffect(() => {
|
||||
if (vault.entries.length > 0 && gitRepoState !== 'checking' && resolvedPath !== vaultOpenedRef.current) {
|
||||
vaultOpenedRef.current = resolvedPath
|
||||
trackEvent('vault_opened', { has_git: gitRepoState === 'ready' ? 1 : 0, note_count: vault.entries.length })
|
||||
}
|
||||
}, [vault.entries.length, gitRepoState, resolvedPath])
|
||||
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
|
||||
|
||||
const autoSync = useAutoSync({
|
||||
@@ -279,7 +290,7 @@ function App() {
|
||||
} else {
|
||||
await mockInvoke('create_vault_folder', { vaultPath: resolvedPath, folderName: name })
|
||||
}
|
||||
await vault.reloadVault()
|
||||
await vault.reloadFolders()
|
||||
setToastMessage(`Created folder "${name}"`)
|
||||
} catch (e) {
|
||||
setToastMessage(`Failed to create folder: ${e}`)
|
||||
@@ -326,6 +337,70 @@ function App() {
|
||||
setToastMessage(`Type "${name}" created`)
|
||||
}, [notes])
|
||||
|
||||
const handleCreateOrUpdateView = useCallback(async (definition: import('./types').ViewDefinition) => {
|
||||
const editing = dialogs.editingView
|
||||
const filename = editing
|
||||
? editing.filename
|
||||
: definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml'
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition })
|
||||
trackEvent(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])
|
||||
|
||||
const handleEditView = useCallback((filename: string) => {
|
||||
const view = vault.views.find((v) => v.filename === filename)
|
||||
if (view) dialogs.openEditView(filename, view.definition)
|
||||
}, [vault.views, dialogs])
|
||||
|
||||
const handleDeleteView = useCallback(async (filename: string) => {
|
||||
const target = isTauri() ? invoke : mockInvoke
|
||||
await target('delete_view_cmd', { vaultPath: resolvedPath, filename })
|
||||
await vault.reloadViews()
|
||||
// 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' })
|
||||
}
|
||||
setToastMessage('View deleted')
|
||||
}, [resolvedPath, vault, selection, handleSetSelection])
|
||||
|
||||
const availableFields = useMemo(() => {
|
||||
const builtIn = ['type', 'status', 'title', 'favorite']
|
||||
if (!vault.entries?.length) return builtIn
|
||||
const customFields = new Set<string>()
|
||||
for (const e of vault.entries) {
|
||||
if (e.properties) {
|
||||
for (const key of Object.keys(e.properties)) customFields.add(key)
|
||||
}
|
||||
if (e.relationships) {
|
||||
for (const key of Object.keys(e.relationships)) customFields.add(key)
|
||||
}
|
||||
}
|
||||
return [...builtIn, ...Array.from(customFields).sort()]
|
||||
}, [vault.entries])
|
||||
|
||||
const valueSuggestionsForField = useCallback((field: string): string[] => {
|
||||
if (!vault.entries?.length) return []
|
||||
const values = new Set<string>()
|
||||
for (const e of vault.entries) {
|
||||
if (field === 'type' && e.isA) values.add(e.isA)
|
||||
else if (field === 'status' && e.status) values.add(e.status)
|
||||
else if (e.properties?.[field] != null) values.add(String(e.properties[field]))
|
||||
}
|
||||
return Array.from(values).sort()
|
||||
}, [vault.entries])
|
||||
|
||||
const bulkActions = useBulkActions(entryActions, setToastMessage)
|
||||
|
||||
// Raw-toggle ref: Editor registers its handleToggleRaw here so the command palette can call it
|
||||
@@ -337,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') {
|
||||
@@ -420,6 +495,7 @@ function App() {
|
||||
noteListFilter,
|
||||
onSetNoteListFilter: setNoteListFilter,
|
||||
onOpenInNewWindow: handleOpenInNewWindow,
|
||||
onToggleFavorite: entryActions.handleToggleFavorite,
|
||||
})
|
||||
|
||||
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
|
||||
@@ -428,7 +504,7 @@ function App() {
|
||||
|
||||
const aiNoteList = useMemo<NoteListItem[]>(() => {
|
||||
const isInbox = selection.kind === 'filter' && selection.filter === 'inbox'
|
||||
const filtered = isInbox ? filterInboxEntries(vault.entries, inboxPeriod) : filterEntries(vault.entries, selection)
|
||||
const filtered = isInbox ? filterInboxEntries(vault.entries, inboxPeriod) : filterEntries(vault.entries, selection, undefined, vault.views)
|
||||
return filtered.map(e => ({
|
||||
path: e.path, title: e.title, type: e.isA ?? 'Note',
|
||||
}))
|
||||
@@ -488,7 +564,7 @@ function App() {
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} inboxCount={inboxCount} />
|
||||
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={selection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={notes.handleSelectNote} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} inboxCount={inboxCount} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
@@ -499,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} />
|
||||
<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} />
|
||||
@@ -532,6 +608,7 @@ function App() {
|
||||
vaultPath={resolvedPath}
|
||||
noteList={aiNoteList}
|
||||
noteListFilter={aiNoteListFilter}
|
||||
onToggleFavorite={entryActions.handleToggleFavorite}
|
||||
onTrashNote={entryActions.handleTrashNote}
|
||||
onRestoreNote={entryActions.handleRestoreNote}
|
||||
onDeleteNote={deleteActions.handleDeleteNote}
|
||||
@@ -566,6 +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} 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}
|
||||
|
||||
@@ -146,6 +146,15 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — action buttons always right-aligned', () => {
|
||||
it('actions container has ml-auto so buttons are always right-aligned', () => {
|
||||
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
|
||||
const actions = container.querySelector('.breadcrumb-bar__actions')
|
||||
expect(actions).toBeInTheDocument()
|
||||
expect(actions).toHaveClass('ml-auto')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BreadcrumbBar — raw editor toggle', () => {
|
||||
it('shows Raw editor button with tooltip "Raw editor" when rawMode is off', () => {
|
||||
const onToggleRaw = vi.fn()
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ArrowCounterClockwise,
|
||||
Archive,
|
||||
ArrowUUpLeft,
|
||||
Star,
|
||||
} from '@phosphor-icons/react'
|
||||
|
||||
interface BreadcrumbBarProps {
|
||||
@@ -28,6 +29,7 @@ interface BreadcrumbBarProps {
|
||||
onToggleAIChat?: () => void
|
||||
inspectorCollapsed?: boolean
|
||||
onToggleInspector?: () => void
|
||||
onToggleFavorite?: () => void
|
||||
onTrash?: () => void
|
||||
onRestore?: () => void
|
||||
onArchive?: () => void
|
||||
@@ -56,10 +58,20 @@ function RawToggleButton({ rawMode, onToggleRaw }: { rawMode?: boolean; onToggle
|
||||
function BreadcrumbActions({ entry, showDiffToggle, diffMode, diffLoading, onToggleDiff,
|
||||
rawMode, onToggleRaw,
|
||||
showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
|
||||
onTrash, onRestore, onArchive, onUnarchive,
|
||||
onToggleFavorite, onTrash, onRestore, onArchive, onUnarchive,
|
||||
}: Omit<BreadcrumbBarProps, 'wordCount'>) {
|
||||
return (
|
||||
<div className="flex items-center" style={{ gap: 12 }}>
|
||||
<div className="breadcrumb-bar__actions ml-auto flex items-center" style={{ gap: 12 }}>
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors",
|
||||
entry.favorite ? "text-yellow-500" : "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
onClick={onToggleFavorite}
|
||||
title={entry.favorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<Star size={16} weight={entry.favorite ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
title="Search in file"
|
||||
|
||||
92
src/components/CreateViewDialog.test.tsx
Normal file
92
src/components/CreateViewDialog.test.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { CreateViewDialog } from './CreateViewDialog'
|
||||
import type { ViewDefinition } from '../types'
|
||||
|
||||
describe('CreateViewDialog', () => {
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
onClose: vi.fn(),
|
||||
onCreate: vi.fn(),
|
||||
availableFields: ['type', 'status', 'title'],
|
||||
}
|
||||
|
||||
it('shows "Create View" title in create mode', () => {
|
||||
render(<CreateViewDialog {...defaultProps} />)
|
||||
expect(screen.getByText('Create View')).toBeInTheDocument()
|
||||
expect(screen.getByText('Create')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Edit View" title when editingView is provided', () => {
|
||||
const editingView: ViewDefinition = {
|
||||
name: 'Active Projects',
|
||||
icon: '🚀',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
}
|
||||
render(<CreateViewDialog {...defaultProps} editingView={editingView} />)
|
||||
expect(screen.getByText('Edit View')).toBeInTheDocument()
|
||||
expect(screen.getByText('Save')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('pre-populates name field in edit mode', () => {
|
||||
const editingView: ViewDefinition = {
|
||||
name: 'Active Projects',
|
||||
icon: '🚀',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
}
|
||||
render(<CreateViewDialog {...defaultProps} editingView={editingView} />)
|
||||
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
|
||||
expect(input).toHaveValue('Active Projects')
|
||||
})
|
||||
|
||||
it('preserves emoji icon when editing a view', () => {
|
||||
const onCreate = vi.fn()
|
||||
const editingView: ViewDefinition = {
|
||||
name: 'Monday',
|
||||
icon: '🗂️',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
|
||||
}
|
||||
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} editingView={editingView} />)
|
||||
// Submit the form without changing anything
|
||||
fireEvent.submit(screen.getByText('Save').closest('form')!)
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ icon: '🗂️' })
|
||||
)
|
||||
})
|
||||
|
||||
it('passes selected emoji icon when creating a view', () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} />)
|
||||
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
|
||||
fireEvent.change(input, { target: { value: 'Test View' } })
|
||||
// Open emoji picker and select an emoji
|
||||
fireEvent.click(screen.getByTitle('Pick icon'))
|
||||
expect(screen.getByTestId('emoji-picker')).toBeInTheDocument()
|
||||
const emojiButtons = screen.getAllByTestId('emoji-option')
|
||||
fireEvent.click(emojiButtons[0])
|
||||
// Submit the form
|
||||
fireEvent.click(screen.getByText('Create'))
|
||||
expect(onCreate).toHaveBeenCalledTimes(1)
|
||||
const definition = onCreate.mock.calls[0][0] as ViewDefinition
|
||||
expect(definition.icon).not.toBeNull()
|
||||
expect(typeof definition.icon).toBe('string')
|
||||
expect(definition.icon!.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('passes null icon when no emoji is selected', () => {
|
||||
const onCreate = vi.fn()
|
||||
render(<CreateViewDialog {...defaultProps} onCreate={onCreate} />)
|
||||
const input = screen.getByPlaceholderText(/Active Projects|Reading List/i)
|
||||
fireEvent.change(input, { target: { value: 'No Icon View' } })
|
||||
fireEvent.submit(screen.getByText('Create').closest('form')!)
|
||||
expect(onCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ icon: null })
|
||||
)
|
||||
})
|
||||
})
|
||||
122
src/components/CreateViewDialog.tsx
Normal file
122
src/components/CreateViewDialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { FilterBuilder } from './FilterBuilder'
|
||||
import { EmojiPicker } from './EmojiPicker'
|
||||
import type { FilterGroup, ViewDefinition, VaultEntry } from '../types'
|
||||
|
||||
interface CreateViewDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreate: (definition: ViewDefinition) => void
|
||||
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, entries, editingView }: CreateViewDialogProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [icon, setIcon] = useState('')
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const [filters, setFilters] = useState<FilterGroup>({
|
||||
all: [{ field: 'type', op: 'equals', value: '' }],
|
||||
})
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const isEditing = !!editingView
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (editingView) {
|
||||
setName(editingView.name) // eslint-disable-line react-hooks/set-state-in-effect -- populate on dialog open
|
||||
setIcon(editingView.icon ?? '') // eslint-disable-line react-hooks/set-state-in-effect -- populate on dialog open
|
||||
setFilters(editingView.filters) // eslint-disable-line react-hooks/set-state-in-effect -- populate on dialog open
|
||||
} else {
|
||||
setName('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setIcon('') // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setFilters({ all: [{ field: availableFields[0] ?? 'type', op: 'equals', value: '' }] }) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
}
|
||||
setShowEmojiPicker(false) // eslint-disable-line react-hooks/set-state-in-effect -- reset on dialog open
|
||||
setTimeout(() => inputRef.current?.focus(), 50)
|
||||
}
|
||||
}, [open, availableFields, editingView])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
const definition: ViewDefinition = {
|
||||
name: trimmed,
|
||||
icon: icon || null,
|
||||
color: null,
|
||||
sort: null,
|
||||
filters,
|
||||
}
|
||||
onCreate(definition)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSelectEmoji = useCallback((emoji: string) => {
|
||||
setIcon(emoji)
|
||||
setShowEmojiPicker(false)
|
||||
}, [])
|
||||
|
||||
const handleCloseEmojiPicker = useCallback(() => {
|
||||
setShowEmojiPicker(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
|
||||
<DialogContent showCloseButton={false} className="flex max-h-[80vh] flex-col sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEditing ? 'Edit View' : 'Create View'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-16 space-y-1.5 relative">
|
||||
<label className="text-xs font-medium text-muted-foreground">Icon</label>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-9 w-full items-center justify-center rounded-md border border-input bg-background text-xl cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||
title="Pick icon"
|
||||
>
|
||||
{icon || <span className="text-sm text-muted-foreground">📋</span>}
|
||||
</button>
|
||||
{showEmojiPicker && (
|
||||
<EmojiPicker onSelect={handleSelectEmoji} onClose={handleCloseEmojiPicker} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">Name</label>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="e.g. Active Projects, Reading List..."
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto">
|
||||
<label className="text-xs font-medium text-muted-foreground">Filters</label>
|
||||
<FilterBuilder
|
||||
group={filters}
|
||||
onChange={setFilters}
|
||||
availableFields={availableFields}
|
||||
valueSuggestions={valueSuggestions}
|
||||
entries={entries}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button type="submit" disabled={!name.trim()}>{isEditing ? 'Save' : 'Create'}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -333,6 +333,10 @@
|
||||
letter-spacing: var(--headings-h1-letter-spacing, -0.015em);
|
||||
color: var(--foreground);
|
||||
padding: 0;
|
||||
resize: none;
|
||||
overflow: hidden;
|
||||
font-family: inherit;
|
||||
field-sizing: content;
|
||||
}
|
||||
|
||||
.title-field__input::placeholder {
|
||||
|
||||
@@ -47,6 +47,7 @@ interface EditorProps {
|
||||
vaultPath?: string
|
||||
noteList?: NoteListItem[]
|
||||
noteListFilter?: { type: string | null; query: string }
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onTrashNote?: (path: string) => void
|
||||
onRestoreNote?: (path: string) => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
@@ -206,7 +207,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onCreateAndOpenNote, onInitializeProperties,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath, noteList, noteListFilter,
|
||||
onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onToggleFavorite, onTrashNote, onRestoreNote, onDeleteNote, onArchiveNote, onUnarchiveNote,
|
||||
onContentChange, onSave, onTitleSync,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
onSetNoteIcon, onRemoveNoteIcon,
|
||||
@@ -252,6 +253,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onToggleInspector={onToggleInspector}
|
||||
onNavigateWikilink={onNavigateWikilink}
|
||||
onEditorChange={handleEditorChange}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onTrashNote={onTrashNote}
|
||||
onRestoreNote={onRestoreNote}
|
||||
onDeleteNote={onDeleteNote}
|
||||
|
||||
@@ -41,6 +41,7 @@ interface EditorContentProps {
|
||||
onToggleInspector: () => void
|
||||
onNavigateWikilink: (target: string) => void
|
||||
onEditorChange?: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onTrashNote?: (path: string) => void
|
||||
onRestoreNote?: (path: string) => void
|
||||
onDeleteNote?: (path: string) => void
|
||||
@@ -144,6 +145,7 @@ function ActiveTabBreadcrumb({ activeTab, barRef, props }: {
|
||||
onToggleAIChat={props.onToggleAIChat}
|
||||
inspectorCollapsed={props.inspectorCollapsed}
|
||||
onToggleInspector={props.onToggleInspector}
|
||||
onToggleFavorite={bindPath(props.onToggleFavorite, path)}
|
||||
onTrash={bindPath(props.onTrashNote, path)}
|
||||
onRestore={bindPath(props.onRestoreNote, path)}
|
||||
onArchive={bindPath(props.onArchiveNote, path)}
|
||||
@@ -168,7 +170,10 @@ export function EditorContent({
|
||||
const freshEntry = activeTab ? entries.find(e => e.path === activeTab.entry.path) : undefined
|
||||
const isTrashed = freshEntry?.trashed ?? activeTab?.entry.trashed ?? false
|
||||
const isArchived = freshEntry?.archived ?? activeTab?.entry.archived ?? false
|
||||
const showEditor = !diffMode && !rawMode
|
||||
// Non-markdown text files always use the raw editor (no BlockNote)
|
||||
const isNonMarkdownText = activeTab?.entry.fileKind === 'text'
|
||||
const effectiveRawMode = rawMode || isNonMarkdownText
|
||||
const showEditor = !diffMode && !effectiveRawMode
|
||||
const entryIcon = activeTab?.entry.icon ?? null
|
||||
const emojiIcon = entryIcon && isEmoji(entryIcon) ? entryIcon : null
|
||||
|
||||
@@ -204,7 +209,7 @@ export function EditorContent({
|
||||
<ActiveTabBreadcrumb
|
||||
activeTab={activeTab}
|
||||
barRef={breadcrumbBarRef}
|
||||
props={{ diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, ...breadcrumbProps }}
|
||||
props={{ diffMode, diffContent, onToggleDiff, rawMode: effectiveRawMode, onToggleRaw, ...breadcrumbProps }}
|
||||
/>
|
||||
)}
|
||||
{activeTab && isTrashed && (
|
||||
@@ -223,7 +228,7 @@ export function EditorContent({
|
||||
/>
|
||||
)}
|
||||
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
|
||||
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} latestContentRef={rawLatestContentRef} vaultPath={vaultPath} />
|
||||
<RawModeEditorSection rawMode={effectiveRawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} latestContentRef={rawLatestContentRef} vaultPath={vaultPath} />
|
||||
{showEditor && activeTab && (
|
||||
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
|
||||
<div ref={titleSectionRef} className="title-section">
|
||||
@@ -250,6 +255,8 @@ export function EditorContent({
|
||||
title={activeTab.entry.title}
|
||||
filename={activeTab.entry.filename}
|
||||
editable={!isTrashed}
|
||||
notePath={activeTab.entry.path}
|
||||
vaultPath={vaultPath}
|
||||
onTitleChange={(newTitle) => onTitleChange?.(activeTab.entry.path, newTitle)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -71,6 +71,7 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
|
||||
<div className="grid grid-cols-8 gap-0.5">
|
||||
{searchResults.map(entry => (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.emoji}
|
||||
className="flex h-8 w-8 items-center justify-center rounded text-xl transition-colors hover:bg-accent"
|
||||
onClick={() => handleSelect(entry.emoji)}
|
||||
@@ -98,6 +99,7 @@ export function EmojiPicker({ onSelect, onClose }: EmojiPickerProps) {
|
||||
<div className="grid grid-cols-8 gap-0.5">
|
||||
{emojis.map(entry => (
|
||||
<button
|
||||
type="button"
|
||||
key={entry.emoji}
|
||||
className="flex h-8 w-8 items-center justify-center rounded text-xl transition-colors hover:bg-accent"
|
||||
onClick={() => handleSelect(entry.emoji)}
|
||||
|
||||
201
src/components/FilterBuilder.test.tsx
Normal file
201
src/components/FilterBuilder.test.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { FilterBuilder } from './FilterBuilder'
|
||||
import type { FilterGroup, VaultEntry } from '../types'
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
path: '/vault/note/test.md',
|
||||
filename: 'test.md',
|
||||
title: 'Test Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: 1700000000,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
wordCount: 0,
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const entries: VaultEntry[] = [
|
||||
makeEntry({ path: '/vault/project/alpha.md', filename: 'alpha.md', title: 'Alpha Project', isA: 'Project' }),
|
||||
makeEntry({ path: '/vault/person/luca.md', filename: 'luca.md', title: 'Luca', isA: 'Person' }),
|
||||
makeEntry({ path: '/vault/topic/ai.md', filename: 'ai.md', title: 'AI Research', isA: 'Topic' }),
|
||||
makeEntry({ path: '/vault/note/plain.md', filename: 'plain.md', title: 'Plain Note', isA: null }),
|
||||
makeEntry({ path: '/vault/person/alice.md', filename: 'alice.md', title: 'Alice Smith', isA: 'Person', aliases: ['Alice'] }),
|
||||
makeEntry({ path: '/vault/trashed.md', filename: 'trashed.md', title: 'Trashed Note', isA: null, trashed: true }),
|
||||
]
|
||||
|
||||
describe('FilterBuilder wikilink autocomplete', () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
function renderWithEntries(group?: FilterGroup) {
|
||||
const defaultGroup: FilterGroup = {
|
||||
all: [{ field: 'title', op: 'contains', value: '' }],
|
||||
}
|
||||
return render(
|
||||
<FilterBuilder
|
||||
group={group ?? defaultGroup}
|
||||
onChange={onChange}
|
||||
availableFields={['type', 'status', 'title']}
|
||||
entries={entries}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
it('renders value input with wikilink support when entries are provided', () => {
|
||||
renderWithEntries()
|
||||
expect(screen.getByTestId('filter-value-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show dropdown for plain text input', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: 'hello' }],
|
||||
})
|
||||
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows dropdown when value starts with [[', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Al' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByTestId('wikilink-dropdown')).toBeInTheDocument()
|
||||
expect(screen.getByText('Alpha Project')).toBeInTheDocument()
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show dropdown for short queries after [[', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[A' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('inserts [[note-title]] when a note is selected', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Alpha' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
fireEvent.click(screen.getByText('Alpha Project'))
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Alpha Project]]' }],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('navigates dropdown with arrow keys and selects with Enter', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Al' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
const selected = document.querySelector('.wikilink-menu__item--selected')
|
||||
expect(selected).toBeTruthy()
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes dropdown on Escape', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Al' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByTestId('wikilink-dropdown')).toBeInTheDocument()
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('excludes trashed notes from autocomplete', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Trashed' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.queryByText('Trashed Note')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('matches on aliases', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Alice' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByText('Alice Smith')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows type badge for typed entries', () => {
|
||||
const personType = makeEntry({
|
||||
path: '/vault/person.md', filename: 'person.md', title: 'Person',
|
||||
isA: 'Type', color: 'yellow', icon: 'user',
|
||||
})
|
||||
const entriesWithType = [...entries, personType]
|
||||
render(
|
||||
<FilterBuilder
|
||||
group={{ all: [{ field: 'title', op: 'contains', value: '[[Luca' }] }}
|
||||
onChange={onChange}
|
||||
availableFields={['type', 'status', 'title']}
|
||||
entries={entriesWithType}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByText('Person')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens dropdown on typing [[ in input', () => {
|
||||
renderWithEntries({
|
||||
all: [{ field: 'title', op: 'contains', value: '[[Al' }],
|
||||
})
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
// Simulate the user typing [[ — dropdown opens when value starts with [[
|
||||
fireEvent.change(input, { target: { value: '[[Al' } })
|
||||
// The internal open state is set by onChange, verified via focus re-trigger
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByTestId('wikilink-dropdown')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('plain text without [[ still works as regular input', () => {
|
||||
renderWithEntries()
|
||||
const input = screen.getByTestId('filter-value-input')
|
||||
fireEvent.change(input, { target: { value: 'some text' } })
|
||||
expect(screen.queryByTestId('wikilink-dropdown')).not.toBeInTheDocument()
|
||||
expect(onChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to plain input when no entries are provided', () => {
|
||||
render(
|
||||
<FilterBuilder
|
||||
group={{ all: [{ field: 'title', op: 'contains', value: '' }] }}
|
||||
onChange={onChange}
|
||||
availableFields={['type', 'status', 'title']}
|
||||
/>,
|
||||
)
|
||||
const input = screen.getByPlaceholderText('value')
|
||||
expect(input).toBeInTheDocument()
|
||||
expect(input).not.toHaveAttribute('data-testid', 'filter-value-input')
|
||||
})
|
||||
})
|
||||
497
src/components/FilterBuilder.tsx
Normal file
497
src/components/FilterBuilder.tsx
Normal file
@@ -0,0 +1,497 @@
|
||||
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, 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' },
|
||||
{ value: 'not_equals', label: 'does not equal' },
|
||||
{ value: 'contains', label: 'contains' },
|
||||
{ value: 'not_contains', label: 'does not contain' },
|
||||
{ value: 'is_empty', label: 'is empty' },
|
||||
{ value: 'is_not_empty', label: 'is not empty' },
|
||||
{ value: 'before', label: 'before' },
|
||||
{ value: 'after', label: 'after' },
|
||||
]
|
||||
|
||||
const NO_VALUE_OPS = new Set<FilterOp>(['is_empty', 'is_not_empty'])
|
||||
const DATE_OPS = new Set<FilterOp>(['before', 'after'])
|
||||
|
||||
function isFilterGroup(node: FilterNode): node is FilterGroup {
|
||||
return 'all' in node || 'any' in node
|
||||
}
|
||||
|
||||
function getGroupChildren(group: FilterGroup): FilterNode[] {
|
||||
return 'all' in group ? group.all : group.any
|
||||
}
|
||||
|
||||
function getGroupMode(group: FilterGroup): 'all' | 'any' {
|
||||
return 'all' in group ? 'all' : 'any'
|
||||
}
|
||||
|
||||
function setGroupChildren(mode: 'all' | 'any', children: FilterNode[]): FilterGroup {
|
||||
return mode === 'all' ? { all: children } : { any: children }
|
||||
}
|
||||
|
||||
function FieldSelect({ value, fields, onChange }: {
|
||||
value: string
|
||||
fields: string[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
const isCustom = value !== '' && !fields.includes(value)
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-8 min-w-[100px] flex-1 gap-1 border-input bg-background px-2 text-sm shadow-none"
|
||||
>
|
||||
<SelectValue placeholder="field" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{isCustom && <SelectItem value={value}>{value}</SelectItem>}
|
||||
{fields.map((f) => (
|
||||
<SelectItem key={f} value={f}>{f}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
function OperatorSelect({ value, onChange }: {
|
||||
value: FilterOp
|
||||
onChange: (v: FilterOp) => void
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={(v) => onChange(v as FilterOp)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-8 shrink-0 gap-1 border-input bg-background px-2 text-sm shadow-none"
|
||||
style={{ minWidth: 120 }}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{OPERATORS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
const MAX_WIKILINK_RESULTS = 10
|
||||
const MIN_WIKILINK_QUERY = 2
|
||||
|
||||
function entryMatchesQuery(e: VaultEntry, lowerQuery: string): boolean {
|
||||
return e.title.toLowerCase().includes(lowerQuery) ||
|
||||
e.aliases.some(a => a.toLowerCase().includes(lowerQuery))
|
||||
}
|
||||
|
||||
function toWikilinkMatch(e: VaultEntry, typeEntryMap: Record<string, VaultEntry>) {
|
||||
const isA = e.isA
|
||||
const te = typeEntryMap[isA ?? '']
|
||||
const noteType = isA || undefined
|
||||
return {
|
||||
title: e.title,
|
||||
noteType,
|
||||
typeColor: noteType ? getTypeColor(isA, te?.color) : undefined,
|
||||
typeLightColor: noteType ? getTypeLightColor(isA, te?.color) : undefined,
|
||||
TypeIcon: noteType ? getTypeIcon(isA, te?.icon) : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function matchWikilinkEntries(entries: VaultEntry[], typeEntryMap: Record<string, VaultEntry>, query: string) {
|
||||
if (query.length < MIN_WIKILINK_QUERY) return []
|
||||
const lowerQuery = query.toLowerCase()
|
||||
return entries
|
||||
.filter(e => !e.trashed && entryMatchesQuery(e, lowerQuery))
|
||||
.slice(0, MAX_WIKILINK_RESULTS)
|
||||
.map(e => toWikilinkMatch(e, typeEntryMap))
|
||||
}
|
||||
|
||||
type WikilinkMatch = ReturnType<typeof toWikilinkMatch>
|
||||
|
||||
function extractWikilinkQuery(value: string): string | null {
|
||||
return value.startsWith('[[') ? value.slice(2).replace(/]]$/, '') : null
|
||||
}
|
||||
|
||||
function useOutsideClick(refs: React.RefObject<HTMLElement | null>[], onClose: () => void) {
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as Node
|
||||
if (refs.every(r => !r.current?.contains(target))) onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [refs, onClose])
|
||||
}
|
||||
|
||||
function WikilinkDropdown({ matches, selectedIndex, onSelect, onHover, menuRef }: {
|
||||
matches: WikilinkMatch[]
|
||||
selectedIndex: number
|
||||
onSelect: (title: string) => void
|
||||
onHover: (index: number) => void
|
||||
menuRef: React.RefObject<HTMLDivElement | null>
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="wikilink-menu"
|
||||
ref={menuRef}
|
||||
style={{ position: 'absolute', top: '100%', left: 0, right: 0, marginTop: 2, zIndex: 50 }}
|
||||
data-testid="wikilink-dropdown"
|
||||
>
|
||||
{matches.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className={`wikilink-menu__item${index === selectedIndex ? ' wikilink-menu__item--selected' : ''}`}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => onSelect(item.title)}
|
||||
onMouseEnter={() => onHover(index)}
|
||||
>
|
||||
<span className="wikilink-menu__title" style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
{item.TypeIcon && <item.TypeIcon width={14} height={14} style={{ color: item.typeColor, flexShrink: 0 }} />}
|
||||
{item.title}
|
||||
</span>
|
||||
{item.noteType && (
|
||||
<span className="wikilink-menu__type" style={{ color: item.typeColor, backgroundColor: item.typeLightColor, borderRadius: 9999, padding: '1px 8px' }}>
|
||||
{item.noteType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useWikilinkMatches(entries: VaultEntry[], value: string, open: boolean) {
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const wikilinkQuery = extractWikilinkQuery(value)
|
||||
return useMemo(
|
||||
() => (open && wikilinkQuery !== null) ? matchWikilinkEntries(entries, typeEntryMap, wikilinkQuery) : [],
|
||||
[entries, typeEntryMap, wikilinkQuery, open],
|
||||
)
|
||||
}
|
||||
|
||||
function useScrollSelectedIntoView(menuRef: React.RefObject<HTMLDivElement | null>, selectedIndex: number) {
|
||||
useEffect(() => {
|
||||
if (selectedIndex < 0 || !menuRef.current) return
|
||||
const el = menuRef.current.children[selectedIndex] as HTMLElement | undefined
|
||||
el?.scrollIntoView?.({ block: 'nearest' })
|
||||
}, [selectedIndex, menuRef])
|
||||
}
|
||||
|
||||
function useDropdownKeyboard(
|
||||
matches: WikilinkMatch[],
|
||||
open: boolean,
|
||||
onSelect: (title: string) => void,
|
||||
onClose: () => void,
|
||||
) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
|
||||
const resetIndex = useCallback(() => setSelectedIndex(-1), [])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!open || matches.length === 0) return
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => (i + 1) % matches.length)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(i => (i <= 0 ? matches.length - 1 : i - 1))
|
||||
} else if (e.key === 'Enter' && selectedIndex >= 0) {
|
||||
e.preventDefault()
|
||||
onSelect(matches[selectedIndex].title)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}, [open, matches, selectedIndex, onSelect, onClose])
|
||||
|
||||
return { selectedIndex, setSelectedIndex, resetIndex, handleKeyDown }
|
||||
}
|
||||
|
||||
function WikilinkValueInput({ value, entries, onChange }: {
|
||||
value: string
|
||||
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) {
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (suggestions.length > 0) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-8 flex-1 min-w-0 gap-1 border-input bg-background px-2 text-sm shadow-none"
|
||||
>
|
||||
<SelectValue placeholder="value" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{value !== '' && !suggestions.includes(value) && (
|
||||
<SelectItem value={value}>{value}</SelectItem>
|
||||
)}
|
||||
{suggestions.map((s) => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
if (entries.length > 0) {
|
||||
return <WikilinkValueInput value={value} entries={entries} onChange={onChange} />
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
placeholder="value"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterRow({ condition, fields, entries, valueSuggestions, onUpdate, onRemove }: {
|
||||
condition: FilterCondition
|
||||
fields: string[]
|
||||
entries: VaultEntry[]
|
||||
valueSuggestions: (field: string) => string[]
|
||||
onUpdate: (c: FilterCondition) => void
|
||||
onRemove: () => void
|
||||
}) {
|
||||
const suggestions = valueSuggestions(condition.field)
|
||||
const isDateOp = DATE_OPS.has(condition.op)
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FieldSelect
|
||||
value={condition.field}
|
||||
fields={fields}
|
||||
onChange={(v) => onUpdate({ ...condition, field: v })}
|
||||
/>
|
||||
<OperatorSelect
|
||||
value={condition.op}
|
||||
onChange={(op) => onUpdate({ ...condition, op })}
|
||||
/>
|
||||
{!NO_VALUE_OPS.has(condition.op) && (
|
||||
<ValueInput
|
||||
value={String(condition.value ?? '')}
|
||||
suggestions={suggestions}
|
||||
isDateOp={isDateOp}
|
||||
entries={entries}
|
||||
onChange={(v) => onUpdate({ ...condition, value: v })}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 shrink-0 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onRemove}
|
||||
title="Remove filter"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
onRemove?: () => void
|
||||
}) {
|
||||
const mode = getGroupMode(group)
|
||||
const children = getGroupChildren(group)
|
||||
|
||||
const toggleMode = () => {
|
||||
onChange(setGroupChildren(mode === 'all' ? 'any' : 'all', children))
|
||||
}
|
||||
|
||||
const updateChild = (index: number, node: FilterNode) => {
|
||||
const next = [...children]
|
||||
next[index] = node
|
||||
onChange(setGroupChildren(mode, next))
|
||||
}
|
||||
|
||||
const removeChild = (index: number) => {
|
||||
const next = children.filter((_, i) => i !== index)
|
||||
onChange(setGroupChildren(mode, next))
|
||||
}
|
||||
|
||||
const addCondition = () => {
|
||||
onChange(setGroupChildren(mode, [...children, { field: fields[0] ?? 'type', op: 'equals' as FilterOp, value: '' }]))
|
||||
}
|
||||
|
||||
const addGroup = () => {
|
||||
const nested: FilterGroup = { all: [{ field: fields[0] ?? 'type', op: 'equals' as FilterOp, value: '' }] }
|
||||
onChange(setGroupChildren(mode, [...children, nested]))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={depth > 0 ? 'ml-3 border-l-2 border-border pl-3 py-1' : ''}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 rounded-full px-2.5 text-[11px] font-medium"
|
||||
onClick={toggleMode}
|
||||
title={`Switch to ${mode === 'all' ? 'OR' : 'AND'}`}
|
||||
>
|
||||
{mode === 'all' ? 'AND' : 'OR'}
|
||||
</Button>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{mode === 'all' ? 'Match all conditions' : 'Match any condition'}
|
||||
</span>
|
||||
{onRemove && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="ml-auto h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onRemove}
|
||||
title="Remove group"
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{children.map((child, i) =>
|
||||
isFilterGroup(child) ? (
|
||||
<FilterGroupView
|
||||
key={i}
|
||||
group={child}
|
||||
fields={fields}
|
||||
entries={entries}
|
||||
valueSuggestions={valueSuggestions}
|
||||
depth={depth + 1}
|
||||
onChange={(g) => updateChild(i, g)}
|
||||
onRemove={() => removeChild(i)}
|
||||
/>
|
||||
) : (
|
||||
<FilterRow
|
||||
key={i}
|
||||
condition={child}
|
||||
fields={fields}
|
||||
entries={entries}
|
||||
valueSuggestions={valueSuggestions}
|
||||
onUpdate={(c) => updateChild(i, c)}
|
||||
onRemove={() => removeChild(i)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs" onClick={addCondition}>
|
||||
<Plus size={12} className="mr-1" /> Add filter
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs" onClick={addGroup}>
|
||||
<Plus size={12} className="mr-1" /> Add group
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface FilterBuilderProps {
|
||||
group: FilterGroup
|
||||
onChange: (group: FilterGroup) => void
|
||||
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, entries }: FilterBuilderProps) {
|
||||
const fields = availableFields.length > 0 ? availableFields : ['type']
|
||||
return (
|
||||
<FilterGroupView
|
||||
group={group}
|
||||
fields={fields}
|
||||
entries={entries ?? []}
|
||||
valueSuggestions={valueSuggestions ?? defaultSuggestions}
|
||||
depth={0}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ interface FolderTreeProps {
|
||||
selection: SidebarSelection
|
||||
onSelect: (selection: SidebarSelection) => void
|
||||
onCreateFolder?: (name: string) => void
|
||||
collapsed?: boolean
|
||||
onToggle?: () => void
|
||||
}
|
||||
|
||||
function FolderItem({
|
||||
@@ -72,8 +74,9 @@ function FolderItem({
|
||||
)
|
||||
}
|
||||
|
||||
export const FolderTree = memo(function FolderTree({ folders, selection, onSelect, onCreateFolder }: FolderTreeProps) {
|
||||
const [sectionCollapsed, setSectionCollapsed] = useState(false)
|
||||
export const FolderTree = memo(function FolderTree({ folders, selection, onSelect, onCreateFolder, collapsed: externalCollapsed, onToggle }: FolderTreeProps) {
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(false)
|
||||
const sectionCollapsed = externalCollapsed ?? internalCollapsed
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [newFolderName, setNewFolderName] = useState('')
|
||||
@@ -99,12 +102,12 @@ export const FolderTree = memo(function FolderTree({ folders, selection, onSelec
|
||||
if (folders.length === 0 && !isCreating) return null
|
||||
|
||||
return (
|
||||
<div style={{ padding: '8px 0' }}>
|
||||
<div style={{ padding: '4px 6px' }}>
|
||||
{/* Header */}
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
|
||||
style={{ padding: '6px 14px 6px 16px' }}
|
||||
onClick={() => setSectionCollapsed((v) => !v)}
|
||||
onClick={() => onToggle ? onToggle() : setInternalCollapsed((v) => !v)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{sectionCollapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
|
||||
@@ -114,7 +117,7 @@ export const FolderTree = memo(function FolderTree({ folders, selection, onSelec
|
||||
<Plus
|
||||
size={12}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); setIsCreating(true); setSectionCollapsed(false) }}
|
||||
onClick={(e) => { e.stopPropagation(); setIsCreating(true); if (sectionCollapsed && onToggle) onToggle(); else if (sectionCollapsed) setInternalCollapsed(false) }}
|
||||
data-testid="create-folder-btn"
|
||||
/>
|
||||
)}
|
||||
|
||||
118
src/components/NoteItem.test.tsx
Normal file
118
src/components/NoteItem.test.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { NoteItem } from './NoteItem'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
vi.mock('../mock-tauri', () => ({ isTauri: () => false, mockInvoke: vi.fn() }))
|
||||
|
||||
function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return {
|
||||
path: '/vault/test.md', filename: 'test.md', title: 'Test Note',
|
||||
isA: 'Movie', aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: 1700000000, createdAt: null, fileSize: 100,
|
||||
snippet: 'A snippet', wordCount: 50,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null,
|
||||
visible: null, favorite: false, favoriteIndex: null,
|
||||
outgoingLinks: [], properties: {},
|
||||
listPropertiesDisplay: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeTypeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
|
||||
return makeEntry({
|
||||
path: '/vault/movie.md', filename: 'movie.md', title: 'Movie',
|
||||
isA: 'Type', listPropertiesDisplay: [],
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
const noop = vi.fn()
|
||||
|
||||
describe('NoteItem property chips', () => {
|
||||
it('renders property chips when type has listPropertiesDisplay', () => {
|
||||
const entry = makeEntry({
|
||||
properties: { rating: 4, genre: 'Drama' },
|
||||
})
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating', 'genre'] })
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('property-chips')).toBeInTheDocument()
|
||||
expect(screen.getByText('4')).toBeInTheDocument()
|
||||
expect(screen.getByText('Drama')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render chips when listPropertiesDisplay is empty', () => {
|
||||
const entry = makeEntry({ properties: { rating: 4 } })
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: [] })
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('property-chips')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('skips chips for missing properties', () => {
|
||||
const entry = makeEntry({ properties: { rating: 4 } })
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating', 'genre'] })
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('4')).toBeInTheDocument()
|
||||
expect(screen.queryByText('genre')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders relationship values as display labels', () => {
|
||||
const entry = makeEntry({
|
||||
relationships: { Director: ['[[spielberg|Steven Spielberg]]'] },
|
||||
})
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['Director'] })
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('Steven Spielberg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows hostname for URL properties', () => {
|
||||
const entry = makeEntry({
|
||||
properties: { url: 'https://www.example.com/page/123' },
|
||||
})
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['url'] })
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.getByText('www.example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render chips for binary files', () => {
|
||||
const entry = makeEntry({
|
||||
fileKind: 'binary',
|
||||
properties: { rating: 4 },
|
||||
})
|
||||
const typeEntry = makeTypeEntry({ listPropertiesDisplay: ['rating'] })
|
||||
|
||||
render(
|
||||
<NoteItem entry={entry} isSelected={false} noteStatus="clean"
|
||||
typeEntryMap={{ Movie: typeEntry }} onClickNote={noop} />
|
||||
)
|
||||
|
||||
expect(screen.queryByTestId('property-chips')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -4,11 +4,13 @@ import { cn } from '@/lib/utils'
|
||||
import {
|
||||
Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, FileText, StackSimple,
|
||||
File, FileDashed,
|
||||
} from '@phosphor-icons/react'
|
||||
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,
|
||||
@@ -84,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)'
|
||||
@@ -91,6 +146,12 @@ function noteItemStyle(isSelected: boolean, isMultiSelected: boolean, typeColor:
|
||||
return base
|
||||
}
|
||||
|
||||
function getFileKindIcon(fileKind: string | undefined): ComponentType<SVGAttributes<SVGSVGElement>> {
|
||||
if (fileKind === 'text') return File
|
||||
if (fileKind === 'binary') return FileDashed
|
||||
return FileText
|
||||
}
|
||||
|
||||
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', typeEntryMap, onClickNote, onPrefetch }: {
|
||||
entry: VaultEntry
|
||||
isSelected: boolean
|
||||
@@ -101,47 +162,58 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
|
||||
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
|
||||
onPrefetch?: (path: string) => void
|
||||
}) {
|
||||
const isBinary = entry.fileKind === 'binary'
|
||||
const isNonMarkdown = !!entry.fileKind && entry.fileKind !== 'markdown'
|
||||
const te = typeEntryMap[entry.isA ?? '']
|
||||
const typeColor = getTypeColor(entry.isA ?? 'Note', te?.color)
|
||||
const typeColor = isBinary ? 'var(--muted-foreground)' : getTypeColor(entry.isA ?? 'Note', te?.color)
|
||||
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color)
|
||||
const TypeIcon = useMemo(() => getTypeIcon(entry.isA, te?.icon), [entry.isA, te?.icon])
|
||||
const TypeIcon = useMemo(() => {
|
||||
if (isNonMarkdown) return getFileKindIcon(entry.fileKind)
|
||||
return getTypeIcon(entry.isA, te?.icon)
|
||||
}, [entry.isA, te?.icon, entry.fileKind, isNonMarkdown])
|
||||
|
||||
const handleClick = isBinary
|
||||
? (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation() }
|
||||
: (e: React.MouseEvent) => onClickNote(entry, e)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative cursor-pointer border-b border-[var(--border)] transition-colors",
|
||||
isSelected && !isMultiSelected && "border-l-[3px]",
|
||||
!isSelected && !isMultiSelected && "hover:bg-muted",
|
||||
isHighlighted && !isSelected && !isMultiSelected && "bg-muted"
|
||||
"relative border-b border-[var(--border)] transition-colors",
|
||||
isBinary ? "cursor-default opacity-50" : "cursor-pointer",
|
||||
isSelected && !isMultiSelected && !isBinary && "border-l-[3px]",
|
||||
!isSelected && !isMultiSelected && !isBinary && "hover:bg-muted",
|
||||
isHighlighted && !isSelected && !isMultiSelected && !isBinary && "bg-muted"
|
||||
)}
|
||||
style={noteItemStyle(isSelected, isMultiSelected, typeColor, typeLightColor)}
|
||||
onClick={(e: React.MouseEvent) => onClickNote(entry, e)}
|
||||
onMouseEnter={onPrefetch ? () => onPrefetch(entry.path) : undefined}
|
||||
data-testid={isMultiSelected ? 'multi-selected-item' : undefined}
|
||||
style={isBinary ? { padding: '14px 16px' } : noteItemStyle(isSelected, isMultiSelected, typeColor, typeLightColor)}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={!isBinary && onPrefetch ? () => onPrefetch(entry.path) : undefined}
|
||||
data-testid={isMultiSelected ? 'multi-selected-item' : isBinary ? 'binary-file-item' : undefined}
|
||||
data-highlighted={isHighlighted || undefined}
|
||||
title={isBinary ? 'Cannot open this file type' : undefined}
|
||||
>
|
||||
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
|
||||
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
|
||||
<div className="pr-5">
|
||||
<div className={cn("truncate text-[13px] text-foreground", isSelected ? "font-semibold" : "font-medium")}>
|
||||
{noteStatus !== 'clean' && <StatusDot noteStatus={noteStatus} />}
|
||||
<div className={cn("truncate text-[13px]", isBinary ? "text-muted-foreground" : "text-foreground", isSelected && !isBinary ? "font-semibold" : "font-medium")}>
|
||||
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
|
||||
{entry.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
|
||||
{entry.title}
|
||||
<StateBadge archived={entry.archived} trashed={entry.trashed} />
|
||||
{!isBinary && <StateBadge archived={entry.archived} trashed={entry.trashed} />}
|
||||
</div>
|
||||
{entry.path.includes('/') && (
|
||||
<div className="truncate text-[10px] text-muted-foreground" data-testid="note-path">{entry.path}</div>
|
||||
)}
|
||||
</div>
|
||||
{entry.snippet && (
|
||||
{entry.snippet && !isBinary && (
|
||||
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" data-testid="note-snippet" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{entry.snippet}
|
||||
</div>
|
||||
)}
|
||||
{entry.trashed && entry.trashedAt
|
||||
{!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>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NoteList } from './NoteList'
|
||||
import { NoteItem } from './NoteItem'
|
||||
import { getSortComparator, filterEntries, countByFilter, countAllByFilter } from '../utils/noteListHelpers'
|
||||
import type { NoteListFilter } from '../utils/noteListHelpers'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
@@ -1453,4 +1454,44 @@ describe('countAllByFilter', () => {
|
||||
const counts = countAllByFilter(entries)
|
||||
expect(counts).toEqual({ open: 2, archived: 1, trashed: 2 })
|
||||
})
|
||||
|
||||
it('excludes non-markdown files from counts', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/1.md', isA: 'Note', fileKind: 'markdown' }),
|
||||
makeEntry({ path: '/2.yml', isA: undefined, fileKind: 'text' }),
|
||||
makeEntry({ path: '/3.png', isA: undefined, fileKind: 'binary' }),
|
||||
]
|
||||
const counts = countAllByFilter(entries)
|
||||
expect(counts).toEqual({ open: 1, archived: 0, trashed: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoteItem — binary file rendering', () => {
|
||||
it('renders binary files as non-clickable with muted style', () => {
|
||||
const binaryEntry = makeEntry({
|
||||
path: '/vault/photo.png', filename: 'photo.png', title: 'photo.png', fileKind: 'binary',
|
||||
})
|
||||
const onClick = vi.fn()
|
||||
render(<NoteItem entry={binaryEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClick} />)
|
||||
|
||||
const item = screen.getByTestId('binary-file-item')
|
||||
expect(item).toBeTruthy()
|
||||
expect(item.className).toContain('opacity-50')
|
||||
expect(item).toHaveAttribute('title', 'Cannot open this file type')
|
||||
|
||||
fireEvent.click(item)
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders text files as clickable', () => {
|
||||
const textEntry = makeEntry({
|
||||
path: '/vault/config.yml', filename: 'config.yml', title: 'config.yml', fileKind: 'text',
|
||||
})
|
||||
const onClick = vi.fn()
|
||||
render(<NoteItem entry={textEntry} isSelected={false} typeEntryMap={{}} onClickNote={onClick} />)
|
||||
|
||||
const item = screen.getByText('config.yml').closest('div')!
|
||||
fireEvent.click(item)
|
||||
expect(onClick).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useCallback, useEffect, memo } from 'react'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, InboxPeriod } from '../types'
|
||||
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'
|
||||
@@ -41,9 +40,10 @@ interface NoteListProps {
|
||||
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
||||
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
|
||||
onOpenInNewWindow?: (entry: VaultEntry) => void
|
||||
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 }: 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'
|
||||
@@ -58,17 +58,12 @@ 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())
|
||||
|
||||
const typeEntryMap = useTypeEntryMap(entries)
|
||||
const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined })
|
||||
const { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount } = useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod: isInboxView ? inboxPeriod : undefined, views })
|
||||
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
|
||||
const deletedCount = useMemo(
|
||||
() => isChangesView ? (modifiedFiles ?? []).filter((f) => f.status === 'deleted').length : 0,
|
||||
@@ -101,11 +96,11 @@ function NoteListInner({ entries, selection, selectedNote, noteListFilter, onNot
|
||||
onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined)
|
||||
}, [onCreateNote, selection])
|
||||
const toggleGroup = useCallback((label: string) => { setCollapsedGroups((prev) => toggleSetMember(prev, label)) }, [])
|
||||
const title = resolveHeaderTitle(selection, typeDocument)
|
||||
const title = resolveHeaderTitle(selection, typeDocument, views)
|
||||
|
||||
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 ? (
|
||||
@@ -116,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} />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useState, useCallback, useEffect, useMemo } from 'react'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import type { EditorView } from '@codemirror/view'
|
||||
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
|
||||
import { attachClickHandlers, enrichSuggestionItems } from '../utils/suggestionEnrichment'
|
||||
@@ -136,6 +137,7 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
|
||||
changes: { from: 0, to: doc.length, insert: newText },
|
||||
selection: { anchor: newCursor },
|
||||
})
|
||||
trackEvent('wikilink_inserted')
|
||||
setAutocomplete(null)
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
|
||||
@@ -154,6 +154,28 @@ describe('SearchPanel', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows note title from VaultEntry instead of filename from search result', async () => {
|
||||
mockInvokeFn.mockResolvedValue({
|
||||
results: [
|
||||
{ title: 'ai-apis', path: '/vault/essay/ai-apis.md', snippet: '...designing APIs...', score: 0.87, note_type: null },
|
||||
],
|
||||
elapsed_ms: 12,
|
||||
})
|
||||
|
||||
render(
|
||||
<SearchPanel open={true} vaultPath="/vault" entries={MOCK_ENTRIES} onSelectNote={vi.fn()} onClose={vi.fn()} />,
|
||||
)
|
||||
|
||||
const input = screen.getByPlaceholderText('Search in all notes...')
|
||||
fireEvent.change(input, { target: { value: 'api' } })
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show VaultEntry title, not filename-based search result title
|
||||
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
|
||||
expect(screen.queryByText('ai-apis')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows no results message when search returns empty', async () => {
|
||||
mockInvokeFn.mockResolvedValue({ results: [], elapsed_ms: 10 })
|
||||
|
||||
@@ -186,13 +208,13 @@ describe('SearchPanel', () => {
|
||||
fireEvent.change(input, { target: { value: 'test' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Result One')).toBeInTheDocument()
|
||||
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
|
||||
await waitFor(() => {
|
||||
const resultTwo = screen.getByText('Result Two').closest('[class*="cursor-pointer"]')!
|
||||
const resultTwo = screen.getByText('Refactoring Retreat').closest('[class*="cursor-pointer"]')!
|
||||
expect(resultTwo.className).toContain('bg-accent')
|
||||
})
|
||||
})
|
||||
@@ -330,9 +352,9 @@ describe('SearchPanel', () => {
|
||||
elapsed_ms: 30,
|
||||
})
|
||||
|
||||
// Spinner disappears after search completes
|
||||
// Spinner disappears after search completes — VaultEntry title shown instead of search result title
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Result')).toBeInTheDocument()
|
||||
expect(screen.getByText('How to Design AI-first APIs')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('search-spinner')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -358,9 +380,9 @@ describe('SearchPanel', () => {
|
||||
fireEvent.change(input, { target: { value: 'first' } })
|
||||
fireEvent.change(input, { target: { value: 'second' } })
|
||||
|
||||
// Only second query results should appear
|
||||
// Only second query results should appear — VaultEntry title shown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Second Result')).toBeInTheDocument()
|
||||
expect(screen.getByText('Refactoring Retreat')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ function SearchContent({
|
||||
<TypeIcon width={14} height={14} className="shrink-0" style={{ color: typeColor ?? 'var(--muted-foreground)' }} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-foreground">
|
||||
{entry?.icon && isEmoji(entry.icon) && <span className="mr-1">{entry.icon}</span>}
|
||||
{result.title}
|
||||
{entry?.title ?? result.title}
|
||||
</span>
|
||||
{noteType && (
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground/70">{noteType}</span>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { X, Eye, EyeSlash, GithubLogo, SignOut } from '@phosphor-icons/react'
|
||||
import { GitHubDeviceFlow } from './GitHubDeviceFlow'
|
||||
import type { Settings } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface SettingsPanelProps {
|
||||
open: boolean
|
||||
@@ -124,7 +125,7 @@ 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)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
@@ -148,10 +149,14 @@ 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,
|
||||
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval, updateChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
|
||||
release_channel: releaseChannel === 'stable' ? null : releaseChannel,
|
||||
}), [openaiKey, googleKey, githubToken, githubUsername, pullInterval, releaseChannel, crashReporting, analytics, settings.telemetry_consent, settings.anonymous_id])
|
||||
|
||||
const handleSave = () => {
|
||||
const prevAnalytics = settings.analytics_enabled ?? false
|
||||
const newAnalytics = analytics
|
||||
if (!prevAnalytics && newAnalytics) trackEvent('telemetry_opted_in')
|
||||
if (prevAnalytics && !newAnalytics) trackEvent('telemetry_opted_out')
|
||||
onSave(buildSettings())
|
||||
onClose()
|
||||
}
|
||||
@@ -199,7 +204,7 @@ 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}
|
||||
/>
|
||||
@@ -234,7 +239,7 @@ 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
|
||||
}
|
||||
@@ -298,23 +303,24 @@ 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>
|
||||
<label style={{ fontSize: 12, fontWeight: 500, color: 'var(--foreground)' }}>Release channel</label>
|
||||
<select
|
||||
value={props.updateChannel}
|
||||
onChange={(e) => props.setUpdateChannel(e.target.value)}
|
||||
value={props.releaseChannel}
|
||||
onChange={(e) => props.setReleaseChannel(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"
|
||||
data-testid="settings-release-channel"
|
||||
>
|
||||
<option value="stable">Stable</option>
|
||||
<option value="canary">Canary (pre-release)</option>
|
||||
<option value="beta">Beta</option>
|
||||
<option value="alpha">Alpha (bleeding edge)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -252,50 +252,18 @@ describe('Sidebar', () => {
|
||||
expect(screen.queryByText('Types')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows entity names under their section groups after expanding', () => {
|
||||
it('does not show inline entity names — sections are flat rows', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Sections start collapsed by default — expand them first
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
fireEvent.click(screen.getByLabelText('Expand Responsibilities'))
|
||||
fireEvent.click(screen.getByLabelText('Expand Experiments'))
|
||||
fireEvent.click(screen.getByLabelText('Expand Procedures'))
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Grow Newsletter')).toBeInTheDocument()
|
||||
expect(screen.getByText('Stock Screener')).toBeInTheDocument()
|
||||
expect(screen.getByText('Write Weekly Essays')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows People and Events items after expanding', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand People'))
|
||||
fireEvent.click(screen.getByLabelText('Expand Events'))
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||||
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses and expands sections', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Start collapsed — items hidden
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
|
||||
// Expand
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
|
||||
// Collapse
|
||||
fireEvent.click(screen.getByLabelText('Collapse Projects'))
|
||||
// Individual entries should NOT appear inline in the sidebar
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Grow Newsletter')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect when clicking an entity', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
kind: 'entity',
|
||||
entry: mockEntries[0],
|
||||
})
|
||||
it('shows note count chip on type sections', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Projects section has 1 entry — count chip should be a sibling of the label
|
||||
const projectsHeader = screen.getByText('Projects').closest('[class*="group/section"]')!
|
||||
expect(projectsHeader.textContent).toContain('1')
|
||||
})
|
||||
|
||||
it('calls onSelect when clicking a section header', () => {
|
||||
@@ -308,37 +276,12 @@ describe('Sidebar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('expands a collapsed section when clicking its header', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Sections start collapsed — items hidden
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
// Click the section header text (not the chevron)
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
// Section should now be expanded
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses an expanded+selected section when clicking its header again', () => {
|
||||
const projectSelection: SidebarSelection = { kind: 'sectionGroup', type: 'Project' }
|
||||
render(<Sidebar entries={mockEntries} selection={projectSelection} onSelect={() => {}} />)
|
||||
// First click expands (starts collapsed) and selects
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
// Second click: section is expanded + selected → should collapse
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selects but keeps expanded an unselected expanded section when clicking its header', () => {
|
||||
it('selects on every click — no expand/collapse toggle', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
|
||||
// Expand via chevron first
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
// Click the header — section is expanded but not selected → should select and stay expanded
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'sectionGroup', type: 'Project' })
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
expect(onSelect).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('calls onSelect with sectionGroup for People', () => {
|
||||
@@ -351,41 +294,15 @@ describe('Sidebar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders Topics section with topic entries after expanding', () => {
|
||||
it('renders Topics section header', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('Topics')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByLabelText('Expand Topics'))
|
||||
expect(screen.getByText('Software Development')).toBeInTheDocument()
|
||||
expect(screen.getByText('Trading')).toBeInTheDocument()
|
||||
// Topic entries are NOT shown inline
|
||||
expect(screen.queryByText('Software Development')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect with entity kind when clicking a topic', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Topics'))
|
||||
fireEvent.click(screen.getByText('Software Development'))
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
kind: 'entity',
|
||||
entry: mockEntries[4],
|
||||
})
|
||||
})
|
||||
|
||||
it('renders + buttons for each section group when onCreateType is provided', () => {
|
||||
const onCreateType = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onCreateType={onCreateType} />)
|
||||
const createButtons = screen.getAllByTitle(/^New /)
|
||||
expect(createButtons.length).toBe(7) // Projects, Experiments, Responsibilities, Procedures, People, Events, Topics (no Type entries → no Types section)
|
||||
})
|
||||
|
||||
it('calls onCreateType with correct type when + button is clicked', () => {
|
||||
const onCreateType = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onCreateType={onCreateType} />)
|
||||
fireEvent.click(screen.getByTitle('New Project'))
|
||||
expect(onCreateType).toHaveBeenCalledWith('Project')
|
||||
})
|
||||
|
||||
it('does not render + buttons when onCreateType is not provided', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
it('does not render + buttons on type sections', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onCreateType={() => {}} />)
|
||||
expect(screen.queryByTitle('New Project')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -517,24 +434,9 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Recipes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows instances of custom types under their section after expanding', () => {
|
||||
it('does not show inline instances — sections are flat rows', () => {
|
||||
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} onCreateType={() => {}} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Recipes'))
|
||||
expect(screen.getByText('Pasta Carbonara')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders + button on custom type sections for creating instances', () => {
|
||||
const onCreateType = vi.fn()
|
||||
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} onCreateType={onCreateType} />)
|
||||
fireEvent.click(screen.getByTitle('New Recipe'))
|
||||
expect(onCreateType).toHaveBeenCalledWith('Recipe')
|
||||
})
|
||||
|
||||
it('calls onCreateNewType when + is clicked on Types section', () => {
|
||||
const onCreateNewType = vi.fn()
|
||||
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} onCreateNewType={onCreateNewType} />)
|
||||
fireEvent.click(screen.getByTitle('New Type'))
|
||||
expect(onCreateNewType).toHaveBeenCalled()
|
||||
expect(screen.queryByText('Pasta Carbonara')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show section for type with zero active entries', () => {
|
||||
@@ -926,11 +828,10 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Notes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('includes both explicit and untyped notes under Notes section', () => {
|
||||
it('counts both explicit and untyped notes in Notes section chip', () => {
|
||||
render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Notes'))
|
||||
expect(screen.getByText('Explicit Note')).toBeInTheDocument()
|
||||
expect(screen.getByText('Untyped Note')).toBeInTheDocument()
|
||||
const notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
|
||||
expect(notesHeader.textContent).toContain('2')
|
||||
})
|
||||
|
||||
it('shows Notes section for untyped entries even without explicit Note entries', () => {
|
||||
@@ -1007,16 +908,8 @@ describe('Sidebar', () => {
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'inbox' })
|
||||
})
|
||||
|
||||
describe('emoji icon in sidebar section children', () => {
|
||||
it('does not show inline entries — no child items in type sections', () => {
|
||||
const entriesWithEmoji: VaultEntry[] = [
|
||||
{
|
||||
path: '/vault/project.md', filename: 'project.md', title: 'Project', isA: 'Type',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: 'rocket-launch', color: 'purple', order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/project/build-app.md', filename: 'build-app.md', title: 'Build App',
|
||||
isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
@@ -1025,30 +918,95 @@ describe('Sidebar', () => {
|
||||
icon: '🚀', color: null, order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
},
|
||||
]
|
||||
render(<Sidebar entries={entriesWithEmoji} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('Build App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('FAVORITES section', () => {
|
||||
const favEntry: VaultEntry = {
|
||||
path: '/vault/project/fav.md', filename: 'fav.md', title: 'My Favorite Note',
|
||||
isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 100, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
favorite: true, favoriteIndex: 0,
|
||||
}
|
||||
|
||||
it('shows FAVORITES section when there are favorites', () => {
|
||||
render(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('FAVORITES')).toBeInTheDocument()
|
||||
expect(screen.getByText('My Favorite Note')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides FAVORITES section when no favorites', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('FAVORITES')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides trashed favorites from the section', () => {
|
||||
const trashedFav = { ...favEntry, trashed: true }
|
||||
render(<Sidebar entries={[...mockEntries, trashedFav]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('FAVORITES')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect with favorites filter when clicking a favorite', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByText('My Favorite Note'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'favorites' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('group separators', () => {
|
||||
it('TYPES header and its entries share the same border-b container (no separator inside group)', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
const sectionsHeader = screen.getByText('TYPES')
|
||||
const projectsSection = screen.getByText('Projects')
|
||||
// Walk up from TYPES header to find the border-b container
|
||||
const borderContainer = sectionsHeader.closest('.border-b')
|
||||
expect(borderContainer).not.toBeNull()
|
||||
// The section entry should be inside the same border-b container
|
||||
expect(borderContainer!.contains(projectsSection)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('view edit button', () => {
|
||||
const mockViews = [
|
||||
{
|
||||
path: '/vault/project/no-icon.md', filename: 'no-icon.md', title: 'No Icon Project',
|
||||
isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 150, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
filename: 'active-projects.yml',
|
||||
definition: {
|
||||
name: 'Active Projects',
|
||||
icon: '🚀',
|
||||
color: null,
|
||||
sort: null,
|
||||
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Project' }] },
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
it('shows emoji icon before title in expanded section child', () => {
|
||||
render(<Sidebar entries={entriesWithEmoji} selection={defaultSelection} onSelect={() => {}} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
const buildApp = screen.getByText('Build App')
|
||||
const parent = buildApp.closest('div')!
|
||||
expect(parent.textContent).toBe('🚀Build App')
|
||||
it('renders edit button for each view item when onEditView is provided', () => {
|
||||
render(
|
||||
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} onEditView={() => {}} onDeleteView={() => {}} />
|
||||
)
|
||||
expect(screen.getByTitle('Edit view')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show emoji for notes without icon', () => {
|
||||
render(<Sidebar entries={entriesWithEmoji} selection={defaultSelection} onSelect={() => {}} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
const noIcon = screen.getByText('No Icon Project')
|
||||
const parent = noIcon.closest('div')!
|
||||
expect(parent.textContent).toBe('No Icon Project')
|
||||
it('does not render edit button when onEditView is not provided', () => {
|
||||
render(
|
||||
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} onDeleteView={() => {}} />
|
||||
)
|
||||
expect(screen.queryByTitle('Edit view')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onEditView with correct filename when clicked', () => {
|
||||
const onEditView = vi.fn()
|
||||
render(
|
||||
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} onEditView={onEditView} onDeleteView={() => {}} />
|
||||
)
|
||||
fireEvent.click(screen.getByTitle('Edit view'))
|
||||
expect(onEditView).toHaveBeenCalledWith('active-projects.yml')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react'
|
||||
import type { VaultEntry, FolderNode, SidebarSelection } from '../types'
|
||||
import type { VaultEntry, FolderNode, SidebarSelection, ViewFile } from '../types'
|
||||
import { buildTypeEntryMap } from '../utils/typeColors'
|
||||
import { buildDynamicSections, sortSections } from '../utils/sidebarSections'
|
||||
import { TypeCustomizePopover } from './TypeCustomizePopover'
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
FileText, Trash, Archive, CaretLeft, Tray,
|
||||
FileText, Trash, Archive, CaretLeft, Tray, CaretRight, CaretDown, Plus, Funnel, PencilSimple,
|
||||
} from '@phosphor-icons/react'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
import { arrayMove } from '@dnd-kit/sortable'
|
||||
import { SlidersHorizontal } from 'lucide-react'
|
||||
import {
|
||||
type SectionGroup, isSelectionActive,
|
||||
@@ -34,6 +36,12 @@ interface SidebarProps {
|
||||
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
|
||||
onRenameSection?: (typeName: string, label: string) => void
|
||||
onToggleTypeVisibility?: (typeName: string) => void
|
||||
onSelectFavorite?: (entry: VaultEntry) => void
|
||||
onReorderFavorites?: (orderedPaths: string[]) => void
|
||||
views?: ViewFile[]
|
||||
onCreateView?: () => void
|
||||
onEditView?: (filename: string) => void
|
||||
onDeleteView?: (filename: string) => void
|
||||
folders?: FolderNode[]
|
||||
onCreateFolder?: (name: string) => void
|
||||
inboxCount?: number
|
||||
@@ -64,6 +72,32 @@ function useSidebarSections(entries: VaultEntry[]) {
|
||||
return { typeEntryMap, allSectionGroups, visibleSections, sectionIds }
|
||||
}
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = 'laputa:sidebar-collapsed'
|
||||
|
||||
type SidebarGroupKey = 'favorites' | 'views' | 'sections' | 'folders'
|
||||
|
||||
function loadCollapsedState(): Record<SidebarGroupKey, boolean> {
|
||||
try {
|
||||
const raw = localStorage.getItem(SIDEBAR_COLLAPSED_KEY)
|
||||
if (raw) return JSON.parse(raw)
|
||||
} catch { /* ignore */ }
|
||||
return { favorites: false, views: false, sections: false, folders: false }
|
||||
}
|
||||
|
||||
function useSidebarCollapsed() {
|
||||
const [collapsed, setCollapsed] = useState<Record<SidebarGroupKey, boolean>>(loadCollapsedState)
|
||||
|
||||
const toggle = useCallback((key: SidebarGroupKey) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = { ...prev, [key]: !prev[key] }
|
||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, JSON.stringify(next))
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
return { collapsed, toggle }
|
||||
}
|
||||
|
||||
function useEntryCounts(entries: VaultEntry[]) {
|
||||
return useMemo(() => {
|
||||
let active = 0, archived = 0, trashed = 0
|
||||
@@ -112,24 +146,21 @@ function applyCustomization(
|
||||
|
||||
function SortableSection({ group, sectionProps }: {
|
||||
group: SectionGroup
|
||||
sectionProps: Omit<SectionContentProps, 'group' | 'items' | 'isCollapsed' | 'onToggle' | 'isRenaming' | 'renameInitialValue'>
|
||||
& { entries: VaultEntry[]; collapsed: Record<string, boolean>; onToggle: (type: string) => void; renamingType: string | null; renameInitialValue: string }
|
||||
sectionProps: Omit<SectionContentProps, 'group' | 'itemCount' | 'isRenaming' | 'renameInitialValue'>
|
||||
& { entries: VaultEntry[]; renamingType: string | null; renameInitialValue: string }
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
|
||||
const items = sectionProps.entries.filter((e) =>
|
||||
const itemCount = sectionProps.entries.filter((e) =>
|
||||
!e.archived && !e.trashed && (group.type === 'Note' ? (e.isA === 'Note' || !e.isA) : e.isA === group.type),
|
||||
)
|
||||
const isCollapsed = sectionProps.collapsed[group.type] ?? true
|
||||
).length
|
||||
const isRenaming = sectionProps.renamingType === group.type
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, padding: isCollapsed ? '0 6px' : '4px 6px' }} {...attributes}>
|
||||
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, padding: '0 6px' }} {...attributes}>
|
||||
<SectionContent
|
||||
group={group} items={items} isCollapsed={isCollapsed}
|
||||
group={group} itemCount={itemCount}
|
||||
selection={sectionProps.selection} onSelect={sectionProps.onSelect}
|
||||
onSelectNote={sectionProps.onSelectNote} onCreateType={sectionProps.onCreateType}
|
||||
onCreateNewType={sectionProps.onCreateNewType} onContextMenu={sectionProps.onContextMenu}
|
||||
onToggle={() => sectionProps.onToggle(group.type)}
|
||||
onContextMenu={sectionProps.onContextMenu}
|
||||
dragHandleProps={listeners}
|
||||
isRenaming={isRenaming}
|
||||
renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined}
|
||||
@@ -140,6 +171,99 @@ function SortableSection({ group, sectionProps }: {
|
||||
)
|
||||
}
|
||||
|
||||
function SortableFavoriteItem({ entry, isActive, onSelect }: {
|
||||
entry: VaultEntry; isActive: boolean; onSelect: () => void
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry.path })
|
||||
const icon = entry.icon && isEmoji(entry.icon) ? entry.icon : null
|
||||
return (
|
||||
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1 }} {...attributes} {...listeners}>
|
||||
<div
|
||||
className={`flex cursor-pointer select-none items-center gap-1.5 rounded text-[13px] font-normal transition-colors ${isActive ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-accent'}`}
|
||||
style={{ padding: '4px 16px 4px 28px' }}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{icon && <span className="shrink-0">{icon}</span>}
|
||||
<span className="truncate">{entry.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FavoritesSection({ entries, selection, onSelect, onSelectNote, onReorder, collapsed, onToggle }: {
|
||||
entries: VaultEntry[]
|
||||
selection: SidebarSelection
|
||||
onSelect: (sel: SidebarSelection) => void
|
||||
onSelectNote?: (entry: VaultEntry) => void
|
||||
onReorder?: (orderedPaths: string[]) => void
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const favorites = useMemo(
|
||||
() => entries
|
||||
.filter((e) => e.favorite && !e.archived && !e.trashed)
|
||||
.sort((a, b) => (a.favoriteIndex ?? Infinity) - (b.favoriteIndex ?? Infinity)),
|
||||
[entries],
|
||||
)
|
||||
const favIds = useMemo(() => favorites.map((f) => f.path), [favorites])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
const oldIndex = favIds.indexOf(active.id as string)
|
||||
const newIndex = favIds.indexOf(over.id as string)
|
||||
if (oldIndex === -1 || newIndex === -1) return
|
||||
const reordered = arrayMove(favIds, oldIndex, newIndex)
|
||||
onReorder?.(reordered)
|
||||
}, [favIds, onReorder])
|
||||
|
||||
if (favorites.length === 0) return null
|
||||
|
||||
return (
|
||||
<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: '8px 14px 8px 16px' }}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{collapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
|
||||
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>FAVORITES</span>
|
||||
</div>
|
||||
<span className="flex items-center justify-center text-muted-foreground" style={{ height: 18, borderRadius: 9999, padding: '0 5px', fontSize: 10, background: 'var(--muted)' }}>
|
||||
{favorites.length}
|
||||
</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={favIds} strategy={verticalListSortingStrategy}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{favorites.map((entry) => {
|
||||
const isActive = isSelectionActive(selection, { kind: 'entity', entry })
|
||||
return (
|
||||
<SortableFavoriteItem
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
isActive={isActive}
|
||||
onSelect={() => {
|
||||
onSelect({ kind: 'filter', filter: 'favorites' })
|
||||
onSelectNote?.(entry)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarTitleBar({ onCollapse }: { onCollapse?: () => void }) {
|
||||
const { onMouseDown } = useDragRegion()
|
||||
return (
|
||||
@@ -205,12 +329,12 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChang
|
||||
// --- Main Sidebar ---
|
||||
|
||||
export const Sidebar = memo(function Sidebar({
|
||||
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
entries, selection, onSelect,
|
||||
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
|
||||
onToggleTypeVisibility,
|
||||
onToggleTypeVisibility, onSelectFavorite, onReorderFavorites,
|
||||
views = [], onCreateView, onEditView, onDeleteView,
|
||||
folders = [], onCreateFolder, inboxCount = 0, onCollapse,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
|
||||
const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const [renamingType, setRenamingType] = useState<string | null>(null)
|
||||
@@ -236,10 +360,6 @@ export const Sidebar = memo(function Sidebar({
|
||||
useOutsideClick(contextMenuRef, !!contextMenuPos, closeContextMenu)
|
||||
useOutsideClick(popoverRef, !!customizeTarget, closeCustomizeTarget)
|
||||
|
||||
const toggleSection = useCallback((type: string) => {
|
||||
setCollapsed((prev) => ({ ...prev, [type]: !(prev[type] ?? true) }))
|
||||
}, [])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
@@ -280,11 +400,16 @@ export const Sidebar = memo(function Sidebar({
|
||||
}, [customizeTarget, onUpdateTypeTemplate])
|
||||
|
||||
const sectionProps = {
|
||||
entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onContextMenu: handleContextMenu, onToggle: toggleSection,
|
||||
entries, selection, onSelect,
|
||||
onContextMenu: handleContextMenu,
|
||||
renamingType, renameInitialValue, onRenameSubmit: handleRenameSubmit, onRenameCancel: cancelRename,
|
||||
}
|
||||
|
||||
const { collapsed: groupCollapsed, toggle: toggleGroup } = useSidebarCollapsed()
|
||||
|
||||
const hasFavorites = entries.some((e) => e.favorite && !e.archived && !e.trashed)
|
||||
const hasViews = views.length > 0 || !!onCreateView
|
||||
|
||||
return (
|
||||
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground">
|
||||
<SidebarTitleBar onCollapse={onCollapse} />
|
||||
@@ -297,28 +422,109 @@ export const Sidebar = memo(function Sidebar({
|
||||
<NavItem icon={Trash} label="Trash" count={trashedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'trash' })} activeClassName="bg-destructive/10 text-destructive" badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} activeBadgeClassName="bg-destructive text-destructive-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
|
||||
</div>
|
||||
|
||||
{/* Sections header + visibility popover */}
|
||||
<div ref={customizeRef} style={{ position: 'relative', padding: '4px 6px 0' }}>
|
||||
<div className="flex w-full select-none items-center justify-between" style={{ padding: '4px 16px' }}>
|
||||
<span className="text-[11px] font-medium text-muted-foreground">Sections</span>
|
||||
<button className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground" style={{ width: 20, height: 20 }} onClick={() => setShowCustomize((v) => !v)} aria-label="Customize sections" title="Customize sections">
|
||||
<SlidersHorizontal size={14} />
|
||||
</button>
|
||||
{/* Favorites */}
|
||||
{hasFavorites && (
|
||||
<div className="border-b border-border">
|
||||
<FavoritesSection entries={entries} selection={selection} onSelect={onSelect} onSelectNote={onSelectFavorite} onReorder={onReorderFavorites} collapsed={groupCollapsed.favorites} onToggle={() => toggleGroup('favorites')} />
|
||||
</div>
|
||||
{showCustomize && <VisibilityPopover sections={allSectionGroups} isSectionVisible={isSectionVisible} onToggle={toggleVisibility} />}
|
||||
)}
|
||||
|
||||
{/* Views */}
|
||||
{hasViews && (
|
||||
<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: '8px 14px 8px 16px' }}
|
||||
onClick={() => toggleGroup('views')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{groupCollapsed.views ? <CaretRight size={12} /> : <CaretDown size={12} />}
|
||||
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>VIEWS</span>
|
||||
</div>
|
||||
{onCreateView && (
|
||||
<Plus
|
||||
size={12}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); onCreateView() }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
{!groupCollapsed.views && (
|
||||
<div style={{ paddingBottom: 4 }}>
|
||||
{views.map((v) => (
|
||||
<div key={v.filename} className="group relative">
|
||||
<NavItem
|
||||
icon={Funnel}
|
||||
emoji={v.definition.icon}
|
||||
label={v.definition.name}
|
||||
isActive={isSelectionActive(selection, { kind: 'view', filename: v.filename })}
|
||||
onClick={() => onSelect({ kind: 'view', filename: v.filename })}
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{onEditView && (
|
||||
<button
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => { e.stopPropagation(); onEditView(v.filename) }}
|
||||
title="Edit view"
|
||||
>
|
||||
<PencilSimple size={12} />
|
||||
</button>
|
||||
)}
|
||||
{onDeleteView && (
|
||||
<button
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
|
||||
onClick={(e) => { e.stopPropagation(); onDeleteView(v.filename) }}
|
||||
title="Delete view"
|
||||
>
|
||||
<Trash size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sections header + entries */}
|
||||
<div className="border-b border-border">
|
||||
<div ref={customizeRef} style={{ position: 'relative', padding: '0 6px' }}>
|
||||
<button
|
||||
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
|
||||
style={{ padding: '8px 14px 8px 16px' }}
|
||||
onClick={() => toggleGroup('sections')}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{groupCollapsed.sections ? <CaretRight size={12} /> : <CaretDown size={12} />}
|
||||
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>TYPES</span>
|
||||
</div>
|
||||
<span
|
||||
role="button"
|
||||
title="Customize sections"
|
||||
aria-label="Customize sections"
|
||||
onClick={(e) => { e.stopPropagation(); setShowCustomize((v) => !v) }}
|
||||
>
|
||||
<SlidersHorizontal size={12} className="text-muted-foreground hover:text-foreground" />
|
||||
</span>
|
||||
</button>
|
||||
{showCustomize && <VisibilityPopover sections={allSectionGroups} isSectionVisible={isSectionVisible} onToggle={toggleVisibility} />}
|
||||
</div>
|
||||
|
||||
{/* Sortable section groups */}
|
||||
{!groupCollapsed.sections && (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
|
||||
{visibleSections.map((g) => (
|
||||
<SortableSection key={g.type} group={g} sectionProps={sectionProps} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sortable section groups */}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
|
||||
{visibleSections.map((g) => (
|
||||
<SortableSection key={g.type} group={g} sectionProps={sectionProps} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Folder tree */}
|
||||
<FolderTree folders={folders} selection={selection} onSelect={onSelect} onCreateFolder={onCreateFolder} />
|
||||
<FolderTree folders={folders} selection={selection} onSelect={onSelect} onCreateFolder={onCreateFolder} collapsed={groupCollapsed.folders} onToggle={() => toggleGroup('folders')} />
|
||||
</nav>
|
||||
|
||||
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} onStartRename={handleStartRename} />
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { type ComponentType, useState, useEffect, useRef } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import type { SidebarSelection } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
import { ChevronRight, ChevronDown, Plus } from 'lucide-react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { type IconProps } from '@phosphor-icons/react'
|
||||
|
||||
@@ -21,14 +19,16 @@ export function isSelectionActive(current: SidebarSelection, check: SidebarSelec
|
||||
case 'sectionGroup': return (current as typeof check).type === check.type
|
||||
case 'folder': return (current as typeof check).path === check.path
|
||||
case 'entity': return (current as typeof check).entry.path === check.entry.path
|
||||
case 'view': return (current as typeof check).filename === check.filename
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
// --- NavItem ---
|
||||
|
||||
export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, activeBadgeClassName, activeBadgeStyle, onClick, disabled, disabledTooltip, compact }: {
|
||||
export function NavItem({ icon: Icon, emoji, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, activeBadgeClassName, activeBadgeStyle, onClick, disabled, disabledTooltip, compact }: {
|
||||
icon: ComponentType<IconProps>
|
||||
emoji?: string | null
|
||||
label: string
|
||||
count?: number
|
||||
isActive?: boolean
|
||||
@@ -47,11 +47,14 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
|
||||
const padding = compact ? '4px 16px' : '6px 16px'
|
||||
const resolvedBadgeClass = isActive && activeBadgeClassName ? activeBadgeClassName : badgeClassName
|
||||
const resolvedBadgeStyle = isActive && activeBadgeClassName ? activeBadgeStyle : badgeStyle
|
||||
const iconEl = emoji
|
||||
? <span style={{ fontSize: iconSize, lineHeight: 1, width: iconSize, textAlign: 'center' }}>{emoji}</span>
|
||||
: <Icon size={iconSize} weight={isActive ? 'fill' : 'regular'} />
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<div className="flex select-none items-center gap-2 rounded text-foreground" style={{ padding, borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }} title={disabledTooltip ?? "Coming soon"}>
|
||||
<Icon size={iconSize} />
|
||||
{iconEl}
|
||||
<span className={cn("flex-1 font-medium", textClass)}>{label}</span>
|
||||
</div>
|
||||
)
|
||||
@@ -62,7 +65,7 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
|
||||
style={{ padding, borderRadius: 4 }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon size={iconSize} weight={isActive ? 'fill' : 'regular'} />
|
||||
{iconEl}
|
||||
<span className={cn("flex-1 font-medium", textClass)}>{label}</span>
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className={cn("flex items-center justify-center", resolvedBadgeClass)} style={{ height: compact ? 18 : 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, ...resolvedBadgeStyle }}>
|
||||
@@ -77,15 +80,10 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
|
||||
|
||||
export interface SectionContentProps {
|
||||
group: SectionGroup
|
||||
items: VaultEntry[]
|
||||
isCollapsed: boolean
|
||||
itemCount: number
|
||||
selection: SidebarSelection
|
||||
onSelect: (sel: SidebarSelection) => void
|
||||
onSelectNote?: (entry: VaultEntry) => void
|
||||
onCreateType?: (type: string) => void
|
||||
onCreateNewType?: () => void
|
||||
onContextMenu: (e: React.MouseEvent, type: string) => void
|
||||
onToggle: () => void
|
||||
dragHandleProps?: Record<string, unknown>
|
||||
isRenaming?: boolean
|
||||
renameInitialValue?: string
|
||||
@@ -93,75 +91,30 @@ export interface SectionContentProps {
|
||||
onRenameCancel?: () => void
|
||||
}
|
||||
|
||||
function childSelection(entry: VaultEntry): SidebarSelection {
|
||||
return { kind: 'entity', entry }
|
||||
}
|
||||
|
||||
function resolveCreateHandler(type: string, onCreateType?: (type: string) => void, onCreateNewType?: () => void): (() => void) | undefined {
|
||||
const isType = type === 'Type'
|
||||
if (!onCreateType && !(isType && onCreateNewType)) return undefined
|
||||
return isType ? () => onCreateNewType?.() : () => onCreateType?.(type)
|
||||
}
|
||||
|
||||
export function SectionContent({
|
||||
group, items, isCollapsed, selection, onSelect, onSelectNote,
|
||||
onCreateType, onCreateNewType, onContextMenu, onToggle, dragHandleProps,
|
||||
group, itemCount, selection, onSelect,
|
||||
onContextMenu, dragHandleProps,
|
||||
isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel,
|
||||
}: SectionContentProps) {
|
||||
const { label, type, Icon, customColor } = group
|
||||
const sectionColor = getTypeColor(type, customColor)
|
||||
const sectionLightColor = getTypeLightColor(type, customColor)
|
||||
const onCreate = resolveCreateHandler(type, onCreateType, onCreateNewType)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
label={label} type={type} Icon={Icon}
|
||||
sectionColor={sectionColor}
|
||||
isCollapsed={isCollapsed}
|
||||
isActive={isSelectionActive(selection, { kind: 'sectionGroup', type })}
|
||||
showCreate={!!onCreate}
|
||||
onSelect={() => onSelect({ kind: 'sectionGroup', type })}
|
||||
onContextMenu={(e) => onContextMenu(e, type)}
|
||||
onToggle={onToggle}
|
||||
onCreate={(e) => { e.stopPropagation(); onCreate?.() }}
|
||||
dragHandleProps={dragHandleProps}
|
||||
isRenaming={isRenaming}
|
||||
renameInitialValue={renameInitialValue}
|
||||
onRenameSubmit={onRenameSubmit}
|
||||
onRenameCancel={onRenameCancel}
|
||||
/>
|
||||
{!isCollapsed && items.length > 0 && (
|
||||
<SectionChildList
|
||||
items={items} selection={selection}
|
||||
sectionColor={sectionColor} sectionLightColor={sectionLightColor}
|
||||
onSelect={onSelect} onSelectNote={onSelectNote}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionChildList({ items, selection, sectionColor, sectionLightColor, onSelect, onSelectNote }: {
|
||||
items: VaultEntry[]; selection: SidebarSelection
|
||||
sectionColor: string; sectionLightColor: string
|
||||
onSelect: (sel: SidebarSelection) => void; onSelectNote?: (entry: VaultEntry) => void
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{items.map((entry) => {
|
||||
const sel = childSelection(entry)
|
||||
const active = isSelectionActive(selection, sel)
|
||||
return (
|
||||
<SectionChildItem
|
||||
key={entry.path} title={entry.title} icon={entry.icon} isActive={active}
|
||||
sectionColor={active ? sectionColor : undefined}
|
||||
sectionLightColor={active ? sectionLightColor : undefined}
|
||||
onClick={() => { onSelect(sel); onSelectNote?.(entry) }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<SectionHeader
|
||||
label={label} type={type} Icon={Icon}
|
||||
sectionColor={sectionColor}
|
||||
sectionLightColor={sectionLightColor}
|
||||
itemCount={itemCount}
|
||||
isActive={isSelectionActive(selection, { kind: 'sectionGroup', type })}
|
||||
onSelect={() => onSelect({ kind: 'sectionGroup', type })}
|
||||
onContextMenu={(e) => onContextMenu(e, type)}
|
||||
dragHandleProps={dragHandleProps}
|
||||
isRenaming={isRenaming}
|
||||
renameInitialValue={renameInitialValue}
|
||||
onRenameSubmit={onRenameSubmit}
|
||||
onRenameCancel={onRenameCancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -195,29 +148,24 @@ function InlineRenameInput({ initialValue, onSubmit, onCancel }: {
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
|
||||
function SectionHeader({ label, type, Icon, sectionColor, sectionLightColor, itemCount, isActive, onSelect, onContextMenu, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
|
||||
label: string; type: string; Icon: ComponentType<IconProps>
|
||||
sectionColor: string; isCollapsed: boolean; isActive: boolean; showCreate: boolean
|
||||
sectionColor: string; sectionLightColor: string; itemCount: number; isActive: boolean
|
||||
onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void
|
||||
onToggle: () => void; onCreate: (e: React.MouseEvent) => void
|
||||
dragHandleProps?: Record<string, unknown>
|
||||
isRenaming?: boolean; renameInitialValue?: string
|
||||
onRenameSubmit?: (value: string) => void; onRenameCancel?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", isActive ? "bg-secondary" : "hover:bg-accent")}
|
||||
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4 }}
|
||||
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", !isActive && "hover:bg-accent")}
|
||||
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4, ...(isActive ? { background: sectionLightColor } : {}) }}
|
||||
{...dragHandleProps}
|
||||
onClick={() => {
|
||||
if (isRenaming) return
|
||||
if (isCollapsed) { onToggle(); onSelect() }
|
||||
else if (isActive) { onToggle() }
|
||||
else { onSelect() }
|
||||
}} onContextMenu={isRenaming ? undefined : onContextMenu}
|
||||
onClick={() => { if (!isRenaming) onSelect() }}
|
||||
onContextMenu={isRenaming ? undefined : onContextMenu}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
|
||||
<Icon size={16} style={{ color: sectionColor, flexShrink: 0 }} />
|
||||
<Icon size={16} weight={isActive ? 'fill' : 'regular'} style={{ color: sectionColor, flexShrink: 0 }} />
|
||||
{isRenaming && onRenameSubmit && onRenameCancel ? (
|
||||
<InlineRenameInput
|
||||
key={`rename-${type}`}
|
||||
@@ -226,35 +174,17 @@ function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive,
|
||||
onCancel={onRenameCancel}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-[13px] font-medium text-foreground" style={{ marginLeft: 4 }}>{label}</span>
|
||||
<span className="text-[13px] font-medium" style={{ marginLeft: 4, color: isActive ? sectionColor : undefined }}>{label}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center" style={{ gap: 2 }}>
|
||||
{showCreate && (
|
||||
<button className="flex shrink-0 items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/section:opacity-100 cursor-pointer" style={{ width: 20, height: 20 }} onClick={onCreate} aria-label={type === 'Type' ? 'Create new Type' : `Create new ${type}`} title={type === 'Type' ? 'New Type' : `New ${type}`}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button className="flex shrink-0 items-center border-none bg-transparent p-0 text-inherit cursor-pointer" onClick={(e) => { e.stopPropagation(); onToggle() }} aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`}>
|
||||
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionChildItem({ title, icon, isActive, sectionColor, sectionLightColor, onClick }: {
|
||||
title: string; icon?: string | null; isActive: boolean
|
||||
sectionColor?: string; sectionLightColor?: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("cursor-pointer truncate rounded-md text-[13px] font-normal transition-colors", isActive ? "text-foreground" : "text-muted-foreground hover:bg-accent")}
|
||||
style={{ padding: '4px 16px 4px 28px', ...(isActive && { backgroundColor: sectionLightColor, color: sectionColor }) }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon && isEmoji(icon) && <span className="mr-1">{icon}</span>}{title}
|
||||
{itemCount > 0 && (
|
||||
<span
|
||||
className={cn("flex items-center justify-center", !isActive && "text-muted-foreground")}
|
||||
style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, ...(isActive ? { background: sectionColor, color: 'white' } : { background: 'var(--muted)' }) }}
|
||||
>
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
import { useCreateBlockNote, SuggestionMenuController } from '@blocknote/react'
|
||||
import { BlockNoteView } from '@blocknote/mantine'
|
||||
import { useEditorTheme } from '../hooks/useTheme'
|
||||
@@ -87,6 +88,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
{ type: 'wikilink' as const, props: { target } },
|
||||
" ",
|
||||
])
|
||||
trackEvent('wikilink_inserted')
|
||||
}, [editor])
|
||||
|
||||
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
|
||||
|
||||
@@ -36,8 +36,12 @@ describe('TitleField', () => {
|
||||
expect(input).toHaveValue('Keep This')
|
||||
})
|
||||
|
||||
it('shows filename indicator when slug differs from current filename', () => {
|
||||
it('shows filename indicator when slug differs and title is focused', () => {
|
||||
render(<TitleField title="My Note" filename="wrong-name.md" onTitleChange={() => {}} />)
|
||||
// Not shown when unfocused
|
||||
expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument()
|
||||
// Shown when focused
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.getByTestId('title-field-filename')).toHaveTextContent('my-note.md')
|
||||
})
|
||||
|
||||
@@ -101,4 +105,64 @@ describe('TitleField', () => {
|
||||
window.dispatchEvent(new CustomEvent('laputa:focus-editor', { detail: { selectTitle: true } }))
|
||||
expect(document.activeElement).toBe(input)
|
||||
})
|
||||
|
||||
it('resets stale localValue when title prop changes after focus', () => {
|
||||
// Regression: creating a new note fires focus-editor before React re-renders,
|
||||
// so handleFocus captures the OLD note's title into localValue.
|
||||
// When React re-renders with the new title, localValue should be cleared.
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(<TitleField title="Old Note" filename="old-note.md" onTitleChange={onChange} />)
|
||||
const input = screen.getByTestId('title-field-input')
|
||||
// Simulate: focus fires while title prop is still "Old Note"
|
||||
fireEvent.focus(input)
|
||||
expect(input).toHaveValue('Old Note')
|
||||
// React re-renders with new note's title (tab switched)
|
||||
rerender(<TitleField title="Untitled note" filename="untitled-note.md" onTitleChange={onChange} />)
|
||||
expect(input).toHaveValue('Untitled note')
|
||||
})
|
||||
|
||||
it('shows vault-relative path (without .md) only when title is focused', () => {
|
||||
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
|
||||
// Path hidden by default
|
||||
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
|
||||
// Focus title → path appears
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
|
||||
// No bare filename shown when path is visible
|
||||
expect(screen.queryByTestId('title-field-filename')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides path on blur', () => {
|
||||
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
|
||||
const input = screen.getByTestId('title-field-input')
|
||||
fireEvent.focus(input)
|
||||
expect(screen.getByTestId('title-field-path')).toBeInTheDocument()
|
||||
fireEvent.blur(input)
|
||||
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides path for notes at vault root even when focused', () => {
|
||||
render(<TitleField title="Root Note" filename="root-note.md" notePath="/Users/luca/Laputa/root-note.md" vaultPath="/Users/luca/Laputa" onTitleChange={() => {}} />)
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides path when vaultPath is not provided', () => {
|
||||
render(<TitleField title="Note" filename="note.md" onTitleChange={() => {}} />)
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.queryByTestId('title-field-path')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('resolves vault-relative path when paths differ by symlink prefix', () => {
|
||||
// vaultPath uses symlink /Users/luca/... but notePath is canonical /Volumes/Jupiter/...
|
||||
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Volumes/Jupiter/Workspace/laputa-app/demo-vault-v2/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Workspace/laputa-app/demo-vault-v2" onTitleChange={() => {}} />)
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
|
||||
})
|
||||
|
||||
it('handles vaultPath with trailing slash', () => {
|
||||
render(<TitleField title="ADR" filename="0001-tauri-stack.md" notePath="/Users/luca/Laputa/docs/adr/0001-tauri-stack.md" vaultPath="/Users/luca/Laputa/" onTitleChange={() => {}} />)
|
||||
fireEvent.focus(screen.getByTestId('title-field-input'))
|
||||
expect(screen.getByTestId('title-field-path')).toHaveTextContent('docs/adr/0001-tauri-stack')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,10 @@ interface TitleFieldProps {
|
||||
title: string
|
||||
filename: string
|
||||
editable?: boolean
|
||||
/** Absolute path of the note file. */
|
||||
notePath?: string
|
||||
/** Absolute path of the vault root. */
|
||||
vaultPath?: string
|
||||
/** Called when the user finishes editing the title (blur or Enter). */
|
||||
onTitleChange: (newTitle: string) => void
|
||||
}
|
||||
@@ -15,6 +19,15 @@ function useOptimisticTitle(title: string, onTitleChange: (t: string) => void) {
|
||||
// [optimisticTitle, forPropTitle]: shown after commit until title prop catches up
|
||||
const [optimistic, setOptimistic] = useState<[string, string] | null>(null)
|
||||
const isFocusedRef = useRef(false)
|
||||
const [prevTitle, setPrevTitle] = useState(title)
|
||||
|
||||
// Reset local edit when the title prop changes (e.g. note switch).
|
||||
// This prevents a stale handleFocus closure from locking in the old note's title
|
||||
// when focus-editor fires before React re-renders with the new tab.
|
||||
if (prevTitle !== title) {
|
||||
setPrevTitle(title)
|
||||
if (localValue !== null) setLocalValue(null)
|
||||
}
|
||||
|
||||
// Clear optimistic once the prop changes (rename completed or tab switched)
|
||||
const optimisticValue = optimistic && optimistic[1] === title ? optimistic[0] : null
|
||||
@@ -48,11 +61,37 @@ function useOptimisticTitle(title: string, onTitleChange: (t: string) => void) {
|
||||
* Dedicated title input field above the editor.
|
||||
* Displays the title as an editable field and shows the resulting filename below.
|
||||
*/
|
||||
export function TitleField({ title, filename, editable = true, onTitleChange }: TitleFieldProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
export function TitleField({ title, filename, editable = true, notePath, vaultPath, onTitleChange }: TitleFieldProps) {
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const { value, isEditing, handleFocus, commitTitle, revert, setEdit } =
|
||||
useOptimisticTitle(title, onTitleChange)
|
||||
|
||||
// Auto-resize textarea to fit content (fallback for browsers without field-sizing: content)
|
||||
const autoResize = useCallback(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = el.scrollHeight + 'px'
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
autoResize()
|
||||
// Schedule a second measurement after the browser has painted, to catch
|
||||
// cases where scrollHeight is stale on the first read (e.g. tab switch).
|
||||
const id = requestAnimationFrame(autoResize)
|
||||
return () => cancelAnimationFrame(id)
|
||||
}, [value, autoResize])
|
||||
|
||||
// Re-measure when container width changes (text may re-wrap)
|
||||
useEffect(() => {
|
||||
const el = inputRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(autoResize)
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [autoResize])
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail
|
||||
@@ -78,17 +117,38 @@ export function TitleField({ title, filename, editable = true, onTitleChange }:
|
||||
|
||||
const expectedSlug = slugify(value.trim() || title)
|
||||
const currentStem = filename.replace(/\.md$/, '')
|
||||
const showFilename = isEditing || currentStem !== expectedSlug
|
||||
|
||||
// Compute vault-relative path (only for notes in subdirectories)
|
||||
const relativePath = (() => {
|
||||
if (!notePath || !vaultPath) return null
|
||||
const vp = vaultPath.replace(/\/+$/, '')
|
||||
const np = notePath.replace(/\.md$/, '')
|
||||
if (np.startsWith(vp + '/')) return np.slice(vp.length + 1)
|
||||
// Fallback: match by vault directory name for symlink-resolved paths
|
||||
const vaultName = vp.split('/').pop()
|
||||
if (!vaultName) return null
|
||||
const segments = np.split('/')
|
||||
const idx = segments.lastIndexOf(vaultName)
|
||||
if (idx >= 0) return segments.slice(idx + 1).join('/')
|
||||
return null
|
||||
})()
|
||||
const isSubdirectory = relativePath != null && relativePath.includes('/')
|
||||
|
||||
// Show path only when title is focused and note is in a subdirectory
|
||||
const showRelativePath = isFocused && isSubdirectory
|
||||
// Show filename hint when slug differs, but only when focused (not always)
|
||||
const showFilename = isFocused && !showRelativePath && (isEditing || currentStem !== expectedSlug)
|
||||
|
||||
return (
|
||||
<div className="title-field" data-testid="title-field">
|
||||
<input
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="title-field__input"
|
||||
value={value}
|
||||
onChange={e => setEdit(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={commitTitle}
|
||||
rows={1}
|
||||
onChange={e => { setEdit(e.target.value); autoResize() }}
|
||||
onFocus={() => { setIsFocused(true); handleFocus() }}
|
||||
onBlur={() => { setIsFocused(false); commitTitle() }}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={!editable}
|
||||
placeholder="Untitled"
|
||||
@@ -100,6 +160,11 @@ export function TitleField({ title, filename, editable = true, onTitleChange }:
|
||||
{expectedSlug}.md
|
||||
</span>
|
||||
)}
|
||||
{showRelativePath && (
|
||||
<span className="title-field__path" data-testid="title-field-path" style={{ display: 'block', fontSize: 11, color: 'var(--muted-foreground)', marginTop: 2 }}>
|
||||
{relativePath}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ interface InboxFilterPillsProps {
|
||||
const PILLS: { value: InboxPeriod; label: string }[] = [
|
||||
{ value: 'week', label: 'Week' },
|
||||
{ value: 'month', label: 'Month' },
|
||||
{ value: 'quarter', label: 'Quarter' },
|
||||
{ value: 'all', label: 'All' },
|
||||
]
|
||||
|
||||
|
||||
142
src/components/note-list/ListPropertiesPopover.tsx
Normal file
142
src/components/note-list/ListPropertiesPopover.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { SlidersHorizontal, DotsSixVertical } from '@phosphor-icons/react'
|
||||
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
|
||||
import type { VaultEntry } from '../../types'
|
||||
import {
|
||||
DndContext, closestCenter, KeyboardSensor, PointerSensor,
|
||||
useSensor, useSensors, type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy,
|
||||
arrayMove,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
|
||||
interface ListPropertiesPopoverProps {
|
||||
typeDocument: VaultEntry
|
||||
entries: VaultEntry[]
|
||||
onSave: (path: string, key: string, value: string[] | null) => void
|
||||
}
|
||||
|
||||
/** Collect all available property/relationship keys from notes of this type. */
|
||||
function collectAvailableProperties(entries: VaultEntry[], typeName: string): string[] {
|
||||
const keys = new Set<string>()
|
||||
for (const entry of entries) {
|
||||
if (entry.isA !== typeName) continue
|
||||
for (const k of Object.keys(entry.properties)) keys.add(k)
|
||||
for (const k of Object.keys(entry.relationships)) keys.add(k)
|
||||
}
|
||||
// Sort alphabetically for stable ordering
|
||||
return [...keys].sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
function SortablePropertyItem({ id, checked, onToggle }: { id: string; checked: boolean; onToggle: (key: string) => void }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id })
|
||||
const style = { transform: CSS.Transform.toString(transform), transition }
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="flex items-center gap-2 rounded px-1 py-1 hover:bg-muted"
|
||||
data-testid={`list-prop-item-${id}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex shrink-0 cursor-grab items-center text-muted-foreground active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<DotsSixVertical size={14} />
|
||||
</button>
|
||||
<label className="flex flex-1 cursor-pointer items-center gap-2 text-[13px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(id)}
|
||||
className="accent-primary"
|
||||
style={{ width: 14, height: 14 }}
|
||||
/>
|
||||
<span className="truncate">{id}</span>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ListPropertiesPopover({ typeDocument, entries, onSave }: ListPropertiesPopoverProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const currentDisplay = typeDocument.listPropertiesDisplay ?? []
|
||||
|
||||
const availableProperties = useMemo(
|
||||
() => collectAvailableProperties(entries, typeDocument.title),
|
||||
[entries, typeDocument.title],
|
||||
)
|
||||
|
||||
// Merge: selected props first (in order), then unselected alphabetically
|
||||
const orderedItems = useMemo(() => {
|
||||
const selected = currentDisplay.filter((p) => availableProperties.includes(p))
|
||||
const unselected = availableProperties.filter((p) => !selected.includes(p))
|
||||
return [...selected, ...unselected]
|
||||
}, [currentDisplay, availableProperties])
|
||||
|
||||
const selectedSet = useMemo(() => new Set(currentDisplay), [currentDisplay])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
const handleToggle = useCallback((key: string) => {
|
||||
const newSelected = selectedSet.has(key)
|
||||
? currentDisplay.filter((k) => k !== key)
|
||||
: [...currentDisplay, key]
|
||||
onSave(typeDocument.path, '_list_properties_display', newSelected.length > 0 ? newSelected : null)
|
||||
}, [selectedSet, currentDisplay, typeDocument.path, onSave])
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
// Only reorder within selected items
|
||||
const selected = currentDisplay.filter((p) => availableProperties.includes(p))
|
||||
const oldIndex = selected.indexOf(String(active.id))
|
||||
const newIndex = selected.indexOf(String(over.id))
|
||||
if (oldIndex === -1 || newIndex === -1) return
|
||||
|
||||
const reordered = arrayMove(selected, oldIndex, newIndex)
|
||||
onSave(typeDocument.path, '_list_properties_display', reordered)
|
||||
}, [currentDisplay, availableProperties, typeDocument.path, onSave])
|
||||
|
||||
if (availableProperties.length === 0) return null
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex items-center text-muted-foreground transition-colors hover:text-foreground"
|
||||
title="Configure list properties"
|
||||
data-testid="list-properties-btn"
|
||||
>
|
||||
<SlidersHorizontal size={16} />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-56 p-2" data-testid="list-properties-popover">
|
||||
<div className="mb-2 px-1 text-[11px] font-medium text-muted-foreground">
|
||||
Show in note list
|
||||
</div>
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={orderedItems} strategy={verticalListSortingStrategy}>
|
||||
{orderedItems.map((key) => (
|
||||
<SortablePropertyItem
|
||||
key={key}
|
||||
id={key}
|
||||
checked={selectedSet.has(key)}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -4,8 +4,9 @@ import type { SortOption, SortDirection } from '../../utils/noteListHelpers'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useDragRegion } from '../../hooks/useDragRegion'
|
||||
import { SortDropdown } from '../SortDropdown'
|
||||
import { ListPropertiesPopover } from './ListPropertiesPopover'
|
||||
|
||||
export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView, trashCount, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onEmptyTrash }: {
|
||||
export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView, trashCount, listSort, listDirection, customProperties, sidebarCollapsed, searchVisible, search, isSectionGroup, entries, onSortChange, onCreateNote, onOpenType, onToggleSearch, onSearchChange, onEmptyTrash, onUpdateTypeProperty }: {
|
||||
title: string
|
||||
typeDocument: VaultEntry | null
|
||||
isEntityView: boolean
|
||||
@@ -17,12 +18,15 @@ export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView,
|
||||
sidebarCollapsed?: boolean
|
||||
searchVisible: boolean
|
||||
search: string
|
||||
isSectionGroup?: boolean
|
||||
entries?: VaultEntry[]
|
||||
onSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
|
||||
onCreateNote: () => void
|
||||
onOpenType: (entry: VaultEntry) => void
|
||||
onToggleSearch: () => void
|
||||
onSearchChange: (value: string) => void
|
||||
onEmptyTrash?: () => void
|
||||
onUpdateTypeProperty?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
||||
}) {
|
||||
const { onMouseDown: onDragMouseDown } = useDragRegion()
|
||||
return (
|
||||
@@ -41,6 +45,9 @@ export function NoteListHeader({ title, typeDocument, isEntityView, isTrashView,
|
||||
<button className="flex items-center text-muted-foreground transition-colors hover:text-foreground" onClick={onToggleSearch} title="Search notes">
|
||||
<MagnifyingGlass size={16} />
|
||||
</button>
|
||||
{isSectionGroup && typeDocument && entries && onUpdateTypeProperty && (
|
||||
<ListPropertiesPopover typeDocument={typeDocument} entries={entries} onSave={onUpdateTypeProperty} />
|
||||
)}
|
||||
{isTrashView && trashCount > 0 && (
|
||||
<button
|
||||
className="flex items-center text-destructive transition-colors hover:text-destructive/80"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../../types'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewFile } from '../../types'
|
||||
import {
|
||||
type SortOption, type SortDirection, type SortConfig, type NoteListFilter,
|
||||
getSortComparator, extractSortableProperties,
|
||||
@@ -20,7 +20,7 @@ export function useTypeEntryMap(entries: VaultEntry[]) {
|
||||
|
||||
// --- useFilteredEntries ---
|
||||
|
||||
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[], subFilter?: NoteListFilter, inboxPeriod?: InboxPeriod) {
|
||||
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[], subFilter?: NoteListFilter, inboxPeriod?: InboxPeriod, views?: ViewFile[]) {
|
||||
const isEntityView = selection.kind === 'entity'
|
||||
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
|
||||
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
|
||||
@@ -28,8 +28,8 @@ export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSele
|
||||
if (isEntityView) return []
|
||||
if (isChangesView) return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes))
|
||||
if (isInboxView) return filterInboxEntries(entries, inboxPeriod ?? 'month')
|
||||
return filterEntries(entries, selection, subFilter)
|
||||
}, [entries, selection, isEntityView, isChangesView, isInboxView, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod])
|
||||
return filterEntries(entries, selection, subFilter, views)
|
||||
}, [entries, selection, isEntityView, isChangesView, isInboxView, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, views])
|
||||
}
|
||||
|
||||
// --- useNoteListData ---
|
||||
@@ -40,14 +40,15 @@ interface NoteListDataParams {
|
||||
modifiedPathSet: Set<string>; modifiedSuffixes: string[]
|
||||
subFilter?: NoteListFilter
|
||||
inboxPeriod?: InboxPeriod
|
||||
views?: ViewFile[]
|
||||
}
|
||||
|
||||
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod }: NoteListDataParams) {
|
||||
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, views }: NoteListDataParams) {
|
||||
const isEntityView = selection.kind === 'entity'
|
||||
const isTrashView = (selection.kind === 'filter' && selection.filter === 'trash') || subFilter === 'trashed'
|
||||
const isArchivedView = (selection.kind === 'filter' && selection.filter === 'archived') || subFilter === 'archived'
|
||||
|
||||
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod)
|
||||
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, views)
|
||||
|
||||
const searched = useMemo(() => {
|
||||
const sorted = [...filteredEntries].sort(getSortComparator(listSort, listDirection))
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../../types'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewFile } from '../../types'
|
||||
import type { RelationshipGroup } from '../../utils/noteListHelpers'
|
||||
|
||||
export function resolveHeaderTitle(selection: SidebarSelection, typeDocument: VaultEntry | null): string {
|
||||
export function resolveHeaderTitle(selection: SidebarSelection, typeDocument: VaultEntry | null, views?: ViewFile[]): string {
|
||||
if (selection.kind === 'view') {
|
||||
const view = views?.find((v) => v.filename === selection.filename)
|
||||
return view?.definition.name ?? 'View'
|
||||
}
|
||||
if (selection.kind === 'entity') return selection.entry.title
|
||||
if (typeDocument) return typeDocument.title
|
||||
if (selection.kind === 'filter' && selection.filter === 'archived') return 'Archive'
|
||||
|
||||
@@ -19,6 +19,8 @@ interface NoteCommandsConfig {
|
||||
onSetNoteIcon?: () => void
|
||||
onRemoveNoteIcon?: () => void
|
||||
onOpenInNewWindow?: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
isFavorite?: boolean
|
||||
}
|
||||
|
||||
export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
@@ -28,7 +30,7 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onEmptyTrash, trashedCount,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
onOpenInNewWindow,
|
||||
onOpenInNewWindow, onToggleFavorite, isFavorite,
|
||||
} = config
|
||||
|
||||
return [
|
||||
@@ -47,6 +49,12 @@ export function buildNoteCommands(config: NoteCommandsConfig): CommandAction[] {
|
||||
keywords: ['archive'], enabled: hasActiveNote,
|
||||
execute: () => { if (activeTabPath) (isArchived ? onUnarchiveNote : onArchiveNote)(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'toggle-favorite', label: isFavorite ? 'Remove from Favorites' : 'Add to Favorites', group: 'Note', shortcut: '⌘D',
|
||||
keywords: ['favorite', 'star', 'bookmark', 'pin'],
|
||||
enabled: hasActiveNote && !!onToggleFavorite,
|
||||
execute: () => { if (activeTabPath) onToggleFavorite?.(activeTabPath) },
|
||||
},
|
||||
{
|
||||
id: 'set-note-icon', label: 'Set Note Icon', group: 'Note',
|
||||
keywords: ['icon', 'emoji', 'set', 'add', 'change', 'picker'],
|
||||
|
||||
@@ -10,8 +10,12 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
||||
type: { isA: null }, is_a: { isA: null }, status: { status: null }, color: { color: null },
|
||||
icon: { icon: null },
|
||||
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
|
||||
archived: { archived: false }, trashed: { trashed: false }, order: { order: null },
|
||||
_archived: { archived: false }, archived: { archived: false },
|
||||
_trashed: { trashed: false }, trashed: { trashed: false },
|
||||
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 `[[...]]`. */
|
||||
@@ -52,12 +56,16 @@ export function frontmatterToEntryPatch(
|
||||
type: { isA: str }, is_a: { isA: str }, status: { status: str }, color: { color: str },
|
||||
icon: { icon: str },
|
||||
aliases: { aliases: arr }, belongs_to: { belongsTo: arr }, related_to: { relatedTo: arr },
|
||||
archived: { archived: Boolean(value) }, trashed: { trashed: Boolean(value) },
|
||||
_archived: { archived: Boolean(value) }, archived: { archived: Boolean(value) },
|
||||
_trashed: { trashed: Boolean(value) }, trashed: { trashed: Boolean(value) },
|
||||
order: { order: typeof value === 'number' ? value : null },
|
||||
template: { template: str },
|
||||
sort: { sort: str },
|
||||
view: { view: str },
|
||||
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) : []
|
||||
|
||||
@@ -65,6 +65,7 @@ interface AppCommandsConfig {
|
||||
noteListFilter?: NoteListFilter
|
||||
onSetNoteListFilter?: (filter: NoteListFilter) => void
|
||||
onOpenInNewWindow?: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
}
|
||||
|
||||
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
|
||||
@@ -112,6 +113,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onToggleAIChat: config.onToggleAIChat,
|
||||
onToggleRawEditor: config.onToggleRawEditor,
|
||||
onToggleInspector: config.onToggleInspector,
|
||||
onToggleFavorite: config.onToggleFavorite,
|
||||
onOpenInNewWindow: config.onOpenInNewWindow,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
})
|
||||
|
||||
@@ -79,6 +79,14 @@ describe('useAppKeyboard', () => {
|
||||
expect(actions.onCreateNote).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+D triggers toggle favorite on active note', () => {
|
||||
const actions = makeActions()
|
||||
actions.onToggleFavorite = vi.fn()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
fireKey('d', { metaKey: true })
|
||||
expect(actions.onToggleFavorite).toHaveBeenCalledWith('/vault/test.md')
|
||||
})
|
||||
|
||||
it('Cmd+J triggers open daily note', () => {
|
||||
const actions = makeActions()
|
||||
renderHook(() => useAppKeyboard(actions))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { ViewMode } from './useViewMode'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface KeyboardActions {
|
||||
onQuickOpen: () => void
|
||||
@@ -20,6 +21,7 @@ interface KeyboardActions {
|
||||
onToggleAIChat?: () => void
|
||||
onToggleRawEditor?: () => void
|
||||
onToggleInspector?: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onOpenInNewWindow?: () => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
}
|
||||
@@ -65,7 +67,7 @@ function handleCmdKey(e: KeyboardEvent, keyMap: Record<string, ShortcutHandler>)
|
||||
|
||||
export function useAppKeyboard({
|
||||
onQuickOpen, onCommandPalette, onSearch, onCreateNote, onOpenDailyNote, onSave, onOpenSettings, onTrashNote, onArchiveNote,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onToggleInspector, onOpenInNewWindow, activeTabPathRef,
|
||||
onSetViewMode, onZoomIn, onZoomOut, onZoomReset, onGoBack, onGoForward, onToggleAIChat, onToggleRawEditor, onToggleInspector, onToggleFavorite, onOpenInNewWindow, activeTabPathRef,
|
||||
}: KeyboardActions) {
|
||||
useEffect(() => {
|
||||
const withActiveTab = (fn: (path: string) => void): ShortcutHandler => () => {
|
||||
@@ -80,6 +82,7 @@ export function useAppKeyboard({
|
||||
j: onOpenDailyNote,
|
||||
s: onSave,
|
||||
',': onOpenSettings,
|
||||
d: withActiveTab((path) => onToggleFavorite?.(path)),
|
||||
e: withActiveTab(onArchiveNote),
|
||||
Backspace: withActiveTab(onTrashNote),
|
||||
Delete: withActiveTab(onTrashNote),
|
||||
@@ -102,6 +105,7 @@ export function useAppKeyboard({
|
||||
// Cmd+Shift+F: full-text search (distinct from Cmd+F browser find)
|
||||
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'f') {
|
||||
e.preventDefault()
|
||||
trackEvent('search_used')
|
||||
onSearch()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { GitPullResult, GitPushResult, GitRemoteStatus, LastCommitInfo, SyncStatus } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
const DEFAULT_INTERVAL_MS = 5 * 60_000
|
||||
|
||||
@@ -219,5 +220,10 @@ export function useAutoSync({
|
||||
const pausePull = useCallback(() => { pauseRef.current = true }, [])
|
||||
const resumePull = useCallback(() => { pauseRef.current = false }, [])
|
||||
|
||||
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync: performPull, pullAndPush, pausePull, resumePull, handlePushRejected }
|
||||
const triggerSync = useCallback(() => {
|
||||
trackEvent('sync_triggered')
|
||||
performPull()
|
||||
}, [performPull])
|
||||
|
||||
return { syncStatus, lastSyncTime, conflictFiles, lastCommitInfo, remoteStatus, triggerSync, pullAndPush, pausePull, resumePull, handlePushRejected }
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface CommandRegistryConfig {
|
||||
onSetNoteIcon?: () => void
|
||||
onRemoveNoteIcon?: () => void
|
||||
onOpenInNewWindow?: () => void
|
||||
onToggleFavorite?: (path: string) => void
|
||||
onQuickOpen: () => void
|
||||
onCreateNote: () => void
|
||||
onCreateNoteOfType: (type: string) => void
|
||||
@@ -85,7 +86,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
mcpStatus, onInstallMcp, onEmptyTrash, trashedCount,
|
||||
onReloadVault, onRepairVault,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
onOpenInNewWindow,
|
||||
onOpenInNewWindow, onToggleFavorite,
|
||||
selection, noteListFilter, onSetNoteListFilter,
|
||||
} = config
|
||||
|
||||
@@ -97,6 +98,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
)
|
||||
const isArchived = activeEntry?.archived ?? false
|
||||
const isTrashed = activeEntry?.trashed ?? false
|
||||
const isFavorite = activeEntry?.favorite ?? false
|
||||
const isSectionGroup = selection?.kind === 'sectionGroup'
|
||||
|
||||
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
|
||||
@@ -107,7 +109,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
hasActiveNote, activeTabPath, isArchived, isTrashed,
|
||||
onCreateNote, onCreateType, onOpenDailyNote, onSave,
|
||||
onTrashNote, onRestoreNote, onArchiveNote, onUnarchiveNote,
|
||||
onEmptyTrash, trashedCount, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow,
|
||||
onEmptyTrash, trashedCount, onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon, onOpenInNewWindow, onToggleFavorite, isFavorite,
|
||||
}),
|
||||
...buildGitCommands({ modifiedCount, onCommitPush, onPull, onResolveConflicts, onSelect }),
|
||||
...buildViewCommands({
|
||||
@@ -136,6 +138,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
|
||||
onReloadVault, onRepairVault,
|
||||
onSetNoteIcon, onRemoveNoteIcon, activeNoteHasIcon,
|
||||
isSectionGroup, noteListFilter, onSetNoteListFilter,
|
||||
onOpenInNewWindow,
|
||||
onOpenInNewWindow, onToggleFavorite, isFavorite,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import type { GitPushResult } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface CommitFlowConfig {
|
||||
savePending: () => Promise<void | boolean>
|
||||
@@ -25,6 +26,7 @@ export function useCommitFlow({ savePending, loadModifiedFiles, commitAndPush, s
|
||||
await savePending()
|
||||
const result = await commitAndPush(message)
|
||||
if (result.status === 'ok') {
|
||||
trackEvent('commit_made')
|
||||
setToastMessage('Committed and pushed')
|
||||
} else if (result.status === 'rejected') {
|
||||
setToastMessage('Committed, but push rejected — remote has new commits. Pull first.')
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface ConfirmDeleteState {
|
||||
title: string
|
||||
@@ -50,7 +51,10 @@ export function useDeleteActions({
|
||||
onConfirm: async () => {
|
||||
setConfirmDelete(null)
|
||||
const ok = await deleteNoteFromDisk(path)
|
||||
if (ok) setToastMessage('Note permanently deleted')
|
||||
if (ok) {
|
||||
trackEvent('note_deleted')
|
||||
setToastMessage('Note permanently deleted')
|
||||
}
|
||||
},
|
||||
})
|
||||
}, [deleteNoteFromDisk, setToastMessage])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { ViewDefinition } from '../types'
|
||||
|
||||
export function useDialogs() {
|
||||
const [showCreateTypeDialog, setShowCreateTypeDialog] = useState(false)
|
||||
@@ -9,6 +10,8 @@ export function useDialogs() {
|
||||
const [showGitHubVault, setShowGitHubVault] = useState(false)
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const [showConflictResolver, setShowConflictResolver] = useState(false)
|
||||
const [showCreateViewDialog, setShowCreateViewDialog] = useState(false)
|
||||
const [editingView, setEditingView] = useState<{ filename: string; definition: ViewDefinition } | null>(null)
|
||||
|
||||
const openCreateType = useCallback(() => setShowCreateTypeDialog(true), [])
|
||||
const closeCreateType = useCallback(() => setShowCreateTypeDialog(false), [])
|
||||
@@ -25,6 +28,12 @@ export function useDialogs() {
|
||||
const closeSearch = useCallback(() => setShowSearch(false), [])
|
||||
const openConflictResolver = useCallback(() => setShowConflictResolver(true), [])
|
||||
const closeConflictResolver = useCallback(() => setShowConflictResolver(false), [])
|
||||
const openCreateView = useCallback(() => { setEditingView(null); setShowCreateViewDialog(true) }, [])
|
||||
const closeCreateView = useCallback(() => { setShowCreateViewDialog(false); setEditingView(null) }, [])
|
||||
const openEditView = useCallback((filename: string, definition: ViewDefinition) => {
|
||||
setEditingView({ filename, definition })
|
||||
setShowCreateViewDialog(true)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
showCreateTypeDialog, openCreateType, closeCreateType,
|
||||
@@ -35,5 +44,6 @@ export function useDialogs() {
|
||||
showGitHubVault, openGitHubVault, closeGitHubVault,
|
||||
showSearch, openSearch, closeSearch,
|
||||
showConflictResolver, openConflictResolver, closeConflictResolver,
|
||||
showCreateViewDialog, openCreateView, closeCreateView, editingView, openEditView,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,8 +67,8 @@ describe('useEntryActions', () => {
|
||||
await result.current.handleTrashNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'Trashed', true, { silent: true })
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'Trashed at', expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), { silent: true })
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', '_trashed', true, { silent: true })
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', '_trashed_at', expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), { silent: true })
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', {
|
||||
trashed: true,
|
||||
trashedAt: expect.any(Number),
|
||||
@@ -99,8 +99,8 @@ describe('useEntryActions', () => {
|
||||
await result.current.handleRestoreNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', 'Trashed', { silent: true })
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', 'Trashed at', { silent: true })
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', '_trashed', { silent: true })
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', '_trashed_at', { silent: true })
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', {
|
||||
trashed: false,
|
||||
@@ -119,7 +119,7 @@ describe('useEntryActions', () => {
|
||||
await result.current.handleArchiveNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'archived', true, { silent: true })
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', '_archived', true, { silent: true })
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { archived: true })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note archived')
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
@@ -146,7 +146,7 @@ describe('useEntryActions', () => {
|
||||
await result.current.handleUnarchiveNote('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', 'archived', { silent: true })
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', '_archived', { silent: true })
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { archived: false })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note unarchived')
|
||||
@@ -521,6 +521,94 @@ describe('useEntryActions', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleToggleFavorite', () => {
|
||||
it('favorites a note: writes _favorite and _favorite_index', async () => {
|
||||
const entry = makeEntry({ path: '/vault/note/test.md', favorite: false, favoriteIndex: null })
|
||||
const { result } = setup([entry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleToggleFavorite('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', '_favorite', true, { silent: true })
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', '_favorite_index', 1, { silent: true })
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { favorite: true, favoriteIndex: 1 })
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('unfavorites a note: deletes _favorite and _favorite_index', async () => {
|
||||
const entry = makeEntry({ path: '/vault/note/test.md', favorite: true, favoriteIndex: 0 })
|
||||
const { result } = setup([entry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleToggleFavorite('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', '_favorite', { silent: true })
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/note/test.md', '_favorite_index', { silent: true })
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { favorite: false, favoriteIndex: null })
|
||||
})
|
||||
|
||||
it('assigns next available index when favoriting', async () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/vault/a.md', favorite: true, favoriteIndex: 3 }),
|
||||
makeEntry({ path: '/vault/b.md', favorite: true, favoriteIndex: 5 }),
|
||||
makeEntry({ path: '/vault/c.md', favorite: false, favoriteIndex: null }),
|
||||
]
|
||||
const { result } = setup(entries)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleToggleFavorite('/vault/c.md')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/c.md', '_favorite_index', 6, { silent: true })
|
||||
})
|
||||
|
||||
it('rolls back on failure', async () => {
|
||||
const entry = makeEntry({ path: '/vault/note/test.md', favorite: false, favoriteIndex: null })
|
||||
handleUpdateFrontmatter.mockRejectedValueOnce(new Error('disk full'))
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const { result } = setup([entry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleToggleFavorite('/vault/note/test.md')
|
||||
})
|
||||
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { favorite: false, favoriteIndex: null })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Failed to favorite — rolled back')
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('does nothing if entry not found', async () => {
|
||||
const { result } = setup([])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleToggleFavorite('/vault/nonexistent.md')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
expect(handleDeleteProperty).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleReorderFavorites', () => {
|
||||
it('updates _favorite_index for all reordered paths', async () => {
|
||||
const { result } = setup()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleReorderFavorites(['/vault/a.md', '/vault/b.md', '/vault/c.md'])
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/a.md', '_favorite_index', 0, { silent: true })
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/b.md', '_favorite_index', 1, { silent: true })
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/c.md', '_favorite_index', 2, { silent: true })
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/a.md', { favoriteIndex: 0 })
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/b.md', { favoriteIndex: 1 })
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/c.md', { favoriteIndex: 2 })
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('onBeforeAction callback', () => {
|
||||
function setupWithBeforeAction(onBeforeAction: ReturnType<typeof vi.fn>) {
|
||||
return renderHook(() =>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterOpOptions } from './frontmatterOps'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
interface EntryActionsConfig {
|
||||
entries: VaultEntry[]
|
||||
@@ -32,11 +33,12 @@ export function useEntryActions({
|
||||
// Optimistic: update UI immediately, write to disk async with rollback on failure
|
||||
const trashedAt = Date.now() / 1000
|
||||
updateEntry(path, { trashed: true, trashedAt })
|
||||
trackEvent('note_trashed')
|
||||
setToastMessage('Note moved to trash')
|
||||
const now = new Date().toISOString().slice(0, 10)
|
||||
try {
|
||||
await handleUpdateFrontmatter(path, 'Trashed', true, { silent: true })
|
||||
await handleUpdateFrontmatter(path, 'Trashed at', now, { silent: true })
|
||||
await handleUpdateFrontmatter(path, '_trashed', true, { silent: true })
|
||||
await handleUpdateFrontmatter(path, '_trashed_at', now, { silent: true })
|
||||
onFrontmatterPersisted?.()
|
||||
} catch (err) {
|
||||
updateEntry(path, { trashed: false, trashedAt: null })
|
||||
@@ -50,8 +52,8 @@ export function useEntryActions({
|
||||
updateEntry(path, { trashed: false, trashedAt: null })
|
||||
setToastMessage('Note restored from trash')
|
||||
try {
|
||||
await handleDeleteProperty(path, 'Trashed', { silent: true })
|
||||
await handleDeleteProperty(path, 'Trashed at', { silent: true })
|
||||
await handleDeleteProperty(path, '_trashed', { silent: true })
|
||||
await handleDeleteProperty(path, '_trashed_at', { silent: true })
|
||||
onFrontmatterPersisted?.()
|
||||
} catch (err) {
|
||||
updateEntry(path, { trashed: true, trashedAt: Date.now() / 1000 })
|
||||
@@ -64,9 +66,10 @@ export function useEntryActions({
|
||||
await onBeforeAction?.(path)
|
||||
// Optimistic: update UI immediately, write to disk async with rollback on failure
|
||||
updateEntry(path, { archived: true })
|
||||
trackEvent('note_archived')
|
||||
setToastMessage('Note archived')
|
||||
try {
|
||||
await handleUpdateFrontmatter(path, 'archived', true, { silent: true })
|
||||
await handleUpdateFrontmatter(path, '_archived', true, { silent: true })
|
||||
onFrontmatterPersisted?.()
|
||||
} catch (err) {
|
||||
updateEntry(path, { archived: false })
|
||||
@@ -80,7 +83,7 @@ export function useEntryActions({
|
||||
updateEntry(path, { archived: false })
|
||||
setToastMessage('Note unarchived')
|
||||
try {
|
||||
await handleDeleteProperty(path, 'archived', { silent: true })
|
||||
await handleDeleteProperty(path, '_archived', { silent: true })
|
||||
onFrontmatterPersisted?.()
|
||||
} catch (err) {
|
||||
updateEntry(path, { archived: true })
|
||||
@@ -125,6 +128,44 @@ export function useEntryActions({
|
||||
onFrontmatterPersisted?.()
|
||||
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted])
|
||||
|
||||
const handleToggleFavorite = useCallback(async (path: string) => {
|
||||
const entry = entries.find((e) => e.path === path)
|
||||
if (!entry) return
|
||||
if (entry.favorite) {
|
||||
trackEvent('note_unfavorited')
|
||||
updateEntry(path, { favorite: false, favoriteIndex: null })
|
||||
try {
|
||||
await handleDeleteProperty(path, '_favorite', { silent: true })
|
||||
await handleDeleteProperty(path, '_favorite_index', { silent: true })
|
||||
onFrontmatterPersisted?.()
|
||||
} catch {
|
||||
updateEntry(path, { favorite: true, favoriteIndex: entry.favoriteIndex })
|
||||
setToastMessage('Failed to unfavorite — rolled back')
|
||||
}
|
||||
} else {
|
||||
trackEvent('note_favorited')
|
||||
const maxIndex = entries.filter((e) => e.favorite).reduce((max, e) => Math.max(max, e.favoriteIndex ?? 0), 0)
|
||||
const newIndex = maxIndex + 1
|
||||
updateEntry(path, { favorite: true, favoriteIndex: newIndex })
|
||||
try {
|
||||
await handleUpdateFrontmatter(path, '_favorite', true, { silent: true })
|
||||
await handleUpdateFrontmatter(path, '_favorite_index', newIndex, { silent: true })
|
||||
onFrontmatterPersisted?.()
|
||||
} catch {
|
||||
updateEntry(path, { favorite: false, favoriteIndex: null })
|
||||
setToastMessage('Failed to favorite — rolled back')
|
||||
}
|
||||
}
|
||||
}, [entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleReorderFavorites = useCallback(async (orderedPaths: string[]) => {
|
||||
for (let i = 0; i < orderedPaths.length; i++) {
|
||||
updateEntry(orderedPaths[i], { favoriteIndex: i })
|
||||
await handleUpdateFrontmatter(orderedPaths[i], '_favorite_index', i, { silent: true })
|
||||
}
|
||||
onFrontmatterPersisted?.()
|
||||
}, [updateEntry, handleUpdateFrontmatter, onFrontmatterPersisted])
|
||||
|
||||
const handleToggleTypeVisibility = useCallback(async (typeName: string) => {
|
||||
const typeEntry = await findOrCreateType(entries, typeName, createTypeEntry)
|
||||
if (typeEntry.visible === false) {
|
||||
@@ -137,5 +178,5 @@ export function useEntryActions({
|
||||
onFrontmatterPersisted?.()
|
||||
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted])
|
||||
|
||||
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection, handleToggleTypeVisibility }
|
||||
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection, handleToggleTypeVisibility, handleToggleFavorite, handleReorderFavorites }
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
/**
|
||||
* Local feature flag hook (V1 — no remote fetching).
|
||||
* Feature flag hook backed by PostHog + release channels.
|
||||
*
|
||||
* Flags are resolved in order:
|
||||
* 1. localStorage override (`ff_<name>`) — for dev/QA testing
|
||||
* 2. Compile-time defaults in FLAG_DEFAULTS
|
||||
*
|
||||
* To add a new flag: add its name to the FeatureFlagName union and
|
||||
* set its default in FLAG_DEFAULTS.
|
||||
* 2. PostHog feature flags (evaluated by release channel)
|
||||
* 3. Alpha channel always returns true (sees all features)
|
||||
*/
|
||||
|
||||
export type FeatureFlagName = 'example_flag'
|
||||
import { isFeatureEnabled } from '../lib/telemetry'
|
||||
|
||||
const FLAG_DEFAULTS: Record<FeatureFlagName, boolean> = {
|
||||
example_flag: false,
|
||||
}
|
||||
export type FeatureFlagName = 'example_flag'
|
||||
|
||||
export function useFeatureFlag(flag: FeatureFlagName): boolean {
|
||||
try {
|
||||
@@ -22,5 +18,5 @@ export function useFeatureFlag(flag: FeatureFlagName): boolean {
|
||||
} catch {
|
||||
// localStorage may be unavailable in some contexts
|
||||
}
|
||||
return FLAG_DEFAULTS[flag] ?? false
|
||||
return isFeatureEnabled(flag)
|
||||
}
|
||||
|
||||
@@ -217,24 +217,24 @@ describe('entryMatchesTarget', () => {
|
||||
describe('buildNoteContent', () => {
|
||||
it('generates frontmatter with status for regular types', () => {
|
||||
const content = buildNoteContent('My Note', 'Note', 'Active')
|
||||
expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n\n# My Note\n\n')
|
||||
expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n')
|
||||
})
|
||||
|
||||
it('omits status when null', () => {
|
||||
const content = buildNoteContent('AI', 'Topic', null)
|
||||
expect(content).toBe('---\ntitle: AI\ntype: Topic\n---\n\n# AI\n\n')
|
||||
expect(content).toBe('---\ntitle: AI\ntype: Topic\n---\n')
|
||||
})
|
||||
|
||||
it('includes template body when provided', () => {
|
||||
const content = buildNoteContent('My Project', 'Project', 'Active', '## Objective\n\n## Notes\n\n')
|
||||
expect(content).toContain('# My Project')
|
||||
expect(content).not.toContain('# My Project')
|
||||
expect(content).toContain('## Objective')
|
||||
expect(content).toContain('## Notes')
|
||||
})
|
||||
|
||||
it('ignores null template', () => {
|
||||
const content = buildNoteContent('My Note', 'Note', 'Active', null)
|
||||
expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n\n# My Note\n\n')
|
||||
expect(content).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -319,7 +319,7 @@ describe('resolveNewType', () => {
|
||||
expect(entry.isA).toBe('Type')
|
||||
expect(entry.status).toBeNull()
|
||||
expect(content).toContain('type: Type')
|
||||
expect(content).toContain('# Recipe')
|
||||
expect(content).not.toContain('# Recipe')
|
||||
})
|
||||
|
||||
it('uses provided vault path instead of hardcoded path', () => {
|
||||
@@ -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({})
|
||||
@@ -468,6 +478,24 @@ describe('contentToEntryPatch', () => {
|
||||
const content = '---\ntype: Note\ncustom: value\n---\n'
|
||||
expect(contentToEntryPatch(content)).toEqual({ isA: 'Note' })
|
||||
})
|
||||
|
||||
it('preserves _favorite_index as a number (not null)', () => {
|
||||
const content = '---\n_favorite: true\n_favorite_index: 2\n---\nBody'
|
||||
const patch = contentToEntryPatch(content)
|
||||
expect(patch.favorite).toBe(true)
|
||||
expect(patch.favoriteIndex).toBe(2)
|
||||
})
|
||||
|
||||
it('preserves _favorite_index: 0 as number 0', () => {
|
||||
const content = '---\n_favorite: true\n_favorite_index: 0\n---\nBody'
|
||||
const patch = contentToEntryPatch(content)
|
||||
expect(patch.favoriteIndex).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves order as a number', () => {
|
||||
const content = '---\ntype: Type\norder: 3\n---\n'
|
||||
expect(contentToEntryPatch(content)).toEqual({ isA: 'Type', order: 3 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('todayDateString', () => {
|
||||
@@ -491,9 +519,9 @@ describe('buildDailyNoteContent', () => {
|
||||
expect(content).toContain('## Reflections')
|
||||
})
|
||||
|
||||
it('includes H1 heading with the date', () => {
|
||||
it('does not include H1 heading with the date', () => {
|
||||
const content = buildDailyNoteContent('2026-03-02')
|
||||
expect(content).toContain('# 2026-03-02')
|
||||
expect(content).not.toMatch(/^# /m)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -116,11 +116,11 @@ describe('entryMatchesTarget', () => {
|
||||
|
||||
describe('buildNoteContent', () => {
|
||||
it('generates frontmatter with status', () => {
|
||||
expect(buildNoteContent('My Note', 'Note', 'Active')).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n\n# My Note\n\n')
|
||||
expect(buildNoteContent('My Note', 'Note', 'Active')).toBe('---\ntitle: My Note\ntype: Note\nstatus: Active\n---\n')
|
||||
})
|
||||
|
||||
it('omits status when null', () => {
|
||||
expect(buildNoteContent('AI', 'Topic', null)).toBe('---\ntitle: AI\ntype: Topic\n---\n\n# AI\n\n')
|
||||
expect(buildNoteContent('AI', 'Topic', null)).toBe('---\ntitle: AI\ntype: Topic\n---\n')
|
||||
})
|
||||
|
||||
it('includes template body when provided', () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, addMockEntry } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { resolveEntry } from '../utils/wikilink'
|
||||
import { trackEvent } from '../lib/telemetry'
|
||||
|
||||
export interface NewEntryParams {
|
||||
path: string
|
||||
@@ -19,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: {},
|
||||
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: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,8 +67,8 @@ export function buildNoteContent(title: string, type: string, status: string | n
|
||||
const lines = ['---', `title: ${title}`, `type: ${type}`]
|
||||
if (status) lines.push(`status: ${status}`)
|
||||
lines.push('---')
|
||||
const body = template ? `\n${template}` : '\n'
|
||||
return `${lines.join('\n')}\n\n# ${title}\n${body}`
|
||||
const body = template ? `\n${template}` : ''
|
||||
return `${lines.join('\n')}\n${body}`
|
||||
}
|
||||
|
||||
export function resolveNewNote(title: string, type: string, vaultPath: string, template?: string | null): { entry: VaultEntry; content: string } {
|
||||
@@ -80,7 +81,7 @@ export function resolveNewNote(title: string, type: string, vaultPath: string, t
|
||||
export function resolveNewType(typeName: string, vaultPath: string): { entry: VaultEntry; content: string } {
|
||||
const slug = slugify(typeName)
|
||||
const entry = buildNewEntry({ path: `${vaultPath}/${slug}.md`, slug, title: typeName, type: 'Type', status: null })
|
||||
return { entry, content: `---\ntype: Type\n---\n\n# ${typeName}\n\n` }
|
||||
return { entry, content: `---\ntype: Type\n---\n` }
|
||||
}
|
||||
|
||||
export function todayDateString(): string {
|
||||
@@ -89,7 +90,7 @@ export function todayDateString(): string {
|
||||
|
||||
export function buildDailyNoteContent(date: string): string {
|
||||
const lines = ['---', `title: ${date}`, 'type: Journal', `date: ${date}`, '---']
|
||||
return `${lines.join('\n')}\n\n# ${date}\n\n## Intentions\n\n\n\n## Reflections\n\n`
|
||||
return `${lines.join('\n')}\n\n## Intentions\n\n\n\n## Reflections\n\n`
|
||||
}
|
||||
|
||||
export function resolveDailyNote(date: string, vaultPath: string): { entry: VaultEntry; content: string } {
|
||||
@@ -250,6 +251,7 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
|
||||
const handleCreateNote = useCallback((title: string, type: string) => {
|
||||
const template = resolveTemplate(entries, type)
|
||||
persistNew(resolveNewNote(title, type, config.vaultPath, template))
|
||||
trackEvent('note_created', { has_type: type !== 'Note' ? 1 : 0, creation_path: 'plus_button' })
|
||||
}, [entries, persistNew, config.vaultPath])
|
||||
|
||||
const handleCreateNoteImmediate = useCallback((type?: string) => {
|
||||
@@ -257,6 +259,7 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
|
||||
entries, vaultPath: config.vaultPath, pendingNames: pendingNamesRef.current,
|
||||
openTabWithContent, addEntry, trackUnsaved: config.trackUnsaved, markContentPending: config.markContentPending,
|
||||
}, type)
|
||||
trackEvent('note_created', { has_type: type ? 1 : 0, creation_path: type ? 'type_section' : 'cmd_n' })
|
||||
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending])
|
||||
|
||||
const handleCreateNoteForRelationship = useCallback((title: string): Promise<boolean> => {
|
||||
@@ -269,7 +272,10 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
|
||||
|
||||
const handleOpenDailyNote = useCallback(() => openDailyNote(entries, handleSelectNote, persistNew, config.vaultPath), [entries, handleSelectNote, persistNew, config.vaultPath])
|
||||
|
||||
const handleCreateType = useCallback((typeName: string) => persistNew(resolveNewType(typeName, config.vaultPath)), [persistNew, config.vaultPath])
|
||||
const handleCreateType = useCallback((typeName: string) => {
|
||||
persistNew(resolveNewType(typeName, config.vaultPath))
|
||||
trackEvent('type_created')
|
||||
}, [persistNew, config.vaultPath])
|
||||
|
||||
/** Create a Type entry file silently (no tab opened). Adds to state and persists to disk. */
|
||||
const createTypeEntrySilent = useCallback(async (typeName: string): Promise<VaultEntry> => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user