Compare commits

...

22 Commits

Author SHA1 Message Date
lucaronin
d5497d7252 Match getting started subtitle copy 2026-04-19 14:30:07 +02:00
lucaronin
046afe0e67 Simplify Getting Started vault subtitle 2026-04-19 14:23:05 +02:00
lucaronin
cb1cc4b394 fix: include inverse neighborhood groups 2026-04-19 14:13:10 +02:00
lucaronin
00cb938766 fix: add cmd o quick-open alias 2026-04-19 13:47:02 +02:00
lucaronin
80c13272f3 fix: keep neighborhood reverse matches path-aware 2026-04-19 12:53:07 +02:00
lucaronin
9534449307 fix: hide absent neighborhood groups 2026-04-19 12:26:31 +02:00
lucaronin
2d5361b2e9 fix: keep keyboard note switching snappy 2026-04-19 11:49:48 +02:00
lucaronin
b22da65d44 fix: keep note switches instant 2026-04-19 11:29:04 +02:00
lucaronin
862af864d3 fix: preserve image selection while hovering toolbar 2026-04-19 10:48:45 +02:00
lucaronin
9ecefc90d5 feat: add stable landing page download link 2026-04-19 04:44:17 +02:00
lucaronin
9693fe24c9 fix: restore type filter icon colors 2026-04-19 04:15:12 +02:00
lucaronin
92ea32c0b8 feat: add neighborhood browsing mode 2026-04-19 03:37:33 +02:00
lucaronin
2e2bd2f3b3 fix: preserve native welcome vault creation 2026-04-19 02:37:29 +02:00
lucaronin
3556d934ee test: format vault command assertions 2026-04-19 01:55:39 +02:00
lucaronin
feef938174 fix: align fresh vault seed files 2026-04-19 01:33:53 +02:00
lucaronin
67a9930ea1 feat: add empty vault creation flow 2026-04-19 01:06:40 +02:00
lucaronin
aed4d05f1d docs: ratchet codescene thresholds 2026-04-19 00:25:35 +02:00
lucaronin
cac7f12c6d fix: stop duplicate native quick prompt paste 2026-04-19 00:18:42 +02:00
lucaronin
72b5b52140 feat: add view note-list controls 2026-04-18 20:31:25 +02:00
lucaronin
7deeb49751 feat: add number property type 2026-04-18 19:40:00 +02:00
lucaronin
8c0a2e7ec8 fix: share onboarding drag shell 2026-04-18 18:50:30 +02:00
lucaronin
c4e66dc74f fix: make onboarding window draggable 2026-04-18 18:09:37 +02:00
97 changed files with 4893 additions and 1007 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.86
AVERAGE_THRESHOLD=9.7
HOTSPOT_THRESHOLD=9.88
AVERAGE_THRESHOLD=9.73

View File

