Compare commits

...

6 Commits

Author SHA1 Message Date
Test
2f658425df chore: fix CodeScene ratchet thresholds to match remote API scores
Remote CodeScene scores (9.60/9.37) were below over-ratcheted thresholds
(9.84/9.38). Floor thresholds to actual remote values.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:48:01 +02:00
Test
5ce1431522 fix: prevent infinite render loop when creating notes
updateEntry's .map() always returned a new array even when no entry matched,
causing unnecessary state changes. During note creation, addEntry uses
startTransition (deferred) while markContentPending calls updateEntry
synchronously — the entry doesn't exist yet, so the no-op .map() produced a
new reference that cascaded into "Maximum update depth exceeded" (which
surfaced as React error #185 in the production WKWebView build).

The fix makes updateEntry bail out (return prev) when no entry was changed,
preventing the spurious state update. Also removes the defensive try-catch
from the previous fix attempt and cleans up an unnecessary setToastMessage
dependency.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 10:33:40 +02:00
Test
36febb75da fix: active section badge shows primary background instead of gray
The inline badgeStyle (background: var(--muted)) was overriding the
Tailwind activeBadgeClassName (bg-primary) because inline styles have
higher specificity. Fixed by not falling back to badgeStyle when
activeBadgeClassName is provided and the item is active.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:50:02 +02:00
Test
8a923a95cf fix: add error handling to createNoteImmediate to prevent crashes
Wraps the note creation flow in try-catch so errors in title
generation, template resolution, or tab opening are logged to
console instead of crashing the app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:34:44 +02:00
Test
c0fed9c5c0 fix: wikilink autocomplete uses relative path, prevent silent rename
Bug 1: Wikilink autocomplete now always inserts the vault-relative path
as the target (e.g. [[docs/adr/0001-tauri-stack|Title]]) instead of
just the filename. This ensures wikilinks are unambiguous and resolve
correctly across subfolders after reload.

Bug 2: unique_dest_path now excludes the source file from collision
checks, preventing false positives where a note's own file is treated
as a naming conflict (causing silent -2 suffix renames).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:15:17 +02:00
Test
4d787d6f84 fix: eliminate scroll stutter and fix breadcrumb shadow consistency
Replace React state (useState + re-render) with direct DOM manipulation
for the breadcrumb title visibility. IntersectionObserver now toggles a
data-title-hidden attribute on the bar element, and CSS handles shadow
+ title visibility. This avoids re-rendering the heavy BlockNote editor
on every scroll intersection change.

Fixes: shadow always appears when title is hidden (CSS-driven, no race),
scroll is smooth (zero React re-renders during scroll).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:48:52 +02:00
24 changed files with 322 additions and 116 deletions

View File

@@ -1,2 +1,2 @@
HOTSPOT_THRESHOLD=9.84
AVERAGE_THRESHOLD=9.38
HOTSPOT_THRESHOLD=9.6
AVERAGE_THRESHOLD=9.37

View File

@@ -1,10 +1,11 @@
---
type: ADR
id: "0032"
title: "Git actions (Changes, Pulse, Commit) in status bar, not sidebar"
title: 0032 Status Bar For Git Actions
status: active
date: 2026-03-31
---
[[Subfolder scanning and folder tree navigation]]
## Context
@@ -16,14 +17,14 @@ The Laputa sidebar originally surfaced git-related affordances — a "Changes" n
## Options considered
- **Keep git items in sidebar** (status quo): familiar placement, visible at all times. Rejected — mixes navigation and action concerns; sidebar becomes harder to scan.
- **Status bar** (chosen): consistent with app conventions (build number, sync status, vault switcher already live there); persistent but unobtrusive; follows macOS app patterns where status/action items live at window bottom.
- **Toolbar / breadcrumb bar**: would require a new chrome layer or polluting the per-note breadcrumb with global git state. Rejected.
* **Keep git items in sidebar** (status quo): familiar placement, visible at all times. Rejected — mixes navigation and action concerns; sidebar becomes harder to scan.
* **Status bar** (chosen): consistent with app conventions (build number, sync status, vault switcher already live there); persistent but unobtrusive; follows macOS app patterns where status/action items live at window bottom.
* **Toolbar / breadcrumb bar**: would require a new chrome layer or polluting the per-note breadcrumb with global git state. Rejected.
## Consequences
- Sidebar props `modifiedCount`, `onCommitPush`, `isGitVault` removed; sidebar renders navigation-only
- `StatusBar` gains `onClickPending`, `onClickPulse`, `onCommitPush`, `isGitVault` props
- Sidebar tests for Changes/Pulse/Commit button removed; StatusBar tests extended
- Users find Commit & Push in the status bar (same location as sync indicators) rather than bottom of sidebar — small discoverability change, offset by status bar being always visible regardless of sidebar collapsed state
- Triggers re-evaluation if: user research shows git actions are hard to discover in the status bar
* Sidebar props `modifiedCount`, `onCommitPush`, `isGitVault` removed; sidebar renders navigation-only
* `StatusBar` gains `onClickPending`, `onClickPulse`, `onCommitPush`, `isGitVault` props
* Sidebar tests for Changes/Pulse/Commit button removed; StatusBar tests extended
* Users find Commit & Push in the status bar (same location as sync indicators) rather than bottom of sidebar — small discoverability change, offset by status bar being always visible regardless of sidebar collapsed state
* Triggers re-evaluation if: user research shows git actions are hard to discover in the status bar

View File

@@ -5,29 +5,30 @@ title: "Subfolder scanning and folder tree navigation"
status: active
date: 2026-03-31
---
## Context
[[0032 Status Bar For Git Actions]]
Supersedes the scanning constraint in [ADR-0006](0006-flat-vault-structure.md) which limited vault indexing to root-level `.md` files plus protected folders (`attachments/`, `assets/`).
Users with folder-based workflows (PARA, Zettelkasten with folders, project directories) could not see or filter notes by directory. The vault scanner silently ignored all subdirectory `.md` files, making Laputa unsuitable for vaults with any folder structure.
## Decision
**Extend the Rust vault scanner to index `.md` files in all visible subdirectories, and expose the vault's folder tree via a new `list_vault_folders` Tauri command so the sidebar can render a collapsible FOLDERS section.**
**Extend the Rust vault scanner to index&#x20;**`.md`**&#x20;files in all visible subdirectories, and expose the vault's folder tree via a new&#x20;**`list_vault_folders`**&#x20;Tauri command so the sidebar can render a collapsible FOLDERS section.**
Hidden directories (names starting with `.`, plus `.git` and `.laputa`) are excluded from both scanning and the folder tree.
## Options considered
- **Option A** (chosen): Scan all subdirectories with `walkdir`, expose separate `list_vault_folders` command — simple, no schema changes to VaultEntry, folder tree is lightweight and independent of the entry cache.
- **Option B**: Add a `folder` field to VaultEntry and derive the tree on the frontend — couples folder metadata to the entry cache, complicates cache invalidation when folders are created/deleted without file changes.
- **Option C**: Keep flat scanning, add a "virtual folders" feature that groups by path prefix from frontmatter — doesn't solve the core problem of missing notes in subdirectories.
* **Option A** (chosen): Scan all subdirectories with `walkdir`, expose separate `list_vault_folders` command — simple, no schema changes to VaultEntry, folder tree is lightweight and independent of the entry cache.
* **Option B**: Add a `folder` field to VaultEntry and derive the tree on the frontend — couples folder metadata to the entry cache, complicates cache invalidation when folders are created/deleted without file changes.
* **Option C**: Keep flat scanning, add a "virtual folders" feature that groups by path prefix from frontmatter — doesn't solve the core problem of missing notes in subdirectories.
## Consequences
- All `.md` files in the vault are now indexed regardless of depth — vaults with many non-note `.md` files (e.g. node_modules) will see spurious entries. Mitigation: hidden directories are already excluded; users can add a `.laputaignore` in the future if needed.
- The git-based cache in `cache.rs` already uses `walkdir` for change detection, so this change aligns scanning with caching.
- `SidebarSelection` gains a new `{ kind: 'folder'; path: string }` variant — all exhaustive switches on selection kind must handle it.
- ADR-0006's "flat vault" principle is relaxed: notes can now live in subdirectories. Type definitions still live in `type/` at the root.
- Re-evaluate if users request recursive folder filtering (currently only direct children are shown when a folder is selected).
* All `.md` files in the vault are now indexed regardless of depth — vaults with many non-note `.md` files (e.g. node_modules) will see spurious entries. Mitigation: hidden directories are already excluded; users can add a `.laputaignore` in the future if needed.
* The git-based cache in `cache.rs` already uses `walkdir` for change detection, so this change aligns scanning with caching.
* `SidebarSelection` gains a new `{ kind: 'folder'; path: string }` variant — all exhaustive switches on selection kind must handle it.
* ADR-0006's "flat vault" principle is relaxed: notes can now live in subdirectories. Type definitions still live in `type/` at the root.
* Re-evaluate if users request recursive folder filtering (currently only direct children are shown when a folder is selected).

View File

@@ -0,0 +1,29 @@
---
type: ADR
id: "0034"
title: "Git repo required — blocking modal enforces vault prerequisite"
status: active
date: 2026-04-01
---
## Context
ADR-0014 (git-based vault cache) and ADR-0021 (push-to-main workflow) both assume the vault is a git repository, but neither codified it as a hard enforcement. In practice, opening a non-git folder silently degraded: the cache couldn't compute a commit hash, Pulse/Changes were empty, and commit/push commands failed. The failure mode was invisible to users.
## Decision
**When the app opens a vault that has no `.git` directory, a blocking modal prevents all app use until the user either initialises a git repository (git init + initial commit, offered as a one-click action) or selects a different vault. The check is performed by a new `is_git_repo` Tauri command. In browser/dev mode, the check fails open (modal is skipped).**
## Options considered
- **Option A** (chosen): Hard block via modal on vault open — unambiguous, prevents silent failures, surfaces the fix immediately. Downside: breaks existing workflows for users with non-git vaults; requires a clear escape hatch (choose different vault).
- **Option B**: Soft warning banner, allow using the app without git — avoids blocking users, but silent failures persist for Pulse/Changes/commit features.
- **Option C**: Auto-init git on vault open without asking — less friction, but surprising; user may not want their vault in git.
## Consequences
- Git is now a first-class prerequisite for Laputa vaults, not just implied by the cache strategy.
- The `is_git_repo` command is intentionally lightweight (checks for `.git` existence only; does not validate remote or commit history).
- The modal offers `git init` + an initial commit as a one-click path, lowering the barrier for new users.
- Browser mode bypasses the check so dev/Storybook workflows are unaffected.
- Re-evaluate if Laputa needs to support non-git vaults (e.g., iCloud-only, shared network drive); at that point ADR-0014 would also need revisiting.

View File

@@ -0,0 +1,31 @@
---
type: ADR
id: "0035"
title: "Path-suffix wikilink resolution for subfolder vaults"
status: active
date: 2026-04-01
---
## Context
ADR-0006 stated that wikilink resolution was "simplified to multi-pass title/filename matching — no path-based matching needed" because the vault was flat. ADR-0033 relaxed the flat-vault constraint by adding subfolder scanning. As a result, wikilinks like `[[docs/adr/0031-foo]]` or `[[adr/0031-foo]]` could not resolve to entries in subdirectories: the resolver only matched on `title` and `filename` stem, never on the vault-relative path.
The backlink detection in the Inspector also used a hardcoded `/Laputa/` path regex, which was wrong for any vault that isn't named "Laputa".
## Decision
**Add path-suffix matching as Pass 1 of wikilink resolution: a link target resolves to a `VaultEntry` if the entry's vault-relative path ends with the link string (with or without `.md`). Filename-stem matching (the previous Pass 1) becomes Pass 2. Inspector backlinks replace the hardcoded `/Laputa/` regex with a generic `targetMatchesEntry` path-suffix helper. Autocomplete pre-filter also matches against the full vault-relative path so subfolder names surface results.**
## Options considered
- **Option A** (chosen): Path-suffix as Pass 1, then filename match as Pass 2 — consistent with how Obsidian resolves links in multi-folder vaults, zero config. Downside: if two notes share the same filename in different folders, only the first (path-suffix) match wins.
- **Option B**: Strict full-path matching only (disable title-stem resolution) — unambiguous, but breaks the majority of existing short-form `[[note-title]]` links.
- **Option C**: Keep title-only matching, require full paths for subfolder notes — backwards-compatible, but forces users to always type full paths for subfolders, defeating the purpose of wikilinks.
## Consequences
- Supersedes the "no path-based matching needed" clause from ADR-0006 (that assumption was contingent on the flat vault invariant, which ADR-0033 relaxed).
- `relativePathStem` utility added in `wikilink.ts` to extract the vault-relative path stem from a full `VaultEntry`.
- The Inspector's `targetMatchesEntry` helper is now the canonical way to test if a link resolves to an entry — use it everywhere instead of ad-hoc regex.
- Wikilink autocomplete suggestions now surface notes in subfolders when users type a folder prefix (e.g. `[[adr/`).
- Re-evaluate if path-suffix ambiguity (two files with the same name in different folders) becomes a user complaint.

View File

@@ -0,0 +1,31 @@
---
type: ADR
id: "0036"
title: "External rename detection via git diff on focus regain"
status: active
date: 2026-04-01
---
## Context
Laputa handles in-app renames (rename.rs) and propagates wikilink updates across the vault. But notes can also be renamed externally — from Finder, another editor, or a git operation (e.g., `git mv`). In those cases, the app had no way to detect that a rename had occurred, leaving wikilinks broken and the vault inconsistent.
The app already uses git for the cache (ADR-0014) and requires git as a vault prerequisite (ADR-0034), making git diff a natural and already-available detection mechanism.
## Decision
**When the app window regains focus, run `git diff --diff-filter=R --name-status HEAD` to detect file renames that occurred since the last committed HEAD. If any renamed `.md` files are found, show a non-blocking banner ("X file(s) renamed — update wikilinks?"). Accepting triggers the existing vault-wide wikilink replacement logic (reused from rename.rs). Ignoring dismisses the banner without changes. New Tauri commands: `detect_renames` and `update_wikilinks_for_renames`.**
## Options considered
- **Option A** (chosen): Git diff on focus regain, non-blocking banner — uses existing infrastructure, non-disruptive, user retains control. Downside: only detects renames that are staged/committed; uncommitted renames via `git mv` are captured, but renames done purely in Finder (no git involvement) are not.
- **Option B**: `FSEvents` / file-system watcher for rename events — catches all renames regardless of git. Downside: significantly more complex, requires Rust async machinery, false positives from editor temp files, and this feature is already planned as a separate enhancement.
- **Option C**: Scan for broken wikilinks on focus — correct but O(n) and noisy; doesn't tell us the new filename.
## Consequences
- Git's rename detection (`--diff-filter=R`) requires the rename to be git-tracked (either staged or committed); renames that happen outside git knowledge are not detected by this mechanism.
- The on-focus check runs `git diff HEAD` which is fast but adds a small shell invocation overhead each time the window activates. This is acceptable for typical vault sizes.
- `rename.rs` is now shared between in-app renames and external rename recovery — the replacement logic is the canonical entry point for wikilink bulk updates.
- The banner is non-blocking and "Ignore" is always available — the user never loses work.
- Re-evaluate if FS-level rename detection (outside git) becomes a priority; at that point this mechanism would be a fallback, not the primary strategy.

View File

@@ -0,0 +1,31 @@
---
type: ADR
id: "0037"
title: "Language-based markdown syntax highlighting in raw editor"
status: active
date: 2026-04-01
---
## Context
The raw editor (CodeMirror 6, introduced in ADR-0022) initially had a custom `frontmatterHighlight` extension that used regex-based decoration for YAML frontmatter and headings. Markdown body content had no syntax highlighting at all, making the raw editor feel like a plain textarea despite being a full CodeMirror instance.
Extending the custom regex-based approach to cover all markdown syntax (bold, italic, links, lists, blockquotes, code) would have been brittle and hard to maintain.
## Decision
**Replace the custom heading decoration in `frontmatterHighlight.ts` with `@codemirror/lang-markdown` (the official CodeMirror language package). A custom `HighlightStyle` maps CodeMirror highlight tags to visual styles for headings, bold, italic, strikethrough, links, lists, blockquotes, and inline code. The frontmatter YAML plugin is retained for YAML-specific colouring but its heading decoration is removed in favour of the language parser.**
## Options considered
- **Option A** (chosen): `@codemirror/lang-markdown` with custom HighlightStyle — uses the official, maintained language parser; future highlight rules are one CSS declaration. Downside: adds a new npm dependency; the custom frontmatter plugin must be kept separately.
- **Option B**: Extend the custom regex plugin to cover all markdown — no new dependency. Downside: regex-based tokenisation is fragile (e.g., nested formatting), already proving hard to maintain after the heading/frontmatter overlap bug.
- **Option C**: Switch to a markdown-aware editor (e.g., Milkdown, Monaco) — full-featured. Downside: major migration, breaks the dual-editor architecture in ADR-0022, significant scope.
## Consequences
- `@codemirror/lang-markdown` added to `package.json` — this is the only new runtime dependency introduced by this change.
- `frontmatterHighlight.ts` is simplified (heading decoration removed); `markdownHighlight.ts` is the new extension responsible for body highlighting.
- The two extensions are composed in `useCodeMirror.ts` — YAML frontmatter block is still styled by the custom plugin; everything else by the language parser.
- Future syntax highlighting changes (e.g., task lists, tables) can be added by extending the `HighlightStyle` without modifying the parser.
- Re-evaluate if `@codemirror/lang-markdown` conflicts with the custom frontmatter YAML handling as the editor evolves (e.g., if frontmatter block needs to be parsed as a code block rather than decorated text).

View File

@@ -89,3 +89,7 @@ proposed → active → superseded
| [0031](0031-full-app-for-note-windows.md) | Full App instance for secondary note windows | active |
| [0032](0032-status-bar-for-git-actions.md) | Git actions (Changes, Pulse, Commit) in status bar, not sidebar | active |
| [0033](0033-subfolder-scanning-and-folder-tree.md) | Subfolder scanning and folder tree navigation | active |
| [0034](0034-git-repo-required-for-vault.md) | Git repo required — blocking modal enforces vault prerequisite | active |
| [0035](0035-path-suffix-wikilink-resolution.md) | Path-suffix wikilink resolution for subfolder vaults | active |
| [0036](0036-external-rename-detection-via-git-diff.md) | External rename detection via git diff on focus regain | active |
| [0037](0037-codemirror-language-markdown-highlighting.md) | Language-based markdown syntax highlighting in raw editor | active |

View File

@@ -5,26 +5,27 @@ title: "Canary release channel and local feature flags"
status: active
date: 2026-03-25
---
## Context
Shipping new features directly to all users is risky. A mechanism was needed to let early adopters test pre-release builds and to gate experimental features behind flags that can be toggled without a new release.
[[Keyboard-first design principle]]
## Decision
**Add a canary release channel alongside stable, with builds from the `canary` branch. Feature flags are localStorage-based (`ff_<name>`) with compile-time defaults, checked via `useFeatureFlag(flag)` hook. The update channel is configurable in Settings.**
**Add a canary release channel alongside stable, with builds from the&#x20;**`canary`**&#x20;branch. Feature flags are localStorage-based (**`ff_<name>`**) with compile-time defaults, checked via&#x20;**`useFeatureFlag(flag)`**&#x20;hook. The update channel is configurable in Settings.**
## Options considered
- **Option A** (chosen): Canary branch + localStorage feature flags — simple, no server infrastructure, users opt in via Settings. Downside: no remote flag management, no gradual rollout percentages.
- **Option B**: Server-side feature flags (LaunchDarkly, Unleash) — gradual rollouts, A/B testing. Downside: external dependency, requires server infrastructure, adds latency.
- **Option C**: Single release channel with only feature flags — simpler CI. Downside: no way to test full pre-release builds.
* **Option A** (chosen): Canary branch + localStorage feature flags — simple, no server infrastructure, users opt in via Settings. Downside: no remote flag management, no gradual rollout percentages.
* **Option B**: Server-side feature flags (LaunchDarkly, Unleash) — gradual rollouts, A/B testing. Downside: external dependency, requires server infrastructure, adds latency.
* **Option C**: Single release channel with only feature flags — simpler CI. Downside: no way to test full pre-release builds.
## Consequences
- `release.yml` builds stable from `main`; `release-canary.yml` builds canary from `canary` branch.
- Canary releases produce `latest-canary.json` on GitHub Pages, marked as prerelease.
- `useUpdater(channel)` checks the appropriate update manifest.
- `useFeatureFlag(flag)` checks localStorage override, then compile-time default. Type-safe via `FeatureFlagName` union.
- `update_channel` stored in Settings as `"stable"` or `"canary"`.
- Re-evaluation trigger: if user base grows enough to warrant server-side gradual rollouts.
* `release.yml` builds stable from `main`; `release-canary.yml` builds canary from `canary` branch.
* Canary releases produce `latest-canary.json` on GitHub Pages, marked as prerelease.
* `useUpdater(channel)` checks the appropriate update manifest.
* `useFeatureFlag(flag)` checks localStorage override, then compile-time default. Type-safe via `FeatureFlagName` union.
* `update_channel` stored in Settings as `"stable"` or `"canary"`.
* Re-evaluation trigger: if user base grows enough to warrant server-side gradual rollouts.

View File

@@ -144,9 +144,10 @@ fn to_path_stem<'a>(abs_path: &'a str, vault_prefix: &str) -> &'a str {
}
/// Determine a unique destination path, appending -2, -3, etc. if a file already exists.
fn unique_dest_path(dest_dir: &Path, filename: &str) -> std::path::PathBuf {
/// `exclude` is the source file being renamed — it should not be treated as a collision.
fn unique_dest_path(dest_dir: &Path, filename: &str, exclude: &Path) -> std::path::PathBuf {
let dest = dest_dir.join(filename);
if !dest.exists() {
if !dest.exists() || dest == exclude {
return dest;
}
let stem = Path::new(filename)
@@ -160,7 +161,7 @@ fn unique_dest_path(dest_dir: &Path, filename: &str) -> std::path::PathBuf {
let mut counter = 2;
loop {
let candidate = dest_dir.join(format!("{}-{}{}", stem, counter, ext));
if !candidate.exists() {
if !candidate.exists() || candidate == exclude {
return candidate;
}
counter += 1;
@@ -223,7 +224,7 @@ pub fn rename_note(
let parent_dir = old_file
.parent()
.ok_or("Cannot determine parent directory")?;
let new_file = unique_dest_path(parent_dir, &expected_filename);
let new_file = unique_dest_path(parent_dir, &expected_filename, old_file);
let new_path_str = new_file.to_string_lossy().to_string();
fs::write(&new_file, &updated_content)

View File

@@ -111,14 +111,9 @@ describe('BreadcrumbBar — archive/unarchive', () => {
})
})
describe('BreadcrumbBar — title in breadcrumb on scroll', () => {
it('does not show title when titleHidden is false', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} titleHidden={false} />)
expect(screen.queryByText('Test Note')).not.toBeInTheDocument()
})
it('shows type and title when titleHidden is true', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} titleHidden={true} />)
describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)', () => {
it('always renders title elements in the DOM', () => {
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
expect(screen.getByText('Note')).toBeInTheDocument()
expect(screen.getByText('')).toBeInTheDocument()
expect(screen.getByText('Test Note')).toBeInTheDocument()
@@ -126,21 +121,29 @@ describe('BreadcrumbBar — title in breadcrumb on scroll', () => {
it('shows emoji icon when entry has an emoji icon', () => {
const entryWithEmoji = { ...baseEntry, icon: '🚀' }
render(<BreadcrumbBar entry={entryWithEmoji} {...defaultProps} titleHidden={true} />)
render(<BreadcrumbBar entry={entryWithEmoji} {...defaultProps} />)
expect(screen.getByText('🚀')).toBeInTheDocument()
})
it('does not show icon when entry has a non-emoji icon', () => {
const entryWithPhosphor = { ...baseEntry, icon: 'cooking-pot' }
render(<BreadcrumbBar entry={entryWithPhosphor} {...defaultProps} titleHidden={true} />)
render(<BreadcrumbBar entry={entryWithPhosphor} {...defaultProps} />)
expect(screen.queryByText('cooking-pot')).not.toBeInTheDocument()
})
it('falls back to "Note" when isA is null', () => {
const entryNoType = { ...baseEntry, isA: null }
render(<BreadcrumbBar entry={entryNoType} {...defaultProps} titleHidden={true} />)
render(<BreadcrumbBar entry={entryNoType} {...defaultProps} />)
expect(screen.getByText('Note')).toBeInTheDocument()
})
it('shadow is controlled by data-title-hidden attribute via CSS', () => {
const { container } = render(<BreadcrumbBar entry={baseEntry} {...defaultProps} />)
const bar = container.querySelector('.breadcrumb-bar')!
expect(bar).not.toHaveAttribute('data-title-hidden')
bar.setAttribute('data-title-hidden', '')
expect(bar).toHaveAttribute('data-title-hidden')
})
})
describe('BreadcrumbBar — raw editor toggle', () => {

View File

@@ -32,8 +32,8 @@ interface BreadcrumbBarProps {
onRestore?: () => void
onArchive?: () => void
onUnarchive?: () => void
/** When true, the note title is scrolled out of view — show it inline. */
titleHidden?: boolean
/** Ref for direct DOM manipulation — avoids re-render on scroll. */
barRef?: React.Ref<HTMLDivElement>
}
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
@@ -178,22 +178,21 @@ function BreadcrumbTitle({ entry }: { entry: VaultEntry }) {
}
export const BreadcrumbBar = memo(function BreadcrumbBar({
entry, titleHidden, ...actionProps
entry, barRef, ...actionProps
}: BreadcrumbBarProps) {
return (
<div
ref={barRef}
data-tauri-drag-region
className="flex shrink-0 items-center"
className="breadcrumb-bar flex shrink-0 items-center"
style={{
height: 52,
background: 'var(--background)',
padding: '6px 16px',
boxShadow: titleHidden ? '0 1px 3px rgba(0,0,0,0.08)' : 'none',
transition: 'box-shadow 0.2s ease',
}}
>
<div className="flex-1 min-w-0">
{titleHidden && <BreadcrumbTitle entry={entry} />}
<div className="breadcrumb-bar__title flex-1 min-w-0">
<BreadcrumbTitle entry={entry} />
</div>
<BreadcrumbActions entry={entry} {...actionProps} />
</div>

View File

@@ -23,6 +23,24 @@
opacity: 0.55;
}
/* Breadcrumb bar: title + shadow toggled via data attribute (no React re-render) */
.breadcrumb-bar {
transition: box-shadow 0.2s ease;
box-shadow: none;
}
.breadcrumb-bar[data-title-hidden] {
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.breadcrumb-bar__title {
display: none;
}
.breadcrumb-bar[data-title-hidden] .breadcrumb-bar__title {
display: flex;
}
/* Scroll area wrapping title + editor — single scroll context for alignment */
.editor-scroll-area {
flex: 1;

View File

@@ -467,7 +467,7 @@ describe('wikilink autocomplete', () => {
expect(items.length).toBeGreaterThan(0)
items[0].onItemClick()
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
{ type: 'wikilink', props: { target: 'Alpha Project' } },
{ type: 'wikilink', props: { target: 'vault/project/test|Alpha Project' } },
' ',
])
})
@@ -620,7 +620,7 @@ describe('person @mention autocomplete', () => {
expect(items.length).toBeGreaterThan(0)
items[0].onItemClick()
expect(mockEditor.insertInlineContent).toHaveBeenCalledWith([
{ type: 'wikilink', props: { target: 'Matteo Cellini' } },
{ type: 'wikilink', props: { target: 'vault/person/matteo-cellini|Matteo Cellini' } },
' ',
])
})

View File

@@ -1,5 +1,5 @@
import type React from 'react'
import { useCallback, useRef, useState, useEffect } from 'react'
import { useCallback, useRef, useEffect } from 'react'
import type { VaultEntry, NoteStatus } from '../types'
import type { useCreateBlockNote } from '@blocknote/react'
import { DiffView } from './DiffView'
@@ -92,7 +92,7 @@ function DiffModeView({ diffContent, onToggleDiff }: { diffContent: string | nul
}
function RawModeEditorSection({
rawMode, activeTab, entries, onContentChange, onSave, latestContentRef,
rawMode, activeTab, entries, onContentChange, onSave, latestContentRef, vaultPath,
}: {
rawMode: boolean
activeTab: Tab | null
@@ -100,6 +100,7 @@ function RawModeEditorSection({
onContentChange?: (path: string, content: string) => void
onSave?: () => void
latestContentRef?: React.MutableRefObject<string | null>
vaultPath?: string
}) {
if (!rawMode || !activeTab) return null
return (
@@ -111,6 +112,7 @@ function RawModeEditorSection({
onContentChange={onContentChange ?? (() => {})}
onSave={onSave ?? (() => {})}
latestContentRef={latestContentRef}
vaultPath={vaultPath}
/>
)
}
@@ -120,9 +122,9 @@ function bindPath(cb: ((path: string) => void) | undefined, path: string) {
return cb ? () => cb(path) : undefined
}
function ActiveTabBreadcrumb({ activeTab, titleHidden, props }: {
function ActiveTabBreadcrumb({ activeTab, barRef, props }: {
activeTab: Tab
titleHidden: boolean
barRef: React.RefObject<HTMLDivElement | null>
props: Omit<EditorContentProps, 'activeTab' | 'isLoadingNewTab' | 'entries' | 'editor' | 'onNavigateWikilink' | 'onEditorChange' | 'onRawContentChange' | 'onSave' | 'onDeleteNote'>
}) {
const wordCount = countWords(activeTab.content)
@@ -131,7 +133,7 @@ function ActiveTabBreadcrumb({ activeTab, titleHidden, props }: {
<BreadcrumbBar
entry={activeTab.entry}
wordCount={wordCount}
titleHidden={titleHidden}
barRef={barRef}
showDiffToggle={props.showDiffToggle}
diffMode={props.diffMode}
diffLoading={props.diffLoading}
@@ -171,18 +173,21 @@ export function EditorContent({
const emojiIcon = entryIcon && isEmoji(entryIcon) ? entryIcon : null
const titleSectionRef = useRef<HTMLDivElement | null>(null)
const [titleScrolledAway, setTitleScrolledAway] = useState(false)
const titleHidden = showEditor && titleScrolledAway
const breadcrumbBarRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
const el = titleSectionRef.current
if (!el) return
const bar = breadcrumbBarRef.current
if (!el || !bar) return
const observer = new IntersectionObserver(
([e]) => setTitleScrolledAway(!e.isIntersecting),
([e]) => {
if (e.isIntersecting) bar.removeAttribute('data-title-hidden')
else bar.setAttribute('data-title-hidden', '')
},
{ threshold: 0 },
)
observer.observe(el)
return () => observer.disconnect()
return () => { observer.disconnect(); bar.removeAttribute('data-title-hidden') }
}, [activeTab?.entry.path, showEditor])
const handleSetIcon = useCallback((emoji: string) => {
@@ -198,7 +203,7 @@ export function EditorContent({
{activeTab && (
<ActiveTabBreadcrumb
activeTab={activeTab}
titleHidden={titleHidden}
barRef={breadcrumbBarRef}
props={{ diffMode, diffContent, onToggleDiff, rawMode, onToggleRaw, ...breadcrumbProps }}
/>
)}
@@ -218,7 +223,7 @@ export function EditorContent({
/>
)}
{diffMode && <DiffModeView diffContent={diffContent} onToggleDiff={onToggleDiff} />}
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} latestContentRef={rawLatestContentRef} />
<RawModeEditorSection rawMode={rawMode} activeTab={activeTab} entries={entries} onContentChange={onRawContentChange} onSave={onSave} latestContentRef={rawLatestContentRef} vaultPath={vaultPath} />
{showEditor && activeTab && (
<div className="editor-scroll-area" style={cssVars as React.CSSProperties}>
<div ref={titleSectionRef} className="title-section">

View File

@@ -21,6 +21,7 @@ export interface RawEditorViewProps {
path: string
entries: VaultEntry[]
onContentChange: (path: string, content: string) => void
vaultPath?: string
onSave: () => void
/** Mutable ref updated on every keystroke with the latest doc string.
* Allows the parent to flush debounced content before unmount. */
@@ -37,7 +38,7 @@ function getCursorCoords(view: EditorView): { top: number; left: number } | null
return { top: coords.bottom, left: coords.left }
}
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef }: RawEditorViewProps) {
export function RawEditorView({ content, path, entries, onContentChange, onSave, latestContentRef, vaultPath }: RawEditorViewProps) {
const containerRef = useRef<HTMLDivElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pathRef = useRef(path)
@@ -92,10 +93,10 @@ export function RawEditorView({ content, path, entries, onContentChange, onSave,
const coords = getCursorCoords(view)
if (!coords) { setAutocomplete(null); return }
const candidates = preFilterWikilinks(baseItems, query)
const withHandlers = attachClickHandlers(candidates, (title: string) => insertWikilinkRef.current(title))
const withHandlers = attachClickHandlers(candidates, (title: string) => insertWikilinkRef.current(title), vaultPath ?? '')
const items = enrichSuggestionItems(withHandlers, query, typeEntryMap)
setAutocomplete({ caretTop: coords.top, caretLeft: coords.left, selectedIndex: 0, items })
}, [baseItems, typeEntryMap])
}, [baseItems, typeEntryMap, vaultPath])
const handleSave = useCallback(() => {
if (debounceRef.current) {

View File

@@ -46,7 +46,7 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
const textClass = compact ? 'text-[12px]' : 'text-[13px]'
const padding = compact ? '4px 16px' : '6px 16px'
const resolvedBadgeClass = isActive && activeBadgeClassName ? activeBadgeClassName : badgeClassName
const resolvedBadgeStyle = isActive && activeBadgeStyle ? activeBadgeStyle : badgeStyle
const resolvedBadgeStyle = isActive && activeBadgeClassName ? activeBadgeStyle : badgeStyle
if (disabled) {
return (

View File

@@ -92,16 +92,16 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
const getWikilinkItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
if (query.length < MIN_QUERY_LENGTH) return []
const candidates = preFilterWikilinks(baseItems, query)
const items = attachClickHandlers(candidates, insertWikilink)
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
return enrichSuggestionItems(items, query, typeEntryMap)
}, [baseItems, insertWikilink, typeEntryMap])
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
const getPersonMentionItems = useCallback(async (query: string): Promise<WikilinkSuggestionItem[]> => {
if (query.length < PERSON_MENTION_MIN_QUERY) return []
const candidates = filterPersonMentions(baseItems, query)
const items = attachClickHandlers(candidates, insertWikilink)
const items = attachClickHandlers(candidates, insertWikilink, vaultPath ?? '')
return enrichSuggestionItems(items, query, typeEntryMap)
}, [baseItems, insertWikilink, typeEntryMap])
}, [baseItems, insertWikilink, typeEntryMap, vaultPath])
return (
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties} onClick={handleContainerClick}>

View File

@@ -257,7 +257,7 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
entries, vaultPath: config.vaultPath, pendingNames: pendingNamesRef.current,
openTabWithContent, addEntry, trackUnsaved: config.trackUnsaved, markContentPending: config.markContentPending,
}, type)
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending, setToastMessage])
}, [entries, openTabWithContent, addEntry, config.vaultPath, config.trackUnsaved, config.markContentPending])
const handleCreateNoteForRelationship = useCallback((title: string): Promise<boolean> => {
createNoteForRelationship({

View File

@@ -128,6 +128,15 @@ describe('useVaultLoader', () => {
expect(result.current.entries[0].archived).toBe(true)
expect(result.current.entries[0].status).toBe('Done')
})
it('preserves entries reference when path does not exist (no-op)', async () => {
const { result } = await renderVaultLoader()
const entriesBefore = result.current.entries
act(() => { result.current.updateEntry('/vault/note/nonexistent.md', { archived: true }) })
expect(result.current.entries).toBe(entriesBefore)
})
})
describe('getNoteStatus', () => {

View File

@@ -133,8 +133,16 @@ export function useVaultLoader(vaultPath: string) {
})
}, [tracker])
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) =>
setEntries((prev) => prev.map((e) => e.path === path ? { ...e, ...patch } : e)), [])
const updateEntry = useCallback((path: string, patch: Partial<VaultEntry>) => {
setEntries((prev) => {
let changed = false
const next = prev.map((e) => {
if (e.path === path) { changed = true; return { ...e, ...patch } }
return e
})
return changed ? next : prev
})
}, [])
const removeEntry = useCallback((path: string) => {
setEntries((prev) => prev.filter((e) => e.path !== path))

View File

@@ -19,56 +19,55 @@ function makeEntry(overrides: Partial<VaultEntry> = {}): VaultEntry {
}
describe('attachClickHandlers', () => {
it('adds onItemClick to each candidate', () => {
const vaultPath = '/vault'
it('inserts relative path stem as wikilink target', () => {
const insertWikilink = vi.fn()
const candidates = [
{ title: 'Note A', aliases: [], group: 'Note', entryTitle: 'Note A', path: '/a.md' },
{ title: 'Note B', aliases: [], group: 'Project', entryTitle: 'Note B', path: '/b.md' },
{ title: 'Note A', aliases: [], group: 'Note', entryTitle: 'Note A', path: '/vault/a.md' },
{ title: 'Note B', aliases: [], group: 'Project', entryTitle: 'Note B', path: '/vault/b.md' },
]
const result = attachClickHandlers(candidates, insertWikilink)
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)
expect(result).toHaveLength(2)
result[0].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('Note A')
expect(insertWikilink).toHaveBeenCalledWith('a|Note A')
result[1].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('Note B')
expect(insertWikilink).toHaveBeenCalledWith('b|Note B')
})
it('preserves all original properties', () => {
const result = attachClickHandlers(
[{ title: 'X', aliases: ['y'], group: 'Topic', entryTitle: 'X', path: '/x.md' }],
[{ title: 'X', aliases: ['y'], group: 'Topic', entryTitle: 'X', path: '/vault/x.md' }],
vi.fn(),
vaultPath,
)
expect(result[0]).toMatchObject({ title: 'X', aliases: ['y'], group: 'Topic', path: '/x.md' })
expect(result[0]).toMatchObject({ title: 'X', aliases: ['y'], group: 'Topic', path: '/vault/x.md' })
})
it('uses slug|title target when candidates have duplicate titles', () => {
it('includes subfolder path in wikilink target', () => {
const insertWikilink = vi.fn()
const candidates = [
{ title: 'Status Update', aliases: [], group: 'Project', entryTitle: 'Status Update', path: '/vault/status-update.md' },
{ title: 'Status Update', aliases: [], group: 'Journal', entryTitle: 'Status Update', path: '/vault/status-update-2.md' },
{ title: 'ADR 001', aliases: [], group: 'Note', entryTitle: 'ADR 001', path: '/vault/docs/adr/0001-tauri-stack.md' },
]
const result = attachClickHandlers(candidates, insertWikilink)
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)
result[0].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('status-update|Status Update')
result[1].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('status-update-2|Status Update')
expect(insertWikilink).toHaveBeenCalledWith('docs/adr/0001-tauri-stack|ADR 001')
})
it('uses title-only target when titles are unique', () => {
it('omits pipe display when title matches path stem', () => {
const insertWikilink = vi.fn()
const candidates = [
{ title: 'Alpha', aliases: [], group: 'Note', entryTitle: 'Alpha', path: '/vault/alpha.md' },
{ title: 'Beta', aliases: [], group: 'Note', entryTitle: 'Beta', path: '/vault/beta.md' },
{ title: 'roadmap', aliases: [], group: 'Note', entryTitle: 'roadmap', path: '/vault/roadmap.md' },
]
const result = attachClickHandlers(candidates, insertWikilink)
const result = attachClickHandlers(candidates, insertWikilink, vaultPath)
result[0].onItemClick()
expect(insertWikilink).toHaveBeenCalledWith('Alpha')
expect(insertWikilink).toHaveBeenCalledWith('roadmap')
})
})

View File

@@ -5,6 +5,7 @@ import { deduplicateByPath, disambiguateTitles } from './wikilinkSuggestions'
import { bestSearchRank } from './fuzzyMatch'
import { filterSuggestionItems } from '@blocknote/core/extensions'
import type { WikilinkSuggestionItem } from '../components/WikilinkSuggestionMenu'
import { relativePathStem } from './wikilink'
const MAX_RESULTS = 20
@@ -16,31 +17,25 @@ interface BaseSuggestionItem {
path: string
}
/** Build a filename-based target with pipe display: "slug|Title" */
function buildPathTarget(item: BaseSuggestionItem): string {
const filename = item.path.split('/').pop() ?? ''
const slug = filename.replace(/\.md$/, '')
return `${slug}|${item.entryTitle}`
/** Build the wikilink target: relative path stem with pipe display for the title.
* e.g. "docs/adr/0001-tauri-stack|Tauri Stack" for subfolders,
* "roadmap|Roadmap" for root files. */
function buildTarget(item: BaseSuggestionItem, vaultPath: string): string {
const stem = relativePathStem(item.path, vaultPath)
return stem === item.entryTitle ? stem : `${stem}|${item.entryTitle}`
}
/** Add onItemClick to raw suggestion candidates.
* When multiple candidates share the same title, inserts a path-based
* target with pipe syntax so the wikilink uniquely identifies the note. */
* Always inserts the vault-relative path as the wikilink target
* so links are unambiguous and work across subfolders. */
export function attachClickHandlers(
candidates: BaseSuggestionItem[],
insertWikilink: (target: string) => void,
vaultPath: string,
) {
const titleCounts = new Map<string, number>()
for (const item of candidates) {
titleCounts.set(item.entryTitle, (titleCounts.get(item.entryTitle) ?? 0) + 1)
}
return candidates.map(item => ({
...item,
onItemClick: () => {
const isDuplicate = (titleCounts.get(item.entryTitle) ?? 0) > 1
insertWikilink(isDuplicate ? buildPathTarget(item) : item.entryTitle)
},
onItemClick: () => insertWikilink(buildTarget(item, vaultPath)),
}))
}

View File

@@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test'
/** Errors that indicate the app has crashed (not just minor internal warnings). */
function isCrashError(msg: string): boolean {
return msg.includes('Maximum update depth') || msg.includes('Invalid hook call') || msg.includes('#185')
}
test('create note via Cmd+N does not crash', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => { if (isCrashError(err.message)) errors.push(err.message) })
await page.goto(process.env.BASE_URL ?? 'http://localhost:5201')
await page.waitForSelector('[data-testid="sidebar-top-nav"]', { timeout: 10000 })
await page.waitForTimeout(500)
await page.keyboard.press('Meta+n')
await page.waitForTimeout(2000)
expect(errors).toHaveLength(0)
await expect(page.locator('[data-testid="title-field-input"]')).toBeVisible()
})
test('create note via sidebar + button does not crash', async ({ page }) => {
const errors: string[] = []
page.on('pageerror', (err) => { if (isCrashError(err.message)) errors.push(err.message) })
await page.goto(process.env.BASE_URL ?? 'http://localhost:5201')
await page.waitForSelector('[data-testid="sidebar-top-nav"]', { timeout: 10000 })
await page.waitForTimeout(500)
const plusButtons = page.locator('button[aria-label*="Create new"]')
if (await plusButtons.count() > 0) {
await plusButtons.first().click()
await page.waitForTimeout(2000)
}
expect(errors).toHaveLength(0)
await expect(page.locator('[data-testid="title-field-input"]')).toBeVisible()
})