@@ -208,6 +208,7 @@ jobs:
ARM_SIG=$(cat updater-aarch64/*.app.tar.gz.sig)
ARM_TARBALL=$(ls updater-aarch64/*.app.tar.gz | xargs basename)
ARM_DMG=$(ls dmg-aarch64/*.dmg | xargs basename)
cat > stable-latest.json << EOF
{
@@ -217,7 +218,8 @@ jobs:
"platforms": {
"darwin-aarch64": {
"signature": "${ARM_SIG}",
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}"
"url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_TARBALL}",
"dmg_url": "https://github.com/${REPO}/releases/download/${TAG}/${ARM_DMG}"
}
}
}
@@ -253,6 +255,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Build release history page
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -263,6 +270,9 @@ jobs:
curl -fsSL "${PAGES_URL}/alpha/latest.json" -o _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "stable-latest.json" --output _site/stable/latest.json || echo '{}' > _site/stable/latest.json
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
mkdir -p _site/download
cp _site/stable/download/index.html _site/download/index.html
cp _site/alpha/latest.json _site/latest.json
cp _site/alpha/latest.json _site/latest-canary.json

View File

@@ -291,6 +291,11 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Build release history page
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -301,6 +306,9 @@ jobs:
gh release download --repo ${{ github.repository }} "${{ needs.version.outputs.tag }}" --pattern "alpha-latest.json" --output _site/alpha/latest.json || echo '{}' > _site/alpha/latest.json
curl -fsSL "${PAGES_URL}/stable/latest.json" -o _site/stable/latest.json || echo '{}' > _site/stable/latest.json
bun scripts/build-release-download-page.ts --latest-json _site/stable/latest.json --releases-json _site/releases.json --output-file _site/stable/download/index.html
mkdir -p _site/download
cp _site/stable/download/index.html _site/download/index.html
cp _site/alpha/latest.json _site/latest.json
cp _site/alpha/latest.json _site/latest-canary.json

View File

@@ -261,10 +261,24 @@ type SidebarFilter = 'all' | 'archived' | 'changes' | 'pulse'
type SidebarSelection =
| { kind: 'filter'; filter: SidebarFilter }
| { kind: 'sectionGroup'; type: string } // e.g. type: 'Project'
| { kind: 'entity'; entry: VaultEntry } // specific entity selected
| { kind: 'topic'; entry: VaultEntry } // topic selected
| { kind: 'folder'; path: string }
| { kind: 'entity'; entry: VaultEntry } // Neighborhood source note
| { kind: 'view'; filename: string }
```
### Neighborhood Mode
`SidebarSelection.kind === 'entity'` is Tolaria's Neighborhood mode for note-list browsing.
- The selected `entry` is the neighborhood source note.
- The source note stays pinned at the top of the note list as a standard active row, not a special card.
- Outgoing relationship groups render first using the note's `relationships` map.
- Inverse groups (`Children`, `Events`, `Referenced By`) and `Backlinks` render after the outgoing groups.
- Empty groups stay visible with count `0`.
- Notes may appear in multiple groups when multiple relationships are true; Neighborhood mode does not deduplicate them across sections.
- Plain click / `Enter` open the focused note without replacing the current Neighborhood.
- Cmd/Ctrl-click and Cmd/Ctrl-`Enter` open the note and pivot the note list into that note's Neighborhood.
## File System Integration
### Vault Scanning (Rust)
@@ -496,7 +510,8 @@ The app uses a single light theme — the vault-based theming system was removed
The Inspector panel (`src/components/Inspector.tsx`) is composed of sub-panels:
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs:
- **Editable properties** (top): Type badge, Status pill with dropdown, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
- **Editable properties** (top): Type badge, Status pill with dropdown, number fields, boolean toggles, array tag pills, text fields. Click-to-edit interaction.
- **Property display modes**: `text`, `number`, `date`, `boolean`, `status`, `url`, `tags`, and `color`. Numeric frontmatter values auto-detect as `number`, and custom scalar keys can be explicitly switched to `Number` through the property-type control.
- **Info section** (bottom, separated by border): Read-only derived metadata — Modified, Created, Words, File Size. Uses muted styling with no interaction.
- Keys in `SKIP_KEYS` (`type`, `aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.

View File

@@ -177,7 +177,7 @@ flowchart TD
```
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse) and collapsible type-based section groups. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Note List / Pulse View** (200-500px, resizable): When a section group or filter is selected, shows filtered notes with snippets, modified dates, and status indicators. When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Note List / Pulse View** (200-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
@@ -442,7 +442,7 @@ Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnbo
`useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed config files (`AGENTS.md`, `CLAUDE.md`, `config.md`) so fresh starter vaults pick up the current default guidance even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` holds the public starter repo URL (`refactoringhq/tolaria-getting-started`), delegates the clone to the git backend, then normalizes Tolaria-managed root guidance and type scaffolding (`AGENTS.md`, `CLAUDE.md`, `type.md`, `note.md`) so fresh starter vaults pick up the current defaults even when the remote starter repo still carries a legacy copy or an older pre-`type:` `is_a`-era template. `AGENTS.md` stays the canonical vault guidance file; `CLAUDE.md` is a compatibility shim that imports it for Claude Code without duplicating the instructions. The clone helper still accepts the legacy `LAPUTA_GETTING_STARTED_REPO_URL` environment override so older automation can continue to redirect the starter source during the transition.
### Remote Clone & Auth Model
@@ -578,7 +578,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `rename.rs` | `rename_note` — renames files, updates `title` frontmatter, and updates wikilinks across the vault |
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
| `migration.rs` | `flatten_vault`, `vault_health_check`, `migrate_is_a_to_type` |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, repairs missing root config/type files such as `config.md` and `note.md` |
| `config_seed.rs` | Maintains vault AI guidance (`AGENTS.md` + `CLAUDE.md` shim), migrates legacy `config/agents.md`, and repairs missing root type scaffolding such as `type.md` and `note.md` |
| `getting_started.rs` | Creates the Getting Started demo vault |
## Rust Backend Modules
@@ -615,7 +615,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
| `check_vault_exists` | Check if vault path exists |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `config.md`, and `note.md` defaults |
| `create_empty_vault` | Create a git-backed vault, then seed root `AGENTS.md`, `CLAUDE.md`, `type.md`, and `note.md` defaults |
| `create_getting_started_vault` | Clone the public Getting Started vault, refresh Tolaria-managed guidance/config defaults, and keep the cloned repo clean |
| `get_vault_ai_guidance_status` | Report whether `AGENTS.md` and the `CLAUDE.md` shim are managed, missing, broken, or custom |
| `restore_vault_ai_guidance` | Restore any missing/broken Tolaria-managed guidance files without overwriting custom ones |
@@ -741,7 +741,7 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
| Shortcut | Action |
|----------|--------|
| Cmd+K | Open command palette |
| Cmd+P | Open quick open palette |
| Cmd+P / Cmd+O | Open quick open palette |
| Cmd+N | Create new note |
| Cmd+S | Save current note |
| Cmd+[ / Cmd+] | Navigate back / forward (replaces tabs) |
@@ -756,7 +756,7 @@ Shortcut routing is explicit:
- `appCommandCatalog.ts` is the shared shortcut manifest for command IDs, modifier rules, and deterministic QA metadata
- `useAppKeyboard` is the primary execution path for real shortcut keypresses, including Tauri runs
- macOS browser-reserved chords such as `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
- macOS browser-reserved chords such as `Cmd+O` and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
- `menu.rs` and `useMenuEvents` emit the same command IDs for native menu clicks and accelerators
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
- Deterministic QA uses two explicit proof paths from the shared manifest:
@@ -798,10 +798,11 @@ push stable-vYYYY.M.D tag
→ pnpm install, stamp version, pnpm build, tauri build --target aarch64-apple-darwin
→ upload signed .app.tar.gz + .sig and .dmg artifacts
→ release job:
→ generate stable-latest.json
→ generate stable-latest.json with both updater tarball and current stable DMG URLs
→ publish GitHub release Tolaria YYYY.M.D
→ pages job:
→ publish stable/latest.json
→ publish stable/download/ and download/ as permanent redirect URLs for the latest stable DMG
→ preserve alpha/latest.json
→ deploy to gh-pages
```

View File

@@ -292,8 +292,9 @@ No global state management (no Redux, no Context). `App.tsx` owns the state and
type SidebarSelection =
| { kind: 'filter'; filter: SidebarFilter }
| { kind: 'sectionGroup'; type: string }
| { kind: 'folder'; path: string }
| { kind: 'entity'; entry: VaultEntry }
| { kind: 'topic'; entry: VaultEntry }
| { kind: 'view'; filename: string }
```
### Command Registry

View File

@@ -0,0 +1,33 @@
---
type: ADR
id: "0069"
title: "Neighborhood mode for note-list relationship browsing"
status: active
date: 2026-04-19
---
## Context
Tolaria already had a relationship-browsing state behind `SidebarSelection.kind === 'entity'`, but the product language and interaction model were still fuzzy. The pinned source note rendered as a special card instead of a normal note row, grouped relationship results were deduplicated across sections, and Cmd-click behaved like a legacy "open separately" affordance rather than a clear graph-navigation action.
The new note-list flow needed an explicit product concept for browsing related notes around a source note, plus keyboard semantics that matched the mouse flow. The team also wanted the list to preserve graph truth instead of collapsing overlapping relationships away when a note legitimately belonged to multiple groups.
## Decision
**Tolaria formalizes `SidebarSelection.kind === 'entity'` as Neighborhood mode.** The note list now treats the selected note as the neighborhood source, pins it at the top using the standard active note-row styling, shows outgoing relationship groups first and inverse/backlink groups after, keeps empty groups visible with count `0`, and allows the same note to appear in multiple groups when multiple relationships are true.
**Neighborhood navigation is a distinct pivot action.** Plain click and plain `Enter` open the focused note without replacing the current neighborhood. Cmd/Ctrl-click and Cmd/Ctrl-`Enter` open the note and pivot the note list into that note's Neighborhood.
## Options considered
- **Reuse the existing `entity` selection as Neighborhood mode** (chosen): keeps the state model localized, avoids a second nearly-identical note-list mode, and lets sidebar navigation exit Neighborhood by selecting any other sidebar target. Cons: code still uses the historical `entity` name internally.
- **Add a new `neighborhood` selection variant**: clearer internal naming, but it duplicates the same source-note payload and would force wider selection-handling churn across the app for little product gain.
- **Keep the old implicit entity-browsing behavior**: lowest short-term engineering effort, but it leaves the product terminology inconsistent and preserves interaction mismatches like deduped groups and non-pivot Cmd-click behavior.
## Consequences
- Product, tests, and docs now refer to Neighborhood as a first-class note-list browsing mode.
- The note list preserves overlapping graph evidence: one note can appear in multiple groups when multiple relationships are true.
- Keyboard-only browsing now matches the pointer flow: arrow keys/open keep the current neighborhood, while Cmd/Ctrl-`Enter` pivots it.
- Sidebar navigation remains the exit path from Neighborhood because the app still models the mode through the existing selection union.
- Internal code still uses the `entity` discriminator, so future refactors should treat "entity selection" and "Neighborhood mode" as the same concept unless a broader navigation redesign justifies a new selection shape.

View File

@@ -124,3 +124,4 @@ proposed → active → superseded
| [0066](0066-calendar-semver-versioning-for-alpha-and-stable-releases.md) | Calendar-semver versioning for alpha and stable releases | active |
| [0067](0067-autogit-idle-and-inactive-checkpoints.md) | AutoGit idle and inactive checkpoints | active |
| [0068](0068-h1-only-title-surface-with-optional-untitled-auto-rename.md) | H1-only title surface with optional untitled auto-rename | active |
| [0069](0069-neighborhood-mode-for-note-list-relationship-browsing.md) | Neighborhood mode for note-list relationship browsing | active |

View File

@@ -14,7 +14,7 @@
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
"playwright:regression": "playwright test tests/smoke/",
"playwright:integration": "playwright test --config playwright.integration.config.ts",
"test:coverage": "vitest run --coverage",

View File

@@ -0,0 +1,39 @@
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import {
buildStableDownloadRedirectPage,
resolveStableDmgUrl,
} from '../src/utils/releaseDownloadPage'
function getArg(flag: string): string {
const index = process.argv.indexOf(flag)
const value = index >= 0 ? process.argv[index + 1] : null
if (!value) {
throw new Error(`Missing required argument: ${flag}`)
}
return value
}
function readLatestReleasePayload(filePath: string): unknown {
try {
return JSON.parse(readFileSync(filePath, 'utf8'))
} catch {
return {}
}
}
const latestJsonPath = resolve(getArg('--latest-json'))
const releasesJsonPath = resolve(getArg('--releases-json'))
const outputFilePath = resolve(getArg('--output-file'))
const latestPayload = readLatestReleasePayload(latestJsonPath)
const releasesPayload = readLatestReleasePayload(releasesJsonPath)
const dmgUrl = resolveStableDmgUrl(latestPayload, releasesPayload)
const html = buildStableDownloadRedirectPage(dmgUrl)
mkdirSync(dirname(outputFilePath), { recursive: true })
writeFileSync(outputFilePath, html)
console.log(`Stable download page written to ${outputFilePath}`)

View File

@@ -121,6 +121,7 @@ fn ensure_missing_folder(folder_path: &std::path::Path, folder_name: &str) -> Re
}
fn initialize_empty_vault(vault_dir: &std::path::Path, vault_path: &str) -> Result<(), String> {
ensure_directory_is_missing_or_empty(vault_dir)?;
std::fs::create_dir_all(vault_dir)
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
@@ -129,6 +130,28 @@ fn initialize_empty_vault(vault_dir: &std::path::Path, vault_path: &str) -> Resu
Ok(())
}
fn ensure_directory_is_missing_or_empty(vault_dir: &std::path::Path) -> Result<(), String> {
if !vault_dir.exists() {
return Ok(());
}
let metadata = std::fs::metadata(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?;
if !metadata.is_dir() {
return Err("Choose a folder path for the new vault".to_string());
}
let has_entries = std::fs::read_dir(vault_dir)
.map_err(|e| format!("Failed to inspect target folder: {e}"))?
.next()
.is_some();
if has_entries {
return Err("Choose an empty folder to create a new vault".to_string());
}
Ok(())
}
fn canonical_vault_path_string(vault_dir: &std::path::Path) -> String {
vault_dir
.canonicalize()
@@ -291,6 +314,7 @@ pub fn repair_vault(vault_path: String) -> Result<String, String> {
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn temp_note(body: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::TempDir::new().unwrap();
@@ -299,6 +323,35 @@ mod tests {
(dir, note)
}
fn assert_paths_exist(root: &Path, paths: &[&str]) {
for path in paths {
assert!(root.join(path).exists(), "{path} should exist");
}
}
fn assert_paths_absent(root: &Path, paths: &[&str]) {
for path in paths {
assert!(!root.join(path).exists(), "{path} should be absent");
}
}
fn assert_seeded_guidance_content(vault_path: &Path) {
let agents = std::fs::read_to_string(vault_path.join("AGENTS.md")).unwrap();
let claude = std::fs::read_to_string(vault_path.join("CLAUDE.md")).unwrap();
assert!(agents.contains("Legacy `title:` frontmatter is still read as a fallback"));
assert!(agents.contains("views/*.yml"));
assert!(claude.starts_with("@AGENTS.md"));
assert!(claude.contains("# CLAUDE.md"));
}
fn assert_seeded_type_scaffolding(vault_path: &Path) {
let type_definition = std::fs::read_to_string(vault_path.join("type.md")).unwrap();
assert!(type_definition.contains("visible: false"));
assert!(type_definition.contains("# Type"));
}
#[test]
fn test_batch_archive_notes() {
let (_dir, note) = temp_note("---\nStatus: Active\n---\n# Note\n");
@@ -427,24 +480,41 @@ mod tests {
let result = repair_vault(vault_path.to_str().unwrap().to_string());
assert!(result.is_ok());
assert!(vault_path.join("AGENTS.md").exists());
assert!(vault_path.join("config.md").exists());
assert!(vault_path.join(".gitignore").exists());
assert_paths_exist(
vault_path,
&["AGENTS.md", "CLAUDE.md", "type.md", "note.md", ".gitignore"],
);
assert_paths_absent(vault_path, &["config.md"]);
}
#[test]
fn test_create_empty_vault_seeds_agents_and_config() {
fn test_create_empty_vault_seeds_agents_and_type_scaffolding() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("fresh-vault");
let result = create_empty_vault(vault_path.to_string_lossy().to_string());
assert!(result.is_ok());
assert!(vault_path.join(".git").exists());
assert!(vault_path.join("AGENTS.md").exists());
assert!(vault_path.join("config.md").exists());
assert_paths_exist(
&vault_path,
&[".git", "AGENTS.md", "CLAUDE.md", "type.md", "note.md"],
);
assert_paths_absent(&vault_path, &["config.md"]);
assert_seeded_guidance_content(&vault_path);
assert_seeded_type_scaffolding(&vault_path);
}
let agents = std::fs::read_to_string(vault_path.join("AGENTS.md")).unwrap();
assert!(agents.contains("Legacy `title:` frontmatter is still read as a fallback"));
assert!(agents.contains("views/*.yml"));
#[test]
fn test_create_empty_vault_rejects_nonempty_target() {
let dir = tempfile::TempDir::new().unwrap();
let vault_path = dir.path().join("existing-folder");
std::fs::create_dir_all(&vault_path).unwrap();
std::fs::write(vault_path.join("keep.txt"), "keep").unwrap();
let result = create_empty_vault(vault_path.to_string_lossy().to_string());
let err = result.expect_err("expected non-empty folder to be rejected");
assert_eq!(err, "Choose an empty folder to create a new vault");
assert_paths_exist(&vault_path, &["keep.txt"]);
assert_paths_absent(&vault_path, &[".git", "AGENTS.md"]);
}
}

View File

@@ -46,7 +46,7 @@ fn run_startup_tasks() {
);
// Migrate legacy config/agents.md → root AGENTS.md (one-time, idempotent)
vault::migrate_agents_md(vp_str);
// Seed AGENTS.md and config.md at vault root if missing
// Seed AGENTS.md and starter type definitions at vault root if missing
vault::seed_config_files(vp_str);
// Register Tolaria MCP server in Claude Code and Cursor configs
@@ -96,6 +96,7 @@ fn setup_desktop_plugins(app: &mut tauri::App) -> Result<(), Box<dyn std::error:
Ok(())
}
const MACOS_WEBVIEW_RESERVED_COMMAND_KEYS: &[&str] = &["O"];
const MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS: &[&str] = &["L"];
#[cfg(all(desktop, target_os = "macos"))]
@@ -110,6 +111,9 @@ fn setup_macos_webview_shortcut_prevention(
// WKWebView can swallow some browser-reserved chords before our shared
// renderer shortcut handler sees them. Keep this list narrow and verify
// every addition with native QA.
for key in MACOS_WEBVIEW_RESERVED_COMMAND_KEYS {
builder = builder.shortcut(KeyboardShortcut::with_modifiers(key, &[MetaKey]));
}
for key in MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS {
builder = builder.shortcut(KeyboardShortcut::with_modifiers(key, &[MetaKey, ShiftKey]));
}

View File

@@ -10,6 +10,7 @@ const APP_CHECK_FOR_UPDATES: &str = "app-check-for-updates";
const FILE_NEW_NOTE: &str = "file-new-note";
const FILE_NEW_TYPE: &str = "file-new-type";
const FILE_QUICK_OPEN: &str = "file-quick-open";
const FILE_QUICK_OPEN_ALIAS: &str = "file-quick-open-alias";
const FILE_SAVE: &str = "file-save";
const EDIT_FIND_IN_VAULT: &str = "edit-find-in-vault";
@@ -57,6 +58,7 @@ const CUSTOM_IDS: &[&str] = &[
FILE_NEW_NOTE,
FILE_NEW_TYPE,
FILE_QUICK_OPEN,
FILE_QUICK_OPEN_ALIAS,
FILE_SAVE,
EDIT_FIND_IN_VAULT,
EDIT_TOGGLE_RAW_EDITOR,
@@ -155,6 +157,10 @@ fn build_file_menu(app: &App) -> MenuResult {
.id(FILE_QUICK_OPEN)
.accelerator("CmdOrCtrl+P")
.build(app)?;
let quick_open_alias = MenuItemBuilder::new("Quick Open (Cmd+O)")
.id(FILE_QUICK_OPEN_ALIAS)
.accelerator("CmdOrCtrl+O")
.build(app)?;
let save = MenuItemBuilder::new("Save")
.id(FILE_SAVE)
.accelerator("CmdOrCtrl+S")
@@ -163,6 +169,7 @@ fn build_file_menu(app: &App) -> MenuResult {
.item(&new_note)
.item(&new_type)
.item(&quick_open)
.item(&quick_open_alias)
.separator()
.item(&save)
.build()?)
@@ -410,9 +417,13 @@ pub fn emit_custom_menu_event(app_handle: &AppHandle, id: &str) -> Result<(), St
if !CUSTOM_IDS.contains(&id) {
return Err(format!("Unknown custom menu event: {id}"));
}
let emitted_id = match id {
FILE_QUICK_OPEN_ALIAS => FILE_QUICK_OPEN,
_ => id,
};
app_handle
.emit("menu-event", id)
.map_err(|err| format!("Failed to emit menu-event {id}: {err}"))
.emit("menu-event", emitted_id)
.map_err(|err| format!("Failed to emit menu-event {emitted_id}: {err}"))
}
fn set_items_enabled(app_handle: &AppHandle, ids: &[&str], enabled: bool) {

View File

@@ -4,19 +4,25 @@ use std::path::{Path, PathBuf};
use super::getting_started::{agents_content_can_be_refreshed, AGENTS_MD};
/// Content for `config.md` — gives the Config type a sidebar icon and label.
const CONFIG_TYPE_DEFINITION: &str = "\
/// Content for `type.md` — describes the generic Type metamodel for the vault.
const TYPE_TYPE_DEFINITION: &str = "\
---
type: Type
icon: gear-six
color: gray
order: 90
sidebar label: Config
order: 0
visible: false
---
# Config
# Type
Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.
A Type defines shared metadata and defaults for a category of notes in this vault.
## Common properties
- **Icon**: Sidebar icon for this type
- **Color**: Accent color for notes of this type
- **Order**: Sidebar ordering
- **Sidebar label**: Override the default plural label
- **Template**: Default body for new notes of this type
- **View**: Preferred note-list view for this type
";
/// Content for `note.md` — restores the default Note type definition when missing.
@@ -30,8 +36,15 @@ type: Type
A Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.
";
const LEGACY_CLAUDE_MD_SHIM: &str = "@AGENTS.md
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
";
const CLAUDE_MD_SHIM: &str = "@AGENTS.md
# CLAUDE.md
This file is a Claude Code compatibility shim. Keep shared agent instructions in `AGENTS.md`.
";
@@ -80,7 +93,9 @@ fn root_agents_can_be_replaced(path: &Path) -> bool {
fn matches_claude_shim(content: &str) -> bool {
let trimmed = content.trim();
trimmed == "@AGENTS.md" || trimmed == CLAUDE_MD_SHIM.trim()
trimmed == "@AGENTS.md"
|| trimmed == LEGACY_CLAUDE_MD_SHIM.trim()
|| trimmed == CLAUDE_MD_SHIM.trim()
}
fn claude_shim_can_be_replaced(path: &Path) -> bool {
@@ -251,7 +266,7 @@ fn ensure_root_type_definition(vault_path: &Path, file_name: &str, content: &str
/// Ensure the default root type definitions exist for opened/repaired vaults.
fn ensure_root_type_definitions(vault_path: &Path) {
ensure_root_type_definition(vault_path, "config.md", CONFIG_TYPE_DEFINITION);
ensure_root_type_definition(vault_path, "type.md", TYPE_TYPE_DEFINITION);
ensure_root_type_definition(vault_path, "note.md", NOTE_TYPE_DEFINITION);
}
@@ -296,7 +311,7 @@ pub fn repair_config_files(vault_path: impl AsRef<str>) -> Result<String, String
let _ = cleanup_empty_config_dir(vault)?;
sync_ai_guidance_files(vault)?;
write_if_missing(&vault.join("config.md"), CONFIG_TYPE_DEFINITION)?;
write_if_missing(&vault.join("type.md"), TYPE_TYPE_DEFINITION)?;
write_if_missing(&vault.join("note.md"), NOTE_TYPE_DEFINITION)?;
Ok("Config files repaired".to_string())
@@ -445,12 +460,13 @@ mod tests {
seed_config_files(vault.to_str().unwrap());
assert!(vault.join("config.md").exists());
assert!(vault.join("type.md").exists());
assert!(vault.join("note.md").exists());
let config_content = fs::read_to_string(vault.join("config.md")).unwrap();
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
assert!(config_content.contains("type: Type"));
assert!(config_content.contains("icon: gear-six"));
assert!(type_content.contains("type: Type"));
assert!(type_content.contains("# Type"));
assert!(type_content.contains("visible: false"));
assert!(note_content.contains("type: Type"));
assert!(note_content.contains("# Note"));
assert!(!vault.join("config").exists());
@@ -569,12 +585,15 @@ mod tests {
assert!(vault.join("AGENTS.md").exists());
assert!(vault.join("CLAUDE.md").exists());
assert!(vault.join("config.md").exists());
assert!(vault.join("type.md").exists());
assert!(vault.join("note.md").exists());
assert!(!vault.join("config").exists());
let agents = read_root_agents(&vault);
assert!(agents.contains("Tolaria Vault"));
let type_content = fs::read_to_string(vault.join("type.md")).unwrap();
assert!(type_content.contains("# Type"));
assert!(type_content.contains("visible: false"));
let note_content = fs::read_to_string(vault.join("note.md")).unwrap();
assert!(note_content.contains("type: Type"));
assert!(note_content.contains("general-purpose document"));

View File

@@ -276,7 +276,7 @@ Keep edits compatible with this starter vault's current conventions. Prefer smal
- One Markdown note per file.
- The first H1 in the body is the preferred display title. Legacy `title:` frontmatter is still read as a fallback when a note has no H1, but do not add it to new notes.
- Store note type in the `type:` frontmatter field.
- In this starter vault, type definitions currently live at the vault root, for example `project.md`, `person.md`, `note.md`, and `config.md`. Keep new type files at the vault root unless the user explicitly asks to reorganize them.
- In this starter vault, type definitions currently live at the vault root, for example `project.md`, `person.md`, `note.md`, and `type.md`. Keep new type files at the vault root unless the user explicitly asks to reorganize them.
- Saved views live in `views/*.yml`.
- Files in `attachments/` are assets, not notes. Reference them from notes, but do not treat them as notes or types.
- Frontmatter properties that start with `_` are usually Tolaria-managed state. Leave them alone unless the user explicitly asks for them to change.
@@ -649,7 +649,8 @@ mod tests {
let content = fs::read_to_string(dest.join("AGENTS.md")).unwrap();
assert_eq!(content, AGENTS_MD);
assert!(dest.join("config.md").exists());
assert!(dest.join("type.md").exists());
assert!(dest.join("note.md").exists());
}
#[test]
@@ -686,7 +687,8 @@ mod tests {
fs::read_to_string(dest.join("AGENTS.md")).unwrap(),
AGENTS_MD
);
assert!(dest.join("config.md").exists());
assert!(dest.join("type.md").exists());
assert!(dest.join("note.md").exists());
}
#[test]

View File

@@ -17,6 +17,12 @@ pub struct ViewDefinition {
pub color: Option<String>,
#[serde(default)]
pub sort: Option<String>,
#[serde(
default,
rename = "listPropertiesDisplay",
skip_serializing_if = "Vec::is_empty"
)]
pub list_properties_display: Vec<String>,
pub filters: FilterGroup,
}
@@ -195,6 +201,37 @@ pub fn migrate_views(vault_path: &Path) {
}
/// Scan all `.yml` files from `vault_path/views/` and return parsed views.
fn is_view_definition_file(path: &Path) -> bool {
path.extension().and_then(|ext| ext.to_str()) == Some("yml")
}
fn read_view_file(path: &Path) -> Option<ViewFile> {
if !is_view_definition_file(path) {
return None;
}
let filename = path.file_name()?.to_string_lossy().to_string();
let content = match fs::read_to_string(path) {
Ok(content) => content,
Err(error) => {
log::warn!("Failed to read view file {}: {}", filename, error);
return None;
}
};
let definition = match serde_yaml::from_str::<ViewDefinition>(&content) {
Ok(definition) => definition,
Err(error) => {
log::warn!("Failed to parse view {}: {}", filename, error);
return None;
}
};
Some(ViewFile {
filename,
definition,
})
}
pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
migrate_views(vault_path);
let views_dir = vault_path.join("views");
@@ -212,20 +249,8 @@ pub fn scan_views(vault_path: &Path) -> Vec<ViewFile> {
};
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),
if let Some(view) = read_view_file(&entry.path()) {
views.push(view);
}
}
@@ -656,6 +681,22 @@ mod tests {
entry
}
fn make_project_view(name: &str) -> ViewDefinition {
ViewDefinition {
name: name.to_string(),
icon: None,
color: None,
sort: None,
list_properties_display: Vec::new(),
filters: FilterGroup::All(vec![FilterNode::Condition(FilterCondition {
field: "type".to_string(),
op: FilterOp::Equals,
value: Some(serde_yaml::Value::String("Project".to_string())),
regex: false,
})]),
}
}
#[test]
fn test_parse_simple_view() {
let yaml = r#"
@@ -670,6 +711,7 @@ filters:
let def: ViewDefinition = serde_yaml::from_str(yaml).unwrap();
assert_eq!(def.name, "Active Projects");
assert_eq!(def.icon.as_deref(), Some("rocket"));
assert!(def.list_properties_display.is_empty());
match &def.filters {
FilterGroup::All(nodes) => {
assert_eq!(nodes.len(), 1);
@@ -945,18 +987,10 @@ filters:
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())),
regex: false,
})]),
};
let mut def = make_project_view("Test View");
def.icon = Some("star".to_string());
def.sort = Some("modified:desc".to_string());
def.list_properties_display = vec!["Priority".to_string(), "Owner".to_string()];
save_view(dir.path(), "test.yml", &def).unwrap();
@@ -964,6 +998,10 @@ filters:
assert_eq!(views.len(), 1);
assert_eq!(views[0].definition.name, "Test View");
assert_eq!(views[0].definition.icon.as_deref(), Some("star"));
assert_eq!(
views[0].definition.list_properties_display,
vec!["Priority".to_string(), "Owner".to_string()]
);
delete_view(dir.path(), "test.yml").unwrap();
let views = scan_views(dir.path());
@@ -974,18 +1012,8 @@ filters:
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())),
regex: false,
})]),
};
let mut def = make_project_view("Monday");
def.icon = Some("🗂️".to_string());
save_view(dir.path(), "monday.yml", &def).unwrap();

View File

@@ -227,7 +227,7 @@ describe('App', () => {
it('shows keyboard shortcut hints', async () => {
render(<App />)
await waitFor(() => {
expect(screen.getByText(/Cmd\+P to search/)).toBeInTheDocument()
expect(screen.getByText(/Cmd\+P or Cmd\+O to search/)).toBeInTheDocument()
})
})

View File

@@ -69,7 +69,7 @@ import { DeleteProgressNotice } from './components/DeleteProgressNotice'
import { UpdateBanner } from './components/UpdateBanner'
import { invoke } from '@tauri-apps/api/core'
import { isTauri, mockInvoke } from './mock-tauri'
import type { SidebarSelection, InboxPeriod, VaultEntry } from './types'
import type { SidebarSelection, InboxPeriod, VaultEntry, ViewDefinition } from './types'
import type { NoteListItem } from './utils/ai-context'
import { filterEntries, filterInboxEntries, type NoteListFilter } from './utils/noteListHelpers'
import { openNoteInNewWindow } from './utils/openNoteWindow'
@@ -189,6 +189,9 @@ function App() {
setSelection(sel)
setNoteListFilter('open')
}, [])
const handleEnterNeighborhood = useCallback((entry: VaultEntry) => {
handleSetSelection({ kind: 'entity', entry })
}, [handleSetSelection])
const layout = useLayoutPanels(noteWindowParams ? { initialInspectorCollapsed: true } : undefined)
const { setInspectorCollapsed } = layout
const visibleNotesRef = useRef<VaultEntry[]>([])
@@ -561,6 +564,11 @@ function App() {
}, [setInspectorCollapsed])
const handleCustomizeNoteListColumns = useCallback(() => {
if (effectiveSelection.kind === 'view') {
openNoteListPropertiesPicker('view')
return
}
if (effectiveSelection.kind !== 'filter') return
if (effectiveSelection.filter === 'all') {
openNoteListPropertiesPicker('all')
@@ -800,21 +808,35 @@ function App() {
)
}, [notes, setToastMessage, vault.entries])
const handleCreateOrUpdateView = useCallback(async (definition: import('./types').ViewDefinition) => {
const handleCreateOrUpdateView = useCallback(async (definition: ViewDefinition) => {
const editing = dialogs.editingView
const filename = editing
? editing.filename
: definition.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') + '.yml'
const nextDefinition = editing ? { ...editing.definition, ...definition } : definition
const target = isTauri() ? invoke : mockInvoke
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition })
await target('save_view_cmd', { vaultPath: resolvedPath, filename, definition: nextDefinition })
trackEvent(editing ? 'view_updated' : 'view_created')
await vault.reloadViews()
await vault.reloadVault()
vault.reloadFolders()
setToastMessage(editing ? `View "${definition.name}" updated` : `View "${definition.name}" created`)
setToastMessage(editing ? `View "${nextDefinition.name}" updated` : `View "${nextDefinition.name}" created`)
handleSetSelection({ kind: 'view', filename })
}, [resolvedPath, vault, handleSetSelection, dialogs.editingView])
const handleUpdateViewDefinition = useCallback(async (filename: string, patch: Partial<ViewDefinition>) => {
const existing = vault.views.find((view) => view.filename === filename)
if (!existing) return
const target = isTauri() ? invoke : mockInvoke
await target('save_view_cmd', {
vaultPath: resolvedPath,
filename,
definition: { ...existing.definition, ...patch },
})
await vault.reloadViews()
}, [resolvedPath, vault])
const handleEditView = useCallback((filename: string) => {
const view = vault.views.find((v) => v.filename === filename)
if (view) dialogs.openEditView(filename, view.definition)
@@ -962,6 +984,17 @@ function App() {
&& activeCommandEntry.filename.toLowerCase().endsWith('.md')
&& !activeDeletedFile
const noteListColumnsLabel = useMemo(() => {
if (effectiveSelection.kind === 'view') {
const selectedView = vault.views.find((view) => view.filename === effectiveSelection.filename)
return selectedView ? `Customize ${selectedView.definition.name} columns` : 'Customize View columns'
}
return effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'all'
? 'Customize All Notes columns'
: 'Customize Inbox columns'
}, [effectiveSelection, vault.views])
const commands = useAppCommands({
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
entries: vault.entries,
@@ -995,6 +1028,7 @@ function App() {
onGoBack: handleGoBack, onGoForward: handleGoForward,
canGoBack: canGoBack, canGoForward: canGoForward,
onOpenVault: vaultSwitcher.handleOpenLocalFolder,
onCreateEmptyVault: vaultSwitcher.handleCreateEmptyVault,
onCreateType: dialogs.openCreateType,
onToggleAIChat: dialogs.toggleAIChat,
onCheckForUpdates: handleCheckForUpdates,
@@ -1026,8 +1060,12 @@ function App() {
onToggleFavorite: entryActions.handleToggleFavorite,
onToggleOrganized: explicitOrganizationEnabled ? entryActions.handleToggleOrganized : undefined,
onCustomizeNoteListColumns: handleCustomizeNoteListColumns,
canCustomizeNoteListColumns: effectiveSelection.kind === 'filter'
&& (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox')),
canCustomizeNoteListColumns: effectiveSelection.kind === 'view'
|| (
effectiveSelection.kind === 'filter'
&& (effectiveSelection.filter === 'all' || (explicitOrganizationEnabled && effectiveSelection.filter === 'inbox'))
),
noteListColumnsLabel,
onRestoreDeletedNote: activeDeletedFile ? () => { void handleDiscardFile(activeDeletedFile.relativePath) } : undefined,
canRestoreDeletedNote: !!activeDeletedFile,
})
@@ -1135,7 +1173,7 @@ function App() {
{effectiveSelection.kind === 'filter' && effectiveSelection.filter === 'pulse' ? (
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => handleSetViewMode('all')} />
) : (
<NoteList entries={vault.entries} selection={effectiveSelection} 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} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
<NoteList entries={vault.entries} selection={effectiveSelection} 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} onEnterNeighborhood={handleEnterNeighborhood} onCreateNote={notes.handleCreateNoteImmediate} onBulkOrganize={explicitOrganizationEnabled ? bulkActions.handleBulkOrganize : undefined} onBulkArchive={bulkActions.handleBulkArchive} onBulkDeletePermanently={deleteActions.handleBulkDeletePermanently} onUpdateTypeSort={notes.handleUpdateFrontmatter} onUpdateViewDefinition={handleUpdateViewDefinition} updateEntry={vault.updateEntry} onOpenInNewWindow={handleOpenEntryInNewWindow} onDiscardFile={handleDiscardFile} onAutoTriggerDiff={() => diffToggleRef.current()} onOpenDeletedNote={handleOpenDeletedNote} allNotesNoteListProperties={vaultConfig.allNotes?.noteListProperties ?? null} onUpdateAllNotesNoteListProperties={handleUpdateAllNotesNoteListProperties} inboxNoteListProperties={vaultConfig.inbox?.noteListProperties ?? null} onUpdateInboxNoteListProperties={handleUpdateInboxNoteListProperties} views={vault.views} visibleNotesRef={visibleNotesRef} multiSelectionCommandRef={multiSelectionCommandRef} />
)}
</div>
<ResizeHandle onResize={layout.handleNoteListResize} />
@@ -1200,7 +1238,7 @@ function App() {
</div>
<UpdateBanner status={updateStatus} actions={updateActions} />
<RenameDetectedBanner renames={detectedRenames} onUpdate={handleUpdateWikilinks} onDismiss={handleDismissRenames} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={resolvedPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenFeedback={openFeedback} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onCreateEmptyVault={vaultSwitcher.handleCreateEmptyVault} onCloneVault={dialogs.openCloneVault} onCloneGettingStarted={cloneGettingStartedVault} onClickPending={() => handleSetSelection({ kind: 'filter', filter: 'changes' })} onClickPulse={() => handleSetSelection({ kind: 'filter', filter: 'pulse' })} onCommitPush={handleCommitPush} isOffline={networkStatus.isOffline} isGitVault={isGitVault} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} remoteStatus={autoSync.remoteStatus} onTriggerSync={autoSync.triggerSync} onPullAndPush={autoSync.pullAndPush} onOpenConflictResolver={conflictFlow.handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} aiAgentsStatus={aiAgentsStatus} vaultAiGuidanceStatus={vaultAiGuidanceStatus} defaultAiAgent={aiAgentPreferences.defaultAiAgent} onSetDefaultAiAgent={aiAgentPreferences.setDefaultAiAgent} onRestoreVaultAiGuidance={() => { void restoreVaultAiGuidance() }} />
<DeleteProgressNotice count={deleteActions.pendingDeleteCount} />
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
@@ -1264,7 +1302,7 @@ function WelcomeView({ onboarding, isOffline }: { onboarding: OnboardingState; i
defaultVaultPath={state.defaultPath}
onCreateVault={onboarding.handleCreateVault}
onRetryCreateVault={onboarding.retryCreateVault}
onCreateNewVault={onboarding.handleCreateNewVault}
onCreateEmptyVault={onboarding.handleCreateEmptyVault}
onOpenFolder={onboarding.handleOpenFolder}
isOffline={isOffline}
creatingAction={onboarding.creatingAction}

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { CalendarIcon, Check, X } from 'lucide-react'
@@ -27,6 +28,17 @@ function dateToISO(day: Date): string {
const ADD_INPUT_CLASS = "h-[26px] min-w-[60px] flex-1 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
function isValidNumberValue(value: string): boolean {
const trimmed = value.trim()
if (trimmed === '') return false
return Number.isFinite(Number(trimmed))
}
function canSubmitProperty({ key, value, displayMode }: { key: string; value: string; displayMode: PropertyDisplayMode }): boolean {
if (!key.trim()) return false
return displayMode !== 'number' || isValidNumberValue(value)
}
function AddBooleanInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const boolVal = value.toLowerCase() === 'true'
return (
@@ -91,21 +103,42 @@ function AddStatusInput({ value, onChange, vaultStatuses }: { value: string; onC
)
}
function AddNumberInput({ value, onChange, onKeyDown }: {
value: string
onChange: (v: string) => void
onKeyDown: (e: React.KeyboardEvent) => void
}) {
return (
<Input
className={`${ADD_INPUT_CLASS} font-mono tabular-nums`}
type="text"
inputMode="decimal"
placeholder="0"
value={value}
onChange={(event) => onChange(event.target.value)}
onKeyDown={onKeyDown}
data-testid="add-property-number-input"
/>
)
}
function AddPropertyValueInput({ displayMode, value, onChange, onKeyDown, vaultStatuses }: {
displayMode: PropertyDisplayMode; value: string; onChange: (v: string) => void
onKeyDown: (e: React.KeyboardEvent) => void; vaultStatuses: string[]
}) {
switch (displayMode) {
case 'number':
return <AddNumberInput value={value} onChange={onChange} onKeyDown={onKeyDown} />
case 'boolean': return <AddBooleanInput value={value} onChange={onChange} />
case 'date': return <AddDateInput value={value} onChange={onChange} />
case 'status': return <AddStatusInput value={value} onChange={onChange} vaultStatuses={vaultStatuses} />
case 'tags': return (
<input className={ADD_INPUT_CLASS} type="text" placeholder="tag1, tag2, ..." value={value}
<Input className={ADD_INPUT_CLASS} type="text" placeholder="tag1, tag2, ..." value={value}
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
/>
)
default: return (
<input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
<Input className={ADD_INPUT_CLASS} type="text" placeholder="Value" value={value}
onChange={(e) => onChange(e.target.value)} onKeyDown={onKeyDown}
/>
)
@@ -119,6 +152,7 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
const [newKey, setNewKey] = useState('')
const [newValue, setNewValue] = useState('')
const [displayMode, setDisplayMode] = useState<PropertyDisplayMode>('text')
const canSubmit = canSubmitProperty({ key: newKey, value: newValue, displayMode })
const handleModeChange = (mode: PropertyDisplayMode) => {
setDisplayMode(mode)
@@ -127,13 +161,13 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && newKey.trim()) onAdd(newKey, newValue, displayMode)
if (e.key === 'Enter' && canSubmit) onAdd(newKey, newValue, displayMode)
else if (e.key === 'Escape') onCancel()
}
return (
<div className="mt-1 flex flex-wrap items-center gap-1.5 rounded px-1.5 py-1" data-testid="add-property-form">
<input
<Input
className="h-[26px] w-20 shrink-0 rounded border border-border bg-muted px-1.5 text-[12px] text-foreground outline-none focus:border-primary"
type="text" placeholder="Property name" value={newKey}
onChange={(e) => setNewKey(e.target.value)} onKeyDown={handleKeyDown} autoFocus
@@ -162,7 +196,7 @@ export function AddPropertyForm({ onAdd, onCancel, vaultStatuses }: {
<AddPropertyValueInput displayMode={displayMode} value={newValue} onChange={setNewValue} onKeyDown={handleKeyDown} vaultStatuses={vaultStatuses} />
<Button
size="icon-xs" onClick={() => onAdd(newKey, newValue, displayMode)}
disabled={!newKey.trim()} title="Add property"
disabled={!canSubmit} title="Add property"
data-testid="add-property-confirm"
>
<Check className="size-3.5" />

View File

@@ -3,10 +3,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AiAgentsOnboardingPrompt } from './AiAgentsOnboardingPrompt'
const openExternalUrl = vi.fn()
const dragRegionMouseDown = vi.fn()
vi.mock('../utils/url', () => ({
openExternalUrl: (...args: unknown[]) => openExternalUrl(...args),
}))
vi.mock('../hooks/useDragRegion', () => ({
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
}))
describe('AiAgentsOnboardingPrompt', () => {
beforeEach(() => {
@@ -65,4 +69,22 @@ describe('AiAgentsOnboardingPrompt', () => {
expect(openExternalUrl).toHaveBeenCalledWith('https://docs.anthropic.com/en/docs/claude-code')
expect(openExternalUrl).toHaveBeenCalledWith('https://developers.openai.com/codex/cli')
})
it('uses the surrounding surface as a drag region and excludes the card', () => {
render(
<AiAgentsOnboardingPrompt
statuses={{
claude_code: { status: 'installed', version: '1.0.20' },
codex: { status: 'missing', version: null },
}}
onContinue={vi.fn()}
/>,
)
const screenContainer = screen.getByTestId('ai-agents-onboarding-screen')
fireEvent.mouseDown(screenContainer)
expect(dragRegionMouseDown).toHaveBeenCalledOnce()
expect(screenContainer.querySelector('[data-no-drag]')).not.toBeNull()
})
})

View File

@@ -7,6 +7,7 @@ import {
type AiAgentsStatus,
} from '../lib/aiAgents'
import { openExternalUrl } from '../utils/url'
import { OnboardingShell } from './OnboardingShell'
import { Button } from './ui/button'
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from './ui/card'
@@ -82,11 +83,12 @@ export function AiAgentsOnboardingPrompt({
const missingAgents = AI_AGENT_DEFINITIONS.filter((definition) => statuses[definition.id].status === 'missing')
return (
<div
className="flex h-full w-full items-center justify-center bg-sidebar px-6 py-10"
data-testid="ai-agents-onboarding-screen"
<OnboardingShell
className="bg-sidebar px-6 py-10"
contentClassName="w-full max-w-2xl"
testId="ai-agents-onboarding-screen"
>
<Card className="w-full max-w-2xl border-border bg-background shadow-sm">
<Card className="border-border bg-background shadow-sm">
<CardHeader className="items-center gap-5 text-center">
<div className={`flex size-16 items-center justify-center rounded-2xl ${copy.accentClassName}`}>
{copy.icon}
@@ -141,6 +143,6 @@ export function AiAgentsOnboardingPrompt({
</div>
</CardFooter>
</Card>
</div>
</OnboardingShell>
)
}

View File

@@ -627,6 +627,14 @@ describe('DynamicPropertiesPanel', () => {
})
})
describe('smart property display — number', () => {
it('renders numeric properties with the number display affordance', () => {
renderEditablePanel({ estimate: -3.25 })
expect(screen.getByTestId('number-display')).toBeInTheDocument()
expect(screen.getByText('-3.25')).toBeInTheDocument()
})
})
describe('smart property display — status auto-detection', () => {
it('renders status badge for property named Status', () => {
renderEditablePanel({ Status: 'Active' })
@@ -741,6 +749,7 @@ describe('DynamicPropertiesPanel', () => {
fireEvent.click(screen.getByTestId('display-mode-trigger'))
expect(screen.getByTestId('display-mode-menu')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-text')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-number')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-date')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-boolean')).toBeInTheDocument()
expect(screen.getByTestId('display-mode-option-status')).toBeInTheDocument()
@@ -798,6 +807,13 @@ describe('DynamicPropertiesPanel', () => {
})
describe('type-aware add property form', () => {
it('shows number input when number type selected', () => {
openAddPropertyForm()
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: /Number/ }))
expect(screen.getByTestId('add-property-number-input')).toBeInTheDocument()
})
it('shows boolean toggle when boolean type selected', () => {
openAddPropertyForm()
// Switch type to boolean
@@ -829,6 +845,16 @@ describe('DynamicPropertiesPanel', () => {
expect(onAddProperty).toHaveBeenCalledWith('published', true)
})
it('stores trimmed decimal values as numbers when adding number properties', () => {
const { keyInput } = openAddPropertyForm()
fireEvent.change(keyInput, { target: { value: 'estimate' } })
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })
fireEvent.click(screen.getByRole('option', { name: /Number/ }))
fireEvent.change(screen.getByTestId('add-property-number-input'), { target: { value: ' -12.5 ' } })
fireEvent.click(screen.getByTestId('add-property-confirm'))
expect(onAddProperty).toHaveBeenCalledWith('estimate', -12.5)
})
it('shows date picker trigger when date type selected', { timeout: 15_000 }, () => {
openAddPropertyForm()
fireEvent.pointerDown(screen.getByTestId('add-property-type-trigger'), { button: 0, pointerType: 'mouse' })

View File

@@ -181,7 +181,7 @@ describe('Editor', () => {
it('shows empty state when no tabs are open', () => {
renderEditor()
expect(screen.getByText('Select a note to start editing')).toBeInTheDocument()
expect(screen.getByText(/Cmd\+P to search/)).toBeInTheDocument()
expect(screen.getByText(/Cmd\+P or Cmd\+O to search/)).toBeInTheDocument()
})
it('renders an invisible drag region in the empty state', () => {

View File

@@ -138,7 +138,7 @@ function EditorEmptyState() {
/>
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-center text-muted-foreground">
<p className="m-0 text-[15px]">Select a note to start editing</p>
<span className="text-xs text-muted-foreground">Cmd+P to search &middot; Cmd+N to create</span>
<span className="text-xs text-muted-foreground">Cmd+P or Cmd+O to search &middot; Cmd+N to create</span>
</div>
</div>
)

View File

@@ -2,6 +2,12 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { GitRequiredModal } from './GitRequiredModal'
const dragRegionMouseDown = vi.fn()
vi.mock('../hooks/useDragRegion', () => ({
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
}))
describe('GitRequiredModal', () => {
it('renders title and explanation', () => {
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
@@ -48,4 +54,14 @@ describe('GitRequiredModal', () => {
expect(screen.getByText(/Permission denied/)).toBeInTheDocument()
})
})
it('uses the surrounding surface as a drag region and excludes the card', () => {
render(<GitRequiredModal onCreateRepo={vi.fn()} onChooseVault={vi.fn()} />)
const shell = screen.getByTestId('git-required-shell')
fireEvent.mouseDown(shell)
expect(dragRegionMouseDown).toHaveBeenCalledOnce()
expect(shell.querySelector('[data-no-drag]')).not.toBeNull()
})
})

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'
import { GitBranch } from '@phosphor-icons/react'
import { OnboardingShell } from './OnboardingShell'
interface GitRequiredModalProps {
onCreateRepo: () => Promise<void>
@@ -22,8 +23,12 @@ export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredMod
}
return (
<div className="flex h-full w-full items-center justify-center" style={{ background: 'var(--sidebar)' }}>
<div className="flex max-w-sm flex-col items-center gap-5 rounded-xl border border-border bg-background p-8 shadow-lg">
<OnboardingShell
style={{ background: 'var(--sidebar)' }}
contentClassName="w-full max-w-sm"
testId="git-required-shell"
>
<div className="flex flex-col items-center gap-5 rounded-xl border border-border bg-background p-8 shadow-lg">
<GitBranch size={36} className="text-muted-foreground" />
<h2 className="m-0 text-lg font-semibold text-foreground">Git repository required</h2>
<p className="m-0 text-center text-[13px] leading-relaxed text-muted-foreground">
@@ -52,6 +57,6 @@ export function GitRequiredModal({ onCreateRepo, onChooseVault }: GitRequiredMod
</button>
</div>
</div>
</div>
</OnboardingShell>
)
}

View File

@@ -1,5 +1,7 @@
import {
useMemo,
useRef,
useState,
type ReactNode,
} from 'react'
import type { VaultEntry } from '../types'
@@ -14,6 +16,12 @@ import {
extractInlineWikilinkReferences,
findActiveWikilinkQuery,
} from './inlineWikilinkText'
import { serializeInlineNode } from './inlineWikilinkDom'
import {
buildPendingPasteState,
type PendingPasteState,
shouldRecoverPendingPaste,
} from './inlineWikilinkPasteRecovery'
import {
InlineWikilinkEditorField,
InlineWikilinkPaletteLayout,
@@ -48,7 +56,6 @@ function collapseSelectionRange(nextSelectionIndex: number) {
end: nextSelectionIndex,
}
}
function submitInlineValue({
onSubmit,
submitOnEmpty,
@@ -66,51 +73,6 @@ function submitInlineValue({
onSubmit(normalizedValue, references)
}
function renderInlineEditorField({
value,
placeholder,
disabled,
inputRef,
dataTestId,
editorClassName,
onInput,
onKeyDown,
onPaste,
onSelectionChange,
segments,
typeEntryMap,
}: {
value: string
placeholder?: string
disabled: boolean
inputRef: React.Ref<HTMLDivElement>
dataTestId: string
editorClassName?: string
onInput: () => void
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onPaste: (event: React.ClipboardEvent<HTMLDivElement>) => void
onSelectionChange: () => void
segments: ReturnType<typeof buildInlineWikilinkSegments>
typeEntryMap: Record<string, VaultEntry>
}) {
return (
<InlineWikilinkEditorField
value={value}
placeholder={placeholder}
disabled={disabled}
inputRef={inputRef}
dataTestId={dataTestId}
editorClassName={editorClassName}
onInput={onInput}
onKeyDown={onKeyDown}
onPaste={onPaste}
onSelectionChange={onSelectionChange}
segments={segments}
typeEntryMap={typeEntryMap}
/>
)
}
function renderInlineSuggestionList({
suggestions,
selectedSuggestionIndex,
@@ -160,12 +122,14 @@ export function InlineWikilinkInput({
paletteEmptyState,
paletteFooter,
}: InlineWikilinkInputProps) {
const [, forceRender] = useState(0)
const segments = useMemo(
() => buildInlineWikilinkSegments(value, entries),
[entries, value],
)
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const {
editorRef,
selectionRange,
selectionIndex,
setSelectionRange,
@@ -178,6 +142,7 @@ export function InlineWikilinkInput({
onChange,
inputRef,
})
const pendingPasteRef = useRef<PendingPasteState | null>(null)
const activeQuery = useMemo(
() => selectionRange.start === selectionRange.end
? findActiveWikilinkQuery(value, selectionIndex)
@@ -218,11 +183,30 @@ export function InlineWikilinkInput({
const pastedText = normalizeInlineWikilinkValue(event.clipboardData.getData('text/plain'))
if (!pastedText) return
event.preventDefault()
const nextState = replaceInlineSelection(value, selectionRange, pastedText)
pendingPasteRef.current = buildPendingPasteState(value, selectionRange, pastedText)
event.preventDefault()
onChange(nextState.value)
setSelectionRange(nextState.selection)
}
const handleInput = () => {
const editor = editorRef.current
const pendingPaste = pendingPasteRef.current
if (editor && pendingPaste) {
const nextValue = normalizeInlineWikilinkValue(serializeInlineNode(editor))
pendingPasteRef.current = null
if (shouldRecoverPendingPaste(nextValue, pendingPaste)) {
onChange(pendingPaste.expectedValue)
forceRender((current) => current + 1)
setSelectionRange({ ...pendingPaste.expectedSelection })
return
}
}
commitValueFromEditor()
}
const submitValue = () =>
submitInlineValue({ onSubmit, submitOnEmpty, value, references })
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) =>
@@ -237,20 +221,22 @@ export function InlineWikilinkInput({
canSubmit: onSubmit !== undefined,
onSubmit: submitValue,
})
const editor = renderInlineEditorField({
value,
placeholder,
disabled,
inputRef: setCombinedRef,
dataTestId,
editorClassName,
onInput: commitValueFromEditor,
onKeyDown: handleKeyDown,
onPaste: handlePaste,
onSelectionChange: syncSelectionRange,
segments,
typeEntryMap,
})
const editor = (
<InlineWikilinkEditorField
value={value}
placeholder={placeholder}
disabled={disabled}
inputRef={setCombinedRef}
dataTestId={dataTestId}
editorClassName={editorClassName}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onSelectionChange={syncSelectionRange}
segments={segments}
typeEntryMap={typeEntryMap}
/>
)
const suggestionList = renderInlineSuggestionList({
suggestions,
selectedSuggestionIndex,

View File

@@ -1,4 +1,4 @@
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NoteList } from './NoteList'
import { makeEntry, makeIndexedEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
@@ -156,14 +156,17 @@ describe('NoteList multi-select', () => {
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[2])
})
it('clears multi-select and opens a new tab on Cmd+Click', () => {
const { onSelectNote } = renderNoteList()
it('clears multi-select and enters Neighborhood on Cmd+Click', async () => {
const { onEnterNeighborhood, onReplaceActiveTab } = renderNoteList()
fireEvent.click(screen.getByText('Build Laputa App'))
fireEvent.click(screen.getByText('Facebook Ads Strategy'), { shiftKey: true })
fireEvent.click(screen.getByText('Matteo Cellini'), { metaKey: true })
expect(screen.queryByTestId('multi-selected-item')).not.toBeInTheDocument()
expect(onSelectNote).toHaveBeenCalledWith(mockEntries[2])
await waitFor(() => {
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[2])
expect(onEnterNeighborhood).toHaveBeenCalledWith(mockEntries[2])
})
})
it('shows the bulk action bar with the selected count', () => {

View File

@@ -1,17 +1,22 @@
import { useState } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NoteList } from './NoteList'
import {
allSelection,
mockEntries,
} from '../test-utils/noteListTestUtils'
import type { VaultEntry } from '../types'
import type { SidebarSelection, VaultEntry } from '../types'
import * as tabManagement from '../hooks/useTabManagement'
function NoteListKeyboardHarness({
onOpen,
onEnterNeighborhood = () => {},
selection = allSelection,
}: {
onOpen: (entry: VaultEntry) => void
onEnterNeighborhood?: (entry: VaultEntry) => void
selection?: SidebarSelection
}) {
const [selectedNote, setSelectedNote] = useState<VaultEntry | null>(null)
@@ -23,18 +28,23 @@ function NoteListKeyboardHarness({
return (
<NoteList
entries={mockEntries}
selection={allSelection}
selection={selection}
selectedNote={selectedNote}
noteListFilter="open"
onNoteListFilterChange={() => {}}
onSelectNote={handleOpen}
onReplaceActiveTab={handleOpen}
onEnterNeighborhood={onEnterNeighborhood}
onCreateNote={() => {}}
/>
)
}
describe('NoteList keyboard activation', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('focuses the list on click and continues arrow navigation from the clicked note', async () => {
const onOpen = vi.fn()
render(<NoteListKeyboardHarness onOpen={onOpen} />)
@@ -51,8 +61,10 @@ describe('NoteList keyboard activation', () => {
fireEvent.keyDown(container, { key: 'ArrowDown' })
expect(onOpen).toHaveBeenNthCalledWith(1, mockEntries[1])
expect(onOpen).toHaveBeenNthCalledWith(2, mockEntries[2])
await waitFor(() => {
expect(onOpen).toHaveBeenNthCalledWith(1, mockEntries[1])
expect(onOpen).toHaveBeenNthCalledWith(2, mockEntries[2])
})
})
it('navigates from global arrow keys when the editor is not focused', async () => {
@@ -71,4 +83,38 @@ describe('NoteList keyboard activation', () => {
expect(onOpen).toHaveBeenCalledWith(mockEntries[0])
})
})
it('supports Cmd+Enter to pivot the highlighted note into Neighborhood mode', async () => {
const onOpen = vi.fn()
const onEnterNeighborhood = vi.fn()
render(
<NoteListKeyboardHarness
onOpen={onOpen}
onEnterNeighborhood={onEnterNeighborhood}
selection={{ kind: 'entity', entry: mockEntries[0] }}
/>,
)
const container = screen.getByTestId('note-list-container')
fireEvent.keyDown(container, { key: 'ArrowDown' })
fireEvent.keyDown(container, { key: 'ArrowDown' })
fireEvent.keyDown(container, { key: 'Enter', metaKey: true })
await waitFor(() => {
expect(onOpen).toHaveBeenLastCalledWith(mockEntries[4])
expect(onEnterNeighborhood).toHaveBeenCalledWith(mockEntries[4])
})
})
it('prefetches note content on hover so click opens can use the warm path', () => {
const prefetchSpy = vi.spyOn(tabManagement, 'prefetchNoteContent').mockImplementation(() => {})
render(<NoteListKeyboardHarness onOpen={vi.fn()} />)
const noteRow = screen.getByText('Facebook Ads Strategy').closest('[data-note-path]')
expect(noteRow).not.toBeNull()
fireEvent.mouseEnter(noteRow!)
expect(prefetchSpy).toHaveBeenCalledWith(mockEntries[1].path)
})
})

View File

@@ -1,13 +1,17 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
import { useState } from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { NoteList } from './NoteList'
import { openNoteListPropertiesPicker } from './note-list/noteListPropertiesEvents'
import {
allSelection,
buildNoteListProps,
makeEntry,
makeTypeDefinition,
mockEntries,
renderNoteList,
} from '../test-utils/noteListTestUtils'
import type { ViewFile } from '../types'
function makeBookTypeEntries(
displayProps: string[] = [],
@@ -28,6 +32,58 @@ function makeBookTypeEntries(
const noop = () => undefined
function makeViewDefinition(overrides: Partial<ViewFile> = {}): ViewFile {
return {
filename: 'active-books.yml',
definition: {
name: 'Active Books',
icon: null,
color: null,
sort: null,
filters: { all: [{ field: 'type', op: 'equals', value: 'Book' }] },
...overrides.definition,
},
...overrides,
}
}
function renderManagedViewNoteList({
entries,
view = makeViewDefinition(),
}: {
entries: Parameters<typeof renderNoteList>[0]['entries']
view?: ViewFile
}) {
const built = buildNoteListProps({
entries,
selection: { kind: 'view', filename: view.filename },
views: [view],
})
function ManagedViewNoteList() {
const [views, setViews] = useState([view])
return (
<NoteList
{...built.props}
views={views}
onUpdateViewDefinition={(filename, patch) => {
setViews((currentViews) => currentViews.map((currentView) => (
currentView.filename === filename
? { ...currentView, definition: { ...currentView.definition, ...patch } }
: currentView
)))
}}
/>
)
}
return {
...render(<ManagedViewNoteList />),
...built,
}
}
function searchNoteList(query: string) {
const searchInput = screen.queryByPlaceholderText('Search notes...')
if (!searchInput) fireEvent.click(screen.getByTitle('Search notes'))
@@ -247,6 +303,124 @@ describe('NoteList rendering', () => {
expect(screen.getByText('Matteo Cellini')).toBeInTheDocument()
})
it('shows no placeholder neighborhood groups when none exist', () => {
const standalone = makeEntry({
path: '/vault/solo.md',
filename: 'solo.md',
title: 'Standalone',
isA: 'Note',
})
renderNoteList({
entries: [standalone],
selection: { kind: 'entity', entry: standalone },
})
expect(screen.queryByRole('button', { name: /Children/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Events/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Referenced By/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Backlinks/i })).not.toBeInTheDocument()
})
it('keeps existing neighborhood groups visible at zero after search filters them out', () => {
const parent = makeEntry({
path: '/vault/parent.md',
filename: 'parent.md',
title: 'Parent',
isA: 'Project',
})
const child = makeEntry({
path: '/vault/child.md',
filename: 'child.md',
title: 'Child Note',
isA: 'Note',
belongsTo: ['[[parent]]'],
})
renderNoteList({
entries: [parent, child],
selection: { kind: 'entity', entry: parent },
})
expect(screen.getByRole('button', { name: /Children\s*1/i })).toBeInTheDocument()
searchNoteList('missing-neighborhood-match')
expect(screen.getByRole('button', { name: /Children\s*0/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Events/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Referenced By/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Backlinks/i })).not.toBeInTheDocument()
expect(screen.queryByText('Child Note')).not.toBeInTheDocument()
})
it('shows the same note in multiple neighborhood groups when relationships overlap', () => {
const parent = makeEntry({
path: '/vault/parent.md',
filename: 'parent.md',
title: 'Parent',
isA: 'Project',
relationships: { 'Related to': ['[[shared-note]]'] },
})
const shared = makeEntry({
path: '/vault/shared-note.md',
filename: 'shared-note.md',
title: 'Shared Note',
isA: 'Note',
relatedTo: ['[[parent]]'],
})
renderNoteList({
entries: [parent, shared],
selection: { kind: 'entity', entry: parent },
})
expect(screen.getByText('Related to')).toBeInTheDocument()
expect(screen.getByText('Referenced By')).toBeInTheDocument()
expect(screen.getAllByText('Shared Note')).toHaveLength(2)
})
it('shows all real inverse relationship groups for custom relationship keys', () => {
const parent = makeEntry({
path: '/vault/parent.md',
filename: 'parent.md',
title: 'Parent',
isA: 'Project',
})
const topicNote = makeEntry({
path: '/vault/topic-note.md',
filename: 'topic-note.md',
title: 'Topic Note',
isA: 'Note',
relationships: { Topics: ['[[parent]]'] },
})
const mentorNote = makeEntry({
path: '/vault/mentor-note.md',
filename: 'mentor-note.md',
title: 'Mentor Note',
isA: 'Note',
relationships: { Mentors: ['[[parent]]'] },
})
const hostEvent = makeEntry({
path: '/vault/host-event.md',
filename: 'host-event.md',
title: 'Host Event',
isA: 'Event',
relationships: { Hosts: ['[[parent]]'] },
})
renderNoteList({
entries: [parent, topicNote, mentorNote, hostEvent],
selection: { kind: 'entity', entry: parent },
})
expect(screen.getByText('← Topics')).toBeInTheDocument()
expect(screen.getByText('← Mentors')).toBeInTheDocument()
expect(screen.getByText('← Hosts')).toBeInTheDocument()
expect(screen.getByText('Topic Note')).toBeInTheDocument()
expect(screen.getByText('Mentor Note')).toBeInTheDocument()
expect(screen.getByText('Host Event')).toBeInTheDocument()
})
it('collapses and expands entity groups', () => {
renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
@@ -258,7 +432,7 @@ describe('NoteList rendering', () => {
expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
})
it('shows the entity snippet in the prominent card', () => {
it('shows the pinned neighborhood note using the standard row content', () => {
renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
expect(screen.getByText('Build a personal knowledge management app.')).toBeInTheDocument()
})
@@ -354,6 +528,44 @@ describe('NoteList rendering', () => {
expect(onUpdateInboxNoteListProperties).toHaveBeenCalledWith(['Priority', 'Owner'])
})
it('opens the view column picker from the global event and applies the saved columns', () => {
renderManagedViewNoteList({
entries: makeBookTypeEntries(['Priority'], { properties: { Priority: 'High', Owner: 'Luca' } }),
})
expect(screen.getByText('High')).toBeInTheDocument()
expect(screen.queryByText('Luca')).not.toBeInTheDocument()
act(() => {
openNoteListPropertiesPicker('view')
})
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
fireEvent.click(screen.getByRole('checkbox', { name: 'Owner' }))
expect(screen.getByText('Luca')).toBeInTheDocument()
})
it('shows an empty-state picker for views with no matching properties', () => {
renderManagedViewNoteList({
entries: makeBookTypeEntries(),
view: makeViewDefinition({
filename: 'empty-view.yml',
definition: {
name: 'Empty View',
filters: { all: [{ field: 'type', op: 'equals', value: 'Project' }] },
},
}),
})
act(() => {
openNoteListPropertiesPicker('view')
})
expect(screen.getByTestId('list-properties-popover')).toBeInTheDocument()
expect(screen.getByText('No properties match this search.')).toBeInTheDocument()
})
it('shows status in the type column picker when at least one note has it set', () => {
renderNoteList({
entries: makeBookTypeEntries([], { status: 'Active' }),
@@ -431,7 +643,7 @@ describe('NoteList rendering', () => {
expect(screen.queryByText('High')).not.toBeInTheDocument()
})
it('Cmd+clicks relationship chips through the note list without triggering the row click', () => {
it('Cmd+clicks relationship chips through the note list without triggering the row click', async () => {
const projectType = makeTypeDefinition('Project')
const taskType = makeTypeDefinition('Task', ['Belongs to'])
const projectEntry = makeEntry({
@@ -450,7 +662,7 @@ describe('NoteList rendering', () => {
createdAt: 1700000001,
})
const { onReplaceActiveTab, onSelectNote } = renderNoteList({
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList({
entries: [projectType, taskType, projectEntry, taskEntry],
selection: { kind: 'sectionGroup', type: 'Task' },
})
@@ -459,57 +671,65 @@ describe('NoteList rendering', () => {
fireEvent.click(chip)
expect(onReplaceActiveTab).not.toHaveBeenCalled()
expect(onSelectNote).not.toHaveBeenCalled()
expect(onEnterNeighborhood).not.toHaveBeenCalled()
fireEvent.click(chip, { metaKey: true })
expect(onSelectNote).toHaveBeenCalledWith(projectEntry)
expect(onReplaceActiveTab).not.toHaveBeenCalled()
await waitFor(() => {
expect(onReplaceActiveTab).toHaveBeenCalledWith(projectEntry)
expect(onEnterNeighborhood).toHaveBeenCalledWith(projectEntry)
})
})
})
describe('NoteList click behavior', () => {
it('opens the current tab on a regular click', () => {
const { onReplaceActiveTab, onSelectNote } = renderNoteList()
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList()
fireEvent.click(screen.getByText('Build Laputa App'))
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
expect(onSelectNote).not.toHaveBeenCalled()
expect(onEnterNeighborhood).not.toHaveBeenCalled()
})
it('opens a new tab on Cmd+Click', () => {
const { onReplaceActiveTab, onSelectNote } = renderNoteList()
it('enters Neighborhood on Cmd+Click', async () => {
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList()
fireEvent.click(screen.getByText('Build Laputa App'), { metaKey: true })
expect(onSelectNote).toHaveBeenCalledWith(mockEntries[0])
expect(onReplaceActiveTab).not.toHaveBeenCalled()
await waitFor(() => {
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
expect(onEnterNeighborhood).toHaveBeenCalledWith(mockEntries[0])
})
})
it('opens a new tab on Ctrl+Click', () => {
const { onReplaceActiveTab, onSelectNote } = renderNoteList()
it('enters Neighborhood on Ctrl+Click', async () => {
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList()
fireEvent.click(screen.getByText('Build Laputa App'), { ctrlKey: true })
expect(onSelectNote).toHaveBeenCalledWith(mockEntries[0])
expect(onReplaceActiveTab).not.toHaveBeenCalled()
await waitFor(() => {
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
expect(onEnterNeighborhood).toHaveBeenCalledWith(mockEntries[0])
})
})
it('supports Cmd+Click on the entity pinned card', () => {
const { onReplaceActiveTab, onSelectNote } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
it('supports Cmd+Click on the entity pinned card', async () => {
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
const titles = screen.getAllByText('Build Laputa App')
fireEvent.click(titles[titles.length - 1], { metaKey: true })
expect(onSelectNote).toHaveBeenCalledWith(mockEntries[0])
expect(onReplaceActiveTab).not.toHaveBeenCalled()
await waitFor(() => {
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
expect(onEnterNeighborhood).toHaveBeenCalledWith(mockEntries[0])
})
})
it('opens the current tab from the entity pinned card on regular click', () => {
const { onReplaceActiveTab, onSelectNote } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
const titles = screen.getAllByText('Build Laputa App')
fireEvent.click(titles[titles.length - 1])
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[0])
expect(onSelectNote).not.toHaveBeenCalled()
expect(onEnterNeighborhood).not.toHaveBeenCalled()
})
it('opens child notes from entity view in the current tab', () => {
const { onReplaceActiveTab, onSelectNote } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
const { onReplaceActiveTab, onEnterNeighborhood } = renderNoteList({ selection: { kind: 'entity', entry: mockEntries[0] } })
fireEvent.click(screen.getByText('Facebook Ads Strategy'))
expect(onReplaceActiveTab).toHaveBeenCalledWith(mockEntries[1])
expect(onSelectNote).not.toHaveBeenCalled()
expect(onEnterNeighborhood).not.toHaveBeenCalled()
})
})

View File

@@ -1,8 +1,11 @@
import { useState } from 'react'
import { beforeEach, describe, expect, it } from 'vitest'
import { fireEvent, screen } from '@testing-library/react'
import { fireEvent, render, screen } from '@testing-library/react'
import { NoteList } from './NoteList'
import { APP_STORAGE_KEYS, LEGACY_APP_STORAGE_KEYS } from '../constants/appStorage'
import { getSortComparator } from '../utils/noteListHelpers'
import { makeEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
import { buildNoteListProps, makeEntry, mockEntries, renderNoteList } from '../test-utils/noteListTestUtils'
import type { ViewFile } from '../types'
describe('getSortComparator', () => {
it('sorts by modified date descending', () => {
@@ -121,6 +124,52 @@ describe('NoteList sort controls', () => {
makeEntry({ path: '/c.md', title: 'Middle', modifiedAt: 2000 }),
]
function makeView(overrides: Partial<ViewFile> = {}): ViewFile {
return {
filename: 'rated-books.yml',
definition: {
name: 'Rated Books',
icon: null,
color: null,
sort: null,
filters: { all: [{ field: 'type', op: 'equals', value: 'Book' }] },
...overrides.definition,
},
...overrides,
}
}
function renderManagedViewSort(entries: typeof zamEntries, view = makeView()) {
const built = buildNoteListProps({
entries,
selection: { kind: 'view', filename: view.filename },
views: [view],
})
function ManagedViewNoteList() {
const [views, setViews] = useState([view])
return (
<NoteList
{...built.props}
views={views}
onUpdateViewDefinition={(filename, patch) => {
setViews((currentViews) => currentViews.map((currentView) => (
currentView.filename === filename
? { ...currentView, definition: { ...currentView.definition, ...patch } }
: currentView
)))
}}
/>
)
}
return {
...render(<ManagedViewNoteList />),
...built,
}
}
function openListSortMenu(entries = mockEntries) {
renderNoteList({ entries })
fireEvent.click(screen.getByTestId('sort-button-__list__'))
@@ -294,4 +343,38 @@ describe('NoteList sort controls', () => {
const titles = screen.getAllByText(/^[ABC]$/).map((element) => element.textContent)
expect(titles).toEqual(['A', 'C', 'B'])
})
it('loads view sort properties from the current view results only', () => {
const entries = [
makeEntry({ path: '/book-a.md', title: 'Book A', isA: 'Book', properties: { Rating: 3 } }),
makeEntry({ path: '/book-b.md', title: 'Book B', isA: 'Book', properties: { Priority: 'High' } }),
makeEntry({ path: '/project-a.md', title: 'Project A', isA: 'Project', properties: { Owner: 'Luca' } }),
]
renderManagedViewSort(entries)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
expect(screen.getByTestId('sort-option-property:Priority')).toBeInTheDocument()
expect(screen.getByTestId('sort-option-property:Rating')).toBeInTheDocument()
expect(screen.queryByTestId('sort-option-property:Owner')).not.toBeInTheDocument()
})
it('supports keyboard selection for view sorting and persists the chosen property', () => {
const entries = [
makeEntry({ path: '/book-a.md', title: 'Book A', isA: 'Book', modifiedAt: 1000, properties: { Rating: 3 } }),
makeEntry({ path: '/book-b.md', title: 'Book B', isA: 'Book', modifiedAt: 3000, properties: { Rating: 1 } }),
makeEntry({ path: '/book-c.md', title: 'Book C', isA: 'Book', modifiedAt: 2000, properties: { Rating: 5 } }),
]
renderManagedViewSort(entries)
fireEvent.click(screen.getByTestId('sort-button-__list__'))
fireEvent.keyDown(screen.getByTestId('sort-menu-__list__'), { key: 'End' })
expect(screen.getByTestId('sort-option-property:Rating')).toHaveFocus()
fireEvent.keyDown(screen.getByTestId('sort-option-property:Rating'), { key: 'Enter' })
const titles = screen.getAllByText(/^Book [ABC]$/).map((element) => element.textContent)
expect(titles).toEqual(['Book B', 'Book A', 'Book C'])
expect(screen.queryByTestId('sort-menu-__list__')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,36 @@
import type { CSSProperties, ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { useDragRegion } from '../hooks/useDragRegion'
interface OnboardingShellProps {
children: ReactNode
className?: string
contentClassName?: string
contentStyle?: CSSProperties
style?: CSSProperties
testId?: string
}
export function OnboardingShell({
children,
className,
contentClassName,
contentStyle,
style,
testId,
}: OnboardingShellProps) {
const { onMouseDown } = useDragRegion()
return (
<div
className={cn('flex h-full w-full items-center justify-center px-6 py-8', className)}
style={style}
data-testid={testId}
onMouseDown={onMouseDown}
>
<div className={contentClassName} style={contentStyle} data-no-drag>
{children}
</div>
</div>
)
}

View File

@@ -1,10 +1,11 @@
import { useState, useCallback, useRef } from 'react'
import { useState, useCallback, useRef, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { ArrowUpRight } from '@phosphor-icons/react'
import type { FrontmatterValue } from './Inspector'
import { EditableValue, TagPillList, UrlValue } from './EditableValue'
import { isUrlValue } from '../utils/url'
import { Calendar } from '@/components/ui/calendar'
import { Input } from '@/components/ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { XIcon } from 'lucide-react'
import { isValidCssColor } from '../utils/colorUtils'
@@ -154,6 +155,77 @@ function BooleanToggle({ value, onToggle }: { value: boolean; onToggle: () => vo
)
}
function NumberValue({
value,
onSave,
onCancel,
isEditing,
onStartEdit,
}: ScalarEditProps) {
const [editValue, setEditValue] = useState(value)
const restoreValue = useCallback(() => {
setEditValue(value)
}, [value])
const commitValue = useCallback(() => {
const trimmed = editValue.trim()
if (trimmed === '') {
onSave('')
return
}
const parsed = Number(trimmed)
if (Number.isFinite(parsed)) {
onSave(trimmed)
return
}
restoreValue()
onCancel()
}, [editValue, onCancel, onSave, restoreValue])
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
commitValue()
return
}
if (event.key === 'Escape') {
restoreValue()
onCancel()
}
}, [commitValue, onCancel, restoreValue])
if (isEditing) {
return (
<Input
className="h-7 w-full border-ring bg-muted px-2 py-1 text-left font-mono text-[12px] tabular-nums"
type="text"
inputMode="decimal"
value={editValue}
onChange={(event) => setEditValue(event.target.value)}
onKeyDown={handleKeyDown}
onBlur={commitValue}
autoFocus
data-testid="number-input"
/>
)
}
return (
<button
type="button"
className="inline-flex h-6 w-full min-w-0 items-center justify-start overflow-hidden rounded-md border-none bg-muted/60 px-2 text-left font-mono text-[12px] tabular-nums text-foreground transition-colors hover:bg-muted"
onClick={onStartEdit}
title={value || 'Click to edit'}
data-testid="number-display"
>
<span className="min-w-0 truncate">{value || '\u2014'}</span>
</button>
)
}
function DateValue({ value, onSave, autoOpen = false, onCancel }: {
value: string
onSave: (newValue: string) => void
@@ -352,48 +424,38 @@ function createScalarEditProps({
}
}
function renderScalarDisplayMode({
propKey,
value,
isEditing,
resolvedMode,
vaultStatuses,
vaultTags,
onSave,
onSaveList,
onStartEdit,
onUpdate,
editProps,
}: SmartCellProps & {
resolvedMode: PropertyDisplayMode
type ScalarRendererProps = SmartCellProps & {
editProps: ScalarEditProps
}) {
switch (resolvedMode) {
case 'status':
return <StatusValue propKey={propKey} value={value ?? ''} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
case 'tags':
return <TagsValue propKey={propKey} value={value ? [String(value)] : []} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
case 'date':
return (
<DateValue
key={`${propKey}:${isEditing ? 'editing' : 'view'}`}
value={String(value ?? '')}
onSave={(v) => onSave(propKey, v)}
autoOpen={isEditing}
onCancel={() => onStartEdit(null)}
/>
)
case 'boolean': {
const boolVal = toBooleanValue(value)
return <BooleanToggle value={boolVal} onToggle={() => onUpdate?.(propKey, !boolVal)} />
}
case 'url':
return <UrlValue {...editProps} />
case 'color':
return <ColorEditableValue {...editProps} />
default:
return <EditableValue {...editProps} />
}
}
const SCALAR_DISPLAY_RENDERERS: Partial<Record<PropertyDisplayMode, (props: ScalarRendererProps) => ReactNode>> = {
status: ({ propKey, value, isEditing, vaultStatuses, onSave, onStartEdit }) => (
<StatusValue propKey={propKey} value={value ?? ''} isEditing={isEditing} vaultStatuses={vaultStatuses} onSave={onSave} onStartEdit={onStartEdit} />
),
tags: ({ propKey, value, isEditing, vaultTags, onSaveList, onStartEdit }) => (
<TagsValue propKey={propKey} value={value ? [String(value)] : []} isEditing={isEditing} vaultTags={vaultTags} onSave={onSaveList} onStartEdit={onStartEdit} />
),
date: ({ propKey, value, isEditing, onSave, onStartEdit }) => (
<DateValue
key={`${propKey}:${isEditing ? 'editing' : 'view'}`}
value={String(value ?? '')}
onSave={(nextValue) => onSave(propKey, nextValue)}
autoOpen={isEditing}
onCancel={() => onStartEdit(null)}
/>
),
number: ({ editProps }) => <NumberValue {...editProps} />,
boolean: ({ propKey, value, onUpdate }) => {
const boolVal = toBooleanValue(value)
return <BooleanToggle value={boolVal} onToggle={() => onUpdate?.(propKey, !boolVal)} />
},
url: ({ editProps }) => <UrlValue {...editProps} />,
color: ({ editProps }) => <ColorEditableValue {...editProps} />,
}
function renderScalarDisplayMode(props: ScalarRendererProps & { resolvedMode: PropertyDisplayMode }) {
const renderer = SCALAR_DISPLAY_RENDERERS[props.resolvedMode]
return renderer ? renderer(props) : <EditableValue {...props.editProps} />
}
function ScalarValueCell(props: SmartCellProps) {

View File

@@ -620,6 +620,96 @@ describe('Sidebar', () => {
expect(screen.getByLabelText('Toggle Topics')).toBeInTheDocument()
})
it('preserves custom section colors in the customize popover', () => {
const entries: VaultEntry[] = [
...mockEntries,
{
path: '/vault/project-type.md',
filename: 'project-type.md',
title: 'Project',
isA: 'Type',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 200,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: 'green',
order: null,
sidebarLabel: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/vault/recipe.md',
filename: 'recipe.md',
title: 'Recipe',
isA: 'Type',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 200,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: 'orange',
order: null,
sidebarLabel: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
{
path: '/vault/recipe/pasta.md',
filename: 'pasta.md',
title: 'Pasta Carbonara',
isA: 'Recipe',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
owner: null,
cadence: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 200,
snippet: '',
wordCount: 0,
relationships: {},
icon: null,
color: null,
order: null,
sidebarLabel: null,
template: null, sort: null,
outgoingLinks: [],
properties: {},
},
]
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
fireEvent.click(screen.getByTitle('Customize sections'))
expect(screen.getByLabelText('Toggle Projects').querySelector('svg')).toHaveStyle({ color: 'var(--accent-green)' })
expect(screen.getByLabelText('Toggle Recipes').querySelector('svg')).toHaveStyle({ color: 'var(--accent-orange)' })
})
it('calls onToggleTypeVisibility when toggling a section in the popover', () => {
const onToggleTypeVisibility = vi.fn()
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onToggleTypeVisibility={onToggleTypeVisibility} />)
@@ -1091,7 +1181,7 @@ describe('Sidebar', () => {
it('aligns the favorites header count pill with the shared sidebar count column', () => {
render(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={() => {}} />)
const favoritesHeader = screen.getByText('FAVORITES').closest('button') as HTMLElement
const favoritesHeader = screen.getByText('FAVORITES').closest('div') as HTMLElement
const countChip = within(favoritesHeader).getByTestId('sidebar-count-chip')
expect(favoritesHeader).toHaveStyle({ padding: '8px 8px 8px 16px' })

View File

@@ -19,6 +19,13 @@ export interface SectionGroup {
customColor?: string | null
}
function resolveSectionColors(type: string, customColor?: string | null) {
return {
sectionColor: getTypeColor(type, customColor),
sectionLightColor: getTypeLightColor(type, customColor),
}
}
// eslint-disable-next-line react-refresh/only-export-components -- utility co-located with component
export function isSelectionActive(current: SidebarSelection, check: SidebarSelection): boolean {
if (current.kind !== check.kind) return false
@@ -43,6 +50,33 @@ function getNavItemPadding(compact: boolean | undefined, hasCount: boolean) {
return hasCount ? SIDEBAR_ITEM_PADDING.withCount : SIDEBAR_ITEM_PADDING.regular
}
function getNavItemIconSize(compact?: boolean) {
return compact ? 14 : 16
}
function getNavItemTextClass(compact?: boolean) {
return compact ? 'text-[12px]' : 'text-[13px]'
}
function resolveBadgeClassName(
isActive: boolean | undefined,
activeBadgeClassName: string | undefined,
badgeClassName: string | undefined,
) {
if (isActive && activeBadgeClassName) return activeBadgeClassName
return badgeClassName
}
function resolveBadgeStyle(
isActive: boolean | undefined,
activeBadgeClassName: string | undefined,
activeBadgeStyle: React.CSSProperties | undefined,
badgeStyle: React.CSSProperties | undefined,
) {
if (isActive && activeBadgeClassName) return activeBadgeStyle
return badgeStyle
}
function SidebarNavIcon({
Icon,
emoji,
@@ -82,6 +116,102 @@ export function SidebarCountPill({
)
}
function NavItemLabel({ label, compact }: { label: string; compact?: boolean }) {
return <span className={cn("flex-1 font-medium", getNavItemTextClass(compact))}>{label}</span>
}
function NavItemCount({
count,
className,
style,
compact,
}: {
count?: number
className?: string
style?: React.CSSProperties
compact?: boolean
}) {
if (!hasSidebarCount(count)) return null
return (
<SidebarCountPill
count={count}
className={className}
style={style}
compact={compact}
/>
)
}
function DisabledNavItem({
Icon,
emoji,
label,
compact,
disabledTooltip,
padding,
}: {
Icon: ComponentType<IconProps>
emoji?: string | null
label: string
compact?: boolean
disabledTooltip?: string
padding: ReturnType<typeof getNavItemPadding>
}) {
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"}>
<SidebarNavIcon Icon={Icon} emoji={emoji} iconSize={getNavItemIconSize(compact)} />
<NavItemLabel label={label} compact={compact} />
</div>
)
}
function ClickableNavItem({
Icon,
emoji,
label,
count,
isActive,
activeClassName,
badgeClassName,
badgeStyle,
activeBadgeClassName,
activeBadgeStyle,
onClick,
compact,
padding,
}: {
Icon: ComponentType<IconProps>
emoji?: string | null
label: string
count?: number
isActive?: boolean
activeClassName: string
badgeClassName?: string
badgeStyle?: React.CSSProperties
activeBadgeClassName?: string
activeBadgeStyle?: React.CSSProperties
onClick?: () => void
compact?: boolean
padding: ReturnType<typeof getNavItemPadding>
}) {
return (
<div
className={cn("flex cursor-pointer select-none items-center gap-2 rounded transition-colors", isActive ? activeClassName : "text-foreground hover:bg-accent")}
style={{ padding, borderRadius: 4 }}
onClick={onClick}
>
<SidebarNavIcon Icon={Icon} emoji={emoji} iconSize={getNavItemIconSize(compact)} isActive={isActive} />
<NavItemLabel label={label} compact={compact} />
<NavItemCount
count={count}
className={resolveBadgeClassName(isActive, activeBadgeClassName, badgeClassName)}
style={resolveBadgeStyle(isActive, activeBadgeClassName, activeBadgeStyle, badgeStyle)}
compact={compact}
/>
</div>
)
}
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
@@ -98,39 +228,36 @@ export function NavItem({ icon: Icon, emoji, label, count, isActive, activeClass
disabledTooltip?: string
compact?: boolean
}) {
const iconSize = compact ? 14 : 16
const textClass = compact ? 'text-[12px]' : 'text-[13px]'
const showCount = hasSidebarCount(count)
const padding = getNavItemPadding(compact, showCount)
const resolvedBadgeClass = isActive && activeBadgeClassName ? activeBadgeClassName : badgeClassName
const resolvedBadgeStyle = isActive && activeBadgeClassName ? activeBadgeStyle : badgeStyle
const iconEl = <SidebarNavIcon Icon={Icon} emoji={emoji} iconSize={iconSize} isActive={isActive} />
const padding = getNavItemPadding(compact, hasSidebarCount(count))
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"}>
{iconEl}
<span className={cn("flex-1 font-medium", textClass)}>{label}</span>
</div>
<DisabledNavItem
Icon={Icon}
emoji={emoji}
label={label}
compact={compact}
disabledTooltip={disabledTooltip}
padding={padding}
/>
)
}
return (
<div
className={cn("flex cursor-pointer select-none items-center gap-2 rounded transition-colors", isActive ? activeClassName : "text-foreground hover:bg-accent")}
style={{ padding, borderRadius: 4 }}
<ClickableNavItem
Icon={Icon}
emoji={emoji}
label={label}
count={count}
isActive={isActive}
activeClassName={activeClassName}
badgeClassName={badgeClassName}
badgeStyle={badgeStyle}
activeBadgeClassName={activeBadgeClassName}
activeBadgeStyle={activeBadgeStyle}
onClick={onClick}
>
{iconEl}
<span className={cn("flex-1 font-medium", textClass)}>{label}</span>
{showCount && (
<SidebarCountPill
count={count}
className={resolvedBadgeClass}
style={resolvedBadgeStyle}
compact={compact}
/>
)}
</div>
compact={compact}
padding={padding}
/>
)
}
@@ -155,8 +282,7 @@ export function SectionContent({
isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel,
}: SectionContentProps) {
const { label, type, Icon, customColor } = group
const sectionColor = getTypeColor(type, customColor)
const sectionLightColor = getTypeLightColor(type, customColor)
const { sectionColor, sectionLightColor } = resolveSectionColors(type, customColor)
return (
<SectionHeader
@@ -206,6 +332,85 @@ function InlineRenameInput({ initialValue, onSubmit, onCancel }: {
)
}
function getSectionHeaderBackground(isActive: boolean, sectionLightColor: string) {
if (!isActive) return undefined
return { background: sectionLightColor }
}
function getSectionHeaderIconWeight(isActive: boolean): IconProps['weight'] {
return isActive ? 'fill' : 'regular'
}
function getSectionHeaderTitleColor(isActive: boolean, sectionColor: string) {
if (!isActive) return undefined
return sectionColor
}
function getSectionSelectHandler(isRenaming: boolean | undefined, onSelect: () => void) {
if (isRenaming) return undefined
return onSelect
}
function getSectionContextMenuHandler(
isRenaming: boolean | undefined,
onContextMenu: (e: React.MouseEvent) => void,
) {
if (isRenaming) return undefined
return onContextMenu
}
function SectionHeaderLabel({
type,
label,
isActive,
sectionColor,
isRenaming,
renameInitialValue,
onRenameSubmit,
onRenameCancel,
}: {
type: string
label: string
isActive: boolean
sectionColor: string
isRenaming?: boolean
renameInitialValue?: string
onRenameSubmit?: (value: string) => void
onRenameCancel?: () => void
}) {
if (isRenaming && onRenameSubmit && onRenameCancel) {
return (
<InlineRenameInput
key={`rename-${type}`}
initialValue={renameInitialValue ?? label}
onSubmit={onRenameSubmit}
onCancel={onRenameCancel}
/>
)
}
return <span className="text-[13px] font-medium" style={{ marginLeft: 4, color: getSectionHeaderTitleColor(isActive, sectionColor) }}>{label}</span>
}
function SectionHeaderCountPill({
itemCount,
isActive,
sectionColor,
}: {
itemCount: number
isActive: boolean
sectionColor: string
}) {
if (itemCount <= 0) return null
return (
<SidebarCountPill
count={itemCount}
className={!isActive ? 'text-muted-foreground' : undefined}
style={isActive ? { background: sectionColor, color: 'white' } : { background: 'var(--muted)' }}
/>
)
}
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; sectionLightColor: string; itemCount: number; isActive: boolean
@@ -217,35 +422,56 @@ function SectionHeader({ label, type, Icon, sectionColor, sectionLightColor, ite
return (
<div
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 } : {}) }}
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4, ...getSectionHeaderBackground(isActive, sectionLightColor) }}
{...dragHandleProps}
onClick={() => { if (!isRenaming) onSelect() }}
onContextMenu={isRenaming ? undefined : onContextMenu}
onClick={getSectionSelectHandler(isRenaming, onSelect)}
onContextMenu={getSectionContextMenuHandler(isRenaming, onContextMenu)}
>
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
<Icon size={16} weight={isActive ? 'fill' : 'regular'} style={{ color: sectionColor, flexShrink: 0 }} />
{isRenaming && onRenameSubmit && onRenameCancel ? (
<InlineRenameInput
key={`rename-${type}`}
initialValue={renameInitialValue ?? label}
onSubmit={onRenameSubmit}
onCancel={onRenameCancel}
/>
) : (
<span className="text-[13px] font-medium" style={{ marginLeft: 4, color: isActive ? sectionColor : undefined }}>{label}</span>
)}
</div>
{itemCount > 0 && (
<SidebarCountPill
count={itemCount}
className={!isActive ? 'text-muted-foreground' : undefined}
style={isActive ? { background: sectionColor, color: 'white' } : { background: 'var(--muted)' }}
<Icon size={16} weight={getSectionHeaderIconWeight(isActive)} style={{ color: sectionColor, flexShrink: 0 }} />
<SectionHeaderLabel
type={type}
label={label}
isActive={isActive}
sectionColor={sectionColor}
isRenaming={isRenaming}
renameInitialValue={renameInitialValue}
onRenameSubmit={onRenameSubmit}
onRenameCancel={onRenameCancel}
/>
)}
</div>
<SectionHeaderCountPill itemCount={itemCount} isActive={isActive} sectionColor={sectionColor} />
</div>
)
}
function VisibilityPopoverItem({
group,
isVisible,
onToggle,
}: {
group: SectionGroup
isVisible: boolean
onToggle: (type: string) => void
}) {
const { label, type, Icon, customColor } = group
const { sectionColor } = resolveSectionColors(type, customColor)
return (
<button
type="button"
className="flex w-full cursor-pointer items-center border-none bg-transparent transition-colors hover:bg-accent"
style={{ padding: '6px 12px', gap: 8 }}
onClick={() => onToggle(type)}
aria-label={`Toggle ${label}`}
>
<Icon size={14} style={{ color: sectionColor }} />
<span className="flex-1 text-left text-[13px] text-foreground">{label}</span>
<ToggleSwitch on={isVisible} />
</button>
)
}
// --- Visibility Popover ---
export function VisibilityPopover({ sections, isSectionVisible, onToggle }: {
@@ -259,12 +485,13 @@ export function VisibilityPopover({ sections, isSectionVisible, onToggle }: {
style={{ position: 'absolute', top: '100%', left: 6, right: 6, zIndex: 50, borderRadius: 8, padding: '8px 0', boxShadow: '0 4px 12px rgba(0,0,0,0.12)' }}
>
<div className="text-[12px] font-semibold text-muted-foreground" style={{ padding: '0 12px 4px' }}>Show in sidebar</div>
{sections.map(({ label, type, Icon }) => (
<button key={type} className="flex w-full cursor-pointer items-center border-none bg-transparent transition-colors hover:bg-accent" style={{ padding: '6px 12px', gap: 8 }} onClick={() => onToggle(type)} aria-label={`Toggle ${label}`}>
<Icon size={14} style={{ color: getTypeColor(type) }} />
<span className="flex-1 text-left text-[13px] text-foreground">{label}</span>
<ToggleSwitch on={isSectionVisible(type)} />
</button>
{sections.map((group) => (
<VisibilityPopoverItem
key={group.type}
group={group}
isVisible={isSectionVisible(group.type)}
onToggle={onToggle}
/>
))}
</div>
)

View File

@@ -1,8 +1,237 @@
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { cn } from '@/lib/utils'
import { ArrowUp, ArrowDown } from '@phosphor-icons/react'
import { type SortOption, type SortDirection, getDefaultDirection, SORT_OPTIONS, getSortOptionLabel } from '../utils/noteListHelpers'
interface SortItem {
value: SortOption
label: string
}
type SortMenuAction =
| { type: 'close' }
| { type: 'focus'; index: number }
function buildSortItems(customProperties?: string[]): SortItem[] {
const builtInItems = SORT_OPTIONS.map(({ value, label }) => ({ value, label }))
const customItems = (customProperties ?? []).map((key) => ({
value: `property:${key}` as SortOption,
label: key,
}))
return [...builtInItems, ...customItems]
}
function resolveFocusedIndex(groupLabel: string, current: SortOption, sortItems: SortItem[]) {
const activeElement = document.activeElement as HTMLElement | null
const activeIndex = Number(activeElement?.dataset.sortItemIndex ?? -1)
if (activeElement?.dataset.sortGroupLabel === groupLabel && activeIndex >= 0) return activeIndex
const currentIndex = sortItems.findIndex((item) => item.value === current)
return currentIndex >= 0 ? currentIndex : 0
}
function focusSortItem(sortButtonRefs: React.MutableRefObject<Array<HTMLButtonElement | null>>, index: number) {
sortButtonRefs.current[index]?.focus()
}
function resolveSortMenuAction(key: string, focusIndex: number, itemCount: number): SortMenuAction | null {
const lastIndex = itemCount - 1
switch (key) {
case 'Escape':
return { type: 'close' }
case 'ArrowDown':
return { type: 'focus', index: Math.min(lastIndex, focusIndex + 1) }
case 'ArrowUp':
return { type: 'focus', index: Math.max(0, focusIndex - 1) }
case 'Home':
return { type: 'focus', index: 0 }
case 'End':
return { type: 'focus', index: lastIndex }
default:
return null
}
}
function selectOnKeyboard(
event: React.KeyboardEvent<HTMLButtonElement>,
value: SortOption,
direction: SortDirection,
onSelect: (opt: SortOption, dir: SortDirection) => void,
) {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
onSelect(value, direction)
}
function getDirectionButtonClass(isActive: boolean, activeDirection: SortDirection, buttonDirection: SortDirection) {
return cn(
'flex items-center rounded p-0.5 hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive && activeDirection === buttonDirection ? 'text-foreground' : 'text-muted-foreground opacity-40',
)
}
function useSortDropdownState({
groupLabel,
current,
sortItems,
onChange,
}: {
groupLabel: string
current: SortOption
sortItems: SortItem[]
onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
}) {
const [open, setOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const sortButtonRefs = useRef<Array<HTMLButtonElement | null>>([])
useEffect(() => {
if (!open) return
function handlePointerDown(event: MouseEvent) {
if (containerRef.current?.contains(event.target as Node)) return
setOpen(false)
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [open])
useEffect(() => {
if (!open) return
focusSortItem(sortButtonRefs, resolveFocusedIndex(groupLabel, current, sortItems))
}, [current, groupLabel, open, sortItems])
const closeMenu = useCallback(() => {
setOpen(false)
triggerRef.current?.focus()
}, [])
const handleSelect = useCallback((option: SortOption, nextDirection: SortDirection) => {
onChange(groupLabel, option, nextDirection)
closeMenu()
}, [closeMenu, groupLabel, onChange])
const handleMenuKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
const action = resolveSortMenuAction(
event.key,
resolveFocusedIndex(groupLabel, current, sortItems),
sortItems.length,
)
if (!action) return
event.preventDefault()
if (action.type === 'close') {
closeMenu()
return
}
focusSortItem(sortButtonRefs, action.index)
}, [closeMenu, current, groupLabel, sortItems])
return {
open,
setOpen,
containerRef,
triggerRef,
sortButtonRefs,
handleSelect,
handleMenuKeyDown,
}
}
function SortDropdownTrigger({
triggerRef,
open,
current,
groupLabel,
direction,
onToggle,
}: {
triggerRef: React.RefObject<HTMLButtonElement | null>
open: boolean
current: SortOption
groupLabel: string
direction: SortDirection
onToggle: () => void
}) {
const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown
return (
<button
ref={triggerRef}
type="button"
className={cn('flex items-center gap-0.5 rounded px-1 py-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground', open && 'bg-accent text-foreground')}
onClick={(event) => {
event.stopPropagation()
onToggle()
}}
title={`Sort by ${getSortOptionLabel(current)}`}
aria-haspopup="menu"
aria-expanded={open}
data-testid={`sort-button-${groupLabel}`}
>
<DirectionIcon size={12} data-testid={`sort-direction-icon-${groupLabel}`} />
<span className="text-[10px] font-medium">{getSortOptionLabel(current)}</span>
</button>
)
}
function SortDropdownMenu({
open,
groupLabel,
current,
direction,
sortItems,
sortButtonRefs,
onKeyDown,
onSelect,
}: {
open: boolean
groupLabel: string
current: SortOption
direction: SortDirection
sortItems: SortItem[]
sortButtonRefs: React.MutableRefObject<Array<HTMLButtonElement | null>>
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onSelect: (option: SortOption, nextDirection: SortDirection) => void
}) {
if (!open) return null
const hasCustom = sortItems.length > SORT_OPTIONS.length
const builtInOptionCount = SORT_OPTIONS.length
return (
<div
role="menu"
aria-label={`Sort ${groupLabel}`}
className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover p-1 shadow-md"
style={{ width: 170, maxHeight: 280, overflowY: 'auto' }}
onKeyDown={onKeyDown}
data-testid={`sort-menu-${groupLabel}`}
>
{sortItems.map((item, index) => (
<SortRow
key={item.value}
index={index}
groupLabel={groupLabel}
value={item.value}
label={item.label}
current={current}
direction={direction}
buttonRef={(node) => {
sortButtonRefs.current[index] = node
}}
showSeparator={hasCustom && index === builtInOptionCount}
onSelect={onSelect}
/>
))}
</div>
)
}
export function SortDropdown({ groupLabel, current, direction, customProperties, onChange }: {
groupLabel: string
current: SortOption
@@ -10,99 +239,142 @@ export function SortDropdown({ groupLabel, current, direction, customProperties,
customProperties?: string[]
onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
const handleSelect = (opt: SortOption, dir: SortDirection) => {
onChange(groupLabel, opt, dir)
setOpen(false)
}
const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown
const hasCustom = customProperties && customProperties.length > 0
const sortItems = useMemo(() => buildSortItems(customProperties), [customProperties])
const {
open,
setOpen,
containerRef,
triggerRef,
sortButtonRefs,
handleSelect,
handleMenuKeyDown,
} = useSortDropdownState({
groupLabel,
current,
sortItems,
onChange,
})
return (
<div ref={ref} className="relative" style={{ zIndex: open ? 10 : 0 }}>
<button
className={cn("flex items-center gap-0.5 rounded px-1 py-0.5 text-muted-foreground transition-colors hover:text-foreground hover:bg-accent", open && "bg-accent text-foreground")}
onClick={(e) => { e.stopPropagation(); setOpen(!open) }}
title={`Sort by ${getSortOptionLabel(current)}`}
data-testid={`sort-button-${groupLabel}`}
>
<DirectionIcon size={12} data-testid={`sort-direction-icon-${groupLabel}`} />
<span className="text-[10px] font-medium">{getSortOptionLabel(current)}</span>
</button>
{open && (
<div
className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md"
style={{ width: 170, padding: 4, maxHeight: 280, overflowY: 'auto' }}
data-testid={`sort-menu-${groupLabel}`}
>
{SORT_OPTIONS.map((opt) => (
<SortRow key={opt.value} value={opt.value} label={opt.label} current={current} direction={direction} onSelect={handleSelect} />
))}
{hasCustom && (
<>
<div className="mx-2 my-1 border-t border-border" data-testid="sort-separator" />
{customProperties.map((key) => {
const value: SortOption = `property:${key}`
return <SortRow key={value} value={value} label={key} current={current} direction={direction} onSelect={handleSelect} />
})}
</>
)}
</div>
)}
<div ref={containerRef} className="relative" style={{ zIndex: open ? 10 : 0 }}>
<SortDropdownTrigger
triggerRef={triggerRef}
open={open}
current={current}
groupLabel={groupLabel}
direction={direction}
onToggle={() => setOpen((value) => !value)}
/>
<SortDropdownMenu
open={open}
groupLabel={groupLabel}
current={current}
direction={direction}
sortItems={sortItems}
sortButtonRefs={sortButtonRefs}
onKeyDown={handleMenuKeyDown}
onSelect={handleSelect}
/>
</div>
)
}
function SortRow({ value, label, current, direction, onSelect }: {
function SortRow({ index, groupLabel, value, label, current, direction, buttonRef, showSeparator, onSelect }: {
index: number
groupLabel: string
value: SortOption
label: string
current: SortOption
direction: SortDirection
buttonRef: (node: HTMLButtonElement | null) => void
showSeparator: boolean
onSelect: (opt: SortOption, dir: SortDirection) => void
}) {
const isActive = value === current
const defaultDirection = isActive ? direction : getDefaultDirection(value)
const itemData = {
'data-sort-group-label': groupLabel,
'data-sort-item-index': String(index),
}
return (
<div
className={cn("flex w-full items-center justify-between rounded px-2 text-[12px] text-popover-foreground hover:bg-accent", isActive && "bg-accent font-medium")}
style={{ height: 28, cursor: 'pointer', background: isActive ? 'var(--accent)' : 'transparent' }}
data-testid={`sort-option-${value}`}
onClick={(e) => { e.stopPropagation(); onSelect(value, isActive ? direction : getDefaultDirection(value)) }}
>
<span className="flex flex-1 items-center gap-1.5 text-inherit truncate">
{label}
</span>
<span className="flex items-center gap-0.5 ml-1 shrink-0">
<>
{showSeparator && <div className="mx-2 my-1 border-t border-border" data-testid="sort-separator" />}
<div
className={cn('flex items-center justify-between gap-1 rounded px-1 text-[12px] text-popover-foreground hover:bg-accent', isActive && 'bg-accent font-medium')}
style={{ minHeight: 28 }}
>
<button
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'asc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
style={{ padding: 2 }}
onClick={(e) => { e.stopPropagation(); onSelect(value, 'asc') }}
data-testid={`sort-dir-asc-${value}`}
title="Ascending"
ref={buttonRef}
type="button"
role="menuitemradio"
aria-checked={isActive}
className="flex min-w-0 flex-1 items-center gap-1.5 rounded px-1 py-1 text-left text-inherit hover:bg-background/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={(event) => {
event.stopPropagation()
onSelect(value, defaultDirection)
}}
onKeyDown={(event) => selectOnKeyboard(event, value, defaultDirection, onSelect)}
data-testid={`sort-option-${value}`}
{...itemData}
>
<ArrowUp size={12} />
<span className="truncate">{label}</span>
</button>
<button
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'desc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
style={{ padding: 2 }}
onClick={(e) => { e.stopPropagation(); onSelect(value, 'desc') }}
data-testid={`sort-dir-desc-${value}`}
title="Descending"
>
<ArrowDown size={12} />
</button>
</span>
</div>
<span className="flex items-center gap-0.5">
<SortDirectionButton
value={value}
direction="asc"
activeDirection={direction}
isActive={isActive}
onSelect={onSelect}
icon={<ArrowUp size={12} />}
itemData={itemData}
/>
<SortDirectionButton
value={value}
direction="desc"
activeDirection={direction}
isActive={isActive}
onSelect={onSelect}
icon={<ArrowDown size={12} />}
itemData={itemData}
/>
</span>
</div>
</>
)
}
function SortDirectionButton({
value,
direction,
activeDirection,
isActive,
onSelect,
icon,
itemData,
}: {
value: SortOption
direction: SortDirection
activeDirection: SortDirection
isActive: boolean
onSelect: (opt: SortOption, dir: SortDirection) => void
icon: React.ReactNode
itemData: Record<string, string>
}) {
return (
<button
type="button"
className={getDirectionButtonClass(isActive, activeDirection, direction)}
onClick={(event) => {
event.stopPropagation()
onSelect(value, direction)
}}
data-testid={`sort-dir-${direction}-${value}`}
title={direction === 'asc' ? 'Ascending' : 'Descending'}
{...itemData}
>
{icon}
</button>
)
}

View File

@@ -154,6 +154,24 @@ describe('StatusBar', () => {
expect(onOpenLocalFolder).toHaveBeenCalledOnce()
})
it('shows "Create empty vault" option in vault menu', () => {
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCreateEmptyVault={vi.fn()} />
)
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
expect(screen.getByText('Create empty vault')).toBeInTheDocument()
})
it('calls onCreateEmptyVault when clicking "Create empty vault"', () => {
const onCreateEmptyVault = vi.fn()
render(
<StatusBar noteCount={100} vaultPath="/Users/luca/Laputa" vaults={vaults} onSwitchVault={vi.fn()} onCreateEmptyVault={onCreateEmptyVault} />
)
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
fireEvent.click(screen.getByText('Create empty vault'))
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
it('shows add-vault options in vault menu', () => {
render(
<StatusBar
@@ -161,11 +179,13 @@ describe('StatusBar', () => {
vaultPath="/Users/luca/Laputa"
vaults={vaults}
onSwitchVault={vi.fn()}
onCreateEmptyVault={vi.fn()}
onOpenLocalFolder={vi.fn()}
onCloneVault={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('button', { name: 'Switch vault' }))
expect(screen.getByText('Create empty vault')).toBeInTheDocument()
expect(screen.getByText('Open local folder')).toBeInTheDocument()
expect(screen.getByText('Clone Git repo')).toBeInTheDocument()
})

View File

@@ -21,6 +21,7 @@ interface StatusBarProps {
onSwitchVault: (path: string) => void
onOpenSettings?: () => void
onOpenLocalFolder?: () => void
onCreateEmptyVault?: () => void
onCloneVault?: () => void
onCloneGettingStarted?: () => void
onClickPending?: () => void
@@ -60,6 +61,7 @@ export function StatusBar({
onSwitchVault,
onOpenSettings,
onOpenLocalFolder,
onCreateEmptyVault,
onCloneVault,
onCloneGettingStarted,
onClickPending,
@@ -121,6 +123,7 @@ export function StatusBar({
vaults={vaults}
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onCreateEmptyVault={onCreateEmptyVault}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onClickPending={onClickPending}

View File

@@ -2,6 +2,12 @@ import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { TelemetryConsentDialog } from './TelemetryConsentDialog'
const dragRegionMouseDown = vi.fn()
vi.mock('../hooks/useDragRegion', () => ({
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
}))
describe('TelemetryConsentDialog', () => {
it('renders the consent dialog', () => {
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
@@ -32,4 +38,14 @@ describe('TelemetryConsentDialog', () => {
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
expect(screen.getByTestId('telemetry-decline')).toHaveFocus()
})
it('uses the surrounding surface as a drag region and excludes the dialog card', () => {
render(<TelemetryConsentDialog onAccept={vi.fn()} onDecline={vi.fn()} />)
const shell = screen.getByTestId('telemetry-consent-shell')
fireEvent.mouseDown(shell)
expect(dragRegionMouseDown).toHaveBeenCalledOnce()
expect(shell.querySelector('[data-no-drag]')).not.toBeNull()
})
})

View File

@@ -1,4 +1,5 @@
import { ShieldCheck } from '@phosphor-icons/react'
import { OnboardingShell } from './OnboardingShell'
interface TelemetryConsentDialogProps {
onAccept: () => void
@@ -7,14 +8,21 @@ interface TelemetryConsentDialogProps {
export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsentDialogProps) {
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
<OnboardingShell
className="fixed inset-0 z-50"
contentClassName="w-full rounded-lg border border-border bg-background shadow-xl"
style={{ background: 'rgba(0,0,0,0.4)' }}
contentStyle={{
width: 'min(440px, 100%)',
padding: 32,
display: 'flex',
flexDirection: 'column',
gap: 20,
alignItems: 'center',
}}
testId="telemetry-consent-shell"
>
<div
className="bg-background border border-border rounded-lg shadow-xl"
style={{ width: 440, padding: 32, display: 'flex', flexDirection: 'column', gap: 20, alignItems: 'center' }}
>
<>
<ShieldCheck size={40} weight="duotone" style={{ color: 'var(--primary)' }} />
<div style={{ textAlign: 'center' }}>
@@ -63,7 +71,7 @@ export function TelemetryConsentDialog({ onAccept, onDecline }: TelemetryConsent
<p style={{ fontSize: 11, color: 'var(--muted-foreground)', margin: 0, textAlign: 'center' }}>
You can change this anytime in Settings.
</p>
</div>
</div>
</>
</OnboardingShell>
)
}

View File

@@ -1,13 +1,19 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { beforeEach, describe, it, expect, vi } from 'vitest'
import { WelcomeScreen } from './WelcomeScreen'
const dragRegionMouseDown = vi.fn()
vi.mock('../hooks/useDragRegion', () => ({
useDragRegion: () => ({ onMouseDown: dragRegionMouseDown }),
}))
const defaultProps = {
mode: 'welcome' as const,
defaultVaultPath: '~/Documents/Laputa',
onCreateVault: vi.fn(),
onRetryCreateVault: vi.fn(),
onCreateNewVault: vi.fn(),
onCreateEmptyVault: vi.fn(),
onOpenFolder: vi.fn(),
isOffline: false,
creatingAction: null as 'template' | 'empty' | null,
@@ -16,6 +22,10 @@ const defaultProps = {
}
describe('WelcomeScreen', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('welcome mode', () => {
it('renders welcome title and subtitle', () => {
render(<WelcomeScreen {...defaultProps} />)
@@ -25,7 +35,7 @@ describe('WelcomeScreen', () => {
it('shows all three option buttons', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-create-new')).toHaveTextContent('Create a new vault')
expect(screen.getByTestId('welcome-create-new')).toHaveTextContent('Create empty vault')
expect(screen.getByTestId('welcome-open-folder')).toHaveTextContent('Open existing vault')
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent('Get started with a template')
})
@@ -35,9 +45,10 @@ describe('WelcomeScreen', () => {
expect(screen.getByTestId('welcome-create-new')).toHaveFocus()
})
it('shows default vault path in template option description', () => {
it('shows the simplified template option description', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByText(/~\/Documents\/Laputa/)).toBeInTheDocument()
expect(screen.getByText('Download the getting started vault')).toBeInTheDocument()
expect(screen.queryByText(/~\/Documents\/Laputa/)).not.toBeInTheDocument()
})
it('shows offline guidance and disables the template option when offline', () => {
@@ -46,11 +57,33 @@ describe('WelcomeScreen', () => {
expect(screen.getByText(/Requires internet — clone later/)).toBeInTheDocument()
})
it('calls onCreateNewVault when create new button is clicked', () => {
const onCreateNewVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateNewVault={onCreateNewVault} />)
it('calls onCreateEmptyVault when create empty button is clicked', () => {
const onCreateEmptyVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateEmptyVault={onCreateEmptyVault} />)
fireEvent.click(screen.getByTestId('welcome-create-new'))
expect(onCreateNewVault).toHaveBeenCalledOnce()
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
it('calls onCreateEmptyVault when create empty button is activated with Enter', () => {
const onCreateEmptyVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateEmptyVault={onCreateEmptyVault} />)
const button = screen.getByTestId('welcome-create-new')
button.focus()
fireEvent.keyDown(button, { key: 'Enter' })
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
it('calls onCreateEmptyVault when create empty button is activated with Space', () => {
const onCreateEmptyVault = vi.fn()
render(<WelcomeScreen {...defaultProps} onCreateEmptyVault={onCreateEmptyVault} />)
const button = screen.getByTestId('welcome-create-new')
button.focus()
fireEvent.keyDown(button, { key: ' ' })
expect(onCreateEmptyVault).toHaveBeenCalledOnce()
})
it('calls onCreateVault when template button is clicked', () => {
@@ -67,6 +100,16 @@ describe('WelcomeScreen', () => {
expect(onOpenFolder).toHaveBeenCalledOnce()
})
it('cycles onboarding actions with Tab and activates the selected action with Enter', () => {
const onOpenFolder = vi.fn()
render(<WelcomeScreen {...defaultProps} onOpenFolder={onOpenFolder} />)
fireEvent.keyDown(window, { key: 'Tab' })
fireEvent.keyDown(window, { key: 'Enter' })
expect(onOpenFolder).toHaveBeenCalledOnce()
})
it('disables all buttons while creating', () => {
render(<WelcomeScreen {...defaultProps} creatingAction="template" />)
expect(screen.getByTestId('welcome-create-new')).toBeDisabled()
@@ -146,5 +189,15 @@ describe('WelcomeScreen', () => {
render(<WelcomeScreen {...defaultProps} />)
expect(screen.getByTestId('welcome-screen')).toBeInTheDocument()
})
it('uses the surrounding surface as a drag region and excludes the card', () => {
render(<WelcomeScreen {...defaultProps} />)
const screenContainer = screen.getByTestId('welcome-screen')
fireEvent.mouseDown(screenContainer)
expect(dragRegionMouseDown).toHaveBeenCalledOnce()
expect(screenContainer.querySelector('[data-no-drag]')).not.toBeNull()
})
})
})

View File

@@ -1,6 +1,8 @@
import { useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { FolderOpen, Plus, AlertTriangle, Loader2, Rocket } from 'lucide-react'
import { OnboardingShell } from './OnboardingShell'
import { Button } from '@/components/ui/button'
interface WelcomeScreenProps {
mode: 'welcome' | 'vault-missing'
@@ -8,7 +10,7 @@ interface WelcomeScreenProps {
defaultVaultPath: string
onCreateVault: () => void
onRetryCreateVault: () => void
onCreateNewVault: () => void
onCreateEmptyVault: () => void
onOpenFolder: () => void
isOffline: boolean
creatingAction: 'template' | 'empty' | null
@@ -25,17 +27,65 @@ interface WelcomeScreenPresentation {
title: string
}
const CONTAINER_STYLE: React.CSSProperties = {
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--sidebar)',
type WelcomeActionButtonRef = React.RefObject<HTMLButtonElement | null>
interface WelcomeAction {
disabled: boolean
run: () => void
}
function isWelcomeActivationKey(event: globalThis.KeyboardEvent): boolean {
return event.key === 'Enter' || event.key === ' '
}
function isWelcomeNavigationKey(event: globalThis.KeyboardEvent): boolean {
return event.key === 'Tab' || event.key === 'ArrowDown' || event.key === 'ArrowUp'
}
function nextWelcomeActionIndex(
currentIndex: number,
event: globalThis.KeyboardEvent,
actionCount: number,
): number {
const direction = event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey) ? -1 : 1
return (currentIndex + direction + actionCount) % actionCount
}
function focusBelongsToWelcomeActions(
activeElement: Element | null,
actionButtonRefs: WelcomeActionButtonRef[],
): boolean {
return activeElement === document.body
|| actionButtonRefs.some(({ current }) => current === activeElement)
}
function getFocusedWelcomeActionIndex(
activeElement: Element | null,
actionButtonRefs: WelcomeActionButtonRef[],
): number {
return Math.max(
0,
actionButtonRefs.findIndex(({ current }) => current === activeElement),
)
}
function focusWelcomeAction(
actionButtonRefs: WelcomeActionButtonRef[],
actionIndex: number,
): void {
actionButtonRefs[actionIndex]?.current?.focus()
}
function triggerWelcomeAction(
actionIndex: number,
actions: WelcomeAction[],
): void {
const action = actions[actionIndex]
if (!action?.disabled) action.run()
}
const CARD_STYLE: React.CSSProperties = {
width: 520,
width: 'min(520px, 100%)',
background: 'var(--background)',
borderRadius: 12,
border: '1px solid var(--border)',
@@ -161,6 +211,7 @@ interface OptionButtonProps {
loading?: boolean
testId: string
autoFocus?: boolean
buttonRef?: React.RefObject<HTMLButtonElement | null>
}
function OptionButton({
@@ -175,11 +226,15 @@ function OptionButton({
loading,
testId,
autoFocus = false,
buttonRef,
}: OptionButtonProps) {
const [hover, setHover] = useState(false)
return (
<button
<Button
type="button"
variant="outline"
size="lg"
style={{
...OPTION_BTN_STYLE,
background: hover ? 'var(--sidebar)' : 'var(--background)',
@@ -191,6 +246,8 @@ function OptionButton({
onMouseLeave={() => setHover(false)}
data-testid={testId}
autoFocus={autoFocus}
className="h-auto justify-start shadow-none"
ref={buttonRef}
>
<div style={{ ...OPTION_ICON_STYLE, background: iconBg }}>
{loading ? <Loader2 size={18} className="animate-spin" style={{ color: 'var(--muted-foreground)' }} /> : icon}
@@ -199,7 +256,7 @@ function OptionButton({
<p style={OPTION_LABEL_STYLE}>{loading ? (loadingLabel ?? label) : label}</p>
<p style={OPTION_DESC_STYLE}>{loading ? (loadingDescription ?? description) : description}</p>
</div>
</button>
</Button>
)
}
@@ -216,7 +273,7 @@ function getWelcomeScreenPresentation(
subtitle: 'Wiki-linked knowledge management for deep thinkers.\nChoose how to get started.',
templateDescription: isOffline
? `Requires internet — clone later. Suggested path: ${defaultVaultPath}`
: `Download the starter vault template — suggested path: ${defaultVaultPath}`,
: 'Download the getting started vault',
title: 'Welcome to Tolaria',
}
}
@@ -228,17 +285,88 @@ function getWelcomeScreenPresentation(
subtitle: 'The vault folder could not be found on disk.\nIt may have been moved or deleted.',
templateDescription: isOffline
? `Requires internet — clone later. Suggested path: ${defaultVaultPath}`
: `Download the starter vault template — suggested path: ${defaultVaultPath}`,
: 'Download the getting started vault',
title: 'Vault not found',
}
}
function useWelcomeActionButtons({
mode,
busy,
isOffline,
onCreateEmptyVault,
onOpenFolder,
onCreateVault,
}: Pick<
WelcomeScreenProps,
'mode' | 'isOffline' | 'onCreateEmptyVault' | 'onOpenFolder' | 'onCreateVault'
> & {
busy: boolean
}) {
const primaryActionRef = useRef<HTMLButtonElement>(null)
const openFolderActionRef = useRef<HTMLButtonElement>(null)
const templateActionRef = useRef<HTMLButtonElement>(null)
const actionButtonRefs = useMemo(
() => [primaryActionRef, openFolderActionRef, templateActionRef],
[],
)
const actions = useMemo<WelcomeAction[]>(
() => ([
{ disabled: false, run: onCreateEmptyVault },
{ disabled: false, run: onOpenFolder },
{ disabled: isOffline, run: onCreateVault },
]),
[isOffline, onCreateEmptyVault, onCreateVault, onOpenFolder],
)
useEffect(() => {
if (busy) return
// WKWebView can ignore `autoFocus`; move focus explicitly so keyboard-only
// onboarding always starts on "Create empty vault".
focusWelcomeAction(actionButtonRefs, 0)
}, [actionButtonRefs, busy, mode])
useEffect(() => {
if (busy) return
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
const activeElement = document.activeElement
if (!focusBelongsToWelcomeActions(activeElement, actionButtonRefs)) return
const actionIndex = getFocusedWelcomeActionIndex(activeElement, actionButtonRefs)
if (isWelcomeNavigationKey(event)) {
event.preventDefault()
focusWelcomeAction(
actionButtonRefs,
nextWelcomeActionIndex(actionIndex, event, actionButtonRefs.length),
)
return
}
if (!isWelcomeActivationKey(event)) return
event.preventDefault()
triggerWelcomeAction(actionIndex, actions)
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [actionButtonRefs, actions, busy])
return {
primaryActionRef,
openFolderActionRef,
templateActionRef,
}
}
export function WelcomeScreen({
mode,
defaultVaultPath,
onCreateVault,
onRetryCreateVault,
onCreateNewVault,
onCreateEmptyVault,
onOpenFolder,
isOffline,
creatingAction,
@@ -247,10 +375,22 @@ export function WelcomeScreen({
}: WelcomeScreenProps) {
const busy = creatingAction !== null
const presentation = getWelcomeScreenPresentation(mode, defaultVaultPath, isOffline)
const { primaryActionRef, openFolderActionRef, templateActionRef } = useWelcomeActionButtons({
mode,
busy,
isOffline,
onCreateEmptyVault,
onOpenFolder,
onCreateVault,
})
return (
<div style={CONTAINER_STYLE} data-testid="welcome-screen">
<div style={CARD_STYLE}>
<OnboardingShell
style={{ background: 'var(--sidebar)' }}
contentStyle={CARD_STYLE}
testId="welcome-screen"
>
<>
<div
style={{
...ICON_WRAP_STYLE,
@@ -273,15 +413,16 @@ export function WelcomeScreen({
<OptionButton
icon={<Plus size={18} style={{ color: 'var(--accent-blue)' }} />}
iconBg="var(--accent-blue-light, #EBF4FF)"
label="Create a new vault"
description="Start fresh in a folder you choose"
label="Create empty vault"
description="Start fresh in an empty folder with Tolaria defaults"
loadingLabel="Creating vault…"
loadingDescription="Preparing an empty vault in the selected folder"
onClick={onCreateNewVault}
loadingDescription="Preparing Tolaria defaults in the selected folder"
onClick={onCreateEmptyVault}
disabled={busy}
loading={creatingAction === 'empty'}
testId="welcome-create-new"
autoFocus
buttonRef={primaryActionRef}
/>
<OptionButton
@@ -292,6 +433,7 @@ export function WelcomeScreen({
onClick={onOpenFolder}
disabled={busy}
testId="welcome-open-folder"
buttonRef={openFolderActionRef}
/>
<OptionButton
@@ -305,6 +447,7 @@ export function WelcomeScreen({
disabled={busy || isOffline}
loading={creatingAction === 'template'}
testId="welcome-create-vault"
buttonRef={templateActionRef}
/>
</div>
@@ -320,18 +463,20 @@ export function WelcomeScreen({
{error}
</p>
{canRetryTemplate && (
<button
<Button
type="button"
variant="outline"
style={RETRY_BUTTON_STYLE}
onClick={onRetryCreateVault}
data-testid="welcome-retry-template"
className="shadow-none"
>
Retry download
</button>
</Button>
)}
</div>
)}
</div>
</div>
</>
</OnboardingShell>
)
}

View File

@@ -1,9 +1,21 @@
import type {
BlockNoteEditor,
BlockSchema,
InlineContentSchema,
StyleSchema,
} from '@blocknote/core'
import { useEffect, useRef, type RefObject } from 'react'
type RectLike = Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom'>
const HOVER_BRIDGE_PADDING_X = 8
const HOVER_BRIDGE_PADDING_Y = 8
const FORMATTING_TOOLBAR_FILE_BLOCK_TYPES = new Set([
'audio',
'file',
'image',
'video',
])
function isVisibleRect(rect: RectLike) {
return rect.right > rect.left && rect.bottom > rect.top
@@ -90,6 +102,32 @@ export function shouldSuppressFormattingToolbarHoverUpdate({
)
}
function getActiveFormattingToolbarFileBlockId(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
) {
const selectedBlock =
editor.getSelection()?.blocks[0] ?? editor.getTextCursorPosition().block
return FORMATTING_TOOLBAR_FILE_BLOCK_TYPES.has(selectedBlock.type)
? selectedBlock.id
: null
}
function restoreFormattingToolbarFileBlockSelection(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
selectedFileBlockIdRef: RefObject<string | null>,
) {
const selectedFileBlockId = selectedFileBlockIdRef.current
if (!selectedFileBlockId) return
if (getActiveFormattingToolbarFileBlockId(editor) === selectedFileBlockId) return
try {
editor.setTextCursorPosition(selectedFileBlockId)
} catch {
// The image block may have been deleted or replaced while the toolbar stayed open.
}
}
function useLastSelectedFormattingToolbarFileBlockId(
selectedFileBlockId: string | null,
isOpen: boolean,
@@ -120,10 +158,12 @@ function getFormattingToolbarHoverGuardEnvironment(container: HTMLElement | null
}
function createFormattingToolbarHoverGuardHandler({
editor,
container,
doc,
selectedFileBlockIdRef,
}: {
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>
container: HTMLElement
doc: Document
selectedFileBlockIdRef: RefObject<string | null>
@@ -141,15 +181,18 @@ function createFormattingToolbarHoverGuardHandler({
return
}
restoreFormattingToolbarFileBlockSelection(editor, selectedFileBlockIdRef)
event.stopPropagation()
}
}
export function useBlockNoteFormattingToolbarHoverGuard({
editor,
container,
selectedFileBlockId,
isOpen,
}: {
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>
container: HTMLElement | null
selectedFileBlockId: string | null
isOpen: boolean
@@ -166,6 +209,7 @@ export function useBlockNoteFormattingToolbarHoverGuard({
if (!environment) return
const handleMouseMove = createFormattingToolbarHoverGuardHandler({
editor,
container: environment.container,
doc: environment.doc,
selectedFileBlockIdRef: lastSelectedFileBlockIdRef,
@@ -175,5 +219,5 @@ export function useBlockNoteFormattingToolbarHoverGuard({
return () => {
environment.view.removeEventListener('mousemove', handleMouseMove, true)
}
}, [container, isOpen, lastSelectedFileBlockIdRef])
}, [container, editor, isOpen, lastSelectedFileBlockIdRef])
}

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import {
buildPendingPasteState,
shouldRecoverPendingPaste,
} from './inlineWikilinkPasteRecovery'
describe('inlineWikilinkPasteRecovery', () => {
it('recognizes the expected post-paste value as recoverable', () => {
const pendingPaste = buildPendingPasteState(' ', { start: 1, end: 1 }, 'hello world')
expect(pendingPaste).toEqual({
duplicatedValue: ' hello worldhello world',
expectedValue: ' hello world',
expectedSelection: { start: 12, end: 12 },
})
expect(shouldRecoverPendingPaste(' hello world', pendingPaste)).toBe(true)
})
it('recognizes the duplicate native replay as recoverable when pasting into the middle of text', () => {
const pendingPaste = buildPendingPasteState('prefix suffix', { start: 7, end: 7 }, 'hello ')
expect(pendingPaste.duplicatedValue).toBe('prefix hello hello suffix')
expect(shouldRecoverPendingPaste('prefix hello hello suffix', pendingPaste)).toBe(true)
expect(shouldRecoverPendingPaste('prefix hello suffix!', pendingPaste)).toBe(false)
})
})

View File

@@ -0,0 +1,32 @@
import type { InlineSelectionRange } from './inlineWikilinkDom'
import { replaceInlineSelection } from './inlineWikilinkEdits'
export interface PendingPasteState {
duplicatedValue: string
expectedValue: string
expectedSelection: InlineSelectionRange
}
export function buildPendingPasteState(
value: string,
selectionRange: InlineSelectionRange,
pastedText: string,
): PendingPasteState {
const nextState = replaceInlineSelection(value, selectionRange, pastedText)
return {
duplicatedValue: replaceInlineSelection(nextState.value, nextState.selection, pastedText).value,
expectedValue: nextState.value,
expectedSelection: nextState.selection,
}
}
export function shouldRecoverPendingPaste(
nextValue: string,
pendingPaste: PendingPasteState,
): boolean {
return (
nextValue === pendingPaste.expectedValue ||
nextValue === pendingPaste.duplicatedValue
)
}

View File

@@ -372,8 +372,6 @@ export function ListPropertiesPopover({
handleToggle,
} = useListPropertiesPopoverState({ scope, availableProperties, currentDisplay, onSave })
if (availableProperties.length === 0) return null
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>

View File

@@ -40,8 +40,6 @@ function NoteListContent({
toggleGroup,
handleSortChange,
renderItem,
typeEntryMap,
handleClickNote,
isArchivedView,
isChangesView,
isInboxView,
@@ -58,8 +56,6 @@ function NoteListContent({
| 'toggleGroup'
| 'handleSortChange'
| 'renderItem'
| 'typeEntryMap'
| 'handleClickNote'
| 'isArchivedView'
| 'isChangesView'
| 'isInboxView'
@@ -79,8 +75,6 @@ function NoteListContent({
onToggleGroup={toggleGroup}
onSortChange={handleSortChange}
renderItem={renderItem}
typeEntryMap={typeEntryMap}
onClickNote={handleClickNote}
/>
) : (
<ListView
@@ -113,8 +107,6 @@ function NoteListBody({
toggleGroup,
handleSortChange,
renderItem,
typeEntryMap,
handleClickNote,
isArchivedView,
isChangesView,
isInboxView,
@@ -140,8 +132,6 @@ function NoteListBody({
| 'toggleGroup'
| 'handleSortChange'
| 'renderItem'
| 'typeEntryMap'
| 'handleClickNote'
| 'isArchivedView'
| 'isChangesView'
| 'isInboxView'
@@ -173,8 +163,6 @@ function NoteListBody({
toggleGroup={toggleGroup}
handleSortChange={handleSortChange}
renderItem={renderItem}
typeEntryMap={typeEntryMap}
handleClickNote={handleClickNote}
isArchivedView={isArchivedView}
isChangesView={isChangesView}
isInboxView={isInboxView}
@@ -222,8 +210,6 @@ export function NoteListLayout({
sortPrefs,
toggleGroup,
renderItem,
typeEntryMap,
handleClickNote,
isArchivedView,
isChangesView,
isInboxView,
@@ -279,8 +265,6 @@ export function NoteListLayout({
toggleGroup={toggleGroup}
handleSortChange={handleSortChange}
renderItem={renderItem}
typeEntryMap={typeEntryMap}
handleClickNote={handleClickNote}
isArchivedView={isArchivedView}
isChangesView={isChangesView}
isInboxView={isInboxView}

View File

@@ -5,7 +5,19 @@ import { PinnedCard } from './PinnedCard'
import { RelationshipGroupSection } from './RelationshipGroupSection'
import { EmptyMessage } from './TrashWarningBanner'
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isArchivedView: boolean, isInboxView: boolean, query: string): string {
function resolveEmptyText({
isChangesView,
changesError,
isArchivedView,
isInboxView,
query,
}: {
isChangesView: boolean
changesError: string | null | undefined
isArchivedView: boolean
isInboxView: boolean
query: string
}): string {
if (isChangesView && changesError) return `Failed to load changes: ${changesError}`
if (isChangesView) return 'No pending changes'
if (isArchivedView) return 'No archived notes'
@@ -13,16 +25,15 @@ function resolveEmptyText(isChangesView: boolean, changesError: string | null |
return query ? 'No matching notes' : 'No notes found'
}
export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggleGroup, onSortChange, renderItem, typeEntryMap, onClickNote }: {
export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs, onToggleGroup, onSortChange, renderItem }: {
entity: VaultEntry; groups: RelationshipGroup[]; query: string
collapsedGroups: Set<string>; sortPrefs: Record<string, SortConfig>
onToggleGroup: (label: string) => void; onSortChange: (label: string, opt: SortOption, dir: SortDirection) => void
renderItem: (entry: VaultEntry) => React.ReactNode
typeEntryMap: Record<string, VaultEntry>; onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
renderItem: (entry: VaultEntry, options?: { forceSelected?: boolean }) => React.ReactNode
}) {
return (
<div className="h-full overflow-y-auto">
<PinnedCard entry={entity} typeEntryMap={typeEntryMap} onClickNote={onClickNote} showDate />
<PinnedCard entry={entity} renderItem={renderItem} />
{groups.length === 0
? <EmptyMessage text={query ? 'No matching items' : 'No related items'} />
: groups.map((group) => (
@@ -39,7 +50,13 @@ export function ListView({ isArchivedView, isChangesView, isInboxView, changesEr
renderItem: (entry: VaultEntry) => React.ReactNode
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
}) {
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, !!isArchivedView, !!isInboxView, query)
const emptyText = resolveEmptyText({
isChangesView: !!isChangesView,
changesError: changesError ?? null,
isArchivedView: !!isArchivedView,
isInboxView: !!isInboxView,
query,
})
if (searched.length === 0) {
return (

View File

@@ -1,29 +1,8 @@
import type { VaultEntry } from '../../types'
import { getTypeColor, getTypeLightColor } from '../../utils/typeColors'
import { getTypeIcon } from '../NoteItem'
import { relativeDate, getDisplayDate } from '../../utils/noteListHelpers'
import { NoteTitleIcon } from '../NoteTitleIcon'
export function PinnedCard({ entry, typeEntryMap, onClickNote, showDate }: {
export function PinnedCard({ entry, renderItem }: {
entry: VaultEntry
typeEntryMap: Record<string, VaultEntry>
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
showDate?: boolean
renderItem: (entry: VaultEntry, options?: { forceSelected?: boolean }) => React.ReactNode
}) {
const te = typeEntryMap[entry.isA ?? '']
const color = getTypeColor(entry.isA ?? '', te?.color)
const bgColor = getTypeLightColor(entry.isA ?? '', te?.color)
const Icon = getTypeIcon(entry.isA, te?.icon)
return (
<div className="relative cursor-pointer border-b border-[var(--border)]" style={{ backgroundColor: bgColor, padding: '14px 16px' }} onClick={(e: React.MouseEvent) => onClickNote(entry, e)}>
{/* eslint-disable-next-line react-hooks/static-components */}
<Icon width={16} height={16} className="absolute right-3 top-3.5" style={{ color }} data-testid="type-icon" />
<div className="pr-6 text-[14px] font-bold" style={{ color }}>
<NoteTitleIcon icon={entry.icon} size={15} className="mr-1" />
{entry.title}
</div>
<div className="mt-1 text-[12px] leading-[1.5] opacity-80" style={{ color, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{entry.snippet}</div>
{showDate && <div className="mt-1 text-[11px] opacity-60" style={{ color }}>{relativeDate(getDisplayDate(entry))}</div>}
</div>
)
return renderItem(entry, { forceSelected: true })
}

View File

@@ -7,6 +7,7 @@ import {
} from '../../utils/noteListHelpers'
import { humanizePropertyKey } from '../../utils/propertyLabels'
import { SortDropdown } from '../SortDropdown'
import { Button } from '../ui/button'
export function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onToggle, handleSortChange, renderItem }: {
group: RelationshipGroup
@@ -14,26 +15,37 @@ export function RelationshipGroupSection({ group, isCollapsed, sortPrefs, onTogg
sortPrefs: Record<string, SortConfig>
onToggle: () => void
handleSortChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
renderItem: (entry: VaultEntry) => React.ReactNode
renderItem: (entry: VaultEntry, options?: { forceSelected?: boolean }) => React.ReactNode
}) {
const groupConfig = sortPrefs[group.label] ?? { option: 'modified' as SortOption, direction: 'desc' as SortDirection }
const sortedEntries = [...group.entries].sort(getSortComparator(groupConfig.option, groupConfig.direction))
const customProperties = useMemo(() => extractSortableProperties(group.entries), [group.entries])
const contentId = `relationship-group-${group.label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`
return (
<div>
<div className="flex w-full items-center justify-between bg-muted" style={{ height: 32, padding: '0 16px' }}>
<button className="flex flex-1 items-center gap-1.5 border-none bg-transparent cursor-pointer p-0" onClick={onToggle}>
<div className="flex w-full items-center justify-between bg-muted" style={{ minHeight: 32, paddingRight: 16 }}>
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 flex-1 justify-start gap-1.5 rounded-none px-4 hover:bg-muted"
onClick={onToggle}
aria-controls={contentId}
aria-expanded={!isCollapsed}
>
{isCollapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
<span className="font-mono-label text-muted-foreground">{humanizePropertyKey(group.label)}</span>
<span className="font-mono-label text-muted-foreground" style={{ fontWeight: 400 }}>{group.entries.length}</span>
</button>
</Button>
<span className="flex items-center gap-1.5">
<SortDropdown groupLabel={group.label} current={groupConfig.option} direction={groupConfig.direction} customProperties={customProperties} onChange={handleSortChange} />
<button className="flex items-center border-none bg-transparent cursor-pointer p-0 text-muted-foreground" onClick={onToggle}>
{isCollapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
</button>
</span>
</div>
{!isCollapsed && sortedEntries.map((entry) => renderItem(entry))}
{!isCollapsed && (
<div id={contentId}>
{sortedEntries.map((entry) => renderItem(entry))}
</div>
)}
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewFile } from '../../types'
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus, ViewDefinition, ViewFile } from '../../types'
import {
type SortOption, type SortDirection, type SortConfig, type NoteListFilter,
getSortComparator, extractSortableProperties,
@@ -109,6 +109,10 @@ interface NoteListDataParams {
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, modifiedFiles, subFilter, inboxPeriod, views }: NoteListDataParams) {
const isEntityView = selection.kind === 'entity'
const isArchivedView = (selection.kind === 'filter' && selection.filter === 'archived') || subFilter === 'archived'
const entityEntry = useMemo(() => {
if (!isEntityView || selection.kind !== 'entity') return null
return entries.find((entry) => entry.path === selection.entry.path) ?? selection.entry
}, [entries, isEntityView, selection])
const filteredEntries = useFilteredEntries({
entries,
@@ -127,15 +131,12 @@ export function useNoteListData({ entries, selection, query, listSort, listDirec
}, [filteredEntries, listSort, listDirection, query])
const searchedGroups = useMemo(() => {
if (!isEntityView) return []
// Look up the fresh entry from the entries array to pick up relationship
// updates that happened after the selection was captured.
const freshEntry = entries.find((e) => e.path === selection.entry.path) ?? selection.entry
const groups = buildRelationshipGroups(freshEntry, entries)
if (!entityEntry) return []
const groups = buildRelationshipGroups(entityEntry, entries)
return filterGroupsByQuery(groups, query)
}, [isEntityView, selection, entries, query])
}, [entityEntry, entries, query])
return { isEntityView, isArchivedView, searched, searchedGroups }
return { entityEntry, isEntityView, isArchivedView, searched, searchedGroups }
}
// --- useNoteListSearch ---
@@ -156,29 +157,99 @@ export function useNoteListSearch() {
const DEFAULT_LIST_CONFIG: SortConfig = { option: 'modified', direction: 'desc' }
function resolveListSortConfig(typeDocument: VaultEntry | null, sortPrefs: Record<string, SortConfig>): SortConfig {
function findSelectedViewFile(selection: SidebarSelection, views?: ViewFile[]): ViewFile | null {
if (selection.kind !== 'view') return null
return views?.find((candidate) => candidate.filename === selection.filename) ?? null
}
function findSelectedTypeDocument(entries: VaultEntry[], selection: SidebarSelection): VaultEntry | null {
if (selection.kind !== 'sectionGroup') return null
return entries.find((entry) => entry.isA === 'Type' && entry.title === selection.type) ?? null
}
function resolveListSortConfig(
typeDocument: VaultEntry | null,
selectedView: ViewFile | null,
sortPrefs: Record<string, SortConfig>,
): SortConfig {
if (typeDocument?.sort) {
const parsed = parseSortConfig(typeDocument.sort)
if (parsed) return parsed
}
return sortPrefs['__list__'] ?? DEFAULT_LIST_CONFIG
if (selectedView?.definition.sort) {
const parsed = parseSortConfig(selectedView.definition.sort)
if (parsed) return parsed
}
return selectedView ? DEFAULT_LIST_CONFIG : (sortPrefs['__list__'] ?? DEFAULT_LIST_CONFIG)
}
interface SortPersistence {
onUpdateTypeSort: (path: string, key: string, value: string) => void
updateEntry: (path: string, patch: Partial<VaultEntry>) => void
onUpdateTypeSort?: (path: string, key: string, value: string) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
}
function createSortPersistence(
onUpdateTypeSort?: SortPersistence['onUpdateTypeSort'],
updateEntry?: SortPersistence['updateEntry'],
onUpdateViewDefinition?: SortPersistence['onUpdateViewDefinition'],
): SortPersistence | null {
if (!onUpdateViewDefinition && !(onUpdateTypeSort && updateEntry)) return null
return { onUpdateTypeSort, updateEntry, onUpdateViewDefinition }
}
function persistSortToType(path: string, config: SortConfig, persistence: SortPersistence) {
const serialized = serializeSortConfig(config)
persistence.onUpdateTypeSort(path, 'sort', serialized)
persistence.updateEntry(path, { sort: serialized })
persistence.onUpdateTypeSort?.(path, 'sort', serialized)
persistence.updateEntry?.(path, { sort: serialized })
clearListSortFromLocalStorage()
}
function resolveTypeSortPersistenceTarget(groupLabel: string, typeDocument: VaultEntry | null, persistence: SortPersistence | null) {
if (groupLabel !== '__list__' || !typeDocument || !persistence) return null
return { path: typeDocument.path, persistence }
function persistSortToView(
filename: string,
config: SortConfig,
onUpdateViewDefinition: NonNullable<SortPersistence['onUpdateViewDefinition']>,
) {
onUpdateViewDefinition(filename, { sort: serializeSortConfig(config) })
}
type SortPersistenceTarget =
| { kind: 'type'; path: string }
| { kind: 'view'; filename: string }
function canPersistTypeSort(
persistence: SortPersistence,
): persistence is SortPersistence & Required<Pick<SortPersistence, 'onUpdateTypeSort' | 'updateEntry'>> {
return Boolean(persistence.onUpdateTypeSort && persistence.updateEntry)
}
function resolveSortPersistenceTarget(
groupLabel: string,
typeDocument: VaultEntry | null,
selectedView: ViewFile | null,
persistence: SortPersistence | null,
): SortPersistenceTarget | null {
if (groupLabel !== '__list__' || !persistence) return null
if (typeDocument && canPersistTypeSort(persistence)) {
return { kind: 'type', path: typeDocument.path }
}
if (selectedView && persistence.onUpdateViewDefinition) {
return { kind: 'view', filename: selectedView.filename }
}
return null
}
function persistListSort(target: SortPersistenceTarget, config: SortConfig, persistence: SortPersistence) {
if (target.kind === 'type') {
persistSortToType(target.path, config, persistence)
return
}
if (persistence.onUpdateViewDefinition) {
persistSortToView(target.filename, config, persistence.onUpdateViewDefinition)
}
}
function migrateListSortToType(typeDoc: VaultEntry, sortPrefs: Record<string, SortConfig>, migrationDone: Set<string>, persistence: SortPersistence) {
@@ -193,6 +264,24 @@ function saveGroupSort(groupLabel: string, option: SortOption, direction: SortDi
setSortPrefs((prev) => { const next = { ...prev, [groupLabel]: { option, direction } }; saveSortPreferences(next); return next })
}
function persistOrSaveGroupSort(
groupLabel: string,
option: SortOption,
direction: SortDirection,
setSortPrefs: React.Dispatch<React.SetStateAction<Record<string, SortConfig>>>,
typeDocument: VaultEntry | null,
selectedView: ViewFile | null,
persistence: SortPersistence | null,
) {
const persistenceTarget = resolveSortPersistenceTarget(groupLabel, typeDocument, selectedView, persistence)
if (!persistenceTarget || !persistence) {
saveGroupSort(groupLabel, option, direction, setSortPrefs)
return
}
persistListSort(persistenceTarget, { option, direction }, persistence)
}
function deriveEffectiveSort(configOption: SortOption, customProperties: string[]): SortOption {
if (!configOption.startsWith('property:')) return configOption
return customProperties.includes(configOption.slice('property:'.length)) ? configOption : 'modified'
@@ -205,22 +294,35 @@ export interface UseNoteListSortParams {
modifiedSuffixes: string[]
subFilter?: NoteListFilter
inboxPeriod?: InboxPeriod
views?: ViewFile[]
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
}
export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, inboxPeriod, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
export function useNoteListSort({
entries,
selection,
modifiedPathSet,
modifiedSuffixes,
subFilter,
inboxPeriod,
views,
onUpdateTypeSort,
onUpdateViewDefinition,
updateEntry,
}: UseNoteListSortParams) {
const [sortPrefs, setSortPrefs] = useState<Record<string, SortConfig>>(loadSortPreferences)
const typeDocument = useMemo(() => findSelectedTypeDocument(entries, selection), [entries, selection])
const selectedView = useMemo(
() => findSelectedViewFile(selection, views),
[selection, views],
)
const typeDocument = useMemo(() => {
if (selection.kind !== 'sectionGroup') return null
return entries.find((e) => e.isA === 'Type' && e.title === selection.type) ?? null
}, [selection, entries])
const listConfig = resolveListSortConfig(typeDocument, sortPrefs)
const listConfig = resolveListSortConfig(typeDocument, selectedView, sortPrefs)
const persistence = useMemo<SortPersistence | null>(
() => (onUpdateTypeSort && updateEntry) ? { onUpdateTypeSort, updateEntry } : null,
[onUpdateTypeSort, updateEntry],
() => createSortPersistence(onUpdateTypeSort, updateEntry, onUpdateViewDefinition),
[onUpdateTypeSort, onUpdateViewDefinition, updateEntry],
)
const migrationDoneRef = useRef<Set<string>>(new Set())
@@ -230,10 +332,16 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS
}, [typeDocument, sortPrefs, persistence])
const handleSortChange = useCallback((groupLabel: string, option: SortOption, direction: SortDirection) => {
const typeSortTarget = resolveTypeSortPersistenceTarget(groupLabel, typeDocument, persistence)
if (!typeSortTarget) return saveGroupSort(groupLabel, option, direction, setSortPrefs)
persistSortToType(typeSortTarget.path, { option, direction }, typeSortTarget.persistence)
}, [typeDocument, persistence])
persistOrSaveGroupSort(
groupLabel,
option,
direction,
setSortPrefs,
typeDocument,
selectedView,
persistence,
)
}, [typeDocument, selectedView, persistence])
const filteredEntries = useFilteredEntries({
entries,
@@ -242,6 +350,7 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS
modifiedSuffixes,
subFilter,
inboxPeriod,
views,
})
const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries])
const listSort = useMemo<SortOption>(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties])
@@ -344,18 +453,27 @@ export function useChangeStatusResolver(isChangesView: boolean, modifiedFiles?:
interface VisibleNotesSyncParams {
visibleNotesRef?: React.MutableRefObject<VaultEntry[]>
isEntityView: boolean
entityEntry?: VaultEntry | null
searched: VaultEntry[]
searchedGroups: Array<{ entries: VaultEntry[] }>
}
export function useVisibleNotesSync({ visibleNotesRef, isEntityView, searched, searchedGroups }: VisibleNotesSyncParams) {
function flattenNeighborhoodEntries(
entityEntry: VaultEntry | null | undefined,
searchedGroups: Array<{ entries: VaultEntry[] }>,
): VaultEntry[] {
if (!entityEntry) return []
return [entityEntry, ...searchedGroups.flatMap((group) => group.entries)]
}
export function useVisibleNotesSync({ visibleNotesRef, isEntityView, entityEntry, searched, searchedGroups }: VisibleNotesSyncParams) {
useEffect(() => {
if (!visibleNotesRef) return
visibleNotesRef.current = isEntityView
? searchedGroups.flatMap((group) => group.entries).filter((entry) => !isDeletedNoteEntry(entry))
? flattenNeighborhoodEntries(entityEntry, searchedGroups).filter((entry) => !isDeletedNoteEntry(entry))
: searched.filter((entry) => !isDeletedNoteEntry(entry))
}, [visibleNotesRef, isEntityView, searched, searchedGroups])
}, [visibleNotesRef, entityEntry, isEntityView, searched, searchedGroups])
}
// --- useListPropertyPicker ---
@@ -393,6 +511,96 @@ function deriveDefaultDisplay(entries: VaultEntry[], typeEntryMap: Record<string
return ordered
}
interface ScopedPropertyPickerState {
availableProperties: string[]
defaultDisplay: string[]
}
function useAllNotesPropertyPickerState(
entries: VaultEntry[],
selection: SidebarSelection,
isAllNotesView: boolean,
typeEntryMap: Record<string, VaultEntry>,
): ScopedPropertyPickerState {
const allNotesEntries = useMemo(
() => isAllNotesView
? [
...filterEntries(entries, selection, 'open'),
...filterEntries(entries, selection, 'archived'),
]
: [],
[entries, isAllNotesView, selection],
)
return {
availableProperties: useMemo(
() => collectAvailableProperties(allNotesEntries),
[allNotesEntries],
),
defaultDisplay: useMemo(
() => deriveDefaultDisplay(allNotesEntries, typeEntryMap),
[allNotesEntries, typeEntryMap],
),
}
}
function useInboxPropertyPickerState(
entries: VaultEntry[],
inboxPeriod: InboxPeriod,
isInboxView: boolean,
typeEntryMap: Record<string, VaultEntry>,
): ScopedPropertyPickerState {
const inboxEntries = useMemo(
() => isInboxView ? filterInboxEntries(entries, inboxPeriod) : [],
[entries, inboxPeriod, isInboxView],
)
return {
availableProperties: useMemo(
() => collectAvailableProperties(inboxEntries),
[inboxEntries],
),
defaultDisplay: useMemo(
() => deriveDefaultDisplay(inboxEntries, typeEntryMap),
[inboxEntries, typeEntryMap],
),
}
}
interface ViewPropertyPickerState extends ScopedPropertyPickerState {
selectedView: ViewFile | null
hasCustomProperties: boolean
}
function useViewPropertyPickerState(
entries: VaultEntry[],
selection: SidebarSelection,
views: ViewFile[] | undefined,
typeEntryMap: Record<string, VaultEntry>,
): ViewPropertyPickerState {
const selectedView = useMemo(
() => findSelectedViewFile(selection, views),
[selection, views],
)
const viewEntries = useMemo(
() => selectedView ? filterEntries(entries, selection, undefined, views) : [],
[entries, selection, selectedView, views],
)
return {
selectedView,
availableProperties: useMemo(
() => collectAvailableProperties(viewEntries),
[viewEntries],
),
defaultDisplay: useMemo(
() => deriveDefaultDisplay(viewEntries, typeEntryMap),
[viewEntries, typeEntryMap],
),
hasCustomProperties: Boolean(selectedView?.definition.listPropertiesDisplay?.length),
}
}
export interface NoteListPropertyPicker {
scope: NoteListPropertiesScope
availableProperties: string[]
@@ -457,6 +665,61 @@ function buildTypePropertyPicker({
}
}
interface BuildViewPropertyPickerParams {
selectedView: ViewFile | null
availableProperties: string[]
defaultDisplay: string[]
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
}
function buildViewPropertyPicker({
selectedView,
availableProperties,
defaultDisplay,
onUpdateViewDefinition,
}: BuildViewPropertyPickerParams): NoteListPropertyPicker | null {
if (!selectedView || !onUpdateViewDefinition) return null
const currentDisplay = (selectedView.definition.listPropertiesDisplay?.length ?? 0) > 0
? selectedView.definition.listPropertiesDisplay ?? []
: defaultDisplay
return {
scope: 'view',
availableProperties,
currentDisplay,
onSave: (value: string[] | null) => onUpdateViewDefinition(selectedView.filename, { listPropertiesDisplay: value ?? [] }),
triggerTitle: `Customize ${selectedView.definition.name} columns`,
}
}
function resolveDisplayPropsOverride({
isAllNotesView,
hasCustomAllNotesProperties,
allNotesNoteListProperties,
isInboxView,
hasCustomInboxProperties,
inboxNoteListProperties,
selectedView,
hasCustomViewProperties,
}: {
isAllNotesView: boolean
hasCustomAllNotesProperties: boolean
allNotesNoteListProperties?: string[] | null
isInboxView: boolean
hasCustomInboxProperties: boolean
inboxNoteListProperties?: string[] | null
selectedView: ViewFile | null
hasCustomViewProperties: boolean
}) {
if (selectedView && hasCustomViewProperties) {
return selectedView.definition.listPropertiesDisplay ?? null
}
if (isAllNotesView && hasCustomAllNotesProperties) return allNotesNoteListProperties ?? null
if (isInboxView && hasCustomInboxProperties) return inboxNoteListProperties ?? null
return null
}
interface UseListPropertyPickerParams {
entries: VaultEntry[]
selection: SidebarSelection
@@ -467,7 +730,9 @@ interface UseListPropertyPickerParams {
onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void
inboxNoteListProperties?: string[] | null
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
views?: ViewFile[]
}
export function useListPropertyPicker({
@@ -480,68 +745,55 @@ export function useListPropertyPicker({
onUpdateAllNotesNoteListProperties,
inboxNoteListProperties,
onUpdateInboxNoteListProperties,
onUpdateViewDefinition,
onUpdateTypeSort,
views,
}: UseListPropertyPickerParams) {
const isAllNotesView = selection.kind === 'filter' && selection.filter === 'all'
const isInboxView = selection.kind === 'filter' && selection.filter === 'inbox'
const isSectionGroup = selection.kind === 'sectionGroup'
const allNotesEntries = useMemo(
() => isAllNotesView
? [
...filterEntries(entries, selection, 'open'),
...filterEntries(entries, selection, 'archived'),
]
: [],
[entries, isAllNotesView, selection],
)
const inboxEntries = useMemo(
() => isInboxView ? filterInboxEntries(entries, inboxPeriod) : [],
[entries, inboxPeriod, isInboxView],
)
const allNotesAvailableProperties = useMemo(
() => collectAvailableProperties(allNotesEntries),
[allNotesEntries],
)
const allNotesDefaultDisplay = useMemo(
() => deriveDefaultDisplay(allNotesEntries, typeEntryMap),
[allNotesEntries, typeEntryMap],
)
const allNotesState = useAllNotesPropertyPickerState(entries, selection, isAllNotesView, typeEntryMap)
const inboxState = useInboxPropertyPickerState(entries, inboxPeriod, isInboxView, typeEntryMap)
const viewState = useViewPropertyPickerState(entries, selection, views, typeEntryMap)
const typeAvailableProperties = useMemo(
() => typeDocument ? collectTypeAvailableProperties(entries, typeDocument.title) : [],
[entries, typeDocument],
)
const inboxAvailableProperties = useMemo(
() => collectAvailableProperties(inboxEntries),
[inboxEntries],
)
const inboxDefaultDisplay = useMemo(
() => deriveDefaultDisplay(inboxEntries, typeEntryMap),
[inboxEntries, typeEntryMap],
)
const hasCustomAllNotesProperties = !!(allNotesNoteListProperties && allNotesNoteListProperties.length > 0)
const hasCustomInboxProperties = !!(inboxNoteListProperties && inboxNoteListProperties.length > 0)
const displayPropsOverride = isAllNotesView && hasCustomAllNotesProperties
? allNotesNoteListProperties
: (isInboxView && hasCustomInboxProperties ? inboxNoteListProperties : null)
const displayPropsOverride = resolveDisplayPropsOverride({
isAllNotesView,
hasCustomAllNotesProperties,
allNotesNoteListProperties,
isInboxView,
hasCustomInboxProperties,
inboxNoteListProperties,
selectedView: viewState.selectedView,
hasCustomViewProperties: viewState.hasCustomProperties,
})
const propertyPicker = useMemo<NoteListPropertyPicker | null>(() => {
return buildFilterPropertyPicker({
return buildViewPropertyPicker({
selectedView: viewState.selectedView,
availableProperties: viewState.availableProperties,
defaultDisplay: viewState.defaultDisplay,
onUpdateViewDefinition,
}) ?? buildFilterPropertyPicker({
scope: 'all',
isActive: isAllNotesView,
availableProperties: allNotesAvailableProperties,
availableProperties: allNotesState.availableProperties,
hasCustomProperties: hasCustomAllNotesProperties,
noteListProperties: allNotesNoteListProperties,
defaultDisplay: allNotesDefaultDisplay,
defaultDisplay: allNotesState.defaultDisplay,
onSave: onUpdateAllNotesNoteListProperties,
triggerTitle: 'Customize All Notes columns',
}) ?? buildFilterPropertyPicker({
scope: 'inbox',
isActive: isInboxView,
availableProperties: inboxAvailableProperties,
availableProperties: inboxState.availableProperties,
hasCustomProperties: hasCustomInboxProperties,
noteListProperties: inboxNoteListProperties,
defaultDisplay: inboxDefaultDisplay,
defaultDisplay: inboxState.defaultDisplay,
onSave: onUpdateInboxNoteListProperties,
triggerTitle: 'Customize Inbox columns',
}) ?? buildTypePropertyPicker({
@@ -551,15 +803,19 @@ export function useListPropertyPicker({
typeAvailableProperties,
})
}, [
allNotesAvailableProperties,
allNotesDefaultDisplay,
allNotesState.availableProperties,
allNotesState.defaultDisplay,
allNotesNoteListProperties,
hasCustomAllNotesProperties,
isAllNotesView,
onUpdateAllNotesNoteListProperties,
hasCustomInboxProperties,
inboxAvailableProperties,
inboxDefaultDisplay,
isAllNotesView,
inboxState.availableProperties,
inboxState.defaultDisplay,
viewState.availableProperties,
viewState.defaultDisplay,
viewState.selectedView,
onUpdateViewDefinition,
onUpdateAllNotesNoteListProperties,
inboxNoteListProperties,
isInboxView,
isSectionGroup,
@@ -576,13 +832,14 @@ export function useListPropertyPicker({
interface UseNoteListInteractionsParams {
searched: VaultEntry[]
searchedGroups: Array<{ entries: VaultEntry[] }>
selectedNotePath: string | null
selection: SidebarSelection
noteListFilter: NoteListFilter
isEntityView: boolean
isChangesView: boolean
entityEntry: VaultEntry | null
onReplaceActiveTab: (entry: VaultEntry) => void
onSelectNote: (entry: VaultEntry) => void
onEnterNeighborhood?: (entry: VaultEntry) => void
onOpenDeletedNote?: (entry: DeletedNoteEntry) => void
onOpenInNewWindow?: (entry: VaultEntry) => void
onAutoTriggerDiff?: () => void
@@ -614,23 +871,38 @@ function openHighlightedChangesContextMenu(
})
}
export function useNoteListInteractions({
function resolveKeyboardEntries(
searched: VaultEntry[],
searchedGroups: Array<{ entries: VaultEntry[] }>,
entityEntry: VaultEntry | null,
): VaultEntry[] {
return entityEntry
? flattenNeighborhoodEntries(entityEntry, searchedGroups)
: searched
}
function useKeyboardInteractionState({
searched,
searchedGroups,
entityEntry,
selectedNotePath,
selection,
noteListFilter,
isEntityView,
isChangesView,
onReplaceActiveTab,
onSelectNote,
onEnterNeighborhood,
onOpenDeletedNote,
onOpenInNewWindow,
onAutoTriggerDiff,
onDiscardFile,
openContextMenuForEntry,
onCreateNote,
}: UseNoteListInteractionsParams) {
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
}: Pick<
UseNoteListInteractionsParams,
| 'searched'
| 'searchedGroups'
| 'entityEntry'
| 'selectedNotePath'
| 'onReplaceActiveTab'
| 'onEnterNeighborhood'
| 'onOpenDeletedNote'
>) {
const keyboardEntries = useMemo(
() => resolveKeyboardEntries(searched, searchedGroups, entityEntry),
[entityEntry, searched, searchedGroups],
)
const handleKeyboardOpen = useCallback((entry: VaultEntry) => {
if (isDeletedNoteEntry(entry)) {
@@ -644,24 +916,47 @@ export function useNoteListInteractions({
if (!isDeletedNoteEntry(entry)) prefetchNoteContent(entry.path)
}, [])
const handleNeighborhoodOpen = useCallback(async (entry: VaultEntry) => {
if (isDeletedNoteEntry(entry)) return
await onReplaceActiveTab(entry)
onEnterNeighborhood?.(entry)
}, [onEnterNeighborhood, onReplaceActiveTab])
const noteListKeyboard = useNoteListKeyboard({
items: searched,
items: keyboardEntries,
selectedNotePath,
onOpen: handleKeyboardOpen,
onEnterNeighborhood: handleNeighborhoodOpen,
onPrefetch: handleKeyboardPrefetch,
enabled: !isEntityView,
enabled: true,
})
const multiSelect = useMultiSelect(searched, selectedNotePath)
const multiSelect = useMultiSelect(keyboardEntries, selectedNotePath)
useEffect(() => {
multiSelect.clear()
}, [noteListFilter, selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear only when selection/filter changes
return { handleNeighborhoodOpen, multiSelect, noteListKeyboard }
}
const handleClickNote = useCallback((entry: VaultEntry, event: React.MouseEvent) => {
function useNoteClickHandler({
isChangesView,
onReplaceActiveTab,
handleNeighborhoodOpen,
onOpenDeletedNote,
onOpenInNewWindow,
onAutoTriggerDiff,
multiSelect,
}: {
isChangesView: boolean
onReplaceActiveTab: (entry: VaultEntry) => void
handleNeighborhoodOpen: (entry: VaultEntry) => Promise<void>
onOpenDeletedNote?: (entry: DeletedNoteEntry) => void
onOpenInNewWindow?: (entry: VaultEntry) => void
onAutoTriggerDiff?: () => void
multiSelect: MultiSelectState
}) {
return useCallback((entry: VaultEntry, event: React.MouseEvent) => {
if (isDeletedNoteEntry(entry)) {
routeNoteClick(entry, event, {
onReplace: () => onOpenDeletedNote?.(entry),
onSelect: () => onOpenDeletedNote?.(entry),
onEnterNeighborhood: () => onOpenDeletedNote?.(entry),
multiSelect,
})
return
@@ -669,7 +964,7 @@ export function useNoteListInteractions({
routeNoteClick(entry, event, {
onReplace: onReplaceActiveTab,
onSelect: onSelectNote,
onEnterNeighborhood: handleNeighborhoodOpen,
onOpenInNewWindow,
multiSelect,
})
@@ -684,15 +979,31 @@ export function useNoteListInteractions({
onOpenDeletedNote,
onOpenInNewWindow,
onReplaceActiveTab,
onSelectNote,
handleNeighborhoodOpen,
])
}
const handleListKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
function useListKeyDownHandler({
isChangesView,
onDiscardFile,
highlightedPath,
searched,
openContextMenuForEntry,
handleKeyDown,
}: {
isChangesView: boolean
onDiscardFile?: (relativePath: string) => Promise<void>
highlightedPath: string | null
searched: VaultEntry[]
openContextMenuForEntry: (entry: VaultEntry, point: { x: number; y: number }) => void
handleKeyDown: (event: React.KeyboardEvent) => void
}) {
return useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
const entry = resolveChangesContextMenuEntry(
event,
isChangesView,
onDiscardFile,
noteListKeyboard.highlightedPath,
highlightedPath,
searched,
)
if (entry) {
@@ -702,8 +1013,60 @@ export function useNoteListInteractions({
return
}
noteListKeyboard.handleKeyDown(event)
}, [isChangesView, noteListKeyboard, onDiscardFile, openContextMenuForEntry, searched])
handleKeyDown(event)
}, [handleKeyDown, highlightedPath, isChangesView, onDiscardFile, openContextMenuForEntry, searched])
}
export function useNoteListInteractions({
searched,
searchedGroups,
selectedNotePath,
selection,
noteListFilter,
isChangesView,
entityEntry,
onReplaceActiveTab,
onEnterNeighborhood,
onOpenDeletedNote,
onOpenInNewWindow,
onAutoTriggerDiff,
onDiscardFile,
openContextMenuForEntry,
onCreateNote,
}: UseNoteListInteractionsParams) {
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
const { handleNeighborhoodOpen, multiSelect, noteListKeyboard } = useKeyboardInteractionState({
searched,
searchedGroups,
entityEntry,
selectedNotePath,
onReplaceActiveTab,
onEnterNeighborhood,
onOpenDeletedNote,
})
useEffect(() => {
multiSelect.clear()
}, [noteListFilter, selection]) // eslint-disable-line react-hooks/exhaustive-deps -- clear only when selection/filter changes
const handleClickNote = useNoteClickHandler({
isChangesView,
onReplaceActiveTab,
handleNeighborhoodOpen,
onOpenDeletedNote,
onOpenInNewWindow,
onAutoTriggerDiff,
multiSelect,
})
const handleListKeyDown = useListKeyDownHandler({
isChangesView,
onDiscardFile,
highlightedPath: noteListKeyboard.highlightedPath,
searched,
openContextMenuForEntry,
handleKeyDown: noteListKeyboard.handleKeyDown,
})
const handleCreateNote = useCallback(() => {
onCreateNote(selection.kind === 'sectionGroup' ? selection.type : undefined)

View File

@@ -1,4 +1,4 @@
export type NoteListPropertiesScope = 'type' | 'inbox' | 'all'
export type NoteListPropertiesScope = 'type' | 'inbox' | 'all' | 'view'
export interface OpenListPropertiesEventDetail {
scope: NoteListPropertiesScope

View File

@@ -66,5 +66,4 @@ export function filterGroupsByNoteListQuery(
...group,
entries: filterEntriesByNoteListQuery(group.entries, normalizedQuery, context),
}))
.filter((group) => group.entries.length > 0)
}

View File

@@ -18,7 +18,7 @@ function makeEntry(path = '/test.md'): VaultEntry {
function makeActions(): ClickActions {
return {
onReplace: vi.fn(),
onSelect: vi.fn(),
onEnterNeighborhood: vi.fn(),
onOpenInNewWindow: vi.fn(),
multiSelect: {
selectRange: vi.fn(),
@@ -49,12 +49,13 @@ describe('routeNoteClick', () => {
expect(actions.multiSelect.setAnchor).toHaveBeenCalledWith(entry.path)
})
it('Cmd+click opens as new tab', () => {
it('Cmd+click enters Neighborhood mode', () => {
const entry = makeEntry()
const actions = makeActions()
routeNoteClick(entry, makeMouseEvent({ metaKey: true }), actions)
expect(actions.onSelect).toHaveBeenCalledWith(entry)
expect(actions.onEnterNeighborhood).toHaveBeenCalledWith(entry)
expect(actions.multiSelect.clear).toHaveBeenCalled()
expect(actions.onReplace).not.toHaveBeenCalled()
})
it('Shift+click selects range', () => {
@@ -70,7 +71,7 @@ describe('routeNoteClick', () => {
routeNoteClick(entry, makeMouseEvent({ metaKey: true, shiftKey: true }), actions)
expect(actions.onOpenInNewWindow).toHaveBeenCalledWith(entry)
expect(actions.onReplace).not.toHaveBeenCalled()
expect(actions.onSelect).not.toHaveBeenCalled()
expect(actions.onEnterNeighborhood).not.toHaveBeenCalled()
})
it('Cmd+Shift+click is a no-op when handler is undefined', () => {
@@ -79,6 +80,6 @@ describe('routeNoteClick', () => {
actions.onOpenInNewWindow = undefined
routeNoteClick(entry, makeMouseEvent({ metaKey: true, shiftKey: true }), actions)
expect(actions.onReplace).not.toHaveBeenCalled()
expect(actions.onSelect).not.toHaveBeenCalled()
expect(actions.onEnterNeighborhood).not.toHaveBeenCalled()
})
})

View File

@@ -44,25 +44,44 @@ export function filterGroupsByQuery(groups: RelationshipGroup[], query: string):
export interface ClickActions {
onReplace: (entry: VaultEntry) => void
onSelect: (entry: VaultEntry) => void
onEnterNeighborhood?: (entry: VaultEntry) => void
onOpenInNewWindow?: (entry: VaultEntry) => void
multiSelect: { selectRange: (path: string) => void; clear: () => void; setAnchor: (path: string) => void }
}
function usesCommandModifier(event: Pick<React.MouseEvent, 'metaKey' | 'ctrlKey'>): boolean {
return event.metaKey || event.ctrlKey
}
function isOpenInNewWindowClick(event: Pick<React.MouseEvent, 'metaKey' | 'ctrlKey' | 'shiftKey'>): boolean {
return usesCommandModifier(event) && event.shiftKey
}
function isRangeSelectionClick(event: Pick<React.MouseEvent, 'shiftKey'>): boolean {
return event.shiftKey
}
function isNeighborhoodClick(
event: Pick<React.MouseEvent, 'metaKey' | 'ctrlKey'>,
actions: ClickActions,
): boolean {
return usesCommandModifier(event) && Boolean(actions.onEnterNeighborhood)
}
export function routeNoteClick(entry: VaultEntry, e: React.MouseEvent, actions: ClickActions) {
if ((e.metaKey || e.ctrlKey) && e.shiftKey) {
if (isOpenInNewWindowClick(e)) {
actions.onOpenInNewWindow?.(entry)
return
}
if (e.shiftKey) {
if (isRangeSelectionClick(e)) {
actions.multiSelect.selectRange(entry.path)
return
}
actions.multiSelect.clear()
if (e.metaKey || e.ctrlKey) {
actions.onSelect(entry)
if (isNeighborhoodClick(e, actions)) {
actions.onEnterNeighborhood?.(entry)
return
}

View File

@@ -5,13 +5,15 @@ import type {
ModifiedFile,
NoteStatus,
InboxPeriod,
ViewDefinition,
ViewFile,
} from '../../types'
import type { NoteListFilter } from '../../utils/noteListHelpers'
import { countByFilter, countAllByFilter, countAllNotesByFilter } from '../../utils/noteListHelpers'
import { NoteItem } from '../NoteItem'
import { prefetchNoteContent } from '../../hooks/useTabManagement'
import type { MultiSelectState } from '../../hooks/useMultiSelect'
import { resolveHeaderTitle, type DeletedNoteEntry } from './noteListUtils'
import { isDeletedNoteEntry, resolveHeaderTitle, type DeletedNoteEntry } from './noteListUtils'
import { filterEntriesByNoteListQuery, filterGroupsByNoteListQuery } from './noteListSearch'
import {
useChangeStatusResolver,
@@ -94,6 +96,7 @@ interface UseNoteListContentParams {
onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void
inboxNoteListProperties?: string[] | null
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
views?: ViewFile[]
@@ -113,6 +116,7 @@ function useNoteListContent({
onUpdateAllNotesNoteListProperties,
inboxNoteListProperties,
onUpdateInboxNoteListProperties,
onUpdateViewDefinition,
onUpdateTypeSort,
updateEntry,
views,
@@ -129,7 +133,9 @@ function useNoteListContent({
modifiedSuffixes,
subFilter,
inboxPeriod: effectiveInboxPeriod,
views,
onUpdateTypeSort,
onUpdateViewDefinition,
updateEntry,
})
const { search, setSearch, query, searchVisible, toggleSearch } = useNoteListSearch()
@@ -144,9 +150,12 @@ function useNoteListContent({
onUpdateAllNotesNoteListProperties,
inboxNoteListProperties,
onUpdateInboxNoteListProperties,
onUpdateViewDefinition,
onUpdateTypeSort,
views,
})
const {
entityEntry,
isEntityView,
isArchivedView,
searched: sortedEntries,
@@ -174,11 +183,12 @@ function useNoteListContent({
typeEntryMap,
displayPropsOverride,
}), [displayPropsOverride, entries, query, sortedGroups, typeEntryMap])
useVisibleNotesSync({ visibleNotesRef, isEntityView, searched, searchedGroups })
useVisibleNotesSync({ visibleNotesRef, isEntityView, entityEntry, searched, searchedGroups })
return {
customProperties,
displayPropsOverride,
entityEntry,
handleSortChange,
isArchivedView,
isEntityView,
@@ -200,15 +210,16 @@ function useNoteListContent({
interface UseNoteListInteractionStateParams {
searched: VaultEntry[]
searchedGroups: Array<{ entries: VaultEntry[] }>
selectedNotePath: string | null
selection: SidebarSelection
noteListFilter: NoteListFilter
isArchivedView: boolean
isChangesView: boolean
isEntityView: boolean
entityEntry: VaultEntry | null
modifiedFiles?: ModifiedFile[]
onReplaceActiveTab: (entry: VaultEntry) => void
onSelectNote: (entry: VaultEntry) => void
onEnterNeighborhood?: (entry: VaultEntry) => void
onOpenDeletedNote?: (entry: DeletedNoteEntry) => void
onOpenInNewWindow?: (entry: VaultEntry) => void
onAutoTriggerDiff?: () => void
@@ -220,15 +231,16 @@ interface UseNoteListInteractionStateParams {
function useNoteListInteractionState({
searched,
searchedGroups,
selectedNotePath,
selection,
noteListFilter,
isArchivedView,
isChangesView,
isEntityView,
entityEntry,
modifiedFiles,
onReplaceActiveTab,
onSelectNote,
onEnterNeighborhood,
onOpenDeletedNote,
onOpenInNewWindow,
onAutoTriggerDiff,
@@ -248,13 +260,14 @@ function useNoteListInteractionState({
toggleGroup,
} = useNoteListInteractions({
searched,
searchedGroups,
selectedNotePath,
selection,
noteListFilter,
isEntityView,
isChangesView,
entityEntry,
onReplaceActiveTab,
onSelectNote,
onEnterNeighborhood,
onOpenDeletedNote,
onOpenInNewWindow,
onAutoTriggerDiff,
@@ -316,11 +329,11 @@ function useRenderItem({
}: UseRenderItemParams) {
const contextMenuHandler = isChangesView && onDiscardFile ? noteContextMenu : undefined
return useCallback((entry: VaultEntry) => (
return useCallback((entry: VaultEntry, options?: { forceSelected?: boolean }) => (
<NoteItem
key={entry.path}
entry={entry}
isSelected={selectedNotePath === entry.path}
isSelected={options?.forceSelected || selectedNotePath === entry.path}
isMultiSelected={multiSelect.selectedPaths.has(entry.path)}
isHighlighted={entry.path === noteListKeyboard.highlightedPath}
noteStatus={resolvedGetNoteStatus(entry.path)}
@@ -329,6 +342,7 @@ function useRenderItem({
allEntries={entries}
displayPropsOverride={displayPropsOverride}
onClickNote={handleClickNote}
onPrefetch={isDeletedNoteEntry(entry) ? undefined : prefetchNoteContent}
onContextMenu={contextMenuHandler}
/>
), [
@@ -359,6 +373,7 @@ export interface NoteListProps {
sidebarCollapsed?: boolean
onSelectNote: (entry: VaultEntry) => void
onReplaceActiveTab: (entry: VaultEntry) => void
onEnterNeighborhood?: (entry: VaultEntry) => void
onCreateNote: (type?: string) => void
onBulkArchive?: (paths: string[]) => void
onBulkDeletePermanently?: (paths: string[]) => void
@@ -372,6 +387,7 @@ export interface NoteListProps {
onUpdateAllNotesNoteListProperties?: (value: string[] | null) => void
inboxNoteListProperties?: string[] | null
onUpdateInboxNoteListProperties?: (value: string[] | null) => void
onUpdateViewDefinition?: (filename: string, patch: Partial<ViewDefinition>) => void
views?: ViewFile[]
visibleNotesRef?: React.MutableRefObject<VaultEntry[]>
}
@@ -387,7 +403,7 @@ function buildNoteListLayoutModel(params: {
onOpenType: (entry: VaultEntry) => void
content: ReturnType<typeof useNoteListContent>
interaction: ReturnType<typeof useNoteListInteractionState> & {
renderItem: (entry: VaultEntry) => React.ReactNode
renderItem: (entry: VaultEntry, options?: { forceSelected?: boolean }) => React.ReactNode
entitySelection: EntitySelection | null
}
}) {
@@ -451,8 +467,8 @@ export function useNoteListModel({
modifiedFilesError,
getNoteStatus,
sidebarCollapsed,
onSelectNote,
onReplaceActiveTab,
onEnterNeighborhood,
onCreateNote,
onBulkArchive,
onBulkDeletePermanently,
@@ -466,6 +482,7 @@ export function useNoteListModel({
onUpdateAllNotesNoteListProperties,
inboxNoteListProperties,
onUpdateInboxNoteListProperties,
onUpdateViewDefinition,
views,
visibleNotesRef,
}: NoteListProps) {
@@ -486,6 +503,7 @@ export function useNoteListModel({
onUpdateAllNotesNoteListProperties,
inboxNoteListProperties,
onUpdateInboxNoteListProperties,
onUpdateViewDefinition,
onUpdateTypeSort,
updateEntry,
views,
@@ -493,15 +511,16 @@ export function useNoteListModel({
})
const interaction = useNoteListInteractionState({
searched: content.searched,
searchedGroups: content.searchedGroups,
selectedNotePath,
selection,
noteListFilter,
isArchivedView: content.isArchivedView,
isEntityView: content.isEntityView,
isChangesView: selection.kind === 'filter' && selection.filter === 'changes',
entityEntry: content.entityEntry,
modifiedFiles,
onReplaceActiveTab,
onSelectNote,
onEnterNeighborhood,
onOpenDeletedNote,
onOpenInNewWindow,
onAutoTriggerDiff,
@@ -538,7 +557,9 @@ export function useNoteListModel({
interaction: {
...interaction,
renderItem,
entitySelection: content.isEntityView && selection.kind === 'entity' ? selection : null,
entitySelection: content.isEntityView && selection.kind === 'entity'
? { ...selection, entry: content.entityEntry ?? selection.entry }
: null,
},
})
}

View File

@@ -19,18 +19,21 @@ export function SidebarGroupHeader({
children,
}: SidebarGroupHeaderProps) {
return (
<button
className="flex w-full cursor-pointer select-none items-center justify-between border-none bg-transparent text-muted-foreground"
<div
className="flex w-full items-center justify-between text-muted-foreground"
style={{ padding: count != null ? SIDEBAR_GROUP_HEADER_PADDING.withCount : SIDEBAR_GROUP_HEADER_PADDING.regular }}
onClick={onToggle}
>
<div className="flex items-center gap-1">
<button
type="button"
className="flex min-w-0 flex-1 cursor-pointer select-none items-center gap-1 border-none bg-transparent p-0 text-muted-foreground"
onClick={onToggle}
>
{collapsed ? <CaretRight size={12} /> : <CaretDown size={12} />}
<span className="text-[10px] font-semibold" style={{ letterSpacing: 0.5 }}>{label}</span>
</div>
</button>
{children ?? (count != null && (
<SidebarCountPill count={count} className="text-muted-foreground" compact style={{ background: 'var(--muted)' }} />
))}
</button>
</div>
)
}

View File

@@ -13,6 +13,7 @@ import { SlidersHorizontal } from 'lucide-react'
import {
CaretLeft, Plus,
} from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import {
type SectionGroup, isSelectionActive, SectionContent, VisibilityPopover,
} from '../SidebarParts'
@@ -161,21 +162,30 @@ export function TypesSection({
<div ref={customizeRef} style={{ position: 'relative', padding: '0 6px' }}>
<SidebarGroupHeader label="TYPES" collapsed={collapsed} onToggle={onToggle}>
<div className="flex items-center gap-1.5">
<span
role="button"
<Button
type="button"
variant="ghost"
size="icon-xs"
title="Customize sections"
aria-label="Customize sections"
className="h-auto w-auto min-w-0 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={(event) => { event.stopPropagation(); setShowCustomize((value) => !value) }}
>
<SlidersHorizontal size={12} className="text-muted-foreground hover:text-foreground" />
</span>
</Button>
{onCreateNewType && (
<Plus
size={12}
className="text-muted-foreground hover:text-foreground"
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-auto w-auto min-w-0 rounded-none p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
data-testid="create-type-btn"
title="Create new type"
aria-label="Create new type"
onClick={(event) => { event.stopPropagation(); onCreateNewType() }}
/>
>
<Plus size={12} className="text-muted-foreground hover:text-foreground" />
</Button>
)}
</div>
</SidebarGroupHeader>

View File

@@ -35,6 +35,7 @@ interface StatusBarPrimarySectionProps {
vaults: VaultOption[]
onSwitchVault: (path: string) => void
onOpenLocalFolder?: () => void
onCreateEmptyVault?: () => void
onCloneVault?: () => void
onCloneGettingStarted?: () => void
onClickPending?: () => void
@@ -77,6 +78,7 @@ export function StatusBarPrimarySection({
vaults,
onSwitchVault,
onOpenLocalFolder,
onCreateEmptyVault,
onCloneVault,
onCloneGettingStarted,
onClickPending,
@@ -111,6 +113,7 @@ export function StatusBarPrimarySection({
vaultPath={vaultPath}
onSwitchVault={onSwitchVault}
onOpenLocalFolder={onOpenLocalFolder}
onCreateEmptyVault={onCreateEmptyVault}
onCloneVault={onCloneVault}
onCloneGettingStarted={onCloneGettingStarted}
onRemoveVault={onRemoveVault}

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { AlertTriangle, Check, FolderOpen, GitBranch, Rocket, X } from 'lucide-react'
import { AlertTriangle, Check, FolderOpen, GitBranch, Plus, Rocket, X } from 'lucide-react'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { Button } from '@/components/ui/button'
import type { VaultOption } from './types'
@@ -11,6 +11,7 @@ interface VaultMenuProps {
vaultPath: string
onSwitchVault: (path: string) => void
onOpenLocalFolder?: () => void
onCreateEmptyVault?: () => void
onCloneVault?: () => void
onCloneGettingStarted?: () => void
onRemoveVault?: (path: string) => void
@@ -42,12 +43,24 @@ interface VaultAction {
}
function buildVaultActions({
onCreateEmptyVault,
onCloneGettingStarted,
onCloneVault,
onOpenLocalFolder,
}: Pick<VaultMenuProps, 'onCloneGettingStarted' | 'onCloneVault' | 'onOpenLocalFolder'>): VaultAction[] {
}: Pick<VaultMenuProps, 'onCreateEmptyVault' | 'onCloneGettingStarted' | 'onCloneVault' | 'onOpenLocalFolder'>): VaultAction[] {
const items: VaultAction[] = []
if (onCreateEmptyVault) {
items.push({
key: 'create-empty',
icon: <Plus size={12} />,
label: 'Create empty vault',
testId: 'vault-menu-create-empty',
accent: true,
onClick: onCreateEmptyVault,
})
}
if (onOpenLocalFolder) {
items.push({
key: 'open-local',
@@ -158,6 +171,7 @@ export function VaultMenu({
vaultPath,
onSwitchVault,
onOpenLocalFolder,
onCreateEmptyVault,
onCloneVault,
onCloneGettingStarted,
onRemoveVault,
@@ -171,11 +185,12 @@ export function VaultMenu({
const actions = useMemo<VaultAction[]>(() => {
return buildVaultActions({
onCreateEmptyVault,
onCloneGettingStarted,
onCloneVault,
onOpenLocalFolder,
})
}, [onCloneGettingStarted, onCloneVault, onOpenLocalFolder])
}, [onCreateEmptyVault, onCloneGettingStarted, onCloneVault, onOpenLocalFolder])
return (
<div ref={menuRef} style={{ position: 'relative' }}>

View File

@@ -476,6 +476,7 @@ export function TolariaFormattingToolbarController(props: {
})
useBlockNoteFormattingToolbarHoverGuard({
editor,
container:
editor.domElement?.closest('.editor__blocknote-container') ??
editor.domElement ??

View File

@@ -69,6 +69,7 @@ export function useInlineWikilinkSelection({
}, [selectionRange, value])
return {
editorRef,
selectionRange,
selectionIndex: selectionRange.end,
setSelectionRange,

View File

@@ -151,7 +151,13 @@ export const APP_COMMAND_DEFINITIONS: Record<AppCommandId, AppCommandDefinition>
[APP_COMMAND_IDS.fileQuickOpen]: {
route: { kind: 'handler', handler: 'onQuickOpen' },
menuOwned: true,
shortcut: { combo: 'command-or-ctrl', key: 'p', code: 'KeyP', display: '⌘P' },
shortcut: {
combo: 'command-or-ctrl',
key: 'p',
aliases: ['o'],
code: 'KeyP',
display: '⌘P / ⌘O',
},
},
[APP_COMMAND_IDS.fileSave]: {
route: { kind: 'handler', handler: 'onSave' },

View File

@@ -77,6 +77,7 @@ describe('appCommandDispatcher', () => {
})
it('finds raw editor and AI shortcuts from the shared catalog', () => {
expect(findShortcutCommandId('command-or-ctrl', 'o', 'KeyO')).toBe(APP_COMMAND_IDS.fileQuickOpen)
expect(findShortcutCommandId('command-or-ctrl', '\\')).toBe(APP_COMMAND_IDS.editToggleRawEditor)
expect(findShortcutCommandId('command-shift', '¬', 'KeyL')).toBe(APP_COMMAND_IDS.viewToggleAiChat)
})
@@ -128,6 +129,16 @@ describe('appCommandDispatcher', () => {
})
it('resolves event modifiers through the shared shortcut catalog', () => {
expect(
findShortcutCommandIdForEvent({
key: 'o',
code: 'KeyO',
altKey: false,
ctrlKey: false,
metaKey: true,
shiftKey: false,
}),
).toBe(APP_COMMAND_IDS.fileQuickOpen)
expect(
findShortcutCommandIdForEvent({
key: '¬',

View File

@@ -14,7 +14,7 @@ interface NavigationCommandsConfig {
export function buildNavigationCommands(config: NavigationCommandsConfig): CommandAction[] {
const { onQuickOpen, onSelect, showInbox = true, onGoBack, onGoForward, canGoBack, canGoForward } = config
const commands: CommandAction[] = [
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P / ⌘O', keywords: ['find', 'open', 'quick'], enabled: true, execute: onQuickOpen },
{ id: 'go-all', label: 'Go to All Notes', group: 'Navigation', keywords: ['filter'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'all' }) },
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },

View File

@@ -29,4 +29,21 @@ describe('buildSettingsCommands', () => {
enabled: true,
})
})
it('adds a create-empty-vault command when the handler is available', () => {
const onOpenSettings = vi.fn()
const onCreateEmptyVault = vi.fn()
const commands = buildSettingsCommands({ onOpenSettings, onCreateEmptyVault })
const command = commands.find((item) => item.id === 'create-empty-vault')
expect(command).toMatchObject({
label: 'Create Empty Vault…',
enabled: true,
group: 'Settings',
})
command?.execute()
expect(onCreateEmptyVault).toHaveBeenCalledTimes(1)
})
})

View File

@@ -7,6 +7,7 @@ interface SettingsCommandsConfig {
onOpenSettings: () => void
onOpenFeedback?: () => void
onOpenVault?: () => void
onCreateEmptyVault?: () => void
onRemoveActiveVault?: () => void
onRestoreGettingStarted?: () => void
onCheckForUpdates?: () => void
@@ -39,10 +40,12 @@ function buildVaultSettingsCommands({
vaultCount,
isGettingStartedHidden,
onOpenVault,
onCreateEmptyVault,
onRemoveActiveVault,
onRestoreGettingStarted,
}: Pick<SettingsCommandsConfig, 'vaultCount' | 'isGettingStartedHidden' | 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'>): CommandAction[] {
}: Pick<SettingsCommandsConfig, 'vaultCount' | 'isGettingStartedHidden' | 'onOpenVault' | 'onCreateEmptyVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'>): CommandAction[] {
return [
{ id: 'create-empty-vault', label: 'Create Empty Vault…', group: 'Settings', keywords: ['vault', 'create', 'new', 'empty', 'folder'], enabled: !!onCreateEmptyVault, execute: () => onCreateEmptyVault?.() },
{ id: 'open-vault', label: 'Open Vault…', group: 'Settings', keywords: ['vault', 'folder', 'switch', 'open', 'workspace'], enabled: true, execute: () => onOpenVault?.() },
{ id: 'remove-vault', label: 'Remove Vault from List', group: 'Settings', keywords: ['vault', 'remove', 'disconnect', 'hide'], enabled: (vaultCount ?? 0) > 1 && !!onRemoveActiveVault, execute: () => onRemoveActiveVault?.() },
{ id: 'restore-getting-started', label: 'Restore Getting Started Vault', group: 'Settings', keywords: ['vault', 'restore', 'demo', 'getting started', 'reset'], enabled: !!isGettingStartedHidden && !!onRestoreGettingStarted, execute: () => onRestoreGettingStarted?.() },
@@ -65,7 +68,7 @@ function buildMaintenanceCommands({
export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAction[] {
const {
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
} = config
@@ -75,6 +78,7 @@ export function buildSettingsCommands(config: SettingsCommandsConfig): CommandAc
vaultCount,
isGettingStartedHidden,
onOpenVault,
onCreateEmptyVault,
onRemoveActiveVault,
onRestoreGettingStarted,
}),

View File

@@ -51,6 +51,7 @@ interface AppCommandsConfig {
canGoBack?: boolean
canGoForward?: boolean
onOpenVault?: () => void
onCreateEmptyVault?: () => void
onCreateType?: () => void
onToggleAIChat?: () => void
onCheckForUpdates?: () => void
@@ -82,6 +83,7 @@ interface AppCommandsConfig {
onToggleOrganized?: (path: string) => void
onCustomizeNoteListColumns?: () => void
canCustomizeNoteListColumns?: boolean
noteListColumnsLabel?: string
onRestoreDeletedNote?: () => void
canRestoreDeletedNote?: boolean
}
@@ -184,6 +186,7 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters<type
onToggleRawEditor: config.onToggleRawEditor,
onToggleAIChat: config.onToggleAIChat,
onOpenVault: config.onOpenVault,
onCreateEmptyVault: config.onCreateEmptyVault,
activeNoteModified: config.activeNoteModified,
onZoomIn: config.onZoomIn,
onZoomOut: config.onZoomOut,
@@ -224,6 +227,7 @@ function createCommandRegistryConfig(config: AppCommandsConfig): Parameters<type
onToggleOrganized: config.onToggleOrganized,
onCustomizeNoteListColumns: config.onCustomizeNoteListColumns,
canCustomizeNoteListColumns: config.canCustomizeNoteListColumns,
noteListColumnsLabel: config.noteListColumnsLabel,
onRestoreDeletedNote: config.onRestoreDeletedNote,
canRestoreDeletedNote: config.canRestoreDeletedNote,
}

View File

@@ -90,6 +90,13 @@ describe('useAppKeyboard', () => {
expect(actions.onQuickOpen).toHaveBeenCalled()
})
it('Cmd+O triggers quick open', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))
fireKey('o', { metaKey: true, code: 'KeyO' })
expect(actions.onQuickOpen).toHaveBeenCalled()
})
it('Cmd+N triggers create note', () => {
const actions = makeActions()
renderHook(() => useAppKeyboard(actions))

View File

@@ -45,6 +45,7 @@ interface CommandRegistryConfig {
onToggleOrganized?: (path: string) => void
onCustomizeNoteListColumns?: () => void
canCustomizeNoteListColumns?: boolean
noteListColumnsLabel?: string
onRestoreDeletedNote?: () => void
canRestoreDeletedNote?: boolean
onQuickOpen: () => void
@@ -54,6 +55,7 @@ interface CommandRegistryConfig {
onOpenSettings: () => void
onOpenFeedback?: () => void
onOpenVault?: () => void
onCreateEmptyVault?: () => void
onCreateType?: () => void
onDeleteNote: (path: string) => void
onArchiveNote: (path: string) => void
@@ -92,7 +94,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
activeTabPath, entries, modifiedCount,
onQuickOpen, onCreateNote, onCreateNoteOfType, onSave, onOpenSettings, onOpenFeedback,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCreateEmptyVault,
activeNoteModified,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect,
@@ -119,9 +121,11 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
const isArchived = activeEntry?.archived ?? false
const isFavorite = activeEntry?.favorite ?? false
const isSectionGroup = selection?.kind === 'sectionGroup'
const noteListColumnsLabel = selection?.kind === 'filter' && selection.filter === 'all'
? 'Customize All Notes columns'
: 'Customize Inbox columns'
const noteListColumnsLabel = config.noteListColumnsLabel ?? (
selection?.kind === 'filter' && selection.filter === 'all'
? 'Customize All Notes columns'
: 'Customize Inbox columns'
)
const vaultTypes = useMemo(() => extractVaultTypes(entries), [entries])
@@ -143,7 +147,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
}),
...buildSettingsCommands({
mcpStatus, vaultCount, isGettingStartedHidden,
onOpenSettings, onOpenFeedback, onOpenVault, onRemoveActiveVault, onRestoreGettingStarted,
onOpenSettings, onOpenFeedback, onOpenVault, onCreateEmptyVault, onRemoveActiveVault, onRestoreGettingStarted,
onCheckForUpdates, onInstallMcp, onReloadVault, onRepairVault,
}),
...buildAiAgentCommands({
@@ -162,7 +166,7 @@ export function useCommandRegistry(config: CommandRegistryConfig): import('./com
hasActiveNote, activeTabPath, isArchived, modifiedCount, activeNoteModified,
onQuickOpen, onCreateNote, onCreateNoteOfType, onCreateType, onSave, onOpenSettings, onOpenFeedback,
onDeleteNote, onArchiveNote, onUnarchiveNote,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault,
onCommitPush, onPull, onResolveConflicts, onSetViewMode, onToggleInspector, onToggleDiff, onToggleRawEditor, onToggleAIChat, onOpenVault, onCreateEmptyVault,
onCheckForUpdates,
onZoomIn, onZoomOut, onZoomReset, zoomLevel,
onSelect,

View File

@@ -0,0 +1,59 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useDragRegion } from './useDragRegion'
const startDragging = vi.fn().mockResolvedValue(undefined)
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => ({ startDragging }),
}))
function DragRegionHarness() {
const { onMouseDown } = useDragRegion()
return (
<div data-testid="drag-surface" onMouseDown={onMouseDown}>
<div data-testid="no-drag-card" data-no-drag>
<button type="button">Action</button>
</div>
</div>
)
}
describe('useDragRegion', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('starts dragging from the background surface', () => {
render(<DragRegionHarness />)
fireEvent.mouseDown(screen.getByTestId('drag-surface'), { button: 0 })
expect(startDragging).toHaveBeenCalledOnce()
})
it('does not start dragging from no-drag containers', () => {
render(<DragRegionHarness />)
fireEvent.mouseDown(screen.getByTestId('no-drag-card'), { button: 0 })
expect(startDragging).not.toHaveBeenCalled()
})
it('does not start dragging from interactive descendants', () => {
render(<DragRegionHarness />)
fireEvent.mouseDown(screen.getByRole('button', { name: 'Action' }), { button: 0 })
expect(startDragging).not.toHaveBeenCalled()
})
it('ignores non-primary mouse buttons', () => {
render(<DragRegionHarness />)
fireEvent.mouseDown(screen.getByTestId('drag-surface'), { button: 1 })
expect(startDragging).not.toHaveBeenCalled()
})
})

View File

@@ -60,11 +60,7 @@ describe('extractEditorBody', () => {
describe('getH1TextFromBlocks', () => {
it('returns text from H1 heading block', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [{ type: 'text', text: 'My Title', styles: {} }],
}]
const blocks = makeHeadingBlocks([{ type: 'text', text: 'My Title', styles: {} }])
expect(getH1TextFromBlocks(blocks)).toBe('My Title')
})
@@ -81,41 +77,25 @@ describe('getH1TextFromBlocks', () => {
})
it('returns null for H2 heading', () => {
const blocks = [{
type: 'heading',
props: { level: 2 },
content: [{ type: 'text', text: 'Subtitle' }],
}]
const blocks = makeHeadingBlocks([{ type: 'text', text: 'Subtitle' }], 2)
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('concatenates multiple text spans', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [
{ type: 'text', text: 'Hello ' },
{ type: 'text', text: 'World' },
],
}]
const blocks = makeHeadingBlocks([
{ type: 'text', text: 'Hello ' },
{ type: 'text', text: 'World' },
])
expect(getH1TextFromBlocks(blocks)).toBe('Hello World')
})
it('returns null for empty H1 content', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [],
}]
const blocks = makeHeadingBlocks([])
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
it('returns null for whitespace-only H1', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [{ type: 'text', text: ' ' }],
}]
const blocks = makeHeadingBlocks([{ type: 'text', text: ' ' }])
expect(getH1TextFromBlocks(blocks)).toBeNull()
})
@@ -125,14 +105,10 @@ describe('getH1TextFromBlocks', () => {
})
it('filters non-text inline content', () => {
const blocks = [{
type: 'heading',
props: { level: 1 },
content: [
{ type: 'text', text: 'Title' },
{ type: 'wikilink', props: { target: 'linked' } },
],
}]
const blocks = makeHeadingBlocks([
{ type: 'text', text: 'Title' },
{ type: 'wikilink', props: { target: 'linked' } },
])
expect(getH1TextFromBlocks(blocks)).toBe('Title')
})
})
@@ -187,7 +163,7 @@ function makeBlankBodyTab(path: string, title = 'Untitled Note 1') {
}
function makeMockEditor(docRef: { current: unknown[] }) {
return {
const editor = {
document: docRef.current,
get prosemirrorView() { return {} },
onMount: (cb: () => void) => { cb(); return () => {} },
@@ -199,39 +175,92 @@ function makeMockEditor(docRef: { current: unknown[] }) {
_tiptapEditor: { commands: { setContent: vi.fn() } },
_docRef: docRef,
}
Object.defineProperty(editor, 'document', { get: () => docRef.current })
return editor
}
function makeHeadingBlocks(
content: Array<Record<string, unknown>>,
level = 1,
) {
return [{
type: 'heading',
props: { level },
content,
}]
}
async function flushEditorTick() {
await act(() => new Promise<void>((resolve) => setTimeout(resolve, 0)))
}
function installEditorDomSpies(scrollTop = 0) {
const scrollEl = { scrollTop }
const frameSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
cb(0)
return 0
})
vi.spyOn(document, 'querySelector').mockReturnValue(scrollEl as unknown as Element)
return { scrollEl, frameSpy }
}
type SwapHarnessProps = {
tabs: ReturnType<typeof makeTab>[]
activeTabPath: string | null
rawMode?: boolean
}
async function createSwapHarness(options: {
initialProps: SwapHarnessProps
onContentChange?: (path: string, content: string) => void
setupEditor?: (editor: ReturnType<typeof makeMockEditor>) => void
}) {
installEditorDomSpies()
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
options.setupEditor?.(mockEditor)
let currentProps = options.initialProps
const rendered = renderHook(
(props: SwapHarnessProps) => useEditorTabSwap({
...props,
editor: mockEditor as never,
onContentChange: options.onContentChange,
}),
{ initialProps: currentProps },
)
await flushEditorTick()
return {
...rendered,
mockEditor,
async rerenderWith(nextProps: Partial<SwapHarnessProps>) {
currentProps = { ...currentProps, ...nextProps }
rendered.rerender(currentProps)
await flushEditorTick()
},
}
}
describe('useEditorTabSwap raw mode sync', () => {
afterEach(() => { vi.restoreAllMocks() })
it('swaps in the new note when the path updates before tabs catch up', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'March 2024')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
})
mockEditor.tryParseMarkdownToBlocks.mockClear()
mockEditor.replaceBlocks.mockClear()
rerender({ tabs: [tabA], activeTabPath: 'b.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
await rerenderWith({ tabs: [tabA], activeTabPath: 'b.md' })
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
await rerenderWith({ tabs: [tabB] })
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith(
expect.stringContaining('March 2024'),
@@ -240,31 +269,18 @@ describe('useEditorTabSwap raw mode sync', () => {
})
it('signals when the target tab content has been applied', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const swapListener = vi.fn()
window.addEventListener('laputa:editor-tab-swapped', swapListener)
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'March 2024')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
const { rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
})
swapListener.mockClear()
rerender({ tabs: [tabB], activeTabPath: 'b.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })
expect(swapListener).toHaveBeenCalledTimes(1)
const event = swapListener.mock.calls[0][0] as CustomEvent
@@ -274,59 +290,33 @@ describe('useEditorTabSwap raw mode sync', () => {
})
it('hard-resets the editor when the target note body is blank', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const populatedTab = makeTab('a.md', 'Note A')
const untitledTab = makeBlankBodyTab('untitled.md')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false },
})
mockEditor._tiptapEditor.commands.setContent.mockClear()
mockEditor.replaceBlocks.mockClear()
rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
await rerenderWith({ tabs: [untitledTab], activeTabPath: 'untitled.md' })
expect(mockEditor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<p></p>')
expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
})
it('renders empty H1 untitled notes via TipTap HTML content', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const populatedTab = makeTab('a.md', 'Note A')
const untitledTab = makeUntitledTab('untitled.md')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false },
})
mockEditor.tryParseMarkdownToBlocks.mockClear()
mockEditor.replaceBlocks.mockClear()
mockEditor._tiptapEditor.commands.setContent.mockClear()
rerender({ tabs: [untitledTab], activeTabPath: 'untitled.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
await rerenderWith({ tabs: [untitledTab], activeTabPath: 'untitled.md' })
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
expect(mockEditor.replaceBlocks).not.toHaveBeenCalled()
@@ -334,35 +324,43 @@ describe('useEditorTabSwap raw mode sync', () => {
})
it('renders empty H1 typed notes with template content under the title', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const docRef = { current: blocksA as unknown[] }
const mockEditor = makeMockEditor(docRef)
mockEditor.blocksToHTMLLossy.mockReturnValue('<h2>Objective</h2><p></p>')
Object.defineProperty(mockEditor, 'document', { get: () => docRef.current })
const populatedTab = makeTab('a.md', 'Note A')
const typedUntitledTab = makeUntitledTab('untitled.md', 'Untitled Project 1', '## Objective\n\n')
const { rerender } = renderHook(
({ tabs, activeTabPath, rawMode }) => useEditorTabSwap({
tabs, activeTabPath, editor: mockEditor as never, rawMode,
}),
{ initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false as boolean } },
)
await act(() => new Promise(r => setTimeout(r, 0)))
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [populatedTab], activeTabPath: 'a.md', rawMode: false },
setupEditor: (editor) => {
editor.blocksToHTMLLossy.mockReturnValue('<h2>Objective</h2><p></p>')
},
})
mockEditor.tryParseMarkdownToBlocks.mockClear()
mockEditor._tiptapEditor.commands.setContent.mockClear()
rerender({ tabs: [typedUntitledTab], activeTabPath: 'untitled.md', rawMode: false })
await act(() => new Promise(r => setTimeout(r, 0)))
await rerenderWith({ tabs: [typedUntitledTab], activeTabPath: 'untitled.md' })
expect(mockEditor.tryParseMarkdownToBlocks).toHaveBeenCalledWith('## Objective\n\n')
expect(mockEditor._tiptapEditor.commands.setContent).toHaveBeenCalledWith('<h1></h1><h2>Objective</h2><p></p>')
})
it('reuses cached editor blocks when reopening a recently visited note', async () => {
const tabA = makeTab('a.md', 'Note A')
const tabB = makeTab('b.md', 'Note B')
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [tabA], activeTabPath: 'a.md', rawMode: false },
})
await rerenderWith({ tabs: [tabB], activeTabPath: 'b.md' })
mockEditor.tryParseMarkdownToBlocks.mockClear()
mockEditor.replaceBlocks.mockClear()
await rerenderWith({ tabs: [tabA], activeTabPath: 'a.md' })
expect(mockEditor.tryParseMarkdownToBlocks).not.toHaveBeenCalled()
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
})
it('ignores editor change events before the pending tab swap applies a new untitled note', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

View File

@@ -11,7 +11,17 @@ interface Tab {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- BlockNote block arrays
type EditorBlocks = any[]
type CachedTabState = { blocks: EditorBlocks; scrollTop: number }
type CachedTabState = { blocks: EditorBlocks; scrollTop: number; sourceContent: string }
const TAB_STATE_CACHE_LIMIT = 24
interface TabSwapState {
cache: Map<string, CachedTabState>
prevPath: string | null
pathChanged: boolean
activeTab: Tab | undefined
previousTab: Tab | undefined
rawModeJustEnded: boolean
}
interface UseEditorTabSwapOptions {
tabs: Tab[]
@@ -93,15 +103,19 @@ function readEditorScrollTop(): number {
function cacheEditorState(
cache: Map<string, CachedTabState>,
path: string,
blocks: EditorBlocks,
nextState: CachedTabState,
) {
cache.set(path, {
blocks,
scrollTop: readEditorScrollTop(),
})
if (cache.has(path)) cache.delete(path)
cache.set(path, nextState)
while (cache.size > TAB_STATE_CACHE_LIMIT) {
const oldestPath = cache.keys().next().value
if (!oldestPath) return
cache.delete(oldestPath)
}
}
function buildFastPathBlocks(preprocessed: string): EditorBlocks | null {
function buildFastPathBlocks(options: { preprocessed: string }): EditorBlocks | null {
const { preprocessed } = options
const trimmed = preprocessed.trim()
if (!trimmed) {
@@ -124,11 +138,13 @@ function buildFastPathBlocks(preprocessed: string): EditorBlocks | null {
]
}
function isBlankBodyContent(content: string): boolean {
function isBlankBodyContent(options: { content: string }): boolean {
const { content } = options
return extractEditorBody(content).trim() === ''
}
function extractBodyRemainderAfterEmptyH1(content: string): string | null {
function extractBodyRemainderAfterEmptyH1(options: { content: string }): string | null {
const { content } = options
const body = extractEditorBody(content)
const [firstLine, secondLine, ...rest] = body.split('\n')
if (!firstLine) return null
@@ -166,23 +182,22 @@ async function resolveBlocksForTarget(
content: string,
): Promise<CachedTabState> {
const cached = cache.get(targetPath)
if (cached) return cached
if (cached?.sourceContent === content) return cached
const body = extractEditorBody(content)
const preprocessed = preProcessWikilinks(body)
const fastPathBlocks = buildFastPathBlocks(preprocessed)
const fastPathBlocks = buildFastPathBlocks({ preprocessed })
if (fastPathBlocks) {
const nextState = { blocks: fastPathBlocks, scrollTop: 0 }
cache.set(targetPath, nextState)
const nextState = { blocks: fastPathBlocks, scrollTop: 0, sourceContent: content }
cacheEditorState(cache, targetPath, nextState)
return nextState
}
const parsed = await parseMarkdownBlocks(editor, preprocessed)
const withWikilinks = injectWikilinks(parsed)
if (withWikilinks.length > 0) {
cache.set(targetPath, { blocks: withWikilinks, scrollTop: 0 })
}
return { blocks: withWikilinks, scrollTop: 0 }
const nextState = { blocks: withWikilinks, scrollTop: 0, sourceContent: content }
cacheEditorState(cache, targetPath, nextState)
return nextState
}
function applyBlocksToEditor(
@@ -262,7 +277,7 @@ async function resolveEmptyHeadingHtml(
editor: ReturnType<typeof useCreateBlockNote>,
content: string,
): Promise<string | null> {
const remainder = extractBodyRemainderAfterEmptyH1(content)
const remainder = extractBodyRemainderAfterEmptyH1({ content })
if (remainder === null) return null
if (!remainder.trim()) return '<h1></h1><p></p>'
@@ -271,7 +286,11 @@ async function resolveEmptyHeadingHtml(
return `<h1></h1>${editor.blocksToHTMLLossy(withWikilinks as typeof parsed)}`
}
function findActiveTab(tabs: Tab[], activeTabPath: string | null): Tab | undefined {
function findActiveTab(options: {
tabs: Tab[]
activeTabPath: string | null
}): Tab | undefined {
const { tabs, activeTabPath } = options
return activeTabPath
? tabs.find(tab => tab.entry.path === activeTabPath)
: undefined
@@ -282,11 +301,16 @@ function serializeEditorBody(editor: ReturnType<typeof useCreateBlockNote>): str
return compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof editor.document))
}
function normalizeTabBody(content: string): string {
function normalizeTabBody(options: { content: string }): string {
const { content } = options
return compactMarkdown(extractEditorBody(content))
}
function renameBodiesOverlap(currentBody: string, nextBody: string): boolean {
function renameBodiesOverlap(options: {
currentBody: string
nextBody: string
}): boolean {
const { currentBody, nextBody } = options
const current = currentBody.trimEnd()
const next = nextBody.trimEnd()
return current === next
@@ -305,10 +329,10 @@ function isUntitledRenameTransition(
const currentHeading = getH1TextFromBlocks(editor.document)
if (!currentHeading || slugifyPathStem(currentHeading) !== pathStem(nextPath)) return false
return renameBodiesOverlap(
serializeEditorBody(editor),
normalizeTabBody(activeTab.content),
)
return renameBodiesOverlap({
currentBody: serializeEditorBody(editor),
nextBody: normalizeTabBody({ content: activeTab.content }),
})
}
function useLatestRef<T>(value: T): MutableRefObject<T> {
@@ -382,21 +406,27 @@ function consumeRawModeTransition(
function cachePreviousTabOnPathChange(options: {
prevPath: string | null
previousTab: Tab | undefined
pathChanged: boolean
editorMountedRef: MutableRefObject<boolean>
cache: Map<string, CachedTabState>
editor: ReturnType<typeof useCreateBlockNote>
}) {
const { prevPath, pathChanged, editorMountedRef, cache, editor } = options
if (!prevPath || !pathChanged || !editorMountedRef.current) return
cacheEditorState(cache, prevPath, editor.document)
const { prevPath, previousTab, pathChanged, editorMountedRef, cache, editor } = options
if (!prevPath || !previousTab || !pathChanged || !editorMountedRef.current) return
cacheEditorState(cache, prevPath, {
blocks: editor.document,
scrollTop: readEditorScrollTop(),
sourceContent: previousTab.content,
})
}
function shouldWaitForActiveTab(
pathChanged: boolean,
activeTabPath: string | null,
activeTab: Tab | undefined,
) {
function shouldWaitForActiveTab(options: {
pathChanged: boolean
activeTabPath: string | null
activeTab: Tab | undefined
}) {
const { pathChanged, activeTabPath, activeTab } = options
return pathChanged && !!activeTabPath && !activeTab
}
@@ -405,6 +435,7 @@ function syncActivePathTransition(options: {
pathChanged: boolean
activeTabPath: string | null
activeTab: Tab | undefined
previousTab: Tab | undefined
cache: Map<string, CachedTabState>
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
@@ -415,14 +446,22 @@ function syncActivePathTransition(options: {
pathChanged,
activeTabPath,
activeTab,
previousTab,
cache,
editor,
editorMountedRef,
prevActivePathRef,
} = options
cachePreviousTabOnPathChange({ prevPath, pathChanged, editorMountedRef, cache, editor })
if (shouldWaitForActiveTab(pathChanged, activeTabPath, activeTab)) return true
cachePreviousTabOnPathChange({
prevPath,
previousTab,
pathChanged,
editorMountedRef,
cache,
editor,
})
if (shouldWaitForActiveTab({ pathChanged, activeTabPath, activeTab })) return true
if (!preserveUntitledRenameState({
prevPath,
@@ -495,7 +534,11 @@ function cacheStableActivePath(options: {
} = options
if (!activeTabPath || !activeTab || !editorMountedRef.current) return
cacheEditorState(cache, activeTabPath, editor.document)
cacheEditorState(cache, activeTabPath, {
blocks: editor.document,
scrollTop: readEditorScrollTop(),
sourceContent: activeTab.content,
})
}
function preserveUntitledRenameState(options: {
@@ -530,15 +573,21 @@ function preserveUntitledRenameState(options: {
return true
}
function signalTabSwap(path: string) {
function signalTabSwap(options: { path: string }) {
const { path } = options
requestAnimationFrame(() => signalEditorTabSwapped(path))
}
function clearStaleSwap(
targetPath: string,
function clearStaleSwap(options: {
targetPath: string
prevActivePathRef: MutableRefObject<string | null>,
suppressChangeRef: MutableRefObject<boolean>,
): boolean {
}): boolean {
const {
targetPath,
prevActivePathRef,
suppressChangeRef,
} = options
if (prevActivePathRef.current === targetPath) return false
suppressChangeRef.current = false
return true
@@ -547,19 +596,25 @@ function clearStaleSwap(
function applyBlankTabState(options: {
cache: Map<string, CachedTabState>
targetPath: string
content: string
editor: ReturnType<typeof useCreateBlockNote>
suppressChangeRef: MutableRefObject<boolean>
}) {
const {
cache,
targetPath,
content,
editor,
suppressChangeRef,
} = options
cache.set(targetPath, { blocks: blankParagraphBlocks(), scrollTop: 0 })
cacheEditorState(cache, targetPath, {
blocks: blankParagraphBlocks(),
scrollTop: 0,
sourceContent: content,
})
applyBlankStateToEditor(editor, suppressChangeRef)
signalTabSwap(targetPath)
signalTabSwap({ path: targetPath })
}
function scheduleEmptyHeadingSwap(options: {
@@ -577,13 +632,13 @@ function scheduleEmptyHeadingSwap(options: {
suppressChangeRef,
} = options
if (extractBodyRemainderAfterEmptyH1(content) === null) return false
if (extractBodyRemainderAfterEmptyH1({ content }) === null) return false
void resolveEmptyHeadingHtml(editor, content)
.then((html) => {
if (prevActivePathRef.current !== targetPath || !html) return
applyHtmlStateToEditor(editor, html, suppressChangeRef)
signalTabSwap(targetPath)
signalTabSwap({ path: targetPath })
})
.catch((err: unknown) => {
suppressChangeRef.current = false
@@ -614,7 +669,7 @@ function scheduleParsedBlockSwap(options: {
.then(({ blocks, scrollTop }) => {
if (prevActivePathRef.current !== targetPath) return
applyBlocksToEditor(editor, blocks, scrollTop, suppressChangeRef)
signalTabSwap(targetPath)
signalTabSwap({ path: targetPath })
})
.catch((err: unknown) => {
suppressChangeRef.current = false
@@ -646,11 +701,17 @@ function scheduleTabSwap(options: {
suppressChangeRef.current = true
const doSwap = () => {
if (clearStaleSwap(targetPath, prevActivePathRef, suppressChangeRef)) return
if (clearStaleSwap({ targetPath, prevActivePathRef, suppressChangeRef })) return
rawSwapPendingRef.current = false
if (isBlankBodyContent(activeTab.content)) {
applyBlankTabState({ cache, targetPath, editor, suppressChangeRef })
if (isBlankBodyContent({ content: activeTab.content })) {
applyBlankTabState({
cache,
targetPath,
content: activeTab.content,
editor,
suppressChangeRef,
})
return
}
@@ -681,6 +742,75 @@ function scheduleTabSwap(options: {
pendingSwapRef.current = doSwap
}
function resolveTabSwapState(options: {
tabs: Tab[]
activeTabPath: string | null
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>
prevActivePathRef: MutableRefObject<string | null>
rawModeJustEnded: boolean
}): TabSwapState {
const {
tabs,
activeTabPath,
tabCacheRef,
prevActivePathRef,
rawModeJustEnded,
} = options
const prevPath = prevActivePathRef.current
return {
cache: tabCacheRef.current,
prevPath,
pathChanged: prevPath !== activeTabPath,
activeTab: findActiveTab({ tabs, activeTabPath }),
previousTab: findActiveTab({ tabs, activeTabPath: prevPath }),
rawModeJustEnded,
}
}
function shouldSkipScheduledTabSwap(options: {
state: TabSwapState
activeTabPath: string | null
editor: ReturnType<typeof useCreateBlockNote>
editorMountedRef: MutableRefObject<boolean>
prevActivePathRef: MutableRefObject<string | null>
rawSwapPendingRef: MutableRefObject<boolean>
}) {
const {
state,
activeTabPath,
editor,
editorMountedRef,
prevActivePathRef,
rawSwapPendingRef,
} = options
if (syncActivePathTransition({
prevPath: state.prevPath,
pathChanged: state.pathChanged,
activeTabPath,
activeTab: state.activeTab,
previousTab: state.previousTab,
cache: state.cache,
editor,
editorMountedRef,
prevActivePathRef,
})) {
return true
}
return handleStableActivePath({
pathChanged: state.pathChanged,
rawModeJustEnded: state.rawModeJustEnded,
activeTabPath,
activeTab: state.activeTab,
cache: state.cache,
editor,
editorMountedRef,
rawSwapPendingRef,
})
}
function runTabSwapEffect(options: {
tabs: Tab[]
activeTabPath: string | null
@@ -708,46 +838,34 @@ function runTabSwapEffect(options: {
suppressChangeRef,
} = options
const cache = tabCacheRef.current
const prevPath = prevActivePathRef.current
const pathChanged = prevPath !== activeTabPath
const activeTab = findActiveTab(tabs, activeTabPath)
const rawModeJustEnded = consumeRawModeTransition(prevRawModeRef, rawMode)
if (rawMode) return
if (syncActivePathTransition({
prevPath,
pathChanged,
const state = resolveTabSwapState({
tabs,
activeTabPath,
tabCacheRef,
prevActivePathRef,
rawModeJustEnded,
})
if (shouldSkipScheduledTabSwap({
state,
activeTabPath,
activeTab,
cache,
editor,
editorMountedRef,
prevActivePathRef,
})) {
return
}
if (handleStableActivePath({
pathChanged,
rawModeJustEnded,
activeTabPath,
activeTab,
cache,
editor,
editorMountedRef,
rawSwapPendingRef,
})) {
return
}
if (!activeTabPath || !activeTab) return
if (!activeTabPath || !state.activeTab) return
scheduleTabSwap({
editor,
cache,
cache: state.cache,
targetPath: activeTabPath,
activeTab,
activeTab: state.activeTab,
pendingSwapRef,
prevActivePathRef,
rawSwapPendingRef,
@@ -811,23 +929,6 @@ function useTabSwapEffect(options: {
])
}
function useTabCacheCleanup(
tabs: Tab[],
tabCacheRef: MutableRefObject<Map<string, CachedTabState>>,
) {
const tabPathsRef = useRef<Set<string>>(new Set())
useEffect(() => {
const currentPaths = new Set(tabs.map(t => t.entry.path))
for (const path of tabPathsRef.current) {
if (!currentPaths.has(path)) {
tabCacheRef.current.delete(path)
}
}
tabPathsRef.current = currentPaths
}, [tabs, tabCacheRef])
}
/**
* Manages the tab content-swap machinery for the BlockNote editor.
*
@@ -871,7 +972,6 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange,
rawSwapPendingRef,
suppressChangeRef,
})
useTabCacheCleanup(tabs, tabCacheRef)
return { handleEditorChange, editorMountedRef }
}

View File

@@ -1,5 +1,5 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest'
import { useNoteListKeyboard } from './useNoteListKeyboard'
import type { VaultEntry } from '../types'
@@ -30,9 +30,41 @@ function keyEvent(key: string, opts: Partial<React.KeyboardEvent> = {}): React.K
return { key, preventDefault: vi.fn(), metaKey: false, ctrlKey: false, altKey: false, ...opts } as unknown as React.KeyboardEvent
}
function installAnimationFrameStub() {
let nextId = 1
const callbacks = new Map<number, FrameRequestCallback>()
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
const id = nextId++
callbacks.set(id, callback)
return id
})
vi.stubGlobal('cancelAnimationFrame', (id: number) => {
callbacks.delete(id)
})
return {
flushAnimationFrame: () => {
const pending = [...callbacks.entries()]
callbacks.clear()
for (const [, callback] of pending) callback(0)
},
}
}
describe('useNoteListKeyboard', () => {
const items = [makeEntry('/a.md', 'A'), makeEntry('/b.md', 'B'), makeEntry('/c.md', 'C')]
const onOpen = vi.fn()
let flushAnimationFrame: () => void
beforeEach(() => {
vi.clearAllMocks()
;({ flushAnimationFrame } = installAnimationFrameStub())
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('initializes with no highlight', () => {
const { result } = renderHook(() =>
@@ -48,27 +80,38 @@ describe('useNoteListKeyboard', () => {
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(result.current.highlightedPath).toBe('/a.md')
expect(open).not.toHaveBeenCalled()
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledWith(items[0])
})
it('ArrowDown advances highlight', () => {
it('ArrowDown advances highlight and opens the latest highlighted note on the next frame', () => {
const open = vi.fn()
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
useNoteListKeyboard({ items, selectedNotePath: null, onOpen: open, enabled: true }),
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(result.current.highlightedPath).toBe('/b.md')
expect(open).not.toHaveBeenCalled()
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledTimes(1)
expect(open).toHaveBeenCalledWith(items[1])
})
it('ArrowDown clamps at end of list', () => {
const open = vi.fn()
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen, enabled: true }),
useNoteListKeyboard({ items, selectedNotePath: null, onOpen: open, enabled: true }),
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(result.current.highlightedPath).toBe('/c.md')
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledTimes(1)
expect(open).toHaveBeenCalledWith(items[2])
})
it('ArrowUp highlights last item from no selection', () => {
@@ -78,6 +121,8 @@ describe('useNoteListKeyboard', () => {
)
act(() => result.current.handleKeyDown(keyEvent('ArrowUp')))
expect(result.current.highlightedPath).toBe('/c.md')
expect(open).not.toHaveBeenCalled()
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledWith(items[2])
})
@@ -110,7 +155,10 @@ describe('useNoteListKeyboard', () => {
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('Enter')))
expect(open).toHaveBeenCalledTimes(1)
expect(open).toHaveBeenCalledWith(items[0])
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledTimes(1)
})
it('Enter does nothing when no item highlighted', () => {
@@ -185,6 +233,7 @@ describe('useNoteListKeyboard', () => {
})
expect(result.current.highlightedPath).toBe('/a.md')
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledWith(items[0])
})
@@ -208,4 +257,23 @@ describe('useNoteListKeyboard', () => {
editor.remove()
})
it('coalesces rapid arrow navigation into a single open for the latest highlighted note', () => {
const open = vi.fn()
const { result } = renderHook(() =>
useNoteListKeyboard({ items, selectedNotePath: null, onOpen: open, enabled: true }),
)
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
act(() => result.current.handleKeyDown(keyEvent('ArrowDown')))
expect(result.current.highlightedPath).toBe('/c.md')
expect(open).not.toHaveBeenCalled()
act(() => flushAnimationFrame())
expect(open).toHaveBeenCalledTimes(1)
expect(open).toHaveBeenCalledWith(items[2])
})
})

View File

@@ -6,6 +6,7 @@ interface NoteListKeyboardOptions {
items: VaultEntry[]
selectedNotePath: string | null
onOpen: (entry: VaultEntry) => void
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
onPrefetch?: (entry: VaultEntry) => void
enabled: boolean
}
@@ -36,6 +37,26 @@ function isEditableElement(element: Element | null): boolean {
return element.isContentEditable || !!element.closest('[contenteditable="true"]')
}
function isInteractiveElement(element: Element | null): boolean {
if (!element) return false
if (isEditableElement(element)) return true
if (!(element instanceof HTMLElement)) return false
return element instanceof HTMLButtonElement
|| element instanceof HTMLAnchorElement
|| element.getAttribute('role') === 'button'
}
function isNestedInteractiveTarget(
target: EventTarget | null,
currentTarget: EventTarget | null,
): boolean {
return target instanceof Element
&& currentTarget instanceof Element
&& target !== currentTarget
&& currentTarget.contains(target)
&& isInteractiveElement(target)
}
function resolveCurrentIndex(
items: VaultEntry[],
highlightedPath: string | null,
@@ -59,13 +80,20 @@ function moveHighlightIndex(
return nextIndex
}
export function useNoteListKeyboard({
items, selectedNotePath, onOpen, onPrefetch, enabled,
}: NoteListKeyboardOptions) {
const [highlightedPathState, setHighlightedPath] = useState<string | null>(null)
const virtuosoRef = useRef<VirtuosoHandle>(null)
const containerRef = useRef<HTMLDivElement>(null)
const highlightedPathRef = useRef<string | null>(null)
function resolveHighlightedEntry(items: VaultEntry[], highlightedPath: string | null): VaultEntry | undefined {
if (!highlightedPath) return undefined
return items.find((entry) => entry.path === highlightedPath)
}
function usesCommandModifier(event: Pick<KeyboardEvent, 'metaKey' | 'ctrlKey'>): boolean {
return event.metaKey || event.ctrlKey
}
function isNeighborhoodKey(event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey'>): boolean {
return event.key === 'Enter' && usesCommandModifier(event) && !event.altKey
}
function useKeyboardItemRefs(items: VaultEntry[], selectedNotePath: string | null) {
const itemsRef = useRef(items)
const selectedNotePathRef = useRef(selectedNotePath)
@@ -74,16 +102,114 @@ export function useNoteListKeyboard({
selectedNotePathRef.current = selectedNotePath
}, [items, selectedNotePath])
return { itemsRef, selectedNotePathRef }
}
function useHighlightedPath() {
const [highlightedPathState, setHighlightedPath] = useState<string | null>(null)
const highlightedPathRef = useRef<string | null>(null)
const syncHighlightedPath = useCallback((nextPath: string | null) => {
highlightedPathRef.current = nextPath
setHighlightedPath(nextPath)
}, [])
const syncToCurrentSelection = useCallback(() => {
syncHighlightedPath(resolveHighlightedPath(itemsRef.current, selectedNotePathRef.current))
}, [syncHighlightedPath])
return { highlightedPathRef, highlightedPathState, syncHighlightedPath }
}
const moveHighlight = useCallback((direction: 1 | -1) => {
function useSelectionSync(
itemsRef: React.RefObject<VaultEntry[]>,
selectedNotePathRef: React.RefObject<string | null>,
syncHighlightedPath: (nextPath: string | null) => void,
) {
return useCallback(() => {
syncHighlightedPath(resolveHighlightedPath(itemsRef.current, selectedNotePathRef.current))
}, [itemsRef, selectedNotePathRef, syncHighlightedPath])
}
interface ScheduledOpenState {
entry: VaultEntry | null
frameId: number | null
}
function cancelScheduledOpen(stateRef: React.RefObject<ScheduledOpenState>): void {
const frameId = stateRef.current.frameId
if (frameId !== null) cancelAnimationFrame(frameId)
stateRef.current.entry = null
stateRef.current.frameId = null
}
function flushScheduledOpen(
stateRef: React.RefObject<ScheduledOpenState>,
onOpen: (entry: VaultEntry) => void,
entry?: VaultEntry,
): void {
if (entry) stateRef.current.entry = entry
const nextEntry = stateRef.current.entry
if (!nextEntry) return
if (stateRef.current.frameId !== null) cancelAnimationFrame(stateRef.current.frameId)
stateRef.current.entry = null
stateRef.current.frameId = null
onOpen(nextEntry)
}
function scheduleOpenForNextFrame(
stateRef: React.RefObject<ScheduledOpenState>,
onOpen: (entry: VaultEntry) => void,
entry: VaultEntry,
): void {
stateRef.current.entry = entry
if (stateRef.current.frameId !== null) return
stateRef.current.frameId = requestAnimationFrame(() => {
flushScheduledOpen(stateRef, onOpen)
})
}
function useScheduledOpen(onOpen: (entry: VaultEntry) => void, enabled: boolean) {
const stateRef = useRef<ScheduledOpenState>({ entry: null, frameId: null })
const scheduleOpen = useCallback((entry: VaultEntry) => {
scheduleOpenForNextFrame(stateRef, onOpen, entry)
}, [onOpen])
const flushOpen = useCallback((entry?: VaultEntry) => {
flushScheduledOpen(stateRef, onOpen, entry)
}, [onOpen])
const cancelOpen = useCallback(() => {
cancelScheduledOpen(stateRef)
}, [])
useEffect(() => {
if (enabled) return
cancelOpen()
}, [cancelOpen, enabled])
useEffect(() => cancelOpen, [cancelOpen])
return { cancelOpen, flushOpen, scheduleOpen }
}
function useMoveHighlight({
items,
selectedNotePath,
highlightedPathRef,
syncHighlightedPath,
virtuosoRef,
onPrefetch,
scheduleOpen,
}: {
items: VaultEntry[]
selectedNotePath: string | null
highlightedPathRef: React.RefObject<string | null>
syncHighlightedPath: (nextPath: string | null) => void
virtuosoRef: React.RefObject<VirtuosoHandle | null>
onPrefetch?: (entry: VaultEntry) => void
scheduleOpen: (entry: VaultEntry) => void
}) {
return useCallback((direction: 1 | -1) => {
const currentIndex = resolveCurrentIndex(items, highlightedPathRef.current, selectedNotePath)
const nextIndex = moveHighlightIndex(currentIndex, direction, items.length)
const currentPath = highlightedPathRef.current ?? selectedNotePath
@@ -92,31 +218,137 @@ export function useNoteListKeyboard({
syncHighlightedPath(nextItem.path)
virtuosoRef.current?.scrollIntoView({ index: nextIndex, behavior: 'auto' })
onOpen(nextItem)
scheduleOpen(nextItem)
onPrefetch?.(nextItem)
}, [items, onOpen, onPrefetch, selectedNotePath, syncHighlightedPath])
}, [highlightedPathRef, items, onPrefetch, scheduleOpen, selectedNotePath, syncHighlightedPath, virtuosoRef])
}
const processKeyDown = useCallback((e: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'preventDefault'>) => {
function resolveEntryForActivation(
items: VaultEntry[],
highlightedPathRef: React.RefObject<string | null>,
): VaultEntry | undefined {
return resolveHighlightedEntry(items, highlightedPathRef.current)
}
function handleNeighborhoodActivation(options: {
event: Pick<KeyboardEvent, 'preventDefault'>
items: VaultEntry[]
highlightedPathRef: React.RefObject<string | null>
cancelOpen: () => void
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
}): boolean {
const {
event,
items,
highlightedPathRef,
cancelOpen,
onEnterNeighborhood,
} = options
const highlightedItem = resolveEntryForActivation(items, highlightedPathRef)
if (!highlightedItem) return false
event.preventDefault()
cancelOpen()
void onEnterNeighborhood?.(highlightedItem)
return true
}
function handleArrowNavigation(
event: Pick<KeyboardEvent, 'key' | 'preventDefault'>,
moveHighlight: (direction: 1 | -1) => void,
): boolean {
if (event.key === 'ArrowDown') {
event.preventDefault()
moveHighlight(1)
return true
}
if (event.key === 'ArrowUp') {
event.preventDefault()
moveHighlight(-1)
return true
}
return false
}
function handleHighlightedOpen(options: {
event: Pick<KeyboardEvent, 'preventDefault'>
items: VaultEntry[]
highlightedPathRef: React.RefObject<string | null>
flushOpen: (entry?: VaultEntry) => void
}): boolean {
const {
event,
items,
highlightedPathRef,
flushOpen,
} = options
const highlightedItem = resolveEntryForActivation(items, highlightedPathRef)
if (!highlightedItem) return false
event.preventDefault()
flushOpen(highlightedItem)
return true
}
function useProcessKeyDown({
enabled,
items,
highlightedPathRef,
moveHighlight,
flushOpen,
cancelOpen,
onEnterNeighborhood,
}: {
enabled: boolean
items: VaultEntry[]
highlightedPathRef: React.RefObject<string | null>
moveHighlight: (direction: 1 | -1) => void
flushOpen: (entry?: VaultEntry) => void
cancelOpen: () => void
onEnterNeighborhood?: (entry: VaultEntry) => void | Promise<void>
}) {
return useCallback((event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'altKey' | 'preventDefault'>) => {
if (!enabled || items.length === 0) return
if (e.metaKey || e.ctrlKey || e.altKey) return
if (e.key === 'ArrowDown') {
e.preventDefault()
moveHighlight(1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
moveHighlight(-1)
} else if (e.key === 'Enter' && highlightedPathRef.current) {
e.preventDefault()
const highlightedItem = items.find((entry) => entry.path === highlightedPathRef.current)
if (highlightedItem) onOpen(highlightedItem)
if (isNeighborhoodKey(event)) {
handleNeighborhoodActivation({
event,
items,
highlightedPathRef,
cancelOpen,
onEnterNeighborhood,
})
return
}
}, [enabled, items, moveHighlight, onOpen])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
processKeyDown(e)
}, [processKeyDown])
if (usesCommandModifier(event) || event.altKey) return
if (handleArrowNavigation(event, moveHighlight)) return
if (event.key !== 'Enter') return
handleHighlightedOpen({
event,
items,
highlightedPathRef,
flushOpen,
})
}, [cancelOpen, enabled, flushOpen, highlightedPathRef, items, moveHighlight, onEnterNeighborhood])
}
function useFocusHandlers({
containerRef,
syncToCurrentSelection,
syncHighlightedPath,
}: {
containerRef: React.RefObject<HTMLDivElement | null>
syncToCurrentSelection: () => void
syncHighlightedPath: (nextPath: string | null) => void
}) {
const handleFocus = useCallback(() => {
syncToCurrentSelection()
}, [syncToCurrentSelection])
@@ -133,20 +365,80 @@ export function useNoteListKeyboard({
requestAnimationFrame(() => {
if (isListActive(containerRef.current)) syncToCurrentSelection()
})
}, [syncToCurrentSelection])
}, [containerRef, syncToCurrentSelection])
return { focusList, handleBlur, handleFocus }
}
function useGlobalKeyboardHandling({
enabled,
containerRef,
processKeyDown,
}: {
enabled: boolean
containerRef: React.RefObject<HTMLDivElement | null>
processKeyDown: (event: KeyboardEvent) => void
}) {
useEffect(() => {
if (!enabled) return
const handleWindowKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented) return
if (isEditableElement(document.activeElement)) return
const activeElement = document.activeElement
if (isEditableElement(activeElement)) return
if (
activeElement !== containerRef.current
&& containerRef.current?.contains(activeElement)
&& isInteractiveElement(activeElement)
) return
processKeyDown(event)
}
window.addEventListener('keydown', handleWindowKeyDown)
return () => window.removeEventListener('keydown', handleWindowKeyDown)
}, [enabled, processKeyDown])
}, [containerRef, enabled, processKeyDown])
}
export function useNoteListKeyboard({
items, selectedNotePath, onOpen, onEnterNeighborhood, onPrefetch, enabled,
}: NoteListKeyboardOptions) {
const virtuosoRef = useRef<VirtuosoHandle>(null)
const containerRef = useRef<HTMLDivElement>(null)
const { itemsRef, selectedNotePathRef } = useKeyboardItemRefs(items, selectedNotePath)
const { highlightedPathRef, highlightedPathState, syncHighlightedPath } = useHighlightedPath()
const syncToCurrentSelection = useSelectionSync(itemsRef, selectedNotePathRef, syncHighlightedPath)
const { cancelOpen, flushOpen, scheduleOpen } = useScheduledOpen(onOpen, enabled)
const moveHighlight = useMoveHighlight({
items,
selectedNotePath,
highlightedPathRef,
syncHighlightedPath,
virtuosoRef,
onPrefetch,
scheduleOpen,
})
const processKeyDown = useProcessKeyDown({
enabled,
items,
highlightedPathRef,
moveHighlight,
flushOpen,
cancelOpen,
onEnterNeighborhood,
})
const handleKeyDown = useCallback((event: React.KeyboardEvent) => {
if (isNestedInteractiveTarget(event.target, event.currentTarget)) return
processKeyDown(event)
}, [processKeyDown])
const { focusList, handleBlur, handleFocus } = useFocusHandlers({
containerRef,
syncToCurrentSelection,
syncHighlightedPath,
})
useGlobalKeyboardHandling({ enabled, containerRef, processKeyDown })
useEffect(() => {
cancelOpen()
}, [cancelOpen, selectedNotePath])
const highlightedPath = items.some((entry) => entry.path === highlightedPathState)
? highlightedPathState

View File

@@ -239,7 +239,7 @@ describe('useOnboarding', () => {
await expectStatus(result, 'welcome')
await act(async () => {
await result.current.handleCreateNewVault()
await result.current.handleCreateEmptyVault()
})
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/new/vault' })
@@ -248,7 +248,7 @@ describe('useOnboarding', () => {
it('does nothing when the empty-vault picker is cancelled', async () => {
await expectCancelledPickerLeavesWelcome(async (onboarding) => {
await onboarding.handleCreateNewVault()
await onboarding.handleCreateEmptyVault()
})
})

View File

@@ -131,7 +131,7 @@ export function useOnboarding(
await createTemplateVault(lastTemplatePath)
}, [createTemplateVault, lastTemplatePath])
const handleCreateNewVault = useCallback(async () => {
const handleCreateEmptyVault = useCallback(async () => {
try {
setError(null)
const path = await pickFolder('Choose where to create your vault')
@@ -174,7 +174,7 @@ export function useOnboarding(
canRetryTemplate: !!error && !!lastTemplatePath && creatingAction === null,
handleCreateVault,
retryCreateVault,
handleCreateNewVault,
handleCreateEmptyVault,
handleOpenFolder,
handleDismiss,
userReadyVaultPath,

View File

@@ -4,6 +4,7 @@ import type { FrontmatterValue } from '../components/Inspector'
import type { ParsedFrontmatter } from '../utils/frontmatter'
import {
type PropertyDisplayMode,
getEffectiveDisplayMode,
loadDisplayModeOverrides,
saveDisplayModeOverride,
removeDisplayModeOverride,
@@ -22,6 +23,14 @@ function coerceValue(raw: string): FrontmatterValue {
return raw
}
function coerceNumberValue(raw: string): FrontmatterValue {
const trimmed = raw.trim()
if (trimmed === '') return ''
const parsed = Number(trimmed)
return Number.isFinite(parsed) ? parsed : raw
}
function parseNewValue(rawValue: string): FrontmatterValue {
if (!rawValue.includes(',')) return rawValue.trim() || ''
const items = rawValue.split(',').map(s => s.trim()).filter(s => s)
@@ -125,6 +134,7 @@ function isHiddenPropertyKey(key: string): boolean {
function parseAddedValue(rawValue: string, mode: PropertyDisplayMode): FrontmatterValue {
if (mode === 'boolean') return rawValue.toLowerCase() === 'true'
if (mode === 'number') return coerceNumberValue(rawValue)
if (mode === 'tags') {
const items = rawValue.split(',').map(s => s.trim()).filter(s => s)
return items
@@ -137,6 +147,37 @@ function persistModeOverride(key: string, mode: PropertyDisplayMode | null) {
else saveDisplayModeOverride(key, mode)
}
function saveScalarProperty({
key,
newValue,
frontmatter,
displayOverrides,
onUpdateProperty,
onDeleteProperty,
}: {
key: string
newValue: string
frontmatter: ParsedFrontmatter
displayOverrides: Record<string, PropertyDisplayMode>
onUpdateProperty: (key: string, value: FrontmatterValue) => void
onDeleteProperty?: (key: string) => void
}) {
const currentValue = frontmatter[key] ?? null
const displayMode = getEffectiveDisplayMode(key, currentValue, displayOverrides)
if (displayMode !== 'number') {
onUpdateProperty(key, coerceValue(newValue))
return
}
const trimmed = newValue.trim()
if (trimmed === '') {
onDeleteProperty?.(key)
return
}
onUpdateProperty(key, coerceNumberValue(newValue))
}
export interface PropertyPanelDeps {
entries: VaultEntry[] | undefined
entryIsA: string | null
@@ -159,8 +200,9 @@ export function usePropertyPanelState(deps: PropertyPanelDeps) {
const handleSaveValue = useCallback((key: string, newValue: string) => {
setEditingKey(null)
if (onUpdateProperty) onUpdateProperty(key, coerceValue(newValue))
}, [onUpdateProperty])
if (!onUpdateProperty) return
saveScalarProperty({ key, newValue, frontmatter, displayOverrides, onUpdateProperty, onDeleteProperty })
}, [displayOverrides, frontmatter, onDeleteProperty, onUpdateProperty])
const handleSaveList = useCallback((key: string, newItems: string[]) => {
if (!onUpdateProperty) return

View File

@@ -266,6 +266,20 @@ describe('useTabManagement (single-note model)', () => {
expect(vi.mocked(mockInvoke)).toHaveBeenCalledTimes(1)
})
it('activates a warmed note immediately while reusing the cached content', async () => {
cacheNoteContent('/vault/note/warm.md', '# Warm content')
const { result } = renderHook(() => useTabManagement())
act(() => {
void result.current.handleSelectNote(makeEntry({ path: '/vault/note/warm.md', title: 'Warm' }))
})
expect(result.current.activeTabPath).toBe('/vault/note/warm.md')
expect(result.current.tabs).toHaveLength(1)
expect(result.current.tabs[0].content).toBe('# Warm content')
})
it('reuses cached content when reopening a recently loaded note', async () => {
const { mockInvoke } = await import('../mock-tauri')
vi.mocked(mockInvoke)

View File

@@ -14,14 +14,15 @@ type NotePath = VaultEntry['path']
// Stores in-flight or recently loaded note content promises, keyed by path.
// Cleared on vault reload to prevent stale content after external edits.
// Latency profile: deduplicates rapid note switches and keeps revisits instant.
const prefetchCache = new Map<string, Promise<string>>()
const NOTE_CONTENT_CACHE_LIMIT = 48
interface NoteContentCacheEntry {
path: NotePath
promise: Promise<string>
value: string | null
}
const prefetchCache = new Map<string, NoteContentCacheEntry>()
const NOTE_CONTENT_CACHE_LIMIT = 48
function trimPrefetchCache(): void {
while (prefetchCache.size > NOTE_CONTENT_CACHE_LIMIT) {
const oldestPath = prefetchCache.keys().next().value
@@ -30,23 +31,35 @@ function trimPrefetchCache(): void {
}
}
function rememberNoteContent({ path, promise }: NoteContentCacheEntry): Promise<string> {
function rememberNoteContent(entry: NoteContentCacheEntry): NoteContentCacheEntry {
const { path } = entry
if (prefetchCache.has(path)) prefetchCache.delete(path)
prefetchCache.set(path, promise)
prefetchCache.set(path, entry)
trimPrefetchCache()
return promise
return entry
}
function requestNoteContent({ path }: Pick<NoteContentCacheEntry, 'path'>): Promise<string> {
function requestNoteContent({ path }: Pick<NoteContentCacheEntry, 'path'>): NoteContentCacheEntry {
const cacheEntry: NoteContentCacheEntry = {
path,
promise: Promise.resolve(''),
value: null,
}
const promise = (isTauri()
? invoke<string>('get_note_content', { path })
: mockInvoke<string>('get_note_content', { path })
).catch((err) => {
prefetchCache.delete(path)
throw err
})
)
.then((content) => {
cacheEntry.value = content
return content
})
.catch((err) => {
prefetchCache.delete(path)
throw err
})
return rememberNoteContent({ path, promise })
cacheEntry.promise = promise
return rememberNoteContent(cacheEntry)
}
/** Prefetch a note's content into the in-memory cache.
@@ -58,7 +71,11 @@ export function prefetchNoteContent(path: string): void {
}
export function cacheNoteContent(path: string, content: string): void {
rememberNoteContent({ path, promise: Promise.resolve(content) })
rememberNoteContent({
path,
promise: Promise.resolve(content),
value: content,
})
}
/** Clear the prefetch cache. Call on vault reload to prevent stale content. */
@@ -66,8 +83,12 @@ export function clearPrefetchCache(): void {
prefetchCache.clear()
}
function getCachedNoteContent(path: string): string | null {
return prefetchCache.get(path)?.value ?? null
}
async function loadNoteContent(path: string): Promise<string> {
return prefetchCache.get(path) ?? requestNoteContent({ path })
return prefetchCache.get(path)?.promise ?? requestNoteContent({ path }).promise
}
export type { Tab }
@@ -102,6 +123,76 @@ function isAlreadyViewingPath(
return activeTabPathRef.current === path || tabsRef.current.some((tab) => tab.entry.path === path)
}
function startEntryNavigation(options: {
entry: VaultEntry
navSeqRef: React.MutableRefObject<number>
tabsRef: React.MutableRefObject<Tab[]>
activeTabPathRef: React.MutableRefObject<string | null>
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
setActiveTabPath: React.Dispatch<React.SetStateAction<string | null>>
}) {
const {
entry,
navSeqRef,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
} = options
const seq = ++navSeqRef.current
const cachedContent = getCachedNoteContent(entry.path)
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
if (cachedContent !== null) {
setSingleTab(tabsRef, setTabs, { entry, content: cachedContent })
}
return { seq, cachedContent }
}
function shouldApplyLoadedEntry(options: {
seq: number
navSeqRef: React.MutableRefObject<number>
cachedContent: string | null
content: string
activeTabPathRef: React.MutableRefObject<string | null>
path: string
}) {
const {
seq,
navSeqRef,
cachedContent,
content,
activeTabPathRef,
path,
} = options
if (navSeqRef.current !== seq) return false
return cachedContent !== content || activeTabPathRef.current !== path
}
function handleEntryLoadFailure(options: {
entry: VaultEntry
seq: number
navSeqRef: React.MutableRefObject<number>
tabsRef: React.MutableRefObject<Tab[]>
setTabs: React.Dispatch<React.SetStateAction<Tab[]>>
error: unknown
}) {
const {
entry,
seq,
navSeqRef,
tabsRef,
setTabs,
error,
} = options
console.warn('Failed to load note content:', error)
if (navSeqRef.current !== seq) return
setSingleTab(tabsRef, setTabs, { entry, content: '' })
}
async function navigateToEntry(options: {
entry: VaultEntry
navSeqRef: React.MutableRefObject<number>
@@ -125,17 +216,35 @@ async function navigateToEntry(options: {
return
}
const seq = ++navSeqRef.current
syncActiveTabPath(activeTabPathRef, setActiveTabPath, entry.path)
const { seq, cachedContent } = startEntryNavigation({
entry,
navSeqRef,
tabsRef,
activeTabPathRef,
setTabs,
setActiveTabPath,
})
try {
const content = await loadNoteContent(entry.path)
if (navSeqRef.current !== seq) return
if (!shouldApplyLoadedEntry({
seq,
navSeqRef,
cachedContent,
content,
activeTabPathRef,
path: entry.path,
})) return
setSingleTab(tabsRef, setTabs, { entry, content })
} catch (err) {
console.warn('Failed to load note content:', err)
if (navSeqRef.current !== seq) return
setSingleTab(tabsRef, setTabs, { entry, content: '' })
handleEntryLoadFailure({
entry,
seq,
navSeqRef,
tabsRef,
setTabs,
error: err,
})
}
}

View File

@@ -32,14 +32,17 @@ vi.mock('../utils/vault-dialog', () => ({
pickFolder: vi.fn(),
}))
type MockInvokeOverrides = {
checkVaultExists?: boolean
createEmptyVault?: (args: { targetPath: string }) => Promise<unknown> | unknown
createGettingStartedVault?: (args: { targetPath: string }) => Promise<unknown> | unknown
}
describe('useVaultSwitcher', () => {
const onSwitch = vi.fn()
const onToast = vi.fn()
beforeEach(() => {
vi.resetAllMocks()
mockVaultListStore = { vaults: [], active_vault: null, hidden_defaults: [] }
// Re-set default implementation after resetAllMocks
const setMockInvokeBehavior = (overrides: MockInvokeOverrides = {}) => {
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>): Promise<unknown> => {
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
if (cmd === 'save_vault_list') {
@@ -47,9 +50,37 @@ describe('useVaultSwitcher', () => {
return Promise.resolve(null)
}
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
if (cmd === 'check_vault_exists') return Promise.resolve(true)
if (cmd === 'check_vault_exists') return Promise.resolve(overrides.checkVaultExists ?? true)
if (cmd === 'create_empty_vault' && overrides.createEmptyVault) {
return Promise.resolve().then(() => overrides.createEmptyVault?.(args as { targetPath: string }))
}
if (cmd === 'create_getting_started_vault' && overrides.createGettingStartedVault) {
return Promise.resolve().then(() => overrides.createGettingStartedVault?.(args as { targetPath: string }))
}
return Promise.resolve(null)
})
}
const renderLoadedVaultSwitcher = async () => {
const hook = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => {
expect(hook.result.current.loaded).toBe(true)
})
return hook
}
const setWorkVaultWithHiddenGettingStarted = () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [expectedDefaultVaultPath],
}
}
beforeEach(() => {
vi.resetAllMocks()
mockVaultListStore = { vaults: [], active_vault: null, hidden_defaults: [] }
setMockInvokeBehavior()
})
it('starts with default vaults', async () => {
@@ -206,6 +237,42 @@ describe('useVaultSwitcher', () => {
expect(onToast).toHaveBeenCalledWith('Vault "MyVault" opened')
})
it('creates an empty vault and switches to it', async () => {
const { pickFolder } = await import('../utils/vault-dialog')
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/New Vault')
setMockInvokeBehavior({
createEmptyVault: ({ targetPath }) => targetPath,
})
const { result } = await renderLoadedVaultSwitcher()
await act(async () => {
await result.current.handleCreateEmptyVault()
})
expect(mockInvokeFn).toHaveBeenCalledWith('create_empty_vault', { targetPath: '/Users/luca/New Vault' })
expect(result.current.vaultPath).toBe('/Users/luca/New Vault')
expect(result.current.allVaults.some(v => v.path === '/Users/luca/New Vault')).toBe(true)
expect(onToast).toHaveBeenCalledWith('Vault "New Vault" created and opened')
})
it('shows a friendly toast when empty-vault creation targets a non-empty folder', async () => {
const { pickFolder } = await import('../utils/vault-dialog')
vi.mocked(pickFolder).mockResolvedValue('/Users/luca/Busy Folder')
setMockInvokeBehavior({
createEmptyVault: () => Promise.reject('Choose an empty folder to create a new vault'),
})
const { result } = await renderLoadedVaultSwitcher()
await act(async () => {
await result.current.handleCreateEmptyVault()
})
expect(result.current.vaultPath).toBe(expectedDefaultVaultPath)
expect(onToast).toHaveBeenCalledWith('Choose an empty folder to create a new vault')
})
describe('removeVault', () => {
it('removes an extra vault from the list', async () => {
mockVaultListStore = {
@@ -304,11 +371,7 @@ describe('useVaultSwitcher', () => {
describe('restoreGettingStarted', () => {
it('un-hides the Getting Started vault', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [expectedDefaultVaultPath],
}
setWorkVaultWithHiddenGettingStarted()
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
@@ -324,11 +387,7 @@ describe('useVaultSwitcher', () => {
})
it('switches to the Getting Started vault after restoring', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [expectedDefaultVaultPath],
}
setWorkVaultWithHiddenGettingStarted()
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
@@ -342,25 +401,13 @@ describe('useVaultSwitcher', () => {
})
it('attempts to create vault on disk if it does not exist', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [expectedDefaultVaultPath],
}
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
if (cmd === 'save_vault_list') {
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
return Promise.resolve(null)
}
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
if (cmd === 'check_vault_exists') return Promise.resolve(false)
if (cmd === 'create_getting_started_vault') return Promise.resolve(expectedDefaultVaultPath)
return Promise.resolve(null)
setWorkVaultWithHiddenGettingStarted()
setMockInvokeBehavior({
checkVaultExists: false,
createGettingStartedVault: ({ targetPath }) => targetPath,
})
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
const { result } = await renderLoadedVaultSwitcher()
await act(async () => {
await result.current.restoreGettingStarted()
@@ -371,27 +418,13 @@ describe('useVaultSwitcher', () => {
})
it('shows a friendly toast and keeps the hidden vault hidden when cloning fails', async () => {
mockVaultListStore = {
vaults: [{ label: 'Work', path: '/work/vault' }],
active_vault: '/work/vault',
hidden_defaults: [expectedDefaultVaultPath],
}
mockInvokeFn.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
if (cmd === 'load_vault_list') return Promise.resolve({ ...mockVaultListStore })
if (cmd === 'save_vault_list') {
mockVaultListStore = { ...(args as { list: PersistedVaultList }).list }
return Promise.resolve(null)
}
if (cmd === 'get_default_vault_path') return Promise.resolve(mockDefaultVaultPath)
if (cmd === 'check_vault_exists') return Promise.resolve(false)
if (cmd === 'create_getting_started_vault') {
return Promise.reject('git clone failed: fatal: unable to access')
}
return Promise.resolve(null)
setWorkVaultWithHiddenGettingStarted()
setMockInvokeBehavior({
checkVaultExists: false,
createGettingStartedVault: () => Promise.reject('git clone failed: fatal: unable to access'),
})
const { result } = renderHook(() => useVaultSwitcher({ onSwitch, onToast }))
await waitFor(() => { expect(result.current.loaded).toBe(true) })
const { result } = await renderLoadedVaultSwitcher()
await act(async () => {
await result.current.restoreGettingStarted()

View File

@@ -359,6 +359,21 @@ function formatGettingStartedRestoreError(err: unknown): string {
return `Could not prepare Getting Started vault: ${message}`
}
function formatCreateEmptyVaultError(err: unknown): string {
const message =
typeof err === 'string'
? err
: err instanceof Error
? err.message
: `${err}`
if (message.includes('Choose an empty folder')) {
return message
}
return `Could not create empty vault: ${message}`
}
async function ensureGettingStartedVaultReady(path: string): Promise<void> {
const exists = await tauriCall<boolean>('check_vault_exists', { path })
if (!exists) {
@@ -505,6 +520,25 @@ function useOpenLocalFolderAction(
}, [addAndSwitch, onToastRef])
}
function useCreateEmptyVaultAction(
addAndSwitch: (path: string, label: string) => void,
onToastRef: MutableRefObject<(msg: string) => void>,
) {
return useCallback(async () => {
try {
const targetPath = await pickFolder('Choose where to create your vault')
if (!targetPath) return
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath })
const label = labelFromPath({ path: vaultPath })
addAndSwitch(vaultPath, label)
onToastRef.current(`Vault "${label}" created and opened`)
} catch (err) {
onToastRef.current(formatCreateEmptyVaultError(err))
}
}, [addAndSwitch, onToastRef])
}
function useRemoveVaultAction({
defaultVaults,
extraVaults,
@@ -589,6 +623,7 @@ function useVaultActions({
}, [addVault, switchVault])
return {
handleCreateEmptyVault: useCreateEmptyVaultAction(addAndSwitch, onToastRef),
handleOpenLocalFolder: useOpenLocalFolderAction(addAndSwitch, onToastRef),
handleVaultCloned: useVaultClonedAction(addAndSwitch, onToastRef),
removeVault: useRemoveVaultAction({
@@ -656,7 +691,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
hiddenDefaults,
extraVaults,
)
const { handleOpenLocalFolder, handleVaultCloned, removeVault, restoreGettingStarted, switchVault } = useVaultActions({
const { handleCreateEmptyVault, handleOpenLocalFolder, handleVaultCloned, removeVault, restoreGettingStarted, switchVault } = useVaultActions({
...persistedState,
allVaults,
defaultVaults,
@@ -668,6 +703,7 @@ export function useVaultSwitcher({ onSwitch, onToast }: UseVaultSwitcherOptions)
return {
allVaults,
defaultPath,
handleCreateEmptyVault,
handleOpenLocalFolder,
handleVaultCloned,
isGettingStartedHidden,

View File

@@ -372,7 +372,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
// In mock mode, the demo-vault-v2 path always "exists"
return args.path.includes('demo-vault-v2')
},
create_empty_vault: (args: { target_path: string }) => args.target_path || '/Users/mock/Documents/My Vault',
create_empty_vault: (args: { targetPath?: string; target_path?: string }) => args.targetPath || args.target_path || '/Users/mock/Documents/My Vault',
create_getting_started_vault: (args: { targetPath?: string | null }) => args.targetPath || '/Users/mock/Documents/Getting Started',
register_mcp_tools: () => 'registered',
check_mcp_status: () => 'installed',

View File

@@ -132,6 +132,7 @@ export function createNoteListSpies() {
return {
onSelectNote: vi.fn(),
onReplaceActiveTab: vi.fn(),
onEnterNeighborhood: vi.fn(),
onCreateNote: vi.fn(),
onNoteListFilterChange: vi.fn(),
}
@@ -147,6 +148,7 @@ export function buildNoteListProps(overrides: Partial<NoteListProps> = {}) {
onNoteListFilterChange: spies.onNoteListFilterChange,
onSelectNote: spies.onSelectNote,
onReplaceActiveTab: spies.onReplaceActiveTab,
onEnterNeighborhood: spies.onEnterNeighborhood,
onCreateNote: spies.onCreateNote,
...overrides,
}

View File

@@ -201,6 +201,7 @@ export interface ViewDefinition {
icon: string | null
color: string | null
sort: string | null
listPropertiesDisplay?: string[]
filters: FilterGroup
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { countAllByFilter, countAllNotesByFilter, countByFilter, filterEntries } from './noteListHelpers'
import { buildRelationshipGroups, countAllByFilter, countAllNotesByFilter, countByFilter, filterEntries } from './noteListHelpers'
import { allSelection, makeEntry, mockEntries } from '../test-utils/noteListTestUtils'
describe('filterEntries', () => {
@@ -127,3 +127,109 @@ describe('countAllNotesByFilter', () => {
expect(countAllNotesByFilter(entries)).toEqual({ open: 1, archived: 1 })
})
})
describe('buildRelationshipGroups', () => {
it('omits computed neighborhood groups when they are absent', () => {
const standalone = makeEntry({
path: '/vault/solo.md',
filename: 'solo.md',
title: 'Standalone',
isA: 'Note',
})
const groups = buildRelationshipGroups(standalone, [standalone])
expect(groups).toEqual([])
})
it('allows the same note to appear in multiple relationship groups', () => {
const parent = makeEntry({
path: '/vault/parent.md',
filename: 'parent.md',
title: 'Parent',
isA: 'Project',
relationships: { 'Related to': ['[[shared-note]]'] },
})
const shared = makeEntry({
path: '/vault/shared-note.md',
filename: 'shared-note.md',
title: 'Shared Note',
isA: 'Note',
relatedTo: ['[[parent]]'],
})
const groups = buildRelationshipGroups(parent, [parent, shared])
expect(groups.find((group) => group.label === 'Related to')?.entries).toEqual([shared])
expect(groups.find((group) => group.label === 'Referenced By')?.entries).toEqual([shared])
})
it('includes all inverse relationship groups for non-core relationship keys', () => {
const parent = makeEntry({
path: '/vault/parent.md',
filename: 'parent.md',
title: 'Parent',
isA: 'Project',
})
const topicNote = makeEntry({
path: '/vault/topic-note.md',
filename: 'topic-note.md',
title: 'Topic Note',
isA: 'Note',
relationships: { Topics: ['[[parent]]'] },
})
const mentorNote = makeEntry({
path: '/vault/mentor-note.md',
filename: 'mentor-note.md',
title: 'Mentor Note',
isA: 'Note',
relationships: { Mentors: ['[[parent]]'] },
})
const hostEvent = makeEntry({
path: '/vault/host-event.md',
filename: 'host-event.md',
title: 'Host Event',
isA: 'Event',
relationships: { Hosts: ['[[parent]]'] },
})
const groups = buildRelationshipGroups(parent, [parent, topicNote, mentorNote, hostEvent])
expect(groups.find((group) => group.label === '← Topics')?.entries).toEqual([topicNote])
expect(groups.find((group) => group.label === '← Mentors')?.entries).toEqual([mentorNote])
expect(groups.find((group) => group.label === '← Hosts')?.entries).toEqual([hostEvent])
})
it('does not treat path-qualified refs for a different note as reverse matches', () => {
const parent = makeEntry({
path: '/vault/projects/alpha.md',
filename: 'alpha.md',
title: 'Alpha',
isA: 'Project',
})
const archiveAlpha = makeEntry({
path: '/vault/archive/alpha.md',
filename: 'alpha.md',
title: 'Alpha Archive',
isA: 'Project',
})
const unrelatedChild = makeEntry({
path: '/vault/notes/child.md',
filename: 'child.md',
title: 'Child Note',
isA: 'Note',
belongsTo: ['[[archive/alpha]]'],
})
const unrelatedEvent = makeEntry({
path: '/vault/events/event.md',
filename: 'event.md',
title: 'Review',
isA: 'Event',
relatedTo: ['[[archive/alpha]]'],
})
const groups = buildRelationshipGroups(parent, [parent, archiveAlpha, unrelatedChild, unrelatedEvent])
expect(groups).toEqual([])
})
})

View File

@@ -67,8 +67,20 @@ export function formatSearchSubtitle(entry: VaultEntry): string {
return parts.join(' \u00b7 ')
}
function refMatchesEntry(ref: string, entry: VaultEntry): boolean {
const target = wikilinkTarget(ref).trim()
if (!target) return false
if (target.includes('/')) {
const normalizedTarget = target.replace(/^\/+/, '').replace(/\.md$/, '').toLowerCase()
return entry.path.toLowerCase().endsWith(`/${normalizedTarget}.md`)
}
return resolveEntry([entry], target) !== undefined
}
function refsMatch(refs: string[], entry: VaultEntry): boolean {
return refs.some((ref) => resolveEntry([entry], wikilinkTarget(ref)) !== undefined)
return refs.some((ref) => refMatchesEntry(ref, entry))
}
function resolveRefs(refs: string[], entries: VaultEntry[]): VaultEntry[] {
@@ -254,33 +266,109 @@ function findBacklinks(entity: VaultEntry, allEntries: VaultEntry[]): VaultEntry
class GroupBuilder {
readonly groups: RelationshipGroup[] = []
private readonly seen: Set<string>
private readonly entityPath: string
private readonly allEntries: VaultEntry[]
constructor(entityPath: string, allEntries: VaultEntry[]) {
this.seen = new Set([entityPath])
this.entityPath = entityPath
this.allEntries = allEntries
}
add(label: string, entries: VaultEntry[]) {
const unseen = entries.filter((e) => !this.seen.has(e.path))
if (unseen.length > 0) {
this.groups.push({ label, entries: unseen })
unseen.forEach((e) => this.seen.add(e.path))
const deduped = new Map<string, VaultEntry>()
for (const entry of entries) {
if (entry.path === this.entityPath || deduped.has(entry.path)) continue
deduped.set(entry.path, entry)
}
if (deduped.size === 0) return
this.groups.push({ label, entries: [...deduped.values()] })
}
addFromRefs(label: string, refs: string[]) {
if (refs.length > 0) {
this.add(label, resolveRefs(refs, this.allEntries).sort(sortByModified))
}
this.add(label, resolveRefs(refs, this.allEntries).sort(sortByModified))
}
filterAndAdd(label: string, predicate: (e: VaultEntry) => boolean) {
this.add(label, this.allEntries.filter((e) => !this.seen.has(e.path) && predicate(e)).sort(sortByModified))
this.add(label, this.allEntries.filter(predicate).sort(sortByModified))
}
}
function appendInverseRelationshipEntries(
inverseGroups: Map<string, VaultEntry[]>,
label: string,
entry: VaultEntry,
) {
const existing = inverseGroups.get(label)
if (existing) {
existing.push(entry)
return
}
inverseGroups.set(label, [entry])
}
function resolveInverseRelationshipLabel(key: string, entry: VaultEntry): string {
if (key === 'Belongs to') return entry.isA === 'Event' ? 'Events' : 'Children'
if (key === 'Related to') return entry.isA === 'Event' ? 'Events' : 'Referenced By'
return `${key}`
}
function appendLegacyInverseRelationshipEntries(
inverseGroups: Map<string, VaultEntry[]>,
entity: VaultEntry,
entry: VaultEntry,
) {
if (refsMatch(entry.belongsTo, entity)) {
appendInverseRelationshipEntries(inverseGroups, resolveInverseRelationshipLabel('Belongs to', entry), entry)
}
if (refsMatch(entry.relatedTo, entity)) {
appendInverseRelationshipEntries(inverseGroups, resolveInverseRelationshipLabel('Related to', entry), entry)
}
}
function appendDynamicInverseRelationshipEntries(
inverseGroups: Map<string, VaultEntry[]>,
entity: VaultEntry,
entry: VaultEntry,
) {
for (const [key, refs] of Object.entries(entry.relationships ?? {})) {
if (key === 'Type' || !refsMatch(refs, entity)) continue
appendInverseRelationshipEntries(inverseGroups, resolveInverseRelationshipLabel(key, entry), entry)
}
}
function orderInverseRelationshipLabels(inverseGroups: Map<string, VaultEntry[]>): string[] {
const preferredOrder = ['Children', 'Events', 'Referenced By']
const customLabels = [...inverseGroups.keys()]
.filter((label) => !preferredOrder.includes(label))
.sort((a, b) => a.localeCompare(b))
return [...preferredOrder, ...customLabels]
}
function collectInverseRelationshipGroups(
entity: VaultEntry,
allEntries: VaultEntry[],
): RelationshipGroup[] {
const inverseGroups = new Map<string, VaultEntry[]>()
for (const other of allEntries) {
if (other.path === entity.path) continue
appendLegacyInverseRelationshipEntries(inverseGroups, entity, other)
appendDynamicInverseRelationshipEntries(inverseGroups, entity, other)
}
return orderInverseRelationshipLabels(inverseGroups)
.map((label) => {
const entries = inverseGroups.get(label)
if (!entries) return null
return { label, entries: [...entries].sort(sortByModified) }
})
.filter((group): group is RelationshipGroup => group !== null)
}
export function buildRelationshipGroups(
entity: VaultEntry,
allEntries: VaultEntry[],
@@ -300,9 +388,7 @@ export function buildRelationshipGroups(
.sort((a, b) => a.localeCompare(b))
.forEach((key) => b.addFromRefs(key, rels[key] ?? []))
b.filterAndAdd('Children', (e) => e.isA !== 'Event' && refsMatch(e.belongsTo, entity))
b.filterAndAdd('Events', (e) => e.isA === 'Event' && (refsMatch(e.belongsTo, entity) || refsMatch(e.relatedTo, entity)))
b.filterAndAdd('Referenced By', (e) => e.isA !== 'Event' && refsMatch(e.relatedTo, entity))
collectInverseRelationshipGroups(entity, allEntries).forEach((group) => b.add(group.label, group.entries))
b.add('Backlinks', findBacklinks(entity, allEntries).sort(sortByModified))
return b.groups

View File

@@ -29,6 +29,11 @@ describe('detectPropertyType', () => {
expect(detectPropertyType('published', false)).toBe('boolean')
})
it('detects number from value type', () => {
expect(detectPropertyType('estimate', 3)).toBe('number')
expect(detectPropertyType('ratio', -1.5)).toBe('number')
})
it('detects status from key name', () => {
expect(detectPropertyType('status', 'Active')).toBe('status')
expect(detectPropertyType('Status', 'Draft')).toBe('status')

View File

@@ -2,10 +2,10 @@ import type { FrontmatterValue } from '../components/Inspector'
import { getAppStorageItem } from '../constants/appStorage'
import { isValidCssColor, isColorKeyName } from './colorUtils'
import { updateVaultConfigField } from './vaultConfigStore'
import { CalendarIcon, Type, ToggleLeft, Circle, Link, Tag, Palette } from 'lucide-react'
import { CalendarIcon, Type, ToggleLeft, Circle, Link, Tag, Palette, Hash } from 'lucide-react'
import { canonicalSystemMetadataKey } from './systemMetadata'
export type PropertyDisplayMode = 'text' | 'date' | 'boolean' | 'status' | 'url' | 'tags' | 'color'
export type PropertyDisplayMode = 'text' | 'number' | 'date' | 'boolean' | 'status' | 'url' | 'tags' | 'color'
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?)?/
const COMMON_DATE_RE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/
@@ -33,17 +33,35 @@ function isDateString(value: string): boolean {
return ISO_DATE_RE.test(value) || COMMON_DATE_RE.test(value)
}
function isStatusKey(key: string): boolean {
return keyMatchesPatterns(key, STATUS_KEY_PATTERNS)
}
function isDateKey(key: string): boolean {
return keyMatchesPatterns(key, DATE_KEY_PATTERNS)
}
function isStatusString(key: string, value: string): boolean {
if (isStatusKey(key)) return true
if (isDateKey(key)) return false
return STATUS_VALUES.has(value.toLowerCase())
}
function isColorString(key: string, value: string): boolean {
return isValidCssColor(value) && (value.startsWith('#') || isColorKeyName(key))
}
function detectStringType(key: string, strValue: string): PropertyDisplayMode {
if (isIconKey(key)) return 'text'
if (keyMatchesPatterns(key, STATUS_KEY_PATTERNS)) return 'status'
if (STATUS_VALUES.has(strValue.toLowerCase()) && !keyMatchesPatterns(key, DATE_KEY_PATTERNS)) return 'status'
if (isStatusString(key, strValue)) return 'status'
if (isDateString(strValue)) return 'date'
if (isValidCssColor(strValue) && (strValue.startsWith('#') || isColorKeyName(key))) return 'color'
if (isColorString(key, strValue)) return 'color'
return 'text'
}
export function detectPropertyType(key: string, value: FrontmatterValue): PropertyDisplayMode {
if (value === null || value === undefined) return 'text'
if (typeof value === 'number') return 'number'
if (typeof value === 'boolean') return 'boolean'
if (isIconKey(key)) return 'text'
if (keyMatchesPatterns(key, TAGS_KEY_PATTERNS)) return 'tags'
@@ -95,22 +113,25 @@ export function getEffectiveDisplayMode(
return overrides[key] ?? detectPropertyType(key, value)
}
export function formatDateValue(value: string): string {
function resolveDateFromValue(value: string): Date | null {
const isoMatch = value.match(ISO_DATE_RE)
if (isoMatch) {
const d = new Date(isoMatch[0])
if (!isNaN(d.getTime())) {
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
}
const date = new Date(isoMatch[0])
return Number.isNaN(date.getTime()) ? null : date
}
const parts = value.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/)
if (parts) {
const d = new Date(Number(parts[3]), Number(parts[1]) - 1, Number(parts[2]))
if (!isNaN(d.getTime())) {
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
}
}
return value
if (!parts) return null
const date = new Date(Number(parts[3]), Number(parts[1]) - 1, Number(parts[2]))
return Number.isNaN(date.getTime()) ? null : date
}
export function formatDateValue(value: string): string {
const date = resolveDateFromValue(value)
return date
? date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
: value
}
export function toISODate(value: string): string {
@@ -123,11 +144,12 @@ export function toISODate(value: string): string {
}
export const DISPLAY_MODE_ICONS: Record<PropertyDisplayMode, typeof Type> = {
text: Type, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag, color: Palette,
text: Type, number: Hash, date: CalendarIcon, boolean: ToggleLeft, status: Circle, url: Link, tags: Tag, color: Palette,
}
export const DISPLAY_MODE_OPTIONS: { value: PropertyDisplayMode; label: string }[] = [
{ value: 'text', label: 'Text' },
{ value: 'number', label: 'Number' },
{ value: 'date', label: 'Date' },
{ value: 'boolean', label: 'Boolean' },
{ value: 'status', label: 'Status' },

View File

@@ -0,0 +1,87 @@
import {
buildStableDownloadRedirectPage,
extractStableDmgUrl,
extractStableDmgUrlFromReleases,
resolveStableDmgUrl,
} from './releaseDownloadPage'
describe('extractStableDmgUrl', () => {
it('returns the stable dmg url when present', () => {
expect(
extractStableDmgUrl({
platforms: {
'darwin-aarch64': {
dmg_url: 'https://example.com/Tolaria.dmg',
},
},
}),
).toBe('https://example.com/Tolaria.dmg')
})
it('returns null when the stable dmg url is missing', () => {
expect(
extractStableDmgUrl({
platforms: {
'darwin-aarch64': {
url: 'https://example.com/Tolaria.app.tar.gz',
},
},
}),
).toBeNull()
})
})
describe('buildStableDownloadRedirectPage', () => {
it('builds a redirect page when a stable dmg exists', () => {
const html = buildStableDownloadRedirectPage('https://example.com/Tolaria.dmg')
expect(html).toContain('Tolaria Stable Download')
expect(html).toContain('window.location.replace("https://example.com/Tolaria.dmg");')
expect(html).toContain('Download latest stable DMG')
expect(html).toContain('meta http-equiv="refresh"')
})
it('builds a fallback page when no stable dmg exists yet', () => {
const html = buildStableDownloadRedirectPage(null)
expect(html).toContain('Tolaria Stable Download Unavailable')
expect(html).toContain('View release history')
expect(html).toContain('https://refactoringhq.github.io/tolaria/')
expect(html).not.toContain('window.location.replace(')
})
})
describe('resolveStableDmgUrl', () => {
it('falls back to the latest stable github release dmg when latest.json has no dmg url', () => {
const latestPayload = {
platforms: {
'darwin-aarch64': {
url: 'https://example.com/Tolaria.app.tar.gz',
},
},
}
const releasesPayload = [
{
prerelease: true,
assets: [
{
name: 'Tolaria-alpha.dmg',
browser_download_url: 'https://example.com/alpha.dmg',
},
],
},
{
prerelease: false,
assets: [
{
name: 'Tolaria.dmg',
browser_download_url: 'https://example.com/stable.dmg',
},
],
},
]
expect(extractStableDmgUrlFromReleases(releasesPayload)).toBe('https://example.com/stable.dmg')
expect(resolveStableDmgUrl(latestPayload, releasesPayload)).toBe('https://example.com/stable.dmg')
})
})

View File

@@ -0,0 +1,211 @@
const RELEASE_HISTORY_URL = 'https://refactoringhq.github.io/tolaria/'
type PlatformPayload = {
dmg_url?: unknown
}
type LatestReleasePayload = {
platforms?: Record<string, PlatformPayload | undefined>
}
type ReleaseAssetPayload = {
browser_download_url?: unknown
name?: unknown
}
type GitHubReleasePayload = {
assets?: ReleaseAssetPayload[]
draft?: unknown
prerelease?: unknown
}
type DownloadPageContent = {
buttonLabel: string
destinationUrl: string
helperText: string
message: string
shouldRedirect: boolean
title: string
}
const REDIRECT_PAGE_STYLES = `
:root {
color-scheme: light;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: #f7f6f3;
color: #37352f;
}
main {
width: min(100%, 460px);
background: #ffffff;
border: 1px solid #e9e9e7;
border-radius: 16px;
padding: 24px;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
h1 {
margin: 0 0 12px;
font-size: 1.5rem;
line-height: 1.2;
}
p {
margin: 0 0 12px;
line-height: 1.5;
}
a {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0 16px;
border-radius: 10px;
background: #155dff;
color: #ffffff;
text-decoration: none;
font-weight: 600;
}
a:hover,
a:focus-visible {
background: #1248cc;
}
`
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
export function extractStableDmgUrl(payload: unknown): string | null {
if (!payload || typeof payload !== 'object') return null
const { platforms } = payload as LatestReleasePayload
if (!platforms || typeof platforms !== 'object') return null
const dmgUrl = platforms['darwin-aarch64']?.dmg_url
if (typeof dmgUrl !== 'string') return null
const trimmedUrl = dmgUrl.trim()
return trimmedUrl.length > 0 ? trimmedUrl : null
}
function isPublicStableRelease(release: GitHubReleasePayload): boolean {
return release.draft !== true && release.prerelease !== true
}
function extractAssetDmgUrl(asset: ReleaseAssetPayload): string | null {
if (typeof asset.name !== 'string' || !asset.name.endsWith('.dmg')) return null
if (typeof asset.browser_download_url !== 'string') return null
const trimmedUrl = asset.browser_download_url.trim()
return trimmedUrl.length > 0 ? trimmedUrl : null
}
function findReleaseDmgUrl(release: GitHubReleasePayload): string | null {
if (!isPublicStableRelease(release)) return null
if (!Array.isArray(release.assets)) return null
for (const asset of release.assets) {
const dmgUrl = extractAssetDmgUrl(asset)
if (dmgUrl !== null) return dmgUrl
}
return null
}
export function extractStableDmgUrlFromReleases(payload: unknown): string | null {
if (!Array.isArray(payload)) return null
for (const release of payload) {
if (!release || typeof release !== 'object') continue
const dmgUrl = findReleaseDmgUrl(release as GitHubReleasePayload)
if (dmgUrl !== null) return dmgUrl
}
return null
}
export function resolveStableDmgUrl(
latestPayload: unknown,
releasesPayload: unknown,
): string | null {
return extractStableDmgUrl(latestPayload) ?? extractStableDmgUrlFromReleases(releasesPayload)
}
function buildStableDownloadPageContent(dmgUrl: string | null): DownloadPageContent {
if (dmgUrl !== null) {
return {
buttonLabel: 'Download latest stable DMG',
destinationUrl: dmgUrl,
helperText: 'If the download does not start automatically, use the button below.',
message: 'Redirecting to the latest stable Tolaria DMG.',
shouldRedirect: true,
title: 'Tolaria Stable Download',
}
}
return {
buttonLabel: 'View release history',
destinationUrl: RELEASE_HISTORY_URL,
helperText: 'Use the button below to check the latest release history.',
message: 'No stable Tolaria DMG is available yet.',
shouldRedirect: false,
title: 'Tolaria Stable Download Unavailable',
}
}
function buildRedirectMarkup(destinationUrl: string, shouldRedirect: boolean): string {
if (!shouldRedirect) return ''
const escapedDestinationUrl = escapeHtml(destinationUrl)
return `
<meta http-equiv="refresh" content="0; url=${escapedDestinationUrl}">
<script>
window.location.replace(${JSON.stringify(destinationUrl)});
</script>`
}
export function buildStableDownloadRedirectPage(dmgUrl: string | null): string {
const page = buildStableDownloadPageContent(dmgUrl)
const escapedDestinationUrl = escapeHtml(page.destinationUrl)
const redirectMarkup = buildRedirectMarkup(page.destinationUrl, page.shouldRedirect)
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${page.title}</title>${redirectMarkup}
<style>${REDIRECT_PAGE_STYLES}
</style>
</head>
<body>
<main>
<h1>${page.title}</h1>
<p>${page.message}</p>
<p>${page.helperText}</p>
<a id="download-link" href="${escapedDestinationUrl}">${page.buttonLabel}</a>
</main>
</body>
</html>
`
}

View File

@@ -0,0 +1,224 @@
import { test, expect, type Page } from '@playwright/test'
import { findCommand, openCommandPalette, sendShortcut } from './helpers'
interface MockEntry {
path: string
filename: string
title: string
isA: string
aliases: string[]
belongsTo: string[]
relatedTo: string[]
status: string | null
archived: boolean
modifiedAt: number | null
createdAt: number | null
fileSize: number
snippet: string
wordCount: number
relationships: Record<string, string[]>
outgoingLinks: string[]
properties: Record<string, unknown>
template: null
sort: null
}
interface VaultSeed {
label: string
path: string
noteTitle?: string
}
interface EntryOptions {
fileName?: string
isA?: string
snippet?: string
}
function untitledNoteRow(page: Page) {
return page.getByText(/^Untitled Note(?: \d+)?$/i).first()
}
async function expectFreshVaultSeedEntries(page: Page) {
await expect(page.getByText('AGENTS.md — Tolaria Vault', { exact: true })).toBeVisible()
await expect(page.getByText('CLAUDE.md', { exact: true })).toBeVisible()
await expect(page.getByText('Config', { exact: true })).toHaveCount(0)
}
async function installEmptyVaultMocks(
page: Page,
config: {
createdVaultPath: string
initialVaults: VaultSeed[]
activeVault: string | null
},
) {
await page.addInitScript((mockConfig) => {
const gettingStartedPath = '/Users/mock/Documents/Getting Started'
function buildEntry(vaultPath: string, title: string, options: EntryOptions = {}): MockEntry {
const defaultStem = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const filename = options.fileName ?? `${defaultStem}.md`
return {
path: `${vaultPath}/${filename}`,
filename,
title,
isA: options.isA ?? 'Note',
aliases: [],
belongsTo: [],
relatedTo: [],
status: null,
archived: false,
modifiedAt: 1700000000,
createdAt: null,
fileSize: 256,
snippet: options.snippet ?? `${title} snippet`,
wordCount: 12,
relationships: {},
outgoingLinks: [],
properties: {},
template: null,
sort: null,
}
}
function buildFreshVaultSeedEntries(vaultPath: string): MockEntry[] {
return [
buildEntry(vaultPath, 'AGENTS.md — Tolaria Vault', { fileName: 'AGENTS.md' }),
buildEntry(vaultPath, 'CLAUDE.md', { fileName: 'CLAUDE.md' }),
buildEntry(vaultPath, 'Type', { fileName: 'type.md', isA: 'Type' }),
buildEntry(vaultPath, 'Note', { fileName: 'note.md', isA: 'Type' }),
]
}
function syncEntryContent(entry: MockEntry) {
allContent[entry.path] = `# ${entry.title}\n\n${entry.snippet}`
}
let savedVaults = mockConfig.initialVaults.map(({ label, path }) => ({ label, path }))
let activeVault = mockConfig.activeVault
let hiddenDefaults: string[] = []
const entriesByVault = Object.fromEntries(
mockConfig.initialVaults.map((vault) => [
vault.path,
vault.noteTitle ? [buildEntry(vault.path, vault.noteTitle)] : [],
]),
) satisfies Record<string, MockEntry[]>
const allContent = Object.fromEntries(
Object.values(entriesByVault)
.flat()
.map((entry) => [entry.path, `# ${entry.title}\n\n${entry.snippet}`]),
)
localStorage.clear()
localStorage.setItem('tolaria:claude-code-onboarding-dismissed', '1')
Object.defineProperty(window, 'prompt', {
configurable: true,
value: () => mockConfig.createdVaultPath,
})
let ref: Record<string, unknown> | null = null
Object.defineProperty(window, '__mockHandlers', {
configurable: true,
set(value) {
ref = value as Record<string, unknown>
ref.load_vault_list = () => ({
vaults: [...savedVaults],
active_vault: activeVault,
hidden_defaults: [...hiddenDefaults],
})
ref.save_vault_list = (args: {
list: {
vaults: Array<{ label: string; path: string }>
active_vault: string | null
hidden_defaults?: string[]
}
}) => {
savedVaults = [...args.list.vaults]
activeVault = args.list.active_vault
hiddenDefaults = [...(args.list.hidden_defaults ?? [])]
return null
}
ref.get_default_vault_path = () => gettingStartedPath
ref.check_vault_exists = (args: { path?: string }) =>
savedVaults.some((vault) => vault.path === args.path)
|| args.path === mockConfig.createdVaultPath
ref.create_empty_vault = (args: { targetPath?: string | null }) => {
if (args.targetPath !== mockConfig.createdVaultPath) {
throw new Error(`Unexpected empty vault target: ${args.targetPath}`)
}
entriesByVault[mockConfig.createdVaultPath] = buildFreshVaultSeedEntries(mockConfig.createdVaultPath)
entriesByVault[mockConfig.createdVaultPath].forEach(syncEntryContent)
return mockConfig.createdVaultPath
}
ref.list_vault = (args: { path?: string }) => entriesByVault[args.path ?? activeVault ?? ''] ?? []
ref.list_vault_folders = () => []
ref.list_views = () => []
ref.get_all_content = () => allContent
ref.get_note_content = (args: { path?: string }) => allContent[args.path ?? ''] ?? ''
ref.get_modified_files = () => []
ref.get_file_history = () => []
},
get() {
return ref
},
})
}, config)
}
test('keyboard onboarding can create an empty vault and the first note', async ({ page }) => {
await installEmptyVaultMocks(page, {
createdVaultPath: '/Users/mock/Documents/Fresh Vault',
initialVaults: [],
activeVault: null,
})
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await expect(page.getByTestId('welcome-create-new')).toContainText('Create empty vault')
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
await expectFreshVaultSeedEntries(page)
await sendShortcut(page, 'n', ['Control'])
await expect(untitledNoteRow(page)).toBeVisible({ timeout: 5_000 })
})
test('command palette and bottom bar expose empty-vault creation from the active app shell', async ({ page }) => {
await installEmptyVaultMocks(page, {
createdVaultPath: '/Users/mock/Documents/Client Vault',
initialVaults: [{ label: 'Work Vault', path: '/Users/mock/Work', noteTitle: 'Work Home' }],
activeVault: '/Users/mock/Work',
})
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByTestId('note-list-container')).toBeVisible({ timeout: 5_000 })
await expect(page.getByTestId('status-vault-trigger')).toContainText('Work Vault')
await openCommandPalette(page)
expect(await findCommand(page, 'Create Empty Vault')).toBe(true)
await page.keyboard.press('Escape')
const trigger = page.getByTestId('status-vault-trigger')
await trigger.focus()
await expect(trigger).toBeFocused()
await page.keyboard.press('Enter')
const createEmptyItem = page.getByTestId('vault-menu-create-empty')
await createEmptyItem.focus()
await expect(createEmptyItem).toBeFocused()
await page.keyboard.press('Enter')
await expect(trigger).toContainText('Client Vault')
await expectFreshVaultSeedEntries(page)
await sendShortcut(page, 'n', ['Control'])
await expect(untitledNoteRow(page)).toBeVisible({ timeout: 5_000 })
})

View File

@@ -62,6 +62,42 @@ test('accepting telemetry consent on a fresh start opens the vault choice wizard
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
})
test('telemetry consent still leaves the welcome wizard fully keyboard navigable @smoke', async ({ page }) => {
await mockFreshStart(page, {
activeVault: null,
checkExistingPath: '/Users/mock/Documents/Getting Started',
})
await page.goto('/', { waitUntil: 'domcontentloaded' })
await expect(page.getByTestId('telemetry-decline')).toBeFocused()
await page.keyboard.press('Tab')
await expect(page.getByTestId('telemetry-accept')).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await expect(page.getByTestId('welcome-create-new')).toBeFocused()
await page.keyboard.press('Tab')
await expect(page.getByTestId('welcome-open-folder')).toBeFocused()
let dialogHandled = false
page.once('dialog', async (dialog) => {
expect(dialog.message()).toContain('Open vault folder')
await dialog.dismiss()
dialogHandled = true
})
await page.keyboard.press('Enter')
await expect.poll(() => dialogHandled).toBe(true)
await expect(page.getByTestId('welcome-screen')).toBeVisible()
await page.keyboard.press('Tab')
await expect(page.getByTestId('welcome-create-vault')).toBeFocused()
await page.keyboard.press('Shift+Tab')
await expect(page.getByTestId('welcome-open-folder')).toBeFocused()
})
for (const action of ['accept', 'decline'] as const) {
test(`${action} telemetry still resumes onboarding with only a remembered default vault @smoke`, async ({ page }) => {
await mockFreshStart(page, {

View File

@@ -91,6 +91,12 @@ async function moveMouseInSteps(
}
}
async function expectImageBlockSelected(image: ReturnType<Page['locator']>) {
await expect.poll(async () => (
image.evaluate((node) => Boolean(node.closest('.ProseMirror-selectednode')))
)).toBe(true)
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(90_000)
tempVaultDir = createFixtureVaultCopy()
@@ -117,9 +123,12 @@ test('image toolbar stays usable while the pointer crosses onto its controls', a
await expect(toolbar).toBeVisible({ timeout: 5_000 })
await expect(replaceButton).toBeVisible()
await expectImageBlockSelected(image)
const replaceButtonBox = await replaceButton.boundingBox()
expect(replaceButtonBox).not.toBeNull()
const toolbarBox = await toolbar.boundingBox()
expect(toolbarBox).not.toBeNull()
await moveMouseInSteps(
page,
@@ -127,6 +136,24 @@ test('image toolbar stays usable while the pointer crosses onto its controls', a
x: imageBox!.x + imageBox!.width / 2,
y: imageBox!.y + imageBox!.height / 2,
},
{
x: toolbarBox!.x + toolbarBox!.width / 2,
y: toolbarBox!.y + toolbarBox!.height + 10,
},
{ steps: 12, stepDelayMs: 35 },
)
await page.waitForTimeout(180)
await expect(toolbar).toBeVisible()
await expect(replaceButton).toBeVisible()
await expectImageBlockSelected(image)
await moveMouseInSteps(
page,
{
x: toolbarBox!.x + toolbarBox!.width / 2,
y: toolbarBox!.y + toolbarBox!.height + 10,
},
{
x: replaceButtonBox!.x + replaceButtonBox!.width / 2,
y: replaceButtonBox!.y + replaceButtonBox!.height / 2,
@@ -136,6 +163,7 @@ test('image toolbar stays usable while the pointer crosses onto its controls', a
await expect(toolbar).toBeVisible()
await expect(replaceButton).toBeVisible()
await expectImageBlockSelected(image)
await replaceButton.click()
await expect(page.locator('.bn-panel-popover')).toBeVisible()

View File

@@ -64,6 +64,39 @@ test.describe('keyboard command routing', () => {
})
})
test('desktop shortcut bridge opens quick open through both Cmd+P and Cmd+O @smoke', async ({ page }) => {
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await dispatchShortcutEvent(page, {
key: 'p',
code: 'KeyP',
ctrlKey: false,
metaKey: true,
shiftKey: false,
altKey: false,
bubbles: true,
cancelable: true,
})
await expect(page.getByTestId('quick-open-palette')).toBeVisible({ timeout: 5_000 })
await expect(page.locator('input[placeholder="Search notes..."]')).toBeFocused()
await page.keyboard.press('Escape')
await expect(page.getByTestId('quick-open-palette')).not.toBeVisible({ timeout: 5_000 })
await dispatchShortcutEvent(page, {
key: 'o',
code: 'KeyO',
ctrlKey: false,
metaKey: true,
shiftKey: false,
altKey: false,
bubbles: true,
cancelable: true,
})
await expect(page.getByTestId('quick-open-palette')).toBeVisible({ timeout: 5_000 })
await expect(page.locator('input[placeholder="Search notes..."]')).toBeFocused()
})
test('desktop menu-command bridge toggles organized state through the shared command path @smoke', async ({ page }) => {
await openAlphaProjectInEditor(page)

View File

@@ -0,0 +1,58 @@
import { test, expect } from '@playwright/test'
import fs from 'fs'
import path from 'path'
import { createFixtureVaultCopy, openFixtureVaultDesktopHarness, removeFixtureVaultCopy } from '../helpers/fixtureVault'
let tempVaultDir: string
function alphaProjectPath(vaultPath: string): string {
return path.join(vaultPath, 'project', 'alpha-project.md')
}
test.describe('Number property editing', () => {
test.beforeEach(async ({ page }) => {
tempVaultDir = createFixtureVaultCopy()
await openFixtureVaultDesktopHarness(page, tempVaultDir)
await page.setViewportSize({ width: 1600, height: 900 })
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('properties panel saves number values across raw-mode switches and note reloads @smoke', async ({ page }) => {
const notePath = alphaProjectPath(tempVaultDir)
const noteList = page.getByTestId('note-list-container')
await noteList.getByText('Alpha Project', { exact: true }).click()
await page.keyboard.press('Control+Shift+i')
await expect(page.getByTestId('add-property-row')).toBeVisible()
await page.getByTestId('add-property-row').focus()
await page.keyboard.press('Enter')
await expect(page.getByTestId('add-property-form')).toBeVisible()
await page.keyboard.type('Estimate')
await page.getByTestId('add-property-type-trigger').click()
await page.getByRole('option', { name: 'Number', exact: true }).click()
const numberInput = page.getByTestId('add-property-number-input')
await expect(numberInput).toBeVisible()
await numberInput.focus()
await page.keyboard.type(' -12.5 ')
await page.keyboard.press('Enter')
await expect.poll(() => fs.readFileSync(notePath, 'utf8')).toContain('Estimate: -12.5')
await expect(page.getByTestId('number-display')).toContainText('-12.5')
await page.keyboard.press('Control+Backslash')
const rawEditor = page.locator('.cm-content')
await expect(rawEditor).toBeVisible()
await expect(rawEditor).toContainText('Estimate: -12.5')
await page.keyboard.press('Control+Backslash')
await page.reload({ waitUntil: 'networkidle' })
await noteList.getByText('Alpha Project', { exact: true }).click()
await page.keyboard.press('Control+Shift+i')
await expect(page.getByTestId('number-display')).toContainText('-12.5')
})
})

View File

@@ -22,5 +22,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
"include": ["vite.config.ts", "scripts/**/*.ts"]
}