Compare commits
39 Commits
v0.2026030
...
v0.2026030
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e503cb179 | ||
|
|
d83f04c6ff | ||
|
|
9ffa6930c5 | ||
|
|
88b20b83dc | ||
|
|
548e5694ac | ||
|
|
0cf8f55a8d | ||
|
|
72b88cef43 | ||
|
|
8db9f61d5c | ||
|
|
fb2067ec79 | ||
|
|
b60bdb685d | ||
|
|
83009d8fb9 | ||
|
|
068d434344 | ||
|
|
6b2a1b0659 | ||
|
|
bf5f5521af | ||
|
|
f4961d0bc3 | ||
|
|
058de96cbc | ||
|
|
d29f919182 | ||
|
|
42e37e035c | ||
|
|
0ad0fa9b6b | ||
|
|
c37c03d6a9 | ||
|
|
fcc264d7dc | ||
|
|
382ba0a6d4 | ||
|
|
4719810b10 | ||
|
|
20b4ba7a3b | ||
|
|
de00ab6794 | ||
|
|
5d8f514bea | ||
|
|
1fd3ea02ae | ||
|
|
8da1484ebf | ||
|
|
90ebc2e939 | ||
|
|
c14927df8f | ||
|
|
a6d60695a2 | ||
|
|
7ddc0c14bf | ||
|
|
2a00b8aac7 | ||
|
|
b7d2304282 | ||
|
|
50b5fa9c2e | ||
|
|
963e7cf111 | ||
|
|
c9a5d20c12 | ||
|
|
586e1fcde5 | ||
|
|
75d67623ce |
@@ -1 +1 @@
|
||||
33549
|
||||
91305
|
||||
|
||||
24
CLAUDE.md
24
CLAUDE.md
@@ -115,7 +115,29 @@ Tauri v2 + React + TypeScript desktop app. Reads a vault of markdown files with
|
||||
|
||||
- **Never develop on `main`** — always on `task/<slug>` branch
|
||||
- **Commit every 20–30 min** — atomic commits, one concern per commit (`feat:`, `fix:`, `refactor:`, `test:`, `docs:`)
|
||||
- **Update docs/** when changing architecture, abstractions, or significant design
|
||||
- **Update docs/** when changing architecture, abstractions, or significant design (mandatory — see rule below)
|
||||
|
||||
## ⛔ DOCS — Keep docs/ in sync with code (mandatory)
|
||||
|
||||
After any significant feature change, update the relevant `docs/` files **in the same commit**:
|
||||
|
||||
- **`docs/ARCHITECTURE.md`** — stack, system overview, component structure, Tauri commands, data flow, backend modules
|
||||
- **`docs/ABSTRACTIONS.md`** — domain models, VaultEntry fields, entity types, key abstractions, integration patterns
|
||||
- **`docs/GETTING-STARTED.md`** — directory structure, key files, common tasks, test commands, onboarding
|
||||
|
||||
**What counts as "significant":**
|
||||
- Adding a new Tauri command or backend module
|
||||
- Adding a new major component, hook, or feature (not a bugfix)
|
||||
- Changing the data model (VaultEntry fields, new types, new config files)
|
||||
- Adding a new integration (API, service, transport)
|
||||
- Changing the architecture (new panels, new state management, new build steps)
|
||||
|
||||
**How to update:**
|
||||
1. Read the relevant doc section before making changes
|
||||
2. After your code changes, update the doc to reflect the new state
|
||||
3. Commit doc changes together with the code — not in a separate follow-up commit
|
||||
|
||||
If unsure whether a change is "significant", err on the side of updating. Stale docs are worse than slightly verbose docs.
|
||||
|
||||
## TDD — Red/Green/Refactor (mandatory)
|
||||
|
||||
|
||||
@@ -8,24 +8,32 @@ All data lives in markdown files with YAML frontmatter. There is no database —
|
||||
|
||||
### VaultEntry
|
||||
|
||||
The core data type representing a single note, defined identically in Rust (`src-tauri/src/vault.rs`) and TypeScript (`src/types.ts`):
|
||||
The core data type representing a single note, defined in Rust (`src-tauri/src/vault/mod.rs`) and TypeScript (`src/types.ts`):
|
||||
|
||||
```typescript
|
||||
// src/types.ts
|
||||
interface VaultEntry {
|
||||
path: string // Absolute file path: /Users/luca/Laputa/project/my-project.md
|
||||
filename: string // Just the filename: my-project.md
|
||||
title: string // Extracted from first # heading, or filename as fallback
|
||||
isA: string | null // Entity type: Project, Procedure, Person, etc.
|
||||
aliases: string[] // Alternative names for wikilink resolution
|
||||
belongsTo: string[] // Parent relationships (wikilinks)
|
||||
relatedTo: string[] // Related entity links (wikilinks)
|
||||
status: string | null // Active, Done, Paused, Archived, Dropped
|
||||
owner: string | null // Person responsible
|
||||
cadence: string | null // Update frequency: Weekly, Monthly, etc.
|
||||
path: string // Absolute file path
|
||||
filename: string // Just the filename
|
||||
title: string // From first # heading, or filename fallback
|
||||
isA: string | null // Entity type: Project, Procedure, Person, etc.
|
||||
aliases: string[] // Alternative names for wikilink resolution
|
||||
belongsTo: string[] // Parent relationships (wikilinks)
|
||||
relatedTo: string[] // Related entity links (wikilinks)
|
||||
relationships: Record<string, string[]> // All frontmatter fields containing wikilinks
|
||||
outgoingLinks: string[] // All [[wikilinks]] found in note body
|
||||
status: string | null // Active, Done, Paused, Archived, Dropped
|
||||
owner: string | null // Person responsible
|
||||
cadence: string | null // Update frequency: Weekly, Monthly, etc.
|
||||
modifiedAt: number | null // Unix timestamp (seconds)
|
||||
createdAt: number | null // Unix timestamp (seconds)
|
||||
fileSize: number
|
||||
wordCount: number | null // Body word count (excludes frontmatter)
|
||||
snippet: string | null // First 200 chars of body
|
||||
archived: boolean // Archived flag
|
||||
trashed: boolean // Trashed flag
|
||||
trashedAt: number | null // When trashed (for auto-purge)
|
||||
properties: Record<string, string> // Scalar frontmatter fields (custom properties)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -47,26 +55,41 @@ Entity type is inferred from the folder structure. The vault is organized by typ
|
||||
├── quarter/ → "Quarter"
|
||||
├── journal/ → "Journal"
|
||||
├── essay/ → "Essay"
|
||||
└── evergreen/ → "Evergreen"
|
||||
├── evergreen/ → "Evergreen"
|
||||
├── theme/ → "Theme" ← vault-based themes
|
||||
└── config/ → "Config" ← meta-configuration files (agents.md, etc.)
|
||||
```
|
||||
|
||||
Mapping logic lives in `vault.rs:parse_md_file()`. If a folder doesn't match any known type, the folder name is capitalized and used as-is.
|
||||
Mapping logic lives in `vault/mod.rs:parse_md_file()`. If a folder doesn't match any known type, the folder name is capitalized and used as-is.
|
||||
|
||||
### Types as Files
|
||||
|
||||
Each entity type can have a corresponding **type document** in the `type/` folder (e.g., `type/project.md`, `type/person.md`). Type documents:
|
||||
|
||||
- Have `Is A: Type` in their frontmatter
|
||||
- Describe what the type means, its expected properties, and how it relates to other types
|
||||
- Are navigable entities — they appear in the sidebar under "Types" and can be opened/edited like any other note
|
||||
- Define type metadata: icon, color, order, sidebar label, template, sort, view, visibility
|
||||
- Are navigable entities — they appear in the sidebar under "Types" and can be opened/edited like any note
|
||||
- Serve as the "definition" for their type category
|
||||
|
||||
**Type document properties** (read by Rust and used in the UI):
|
||||
|
||||
| Property | Type | Description |
|
||||
|----------|------|-------------|
|
||||
| `icon` | string | Phosphor icon name (kebab-case, e.g., "cooking-pot") |
|
||||
| `color` | string | Accent color: red, purple, blue, green, yellow, orange |
|
||||
| `order` | number | Sidebar display order (lower = higher priority) |
|
||||
| `sidebar_label` | string | Custom label overriding auto-pluralization |
|
||||
| `template` | string | Markdown template for new notes of this type |
|
||||
| `sort` | string | Default sort: "modified:desc", "title:asc", "property:Priority:asc" |
|
||||
| `view` | string | Default view mode: "all", "editor-list", "editor-only" |
|
||||
| `visible` | bool | Whether type appears in sidebar (default: true) |
|
||||
|
||||
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[type/project]]`. This makes the type navigable from the Inspector panel.
|
||||
|
||||
**UI behavior**:
|
||||
- Clicking a section group header (e.g., "Projects") pins the type document at the top of the NoteList if it exists, with instances listed below
|
||||
- Clicking a section group header pins the type document at the top of the NoteList if it exists
|
||||
- Viewing a type document in entity view shows an "Instances" group listing all entries of that type
|
||||
- The Type field in the Inspector properties panel is rendered as a clickable chip that navigates to the type document
|
||||
- The Type field in the Inspector is rendered as a clickable chip that navigates to the type document
|
||||
|
||||
### Frontmatter Format
|
||||
|
||||
@@ -88,24 +111,48 @@ aliases:
|
||||
---
|
||||
```
|
||||
|
||||
Supported value types (defined in `src-tauri/src/frontmatter.rs` as `FrontmatterValue`):
|
||||
Supported value types (defined in `src-tauri/src/frontmatter/yaml.rs` as `FrontmatterValue`):
|
||||
- **String**: `status: Active`
|
||||
- **Number**: `priority: 5`
|
||||
- **Bool**: `archived: true`
|
||||
- **List**: Multi-line ` - item` or inline `[item1, item2]`
|
||||
- **Null**: `owner:` (empty value)
|
||||
|
||||
### Custom Relationships
|
||||
|
||||
The Rust parser scans all frontmatter keys for fields containing `[[wikilinks]]`. Any non-standard field with wikilink values is captured in the `relationships` HashMap:
|
||||
|
||||
```yaml
|
||||
---
|
||||
Topics:
|
||||
- "[[topic/writing]]"
|
||||
- "[[topic/productivity]]"
|
||||
Key People:
|
||||
- "[[person/matteo-cellini]]"
|
||||
---
|
||||
```
|
||||
|
||||
Becomes: `relationships["Topics"] = ["[[topic/writing]]", "[[topic/productivity]]"]`
|
||||
|
||||
This enables arbitrary, extensible relationship types without code changes.
|
||||
|
||||
### Outgoing Links
|
||||
|
||||
All `[[wikilinks]]` in the note body (not frontmatter) are extracted by regex and stored in `outgoingLinks`. Used for backlink detection and relationship graphs.
|
||||
|
||||
### Title Extraction
|
||||
|
||||
Title comes from the first `# Heading` in the markdown body. If none is found, the filename (without `.md`) is used as fallback. This logic lives in `vault.rs:extract_title()`.
|
||||
Title comes from the first `# Heading` in the markdown body. If none is found, the filename (without `.md`) is used as fallback. Logic in `vault/parsing.rs:extract_title()`.
|
||||
|
||||
### Sidebar Selection
|
||||
|
||||
Navigation state is modeled as a discriminated union:
|
||||
|
||||
```typescript
|
||||
type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse'
|
||||
|
||||
type SidebarSelection =
|
||||
| { kind: 'filter'; filter: 'all' | 'favorites' }
|
||||
| { 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
|
||||
@@ -115,49 +162,60 @@ type SidebarSelection =
|
||||
|
||||
### Vault Scanning (Rust)
|
||||
|
||||
`vault::scan_vault(path)` in `src-tauri/src/vault.rs`:
|
||||
`vault::scan_vault(path)` in `src-tauri/src/vault/mod.rs`:
|
||||
|
||||
1. Validates the path exists and is a directory
|
||||
2. Uses `walkdir` to recursively traverse the directory (follows symlinks)
|
||||
2. Uses `walkdir` to recursively traverse (follows symlinks)
|
||||
3. Filters to `.md` files only
|
||||
4. For each file, calls `parse_md_file()`:
|
||||
- Reads file content with `fs::read_to_string()`
|
||||
- Reads content with `fs::read_to_string()`
|
||||
- Parses frontmatter with `gray_matter::Matter::<YAML>`
|
||||
- Extracts title from first `#` heading
|
||||
- Infers entity type from parent folder name
|
||||
- Parses dates (`created_at`, `created_time`) as ISO 8601 to Unix timestamps
|
||||
- Collects file metadata (size, modification time)
|
||||
5. Sorts results by `modified_at` descending (newest first)
|
||||
- Infers entity type from parent folder name (or explicit `Is A`/`type` frontmatter)
|
||||
- Parses dates as ISO 8601 to Unix timestamps
|
||||
- Extracts relationships, outgoing links, custom properties, word count, snippet
|
||||
5. Sorts by `modified_at` descending
|
||||
6. Skips unparseable files with a warning log
|
||||
|
||||
### Vault Caching
|
||||
|
||||
`vault::scan_vault_cached(path)` wraps scanning with git-based caching:
|
||||
|
||||
1. Reads `.laputa-cache.json` if it exists
|
||||
2. Compares cache version, vault path, and git HEAD commit hash
|
||||
3. If cache is valid and same commit → only re-parse uncommitted changed files
|
||||
4. If different commit → use `git diff` to find changed files → selective re-parse
|
||||
5. If no cache → full scan
|
||||
6. Writes updated cache after every scan
|
||||
|
||||
### Frontmatter Manipulation (Rust)
|
||||
|
||||
`frontmatter::update_frontmatter_content()` in `src-tauri/src/frontmatter.rs` performs line-by-line YAML editing:
|
||||
`frontmatter/ops.rs:update_frontmatter_content()` performs line-by-line YAML editing:
|
||||
|
||||
1. Finds the frontmatter block between `---` delimiters
|
||||
2. Iterates through lines looking for the target key (handles quoted keys like `"Is A"`)
|
||||
2. Iterates through lines looking for the target key
|
||||
3. If found: replaces the value (consuming multi-line list items if present)
|
||||
4. If not found: appends the new key-value at the end of the frontmatter
|
||||
5. If no frontmatter exists: creates a new `---` block with the key-value
|
||||
4. If not found: appends the new key-value at the end
|
||||
5. If no frontmatter exists: creates a new `---` block
|
||||
|
||||
The `with_frontmatter()` helper wraps this in a read-transform-write cycle on the actual file.
|
||||
|
||||
### Content Loading
|
||||
|
||||
- **Tauri mode**: Content is loaded on-demand when a tab is opened via `invoke('get_note_content', { path })`
|
||||
- **Browser mode**: All content is loaded at startup from `MOCK_CONTENT` in `mock-tauri.ts`
|
||||
- **Tauri mode**: Content loaded on-demand when a tab is opened via `invoke('get_note_content', { path })`
|
||||
- **Browser mode**: All content loaded at startup from mock data
|
||||
- Content for backlink detection (`allContent`) is stored in memory as `Record<string, string>`
|
||||
|
||||
## Git Integration
|
||||
|
||||
Git operations live in `src-tauri/src/git.rs`. All operations shell out to the `git` CLI (not libgit2).
|
||||
Git operations live in `src-tauri/src/git/`. All operations shell out to the `git` CLI (not libgit2).
|
||||
|
||||
### Data Types
|
||||
|
||||
```typescript
|
||||
interface GitCommit {
|
||||
hash: string // Full SHA-1
|
||||
shortHash: string // First 7 chars
|
||||
hash: string
|
||||
shortHash: string
|
||||
message: string
|
||||
author: string
|
||||
date: number // Unix timestamp
|
||||
@@ -168,32 +226,54 @@ interface ModifiedFile {
|
||||
relativePath: string // Relative to vault root
|
||||
status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'
|
||||
}
|
||||
|
||||
interface PulseCommit {
|
||||
hash: string
|
||||
shortHash: string
|
||||
message: string
|
||||
date: number
|
||||
githubUrl: string | null
|
||||
files: PulseFile[]
|
||||
added: number
|
||||
modified: number
|
||||
deleted: number
|
||||
}
|
||||
```
|
||||
|
||||
### Operations
|
||||
|
||||
| Operation | Git command | Notes |
|
||||
|-----------|------------|-------|
|
||||
| File history | `git log --format=%H\|%h\|%an\|%aI\|%s -n 20 -- <file>` | Last 20 commits for a file |
|
||||
| Modified files | `git status --porcelain` | Filtered to `.md` files only |
|
||||
| File diff | `git diff -- <file>`, fallback to `--cached`, then synthetic diff for untracked | Unified diff format |
|
||||
| Commit | `git add -A && git commit -m "<message>"` | Stages all changes |
|
||||
| Push | `git push` | Pushes to upstream of current branch |
|
||||
| Module | Operation | Notes |
|
||||
|--------|-----------|-------|
|
||||
| `history.rs` | File history | `git log` — last 20 commits per file |
|
||||
| `status.rs` | Modified files | `git status --porcelain` — filtered to `.md` |
|
||||
| `status.rs` | File diff | `git diff`, fallback to `--cached`, then synthetic for untracked |
|
||||
| `commit.rs` | Commit | `git add -A && git commit -m "..."` |
|
||||
| `remote.rs` | Pull / Push | `git pull --rebase` / `git push` |
|
||||
| `conflict.rs` | Conflict resolution | Detect conflicts, resolve with ours/theirs/manual |
|
||||
| `pulse.rs` | Activity feed | `git log` with `--name-status` for file changes |
|
||||
|
||||
### Auto-Sync
|
||||
|
||||
`useAutoSync` hook handles automatic git sync:
|
||||
- Configurable interval (from app settings: `auto_pull_interval_minutes`)
|
||||
- Pulls on interval, pushes after commits
|
||||
- Detects merge conflicts → opens `ConflictResolverModal`
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
- **Modified file badges**: Loaded at startup, shown in sidebar and breadcrumb bar
|
||||
- **Diff view**: Loaded on-demand when user clicks the diff toggle in the breadcrumb bar
|
||||
- **Git history**: Loaded when active tab changes, shown in Inspector panel
|
||||
- **Commit dialog**: Triggered from sidebar, runs commit + push
|
||||
- **Modified file badges**: Orange dots in sidebar and tab bar
|
||||
- **Diff view**: Toggle in breadcrumb bar → shows unified diff
|
||||
- **Git history**: Shown in Inspector panel for active note
|
||||
- **Commit dialog**: Triggered from sidebar or Cmd+K
|
||||
- **Pulse view**: Activity feed when Pulse filter is selected
|
||||
|
||||
## BlockNote Customization
|
||||
|
||||
The editor uses [BlockNote](https://www.blocknotejs.org/) (not CodeMirror 6) for rich text editing.
|
||||
The editor uses [BlockNote](https://www.blocknotejs.org/) for rich text editing, with CodeMirror 6 available as a raw editing alternative.
|
||||
|
||||
### Custom Wikilink Inline Content
|
||||
|
||||
Defined in `src/components/Editor.tsx`:
|
||||
Defined in `src/components/editorSchema.tsx`:
|
||||
|
||||
```typescript
|
||||
const WikiLink = createReactInlineContentSpec(
|
||||
@@ -202,69 +282,196 @@ const WikiLink = createReactInlineContentSpec(
|
||||
propSchema: { target: { default: "" } },
|
||||
content: "none",
|
||||
},
|
||||
{
|
||||
render: (props) => (
|
||||
<span className="wikilink" data-target={props.inlineContent.props.target}>
|
||||
{props.inlineContent.props.target}
|
||||
</span>
|
||||
),
|
||||
}
|
||||
{ render: (props) => <span className="wikilink">...</span> }
|
||||
)
|
||||
|
||||
const schema = BlockNoteSchema.create({
|
||||
inlineContentSpecs: {
|
||||
...defaultInlineContentSpecs,
|
||||
wikilink: WikiLink,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### Markdown-to-BlockNote Pipeline
|
||||
|
||||
Since BlockNote doesn't natively understand `[[wikilinks]]`, content goes through a preprocessing pipeline in `src/utils/wikilinks.ts`:
|
||||
|
||||
```
|
||||
Raw markdown
|
||||
→ splitFrontmatter() → [yaml, body]
|
||||
→ preProcessWikilinks(body) → replaces [[target]] with Unicode placeholder tokens
|
||||
→ editor.tryParseMarkdownToBlocks() → BlockNote block tree
|
||||
→ injectWikilinks(blocks) → walks tree, replaces placeholder text with wikilink inline content nodes
|
||||
→ injectWikilinks(blocks) → walks tree, replaces placeholders with wikilink inline content nodes
|
||||
→ editor.replaceBlocks()
|
||||
```
|
||||
|
||||
Placeholder tokens use `\u2039` (single left-pointing angle quotation mark) and `\u203A` (single right-pointing) to avoid colliding with markdown syntax.
|
||||
Placeholder tokens use `\u2039` and `\u203A` to avoid colliding with markdown syntax.
|
||||
|
||||
### BlockNote-to-Markdown Pipeline (Save)
|
||||
|
||||
```
|
||||
BlockNote blocks
|
||||
→ editor.blocksToMarkdownLossy()
|
||||
→ postProcessWikilinks() → restore [[target]] syntax from wikilink nodes
|
||||
→ prepend frontmatter yaml
|
||||
→ invoke('save_note_content', { path, content })
|
||||
```
|
||||
|
||||
### Wikilink Navigation
|
||||
|
||||
Two navigation mechanisms:
|
||||
|
||||
1. **Click handler**: A DOM event listener on `.editor__blocknote-container` catches clicks on `.wikilink` elements and calls `onNavigateWikilink(target)`.
|
||||
1. **Click handler**: DOM event listener on `.editor__blocknote-container` catches clicks on `.wikilink` elements → `onNavigateWikilink(target)`.
|
||||
2. **Suggestion menu**: Typing `[[` triggers `SuggestionMenuController` with filtered vault entries.
|
||||
|
||||
2. **Suggestion menu**: Typing `[[` triggers BlockNote's `SuggestionMenuController`, which shows a filtered list of all vault entries. Selecting one inserts a wikilink inline content node.
|
||||
Wikilink resolution (`useNoteActions`) uses fuzzy matching: exact title → alias → path stem → filename stem → slug-to-words.
|
||||
|
||||
Wikilink resolution in `useNoteActions.handleNavigateWikilink()` uses fuzzy matching:
|
||||
- Exact title match
|
||||
- Alias match
|
||||
- Path stem match (e.g., `person/matteo-cellini`)
|
||||
- Filename stem match
|
||||
- Slug-to-words match (e.g., `matteo-cellini` → `matteo cellini`)
|
||||
### Raw Editor Mode
|
||||
|
||||
Toggle via Cmd+K → "Raw Editor" or breadcrumb bar button. Uses CodeMirror 6 (`useCodeMirror` hook) to edit the raw markdown + frontmatter directly. Changes saved via the same `save_note_content` command.
|
||||
|
||||
## Theme System
|
||||
|
||||
See [THEMING.md](./THEMING.md) for the full theme system documentation.
|
||||
|
||||
In brief: `src/theme.json` defines editor typography and styling as nested JSON. The `useEditorTheme` hook flattens it into CSS custom properties that are applied as inline styles on the BlockNote container.
|
||||
### Overview
|
||||
|
||||
Two-layer theming:
|
||||
1. **Global CSS variables** (`src/index.css`): App-wide colors via `:root`, bridged to Tailwind v4
|
||||
2. **Editor theme** (`src/theme.json`): BlockNote typography, flattened to CSS vars by `useEditorTheme`
|
||||
|
||||
### Vault-Based Themes
|
||||
|
||||
Themes are markdown notes in `theme/` with `Is A: Theme` frontmatter. Each property becomes a CSS variable with `--` prefix.
|
||||
|
||||
```yaml
|
||||
---
|
||||
Is A: Theme
|
||||
Description: Light theme with warm, paper-like tones
|
||||
background: "#FFFFFF"
|
||||
foreground: "#37352F"
|
||||
accent-blue: "#155DFF"
|
||||
editor-font-size: 16
|
||||
editor-line-height: 1.5
|
||||
---
|
||||
```
|
||||
|
||||
### ThemeManager
|
||||
|
||||
`useThemeManager` hook manages the theme lifecycle:
|
||||
|
||||
```typescript
|
||||
interface ThemeManager {
|
||||
themes: ThemeFile[]
|
||||
activeThemeId: string | null
|
||||
activeTheme: ThemeFile | null
|
||||
isDark: boolean
|
||||
switchTheme(themeId: string): Promise<void>
|
||||
createTheme(name?: string): Promise<string>
|
||||
reloadThemes(): Promise<void>
|
||||
updateThemeProperty(key: string, value: string): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
- Detects dark backgrounds via luminance calculation → sets `color-scheme` and `data-theme-mode`
|
||||
- Live preview: re-applies when active theme note is saved
|
||||
- Three built-in themes: Default (light), Dark (deep navy), Minimal (high contrast)
|
||||
- Legacy JSON themes (`_themes/*.json`) supported for backward compatibility
|
||||
|
||||
### Theme Property Editor
|
||||
|
||||
`ThemePropertyEditor` component provides an interactive UI for editing theme properties. Uses `themeSchema.ts` to determine input types (color picker, number slider, text field) based on property names and values.
|
||||
|
||||
## Inspector Abstraction
|
||||
|
||||
The Inspector panel (`src/components/Inspector.tsx`) is composed of four sub-panels:
|
||||
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 with two distinct sections:
|
||||
- **Editable properties** (top): frontmatter fields the user can modify — shown with interactive hover styling (`hover:bg-muted`), cursor pointer, and click-to-edit. Includes Type badge, Status pill, boolean toggles, array tag pills, and text fields.
|
||||
- **Info section** (bottom, separated by border): read-only derived metadata — Modified, Created, Words, File Size. Uses muted text color (`--text-muted`) with no hover states or click interaction. These fields are computed from file metadata and content, not from frontmatter.
|
||||
- Keys in `SKIP_KEYS` (`aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section since they are either internal or already displayed elsewhere (e.g., `is_a` is shown via the TypeRow badge).
|
||||
2. **Relationships**: Shows `belongs_to` and `related_to` wikilinks as clickable chips.
|
||||
3. **Backlinks**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.
|
||||
4. **Git History**: Shows the last few commits from `gitHistory` state.
|
||||
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.
|
||||
- **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` (`aliases`, `notion_id`, `workspace`, `is_a`, `Is A`) are hidden from the editable section.
|
||||
|
||||
Frontmatter parsing on the TypeScript side is handled by `src/utils/frontmatter.ts:parseFrontmatter()`, a lightweight YAML parser that handles strings, booleans, inline arrays, and multi-line lists.
|
||||
2. **RelationshipsPanel**: Shows `belongs_to`, `related_to`, and all custom relationship fields as clickable wikilink chips.
|
||||
|
||||
3. **BacklinksPanel**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.
|
||||
|
||||
4. **GitHistoryPanel**: Shows recent commits from file history with relative timestamps.
|
||||
|
||||
## Search & Indexing
|
||||
|
||||
### Search Modes
|
||||
|
||||
```typescript
|
||||
type SearchMode = 'keyword' | 'semantic' | 'hybrid'
|
||||
|
||||
interface SearchResult {
|
||||
title: string
|
||||
path: string
|
||||
snippet: string
|
||||
score: number
|
||||
}
|
||||
|
||||
interface SearchResponse {
|
||||
results: SearchResult[]
|
||||
elapsedMs: number
|
||||
}
|
||||
```
|
||||
|
||||
### Search Integration
|
||||
|
||||
`SearchPanel` component provides the search UI:
|
||||
- Mode selector (keyword/semantic/hybrid)
|
||||
- Real-time results as user types
|
||||
- Click result to open note in editor
|
||||
- Shows relevance score and snippet
|
||||
|
||||
### Indexing
|
||||
|
||||
Managed by `useIndexing` hook:
|
||||
- Checks index status on vault load
|
||||
- Two-phase indexing: scanning (parse files) → embedding (generate vectors)
|
||||
- Progress streamed via Tauri events
|
||||
- Incremental updates after git sync
|
||||
- Metadata persisted in `.laputa-index.json`
|
||||
|
||||
## Vault Management
|
||||
|
||||
### Vault Switching
|
||||
|
||||
`useVaultSwitcher` hook manages multiple vaults:
|
||||
- Persists vault list to `~/.config/com.laputa.app/vaults.json`
|
||||
- Switching closes all tabs and resets sidebar
|
||||
- Supports adding, removing, hiding/restoring vaults
|
||||
- Default vault: Getting Started demo vault
|
||||
|
||||
### Vault Config
|
||||
|
||||
Per-vault settings stored in `config/ui.config.md`:
|
||||
- Editable as a normal note (YAML frontmatter)
|
||||
- Managed by `useVaultConfig` hook and `vaultConfigStore`
|
||||
- Settings: zoom, view mode, tag colors, status colors, property display modes
|
||||
- One-time migration from localStorage (`configMigration.ts`)
|
||||
|
||||
### Getting Started / Onboarding
|
||||
|
||||
`useOnboarding` hook detects first launch:
|
||||
- If vault path doesn't exist → show `WelcomeScreen`
|
||||
- User can create Getting Started vault or open existing folder
|
||||
- Welcome state tracked in localStorage (`laputa_welcome_dismissed`)
|
||||
|
||||
### GitHub Integration
|
||||
|
||||
Device Authorization Flow for GitHub-backed vaults:
|
||||
- `GitHubDeviceFlow` component handles OAuth
|
||||
- `GitHubVaultModal` for cloning existing repos or creating new ones
|
||||
- Token persisted in app settings for future git operations
|
||||
- `SettingsPanel` shows connection status with disconnect option
|
||||
|
||||
## Settings
|
||||
|
||||
App-level settings persisted at `~/.config/com.laputa.app/settings.json`:
|
||||
|
||||
```typescript
|
||||
interface Settings {
|
||||
anthropic_key: string | null
|
||||
openai_key: string | null
|
||||
google_key: string | null
|
||||
github_token: string | null
|
||||
github_username: string | null
|
||||
auto_pull_interval_minutes: number | null
|
||||
}
|
||||
```
|
||||
|
||||
Managed by `useSettings` hook and `SettingsPanel` component.
|
||||
|
||||
@@ -9,46 +9,58 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault
|
||||
| Desktop shell | Tauri v2 | 2.10.0 |
|
||||
| Frontend | React + TypeScript | React 19, TS 5.9 |
|
||||
| Editor | BlockNote | 0.46.2 |
|
||||
| Raw editor | CodeMirror 6 | - |
|
||||
| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 |
|
||||
| UI primitives | Radix UI + shadcn/ui | - |
|
||||
| Icons | Phosphor Icons + Lucide | - |
|
||||
| Build | Vite | 7.3.1 |
|
||||
| Backend language | Rust (edition 2021) | 1.77.2 |
|
||||
| Frontmatter parsing | gray_matter | 0.2 |
|
||||
| AI | Anthropic Claude API (Haiku 3.5 default) | - |
|
||||
| AI (in-app chat) | Anthropic Claude API (Haiku 3.5 default) | - |
|
||||
| AI (agent panel) | Claude CLI subprocess (streaming NDJSON) | - |
|
||||
| Search | qmd (keyword + semantic + hybrid) | - |
|
||||
| MCP | @modelcontextprotocol/sdk | 1.0 |
|
||||
| Tests | Vitest (unit), Playwright (E2E), cargo test (Rust) | - |
|
||||
| Tests | Vitest (unit), Playwright (E2E/smoke), cargo test (Rust) | - |
|
||||
| Package manager | pnpm | - |
|
||||
|
||||
## System Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Tauri v2 Window │
|
||||
│ │
|
||||
│ ┌─────────────────── React Frontend ───────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ App.tsx (orchestrator) │ │
|
||||
│ │ ├── Sidebar (navigation + filters) │ │
|
||||
│ │ ├── NoteList (filtered note list) │ │
|
||||
│ │ ├── Editor (BlockNote + tabs + diff) │ │
|
||||
│ │ │ ├── Inspector (metadata + relationships) │ │
|
||||
│ │ │ └── AIChatPanel (AI assistant + context) │ │
|
||||
│ │ ├── StatusBar (footer info) │ │
|
||||
│ │ └── Modals (QuickOpen, CreateNote, CommitDialog) │ │
|
||||
│ │ │ │
|
||||
│ └──────────────┬──────────┬──────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ Tauri IPC│ Vite Proxy / WS │
|
||||
│ ┌──────────────▼────┐ ┌──▼───────────────────────────┐ │
|
||||
│ │ Rust Backend │ │ External Services │ │
|
||||
│ │ lib.rs → 10 cmds │ │ Anthropic API (Claude) │ │
|
||||
│ │ vault/ │ │ MCP Server (ws://9710) │ │
|
||||
│ │ frontmatter.rs │ │ │ │
|
||||
│ │ git.rs │ └──────────────────────────────┘ │
|
||||
│ │ ai_chat.rs │ │
|
||||
│ └───────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Tauri v2 Window │
|
||||
│ │
|
||||
│ ┌──────────────────── React Frontend ────────────────────────┐ │
|
||||
│ │ │ │
|
||||
│ │ App.tsx (orchestrator) │ │
|
||||
│ │ ├── WelcomeScreen (onboarding / vault-missing) │ │
|
||||
│ │ ├── Sidebar (navigation + filters + types) │ │
|
||||
│ │ ├── NoteList / PulseView (filtered list / activity) │ │
|
||||
│ │ ├── Editor (BlockNote + tabs + diff + raw) │ │
|
||||
│ │ │ ├── Inspector (metadata + relationships) │ │
|
||||
│ │ │ ├── AIChatPanel (API-based chat) │ │
|
||||
│ │ │ └── AiPanel (Claude CLI agent + tools) │ │
|
||||
│ │ ├── SearchPanel (keyword/semantic/hybrid search) │ │
|
||||
│ │ ├── SettingsPanel (API keys, GitHub, zoom, theme) │ │
|
||||
│ │ ├── StatusBar (vault picker + sync + version) │ │
|
||||
│ │ ├── CommandPalette (Cmd+K fuzzy command launcher) │ │
|
||||
│ │ └── Modals (CreateNote, CreateType, Commit, GitHub) │ │
|
||||
│ │ │ │
|
||||
│ └──────────────┬──────────┬──────────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ Tauri IPC│ Vite Proxy / WS │
|
||||
│ ┌──────────────▼────┐ ┌──▼────────────────────────────────┐ │
|
||||
│ │ Rust Backend │ │ External Services │ │
|
||||
│ │ lib.rs → 61 cmds │ │ Anthropic API (Claude chat) │ │
|
||||
│ │ vault/ │ │ Claude CLI (agent subprocess) │ │
|
||||
│ │ frontmatter/ │ │ MCP Server (ws://9710, 9711) │ │
|
||||
│ │ git/ │ │ qmd (search/indexing engine) │ │
|
||||
│ │ github/ │ │ GitHub API (OAuth, repos, clone) │ │
|
||||
│ │ theme/ │ │ │ │
|
||||
│ │ search.rs │ └───────────────────────────────────┘ │
|
||||
│ │ indexing.rs │ │
|
||||
│ │ claude_cli.rs │ │
|
||||
│ └───────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Four-Panel Layout
|
||||
@@ -57,71 +69,90 @@ Laputa is a personal knowledge and life management desktop app. It reads a vault
|
||||
┌────────┬─────────────┬─────────────────────────┬────────────┐
|
||||
│Sidebar │ Note List │ Editor │ Inspector │
|
||||
│(250px) │ (300px) │ (flex-1) │ (280px) │
|
||||
│ │ │ │ OR │
|
||||
│ All │ [Search] │ [Tab Bar] │ AI Chat │
|
||||
│ Favs │ [Type Pill] │ [Breadcrumb Bar] │ │
|
||||
│ │ OR │ │ OR │
|
||||
│ All │ Pulse View │ [Tab Bar] │ AI Chat │
|
||||
│ Favs │ │ [Breadcrumb Bar] │ OR │
|
||||
│ Changes│ [Search] │ │ AI Agent │
|
||||
│ Pulse │ [Sort/Filt] │ # My Note │ │
|
||||
│ │ │ │ Context │
|
||||
│Projects│ Note 1 │ # My Note │ Messages │
|
||||
│Experim.│ Note 2 │ │ Actions │
|
||||
│Respons.│ Note 3 │ Content here... │ Input │
|
||||
│Procedu.│ ... │ │ │
|
||||
│People │ │ │ │
|
||||
│Projects│ Note 1 │ Content here... │ Messages │
|
||||
│Experim.│ Note 2 │ (BlockNote or Raw) │ Actions │
|
||||
│Respons.│ Note 3 │ │ Input │
|
||||
│People │ ... │ │ │
|
||||
│Events │ │ │ │
|
||||
│Topics │ │ │ │
|
||||
├────────┴─────────────┴─────────────────────────┴────────────┤
|
||||
│ StatusBar: v0.4.2 │ main │ Synced 2m ago │ 3 pending notes│
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│ StatusBar: v0.4.2 │ main │ Synced 2m ago │ Vault: ~/Laputa │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Favorites) and collapsible section groups (Projects, Experiments, Responsibilities, etc.)
|
||||
- **Note List** (200-500px, resizable): Filtered list of notes matching the sidebar selection. Shows snippets, modified dates, relationship groups, and orange dot indicators for uncommitted modified notes.
|
||||
- **Editor** (flex, fills remaining space): Tab bar (with orange modified dots on dirty tabs), breadcrumb bar with word count and modified indicator, BlockNote editor with wikilink support. Can toggle to diff view for modified files. Decomposed into focused subcomponents: `Editor` (orchestrator), `EditorContent` (breadcrumb + editor/diff views), `EditorRightPanel` (inspector/AI toggle), `SingleEditorView` (BlockNote + suggestions), with hooks `useDiffMode` and `useEditorFocus`.
|
||||
- **Inspector / AI Chat** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, backlinks, git history) and AI Chat panel. The Sparkle icon in the breadcrumb bar toggles between them.
|
||||
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Favorites, 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.
|
||||
- **Editor** (flex, fills remaining space): Tab bar with modified dots, breadcrumb bar with word count, BlockNote rich text editor with wikilink support. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, `useEditorSave`, `useRawMode`.
|
||||
- **Inspector / AI Chat / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, backlinks, git history), AI Chat panel (API-based), and AI Agent panel (Claude CLI subprocess with tool execution). The Sparkle icon in the breadcrumb bar toggles between them.
|
||||
|
||||
Panels are separated by `ResizeHandle` components that support drag-to-resize.
|
||||
|
||||
## AI Chat System
|
||||
## AI System
|
||||
|
||||
### Architecture
|
||||
Laputa has two AI interfaces with distinct architectures:
|
||||
|
||||
The AI chat feature has three layers:
|
||||
### AI Chat (AIChatPanel)
|
||||
|
||||
Simple chat mode — no tool execution, streaming text responses.
|
||||
|
||||
1. **Frontend** (`AIChatPanel` + `useAIChat` hook) — UI and state management
|
||||
2. **API Proxy** (Vite middleware in dev, Rust `ai_chat` command in Tauri) — routes to Anthropic
|
||||
3. **MCP Server** (`mcp-server/`) — vault operation tools for AI assistants
|
||||
3. **Context picker** — selected notes sent as system context with token estimation
|
||||
|
||||
### Data Flow
|
||||
### AI Agent (AiPanel)
|
||||
|
||||
Full agent mode — spawns Claude CLI as a subprocess with tool access and MCP vault integration.
|
||||
|
||||
1. **Frontend** (`AiPanel` + `useAiAgent` hook) — streaming UI with reasoning blocks, tool action cards, and response display
|
||||
2. **Backend** (`claude_cli.rs`) — spawns `claude` binary with `--output-format stream-json`, parses NDJSON events
|
||||
3. **MCP Integration** — passes vault MCP config via `--mcp-config` flag so the agent can search, read, and modify vault notes
|
||||
|
||||
#### Agent Event Flow
|
||||
|
||||
```
|
||||
User types message in AIChatPanel
|
||||
→ useAIChat.sendMessage(text)
|
||||
→ buildSystemPrompt(contextNotes, allContent, model)
|
||||
→ Assembles selected notes as system context
|
||||
→ Estimates tokens, truncates if needed
|
||||
→ streamChat(messages, systemPrompt, model, callbacks)
|
||||
→ POST /api/ai/chat (Vite proxy → Anthropic API)
|
||||
→ SSE stream parsed, chunks dispatched to onChunk callback
|
||||
→ UI updates in real-time as tokens arrive
|
||||
→ On completion: message added to conversation history
|
||||
User sends message in AiPanel
|
||||
→ useAiAgent.sendMessage(text, references)
|
||||
→ buildContextSnapshot(activeNote, linkedNotes, openTabs)
|
||||
→ invoke('stream_claude_agent', { message, systemPrompt, vaultPath })
|
||||
→ Rust spawns: claude -p <msg> --output-format stream-json --mcp-config <json>
|
||||
→ NDJSON lines parsed into ClaudeStreamEvent variants:
|
||||
Init, TextDelta, ThinkingDelta, ToolStart, ToolDone, Result, Error, Done
|
||||
→ Events emitted via Tauri: app_handle.emit("claude-agent-stream", &event)
|
||||
→ Frontend listener routes events:
|
||||
onText → accumulate response (revealed on Done)
|
||||
onThinking → show reasoning block (collapsed on first text)
|
||||
onToolStart → add AiActionCard with spinner
|
||||
onToolDone → update card with output
|
||||
onDone → reveal full response, detect file operations
|
||||
```
|
||||
|
||||
### Context Picker
|
||||
#### File Operation Detection
|
||||
|
||||
The context picker controls which notes are sent to the AI as context:
|
||||
When the agent writes or edits vault files, `useAiAgent` detects this from tool inputs (Write/Edit tool JSON) and calls `onFileCreated` or `onFileModified` callbacks to trigger vault reload.
|
||||
|
||||
- **Current note** is auto-added when the panel opens
|
||||
- **Add button** opens a search dropdown to select additional notes
|
||||
- **Token estimation** shows approximate context size (~4 chars/token)
|
||||
- **Truncation** kicks in when context exceeds 60% of model limit (108k tokens)
|
||||
- Context pills show selected notes with remove buttons
|
||||
### Context Building
|
||||
|
||||
### API Key Management
|
||||
Both AI modes use context from the active note and linked entries. The agent panel (`ai-context.ts`) builds a structured JSON snapshot:
|
||||
|
||||
- Stored in `localStorage` under key `laputa:anthropic-api-key`
|
||||
- Configurable via the key icon in the AI Chat header
|
||||
- When no key is set, falls back to mock responses for testing
|
||||
```json
|
||||
{
|
||||
"activeNote": { "path", "title", "type", "frontmatter", "content" },
|
||||
"linkedNotes": [{ "path", "title", "content" }],
|
||||
"openTabs": [{ "title", "snippet" }],
|
||||
"vaultMetadata": { "noteTypes", "stats", "filter" },
|
||||
"references": [{ "title", "path", "type" }]
|
||||
}
|
||||
```
|
||||
|
||||
### Models
|
||||
Token budget: 60% of 180k context limit (~108k tokens max). Active note gets priority, then linked notes, then truncation.
|
||||
|
||||
### Models (Chat mode)
|
||||
|
||||
| Model | ID | Use case |
|
||||
|-------|----|----------|
|
||||
@@ -129,11 +160,17 @@ The context picker controls which notes are sent to the AI as context:
|
||||
| Sonnet 4 | `claude-sonnet-4-20250514` | Balanced |
|
||||
| Opus 4 | `claude-opus-4-20250514` | Most capable |
|
||||
|
||||
### MCP Server
|
||||
### API Key Management
|
||||
|
||||
- Stored in app settings (`~/.config/com.laputa.app/settings.json`) under `anthropic_key`
|
||||
- Configurable via Settings panel (also supports `openai_key`, `google_key`)
|
||||
- Claude CLI (agent mode) uses its own authentication — no API key needed
|
||||
|
||||
## MCP Server
|
||||
|
||||
The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistants (Claude Code, Cursor, or any MCP-compatible client).
|
||||
|
||||
#### Tool Surface (14 tools)
|
||||
### Tool Surface (14 tools)
|
||||
|
||||
| Tool | Params | Description |
|
||||
|------|--------|-------------|
|
||||
@@ -146,30 +183,28 @@ The MCP server (`mcp-server/`) exposes vault operations as tools for AI assistan
|
||||
| `delete_note` | `path` | Delete a note file from the vault |
|
||||
| `link_notes` | `source_path, property, target_title` | Add a target to an array property in frontmatter |
|
||||
| `list_notes` | `[type_filter], [sort]` | List all notes, optionally filtered by type |
|
||||
| `vault_context` | — | Get vault summary: entity types + 20 recent notes |
|
||||
| `vault_context` | — | Get vault summary: entity types + 20 recent notes + configFiles |
|
||||
| `ui_open_note` | `path` | Open a note in the Laputa UI editor |
|
||||
| `ui_open_tab` | `path` | Open a note in a new UI tab |
|
||||
| `ui_highlight` | `element, [path]` | Highlight a UI element (editor, tab, properties, notelist) |
|
||||
| `ui_set_filter` | `type` | Set the sidebar filter to a specific type |
|
||||
|
||||
#### Transports
|
||||
### Transports
|
||||
|
||||
- **stdio** — standard MCP transport for Claude Code / Cursor (`node mcp-server/index.js`)
|
||||
- **WebSocket** — live bridge for Laputa app integration:
|
||||
- Port **9710**: Tool bridge — AI/Claude clients call vault tools here
|
||||
- Port **9711**: UI bridge — Frontend listens for UI action broadcasts from MCP tools
|
||||
|
||||
#### Auto-Registration
|
||||
### Auto-Registration
|
||||
|
||||
On app startup, Laputa automatically registers itself as an MCP server in:
|
||||
- `~/.claude/mcp.json` (Claude Code)
|
||||
- `~/.cursor/mcp.json` (Cursor)
|
||||
|
||||
The registration is non-destructive (additive — preserves other MCP servers) and uses `upsert` semantics. The entry points to `mcp-server/index.js` with the active vault path as `VAULT_PATH` env var.
|
||||
Registration is non-destructive (additive, preserves other servers) and uses `upsert` semantics. The `useMcpStatus` hook tracks registration state (`checking | installed | not_installed | no_claude_cli`).
|
||||
|
||||
Registration also runs from the frontend via the `useMcpRegistration` hook and `register_mcp_tools` Tauri command, ensuring the config stays up-to-date when the vault path changes.
|
||||
|
||||
#### Architecture
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
@@ -200,16 +235,16 @@ The WebSocket bridge enables real-time vault operations from both the frontend a
|
||||
|
||||
```
|
||||
Frontend (useMcpBridge) ←→ ws://localhost:9710 ←→ ws-bridge.js ←→ vault.js
|
||||
MCP stdio tools ←→ ws://localhost:9711 ←→ Frontend UI actions
|
||||
MCP stdio tools ←→ ws://localhost:9711 ←→ Frontend UI actions (useAiActivity)
|
||||
```
|
||||
|
||||
**Tool bridge protocol** (port 9710):
|
||||
- Request: `{ "id": "req-1", "tool": "search_notes", "args": { "query": "test" } }`
|
||||
- Response: `{ "id": "req-1", "result": { ... } }`
|
||||
- Error: `{ "id": "req-1", "error": "message" }`
|
||||
|
||||
**UI bridge protocol** (port 9711):
|
||||
- Broadcast: `{ "type": "ui_action", "action": "open_note", "path": "..." }`
|
||||
- `useAiActivity` hook receives these and applies them (highlight with 800ms feedback, open note, set filter, etc.)
|
||||
|
||||
### Rust MCP Module
|
||||
|
||||
@@ -223,29 +258,132 @@ MCP stdio tools ←→ ws://localhost:9711 ←→ Frontend UI actions
|
||||
|
||||
The `WsBridgeChild` state wrapper in `lib.rs` ensures the bridge process is killed on app exit via `RunEvent::Exit` handler.
|
||||
|
||||
### Rust Backend (Tauri)
|
||||
## Search & Indexing
|
||||
|
||||
The `ai_chat` Tauri command (`src-tauri/src/ai_chat.rs`) provides a non-streaming alternative:
|
||||
- Uses `reqwest` to call the Anthropic Messages API directly
|
||||
- API key from `ANTHROPIC_API_KEY` environment variable
|
||||
- Returns full response (not streamed)
|
||||
- Used in production Tauri builds where Vite proxy is unavailable
|
||||
### Search Engine
|
||||
|
||||
### Files
|
||||
Search uses the external `qmd` binary (semantic search engine) with three modes:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/components/AIChatPanel.tsx` | Main UI: context bar, messages, input, quick actions |
|
||||
| `src/hooks/useAIChat.ts` | Chat state: messages, streaming, send/retry/clear |
|
||||
| `src/hooks/useMcpBridge.ts` | WebSocket client for MCP vault tool calls |
|
||||
| `src/hooks/useMcpRegistration.ts` | Auto-registers Laputa MCP on vault load |
|
||||
| `src/utils/ai-chat.ts` | API client, token estimation, context builder |
|
||||
| `src-tauri/src/ai_chat.rs` | Rust Anthropic API client (non-streaming) |
|
||||
| `src-tauri/src/mcp.rs` | MCP server spawning + config registration |
|
||||
| `mcp-server/index.js` | MCP server entry (stdio transport, 14 tools) |
|
||||
| `mcp-server/vault.js` | Vault file operations (9 functions) |
|
||||
| `mcp-server/ws-bridge.js` | WebSocket bridge server (tool + UI bridges) |
|
||||
| `mcp-server/test.js` | 26 unit tests for all vault.js functions |
|
||||
| Mode | Command | Description |
|
||||
|------|---------|-------------|
|
||||
| `keyword` | `qmd search` | Term matching (default) |
|
||||
| `semantic` | `qmd vsearch` | Vector similarity search |
|
||||
| `hybrid` | `qmd query` | Combined keyword + semantic |
|
||||
|
||||
### Indexing Flow
|
||||
|
||||
```
|
||||
Vault opened
|
||||
→ check_index_status() → parse qmd status output
|
||||
→ if stale or missing:
|
||||
→ start_indexing() (two phases):
|
||||
Phase 1 (Scanning): qmd update — scan all .md files
|
||||
Phase 2 (Embedding): qmd embed — generate vector embeddings
|
||||
→ Progress streamed via Tauri "indexing-progress" event
|
||||
→ Metadata saved to .laputa-index.json (last_indexed_commit, timestamp)
|
||||
→ run_incremental_update() for subsequent changes
|
||||
```
|
||||
|
||||
Embedding failure is non-fatal — keyword search still works.
|
||||
|
||||
### qmd Binary Resolution
|
||||
|
||||
1. Bundled macOS app resource: `<app>/Contents/Resources/qmd/qmd`
|
||||
2. Dev mode: `CARGO_MANIFEST_DIR/resources/qmd/qmd`
|
||||
3. System locations: `~/.bun/bin/qmd`, `/usr/local/bin/qmd`, `/opt/homebrew/bin/qmd`
|
||||
4. PATH lookup via `which qmd`
|
||||
5. Auto-install via `bun install -g qmd` if missing
|
||||
|
||||
## Vault Cache System
|
||||
|
||||
The vault cache (`src-tauri/src/vault/cache.rs`) accelerates vault scanning using git-based incremental updates.
|
||||
|
||||
### Cache File
|
||||
|
||||
`.laputa-cache.json` at vault root. Stores: vault path, git HEAD commit hash, all VaultEntry objects. Version: v5 (bumped on VaultEntry field changes to force full rescan).
|
||||
|
||||
### Three Cache Strategies
|
||||
|
||||
1. **Same Commit (Cache Hit)**: Git HEAD matches cached hash → only re-parse uncommitted changed files via `git status --porcelain`
|
||||
2. **Different Commit (Incremental Update)**: Uses `git diff <old>..<new> --name-only` to find changed files + uncommitted changes → selective re-parse
|
||||
3. **No Cache / Corrupt Cache (Full Scan)**: Recursive `walkdir` of all `.md` files → full parse
|
||||
|
||||
Cache auto-excludes itself from git via `.git/info/exclude`.
|
||||
|
||||
## Theme System
|
||||
|
||||
See [THEMING.md](./THEMING.md) for the full theme system documentation.
|
||||
|
||||
### Two-Layer Architecture
|
||||
|
||||
1. **Global CSS variables** (`src/index.css`): App-wide colors, borders, backgrounds. Bridged to Tailwind v4 via `@theme inline`.
|
||||
2. **Editor theme** (`src/theme.json`): BlockNote-specific typography. Flattened to CSS vars by `useEditorTheme`.
|
||||
|
||||
### Vault-Based Themes
|
||||
|
||||
Themes are markdown notes in the `theme/` folder with `Is A: Theme` frontmatter. Each frontmatter property becomes a CSS variable. Managed by `useThemeManager` hook and the `src-tauri/src/theme/` Rust module (create, seed, defaults).
|
||||
|
||||
- **Vault settings**: `.laputa/settings.json` stores the active theme reference
|
||||
- **Legacy support**: `_themes/*.json` files still supported for backward compatibility
|
||||
- **Built-in themes**: Default (light), Dark, Minimal — auto-seeded on vault open
|
||||
- **Live preview**: Re-applies when the active theme note is saved
|
||||
|
||||
## Vault Management
|
||||
|
||||
### Vault List
|
||||
|
||||
Persisted at `~/.config/com.laputa.app/vaults.json`:
|
||||
```json
|
||||
{
|
||||
"vaults": [{ "label": "My Vault", "path": "/path/to/vault" }],
|
||||
"active_vault": "/path/to/vault",
|
||||
"hidden_defaults": []
|
||||
}
|
||||
```
|
||||
|
||||
Managed by `useVaultSwitcher` hook. Switching vaults closes all tabs and resets sidebar.
|
||||
|
||||
### Vault Config
|
||||
|
||||
Per-vault UI settings stored in `config/ui.config.md` (YAML frontmatter in a markdown note):
|
||||
- `zoom`: Float zoom level (0.8–1.5)
|
||||
- `view_mode`: "all" | "editor-list" | "editor-only"
|
||||
- `tag_colors`, `status_colors`: Custom color overrides
|
||||
- `property_display_modes`: Property display preferences
|
||||
|
||||
### Getting Started Vault
|
||||
|
||||
On first launch, `useOnboarding` checks if the default vault exists. If not, shows `WelcomeScreen` with two options:
|
||||
- **Create Getting Started vault** → calls `create_getting_started_vault()` Tauri command
|
||||
- **Open an existing folder** → system file picker
|
||||
|
||||
### GitHub OAuth Integration
|
||||
|
||||
Implements GitHub Device Authorization Flow for cloning/creating GitHub-backed vaults.
|
||||
|
||||
**Flow:**
|
||||
1. User clicks "Login with GitHub" in Settings panel
|
||||
2. `github_device_flow_start()` returns a user code + verification URL
|
||||
3. User authorizes at `github.com/login/device`
|
||||
4. App polls `github_device_flow_poll()` until authorized
|
||||
5. Token stored in `~/.config/com.laputa.app/settings.json`
|
||||
|
||||
**Vault operations:**
|
||||
- `GitHubVaultModal`: Clone existing repo or create new private/public repo
|
||||
- `clone_repo()`: Clones with token-injected HTTPS URL
|
||||
- Token persists for future git push/pull operations
|
||||
|
||||
## Pulse View
|
||||
|
||||
`PulseView` is a git activity feed that replaces the NoteList when the Pulse filter is selected.
|
||||
|
||||
- Groups commits by day ("Today", "Yesterday", or full date)
|
||||
- Shows commit message, short hash, timestamp, and changed files
|
||||
- Files have status icons (added/modified/deleted) and are clickable to open in editor
|
||||
- Links to GitHub commits when `githubUrl` is available
|
||||
- Infinite scroll pagination (20 commits per page) via Intersection Observer
|
||||
|
||||
Backend: `get_vault_pulse` Tauri command parses `git log` with `--name-status`.
|
||||
|
||||
## Data Flow
|
||||
|
||||
@@ -253,93 +391,201 @@ The `ai_chat` Tauri command (`src-tauri/src/ai_chat.rs`) provides a non-streamin
|
||||
|
||||
```
|
||||
1. Tauri setup:
|
||||
a. run_startup_tasks() → purge trash, migrate frontmatter, register MCP config
|
||||
a. run_startup_tasks() → purge trash, migrate frontmatter, seed themes, migrate AGENTS.md, seed config files, register MCP
|
||||
b. spawn_ws_bridge() → start MCP WebSocket bridge (ports 9710, 9711)
|
||||
2. App mounts
|
||||
3. useVaultLoader fires:
|
||||
a. isTauri() ? invoke('list_vault') : mockInvoke('list_vault')
|
||||
→ VaultEntry[] stored in state
|
||||
b. Load all content (mock mode) or on-demand (Tauri mode)
|
||||
c. invoke('get_modified_files') → ModifiedFile[] stored in state
|
||||
d. useMcpRegistration → invoke('register_mcp_tools') → ensures MCP config current
|
||||
4. User clicks note in NoteList
|
||||
4. useNoteActions.handleSelectNote:
|
||||
a. invoke('get_note_content') → raw markdown string
|
||||
3. useOnboarding checks vault exists → WelcomeScreen if not
|
||||
4. useVaultLoader fires:
|
||||
a. invoke('list_vault', { path }) → scan_vault_cached() → VaultEntry[]
|
||||
b. Load modified files via invoke('get_modified_files')
|
||||
c. useMcpStatus → register MCP if needed
|
||||
d. useThemeManager → load and apply active theme
|
||||
e. useIndexing → check index status, trigger incremental update if needed
|
||||
5. User clicks note in NoteList
|
||||
6. useNoteActions.handleSelectNote:
|
||||
a. invoke('get_note_content') → raw markdown
|
||||
b. Add tab { entry, content } to tabs state
|
||||
c. Set activeTabPath
|
||||
5. Editor renders BlockNoteTab:
|
||||
7. Editor renders BlockNoteTab:
|
||||
a. splitFrontmatter(content) → [yaml, body]
|
||||
b. preProcessWikilinks(body) → replaces [[target]] with tokens
|
||||
c. editor.tryParseMarkdownToBlocks(preprocessed)
|
||||
d. injectWikilinks(blocks) → replaces tokens with wikilink nodes
|
||||
e. editor.replaceBlocks()
|
||||
6. Inspector renders frontmatter parsed from content
|
||||
8. Inspector renders frontmatter parsed from content
|
||||
```
|
||||
|
||||
### Frontmatter Edit Flow
|
||||
### Auto-Save Flow
|
||||
|
||||
```
|
||||
User edits property in Inspector
|
||||
→ handleUpdateFrontmatter(path, key, value)
|
||||
→ Tauri: invoke('update_frontmatter') → Rust reads file, modifies YAML, writes back
|
||||
→ Mock: updateMockFrontmatter() → client-side YAML manipulation
|
||||
→ Update tab content in state
|
||||
→ Update allContent for backlink recalculation
|
||||
→ Toast: "Property updated"
|
||||
Editor content changes
|
||||
→ useEditorSave detects change (debounced)
|
||||
→ serialize BlockNote blocks → markdown
|
||||
→ postProcessWikilinks → restore [[target]] syntax
|
||||
→ invoke('save_note_content', { path, content })
|
||||
→ Update tab status indicator
|
||||
```
|
||||
|
||||
### Git Flow
|
||||
### Git Sync Flow
|
||||
|
||||
```
|
||||
User clicks Commit button → CommitDialog opens
|
||||
→ handleCommitPush(message)
|
||||
→ invoke('git_commit') → git add -A && git commit -m "..."
|
||||
→ invoke('git_push') → git push
|
||||
useAutoSync (configurable interval, default from settings):
|
||||
→ invoke('git_pull') → GitPullResult
|
||||
→ if conflicts → ConflictResolverModal
|
||||
→ if fast-forward → reload vault
|
||||
→ invoke('git_push') → GitPushResult
|
||||
|
||||
Manual commit:
|
||||
→ CommitDialog → invoke('git_commit', { message })
|
||||
→ invoke('git_push')
|
||||
→ Reload modified files
|
||||
→ Toast: "Committed and pushed"
|
||||
```
|
||||
|
||||
## Vault Module Structure
|
||||
|
||||
The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
|
||||
| File | Purpose | CodeScene Health |
|
||||
|------|---------|-----------------|
|
||||
| `mod.rs` | Core types (`VaultEntry`, `Frontmatter`), `parse_md_file`, `scan_vault`, relationship extraction | 10.0 |
|
||||
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` | 9.68 |
|
||||
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers | 9.68 |
|
||||
| `trash.rs` | `purge_trash` — deletes trashed notes older than 30 days | 9.38 |
|
||||
| `rename.rs` | `rename_note` — renames files and updates wikilinks across the vault | 9.68 |
|
||||
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames | 10.0 |
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `mod.rs` | Core types (`VaultEntry`, `Frontmatter`), `parse_md_file`, `scan_vault`, relationship/link extraction |
|
||||
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` |
|
||||
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
|
||||
| `trash.rs` | `purge_trash` — deletes trashed notes older than 30 days |
|
||||
| `rename.rs` | `rename_note` — renames files and updates wikilinks across the vault |
|
||||
| `image.rs` | `save_image` — saves base64-encoded attachments with sanitized filenames |
|
||||
| `migration.rs` | Frontmatter migration utilities |
|
||||
| `config_seed.rs` | Seeds `config/` folder, migrates `AGENTS.md`, repairs missing config files |
|
||||
| `getting_started.rs` | Creates the Getting Started demo vault |
|
||||
|
||||
Public API (re-exported from `mod.rs`): `scan_vault_cached`, `save_image`, `rename_note`, `RenameResult`, `purge_trash`, `get_note_content`, `parse_md_file`, `VaultEntry`.
|
||||
## Rust Backend Modules
|
||||
|
||||
## Tauri IPC Commands
|
||||
| Module | Purpose |
|
||||
|--------|---------|
|
||||
| `vault/` | Vault scanning, caching, parsing, trash, rename, image, migration |
|
||||
| `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) |
|
||||
| `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`) |
|
||||
| `github/` | GitHub OAuth + API (`auth.rs`, `api.rs`, `clone.rs`) |
|
||||
| `theme/` | Theme management (`mod.rs`, `create.rs`, `defaults.rs`, `seed.rs`) |
|
||||
| `search.rs` | qmd search integration (keyword/semantic/hybrid) |
|
||||
| `indexing.rs` | qmd indexing with progress streaming |
|
||||
| `claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing |
|
||||
| `ai_chat.rs` | Direct Anthropic API client (non-streaming, for Tauri builds) |
|
||||
| `mcp.rs` | MCP server spawning + config registration |
|
||||
| `commands.rs` | All 61 Tauri command handlers |
|
||||
| `settings.rs` | App settings persistence |
|
||||
| `vault_config.rs` | Per-vault UI config |
|
||||
| `vault_list.rs` | Vault list persistence |
|
||||
| `menu.rs` | Native macOS menu bar |
|
||||
|
||||
All commands are defined in `src-tauri/src/lib.rs` and registered via `tauri::generate_handler![]`.
|
||||
## Tauri IPC Commands (61 total)
|
||||
|
||||
| Command | Params | Returns | Backend function |
|
||||
|---------|--------|---------|-----------------|
|
||||
| `list_vault` | `path` | `Vec<VaultEntry>` | `vault::scan_vault()` |
|
||||
| `get_note_content` | `path` | `String` | `vault::get_note_content()` |
|
||||
| `update_frontmatter` | `path, key, value` | `String` (updated content) | `frontmatter::with_frontmatter()` |
|
||||
| `delete_frontmatter_property` | `path, key` | `String` (updated content) | `frontmatter::with_frontmatter()` |
|
||||
| `get_file_history` | `vault_path, path` | `Vec<GitCommit>` | `git::get_file_history()` |
|
||||
| `get_modified_files` | `vault_path` | `Vec<ModifiedFile>` | `git::get_modified_files()` |
|
||||
| `get_file_diff` | `vault_path, path` | `String` (unified diff) | `git::get_file_diff()` |
|
||||
| `git_commit` | `vault_path, message` | `String` | `git::git_commit()` |
|
||||
| `git_push` | `vault_path` | `String` | `git::git_push()` |
|
||||
| `ai_chat` | `request: AiChatRequest` | `AiChatResponse` | `ai_chat::send_chat()` |
|
||||
| `register_mcp_tools` | `vault_path` | `String` ("registered" or "updated") | `mcp::register_mcp()` |
|
||||
### Vault Operations
|
||||
|
||||
All commands return `Result<T, String>`. Errors are serialized as JSON error objects to the frontend.
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list_vault` | Scan vault (cached) → `Vec<VaultEntry>` |
|
||||
| `get_note_content` | Read note file content |
|
||||
| `save_note_content` | Write note content to disk |
|
||||
| `delete_note` | Move note to trash |
|
||||
| `rename_note` | Rename note + update cross-vault wikilinks |
|
||||
| `batch_archive_notes` | Archive multiple notes |
|
||||
| `batch_trash_notes` | Trash multiple notes |
|
||||
| `purge_trash` | Delete notes trashed >30 days ago |
|
||||
| `check_vault_exists` | Check if vault path exists |
|
||||
| `create_getting_started_vault` | Bootstrap demo vault |
|
||||
|
||||
### Frontmatter
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `update_frontmatter` | Update a frontmatter property |
|
||||
| `delete_frontmatter_property` | Remove a frontmatter property |
|
||||
|
||||
### Git
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `git_commit` | Stage all + commit |
|
||||
| `git_pull` | Pull from remote |
|
||||
| `git_push` | Push to remote |
|
||||
| `git_resolve_conflict` | Resolve a merge conflict |
|
||||
| `git_commit_conflict_resolution` | Commit conflict resolution |
|
||||
| `get_file_history` | Last N commits for a file |
|
||||
| `get_modified_files` | `git status` filtered to .md |
|
||||
| `get_file_diff` | Unified diff for a file |
|
||||
| `get_file_diff_at_commit` | Diff at a specific commit |
|
||||
| `get_conflict_files` | List conflicted files |
|
||||
| `get_conflict_mode` | Get conflict resolution mode |
|
||||
| `get_vault_pulse` | Git activity feed (paginated) |
|
||||
| `get_last_commit_info` | Latest commit metadata |
|
||||
|
||||
### GitHub
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `github_device_flow_start` | Begin OAuth device flow |
|
||||
| `github_device_flow_poll` | Poll for authorization |
|
||||
| `github_get_user` | Get authenticated user info |
|
||||
| `github_list_repos` | List user's repos |
|
||||
| `github_create_repo` | Create new repo |
|
||||
| `clone_repo` | Clone repo with token auth |
|
||||
|
||||
### Search & Indexing
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `search_vault` | Search via qmd (keyword/semantic/hybrid) |
|
||||
| `get_index_status` | Check qmd index state |
|
||||
| `start_indexing` | Full index with progress streaming |
|
||||
| `trigger_incremental_index` | Incremental index update |
|
||||
|
||||
### Theme
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `list_themes` | List all themes (legacy JSON) |
|
||||
| `get_theme` | Read a theme file |
|
||||
| `get_vault_settings` | Read `.laputa/settings.json` |
|
||||
| `save_vault_settings` | Write vault settings |
|
||||
| `set_active_theme` | Set active theme ID |
|
||||
| `create_theme` | Create JSON theme from template |
|
||||
| `create_vault_theme` | Create markdown theme note |
|
||||
| `ensure_vault_themes` | Seed default themes if missing |
|
||||
| `restore_default_themes` | Restore all default themes |
|
||||
| `repair_vault` | Restore default themes + missing config files |
|
||||
|
||||
### AI & MCP
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `ai_chat` | Direct Anthropic API call (non-streaming) |
|
||||
| `stream_claude_chat` | Claude CLI chat mode (streaming) |
|
||||
| `stream_claude_agent` | Claude CLI agent mode (streaming + tools) |
|
||||
| `check_claude_cli` | Check if Claude CLI is available |
|
||||
| `register_mcp_tools` | Register MCP in Claude/Cursor config |
|
||||
| `check_mcp_status` | Check MCP registration state |
|
||||
|
||||
### Settings & Config
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `get_settings` | Load app settings |
|
||||
| `save_settings` | Save app settings |
|
||||
| `load_vault_list` | Load vault list |
|
||||
| `save_vault_list` | Save vault list |
|
||||
| `get_vault_config` | Load per-vault UI config |
|
||||
| `save_vault_config` | Save per-vault UI config |
|
||||
| `get_default_vault_path` | Get default vault path |
|
||||
| `get_build_number` | Get app build number |
|
||||
| `save_image` | Save base64 image to vault |
|
||||
| `copy_image_to_vault` | Copy image file to vault |
|
||||
| `update_menu_state` | Update native menu checkmarks |
|
||||
|
||||
## Mock Layer
|
||||
|
||||
When running outside Tauri (browser at `localhost:5201`), `src/mock-tauri.ts` provides a transparent mock layer:
|
||||
When running outside Tauri (browser at `localhost:5173`), `src/mock-tauri.ts` provides a transparent mock layer:
|
||||
|
||||
```typescript
|
||||
// In hooks, the pattern is always:
|
||||
if (isTauri()) {
|
||||
result = await invoke<T>('command_name', { args })
|
||||
} else {
|
||||
@@ -347,14 +593,7 @@ if (isTauri()) {
|
||||
}
|
||||
```
|
||||
|
||||
The mock layer includes:
|
||||
- **15 sample entries** across all entity types (Project, Responsibility, Procedure, Experiment, Note, Person, Event, Topic, Essay)
|
||||
- **Full markdown content** with realistic frontmatter for each entry
|
||||
- **Mock git history, modified files, and diff output**
|
||||
- **Mock AI chat responses** with context-aware answers (summarize, expand, grammar)
|
||||
- `addMockEntry()` and `updateMockContent()` for runtime updates
|
||||
|
||||
This means the entire UI can be developed and tested in Chrome without the Rust backend.
|
||||
The mock layer includes sample entries across all entity types, full markdown content with realistic frontmatter, mock git history, mock AI responses, and mock pulse commits.
|
||||
|
||||
## State Management
|
||||
|
||||
@@ -362,11 +601,18 @@ No Redux or global context. State lives in the root `App.tsx` and custom hooks:
|
||||
|
||||
| State owner | State | Purpose |
|
||||
|-------------|-------|---------|
|
||||
| `App.tsx` | `selection`, panel widths, dialog visibility, toast, `showAIChat` | UI state |
|
||||
| `App.tsx` | `selection`, panel widths, dialog visibility, toast, view mode | UI state |
|
||||
| `useVaultLoader` | `entries`, `allContent`, `modifiedFiles` | Vault data |
|
||||
| `useNoteActions` | `tabs`, `activeTabPath` | Open tabs and note operations |
|
||||
| `useAIChat` | `messages`, `isStreaming`, `streamingContent` | AI conversation state |
|
||||
| `useMcpBridge` | `connected`, tool methods | MCP WebSocket connection |
|
||||
| `useTabManagement` | Tab ordering, pinning, swapping | Tab lifecycle |
|
||||
| `useVaultSwitcher` | `vaultPath`, `extraVaults` | Vault switching |
|
||||
| `useThemeManager` | `themes`, `activeThemeId`, `isDark` | Theme state |
|
||||
| `useAIChat` | `messages`, `isStreaming` | AI chat conversation |
|
||||
| `useAiAgent` | `messages`, `status`, tool actions | AI agent conversation |
|
||||
| `useAutoSync` | Sync interval, pull/push state | Git auto-sync |
|
||||
| `useIndexing` | Index status, progress | Search indexing |
|
||||
| `useSettings` | App settings (API keys, GitHub token) | Persistent settings |
|
||||
| `useVaultConfig` | Per-vault UI preferences | Vault-specific config |
|
||||
|
||||
Data flows unidirectionally: `App` passes data and callbacks as props to child components. No child-to-child communication — everything goes through `App`.
|
||||
|
||||
@@ -374,10 +620,14 @@ Data flows unidirectionally: `App` passes data and callbacks as props to child c
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| Cmd+P | Open Quick Open palette |
|
||||
| Cmd+N | Open Create Note dialog |
|
||||
| Cmd+S | Show "Saved" toast |
|
||||
| Cmd+K | Open command palette |
|
||||
| Cmd+P | Open quick open palette |
|
||||
| Cmd+N | Create new note |
|
||||
| Cmd+S | Save current note |
|
||||
| Cmd+W | Close active tab |
|
||||
| Cmd+Z / Cmd+Shift+Z | Undo / Redo |
|
||||
| Cmd+1–9 | Switch to tab N |
|
||||
| Cmd+[ / Cmd+] | Navigate back / forward |
|
||||
| `[[` in editor | Open wikilink suggestion menu |
|
||||
|
||||
## Auto-Release & In-App Updates
|
||||
@@ -399,47 +649,22 @@ push to main
|
||||
→ generate latest.json (per-arch + universal platform entries)
|
||||
→ publish GitHub Release with all assets + auto-generated notes
|
||||
→ pages job:
|
||||
→ fetch all releases via gh api
|
||||
→ build static HTML release history page
|
||||
→ deploy to gh-pages via peaceiris/actions-gh-pages
|
||||
→ deploy to gh-pages
|
||||
```
|
||||
|
||||
### Versioning
|
||||
|
||||
Format: `0.YYYYMMDD.GITHUB_RUN_NUMBER` (e.g. `0.20260223.42`). The `0.` prefix keeps it SemVer-compatible while making it clear these are date-based auto-releases. The version is stamped into both `tauri.conf.json` and `Cargo.toml` dynamically in the workflow.
|
||||
Format: `0.YYYYMMDD.GITHUB_RUN_NUMBER` (e.g. `0.20260223.42`). Stamped into `tauri.conf.json` and `Cargo.toml` dynamically.
|
||||
|
||||
### Universal Binary
|
||||
|
||||
macOS builds produce both `aarch64-apple-darwin` and `x86_64-apple-darwin` in parallel. The release job merges them with `lipo` — copying the arm64 `.app` as the base and replacing only the main executable with a universal fat binary. The per-arch updater tarballs are also uploaded so the Tauri updater downloads only the relevant architecture (smaller download).
|
||||
|
||||
### Updater Endpoint
|
||||
|
||||
The Tauri updater plugin is configured to fetch:
|
||||
```
|
||||
https://github.com/refactoringhq/laputa-app/releases/latest/download/latest.json
|
||||
```
|
||||
|
||||
This JSON manifest contains `version`, `pub_date`, `notes`, and per-platform entries (`darwin-aarch64`, `darwin-x86_64`) with `url` and `signature` fields. The updater compares the manifest version against the running app version, downloads the matching platform artifact, verifies the signature, and installs it.
|
||||
|
||||
### In-App Update UI
|
||||
### In-App Updates
|
||||
|
||||
```
|
||||
App startup (3s delay)
|
||||
→ useUpdater.check()
|
||||
→ idle (no update) → no UI shown
|
||||
→ available → UpdateBanner: "Laputa X.Y.Z is available" + Release Notes + Update Now + X
|
||||
→ user clicks Update Now → downloading → progress bar
|
||||
→ download complete → ready → "Restart to apply" + Restart Now button
|
||||
→ user clicks Restart → relaunch()
|
||||
→ network error / 404 → fail silently, no UI
|
||||
→ idle (no update) → no UI
|
||||
→ available → UpdateBanner with release notes + "Update Now"
|
||||
→ downloading → progress bar
|
||||
→ ready → "Restart to apply" + Restart Now
|
||||
→ network error → fail silently
|
||||
```
|
||||
|
||||
| Component | File | Purpose |
|
||||
|-----------|------|---------|
|
||||
| `useUpdater` | `src/hooks/useUpdater.ts` | State machine: idle → available → downloading → ready → error |
|
||||
| `UpdateBanner` | `src/components/UpdateBanner.tsx` | Top-of-app notification bar |
|
||||
| `restartApp` | `src/hooks/useUpdater.ts` | Calls `@tauri-apps/plugin-process` relaunch |
|
||||
|
||||
### GitHub Pages
|
||||
|
||||
Release history site at `https://refactoringhq.github.io/laputa-app/`. Auto-updated by the workflow after each release. The page loads `releases.json` (deployed alongside) and renders each release with date, notes, and `.dmg` download links. Linked from the in-app "Release Notes" button.
|
||||
|
||||
@@ -7,6 +7,7 @@ How to navigate the codebase, run the app, and find what you need.
|
||||
- **Node.js** 18+ and **pnpm**
|
||||
- **Rust** 1.77.2+ (for the Tauri backend)
|
||||
- **git** CLI (required by the git integration features)
|
||||
- **qmd** (optional — for search indexing; auto-installed if missing)
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -24,7 +25,7 @@ pnpm tauri dev
|
||||
# Run tests
|
||||
pnpm test # Vitest unit tests
|
||||
cargo test # Rust tests (from src-tauri/)
|
||||
pnpm test:e2e # Playwright E2E tests
|
||||
pnpm playwright:smoke # Playwright smoke tests
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
@@ -33,39 +34,94 @@ pnpm test:e2e # Playwright E2E tests
|
||||
laputa-app/
|
||||
├── src/ # React frontend
|
||||
│ ├── main.tsx # Entry point (renders <App />)
|
||||
│ ├── App.tsx # Root component — orchestrates 4-panel layout
|
||||
│ ├── App.tsx # Root component — orchestrates layout + state
|
||||
│ ├── App.css # App shell layout styles
|
||||
│ ├── types.ts # Shared TS types (VaultEntry, GitCommit, etc.)
|
||||
│ ├── types.ts # Shared TS types (VaultEntry, Settings, etc.)
|
||||
│ ├── mock-tauri.ts # Mock Tauri layer for browser testing
|
||||
│ ├── theme.json # Editor theme configuration
|
||||
│ ├── index.css # Global CSS variables + Tailwind setup
|
||||
│ │
|
||||
│ ├── components/ # UI components
|
||||
│ │ ├── Sidebar.tsx # Left panel: filters + section groups
|
||||
│ ├── components/ # UI components (~98 files)
|
||||
│ │ ├── Sidebar.tsx # Left panel: filters + type groups
|
||||
│ │ ├── SidebarParts.tsx # Sidebar subcomponents
|
||||
│ │ ├── NoteList.tsx # Second panel: filtered note list
|
||||
│ │ ├── Editor.tsx # Third panel: tabs + BlockNote + diff
|
||||
│ │ ├── NoteItem.tsx # Individual note item
|
||||
│ │ ├── PulseView.tsx # Git activity feed (replaces NoteList)
|
||||
│ │ ├── Editor.tsx # Third panel: tabs + editor orchestration
|
||||
│ │ ├── EditorContent.tsx # Editor content area
|
||||
│ │ ├── EditorRightPanel.tsx # Right panel toggle
|
||||
│ │ ├── editorSchema.tsx # BlockNote schema + wikilink type
|
||||
│ │ ├── RawEditorView.tsx # CodeMirror raw editor
|
||||
│ │ ├── Inspector.tsx # Fourth panel: metadata + relationships
|
||||
│ │ ├── DynamicPropertiesPanel.tsx # Editable frontmatter properties
|
||||
│ │ ├── EditableValue.tsx # Inline value editor component
|
||||
│ │ ├── DiffView.tsx # Git diff viewer
|
||||
│ │ ├── ResizeHandle.tsx # Draggable panel divider
|
||||
│ │ ├── StatusBar.tsx # Bottom status bar
|
||||
│ │ ├── QuickOpenPalette.tsx # Cmd+P command palette
|
||||
│ │ ├── CreateNoteDialog.tsx # New note modal
|
||||
│ │ ├── AIChatPanel.tsx # AI chat (API-based)
|
||||
│ │ ├── AiPanel.tsx # AI agent (Claude CLI subprocess)
|
||||
│ │ ├── AiMessage.tsx # Agent message display
|
||||
│ │ ├── AiActionCard.tsx # Agent tool action cards
|
||||
│ │ ├── SearchPanel.tsx # Search interface
|
||||
│ │ ├── SettingsPanel.tsx # App settings
|
||||
│ │ ├── StatusBar.tsx # Bottom bar: vault picker + sync
|
||||
│ │ ├── CommandPalette.tsx # Cmd+K command launcher
|
||||
│ │ ├── TabBar.tsx # Tab management
|
||||
│ │ ├── BreadcrumbBar.tsx # Breadcrumb + word count + actions
|
||||
│ │ ├── WelcomeScreen.tsx # Onboarding screen
|
||||
│ │ ├── GitHubVaultModal.tsx # GitHub vault clone/create
|
||||
│ │ ├── GitHubDeviceFlow.tsx # GitHub OAuth device flow
|
||||
│ │ ├── ThemePropertyEditor.tsx # Interactive theme editor
|
||||
│ │ ├── ConflictResolverModal.tsx # Git conflict resolution
|
||||
│ │ ├── CommitDialog.tsx # Git commit modal
|
||||
│ │ ├── Toast.tsx # Toast notifications
|
||||
│ │ ├── Editor.css # Editor layout styles
|
||||
│ │ ├── EditorTheme.css # BlockNote theme overrides
|
||||
│ │ └── ui/ # shadcn/ui primitives (button, dialog, etc.)
|
||||
│ │ ├── CreateNoteDialog.tsx # New note modal
|
||||
│ │ ├── CreateTypeDialog.tsx # New type modal
|
||||
│ │ ├── UpdateBanner.tsx # In-app update notification
|
||||
│ │ ├── inspector/ # Inspector sub-panels
|
||||
│ │ │ ├── BacklinksPanel.tsx
|
||||
│ │ │ ├── RelationshipsPanel.tsx
|
||||
│ │ │ ├── GitHistoryPanel.tsx
|
||||
│ │ │ └── ...
|
||||
│ │ └── ui/ # shadcn/ui primitives
|
||||
│ │ ├── button.tsx, dialog.tsx, input.tsx, ...
|
||||
│ │
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ │ ├── useVaultLoader.ts # Loads vault entries, git status, content
|
||||
│ │ ├── useNoteActions.ts # Tab management, frontmatter CRUD, navigation
|
||||
│ │ └── useTheme.ts # Flattens theme.json into CSS variables
|
||||
│ ├── hooks/ # Custom React hooks (~87 files)
|
||||
│ │ ├── useVaultLoader.ts # Loads vault entries + content
|
||||
│ │ ├── useVaultSwitcher.ts # Multi-vault management
|
||||
│ │ ├── useVaultConfig.ts # Per-vault UI settings
|
||||
│ │ ├── useNoteActions.ts # Tab management, navigation, CRUD
|
||||
│ │ ├── useTabManagement.ts # Tab ordering + lifecycle
|
||||
│ │ ├── useAIChat.ts # AI chat state
|
||||
│ │ ├── useAiAgent.ts # AI agent state + tool tracking
|
||||
│ │ ├── useAiActivity.ts # MCP UI bridge listener
|
||||
│ │ ├── useAutoSync.ts # Auto git pull/push
|
||||
│ │ ├── useConflictResolver.ts # Git conflict handling
|
||||
│ │ ├── useEditorSave.ts # Auto-save with debounce
|
||||
│ │ ├── useTheme.ts # Flatten theme.json → CSS vars
|
||||
│ │ ├── useThemeManager.ts # Vault theme lifecycle
|
||||
│ │ ├── useIndexing.ts # Search indexing management
|
||||
│ │ ├── useNoteSearch.ts # Note search
|
||||
│ │ ├── useCommandRegistry.ts # Command palette registry
|
||||
│ │ ├── useAppCommands.ts # App-level commands
|
||||
│ │ ├── useAppKeyboard.ts # Keyboard shortcuts
|
||||
│ │ ├── useSettings.ts # App settings
|
||||
│ │ ├── useOnboarding.ts # First-launch flow
|
||||
│ │ ├── useCodeMirror.ts # CodeMirror raw editor
|
||||
│ │ ├── useMcpBridge.ts # MCP WebSocket client
|
||||
│ │ ├── useMcpStatus.ts # MCP registration status
|
||||
│ │ ├── useUpdater.ts # In-app updates
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── utils/ # Pure utility functions
|
||||
│ │ ├── frontmatter.ts # TypeScript YAML frontmatter parser
|
||||
│ │ └── wikilinks.ts # Wikilink preprocessing + word count
|
||||
│ ├── utils/ # Pure utility functions (~48 files)
|
||||
│ │ ├── wikilinks.ts # Wikilink preprocessing pipeline
|
||||
│ │ ├── frontmatter.ts # TypeScript YAML parser
|
||||
│ │ ├── ai-agent.ts # Agent stream utilities
|
||||
│ │ ├── ai-chat.ts # Chat API client + token estimation
|
||||
│ │ ├── ai-context.ts # Context snapshot builder
|
||||
│ │ ├── noteListHelpers.ts # Sorting, filtering, date formatting
|
||||
│ │ ├── themeSchema.ts # Theme editor schema builder
|
||||
│ │ ├── configMigration.ts # localStorage → vault config migration
|
||||
│ │ ├── iconRegistry.ts # Phosphor icon registry
|
||||
│ │ ├── propertyTypes.ts # Property type definitions
|
||||
│ │ ├── vaultListStore.ts # Vault list persistence
|
||||
│ │ ├── vaultConfigStore.ts # Vault config store
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── lib/
|
||||
│ │ └── utils.ts # Tailwind merge + cn() helper
|
||||
@@ -78,28 +134,58 @@ laputa-app/
|
||||
│ ├── build.rs # Tauri build script
|
||||
│ ├── tauri.conf.json # Tauri app configuration
|
||||
│ ├── capabilities/ # Tauri v2 security capabilities
|
||||
│ │ └── default.json
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs # Entry point (calls lib::run())
|
||||
│ │ ├── lib.rs # Tauri command registration (9 commands)
|
||||
│ │ ├── vault.rs # Vault scanning + markdown parsing
|
||||
│ │ ├── frontmatter.rs # YAML frontmatter manipulation
|
||||
│ │ └── git.rs # Git CLI operations
|
||||
│ │ ├── lib.rs # Tauri setup + command registration (61 commands)
|
||||
│ │ ├── commands.rs # All Tauri command handlers
|
||||
│ │ ├── vault/ # Vault module
|
||||
│ │ │ ├── mod.rs # Core types, parse_md_file, scan_vault
|
||||
│ │ │ ├── cache.rs # Git-based incremental caching
|
||||
│ │ │ ├── parsing.rs # Text processing + title extraction
|
||||
│ │ │ ├── trash.rs # Trash auto-purge
|
||||
│ │ │ ├── rename.rs # Rename + cross-vault wikilink update
|
||||
│ │ │ ├── image.rs # Image attachment saving
|
||||
│ │ │ ├── migration.rs # Frontmatter migration
|
||||
│ │ │ └── getting_started.rs # Getting Started vault creation
|
||||
│ │ ├── frontmatter/ # Frontmatter module
|
||||
│ │ │ ├── mod.rs, yaml.rs, ops.rs
|
||||
│ │ ├── git/ # Git module
|
||||
│ │ │ ├── mod.rs, commit.rs, status.rs, history.rs
|
||||
│ │ │ ├── conflict.rs, remote.rs, pulse.rs
|
||||
│ │ ├── github/ # GitHub module
|
||||
│ │ │ ├── mod.rs, auth.rs, api.rs, clone.rs
|
||||
│ │ ├── theme/ # Theme module
|
||||
│ │ │ ├── mod.rs, create.rs, defaults.rs, seed.rs
|
||||
│ │ ├── search.rs # qmd search integration
|
||||
│ │ ├── indexing.rs # qmd indexing + progress streaming
|
||||
│ │ ├── claude_cli.rs # Claude CLI subprocess management
|
||||
│ │ ├── ai_chat.rs # Direct Anthropic API client
|
||||
│ │ ├── mcp.rs # MCP server lifecycle + registration
|
||||
│ │ ├── settings.rs # App settings persistence
|
||||
│ │ ├── vault_config.rs # Per-vault UI config
|
||||
│ │ ├── vault_list.rs # Vault list persistence
|
||||
│ │ └── menu.rs # Native macOS menu bar
|
||||
│ └── icons/ # App icons
|
||||
│
|
||||
├── e2e/ # Playwright E2E tests
|
||||
│ ├── app.spec.ts # App loading tests
|
||||
│ ├── core-flows.spec.ts # Main user workflows
|
||||
│ ├── keyboard-shortcuts.spec.ts
|
||||
│ ├── quick-open.spec.ts
|
||||
│ ├── screenshot.spec.ts # Visual regression screenshots
|
||||
│ └── ...
|
||||
├── mcp-server/ # MCP bridge (Node.js)
|
||||
│ ├── index.js # MCP server entry (stdio, 14 tools)
|
||||
│ ├── vault.js # Vault file operations
|
||||
│ ├── ws-bridge.js # WebSocket bridge (ports 9710, 9711)
|
||||
│ ├── test.js # MCP server tests
|
||||
│ └── package.json
|
||||
│
|
||||
├── e2e/ # Playwright E2E tests (~26 specs)
|
||||
├── tests/smoke/ # Smoke tests (~10 specs)
|
||||
├── design/ # Per-task design files
|
||||
├── demo-vault-v2/ # Getting Started demo vault
|
||||
├── scripts/ # Build/utility scripts
|
||||
│
|
||||
├── package.json # Frontend dependencies + scripts
|
||||
├── vite.config.ts # Vite bundler config
|
||||
├── tsconfig.json # TypeScript config
|
||||
├── playwright.config.ts # E2E test config
|
||||
├── CLAUDE.md # Project instructions for Claude
|
||||
├── ui-design.pen # Master design file
|
||||
├── CLAUDE.md # Project instructions
|
||||
└── docs/ # This documentation
|
||||
```
|
||||
|
||||
@@ -109,9 +195,10 @@ laputa-app/
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/App.tsx` | The root component. Shows how the 4-panel layout is assembled and how state flows between components. |
|
||||
| `src/App.tsx` | Root component. Shows the 4-panel layout, state flow, and how all features connect. |
|
||||
| `src/types.ts` | All shared TypeScript types. Read this first to understand the data model. |
|
||||
| `src-tauri/src/lib.rs` | All 9 Tauri commands in one place. This is the frontend-backend API surface. |
|
||||
| `src-tauri/src/commands.rs` | All 61 Tauri command handlers. This is the frontend-backend API surface. |
|
||||
| `src-tauri/src/lib.rs` | Tauri setup, command registration, startup tasks, WebSocket bridge lifecycle. |
|
||||
|
||||
### Data layer
|
||||
|
||||
@@ -119,31 +206,55 @@ laputa-app/
|
||||
|------|---------------|
|
||||
| `src/hooks/useVaultLoader.ts` | How vault data is loaded and managed. The Tauri/mock branching pattern. |
|
||||
| `src/hooks/useNoteActions.ts` | Tab management, wikilink navigation, frontmatter CRUD. The biggest hook. |
|
||||
| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, Getting Started vault. |
|
||||
| `src/mock-tauri.ts` | Mock data for browser testing. Shows the shape of all Tauri responses. |
|
||||
|
||||
### Backend
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src-tauri/src/vault.rs` | Vault scanning, frontmatter parsing, entity type inference. The core backend logic. |
|
||||
| `src-tauri/src/frontmatter.rs` | YAML manipulation — how properties are updated/deleted in files. |
|
||||
| `src-tauri/src/git.rs` | All git operations. Shells out to git CLI. |
|
||||
| `src-tauri/src/vault/mod.rs` | Vault scanning, frontmatter parsing, entity type inference, relationship extraction. |
|
||||
| `src-tauri/src/vault/cache.rs` | Git-based incremental caching — how large vaults load fast. |
|
||||
| `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. |
|
||||
| `src-tauri/src/git/` | All git operations (commit, pull, push, conflicts, pulse). |
|
||||
| `src-tauri/src/github/` | GitHub OAuth device flow + repo clone/create. |
|
||||
| `src-tauri/src/search.rs` | qmd search integration (keyword/semantic/hybrid). |
|
||||
| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning + NDJSON stream parsing. |
|
||||
|
||||
### Editor
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/components/Editor.tsx` | BlockNote setup, custom wikilink schema, tab bar, breadcrumb bar, diff toggle. |
|
||||
| `src/utils/wikilinks.ts` | The wikilink preprocessing pipeline (markdown → BlockNote blocks with wikilinks). |
|
||||
| `src/components/EditorTheme.css` | BlockNote CSS overrides for typography and styling. |
|
||||
| `src/components/Editor.tsx` | BlockNote setup, tab bar, breadcrumb bar, diff/raw toggle. |
|
||||
| `src/components/editorSchema.tsx` | Custom wikilink inline content type definition. |
|
||||
| `src/utils/wikilinks.ts` | Wikilink preprocessing pipeline (markdown ↔ BlockNote). |
|
||||
| `src/components/RawEditorView.tsx` | CodeMirror 6 raw markdown editor. |
|
||||
|
||||
### Styling
|
||||
### AI
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/index.css` | All CSS custom properties (colors, spacing). The design token source of truth. |
|
||||
| `src/components/AiPanel.tsx` | AI agent panel — Claude CLI with tool execution, reasoning, actions. |
|
||||
| `src/components/AIChatPanel.tsx` | AI chat panel — API-based chat without tools. |
|
||||
| `src/hooks/useAiAgent.ts` | Agent state: messages, streaming, tool tracking, file detection. |
|
||||
| `src/utils/ai-context.ts` | Context snapshot builder for AI conversations. |
|
||||
|
||||
### Styling & Themes
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/index.css` | All CSS custom properties. The design token source of truth. |
|
||||
| `src/theme.json` | Editor-specific theme (fonts, headings, lists, code blocks). |
|
||||
| `src/hooks/useTheme.ts` | Converts theme.json into CSS variables for the editor. |
|
||||
| `src/hooks/useThemeManager.ts` | Vault theme lifecycle (switch, create, apply, live preview). |
|
||||
| `docs/THEMING.md` | Full theme system documentation. |
|
||||
|
||||
### Settings & Config
|
||||
|
||||
| File | Why it matters |
|
||||
|------|---------------|
|
||||
| `src/hooks/useSettings.ts` | App settings (API keys, GitHub token, sync interval). |
|
||||
| `src/hooks/useVaultConfig.ts` | Per-vault UI preferences (zoom, view mode, colors). |
|
||||
| `src/components/SettingsPanel.tsx` | Settings UI including GitHub OAuth connection. |
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
@@ -169,13 +280,15 @@ No global state management (no Redux, no Context). `App.tsx` owns the state and
|
||||
|
||||
```typescript
|
||||
type SidebarSelection =
|
||||
| { kind: 'filter'; filter: 'all' | 'favorites' }
|
||||
| { kind: 'filter'; filter: SidebarFilter }
|
||||
| { kind: 'sectionGroup'; type: string }
|
||||
| { kind: 'entity'; entry: VaultEntry }
|
||||
| { kind: 'topic'; entry: VaultEntry }
|
||||
```
|
||||
|
||||
This pattern makes it easy to handle all selection states exhaustively.
|
||||
### Command Registry
|
||||
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. The native macOS menu bar also triggers commands via `useMenuEvents`.
|
||||
|
||||
## Running Tests
|
||||
|
||||
@@ -183,26 +296,28 @@ This pattern makes it easy to handle all selection states exhaustively.
|
||||
# Unit tests (fast, no browser)
|
||||
pnpm test
|
||||
|
||||
# Rust tests
|
||||
cd src-tauri && cargo test
|
||||
# Unit tests with coverage (must pass ≥70%)
|
||||
pnpm test:coverage
|
||||
|
||||
# E2E tests (requires dev server)
|
||||
pnpm test:e2e
|
||||
# Rust tests
|
||||
cargo test
|
||||
|
||||
# Rust coverage (must pass ≥85% line coverage)
|
||||
cargo llvm-cov --manifest-path src-tauri/Cargo.toml --no-clean --fail-under-lines 85
|
||||
|
||||
# Playwright smoke tests (requires dev server)
|
||||
BASE_URL="http://localhost:5173" pnpm playwright:smoke
|
||||
|
||||
# Single Playwright test
|
||||
npx playwright test e2e/screenshot.spec.ts
|
||||
|
||||
# Visual verification screenshots
|
||||
npx playwright test e2e/screenshot.spec.ts
|
||||
# Screenshots saved to test-results/
|
||||
BASE_URL="http://localhost:5173" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Add a new Tauri command
|
||||
|
||||
1. Write the Rust function in `vault.rs`, `git.rs`, or a new module
|
||||
2. Add `#[tauri::command]` wrapper in `lib.rs`
|
||||
1. Write the Rust function in the appropriate module (`vault/`, `git/`, etc.)
|
||||
2. Add a command handler in `commands.rs`
|
||||
3. Register it in the `generate_handler![]` macro in `lib.rs`
|
||||
4. Call it from the frontend via `invoke()` in the appropriate hook
|
||||
5. Add a mock handler in `mock-tauri.ts`
|
||||
@@ -217,6 +332,25 @@ npx playwright test e2e/screenshot.spec.ts
|
||||
### Add a new entity type
|
||||
|
||||
1. Create the folder in the vault (e.g., `~/Laputa/mytype/`)
|
||||
2. Add the folder → type mapping in `vault.rs:parse_md_file()` (the `match` on folder names)
|
||||
3. The sidebar section groups are defined as `SECTION_GROUPS` in `Sidebar.tsx` — add it there
|
||||
4. Update `CreateNoteDialog.tsx` type options if users should be able to create it
|
||||
2. Create a type document: `type/mytype.md` with `Is A: Type` frontmatter (icon, color, order, etc.)
|
||||
3. The sidebar section groups are auto-generated from type documents — no code change needed if `visible: true`
|
||||
4. Update `CreateNoteDialog.tsx` type options if users should be able to create it from the dialog
|
||||
|
||||
### Add a command palette entry
|
||||
|
||||
1. Register the command in `useAppCommands.ts` via the command registry
|
||||
2. Add a corresponding menu bar item in `menu.rs` for discoverability
|
||||
3. If it has a keyboard shortcut, register it in `useAppKeyboard.ts`
|
||||
|
||||
### Add or modify a theme
|
||||
|
||||
1. **Vault-based** (preferred): Create/edit a markdown note in `theme/` with `Is A: Theme` frontmatter
|
||||
2. **Programmatic**: Edit defaults in `src-tauri/src/theme/defaults.rs`
|
||||
3. See `docs/THEMING.md` for the full property reference
|
||||
|
||||
### Work with the AI agent
|
||||
|
||||
1. **Agent system prompt**: Edit `src/utils/ai-agent.ts` (inline system prompt string)
|
||||
2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent
|
||||
3. **Tool action display**: Edit `src/components/AiActionCard.tsx`
|
||||
4. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`)
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
* - get_vault_context: vault structure overview (types, note count, folders)
|
||||
* - get_note: parsed frontmatter + content (convenience over raw cat)
|
||||
* - open_note: signal Laputa UI to open a note as a tab
|
||||
* - highlight_editor: visually highlight a UI element (editor, tab, etc.)
|
||||
* - refresh_vault: trigger vault rescan so new/modified files appear
|
||||
*/
|
||||
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
@@ -94,6 +96,28 @@ const TOOLS = [
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'highlight_editor',
|
||||
description: 'Visually highlight a UI element in Laputa (editor, tab, properties panel, or note list). The highlight auto-clears after a short delay.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
element: { type: 'string', enum: ['editor', 'tab', 'properties', 'notelist'], description: 'Which UI element to highlight' },
|
||||
path: { type: 'string', description: 'Optional note path to associate with the highlight' },
|
||||
},
|
||||
required: ['element'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'refresh_vault',
|
||||
description: 'Trigger a vault rescan so new or modified files appear immediately in the Laputa note list.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
path: { type: 'string', description: 'Optional specific note path that changed' },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const TOOL_HANDLERS = {
|
||||
@@ -101,6 +125,8 @@ const TOOL_HANDLERS = {
|
||||
get_vault_context: handleVaultContext,
|
||||
get_note: handleGetNote,
|
||||
open_note: handleOpenNote,
|
||||
highlight_editor: handleHighlightEditor,
|
||||
refresh_vault: handleRefreshVault,
|
||||
}
|
||||
|
||||
async function handleSearchNotes(args) {
|
||||
@@ -122,14 +148,27 @@ async function handleGetNote(args) {
|
||||
}
|
||||
|
||||
function handleOpenNote(args) {
|
||||
// Refresh vault first so the new/modified note appears in the note list,
|
||||
// then signal the UI to open it in a tab.
|
||||
broadcastUiAction('vault_changed', { path: args.path })
|
||||
broadcastUiAction('open_tab', { path: args.path })
|
||||
return { content: [{ type: 'text', text: `Opening ${args.path} in Laputa` }] }
|
||||
}
|
||||
|
||||
function handleHighlightEditor(args) {
|
||||
broadcastUiAction('highlight', { element: args.element, path: args.path })
|
||||
return { content: [{ type: 'text', text: `Highlighting ${args.element}` }] }
|
||||
}
|
||||
|
||||
function handleRefreshVault(args) {
|
||||
broadcastUiAction('vault_changed', { path: args?.path })
|
||||
return { content: [{ type: 'text', text: 'Vault refresh triggered' }] }
|
||||
}
|
||||
|
||||
// --- Server setup ---
|
||||
|
||||
const server = new Server(
|
||||
{ name: 'laputa-mcp-server', version: '0.2.0' },
|
||||
{ name: 'laputa-mcp-server', version: '0.3.0' },
|
||||
{ capabilities: { tools: {} } },
|
||||
)
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import os from 'node:os'
|
||||
import {
|
||||
readNote, createNote, searchNotes, appendToNote, findMarkdownFiles,
|
||||
editNoteFrontmatter, deleteNote, linkNotes, listNotes, vaultContext,
|
||||
findMarkdownFiles, getNote, searchNotes, vaultContext,
|
||||
} from './vault.js'
|
||||
|
||||
let tmpDir
|
||||
@@ -65,39 +64,23 @@ describe('findMarkdownFiles', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('readNote', () => {
|
||||
it('should read a note by relative path', async () => {
|
||||
const content = await readNote(tmpDir, 'project/test-project.md')
|
||||
assert.ok(content.includes('Test Project'))
|
||||
assert.ok(content.includes('is_a: Project'))
|
||||
describe('getNote', () => {
|
||||
it('should read a note with parsed frontmatter', async () => {
|
||||
const note = await getNote(tmpDir, 'project/test-project.md')
|
||||
assert.equal(note.path, 'project/test-project.md')
|
||||
assert.equal(note.frontmatter.title, 'Test Project')
|
||||
assert.equal(note.frontmatter.is_a, 'Project')
|
||||
assert.ok(note.content.includes('test project for the MCP server'))
|
||||
})
|
||||
|
||||
it('should throw for missing notes', async () => {
|
||||
await assert.rejects(
|
||||
() => readNote(tmpDir, 'nonexistent.md'),
|
||||
() => getNote(tmpDir, 'nonexistent.md'),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNote', () => {
|
||||
it('should create a note with frontmatter', async () => {
|
||||
const absPath = await createNote(tmpDir, 'note/new-note.md', 'My New Note', { is_a: 'Note' })
|
||||
assert.ok(absPath.endsWith('new-note.md'))
|
||||
|
||||
const content = await fs.readFile(absPath, 'utf-8')
|
||||
assert.ok(content.includes('title: My New Note'))
|
||||
assert.ok(content.includes('is_a: Note'))
|
||||
assert.ok(content.includes('# My New Note'))
|
||||
})
|
||||
|
||||
it('should create parent directories', async () => {
|
||||
const absPath = await createNote(tmpDir, 'deep/nested/dir/note.md', 'Deep Note')
|
||||
const content = await fs.readFile(absPath, 'utf-8')
|
||||
assert.ok(content.includes('# Deep Note'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchNotes', () => {
|
||||
it('should find notes matching title', async () => {
|
||||
const results = await searchNotes(tmpDir, 'Test Project')
|
||||
@@ -121,123 +104,6 @@ describe('searchNotes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendToNote', () => {
|
||||
it('should append text to a note', async () => {
|
||||
await appendToNote(tmpDir, 'note/daily-log.md', '## Evening Update\nFinished testing.')
|
||||
const content = await readNote(tmpDir, 'note/daily-log.md')
|
||||
assert.ok(content.includes('## Evening Update'))
|
||||
assert.ok(content.includes('Finished testing.'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('editNoteFrontmatter', () => {
|
||||
it('should merge a patch into frontmatter', async () => {
|
||||
const updated = await editNoteFrontmatter(tmpDir, 'project/test-project.md', { status: 'Completed', priority: 'High' })
|
||||
assert.equal(updated.status, 'Completed')
|
||||
assert.equal(updated.priority, 'High')
|
||||
assert.equal(updated.title, 'Test Project')
|
||||
})
|
||||
|
||||
it('should preserve existing frontmatter fields', async () => {
|
||||
const content = await readNote(tmpDir, 'project/test-project.md')
|
||||
assert.ok(content.includes('is_a: Project'))
|
||||
assert.ok(content.includes('status: Completed'))
|
||||
})
|
||||
|
||||
it('should throw for missing file', async () => {
|
||||
await assert.rejects(
|
||||
() => editNoteFrontmatter(tmpDir, 'nonexistent.md', { foo: 'bar' }),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteNote', () => {
|
||||
it('should delete an existing note', async () => {
|
||||
const delPath = 'note/to-delete.md'
|
||||
await createNote(tmpDir, delPath, 'To Delete')
|
||||
const absPath = path.join(tmpDir, delPath)
|
||||
|
||||
// Verify it exists
|
||||
await fs.access(absPath)
|
||||
|
||||
await deleteNote(tmpDir, delPath)
|
||||
|
||||
await assert.rejects(
|
||||
() => fs.access(absPath),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw for missing file', async () => {
|
||||
await assert.rejects(
|
||||
() => deleteNote(tmpDir, 'nonexistent.md'),
|
||||
{ code: 'ENOENT' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('linkNotes', () => {
|
||||
it('should add a target to an array property', async () => {
|
||||
const linkPath = 'project/link-test.md'
|
||||
await createNote(tmpDir, linkPath, 'Link Test', { is_a: 'Project' })
|
||||
|
||||
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
|
||||
assert.deepEqual(result, ['[[note/daily-log]]'])
|
||||
})
|
||||
|
||||
it('should not duplicate existing links', async () => {
|
||||
const linkPath = 'project/link-test.md'
|
||||
|
||||
await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
|
||||
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[note/daily-log]]')
|
||||
assert.equal(result.length, 1)
|
||||
})
|
||||
|
||||
it('should add multiple distinct links', async () => {
|
||||
const linkPath = 'project/link-test.md'
|
||||
|
||||
await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
|
||||
const result = await linkNotes(tmpDir, linkPath, 'related_to', '[[project/test-project]]')
|
||||
// Should have daily-log and test-project
|
||||
assert.ok(result.includes('[[note/daily-log]]'))
|
||||
assert.ok(result.includes('[[project/test-project]]'))
|
||||
assert.equal(result.length, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('listNotes', () => {
|
||||
it('should list all notes sorted by title', async () => {
|
||||
const notes = await listNotes(tmpDir)
|
||||
assert.ok(notes.length >= 3)
|
||||
// Verify sorted by title
|
||||
for (let i = 1; i < notes.length; i++) {
|
||||
assert.ok(notes[i - 1].title.localeCompare(notes[i].title) <= 0)
|
||||
}
|
||||
})
|
||||
|
||||
it('should filter by type', async () => {
|
||||
const projects = await listNotes(tmpDir, 'Project')
|
||||
assert.ok(projects.length >= 1)
|
||||
for (const n of projects) {
|
||||
assert.equal(n.type, 'Project')
|
||||
}
|
||||
})
|
||||
|
||||
it('should return empty for unknown type', async () => {
|
||||
const notes = await listNotes(tmpDir, 'UnknownType12345')
|
||||
assert.equal(notes.length, 0)
|
||||
})
|
||||
|
||||
it('should support mtime sorting', async () => {
|
||||
const notes = await listNotes(tmpDir, undefined, 'mtime')
|
||||
assert.ok(notes.length >= 1)
|
||||
// Just verify it returns results without crashing
|
||||
assert.ok(notes[0].path)
|
||||
assert.ok(notes[0].title)
|
||||
})
|
||||
})
|
||||
|
||||
describe('vaultContext', () => {
|
||||
it('should return types, recent notes, and vault path', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
@@ -264,4 +130,15 @@ describe('vaultContext', () => {
|
||||
assert.ok(note.title)
|
||||
}
|
||||
})
|
||||
|
||||
it('should include folders', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
assert.ok(ctx.folders.includes('project/'))
|
||||
assert.ok(ctx.folders.includes('note/'))
|
||||
})
|
||||
|
||||
it('should report correct note count', async () => {
|
||||
const ctx = await vaultContext(tmpDir)
|
||||
assert.equal(ctx.noteCount, 3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -107,11 +107,22 @@ export async function vaultContext(vaultPath) {
|
||||
notesWithMtime.sort((a, b) => b.mtime - a.mtime)
|
||||
const recentNotes = notesWithMtime.slice(0, 20).map(({ mtime: _mtime, ...rest }) => rest)
|
||||
|
||||
// Read config files for AI agent context
|
||||
const configFiles = {}
|
||||
try {
|
||||
const agentsPath = path.join(vaultPath, 'config', 'agents.md')
|
||||
const agentsContent = await fs.readFile(agentsPath, 'utf-8')
|
||||
configFiles.agents = agentsContent
|
||||
} catch {
|
||||
// config/agents.md may not exist yet
|
||||
}
|
||||
|
||||
return {
|
||||
types: [...typesSet].sort(),
|
||||
noteCount: files.length,
|
||||
folders: [...foldersSet].sort(),
|
||||
recentNotes,
|
||||
configFiles,
|
||||
vaultPath,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,10 +46,12 @@ const TOOL_HANDLERS = {
|
||||
read_note: (args) => getNote(VAULT_PATH, args.path).then(note => ({ content: note.content, frontmatter: note.frontmatter })),
|
||||
search_notes: (args) => searchNotes(VAULT_PATH, args.query, args.limit),
|
||||
vault_context: () => vaultContext(VAULT_PATH),
|
||||
ui_open_note: (args) => { broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
|
||||
ui_open_tab: (args) => { broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
|
||||
ui_open_note: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_note', { path: args.path }); return { ok: true } },
|
||||
ui_open_tab: (args) => { broadcastUiAction('vault_changed', { path: args.path }); broadcastUiAction('open_tab', { path: args.path }); return { ok: true } },
|
||||
ui_highlight: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
ui_set_filter: (args) => { broadcastUiAction('set_filter', { filterType: args.type }); return { ok: true } },
|
||||
highlight_editor: (args) => { broadcastUiAction('highlight', { element: args.element, path: args.path }); return { ok: true } },
|
||||
refresh_vault: (args) => { broadcastUiAction('vault_changed', { path: args?.path }); return { ok: true } },
|
||||
}
|
||||
|
||||
async function handleMessage(data) {
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"esbuild": "^0.27.3",
|
||||
@@ -86,6 +87,7 @@
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
"vitest": "^4.0.18",
|
||||
"ws": "^8.19.0"
|
||||
}
|
||||
}
|
||||
|
||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -168,6 +168,9 @@ importers:
|
||||
'@types/react-dom':
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.3(@types/react@19.2.14)
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.1.1
|
||||
version: 5.1.4(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.30.2))
|
||||
@@ -210,6 +213,9 @@ importers:
|
||||
vitest:
|
||||
specifier: ^4.0.18
|
||||
version: 4.0.18(@types/node@24.10.13)(jiti@2.6.1)(jsdom@28.0.0)(lightningcss@1.30.2)
|
||||
ws:
|
||||
specifier: ^8.19.0
|
||||
version: 8.19.0
|
||||
|
||||
mcp-server:
|
||||
dependencies:
|
||||
@@ -2043,6 +2049,9 @@ packages:
|
||||
'@types/use-sync-external-store@1.5.0':
|
||||
resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.55.0':
|
||||
resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -6011,6 +6020,10 @@ snapshots:
|
||||
|
||||
'@types/use-sync-external-store@1.5.0': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 24.10.13
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
|
||||
@@ -5,7 +5,9 @@ use crate::claude_cli::{
|
||||
AgentStreamRequest, ChatStreamRequest, ClaudeCliStatus, ClaudeStreamEvent,
|
||||
};
|
||||
use crate::frontmatter::FrontmatterValue;
|
||||
use crate::git::{GitCommit, GitPullResult, LastCommitInfo, ModifiedFile, PulseCommit};
|
||||
use crate::git::{
|
||||
GitCommit, GitPullResult, GitPushResult, LastCommitInfo, ModifiedFile, PulseCommit,
|
||||
};
|
||||
use crate::github::{DeviceFlowPollResult, DeviceFlowStart, GitHubUser, GithubRepo};
|
||||
use crate::indexing::{IndexStatus, IndexingProgress};
|
||||
use crate::search::SearchResponse;
|
||||
@@ -276,7 +278,7 @@ pub fn git_commit_conflict_resolution(vault_path: String) -> Result<String, Stri
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn git_push(vault_path: String) -> Result<String, String> {
|
||||
pub fn git_push(vault_path: String) -> Result<GitPushResult, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
git::git_push(&vault_path)
|
||||
}
|
||||
@@ -509,6 +511,16 @@ pub fn restore_default_themes(vault_path: String) -> Result<String, String> {
|
||||
theme::restore_default_themes(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn repair_vault(vault_path: String) -> Result<String, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
// Repair themes
|
||||
theme::restore_default_themes(&vault_path)?;
|
||||
// Repair config files (config/agents.md, type/config.md, AGENTS.md stub)
|
||||
vault::repair_config_files(&vault_path)?;
|
||||
Ok("Vault repaired".to_string())
|
||||
}
|
||||
|
||||
// ── Settings & config commands ──────────────────────────────────────────────
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -193,8 +193,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_value_number_float() {
|
||||
let v = FrontmatterValue::Number(3.14);
|
||||
assert_eq!(v.to_yaml_value(), "3.14");
|
||||
let v = FrontmatterValue::Number(3.125);
|
||||
assert_eq!(v.to_yaml_value(), "3.125");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -15,7 +15,7 @@ pub use conflict::{
|
||||
};
|
||||
pub use history::{get_file_diff, get_file_diff_at_commit, get_file_history};
|
||||
pub use pulse::{get_last_commit_info, get_vault_pulse, LastCommitInfo, PulseCommit, PulseFile};
|
||||
pub use remote::{git_pull, git_push, has_remote, GitPullResult};
|
||||
pub use remote::{git_pull, git_push, has_remote, GitPullResult, GitPushResult};
|
||||
pub use status::{get_modified_files, ModifiedFile};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -54,7 +54,11 @@ fn parse_file_status(code: &str) -> &str {
|
||||
|
||||
/// Get the pulse (commit activity feed) for a vault, showing only .md file changes.
|
||||
/// `skip` offsets into the commit list for pagination; `limit` caps how many to return.
|
||||
pub fn get_vault_pulse(vault_path: &str, limit: usize, skip: usize) -> Result<Vec<PulseCommit>, String> {
|
||||
pub fn get_vault_pulse(
|
||||
vault_path: &str,
|
||||
limit: usize,
|
||||
skip: usize,
|
||||
) -> Result<Vec<PulseCommit>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
if !vault.join(".git").exists() {
|
||||
|
||||
@@ -110,8 +110,74 @@ fn parse_updated_files(stdout: &str) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||
pub struct GitPushResult {
|
||||
pub status: String, // "ok" | "rejected" | "auth_error" | "network_error" | "error"
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Classify a git push stderr message into a user-friendly status and message.
|
||||
pub fn classify_push_error(stderr: &str) -> GitPushResult {
|
||||
let lower = stderr.to_lowercase();
|
||||
|
||||
if lower.contains("non-fast-forward")
|
||||
|| lower.contains("[rejected]")
|
||||
|| lower.contains("fetch first")
|
||||
|| lower.contains("failed to push some refs")
|
||||
&& (lower.contains("updates were rejected") || lower.contains("non-fast-forward"))
|
||||
{
|
||||
return GitPushResult {
|
||||
status: "rejected".to_string(),
|
||||
message: "Push rejected: remote has new commits. Pull first, then push.".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if lower.contains("authentication failed")
|
||||
|| lower.contains("could not read username")
|
||||
|| lower.contains("permission denied")
|
||||
|| lower.contains("403")
|
||||
|| lower.contains("invalid credentials")
|
||||
{
|
||||
return GitPushResult {
|
||||
status: "auth_error".to_string(),
|
||||
message: "Push failed: authentication error. Check your credentials.".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
if lower.contains("could not resolve host")
|
||||
|| lower.contains("unable to access")
|
||||
|| lower.contains("connection refused")
|
||||
|| lower.contains("network is unreachable")
|
||||
|| lower.contains("timed out")
|
||||
{
|
||||
return GitPushResult {
|
||||
status: "network_error".to_string(),
|
||||
message: "Push failed: network error. Check your connection and try again.".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: extract the hint line if present, otherwise use the full stderr
|
||||
let hint_line = stderr
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with("hint:"))
|
||||
.map(|l| l.trim_start().strip_prefix("hint:").unwrap_or(l).trim())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let detail = if hint_line.is_empty() {
|
||||
stderr.trim().to_string()
|
||||
} else {
|
||||
hint_line
|
||||
};
|
||||
|
||||
GitPushResult {
|
||||
status: "error".to_string(),
|
||||
message: format!("Push failed: {detail}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push to remote.
|
||||
pub fn git_push(vault_path: &str) -> Result<String, String> {
|
||||
pub fn git_push(vault_path: &str) -> Result<GitPushResult, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
let output = Command::new("git")
|
||||
@@ -122,13 +188,13 @@ pub fn git_push(vault_path: &str) -> Result<String, String> {
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git push failed: {}", stderr));
|
||||
return Ok(classify_push_error(&stderr));
|
||||
}
|
||||
|
||||
// git push often writes to stderr even on success
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Ok(format!("{}{}", stdout, stderr))
|
||||
Ok(GitPushResult {
|
||||
status: "ok".to_string(),
|
||||
message: "Pushed to remote".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -227,6 +293,104 @@ mod tests {
|
||||
assert!(files.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_non_fast_forward() {
|
||||
let stderr = r#"To github.com:user/repo.git
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'github.com:user/repo.git'
|
||||
hint: Updates were rejected because the remote contains work that you do not
|
||||
hint: have locally."#;
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "rejected");
|
||||
assert!(result.message.contains("Pull first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_fetch_first() {
|
||||
let stderr = "error: failed to push some refs\nhint: Updates were rejected because the tip of your current branch is behind\nhint: its remote counterpart. Integrate the remote changes (e.g.\nhint: 'git pull ...') before pushing again.\nhint: See the 'Note about fast-forwards' in 'git push --help' for details.\n ! [rejected] main -> main (fetch first)\n";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_auth_failure() {
|
||||
let stderr = "remote: Permission denied to user/repo.git\nfatal: unable to access 'https://github.com/user/repo.git/': The requested URL returned error: 403";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "auth_error");
|
||||
assert!(result.message.contains("authentication"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_network() {
|
||||
let stderr = "fatal: unable to access 'https://github.com/user/repo.git/': Could not resolve host: github.com";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "network_error");
|
||||
assert!(result.message.contains("network"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_unknown() {
|
||||
let stderr = "error: something unexpected happened\nhint: Try again later";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "error");
|
||||
assert!(result.message.contains("Try again later"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_classify_push_error_unknown_no_hint() {
|
||||
let stderr = "error: something totally weird";
|
||||
let result = classify_push_error(stderr);
|
||||
assert_eq!(result.status, "error");
|
||||
assert!(result.message.contains("something totally weird"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_result_serialization() {
|
||||
let result = GitPushResult {
|
||||
status: "rejected".to_string(),
|
||||
message: "Push rejected".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"rejected\""));
|
||||
let parsed: GitPushResult = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.status, "rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_success_returns_ok() {
|
||||
let (_bare, clone_a, _clone_b) = setup_remote_pair();
|
||||
let vp_a = clone_a.path().to_str().unwrap();
|
||||
|
||||
fs::write(clone_a.path().join("note.md"), "# Note\n").unwrap();
|
||||
git_commit(vp_a, "initial").unwrap();
|
||||
let result = git_push(vp_a).unwrap();
|
||||
assert_eq!(result.status, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_push_rejected_returns_rejected() {
|
||||
let (_bare, clone_a, clone_b) = setup_remote_pair();
|
||||
let vp_a = clone_a.path().to_str().unwrap();
|
||||
let vp_b = clone_b.path().to_str().unwrap();
|
||||
|
||||
// Both clones commit and push — second push should be rejected
|
||||
fs::write(clone_a.path().join("note.md"), "# A\n").unwrap();
|
||||
git_commit(vp_a, "from A").unwrap();
|
||||
git_push(vp_a).unwrap();
|
||||
|
||||
git_pull(vp_b).unwrap();
|
||||
fs::write(clone_b.path().join("note.md"), "# B\n").unwrap();
|
||||
git_commit(vp_b, "from B").unwrap();
|
||||
git_push(vp_b).unwrap();
|
||||
|
||||
// Now A has a new commit but hasn't pulled B's changes
|
||||
fs::write(clone_a.path().join("other.md"), "# Other\n").unwrap();
|
||||
git_commit(vp_a, "from A again").unwrap();
|
||||
let result = git_push(vp_a).unwrap();
|
||||
assert_eq!(result.status, "rejected");
|
||||
assert!(result.message.contains("Pull first"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_git_pull_result_serialization() {
|
||||
let result = GitPullResult {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Mutex;
|
||||
@@ -223,10 +223,72 @@ pub struct IndexStatus {
|
||||
pub indexed_count: usize,
|
||||
pub embedded_count: usize,
|
||||
pub pending_embed: usize,
|
||||
pub last_indexed_commit: Option<String>,
|
||||
pub last_indexed_at: Option<u64>,
|
||||
}
|
||||
|
||||
// --- Index metadata persistence ---
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
struct IndexMetadata {
|
||||
#[serde(default)]
|
||||
last_indexed_commit: Option<String>,
|
||||
#[serde(default)]
|
||||
last_indexed_at: Option<u64>,
|
||||
}
|
||||
|
||||
fn index_metadata_path(vault_path: &str) -> PathBuf {
|
||||
Path::new(vault_path).join(".laputa-index.json")
|
||||
}
|
||||
|
||||
fn load_index_metadata(vault_path: &str) -> IndexMetadata {
|
||||
let path = index_metadata_path(vault_path);
|
||||
std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_index_metadata(vault_path: &str, meta: &IndexMetadata) -> Result<(), String> {
|
||||
let path = index_metadata_path(vault_path);
|
||||
let json =
|
||||
serde_json::to_string_pretty(meta).map_err(|e| format!("Failed to serialize: {e}"))?;
|
||||
std::fs::write(&path, json).map_err(|e| format!("Failed to write index metadata: {e}"))
|
||||
}
|
||||
|
||||
/// Get the current HEAD commit hash for a vault.
|
||||
fn get_head_commit(vault_path: &str) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.current_dir(vault_path)
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.status.success() {
|
||||
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the current HEAD as the last indexed commit.
|
||||
fn stamp_index_commit(vault_path: &str) {
|
||||
if let Some(commit) = get_head_commit(vault_path) {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let meta = IndexMetadata {
|
||||
last_indexed_commit: Some(commit),
|
||||
last_indexed_at: Some(now),
|
||||
};
|
||||
let _ = save_index_metadata(vault_path, &meta);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the vault has a qmd index and its status.
|
||||
pub fn check_index_status(vault_path: &str) -> IndexStatus {
|
||||
let meta = load_index_metadata(vault_path);
|
||||
|
||||
let qmd = match find_qmd_binary() {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
@@ -237,6 +299,8 @@ pub fn check_index_status(vault_path: &str) -> IndexStatus {
|
||||
indexed_count: 0,
|
||||
embedded_count: 0,
|
||||
pending_embed: 0,
|
||||
last_indexed_commit: meta.last_indexed_commit,
|
||||
last_indexed_at: meta.last_indexed_at,
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -244,7 +308,7 @@ pub fn check_index_status(vault_path: &str) -> IndexStatus {
|
||||
let vault_name = vault_dir_name(vault_path);
|
||||
let output = qmd.command().args(["status"]).output();
|
||||
|
||||
match output {
|
||||
let mut status = match output {
|
||||
Ok(o) if o.status.success() => {
|
||||
let stdout = String::from_utf8_lossy(&o.stdout);
|
||||
parse_status_for_vault(&stdout, &vault_name)
|
||||
@@ -256,8 +320,14 @@ pub fn check_index_status(vault_path: &str) -> IndexStatus {
|
||||
indexed_count: 0,
|
||||
embedded_count: 0,
|
||||
pending_embed: 0,
|
||||
last_indexed_commit: None,
|
||||
last_indexed_at: None,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
status.last_indexed_commit = meta.last_indexed_commit;
|
||||
status.last_indexed_at = meta.last_indexed_at;
|
||||
status
|
||||
}
|
||||
|
||||
fn vault_dir_name(vault_path: &str) -> String {
|
||||
@@ -323,6 +393,8 @@ fn parse_status_for_vault(status_output: &str, vault_name: &str) -> IndexStatus
|
||||
indexed_count,
|
||||
embedded_count,
|
||||
pending_embed,
|
||||
last_indexed_commit: None,
|
||||
last_indexed_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,7 +464,9 @@ where
|
||||
|
||||
ensure_collection(vault_path)?;
|
||||
|
||||
// Phase 1: update (scan files)
|
||||
let vault_name = vault_dir_name(vault_path);
|
||||
|
||||
// Phase 1: update (scan files) — scoped to this vault's collection only
|
||||
on_progress(IndexingProgress {
|
||||
phase: "scanning".to_string(),
|
||||
current: 0,
|
||||
@@ -403,7 +477,7 @@ where
|
||||
|
||||
let update_output = qmd
|
||||
.command()
|
||||
.args(["update"])
|
||||
.args(["update", &vault_name])
|
||||
.output()
|
||||
.map_err(|e| format!("qmd update failed: {e}"))?;
|
||||
|
||||
@@ -432,7 +506,7 @@ where
|
||||
error: None,
|
||||
});
|
||||
|
||||
// Phase 2: embed (generate vectors)
|
||||
// Phase 2: embed (generate vectors) — scoped to this vault's collection only
|
||||
on_progress(IndexingProgress {
|
||||
phase: "embedding".to_string(),
|
||||
current: 0,
|
||||
@@ -443,7 +517,7 @@ where
|
||||
|
||||
let embed_output = qmd
|
||||
.command()
|
||||
.args(["embed"])
|
||||
.args(["embed", "-c", &vault_name])
|
||||
.output()
|
||||
.map_err(|e| format!("qmd embed failed: {e}"))?;
|
||||
|
||||
@@ -451,6 +525,7 @@ where
|
||||
let stderr = String::from_utf8_lossy(&embed_output.stderr);
|
||||
// Embedding failure is non-fatal — keyword search still works
|
||||
log::warn!("qmd embed failed (keyword search still works): {stderr}");
|
||||
stamp_index_commit(vault_path);
|
||||
on_progress(IndexingProgress {
|
||||
phase: "complete".to_string(),
|
||||
current: total,
|
||||
@@ -461,6 +536,8 @@ where
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
stamp_index_commit(vault_path);
|
||||
|
||||
on_progress(IndexingProgress {
|
||||
phase: "complete".to_string(),
|
||||
current: total,
|
||||
@@ -504,7 +581,7 @@ pub fn run_incremental_update(vault_path: &str) -> Result<(), String> {
|
||||
|
||||
let output = qmd
|
||||
.command()
|
||||
.args(["update"])
|
||||
.args(["update", &vault_name])
|
||||
.output()
|
||||
.map_err(|e| format!("qmd incremental update failed: {e}"))?;
|
||||
|
||||
@@ -513,9 +590,23 @@ pub fn run_incremental_update(vault_path: &str) -> Result<(), String> {
|
||||
return Err(format!("qmd update failed: {stderr}"));
|
||||
}
|
||||
|
||||
stamp_index_commit(vault_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if HEAD has advanced past the last indexed commit.
|
||||
#[cfg(test)]
|
||||
fn needs_reindex_after_sync(vault_path: &str) -> bool {
|
||||
let meta = load_index_metadata(vault_path);
|
||||
let head = get_head_commit(vault_path);
|
||||
match (meta.last_indexed_commit, head) {
|
||||
(Some(last), Some(current)) => last != current,
|
||||
(None, Some(_)) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -698,4 +789,126 @@ Collections
|
||||
// It verifies the function doesn't panic.
|
||||
let _ = find_bun();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_metadata_roundtrip() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().to_str().unwrap();
|
||||
|
||||
// Default when no file exists
|
||||
let meta = load_index_metadata(vault);
|
||||
assert!(meta.last_indexed_commit.is_none());
|
||||
assert!(meta.last_indexed_at.is_none());
|
||||
|
||||
// Write and read back
|
||||
let meta = IndexMetadata {
|
||||
last_indexed_commit: Some("abc123def456".to_string()),
|
||||
last_indexed_at: Some(1709000000),
|
||||
};
|
||||
save_index_metadata(vault, &meta).unwrap();
|
||||
|
||||
let loaded = load_index_metadata(vault);
|
||||
assert_eq!(loaded.last_indexed_commit.as_deref(), Some("abc123def456"));
|
||||
assert_eq!(loaded.last_indexed_at, Some(1709000000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_metadata_survives_malformed_json() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().to_str().unwrap();
|
||||
|
||||
// Write garbage
|
||||
std::fs::write(dir.path().join(".laputa-index.json"), "not json").unwrap();
|
||||
|
||||
let meta = load_index_metadata(vault);
|
||||
assert!(meta.last_indexed_commit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_reindex_after_sync_no_metadata() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().to_str().unwrap();
|
||||
|
||||
// Init a git repo so get_head_commit works
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// No metadata → needs reindex
|
||||
assert!(needs_reindex_after_sync(vault));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_reindex_after_sync_same_commit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().to_str().unwrap();
|
||||
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let head = get_head_commit(vault).unwrap();
|
||||
let meta = IndexMetadata {
|
||||
last_indexed_commit: Some(head),
|
||||
last_indexed_at: Some(1709000000),
|
||||
};
|
||||
save_index_metadata(vault, &meta).unwrap();
|
||||
|
||||
assert!(!needs_reindex_after_sync(vault));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_reindex_after_sync_different_commit() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().to_str().unwrap();
|
||||
|
||||
Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
let meta = IndexMetadata {
|
||||
last_indexed_commit: Some("old_commit_hash".to_string()),
|
||||
last_indexed_at: Some(1709000000),
|
||||
};
|
||||
save_index_metadata(vault, &meta).unwrap();
|
||||
|
||||
assert!(needs_reindex_after_sync(vault));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_index_status_includes_metadata() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let vault = dir.path().to_str().unwrap();
|
||||
|
||||
let meta = IndexMetadata {
|
||||
last_indexed_commit: Some("abc123".to_string()),
|
||||
last_indexed_at: Some(1709000000),
|
||||
};
|
||||
save_index_metadata(vault, &meta).unwrap();
|
||||
|
||||
let status = check_index_status(vault);
|
||||
assert_eq!(status.last_indexed_commit.as_deref(), Some("abc123"));
|
||||
assert_eq!(status.last_indexed_at, Some(1709000000));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ fn run_startup_tasks() {
|
||||
// Seed type/theme.md so the Theme type has an icon in the sidebar
|
||||
let _ = theme::ensure_theme_type_definition(vp_str);
|
||||
|
||||
// Migrate root AGENTS.md → config/agents.md (one-time, idempotent)
|
||||
vault::migrate_agents_md(vp_str);
|
||||
// Seed config/ with default config files if missing
|
||||
vault::seed_config_files(vp_str);
|
||||
|
||||
// Register Laputa MCP server in Claude Code and Cursor configs
|
||||
match mcp::register_mcp(vp_str) {
|
||||
Ok(status) => log::info!("MCP registration: {status}"),
|
||||
@@ -167,6 +172,7 @@ pub fn run() {
|
||||
commands::create_vault_theme,
|
||||
commands::ensure_vault_themes,
|
||||
commands::restore_default_themes,
|
||||
commands::repair_vault,
|
||||
commands::get_vault_config,
|
||||
commands::save_vault_config
|
||||
])
|
||||
|
||||
@@ -32,7 +32,6 @@ const VIEW_GO_BACK: &str = "view-go-back";
|
||||
const VIEW_GO_FORWARD: &str = "view-go-forward";
|
||||
|
||||
const GO_ALL_NOTES: &str = "go-all-notes";
|
||||
const GO_FAVORITES: &str = "go-favorites";
|
||||
const GO_ARCHIVED: &str = "go-archived";
|
||||
const GO_TRASH: &str = "go-trash";
|
||||
const GO_CHANGES: &str = "go-changes";
|
||||
@@ -49,6 +48,8 @@ const VAULT_COMMIT_PUSH: &str = "vault-commit-push";
|
||||
const VAULT_RESOLVE_CONFLICTS: &str = "vault-resolve-conflicts";
|
||||
const VAULT_VIEW_CHANGES: &str = "vault-view-changes";
|
||||
const VAULT_INSTALL_MCP: &str = "vault-install-mcp";
|
||||
const VAULT_REINDEX: &str = "vault-reindex";
|
||||
const VAULT_REPAIR: &str = "vault-repair";
|
||||
|
||||
const CUSTOM_IDS: &[&str] = &[
|
||||
APP_SETTINGS,
|
||||
@@ -75,7 +76,6 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
VIEW_GO_BACK,
|
||||
VIEW_GO_FORWARD,
|
||||
GO_ALL_NOTES,
|
||||
GO_FAVORITES,
|
||||
GO_ARCHIVED,
|
||||
GO_TRASH,
|
||||
GO_CHANGES,
|
||||
@@ -90,6 +90,8 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
VAULT_RESOLVE_CONFLICTS,
|
||||
VAULT_VIEW_CHANGES,
|
||||
VAULT_INSTALL_MCP,
|
||||
VAULT_REINDEX,
|
||||
VAULT_REPAIR,
|
||||
];
|
||||
|
||||
/// IDs of menu items that should be disabled when no note tab is active.
|
||||
@@ -249,9 +251,6 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
let all_notes = MenuItemBuilder::new("All Notes")
|
||||
.id(GO_ALL_NOTES)
|
||||
.build(app)?;
|
||||
let favorites = MenuItemBuilder::new("Favorites")
|
||||
.id(GO_FAVORITES)
|
||||
.build(app)?;
|
||||
let archived = MenuItemBuilder::new("Archived")
|
||||
.id(GO_ARCHIVED)
|
||||
.build(app)?;
|
||||
@@ -268,7 +267,6 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Go")
|
||||
.item(&all_notes)
|
||||
.item(&favorites)
|
||||
.item(&archived)
|
||||
.item(&trash)
|
||||
.item(&changes)
|
||||
@@ -335,9 +333,15 @@ fn build_vault_menu(app: &App) -> MenuResult {
|
||||
let view_changes = MenuItemBuilder::new("View Pending Changes")
|
||||
.id(VAULT_VIEW_CHANGES)
|
||||
.build(app)?;
|
||||
let install_mcp = MenuItemBuilder::new("Install MCP Server")
|
||||
let install_mcp = MenuItemBuilder::new("Restore MCP Server")
|
||||
.id(VAULT_INSTALL_MCP)
|
||||
.build(app)?;
|
||||
let reindex = MenuItemBuilder::new("Reindex Vault")
|
||||
.id(VAULT_REINDEX)
|
||||
.build(app)?;
|
||||
let repair = MenuItemBuilder::new("Repair Vault")
|
||||
.id(VAULT_REPAIR)
|
||||
.build(app)?;
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Vault")
|
||||
.item(&open_vault)
|
||||
@@ -351,6 +355,8 @@ fn build_vault_menu(app: &App) -> MenuResult {
|
||||
.item(&resolve_conflicts)
|
||||
.item(&view_changes)
|
||||
.separator()
|
||||
.item(&reindex)
|
||||
.item(&repair)
|
||||
.item(&install_mcp)
|
||||
.build()?)
|
||||
}
|
||||
@@ -454,7 +460,6 @@ mod tests {
|
||||
VIEW_GO_BACK,
|
||||
VIEW_GO_FORWARD,
|
||||
GO_ALL_NOTES,
|
||||
GO_FAVORITES,
|
||||
GO_ARCHIVED,
|
||||
GO_TRASH,
|
||||
GO_CHANGES,
|
||||
@@ -469,6 +474,7 @@ mod tests {
|
||||
VAULT_RESOLVE_CONFLICTS,
|
||||
VAULT_VIEW_CHANGES,
|
||||
VAULT_INSTALL_MCP,
|
||||
VAULT_REINDEX,
|
||||
];
|
||||
for id in &expected {
|
||||
assert!(CUSTOM_IDS.contains(id), "missing custom ID: {id}");
|
||||
|
||||
@@ -158,10 +158,7 @@ fn parse_files_at(vault: &Path, rel_paths: &[String]) -> Vec<VaultEntry> {
|
||||
|
||||
/// Machine-local files that should never be git-tracked in any vault.
|
||||
/// These are either caches with absolute paths or per-machine settings.
|
||||
const UNTRACKED_FILES: &[&str] = &[
|
||||
".laputa-cache.json",
|
||||
".laputa/settings.json",
|
||||
];
|
||||
const UNTRACKED_FILES: &[&str] = &[".laputa-cache.json", ".laputa/settings.json"];
|
||||
|
||||
/// Ensure machine-local files are excluded from git via `.git/info/exclude`
|
||||
/// and un-tracked if they were previously committed (git rm --cached).
|
||||
@@ -184,7 +181,11 @@ fn ensure_cache_excluded(vault: &Path) {
|
||||
|
||||
if !to_add.is_empty() {
|
||||
to_add.sort();
|
||||
let separator = if existing.ends_with('\n') || existing.is_empty() { "" } else { "\n" };
|
||||
let separator = if existing.ends_with('\n') || existing.is_empty() {
|
||||
""
|
||||
} else {
|
||||
"\n"
|
||||
};
|
||||
let additions = to_add.join("\n");
|
||||
let _ = fs::write(&exclude_path, format!("{existing}{separator}{additions}\n"));
|
||||
}
|
||||
@@ -645,4 +646,63 @@ mod tests {
|
||||
assert!(titles.contains(&"Default Theme"));
|
||||
assert!(titles.contains(&"Dark Theme"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_same_commit_visible_removed_from_type_note() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path();
|
||||
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Commit a type note with visible: false
|
||||
create_test_file(
|
||||
vault,
|
||||
"type/topic.md",
|
||||
"---\ntype: Type\nvisible: false\n---\n# Topic\n",
|
||||
);
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "init"])
|
||||
.current_dir(vault)
|
||||
.output()
|
||||
.unwrap();
|
||||
|
||||
// Prime the cache
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(
|
||||
entries[0].visible,
|
||||
Some(false),
|
||||
"visible must be false initially"
|
||||
);
|
||||
|
||||
// User removes visible field (uncommitted edit)
|
||||
create_test_file(vault, "type/topic.md", "---\ntype: Type\n---\n# Topic\n");
|
||||
|
||||
// Reload — must reflect the removal (visible defaults to None)
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 1);
|
||||
assert_eq!(
|
||||
entries2[0].visible, None,
|
||||
"visible must be None after removing the field"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
355
src-tauri/src/vault/config_seed.rs
Normal file
355
src-tauri/src/vault/config_seed.rs
Normal file
@@ -0,0 +1,355 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use super::getting_started::AGENTS_MD;
|
||||
|
||||
/// Content for `type/config.md` — gives the Config type a sidebar icon and label.
|
||||
const CONFIG_TYPE_DEFINITION: &str = "\
|
||||
---
|
||||
Is A: Type
|
||||
icon: gear-six
|
||||
color: gray
|
||||
order: 90
|
||||
sidebar label: Config
|
||||
---
|
||||
|
||||
# Config
|
||||
|
||||
Vault configuration files. These control how AI agents, tools, and other integrations interact with this vault.
|
||||
";
|
||||
|
||||
/// Minimal root `AGENTS.md` stub that redirects to `config/agents.md`.
|
||||
const AGENTS_MD_STUB: &str = "\
|
||||
# Agent Instructions
|
||||
|
||||
See config/agents.md for vault instructions.
|
||||
";
|
||||
|
||||
/// Seed `config/agents.md` if missing or empty (idempotent, per-file).
|
||||
/// Also seeds `type/config.md` for sidebar visibility.
|
||||
pub fn seed_config_files(vault_path: &str) {
|
||||
let vault = Path::new(vault_path);
|
||||
let config_dir = vault.join("config");
|
||||
if fs::create_dir_all(&config_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let agents_path = config_dir.join("agents.md");
|
||||
let needs_write =
|
||||
!agents_path.exists() || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
let _ = fs::write(&agents_path, AGENTS_MD);
|
||||
log::info!("Seeded config/agents.md");
|
||||
}
|
||||
|
||||
ensure_config_type_definition(vault_path);
|
||||
}
|
||||
|
||||
/// Ensure `type/config.md` exists (gives Config type a sidebar icon/color).
|
||||
fn ensure_config_type_definition(vault_path: &str) {
|
||||
let type_dir = Path::new(vault_path).join("type");
|
||||
if fs::create_dir_all(&type_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
let path = type_dir.join("config.md");
|
||||
let needs_write = !path.exists() || fs::metadata(&path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
let _ = fs::write(&path, CONFIG_TYPE_DEFINITION);
|
||||
}
|
||||
}
|
||||
|
||||
/// Migrate root `AGENTS.md` → `config/agents.md` for existing vaults.
|
||||
///
|
||||
/// - If root `AGENTS.md` exists and `config/agents.md` does not: move content, write stub.
|
||||
/// - If root `AGENTS.md` exists and `config/agents.md` also exists: just replace root with stub.
|
||||
/// - If root `AGENTS.md` doesn't exist: write the stub anyway (for Codex discoverability).
|
||||
///
|
||||
/// Always idempotent and silent.
|
||||
pub fn migrate_agents_md(vault_path: &str) {
|
||||
let vault = Path::new(vault_path);
|
||||
let root_agents = vault.join("AGENTS.md");
|
||||
let config_dir = vault.join("config");
|
||||
let config_agents = config_dir.join("agents.md");
|
||||
|
||||
// Ensure config/ directory exists
|
||||
if fs::create_dir_all(&config_dir).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If root AGENTS.md has real content (not already a stub), migrate it
|
||||
if root_agents.exists() {
|
||||
let content = fs::read_to_string(&root_agents).unwrap_or_default();
|
||||
let is_stub = content.contains("See config/agents.md");
|
||||
|
||||
if !is_stub {
|
||||
// Only move content if config/agents.md doesn't exist yet
|
||||
let config_needs_write = !config_agents.exists()
|
||||
|| fs::metadata(&config_agents).map_or(true, |m| m.len() == 0);
|
||||
if config_needs_write {
|
||||
let _ = fs::write(&config_agents, &content);
|
||||
log::info!("Migrated AGENTS.md content to config/agents.md");
|
||||
}
|
||||
// Replace root with stub
|
||||
let _ = fs::write(&root_agents, AGENTS_MD_STUB);
|
||||
log::info!("Replaced root AGENTS.md with stub pointing to config/agents.md");
|
||||
}
|
||||
} else {
|
||||
// No root AGENTS.md — write stub for Codex discoverability
|
||||
let _ = fs::write(&root_agents, AGENTS_MD_STUB);
|
||||
}
|
||||
}
|
||||
|
||||
/// Repair config files: re-create missing `config/agents.md` and `type/config.md`.
|
||||
/// Called by the "Repair Vault" command. Returns a status message.
|
||||
pub fn repair_config_files(vault_path: &str) -> Result<String, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
|
||||
// Ensure config/ directory
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir)
|
||||
.map_err(|e| format!("Failed to create config directory: {e}"))?;
|
||||
|
||||
let agents_path = config_dir.join("agents.md");
|
||||
let root_agents = vault.join("AGENTS.md");
|
||||
|
||||
// Step 1: Migrate root AGENTS.md content → config/agents.md if needed
|
||||
if root_agents.exists() {
|
||||
let root_content = fs::read_to_string(&root_agents).unwrap_or_default();
|
||||
let is_stub = root_content.contains("See config/agents.md");
|
||||
if !is_stub && !root_content.is_empty() {
|
||||
let config_needs_write =
|
||||
!agents_path.exists() || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0);
|
||||
if config_needs_write {
|
||||
fs::write(&agents_path, &root_content)
|
||||
.map_err(|e| format!("Failed to migrate AGENTS.md: {e}"))?;
|
||||
}
|
||||
fs::write(&root_agents, AGENTS_MD_STUB)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md stub: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Seed config/agents.md with defaults if still missing or empty
|
||||
let needs_write =
|
||||
!agents_path.exists() || fs::metadata(&agents_path).map_or(true, |m| m.len() == 0);
|
||||
if needs_write {
|
||||
fs::write(&agents_path, AGENTS_MD)
|
||||
.map_err(|e| format!("Failed to write config/agents.md: {e}"))?;
|
||||
}
|
||||
|
||||
// Step 3: Ensure type/config.md
|
||||
let type_dir = vault.join("type");
|
||||
fs::create_dir_all(&type_dir).map_err(|e| format!("Failed to create type directory: {e}"))?;
|
||||
let config_type_path = type_dir.join("config.md");
|
||||
let type_needs_write = !config_type_path.exists()
|
||||
|| fs::metadata(&config_type_path).map_or(true, |m| m.len() == 0);
|
||||
if type_needs_write {
|
||||
fs::write(&config_type_path, CONFIG_TYPE_DEFINITION)
|
||||
.map_err(|e| format!("Failed to write type/config.md: {e}"))?;
|
||||
}
|
||||
|
||||
// Step 4: Ensure root AGENTS.md stub exists
|
||||
let stub_needs_write = !root_agents.exists()
|
||||
|| fs::read_to_string(&root_agents).map_or(true, |c| !c.contains("See config/agents.md"));
|
||||
if stub_needs_write {
|
||||
fs::write(&root_agents, AGENTS_MD_STUB)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md stub: {e}"))?;
|
||||
}
|
||||
|
||||
Ok("Config files repaired".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_creates_dir_and_agents() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("config").is_dir());
|
||||
assert!(vault.join("config/agents.md").exists());
|
||||
let content = fs::read_to_string(vault.join("config/agents.md")).unwrap();
|
||||
assert!(content.contains("Vault Instructions for AI Agents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_creates_type_definition() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("type/config.md").exists());
|
||||
let content = fs::read_to_string(vault.join("type/config.md")).unwrap();
|
||||
assert!(content.contains("Is A: Type"));
|
||||
assert!(content.contains("icon: gear-six"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_is_idempotent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
// Customize the file
|
||||
let custom = "---\nIs A: Config\n---\n# Custom Agents\nMy custom instructions\n";
|
||||
fs::write(vault.join("config/agents.md"), custom).unwrap();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
let content = fs::read_to_string(vault.join("config/agents.md")).unwrap();
|
||||
assert!(
|
||||
content.contains("Custom Agents"),
|
||||
"must preserve existing content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_seed_config_files_reseeds_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
fs::write(config_dir.join("agents.md"), "").unwrap();
|
||||
|
||||
seed_config_files(vault.to_str().unwrap());
|
||||
let content = fs::read_to_string(config_dir.join("agents.md")).unwrap();
|
||||
assert!(content.contains("Vault Instructions for AI Agents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_moves_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("AGENTS.md"), AGENTS_MD).unwrap();
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
// config/agents.md should have the original content
|
||||
let config_content = fs::read_to_string(vault.join("config/agents.md")).unwrap();
|
||||
assert!(config_content.contains("Vault Instructions for AI Agents"));
|
||||
|
||||
// Root AGENTS.md should be a stub
|
||||
let root_content = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root_content.contains("See config/agents.md"));
|
||||
assert!(!root_content.contains("## Structure"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_preserves_existing_config() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
let custom = "# Custom agent instructions\n";
|
||||
fs::write(config_dir.join("agents.md"), custom).unwrap();
|
||||
fs::write(vault.join("AGENTS.md"), AGENTS_MD).unwrap();
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
// config/agents.md should preserve custom content
|
||||
let content = fs::read_to_string(config_dir.join("agents.md")).unwrap();
|
||||
assert!(content.contains("Custom agent instructions"));
|
||||
|
||||
// Root should be a stub
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root.contains("See config/agents.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_idempotent_on_stub() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
fs::write(vault.join("AGENTS.md"), AGENTS_MD_STUB).unwrap();
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
// Stub should remain unchanged
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root.contains("See config/agents.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_agents_md_writes_stub_when_no_root() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
migrate_agents_md(vault.to_str().unwrap());
|
||||
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root.contains("See config/agents.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_creates_all() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
|
||||
let msg = repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
assert_eq!(msg, "Config files repaired");
|
||||
|
||||
assert!(vault.join("config/agents.md").exists());
|
||||
assert!(vault.join("type/config.md").exists());
|
||||
assert!(vault.join("AGENTS.md").exists());
|
||||
|
||||
let agents = fs::read_to_string(vault.join("config/agents.md")).unwrap();
|
||||
assert!(agents.contains("Vault Instructions for AI Agents"));
|
||||
|
||||
let stub = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(stub.contains("See config/agents.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_preserves_custom_content() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
let config_dir = vault.join("config");
|
||||
fs::create_dir_all(&config_dir).unwrap();
|
||||
let custom = "# My custom agent config\nDo not overwrite me\n";
|
||||
fs::write(config_dir.join("agents.md"), custom).unwrap();
|
||||
fs::write(
|
||||
vault.join("AGENTS.md"),
|
||||
"# Agent Instructions\nSee config/agents.md for vault instructions.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
let content = fs::read_to_string(config_dir.join("agents.md")).unwrap();
|
||||
assert!(
|
||||
content.contains("My custom agent config"),
|
||||
"must preserve existing content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repair_config_files_migrates_root_agents() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vault = dir.path().join("vault");
|
||||
fs::create_dir_all(&vault).unwrap();
|
||||
let original = "# My vault agents instructions\nCustom content here\n";
|
||||
fs::write(vault.join("AGENTS.md"), original).unwrap();
|
||||
|
||||
repair_config_files(vault.to_str().unwrap()).unwrap();
|
||||
|
||||
// Root should be a stub
|
||||
let root = fs::read_to_string(vault.join("AGENTS.md")).unwrap();
|
||||
assert!(root.contains("See config/agents.md"));
|
||||
|
||||
// config/agents.md should have the original content
|
||||
let config = fs::read_to_string(vault.join("config/agents.md")).unwrap();
|
||||
assert!(config.contains("My vault agents instructions"));
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,10 @@ struct SampleFile {
|
||||
content: &'static str,
|
||||
}
|
||||
|
||||
/// Content for the AGENTS.md file written to the vault root.
|
||||
/// Content for config/agents.md — vault instructions for AI agents.
|
||||
/// This file has no YAML frontmatter — it is a convention file for AI agents,
|
||||
/// not a vault note. The vault scanner will still pick it up as a regular entry.
|
||||
const AGENTS_MD: &str = r#"# AGENTS.md — Vault Instructions for AI Agents
|
||||
pub(super) const AGENTS_MD: &str = r#"# AGENTS.md — Vault Instructions for AI Agents
|
||||
|
||||
This is a [Laputa](https://github.com/refactoring-ai/laputa) vault — a folder of markdown files with YAML frontmatter that form a personal knowledge graph.
|
||||
|
||||
@@ -137,6 +137,10 @@ const SAMPLE_FILES: &[SampleFile] = &[
|
||||
rel_path: "type/theme.md",
|
||||
content: "---\nIs A: Type\nicon: palette\ncolor: purple\norder: 50\n---\n\n# Theme\n\nA visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "type/config.md",
|
||||
content: "---\nIs A: Type\nicon: gear-six\ncolor: gray\norder: 90\nsidebar label: Config\n---\n\n# Config\n\nVault configuration files. These control how AI agents, tools, and other integrations interact with this vault.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "note/welcome-to-laputa.md",
|
||||
content: r#"---
|
||||
@@ -384,9 +388,19 @@ pub fn create_getting_started_vault(target_path: &str) -> Result<String, String>
|
||||
fs::create_dir_all(vault_dir)
|
||||
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
|
||||
|
||||
// Write AGENTS.md at the vault root
|
||||
fs::write(vault_dir.join("AGENTS.md"), AGENTS_MD)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md: {}", e))?;
|
||||
// Write config/agents.md with vault instructions for AI agents
|
||||
let config_dir = vault_dir.join("config");
|
||||
fs::create_dir_all(&config_dir)
|
||||
.map_err(|e| format!("Failed to create config directory: {}", e))?;
|
||||
fs::write(config_dir.join("agents.md"), AGENTS_MD)
|
||||
.map_err(|e| format!("Failed to write config/agents.md: {}", e))?;
|
||||
|
||||
// Write root AGENTS.md stub for Codex discoverability
|
||||
fs::write(
|
||||
vault_dir.join("AGENTS.md"),
|
||||
"# Agent Instructions\n\nSee config/agents.md for vault instructions.\n",
|
||||
)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md stub: {}", e))?;
|
||||
|
||||
for sample in SAMPLE_FILES {
|
||||
let file_path = vault_dir.join(sample.rel_path);
|
||||
@@ -462,6 +476,7 @@ mod tests {
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Verify key files exist
|
||||
assert!(vault_path.join("config/agents.md").exists());
|
||||
assert!(vault_path.join("AGENTS.md").exists());
|
||||
assert!(vault_path.join("note/welcome-to-laputa.md").exists());
|
||||
assert!(vault_path.join("note/editor-basics.md").exists());
|
||||
@@ -476,6 +491,7 @@ mod tests {
|
||||
assert!(vault_path.join("type/note.md").exists());
|
||||
assert!(vault_path.join("type/person.md").exists());
|
||||
assert!(vault_path.join("type/topic.md").exists());
|
||||
assert!(vault_path.join("type/config.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -530,57 +546,63 @@ mod tests {
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entries = crate::vault::scan_vault(&vault_path).unwrap();
|
||||
// SAMPLE_FILES + AGENTS.md + 3 vault theme notes (theme/default.md, dark.md, minimal.md)
|
||||
assert_eq!(entries.len(), SAMPLE_FILES.len() + 1 + 3);
|
||||
// SAMPLE_FILES + config/agents.md + AGENTS.md stub + 3 vault theme notes
|
||||
assert_eq!(entries.len(), SAMPLE_FILES.len() + 2 + 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_md_present_after_vault_creation() {
|
||||
fn test_config_agents_md_present_after_vault_creation() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("agents-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let agents_path = vault_path.join("AGENTS.md");
|
||||
assert!(agents_path.exists(), "AGENTS.md should exist at vault root");
|
||||
let agents_path = vault_path.join("config/agents.md");
|
||||
assert!(
|
||||
agents_path.exists(),
|
||||
"config/agents.md should exist in vault"
|
||||
);
|
||||
|
||||
let content = fs::read_to_string(&agents_path).unwrap();
|
||||
assert!(content.contains("Vault Instructions for AI Agents"));
|
||||
assert!(content.contains("## Structure"));
|
||||
assert!(content.contains("## Frontmatter"));
|
||||
assert!(content.contains("## Wikilinks"));
|
||||
assert!(content.contains("## Type definitions"));
|
||||
assert!(content.contains("## Conventions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_root_agents_md_is_stub_after_vault_creation() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("stub-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let root_path = vault_path.join("AGENTS.md");
|
||||
assert!(root_path.exists(), "Root AGENTS.md stub should exist");
|
||||
|
||||
let content = fs::read_to_string(&root_path).unwrap();
|
||||
assert!(
|
||||
content.contains("Vault Instructions for AI Agents"),
|
||||
"AGENTS.md should contain instructions header"
|
||||
content.contains("See config/agents.md"),
|
||||
"Root AGENTS.md should redirect to config/agents.md"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Structure"),
|
||||
"AGENTS.md should describe vault structure"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Frontmatter"),
|
||||
"AGENTS.md should describe frontmatter"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Wikilinks"),
|
||||
"AGENTS.md should describe wikilinks"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Type definitions"),
|
||||
"AGENTS.md should describe type definitions"
|
||||
);
|
||||
assert!(
|
||||
content.contains("## Conventions"),
|
||||
"AGENTS.md should describe conventions"
|
||||
!content.contains("## Structure"),
|
||||
"Root AGENTS.md should not contain full instructions"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_md_parseable_as_vault_entry() {
|
||||
fn test_config_agents_md_parseable_as_vault_entry() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("agents-parse-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md")).unwrap();
|
||||
let entry = crate::vault::parse_md_file(&vault_path.join("config/agents.md")).unwrap();
|
||||
assert_eq!(
|
||||
entry.title,
|
||||
"AGENTS.md \u{2014} Vault Instructions for AI Agents"
|
||||
);
|
||||
assert_eq!(entry.is_a.as_deref(), Some("Config"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod cache;
|
||||
mod config_seed;
|
||||
mod getting_started;
|
||||
mod image;
|
||||
mod migration;
|
||||
@@ -7,6 +8,7 @@ mod rename;
|
||||
mod trash;
|
||||
|
||||
pub use cache::scan_vault_cached;
|
||||
pub use config_seed::{migrate_agents_md, repair_config_files, seed_config_files};
|
||||
pub use getting_started::{create_getting_started_vault, default_vault_path, vault_exists};
|
||||
pub use image::{copy_image_to_vault, save_image};
|
||||
pub use migration::migrate_is_a_to_type;
|
||||
@@ -277,6 +279,7 @@ fn infer_type_from_folder(folder: &str) -> String {
|
||||
"target" => "Target",
|
||||
"journal" => "Journal",
|
||||
"month" => "Month",
|
||||
"config" => "Config",
|
||||
"essay" => "Essay",
|
||||
"evergreen" => "Evergreen",
|
||||
_ => return title_case_folder(folder),
|
||||
|
||||
@@ -179,7 +179,7 @@ describe('App', () => {
|
||||
await waitFor(() => {
|
||||
// "All Notes" should be rendered as the selected nav item
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
||||
expect(screen.getByText('Archive')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
61
src/App.tsx
61
src/App.tsx
@@ -48,10 +48,12 @@ import { filterEntries } from './utils/noteListHelpers'
|
||||
import { openLocalFile } from './utils/url'
|
||||
import './App.css'
|
||||
|
||||
// Type declaration for mock content storage
|
||||
// Type declarations for mock content storage and test overrides
|
||||
declare global {
|
||||
interface Window {
|
||||
__mockContent?: Record<string, string>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map for Playwright test overrides
|
||||
__mockHandlers?: Record<string, (args: any) => any>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,10 +113,13 @@ function App() {
|
||||
|
||||
const { mcpStatus, installMcp } = useMcpStatus(resolvedPath, setToastMessage)
|
||||
|
||||
const indexing = useIndexing(resolvedPath)
|
||||
|
||||
const autoSync = useAutoSync({
|
||||
vaultPath: resolvedPath,
|
||||
intervalMinutes: settings.auto_pull_interval_minutes,
|
||||
onVaultUpdated: vault.reloadVault,
|
||||
onSyncUpdated: indexing.triggerIncrementalIndex,
|
||||
onConflict: (files) => {
|
||||
const names = files.map((f) => f.split('/').pop()).join(', ')
|
||||
setToastMessage(`Conflict in ${names} — click to resolve`)
|
||||
@@ -125,8 +130,6 @@ function App() {
|
||||
// Ref bridges for conflict resolution callbacks (notes declared below)
|
||||
const openConflictFileRef = useRef<(relativePath: string) => void>(() => {})
|
||||
|
||||
const indexing = useIndexing(resolvedPath)
|
||||
|
||||
const conflictResolver = useConflictResolver({
|
||||
vaultPath: resolvedPath,
|
||||
onResolved: () => {
|
||||
@@ -230,19 +233,26 @@ function App() {
|
||||
|
||||
useNavigationGestures({ onGoBack: handleGoBack, onGoForward: handleGoForward })
|
||||
|
||||
// O(1) path lookup map — rebuilt only when vault.entries changes
|
||||
const entriesByPath = useMemo(() => {
|
||||
const map = new Map<string, VaultEntry>()
|
||||
for (const e of vault.entries) map.set(e.path, e)
|
||||
return map
|
||||
}, [vault.entries])
|
||||
|
||||
// MCP UI bridge: react to AI-driven open/highlight/vault-change events
|
||||
const openNoteByPath = useCallback((path: string) => {
|
||||
const entry = vault.entries.find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
const entry = entriesByPath.get(path) ?? entriesByPath.get(`${resolvedPath}/${path}`)
|
||||
if (entry) {
|
||||
notes.handleSelectNote(entry)
|
||||
} else {
|
||||
// Entry not yet in vault (just created) — reload then open
|
||||
vault.reloadVault().then(freshEntries => {
|
||||
const fresh = freshEntries.find((e: VaultEntry) => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
const fresh = (freshEntries as VaultEntry[]).find(e => e.path === path || e.path === `${resolvedPath}/${path}`)
|
||||
if (fresh) notes.handleSelectNote(fresh)
|
||||
})
|
||||
}
|
||||
}, [vault, notes, resolvedPath])
|
||||
}, [entriesByPath, vault, notes, resolvedPath])
|
||||
|
||||
const aiActivity = useAiActivity({
|
||||
onOpenNote: openNoteByPath,
|
||||
@@ -253,10 +263,18 @@ function App() {
|
||||
onVaultChanged: () => { vault.reloadVault() },
|
||||
})
|
||||
|
||||
// Stable callback for Pulse "open note" — never triggers reloadVault.
|
||||
// Pulse files always exist in the vault; if somehow not found, silently skip.
|
||||
const handlePulseOpenNote = useCallback((relativePath: string) => {
|
||||
const fullPath = `${resolvedPath}/${relativePath}`
|
||||
const entry = entriesByPath.get(fullPath) ?? entriesByPath.get(relativePath)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
}, [entriesByPath, resolvedPath, notes])
|
||||
|
||||
// Agent file operation handlers: auto-open created notes, live-refresh modified notes
|
||||
const handleAgentFileCreated = useCallback((relativePath: string) => {
|
||||
vault.reloadVault().then(freshEntries => {
|
||||
const entry = freshEntries.find((e: VaultEntry) => e.path === relativePath || e.path === `${resolvedPath}/${relativePath}`)
|
||||
const entry = (freshEntries as VaultEntry[]).find(e => e.path === relativePath || e.path === `${resolvedPath}/${relativePath}`)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
})
|
||||
}, [vault, notes, resolvedPath])
|
||||
@@ -269,6 +287,10 @@ function App() {
|
||||
}
|
||||
}, [vault, notes, resolvedPath])
|
||||
|
||||
const handleAgentVaultChanged = useCallback(() => {
|
||||
vault.reloadVault()
|
||||
}, [vault])
|
||||
|
||||
const { triggerIncrementalIndex } = indexing
|
||||
const onAfterSave = useCallback(() => {
|
||||
vault.loadModifiedFiles()
|
||||
@@ -315,6 +337,7 @@ function App() {
|
||||
handleUpdateFrontmatter: notes.handleUpdateFrontmatter,
|
||||
handleDeleteProperty: notes.handleDeleteProperty, setToastMessage,
|
||||
createTypeEntry: notes.createTypeEntrySilent,
|
||||
onFrontmatterPersisted: vault.loadModifiedFiles,
|
||||
})
|
||||
|
||||
const handleDeleteNote = useCallback(async (path: string) => {
|
||||
@@ -393,6 +416,19 @@ function App() {
|
||||
}
|
||||
}, [resolvedPath, vault, themeManager, setToastMessage])
|
||||
|
||||
const handleRepairVault = useCallback(async () => {
|
||||
if (!resolvedPath) return
|
||||
try {
|
||||
const tauriInvoke = isTauri() ? invoke : mockInvoke
|
||||
const msg = await tauriInvoke<string>('repair_vault', { vaultPath: resolvedPath })
|
||||
await vault.reloadVault()
|
||||
await themeManager.reloadThemes()
|
||||
setToastMessage(msg)
|
||||
} catch (err) {
|
||||
setToastMessage(`Failed to repair vault: ${err}`)
|
||||
}
|
||||
}, [resolvedPath, vault, themeManager, setToastMessage])
|
||||
|
||||
const commands = useAppCommands({
|
||||
activeTabPath: notes.activeTabPath, activeTabPathRef: notes.activeTabPathRef,
|
||||
handleCloseTabRef: notes.handleCloseTabRef, tabs: notes.tabs,
|
||||
@@ -448,6 +484,8 @@ function App() {
|
||||
vaultCount: vaultSwitcher.allVaults.length,
|
||||
mcpStatus,
|
||||
onInstallMcp: installMcp,
|
||||
onReindexVault: indexing.triggerFullReindex,
|
||||
onRepairVault: handleRepairVault,
|
||||
})
|
||||
|
||||
const activeTab = notes.tabs.find((t) => t.entry.path === notes.activeTabPath) ?? null
|
||||
@@ -509,11 +547,7 @@ function App() {
|
||||
<>
|
||||
<div className={`app__note-list${aiActivity.highlightElement === 'notelist' ? ' ai-highlight' : ''}`} style={{ width: layout.noteListWidth }}>
|
||||
{selection.kind === 'filter' && selection.filter === 'pulse' ? (
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={(relativePath) => {
|
||||
const fullPath = `${resolvedPath}/${relativePath}`
|
||||
const entry = vault.entries.find(e => e.path === fullPath || e.path === relativePath)
|
||||
if (entry) notes.handleSelectNote(entry)
|
||||
}} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
<PulseView vaultPath={resolvedPath} onOpenNote={handlePulseOpenNote} sidebarCollapsed={!sidebarVisible} onExpandSidebar={() => setViewMode('all')} />
|
||||
) : (
|
||||
<NoteList entries={vault.entries} selection={selection} selectedNote={activeTab?.entry ?? null} allContent={vault.allContent} modifiedFiles={vault.modifiedFiles} modifiedFilesError={vault.modifiedFilesError} getNoteStatus={vault.getNoteStatus} sidebarCollapsed={!sidebarVisible} onSelectNote={notes.handleSelectNote} onReplaceActiveTab={notes.handleReplaceActiveTab} onCreateNote={notes.handleCreateNoteImmediate} onBulkArchive={bulkActions.handleBulkArchive} onBulkTrash={bulkActions.handleBulkTrash} onUpdateTypeSort={notes.handleUpdateFrontmatter} updateEntry={vault.updateEntry} />
|
||||
)}
|
||||
@@ -569,11 +603,12 @@ function App() {
|
||||
isDarkTheme={themeManager.isDark}
|
||||
onFileCreated={handleAgentFileCreated}
|
||||
onFileModified={handleAgentFileModified}
|
||||
onVaultChanged={handleAgentVaultChanged}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<UpdateBanner status={updateStatus} actions={updateActions} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} onRetryIndexing={indexing.retryIndexing} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
|
||||
<StatusBar noteCount={vault.entries.length} modifiedCount={vault.modifiedFiles.length} vaultPath={vaultSwitcher.vaultPath} vaults={vaultSwitcher.allVaults} onSwitchVault={vaultSwitcher.switchVault} onOpenSettings={dialogs.openSettings} onOpenLocalFolder={vaultSwitcher.handleOpenLocalFolder} onConnectGitHub={dialogs.openGitHubVault} onClickPending={() => setSelection({ kind: 'filter', filter: 'changes' })} hasGitHub={!!settings.github_token} syncStatus={autoSync.syncStatus} lastSyncTime={autoSync.lastSyncTime} conflictCount={autoSync.conflictFiles.length} lastCommitInfo={autoSync.lastCommitInfo} onTriggerSync={autoSync.triggerSync} onOpenConflictResolver={handleOpenConflictResolver} zoomLevel={zoom.zoomLevel} onZoomReset={zoom.zoomReset} buildNumber={buildNumber} onCheckForUpdates={handleCheckForUpdates} indexingProgress={indexing.progress} lastIndexedTime={indexing.lastIndexedTime} onRetryIndexing={indexing.retryIndexing} onReindexVault={indexing.triggerFullReindex} onRemoveVault={vaultSwitcher.removeVault} mcpStatus={mcpStatus} onInstallMcp={installMcp} />
|
||||
<Toast message={toastMessage} onDismiss={() => setToastMessage(null)} />
|
||||
<QuickOpenPalette open={dialogs.showQuickOpen} entries={vault.entries} onSelect={notes.handleSelectNote} onClose={dialogs.closeQuickOpen} />
|
||||
<CommandPalette open={dialogs.showCommandPalette} commands={commands} onClose={dialogs.closeCommandPalette} />
|
||||
|
||||
@@ -18,6 +18,7 @@ interface AIChatPanelProps {
|
||||
allContent: Record<string, string>
|
||||
entries?: VaultEntry[]
|
||||
onClose: () => void
|
||||
onNavigateWikilink?: (target: string) => void
|
||||
}
|
||||
|
||||
function TypingIndicator() {
|
||||
@@ -99,10 +100,10 @@ function ContextSearchDropdown({
|
||||
)
|
||||
}
|
||||
|
||||
function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => void }) {
|
||||
function AssistantMessage({ msg, onRetry, onNavigateWikilink }: { msg: ChatMessage; onRetry: () => void; onNavigateWikilink?: (target: string) => void }) {
|
||||
return (
|
||||
<div>
|
||||
<MarkdownContent content={msg.content} />
|
||||
<MarkdownContent content={msg.content} onWikilinkClick={onNavigateWikilink} />
|
||||
<div className="flex items-center gap-3" style={{ marginTop: 4 }}>
|
||||
<button className="border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:underline"
|
||||
style={{ fontSize: 11 }} onClick={() => navigator.clipboard.writeText(msg.content)}>
|
||||
@@ -121,10 +122,10 @@ function AssistantMessage({ msg, onRetry }: { msg: ChatMessage; onRetry: () => v
|
||||
)
|
||||
}
|
||||
|
||||
function StreamingContent({ content }: { content: string }) {
|
||||
function StreamingContent({ content, onNavigateWikilink }: { content: string; onNavigateWikilink?: (target: string) => void }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<MarkdownContent content={content} />
|
||||
<MarkdownContent content={content} onWikilinkClick={onNavigateWikilink} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -176,7 +177,7 @@ function useContextNotes(entry: VaultEntry | null) {
|
||||
|
||||
// --- Main component ---
|
||||
|
||||
export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChatPanelProps) {
|
||||
export function AIChatPanel({ entry, allContent, entries = [], onClose, onNavigateWikilink }: AIChatPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
@@ -213,7 +214,7 @@ export function AIChatPanel({ entry, allContent, entries = [], onClose }: AIChat
|
||||
<MessageList
|
||||
messages={chat.messages} isStreaming={chat.isStreaming}
|
||||
streamingContent={chat.streamingContent} onRetry={chat.retryMessage}
|
||||
messagesEndRef={messagesEndRef}
|
||||
messagesEndRef={messagesEndRef} onNavigateWikilink={onNavigateWikilink}
|
||||
/>
|
||||
|
||||
<QuickActionsBar actions={QUICK_ACTIONS} disabled={chat.isStreaming}
|
||||
@@ -284,10 +285,11 @@ function ContextBar({
|
||||
}
|
||||
|
||||
function MessageList({
|
||||
messages, isStreaming, streamingContent, onRetry, messagesEndRef,
|
||||
messages, isStreaming, streamingContent, onRetry, messagesEndRef, onNavigateWikilink,
|
||||
}: {
|
||||
messages: ChatMessage[]; isStreaming: boolean; streamingContent: string
|
||||
onRetry: (idx: number) => void; messagesEndRef: React.RefObject<HTMLDivElement | null>
|
||||
onNavigateWikilink?: (target: string) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
|
||||
@@ -302,10 +304,10 @@ function MessageList({
|
||||
<div key={msg.id} style={{ marginBottom: 12 }}>
|
||||
{msg.role === 'user'
|
||||
? <UserBubble content={msg.content} />
|
||||
: <AssistantMessage msg={msg} onRetry={() => onRetry(idx)} />}
|
||||
: <AssistantMessage msg={msg} onRetry={() => onRetry(idx)} onNavigateWikilink={onNavigateWikilink} />}
|
||||
</div>
|
||||
))}
|
||||
{isStreaming && streamingContent && <StreamingContent content={streamingContent} />}
|
||||
{isStreaming && streamingContent && <StreamingContent content={streamingContent} onNavigateWikilink={onNavigateWikilink} />}
|
||||
{isStreaming && !streamingContent && <TypingIndicator />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface AiMessageProps {
|
||||
response?: string
|
||||
isStreaming?: boolean
|
||||
onOpenNote?: (path: string) => void
|
||||
onNavigateWikilink?: (target: string) => void
|
||||
}
|
||||
|
||||
function ReferencePill({ reference, onClick }: {
|
||||
@@ -147,10 +148,10 @@ function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: {
|
||||
)
|
||||
}
|
||||
|
||||
function ResponseBlock({ text }: { text: string }) {
|
||||
function ResponseBlock({ text, onNavigateWikilink }: { text: string; onNavigateWikilink?: (target: string) => void }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<MarkdownContent content={text} />
|
||||
<MarkdownContent content={text} onWikilinkClick={onNavigateWikilink} />
|
||||
<button
|
||||
className="flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
style={{ fontSize: 11, marginTop: 4 }}
|
||||
@@ -175,7 +176,7 @@ function StreamingIndicator() {
|
||||
)
|
||||
}
|
||||
|
||||
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
|
||||
export function AiMessage({ userMessage, references, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote, onNavigateWikilink }: AiMessageProps) {
|
||||
// Manual override: null = follow auto behavior, true/false = user forced
|
||||
const [userOverride, setUserOverride] = useState(false)
|
||||
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())
|
||||
@@ -212,7 +213,7 @@ export function AiMessage({ userMessage, references, reasoning, reasoningDone, a
|
||||
onToggleExpand={toggleAction}
|
||||
/>
|
||||
)}
|
||||
{response && <ResponseBlock text={response} />}
|
||||
{response && <ResponseBlock text={response} onNavigateWikilink={onNavigateWikilink} />}
|
||||
{isStreaming && !response && <StreamingIndicator />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -4,10 +4,12 @@ import { AiPanel } from './AiPanel'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
// Mock the hooks and utils to isolate component tests
|
||||
let mockMessages: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['messages'] = []
|
||||
let mockStatus: ReturnType<typeof import('../hooks/useAiAgent').useAiAgent>['status'] = 'idle'
|
||||
vi.mock('../hooks/useAiAgent', () => ({
|
||||
useAiAgent: () => ({
|
||||
messages: [],
|
||||
status: 'idle',
|
||||
messages: mockMessages,
|
||||
status: mockStatus,
|
||||
sendMessage: vi.fn(),
|
||||
clearConversation: vi.fn(),
|
||||
}),
|
||||
@@ -45,6 +47,11 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
})
|
||||
|
||||
describe('AiPanel', () => {
|
||||
beforeEach(() => {
|
||||
mockMessages = []
|
||||
mockStatus = 'idle'
|
||||
})
|
||||
|
||||
it('renders panel with AI Chat header', () => {
|
||||
render(<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" />)
|
||||
expect(screen.getByText('AI Chat')).toBeTruthy()
|
||||
@@ -162,4 +169,41 @@ describe('AiPanel', () => {
|
||||
fireEvent.keyDown(panel, { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('clicking a wikilink in AI response calls onOpenNote with the target', () => {
|
||||
mockMessages = [{
|
||||
userMessage: 'Tell me about notes',
|
||||
actions: [],
|
||||
response: 'Check out [[Build Laputa App]] for details.',
|
||||
id: 'msg-1',
|
||||
}]
|
||||
const onOpenNote = vi.fn()
|
||||
const { container } = render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" onOpenNote={onOpenNote} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')
|
||||
expect(wikilink).toBeTruthy()
|
||||
expect(wikilink!.textContent).toBe('Build Laputa App')
|
||||
fireEvent.click(wikilink!)
|
||||
expect(onOpenNote).toHaveBeenCalledWith('Build Laputa App')
|
||||
})
|
||||
|
||||
it('renders wikilinks with special characters and clicking works', () => {
|
||||
mockMessages = [{
|
||||
userMessage: 'Tell me about meetings',
|
||||
actions: [],
|
||||
response: 'See [[Meeting — 2024/01/15]] and [[Pasta Carbonara]].',
|
||||
id: 'msg-2',
|
||||
}]
|
||||
const onOpenNote = vi.fn()
|
||||
const { container } = render(
|
||||
<AiPanel onClose={vi.fn()} vaultPath="/tmp/vault" onOpenNote={onOpenNote} />,
|
||||
)
|
||||
const wikilinks = container.querySelectorAll('.chat-wikilink')
|
||||
expect(wikilinks).toHaveLength(2)
|
||||
fireEvent.click(wikilinks[0])
|
||||
expect(onOpenNote).toHaveBeenCalledWith('Meeting — 2024/01/15')
|
||||
fireEvent.click(wikilinks[1])
|
||||
expect(onOpenNote).toHaveBeenCalledWith('Pasta Carbonara')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ interface AiPanelProps {
|
||||
onOpenNote?: (path: string) => void
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
vaultPath: string
|
||||
activeEntry?: VaultEntry | null
|
||||
entries?: VaultEntry[]
|
||||
@@ -89,8 +90,8 @@ function EmptyState({ hasContext }: { hasContext: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
|
||||
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; hasContext: boolean
|
||||
function MessageHistory({ messages, isActive, onOpenNote, onNavigateWikilink, hasContext }: {
|
||||
messages: AiAgentMessage[]; isActive: boolean; onOpenNote?: (path: string) => void; onNavigateWikilink?: (target: string) => void; hasContext: boolean
|
||||
}) {
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -102,14 +103,14 @@ function MessageHistory({ messages, isActive, onOpenNote, hasContext }: {
|
||||
<div className="flex-1 overflow-y-auto" style={{ padding: 12 }}>
|
||||
{messages.length === 0 && !isActive && <EmptyState hasContext={hasContext} />}
|
||||
{messages.map((msg, i) => (
|
||||
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} />
|
||||
<AiMessage key={msg.id ?? i} {...msg} onOpenNote={onOpenNote} onNavigateWikilink={onNavigateWikilink} />
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
|
||||
export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, onVaultChanged, vaultPath, activeEntry, entries, allContent, openTabs, noteList, noteListFilter }: AiPanelProps) {
|
||||
const [input, setInput] = useState('')
|
||||
const [pendingRefs, setPendingRefs] = useState<NoteReference[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
@@ -136,7 +137,8 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
|
||||
const fileCallbacks = useMemo<AgentFileCallbacks>(() => ({
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
}), [onFileCreated, onFileModified])
|
||||
onVaultChanged,
|
||||
}), [onFileCreated, onFileModified, onVaultChanged])
|
||||
|
||||
const agent = useAiAgent(vaultPath, contextPrompt, fileCallbacks)
|
||||
const hasContext = !!activeEntry
|
||||
@@ -167,6 +169,10 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [handleEscape])
|
||||
|
||||
const handleNavigateWikilink = useCallback((target: string) => {
|
||||
onOpenNote?.(target)
|
||||
}, [onOpenNote])
|
||||
|
||||
const handleSend = useCallback((text: string, references: NoteReference[]) => {
|
||||
if (!text.trim() || isActive) return
|
||||
setPendingRefs(references)
|
||||
@@ -198,6 +204,7 @@ export function AiPanel({ onClose, onOpenNote, onFileCreated, onFileModified, va
|
||||
messages={agent.messages}
|
||||
isActive={isActive}
|
||||
onOpenNote={onOpenNote}
|
||||
onNavigateWikilink={handleNavigateWikilink}
|
||||
hasContext={hasContext}
|
||||
/>
|
||||
<div
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useEffect, useCallback, memo } from 'react'
|
||||
import { useRef, useEffect, useCallback, memo, useMemo } from 'react'
|
||||
import { useEditorTabSwap, getH1TextFromBlocks } from '../hooks/useEditorTabSwap'
|
||||
import { useHeadingTitleSync } from '../hooks/useHeadingTitleSync'
|
||||
import { useCreateBlockNote } from '@blocknote/react'
|
||||
@@ -15,6 +15,7 @@ import { useEditorFocus } from '../hooks/useEditorFocus'
|
||||
import { EditorRightPanel } from './EditorRightPanel'
|
||||
import { EditorContent } from './EditorContent'
|
||||
import { schema } from './editorSchema'
|
||||
import { mergeTabContent } from '../utils/mergeTabContent'
|
||||
import './Editor.css'
|
||||
import './EditorTheme.css'
|
||||
|
||||
@@ -73,6 +74,7 @@ interface EditorProps {
|
||||
diffToggleRef?: React.MutableRefObject<() => void>
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
}
|
||||
|
||||
function useEditorModeExclusion({
|
||||
@@ -131,6 +133,7 @@ export const Editor = memo(function Editor({
|
||||
diffToggleRef,
|
||||
onFileCreated,
|
||||
onFileModified,
|
||||
onVaultChanged,
|
||||
}: EditorProps) {
|
||||
const vaultPathRef = useRef(vaultPath)
|
||||
useEffect(() => { vaultPathRef.current = vaultPath }, [vaultPath])
|
||||
@@ -170,6 +173,11 @@ export const Editor = memo(function Editor({
|
||||
diffMode, rawMode, handleToggleDiff, handleToggleRaw, rawToggleRef, diffToggleRef,
|
||||
})
|
||||
|
||||
const enrichedAllContent = useMemo(
|
||||
() => mergeTabContent(allContent, tabs),
|
||||
[allContent, tabs],
|
||||
)
|
||||
|
||||
const isLoadingNewTab = activeTabPath !== null && !activeTab
|
||||
const activeStatus = activeTab ? getNoteStatus?.(activeTab.entry.path) ?? 'clean' : 'clean'
|
||||
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
|
||||
@@ -232,7 +240,7 @@ export const Editor = memo(function Editor({
|
||||
inspectorEntry={inspectorEntry}
|
||||
inspectorContent={inspectorContent}
|
||||
entries={entries}
|
||||
allContent={allContent}
|
||||
allContent={enrichedAllContent}
|
||||
gitHistory={gitHistory}
|
||||
vaultPath={vaultPath ?? ''}
|
||||
openTabs={tabs.map(t => t.entry)}
|
||||
@@ -248,6 +256,7 @@ export const Editor = memo(function Editor({
|
||||
onOpenNote={onNavigateWikilink}
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
onVaultChanged={onVaultChanged}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ interface EditorRightPanelProps {
|
||||
onOpenNote?: (path: string) => void
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
onVaultChanged?: () => void
|
||||
}
|
||||
|
||||
export function EditorRightPanel({
|
||||
@@ -34,7 +35,7 @@ export function EditorRightPanel({
|
||||
noteList, noteListFilter,
|
||||
onToggleInspector, onToggleAIChat, onNavigateWikilink, onViewCommitDiff,
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty, onOpenNote,
|
||||
onFileCreated, onFileModified,
|
||||
onFileCreated, onFileModified, onVaultChanged,
|
||||
}: EditorRightPanelProps) {
|
||||
if (showAIChat) {
|
||||
return (
|
||||
@@ -47,6 +48,7 @@ export function EditorRightPanel({
|
||||
onOpenNote={onOpenNote}
|
||||
onFileCreated={onFileCreated}
|
||||
onFileModified={onFileModified}
|
||||
onVaultChanged={onVaultChanged}
|
||||
vaultPath={vaultPath}
|
||||
activeEntry={inspectorEntry}
|
||||
entries={entries}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { MarkdownContent } from './MarkdownContent'
|
||||
import { preprocessWikilinks } from '../utils/chatWikilinks'
|
||||
|
||||
describe('MarkdownContent', () => {
|
||||
it('renders bold text', () => {
|
||||
@@ -72,4 +73,110 @@ describe('MarkdownContent', () => {
|
||||
expect(bq).toBeTruthy()
|
||||
expect(bq!.textContent).toContain('A quote')
|
||||
})
|
||||
|
||||
describe('wikilinks', () => {
|
||||
it('preprocessWikilinks converts [[Target]] to markdown links', () => {
|
||||
expect(preprocessWikilinks('See [[My Note]]')).toBe('See [My Note](wikilink://My%20Note)')
|
||||
expect(preprocessWikilinks('[[A]] and [[B]]')).toBe('[A](wikilink://A) and [B](wikilink://B)')
|
||||
expect(preprocessWikilinks('`[[code]]`')).toBe('`[[code]]`')
|
||||
})
|
||||
|
||||
it('renders [[Note Title]] as a clickable wikilink chip', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="Check out [[My Note]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')
|
||||
expect(wikilink).toBeTruthy()
|
||||
expect(wikilink!.textContent).toBe('My Note')
|
||||
expect(wikilink!.getAttribute('data-wikilink-target')).toBe('My Note')
|
||||
})
|
||||
|
||||
it('fires onWikilinkClick when a wikilink is clicked', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[Daily Log]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')!
|
||||
fireEvent.click(wikilink)
|
||||
expect(onClick).toHaveBeenCalledWith('Daily Log')
|
||||
})
|
||||
|
||||
it('renders multiple wikilinks in the same paragraph', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[Note A]] and [[Note B]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilinks = container.querySelectorAll('.chat-wikilink')
|
||||
expect(wikilinks).toHaveLength(2)
|
||||
expect(wikilinks[0].textContent).toBe('Note A')
|
||||
expect(wikilinks[1].textContent).toBe('Note B')
|
||||
})
|
||||
|
||||
it('handles pipe syntax [[target|display]]', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[path/to/note|My Display]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')!
|
||||
expect(wikilink.textContent).toBe('My Display')
|
||||
expect(wikilink.getAttribute('data-wikilink-target')).toBe('path/to/note')
|
||||
fireEvent.click(wikilink)
|
||||
expect(onClick).toHaveBeenCalledWith('path/to/note')
|
||||
})
|
||||
|
||||
it('does not render wikilinks inside inline code', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="Use `[[Not a link]]` syntax" onWikilinkClick={onClick} />,
|
||||
)
|
||||
expect(container.querySelector('.chat-wikilink')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not render wikilinks inside code blocks', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content={'```\n[[Not a link]]\n```'} onWikilinkClick={onClick} />,
|
||||
)
|
||||
expect(container.querySelector('.chat-wikilink')).toBeNull()
|
||||
})
|
||||
|
||||
it('handles notes with special characters in title', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="Check [[Meeting — 2024/01/15]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')!
|
||||
expect(wikilink.textContent).toBe('Meeting — 2024/01/15')
|
||||
fireEvent.click(wikilink)
|
||||
expect(onClick).toHaveBeenCalledWith('Meeting — 2024/01/15')
|
||||
})
|
||||
|
||||
it('does not transform wikilinks when onWikilinkClick is not provided', () => {
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[Some Note]]" />,
|
||||
)
|
||||
expect(container.querySelector('.chat-wikilink')).toBeNull()
|
||||
expect(container.textContent).toContain('[[Some Note]]')
|
||||
})
|
||||
|
||||
it('renders wikilinks inside list items', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content={'- First [[Note A]]\n- Second [[Note B]]'} onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilinks = container.querySelectorAll('.chat-wikilink')
|
||||
expect(wikilinks).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('has role="link" and tabIndex for accessibility', () => {
|
||||
const onClick = vi.fn()
|
||||
const { container } = render(
|
||||
<MarkdownContent content="See [[Accessible Note]]" onWikilinkClick={onClick} />,
|
||||
)
|
||||
const wikilink = container.querySelector('.chat-wikilink')!
|
||||
expect(wikilink.getAttribute('role')).toBe('link')
|
||||
expect(wikilink.getAttribute('tabindex')).toBe('0')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,18 +1,63 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import Markdown from 'react-markdown'
|
||||
import { memo, useMemo, useCallback, type MouseEvent } from 'react'
|
||||
import Markdown, { defaultUrlTransform } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import rehypeHighlight from 'rehype-highlight'
|
||||
import { preprocessWikilinks, WIKILINK_SCHEME } from '../utils/chatWikilinks'
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm]
|
||||
const REHYPE_PLUGINS = [rehypeHighlight]
|
||||
|
||||
export const MarkdownContent = memo(function MarkdownContent({ content }: { content: string }) {
|
||||
const rendered = useMemo(() => (
|
||||
<div className="ai-markdown">
|
||||
<Markdown remarkPlugins={REMARK_PLUGINS} rehypePlugins={REHYPE_PLUGINS}>
|
||||
{content}
|
||||
function wikilinkUrlTransform(url: string): string {
|
||||
if (url.startsWith(WIKILINK_SCHEME)) return url
|
||||
return defaultUrlTransform(url)
|
||||
}
|
||||
|
||||
interface MarkdownContentProps {
|
||||
content: string
|
||||
onWikilinkClick?: (target: string) => void
|
||||
}
|
||||
|
||||
export const MarkdownContent = memo(function MarkdownContent({ content, onWikilinkClick }: MarkdownContentProps) {
|
||||
const processedContent = useMemo(
|
||||
() => onWikilinkClick ? preprocessWikilinks(content) : content,
|
||||
[content, onWikilinkClick],
|
||||
)
|
||||
|
||||
const handleClick = useCallback((e: MouseEvent) => {
|
||||
const el = (e.target as HTMLElement).closest<HTMLElement>('[data-wikilink-target]')
|
||||
if (el) {
|
||||
e.preventDefault()
|
||||
onWikilinkClick?.(el.dataset.wikilinkTarget!)
|
||||
}
|
||||
}, [onWikilinkClick])
|
||||
|
||||
const components = useMemo(() => {
|
||||
if (!onWikilinkClick) return undefined
|
||||
return {
|
||||
a: ({ href, children }: { href?: string; children?: React.ReactNode }) => {
|
||||
if (href?.startsWith(WIKILINK_SCHEME)) {
|
||||
const target = decodeURIComponent(href.slice(WIKILINK_SCHEME.length))
|
||||
return (
|
||||
<span className="chat-wikilink" data-wikilink-target={target} role="link" tabIndex={0}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <a href={href}>{children}</a>
|
||||
},
|
||||
}
|
||||
}, [onWikilinkClick])
|
||||
|
||||
return (
|
||||
<div className="ai-markdown" onClick={onWikilinkClick ? handleClick : undefined} role="presentation">
|
||||
<Markdown
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
rehypePlugins={REHYPE_PLUGINS}
|
||||
components={components}
|
||||
urlTransform={onWikilinkClick ? wikilinkUrlTransform : undefined}
|
||||
>
|
||||
{processedContent}
|
||||
</Markdown>
|
||||
</div>
|
||||
), [content])
|
||||
return rendered
|
||||
)
|
||||
})
|
||||
|
||||
@@ -233,10 +233,10 @@ const mockEntries: VaultEntry[] = [
|
||||
const defaultSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
|
||||
describe('Sidebar', () => {
|
||||
it('renders top nav items (All Notes and Favorites)', () => {
|
||||
it('renders top nav items (All Notes)', () => {
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Favorites')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders section group headers only for types present in entries', () => {
|
||||
@@ -764,7 +764,7 @@ describe('Sidebar', () => {
|
||||
]
|
||||
render(<Sidebar entries={entries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Favorites')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Favorites')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a "Customize sections" button', () => {
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
FileText, Star, Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive, CaretLeft, GitDiff, Pulse,
|
||||
FileText, Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, Trash, StackSimple, Archive, CaretLeft, GitDiff, Pulse,
|
||||
} from '@phosphor-icons/react'
|
||||
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
|
||||
import {
|
||||
@@ -356,9 +356,7 @@ export const Sidebar = memo(function Sidebar({
|
||||
{/* Top nav */}
|
||||
<div className="border-b border-border" style={{ padding: '4px 6px' }}>
|
||||
<NavItem icon={FileText} label="All Notes" count={activeCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })} badgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'all' })} />
|
||||
<NavItem icon={Star} label="Favorites" isActive={isSelectionActive(selection, { kind: 'filter', filter: 'favorites' })} onClick={() => onSelect({ kind: 'filter', filter: 'favorites' })} />
|
||||
<NavItem icon={Archive} label="Archive" count={archivedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'archived' })} />
|
||||
<NavItem icon={TagSimple} label="Untagged" disabled />
|
||||
<NavItem icon={Trash} label="Trash" count={trashedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'trash' })} activeClassName="bg-destructive/10 text-destructive" badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
|
||||
{modifiedCount > 0 && (
|
||||
<NavItem icon={GitDiff} label="Changes" count={modifiedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'changes' })} activeClassName="bg-[color:var(--accent-orange)]/10 text-[var(--accent-orange)]" badgeClassName="text-white" badgeStyle={{ background: 'var(--accent-orange)' }} onClick={() => onSelect({ kind: 'filter', filter: 'changes' })} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { StatusBar } from './StatusBar'
|
||||
import type { VaultOption } from './StatusBar'
|
||||
import { formatIndexedElapsed } from '../utils/indexingHelpers'
|
||||
|
||||
vi.mock('../utils/url', async () => {
|
||||
const actual = await vi.importActual('../utils/url')
|
||||
@@ -394,4 +395,71 @@ describe('StatusBar', () => {
|
||||
fireEvent.click(screen.getByTestId('status-mcp'))
|
||||
expect(onInstallMcp).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows "Indexed just now" when lastIndexedTime is recent and phase is idle', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
|
||||
lastIndexedTime={Date.now() - 5000}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/Indexed just now/)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('status-indexed-time')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onReindexVault when clicking the indexed time badge', () => {
|
||||
const onReindexVault = vi.fn()
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
|
||||
lastIndexedTime={Date.now() - 5000}
|
||||
onReindexVault={onReindexVault}
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('status-indexed-time'))
|
||||
expect(onReindexVault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides indexed time badge when no lastIndexedTime', () => {
|
||||
render(
|
||||
<StatusBar
|
||||
noteCount={100}
|
||||
vaultPath="/Users/luca/Laputa"
|
||||
vaults={vaults}
|
||||
onSwitchVault={vi.fn()}
|
||||
indexingProgress={{ phase: 'idle', current: 0, total: 0, done: false, error: null }}
|
||||
/>
|
||||
)
|
||||
expect(screen.queryByTestId('status-indexed-time')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatIndexedElapsed', () => {
|
||||
it('returns empty string for null', () => {
|
||||
expect(formatIndexedElapsed(null)).toBe('')
|
||||
})
|
||||
|
||||
it('returns "Indexed just now" for < 60s', () => {
|
||||
expect(formatIndexedElapsed(Date.now() - 30_000)).toBe('Indexed just now')
|
||||
})
|
||||
|
||||
it('returns minutes for < 60min', () => {
|
||||
expect(formatIndexedElapsed(Date.now() - 5 * 60_000)).toBe('Indexed 5m ago')
|
||||
})
|
||||
|
||||
it('returns hours for < 24h', () => {
|
||||
expect(formatIndexedElapsed(Date.now() - 3 * 3600_000)).toBe('Indexed 3h ago')
|
||||
})
|
||||
|
||||
it('returns days for >= 24h', () => {
|
||||
expect(formatIndexedElapsed(Date.now() - 48 * 3600_000)).toBe('Indexed 2d ago')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { LastCommitInfo, SyncStatus } from '../types'
|
||||
import type { IndexingProgress } from '../hooks/useIndexing'
|
||||
import type { McpStatus } from '../hooks/useMcpStatus'
|
||||
import { openExternalUrl } from '../utils/url'
|
||||
import { formatIndexedElapsed } from '../utils/indexingHelpers'
|
||||
|
||||
export interface VaultOption {
|
||||
label: string
|
||||
@@ -33,7 +34,9 @@ interface StatusBarProps {
|
||||
buildNumber?: string
|
||||
onCheckForUpdates?: () => void
|
||||
indexingProgress?: IndexingProgress
|
||||
lastIndexedTime?: number | null
|
||||
onRetryIndexing?: () => void
|
||||
onReindexVault?: () => void
|
||||
onRemoveVault?: (path: string) => void
|
||||
mcpStatus?: McpStatus
|
||||
onInstallMcp?: () => void
|
||||
@@ -245,8 +248,32 @@ const INDEXING_LABELS: Record<string, string> = {
|
||||
unavailable: 'Search unavailable',
|
||||
}
|
||||
|
||||
function IndexingBadge({ progress, onRetry }: { progress: IndexingProgress; onRetry?: () => void }) {
|
||||
if (progress.phase === 'idle' || progress.phase === 'unavailable') return null
|
||||
function IndexingBadge({ progress, lastIndexedTime, onRetry, onReindex }: { progress: IndexingProgress; lastIndexedTime?: number | null; onRetry?: () => void; onReindex?: () => void }) {
|
||||
const isIdle = progress.phase === 'idle' || progress.phase === 'unavailable'
|
||||
|
||||
// When idle, show "Indexed Xm ago" if we have a timestamp
|
||||
if (isIdle) {
|
||||
if (!lastIndexedTime) return null
|
||||
const elapsed = formatIndexedElapsed(lastIndexedTime)
|
||||
if (!elapsed) return null
|
||||
return (
|
||||
<>
|
||||
<span style={SEP_STYLE}>|</span>
|
||||
<span
|
||||
role={onReindex ? 'button' : undefined}
|
||||
onClick={onReindex}
|
||||
style={{ ...ICON_STYLE, color: 'var(--muted-foreground)', cursor: onReindex ? 'pointer' : 'default', padding: '2px 4px', borderRadius: 3, background: 'transparent' }}
|
||||
title={onReindex ? 'Click to reindex vault' : undefined}
|
||||
data-testid="status-indexed-time"
|
||||
onMouseEnter={onReindex ? (e) => { e.currentTarget.style.background = 'var(--hover)' } : undefined}
|
||||
onMouseLeave={onReindex ? (e) => { e.currentTarget.style.background = 'transparent' } : undefined}
|
||||
>
|
||||
<Search size={13} />{elapsed}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const label = INDEXING_LABELS[progress.phase] ?? progress.phase
|
||||
const isActive = !progress.done
|
||||
const isError = progress.phase === 'error'
|
||||
@@ -329,7 +356,7 @@ function McpBadge({ status, onInstall }: { status: McpStatus; onInstall?: () =>
|
||||
)
|
||||
}
|
||||
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, onRetryIndexing, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
|
||||
export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onSwitchVault, onOpenSettings, onOpenLocalFolder, onConnectGitHub, onClickPending, hasGitHub, syncStatus = 'idle', lastSyncTime = null, conflictCount = 0, lastCommitInfo, onTriggerSync, onOpenConflictResolver, zoomLevel = 100, onZoomReset, buildNumber, onCheckForUpdates, indexingProgress, lastIndexedTime, onRetryIndexing, onReindexVault, onRemoveVault, mcpStatus, onInstallMcp }: StatusBarProps) {
|
||||
const [, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), 30_000)
|
||||
@@ -355,7 +382,7 @@ export function StatusBar({ noteCount, modifiedCount = 0, vaultPath, vaults, onS
|
||||
{lastCommitInfo && <CommitBadge info={lastCommitInfo} />}
|
||||
<ConflictBadge count={conflictCount} onClick={onOpenConflictResolver} />
|
||||
<PendingBadge count={modifiedCount} onClick={onClickPending} />
|
||||
{indexingProgress && <IndexingBadge progress={indexingProgress} onRetry={onRetryIndexing} />}
|
||||
{indexingProgress && <IndexingBadge progress={indexingProgress} lastIndexedTime={lastIndexedTime} onRetry={onRetryIndexing} onReindex={onReindexVault} />}
|
||||
{mcpStatus && <McpBadge status={mcpStatus} onInstall={onInstallMcp} />}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
|
||||
161
src/hooks/useAIChat.test.ts
Normal file
161
src/hooks/useAIChat.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
|
||||
// Capture what streamClaudeChat receives
|
||||
const streamClaudeChatMock = vi.fn<
|
||||
Parameters<typeof import('../utils/ai-chat').streamClaudeChat>,
|
||||
ReturnType<typeof import('../utils/ai-chat').streamClaudeChat>
|
||||
>()
|
||||
|
||||
vi.mock('../utils/ai-chat', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils/ai-chat')>('../utils/ai-chat')
|
||||
return {
|
||||
...actual,
|
||||
streamClaudeChat: (...args: Parameters<typeof actual.streamClaudeChat>) => {
|
||||
streamClaudeChatMock(...args)
|
||||
// Simulate async: emit Init with session_id, then text, then done
|
||||
const callbacks = args[3]
|
||||
setTimeout(() => {
|
||||
callbacks.onInit?.('session-001')
|
||||
callbacks.onText('mock response')
|
||||
callbacks.onDone()
|
||||
}, 10)
|
||||
return Promise.resolve('session-001')
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { useAIChat } from './useAIChat'
|
||||
|
||||
beforeEach(() => {
|
||||
streamClaudeChatMock.mockClear()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useAIChat', () => {
|
||||
const emptyContent: Record<string, string> = {}
|
||||
|
||||
it('sends first message without session_id (new session)', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(1)
|
||||
const [message, , sessionId] = streamClaudeChatMock.mock.calls[0]
|
||||
// First message: raw text, no session_id
|
||||
expect(message).toBe('hello')
|
||||
expect(sessionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resumes session on second message via --resume', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Send first message
|
||||
act(() => { result.current.sendMessage('What is Rust?') })
|
||||
// Wait for mock response (which fires onInit with session-001)
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
expect(result.current.messages).toHaveLength(2)
|
||||
|
||||
// Send second message — should resume with session_id
|
||||
act(() => { result.current.sendMessage('Tell me more') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
|
||||
const [message, systemPrompt, sessionId] = streamClaudeChatMock.mock.calls[1]
|
||||
// Raw message only (no embedded history)
|
||||
expect(message).toBe('Tell me more')
|
||||
// Session resumed via --resume
|
||||
expect(sessionId).toBe('session-001')
|
||||
// System prompt omitted on resumed sessions
|
||||
expect(systemPrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resets session on clearConversation', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Send a message and get response
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Clear conversation
|
||||
act(() => { result.current.clearConversation() })
|
||||
expect(result.current.messages).toHaveLength(0)
|
||||
|
||||
// Send new message — should start a fresh session (no session_id)
|
||||
act(() => { result.current.sendMessage('fresh start') })
|
||||
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
expect(lastCall[0]).toBe('fresh start')
|
||||
expect(lastCall[2]).toBeUndefined() // no session_id
|
||||
})
|
||||
|
||||
it('resumes session across multiple exchanges', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Exchange 1
|
||||
act(() => { result.current.sendMessage('Q1') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Exchange 2
|
||||
act(() => { result.current.sendMessage('Q2') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Exchange 3
|
||||
act(() => { result.current.sendMessage('Q3') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(3)
|
||||
|
||||
// First call: no session
|
||||
expect(streamClaudeChatMock.mock.calls[0][2]).toBeUndefined()
|
||||
// Second and third calls: resume session
|
||||
expect(streamClaudeChatMock.mock.calls[1][2]).toBe('session-001')
|
||||
expect(streamClaudeChatMock.mock.calls[2][2]).toBe('session-001')
|
||||
|
||||
// All messages are raw text (no embedded history)
|
||||
expect(streamClaudeChatMock.mock.calls[0][0]).toBe('Q1')
|
||||
expect(streamClaudeChatMock.mock.calls[1][0]).toBe('Q2')
|
||||
expect(streamClaudeChatMock.mock.calls[2][0]).toBe('Q3')
|
||||
})
|
||||
|
||||
it('includes system prompt only on first message of a session', async () => {
|
||||
const content = { 'note.md': 'Some note content' }
|
||||
const notes = [{ path: 'note.md', title: 'Test Note' }] as import('../types').VaultEntry[]
|
||||
|
||||
const { result } = renderHook(() => useAIChat(content, notes))
|
||||
|
||||
// First message — system prompt included
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
const firstSystemPrompt = streamClaudeChatMock.mock.calls[0][1]
|
||||
expect(firstSystemPrompt).toBeTruthy()
|
||||
expect(firstSystemPrompt).toContain('Test Note')
|
||||
|
||||
// Second message — system prompt omitted (session already has it)
|
||||
act(() => { result.current.sendMessage('follow up') })
|
||||
|
||||
const secondSystemPrompt = streamClaudeChatMock.mock.calls[1][1]
|
||||
expect(secondSystemPrompt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resets session on retry', async () => {
|
||||
const { result } = renderHook(() => useAIChat(emptyContent, []))
|
||||
|
||||
// Send message and get response
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
expect(result.current.messages).toHaveLength(2)
|
||||
|
||||
// Retry the assistant response (index 1)
|
||||
act(() => { result.current.retryMessage(1) })
|
||||
|
||||
// Should start a fresh session
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
expect(lastCall[2]).toBeUndefined() // no session_id — fresh session
|
||||
expect(lastCall[0]).toBe('hello') // re-sends the user message
|
||||
})
|
||||
})
|
||||
@@ -1,14 +1,57 @@
|
||||
/**
|
||||
* Custom hook encapsulating AI chat state and message handling.
|
||||
* Uses Claude CLI subprocess via Tauri for streaming responses.
|
||||
*
|
||||
* Conversation continuity uses the CLI's --resume flag: the first message
|
||||
* starts a new session; subsequent messages resume it via session_id.
|
||||
* This avoids embedding history as text in the prompt (which the CLI's
|
||||
* -p mode treats as a single user turn, losing turn boundaries).
|
||||
*/
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
type ChatMessage, nextMessageId,
|
||||
type ChatMessage, type ChatStreamCallbacks, nextMessageId,
|
||||
buildSystemPrompt, streamClaudeChat,
|
||||
} from '../utils/ai-chat'
|
||||
|
||||
interface ChatStreamRefs {
|
||||
abortRef: React.RefObject<boolean>
|
||||
sessionIdRef: React.MutableRefObject<string | undefined>
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
|
||||
setStreamingContent: React.Dispatch<React.SetStateAction<string>>
|
||||
setIsStreaming: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
/** Create stream callbacks that accumulate text and update React state. */
|
||||
function makeStreamCallbacks(
|
||||
refs: ChatStreamRefs,
|
||||
): { callbacks: ChatStreamCallbacks; getAccumulated: () => string } {
|
||||
let accumulated = ''
|
||||
const callbacks: ChatStreamCallbacks = {
|
||||
onInit: (sid) => { refs.sessionIdRef.current = sid },
|
||||
onText: (chunk) => {
|
||||
if (refs.abortRef.current) return
|
||||
accumulated += chunk
|
||||
refs.setStreamingContent(accumulated)
|
||||
},
|
||||
onError: (error) => {
|
||||
if (refs.abortRef.current) return
|
||||
refs.setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${error}`, id: nextMessageId() }])
|
||||
refs.setStreamingContent('')
|
||||
refs.setIsStreaming(false)
|
||||
},
|
||||
onDone: () => {
|
||||
if (refs.abortRef.current) return
|
||||
if (accumulated) {
|
||||
refs.setMessages(prev => [...prev, { role: 'assistant', content: accumulated, id: nextMessageId() }])
|
||||
}
|
||||
refs.setStreamingContent('')
|
||||
refs.setIsStreaming(false)
|
||||
},
|
||||
}
|
||||
return { callbacks, getAccumulated: () => accumulated }
|
||||
}
|
||||
|
||||
export function useAIChat(
|
||||
allContent: Record<string, string>,
|
||||
contextNotes: VaultEntry[],
|
||||
@@ -22,42 +65,26 @@ export function useAIChat(
|
||||
const sendMessage = useCallback((text: string) => {
|
||||
if (!text.trim() || isStreaming) return
|
||||
|
||||
const userMsg: ChatMessage = { role: 'user', content: text.trim(), id: nextMessageId() }
|
||||
setMessages(prev => [...prev, userMsg])
|
||||
setMessages(prev => [...prev, { role: 'user', content: text.trim(), id: nextMessageId() }])
|
||||
setIsStreaming(true)
|
||||
setStreamingContent('')
|
||||
abortRef.current = false
|
||||
|
||||
const { prompt: systemPrompt } = buildSystemPrompt(contextNotes, allContent)
|
||||
let accumulated = ''
|
||||
const currentSessionId = sessionIdRef.current
|
||||
// System prompt only on first message (new session).
|
||||
const systemPrompt = currentSessionId
|
||||
? undefined
|
||||
: (buildSystemPrompt(contextNotes, allContent).prompt || undefined)
|
||||
|
||||
streamClaudeChat(text.trim(), systemPrompt || undefined, sessionIdRef.current, {
|
||||
onInit: (sid) => { sessionIdRef.current = sid },
|
||||
|
||||
onText: (chunk) => {
|
||||
if (abortRef.current) return
|
||||
accumulated += chunk
|
||||
setStreamingContent(accumulated)
|
||||
},
|
||||
|
||||
onError: (error) => {
|
||||
if (abortRef.current) return
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${error}`, id: nextMessageId() }])
|
||||
setStreamingContent('')
|
||||
setIsStreaming(false)
|
||||
},
|
||||
|
||||
onDone: () => {
|
||||
if (abortRef.current) return
|
||||
if (accumulated) {
|
||||
setMessages(prev => [...prev, { role: 'assistant', content: accumulated, id: nextMessageId() }])
|
||||
}
|
||||
setStreamingContent('')
|
||||
setIsStreaming(false)
|
||||
},
|
||||
}).then(sid => {
|
||||
if (sid) sessionIdRef.current = sid
|
||||
const { callbacks } = makeStreamCallbacks({
|
||||
abortRef, sessionIdRef, setMessages, setStreamingContent, setIsStreaming,
|
||||
})
|
||||
|
||||
streamClaudeChat(text.trim(), systemPrompt, currentSessionId, callbacks)
|
||||
.then((sid) => {
|
||||
if (sid && !sessionIdRef.current) sessionIdRef.current = sid
|
||||
})
|
||||
.catch(() => { /* errors forwarded via onError */ })
|
||||
}, [isStreaming, allContent, contextNotes])
|
||||
|
||||
const clearConversation = useCallback(() => {
|
||||
@@ -74,6 +101,7 @@ export function useAIChat(
|
||||
const userMsg = messages[userMsgIndex]
|
||||
if (userMsg.role !== 'user') return
|
||||
|
||||
sessionIdRef.current = undefined
|
||||
setMessages(prev => prev.slice(0, msgIndex))
|
||||
sendMessage(userMsg.content)
|
||||
}, [messages, sendMessage])
|
||||
|
||||
178
src/hooks/useAiAgent.test.ts
Normal file
178
src/hooks/useAiAgent.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { detectFileOperation, parseBashFileCreation } from './useAiAgent'
|
||||
import type { AgentFileCallbacks } from './useAiAgent'
|
||||
|
||||
const VAULT = '/Users/luca/Laputa'
|
||||
|
||||
function makeCallbacks() {
|
||||
return {
|
||||
onFileCreated: vi.fn(),
|
||||
onFileModified: vi.fn(),
|
||||
onVaultChanged: vi.fn(),
|
||||
} satisfies AgentFileCallbacks
|
||||
}
|
||||
|
||||
describe('detectFileOperation', () => {
|
||||
it('calls onFileCreated for Write tool with .md in vault', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
|
||||
expect(cb.onFileCreated).toHaveBeenCalledWith('note/test.md')
|
||||
expect(cb.onFileModified).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onFileModified for Edit tool with .md in vault', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Edit', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
|
||||
expect(cb.onFileModified).toHaveBeenCalledWith('note/test.md')
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores non-md files', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/image.png` }), VAULT, cb)
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores files outside vault', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Write', JSON.stringify({ file_path: '/tmp/other.md' }), VAULT, cb)
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores unknown tool names', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Read', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
expect(cb.onFileModified).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onVaultChanged when Write input is undefined', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Write', undefined, VAULT, cb)
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
expect(cb.onVaultChanged).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onVaultChanged when Edit input is undefined', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Edit', undefined, VAULT, cb)
|
||||
expect(cb.onFileModified).not.toHaveBeenCalled()
|
||||
expect(cb.onVaultChanged).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onVaultChanged when Write has malformed JSON input', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Write', 'not-json', VAULT, cb)
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
expect(cb.onVaultChanged).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call onVaultChanged when Write detects specific file', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, cb)
|
||||
expect(cb.onFileCreated).toHaveBeenCalledWith('note/test.md')
|
||||
expect(cb.onVaultChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call onVaultChanged for Read tool', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Read', undefined, VAULT, cb)
|
||||
expect(cb.onVaultChanged).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onVaultChanged when Bash input is undefined', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Bash', undefined, VAULT, cb)
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
expect(cb.onVaultChanged).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles undefined callbacks gracefully', () => {
|
||||
expect(() => detectFileOperation('Write', JSON.stringify({ file_path: `${VAULT}/note/test.md` }), VAULT, undefined)).not.toThrow()
|
||||
})
|
||||
|
||||
it('detects Bash redirect creating .md file in vault', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Bash', JSON.stringify({ command: `echo "# Note" > ${VAULT}/note/new.md` }), VAULT, cb)
|
||||
expect(cb.onFileCreated).toHaveBeenCalledWith('note/new.md')
|
||||
})
|
||||
|
||||
it('detects Bash append redirect creating .md file', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Bash', JSON.stringify({ command: `cat content.txt >> ${VAULT}/daily/2026-03-07.md` }), VAULT, cb)
|
||||
expect(cb.onFileCreated).toHaveBeenCalledWith('daily/2026-03-07.md')
|
||||
})
|
||||
|
||||
it('detects Bash tee command creating .md file', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Bash', JSON.stringify({ command: `echo "content" | tee ${VAULT}/note/tee-note.md` }), VAULT, cb)
|
||||
expect(cb.onFileCreated).toHaveBeenCalledWith('note/tee-note.md')
|
||||
})
|
||||
|
||||
it('ignores Bash commands without file creation', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Bash', JSON.stringify({ command: 'ls -la' }), VAULT, cb)
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores Bash creating non-md files', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Bash', JSON.stringify({ command: `echo "data" > ${VAULT}/config.json` }), VAULT, cb)
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ignores Bash creating .md files outside vault', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Bash', JSON.stringify({ command: 'echo "text" > /tmp/other.md' }), VAULT, cb)
|
||||
expect(cb.onFileCreated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses path field when file_path is missing', () => {
|
||||
const cb = makeCallbacks()
|
||||
detectFileOperation('Write', JSON.stringify({ path: `${VAULT}/note/alt.md` }), VAULT, cb)
|
||||
expect(cb.onFileCreated).toHaveBeenCalledWith('note/alt.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseBashFileCreation', () => {
|
||||
it('returns null for undefined input', () => {
|
||||
expect(parseBashFileCreation(undefined, VAULT)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for malformed JSON', () => {
|
||||
expect(parseBashFileCreation('not json', VAULT)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when no command field', () => {
|
||||
expect(parseBashFileCreation(JSON.stringify({ other: 'value' }), VAULT)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null when command is not a string', () => {
|
||||
expect(parseBashFileCreation(JSON.stringify({ command: 42 }), VAULT)).toBeNull()
|
||||
})
|
||||
|
||||
it('parses simple redirect', () => {
|
||||
const input = JSON.stringify({ command: `echo "# Title" > ${VAULT}/note.md` })
|
||||
expect(parseBashFileCreation(input, VAULT)).toBe('note.md')
|
||||
})
|
||||
|
||||
it('parses append redirect', () => {
|
||||
const input = JSON.stringify({ command: `echo "line" >> ${VAULT}/sub/note.md` })
|
||||
expect(parseBashFileCreation(input, VAULT)).toBe('sub/note.md')
|
||||
})
|
||||
|
||||
it('parses tee command', () => {
|
||||
const input = JSON.stringify({ command: `echo "data" | tee ${VAULT}/new.md` })
|
||||
expect(parseBashFileCreation(input, VAULT)).toBe('new.md')
|
||||
})
|
||||
|
||||
it('parses tee -a (append) command', () => {
|
||||
const input = JSON.stringify({ command: `echo "extra" | tee -a ${VAULT}/new.md` })
|
||||
expect(parseBashFileCreation(input, VAULT)).toBe('new.md')
|
||||
})
|
||||
|
||||
it('returns null for non-md redirect', () => {
|
||||
const input = JSON.stringify({ command: `echo "x" > ${VAULT}/file.txt` })
|
||||
expect(parseBashFileCreation(input, VAULT)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,8 @@ export interface AiAgentMessage {
|
||||
export interface AgentFileCallbacks {
|
||||
onFileCreated?: (relativePath: string) => void
|
||||
onFileModified?: (relativePath: string) => void
|
||||
/** Fallback: vault may have changed but we can't determine the specific file. */
|
||||
onVaultChanged?: () => void
|
||||
}
|
||||
|
||||
export function useAiAgent(
|
||||
@@ -105,7 +107,10 @@ export function useAiAgent(
|
||||
if (abortRef.current.aborted) return
|
||||
markReasoningDone()
|
||||
setStatus('tool-executing')
|
||||
toolInputMapRef.current.set(toolId, { tool: toolName, input })
|
||||
// Preserve accumulated input — tool_progress events arrive with
|
||||
// input=undefined AFTER the assistant message set the full input.
|
||||
const prev = toolInputMapRef.current.get(toolId)
|
||||
toolInputMapRef.current.set(toolId, { tool: toolName, input: input ?? prev?.input })
|
||||
update(m => {
|
||||
const existing = m.actions.find(a => a.toolId === toolId)
|
||||
if (existing) {
|
||||
@@ -169,6 +174,8 @@ export function useAiAgent(
|
||||
response: finalResponse,
|
||||
actions: m.actions.map(a => a.status === 'pending' ? { ...a, status: 'done' as const } : a),
|
||||
}))
|
||||
// Safety net: refresh vault after agent completes in case file changes were missed
|
||||
fileCallbacksRef.current?.onVaultChanged?.()
|
||||
},
|
||||
})
|
||||
}, [status, vaultPath])
|
||||
@@ -205,22 +212,53 @@ function toVaultRelative(filePath: string, vaultPath: string): string | null {
|
||||
}
|
||||
|
||||
/** Detect file operations from completed tool calls and notify callbacks. */
|
||||
function detectFileOperation(
|
||||
export function detectFileOperation(
|
||||
toolName: string,
|
||||
input: string | undefined,
|
||||
vaultPath: string,
|
||||
callbacks: AgentFileCallbacks | undefined,
|
||||
) {
|
||||
if (!callbacks) return
|
||||
|
||||
// Handle Bash commands that create/write .md files
|
||||
if (toolName === 'Bash') {
|
||||
const mdPath = parseBashFileCreation(input, vaultPath)
|
||||
if (mdPath) { callbacks.onFileCreated?.(mdPath); return }
|
||||
// Bash ran but we couldn't detect a specific .md file — still may have changed vault
|
||||
callbacks.onVaultChanged?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (toolName !== 'Write' && toolName !== 'Edit') return
|
||||
|
||||
const filePath = parseFilePath(input)
|
||||
if (!filePath || !filePath.endsWith('.md')) return
|
||||
const rel = toVaultRelative(filePath, vaultPath)
|
||||
if (!rel) return
|
||||
if (toolName === 'Write') {
|
||||
callbacks.onFileCreated?.(rel)
|
||||
} else {
|
||||
callbacks.onFileModified?.(rel)
|
||||
if (filePath && filePath.endsWith('.md')) {
|
||||
const rel = toVaultRelative(filePath, vaultPath)
|
||||
if (rel) {
|
||||
if (toolName === 'Write') callbacks.onFileCreated?.(rel)
|
||||
else callbacks.onFileModified?.(rel)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Write/Edit completed but couldn't determine target file — trigger vault refresh
|
||||
callbacks.onVaultChanged?.()
|
||||
}
|
||||
|
||||
/** Detect .md file creation from a Bash command string. */
|
||||
export function parseBashFileCreation(input: string | undefined, vaultPath: string): string | null {
|
||||
if (!input) return null
|
||||
try {
|
||||
const parsed = JSON.parse(input)
|
||||
const cmd = parsed.command ?? parsed.cmd
|
||||
if (typeof cmd !== 'string') return null
|
||||
// Match redirect patterns: > file.md, >> file.md, tee file.md, cat > file.md
|
||||
const match = cmd.match(/(?:>|>>|tee\s+(?:-a\s+)?)\s*["']?([^\s"'|;]+\.md)["']?/)
|
||||
if (!match) return null
|
||||
const filePath = match[1]
|
||||
return toVaultRelative(filePath, vaultPath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,8 @@ interface AppCommandsConfig {
|
||||
vaultCount?: number
|
||||
mcpStatus?: string
|
||||
onInstallMcp?: () => void
|
||||
onReindexVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
}
|
||||
|
||||
/** Sets up keyboard shortcuts, command registry, menu events, and keyboard navigation. */
|
||||
@@ -86,7 +88,7 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
|
||||
const { onSelect } = config
|
||||
|
||||
const selectFilter = useCallback((filter: 'all' | 'favorites' | 'archived' | 'trash' | 'changes' | 'pulse') => {
|
||||
const selectFilter = useCallback((filter: 'all' | 'archived' | 'trash' | 'changes' | 'pulse') => {
|
||||
onSelect({ kind: 'filter', filter })
|
||||
}, [onSelect])
|
||||
|
||||
@@ -148,6 +150,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
onResolveConflicts: config.onResolveConflicts,
|
||||
onViewChanges: viewChanges,
|
||||
onInstallMcp: config.onInstallMcp,
|
||||
onReindexVault: config.onReindexVault,
|
||||
onRepairVault: config.onRepairVault,
|
||||
activeTabPathRef: config.activeTabPathRef,
|
||||
handleCloseTabRef: config.handleCloseTabRef,
|
||||
activeTabPath: config.activeTabPath,
|
||||
@@ -201,6 +205,8 @@ export function useAppCommands(config: AppCommandsConfig): CommandAction[] {
|
||||
vaultCount: config.vaultCount,
|
||||
mcpStatus: config.mcpStatus,
|
||||
onInstallMcp: config.onInstallMcp,
|
||||
onReindexVault: config.onReindexVault,
|
||||
onRepairVault: config.onRepairVault,
|
||||
})
|
||||
|
||||
useKeyboardNavigation({
|
||||
|
||||
@@ -255,6 +255,47 @@ describe('useAutoSync', () => {
|
||||
expect(pullCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('calls onSyncUpdated when pull has updates', async () => {
|
||||
const onSyncUpdated = vi.fn()
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'get_last_commit_info') return Promise.resolve(MOCK_COMMIT_INFO)
|
||||
return Promise.resolve(updated(['note.md']))
|
||||
})
|
||||
renderHook(() =>
|
||||
useAutoSync({
|
||||
vaultPath: '/Users/luca/Laputa',
|
||||
intervalMinutes: 5,
|
||||
onVaultUpdated,
|
||||
onSyncUpdated,
|
||||
onConflict,
|
||||
onToast,
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSyncUpdated).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call onSyncUpdated when pull is up_to_date', async () => {
|
||||
const onSyncUpdated = vi.fn()
|
||||
renderHook(() =>
|
||||
useAutoSync({
|
||||
vaultPath: '/Users/luca/Laputa',
|
||||
intervalMinutes: 5,
|
||||
onVaultUpdated,
|
||||
onSyncUpdated,
|
||||
onConflict,
|
||||
onToast,
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onVaultUpdated).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(onSyncUpdated).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('detects conflicts when git_pull returns error with unresolved conflicts', async () => {
|
||||
mockInvokeFn.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'get_conflict_files') return Promise.resolve(['conflict.md'])
|
||||
|
||||
@@ -13,6 +13,7 @@ interface UseAutoSyncOptions {
|
||||
vaultPath: string
|
||||
intervalMinutes: number | null
|
||||
onVaultUpdated: () => void
|
||||
onSyncUpdated?: () => void
|
||||
onConflict: (files: string[]) => void
|
||||
onToast: (msg: string) => void
|
||||
}
|
||||
@@ -33,6 +34,7 @@ export function useAutoSync({
|
||||
vaultPath,
|
||||
intervalMinutes,
|
||||
onVaultUpdated,
|
||||
onSyncUpdated,
|
||||
onConflict,
|
||||
onToast,
|
||||
}: UseAutoSyncOptions): AutoSyncState {
|
||||
@@ -42,8 +44,8 @@ export function useAutoSync({
|
||||
const [lastCommitInfo, setLastCommitInfo] = useState<LastCommitInfo | null>(null)
|
||||
const syncingRef = useRef(false)
|
||||
const pauseRef = useRef(false)
|
||||
const callbacksRef = useRef({ onVaultUpdated, onConflict, onToast })
|
||||
callbacksRef.current = { onVaultUpdated, onConflict, onToast }
|
||||
const callbacksRef = useRef({ onVaultUpdated, onSyncUpdated, onConflict, onToast })
|
||||
callbacksRef.current = { onVaultUpdated, onSyncUpdated, onConflict, onToast }
|
||||
|
||||
/** Check for pre-existing conflicts (e.g. from a prior session or interrupted rebase). */
|
||||
const checkExistingConflicts = useCallback(async (): Promise<boolean> => {
|
||||
@@ -77,6 +79,7 @@ export function useAutoSync({
|
||||
setSyncStatus('idle')
|
||||
setConflictFiles([])
|
||||
callbacksRef.current.onVaultUpdated()
|
||||
callbacksRef.current.onSyncUpdated?.()
|
||||
callbacksRef.current.onToast(`Pulled ${result.updatedFiles.length} update(s) from remote`)
|
||||
} else if (result.status === 'conflict') {
|
||||
setSyncStatus('conflict')
|
||||
|
||||
@@ -95,6 +95,46 @@ describe('useCommandRegistry', () => {
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('includes reindex-vault command in Settings group', () => {
|
||||
const config = makeConfig({ onReindexVault: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'reindex-vault')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.group).toBe('Settings')
|
||||
expect(cmd!.label).toBe('Reindex Vault')
|
||||
})
|
||||
|
||||
it('reindex-vault is enabled when onReindexVault is provided', () => {
|
||||
const config = makeConfig({ onReindexVault: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'reindex-vault')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('reindex-vault is disabled when onReindexVault is not provided', () => {
|
||||
const config = makeConfig()
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'reindex-vault')
|
||||
expect(cmd!.enabled).toBe(false)
|
||||
})
|
||||
|
||||
it('reindex-vault executes onReindexVault callback', () => {
|
||||
const onReindexVault = vi.fn()
|
||||
const config = makeConfig({ onReindexVault })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'reindex-vault')
|
||||
cmd!.execute()
|
||||
expect(onReindexVault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reindex-vault has searchable keywords', () => {
|
||||
const config = makeConfig({ onReindexVault: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'reindex-vault')
|
||||
expect(cmd!.keywords).toContain('reindex')
|
||||
expect(cmd!.keywords).toContain('search')
|
||||
})
|
||||
|
||||
it('resolve-conflicts stays enabled across rerenders', () => {
|
||||
const config = makeConfig()
|
||||
const { result, rerender } = renderHook(
|
||||
@@ -157,6 +197,64 @@ describe('groupSortKey', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('install-mcp command', () => {
|
||||
it('is enabled when mcpStatus is not_installed and handler provided', () => {
|
||||
const config = makeConfig({ mcpStatus: 'not_installed', onInstallMcp: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'install-mcp')
|
||||
expect(cmd).toBeDefined()
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
expect(cmd!.label).toBe('Install MCP Server')
|
||||
})
|
||||
|
||||
it('is enabled when mcpStatus is installed and handler provided (restore use case)', () => {
|
||||
const config = makeConfig({ mcpStatus: 'installed', onInstallMcp: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'install-mcp')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
expect(cmd!.label).toBe('Restore MCP Server')
|
||||
})
|
||||
|
||||
it('is enabled even when mcpStatus is checking', () => {
|
||||
const config = makeConfig({ mcpStatus: 'checking', onInstallMcp: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'install-mcp')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('is enabled even when no handler provided', () => {
|
||||
const config = makeConfig({ mcpStatus: 'not_installed' })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'install-mcp')
|
||||
expect(cmd!.enabled).toBe(true)
|
||||
})
|
||||
|
||||
it('has restore keyword for discoverability', () => {
|
||||
const config = makeConfig({ mcpStatus: 'installed', onInstallMcp: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'install-mcp')
|
||||
expect(cmd!.keywords).toContain('restore')
|
||||
expect(cmd!.keywords).toContain('mcp')
|
||||
expect(cmd!.keywords).toContain('claude')
|
||||
})
|
||||
|
||||
it('executes onInstallMcp callback', () => {
|
||||
const onInstallMcp = vi.fn()
|
||||
const config = makeConfig({ mcpStatus: 'installed', onInstallMcp })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'install-mcp')
|
||||
cmd!.execute()
|
||||
expect(onInstallMcp).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is in Settings group', () => {
|
||||
const config = makeConfig({ mcpStatus: 'installed', onInstallMcp: vi.fn() })
|
||||
const { result } = renderHook(() => useCommandRegistry(config))
|
||||
const cmd = findCommand(result.current, 'install-mcp')
|
||||
expect(cmd!.group).toBe('Settings')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTypeCommands', () => {
|
||||
it('creates new and list commands for each type', () => {
|
||||
const onCreateNoteOfType = vi.fn()
|
||||
|
||||
@@ -20,6 +20,8 @@ interface CommandRegistryConfig {
|
||||
modifiedCount: number
|
||||
mcpStatus?: string
|
||||
onInstallMcp?: () => void
|
||||
onReindexVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
|
||||
onQuickOpen: () => void
|
||||
onCreateNote: () => void
|
||||
@@ -196,6 +198,8 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
onCreateType,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, onRestoreDefaultThemes, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp,
|
||||
onReindexVault,
|
||||
onRepairVault,
|
||||
} = config
|
||||
|
||||
const hasActiveNote = activeTabPath !== null
|
||||
@@ -214,7 +218,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
// Navigation
|
||||
{ id: 'search-notes', label: 'Search Notes', group: 'Navigation', shortcut: '⌘P', 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-favorites', label: 'Go to Favorites', group: 'Navigation', keywords: ['starred'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'favorites' }) },
|
||||
{ id: 'go-archived', label: 'Go to Archived', group: 'Navigation', keywords: [], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'archived' }) },
|
||||
{ id: 'go-trash', label: 'Go to Trash', group: 'Navigation', keywords: ['deleted'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'trash' }) },
|
||||
{ id: 'go-changes', label: 'Go to Changes', group: 'Navigation', keywords: ['git', 'modified', 'pending'], enabled: true, execute: () => onSelect({ kind: 'filter', filter: 'changes' }) },
|
||||
@@ -257,7 +260,9 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
{ 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?.() },
|
||||
{ id: 'check-updates', label: 'Check for Updates', group: 'Settings', keywords: ['update', 'version', 'upgrade', 'release'], enabled: true, execute: () => onCheckForUpdates?.() },
|
||||
{ id: 'install-mcp', label: 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install'], enabled: mcpStatus === 'not_installed' && !!onInstallMcp, execute: () => onInstallMcp?.() },
|
||||
{ id: 'install-mcp', label: mcpStatus === 'installed' ? 'Restore MCP Server' : 'Install MCP Server', group: 'Settings', keywords: ['mcp', 'claude', 'ai', 'tools', 'install', 'restore', 'fix', 'repair'], enabled: true, execute: () => onInstallMcp?.() },
|
||||
{ id: 'reindex-vault', label: 'Reindex Vault', group: 'Settings', keywords: ['reindex', 'index', 'search', 'rebuild', 'refresh'], enabled: !!onReindexVault, execute: () => onReindexVault?.() },
|
||||
{ id: 'repair-vault', label: 'Repair Vault', group: 'Settings', keywords: ['repair', 'fix', 'restore', 'config', 'agents', 'themes', 'missing', 'reset'], enabled: !!onRepairVault, execute: () => onRepairVault?.() },
|
||||
|
||||
// Type-aware: "New [Type]" and "List [Type]"
|
||||
...buildTypeCommands(vaultTypes, onCreateNoteOfType, onSelect),
|
||||
@@ -276,5 +281,6 @@ export function useCommandRegistry(config: CommandRegistryConfig): CommandAction
|
||||
vaultTypes, themes, activeThemeId, onSwitchTheme, onCreateTheme, onOpenTheme, onRestoreDefaultThemes,
|
||||
onRemoveActiveVault, onRestoreGettingStarted, isGettingStartedHidden, vaultCount,
|
||||
mcpStatus, onInstallMcp,
|
||||
onReindexVault, onRepairVault,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
order: null,
|
||||
template: null, sort: null,
|
||||
order: null, sidebarLabel: null,
|
||||
template: null, sort: null, view: null, visible: null,
|
||||
outgoingLinks: [],
|
||||
properties: {},
|
||||
...overrides,
|
||||
@@ -50,10 +50,13 @@ describe('useEntryActions', () => {
|
||||
handleDeleteProperty,
|
||||
setToastMessage,
|
||||
createTypeEntry,
|
||||
onFrontmatterPersisted,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const onFrontmatterPersisted = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
@@ -73,6 +76,7 @@ describe('useEntryActions', () => {
|
||||
trashedAt: expect.any(Number),
|
||||
})
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note moved to trash')
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -91,6 +95,7 @@ describe('useEntryActions', () => {
|
||||
trashedAt: null,
|
||||
})
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note restored from trash')
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -105,6 +110,7 @@ describe('useEntryActions', () => {
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'archived', true)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { archived: true })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note archived')
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -119,6 +125,7 @@ describe('useEntryActions', () => {
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/note/test.md', 'archived', false)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/note/test.md', { archived: false })
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Note unarchived')
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -134,6 +141,7 @@ describe('useEntryActions', () => {
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'icon', 'cooking-pot')
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'color', 'green')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { icon: 'cooking-pot', color: 'green' })
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('auto-creates type entry when not found and applies customization', async () => {
|
||||
@@ -167,50 +175,52 @@ describe('useEntryActions', () => {
|
||||
})
|
||||
|
||||
describe('handleUpdateTypeTemplate', () => {
|
||||
it('updates template on the type entry', () => {
|
||||
it('updates template on the type entry', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
act(() => {
|
||||
result.current.handleUpdateTypeTemplate('Project', '## Objective\n\n## Notes')
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateTypeTemplate('Project', '## Objective\n\n## Notes')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '## Objective\n\n## Notes')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: '## Objective\n\n## Notes' })
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets template to null when empty string', () => {
|
||||
it('sets template to null when empty string', async () => {
|
||||
const typeEntry = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
|
||||
const { result } = setup([typeEntry])
|
||||
|
||||
act(() => {
|
||||
result.current.handleUpdateTypeTemplate('Project', '')
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateTypeTemplate('Project', '')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'template', '')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { template: null })
|
||||
})
|
||||
|
||||
it('does nothing when type entry not found', () => {
|
||||
it('auto-creates type entry when not found', async () => {
|
||||
const { result } = setup([])
|
||||
|
||||
act(() => {
|
||||
result.current.handleUpdateTypeTemplate('NonExistent', '## Template')
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateTypeTemplate('NonExistent', '## Template')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
expect(createTypeEntry).toHaveBeenCalledWith('NonExistent')
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/nonexistent.md', 'template', '## Template')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/nonexistent.md', { template: '## Template' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleReorderSections', () => {
|
||||
it('updates order on multiple type entries', () => {
|
||||
it('updates order on multiple type entries', async () => {
|
||||
const typeA = makeEntry({ isA: 'Type', title: 'Note', path: '/vault/type/note.md' })
|
||||
const typeB = makeEntry({ isA: 'Type', title: 'Project', path: '/vault/type/project.md' })
|
||||
const { result } = setup([typeA, typeB])
|
||||
|
||||
act(() => {
|
||||
result.current.handleReorderSections([
|
||||
await act(async () => {
|
||||
await result.current.handleReorderSections([
|
||||
{ typeName: 'Note', order: 0 },
|
||||
{ typeName: 'Project', order: 1 },
|
||||
])
|
||||
@@ -220,23 +230,24 @@ describe('useEntryActions', () => {
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/project.md', 'order', 1)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/note.md', { order: 0 })
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/project.md', { order: 1 })
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips types that are not found', () => {
|
||||
it('auto-creates type entries when not found', async () => {
|
||||
const typeA = makeEntry({ isA: 'Type', title: 'Note', path: '/vault/type/note.md' })
|
||||
const { result } = setup([typeA])
|
||||
|
||||
act(() => {
|
||||
result.current.handleReorderSections([
|
||||
await act(async () => {
|
||||
await result.current.handleReorderSections([
|
||||
{ typeName: 'Note', order: 0 },
|
||||
{ typeName: 'Missing', order: 1 },
|
||||
])
|
||||
})
|
||||
|
||||
// Only Note's order was set; Missing was skipped
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledTimes(1)
|
||||
expect(createTypeEntry).toHaveBeenCalledWith('Missing')
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledTimes(2)
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/note.md', 'order', 0)
|
||||
expect(updateEntry).toHaveBeenCalledTimes(1)
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/missing.md', 'order', 1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -251,6 +262,7 @@ describe('useEntryActions', () => {
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label', 'Recipes')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: 'Recipes' })
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('trims whitespace before saving', async () => {
|
||||
@@ -278,15 +290,16 @@ describe('useEntryActions', () => {
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when type entry not found', async () => {
|
||||
it('auto-creates type entry when not found', async () => {
|
||||
const { result } = setup([])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRenameSection('NonExistent', 'Label')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).not.toHaveBeenCalled()
|
||||
expect(updateEntry).not.toHaveBeenCalled()
|
||||
expect(createTypeEntry).toHaveBeenCalledWith('NonExistent')
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/nonexistent.md', 'sidebar label', 'Label')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/nonexistent.md', { sidebarLabel: 'Label' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -301,6 +314,7 @@ describe('useEntryActions', () => {
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/journal.md', 'visible', false)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: false })
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sets visible to true (deletes property) when currently hidden', async () => {
|
||||
@@ -313,6 +327,7 @@ describe('useEntryActions', () => {
|
||||
|
||||
expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/type/journal.md', 'visible')
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/type/journal.md', { visible: null })
|
||||
expect(onFrontmatterPersisted).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('auto-creates type entry when not found', async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ interface EntryActionsConfig {
|
||||
handleDeleteProperty: (path: string, key: string) => Promise<void>
|
||||
setToastMessage: (msg: string | null) => void
|
||||
createTypeEntry: (typeName: string) => Promise<VaultEntry>
|
||||
onFrontmatterPersisted?: () => void
|
||||
}
|
||||
|
||||
function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | undefined {
|
||||
@@ -15,7 +16,7 @@ function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | un
|
||||
}
|
||||
|
||||
export function useEntryActions({
|
||||
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry,
|
||||
entries, updateEntry, handleUpdateFrontmatter, handleDeleteProperty, setToastMessage, createTypeEntry, onFrontmatterPersisted,
|
||||
}: EntryActionsConfig) {
|
||||
const handleTrashNote = useCallback(async (path: string) => {
|
||||
const now = new Date().toISOString().slice(0, 10)
|
||||
@@ -23,26 +24,30 @@ export function useEntryActions({
|
||||
await handleUpdateFrontmatter(path, 'Trashed at', now)
|
||||
updateEntry(path, { trashed: true, trashedAt: Date.now() / 1000 })
|
||||
setToastMessage('Note moved to trash')
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleRestoreNote = useCallback(async (path: string) => {
|
||||
await handleUpdateFrontmatter(path, 'Trashed', false)
|
||||
await handleDeleteProperty(path, 'Trashed at')
|
||||
updateEntry(path, { trashed: false, trashedAt: null })
|
||||
setToastMessage('Note restored from trash')
|
||||
}, [handleUpdateFrontmatter, handleDeleteProperty, updateEntry, setToastMessage])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [handleUpdateFrontmatter, handleDeleteProperty, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleArchiveNote = useCallback(async (path: string) => {
|
||||
await handleUpdateFrontmatter(path, 'archived', true)
|
||||
updateEntry(path, { archived: true })
|
||||
setToastMessage('Note archived')
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleUnarchiveNote = useCallback(async (path: string) => {
|
||||
await handleUpdateFrontmatter(path, 'archived', false)
|
||||
updateEntry(path, { archived: false })
|
||||
setToastMessage('Note unarchived')
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [handleUpdateFrontmatter, updateEntry, setToastMessage, onFrontmatterPersisted])
|
||||
|
||||
const handleCustomizeType = useCallback(async (typeName: string, icon: string, color: string) => {
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
@@ -50,27 +55,30 @@ export function useEntryActions({
|
||||
updateEntry(typeEntry.path, { icon, color })
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'icon', icon)
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'color', color)
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
|
||||
|
||||
const handleReorderSections = useCallback((orderedTypes: { typeName: string; order: number }[]) => {
|
||||
const handleReorderSections = useCallback(async (orderedTypes: { typeName: string; order: number }[]) => {
|
||||
for (const { typeName, order } of orderedTypes) {
|
||||
const typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) continue
|
||||
handleUpdateFrontmatter(typeEntry.path, 'order', order)
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'order', order)
|
||||
updateEntry(typeEntry.path, { order })
|
||||
}
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
|
||||
|
||||
const handleUpdateTypeTemplate = useCallback((typeName: string, template: string) => {
|
||||
const typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) return
|
||||
handleUpdateFrontmatter(typeEntry.path, 'template', template)
|
||||
const handleUpdateTypeTemplate = useCallback(async (typeName: string, template: string) => {
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'template', template)
|
||||
updateEntry(typeEntry.path, { template: template || null })
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [entries, handleUpdateFrontmatter, updateEntry, createTypeEntry, onFrontmatterPersisted])
|
||||
|
||||
const handleRenameSection = useCallback(async (typeName: string, label: string) => {
|
||||
const typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) return
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
if (!typeEntry) typeEntry = await createTypeEntry(typeName)
|
||||
const trimmed = label.trim()
|
||||
updateEntry(typeEntry.path, { sidebarLabel: trimmed || null })
|
||||
if (trimmed) {
|
||||
@@ -78,7 +86,8 @@ export function useEntryActions({
|
||||
} else {
|
||||
await handleDeleteProperty(typeEntry.path, 'sidebar label')
|
||||
}
|
||||
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted])
|
||||
|
||||
const handleToggleTypeVisibility = useCallback(async (typeName: string) => {
|
||||
let typeEntry = findTypeEntry(entries, typeName)
|
||||
@@ -90,7 +99,8 @@ export function useEntryActions({
|
||||
updateEntry(typeEntry.path, { visible: false })
|
||||
await handleUpdateFrontmatter(typeEntry.path, 'visible', false)
|
||||
}
|
||||
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry])
|
||||
onFrontmatterPersisted?.()
|
||||
}, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry, createTypeEntry, onFrontmatterPersisted])
|
||||
|
||||
return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection, handleToggleTypeVisibility }
|
||||
}
|
||||
|
||||
@@ -84,4 +84,62 @@ describe('useIndexing', () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
expect(typeof result.current.retryIndexing).toBe('function')
|
||||
})
|
||||
|
||||
it('exposes triggerFullReindex function', () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
expect(typeof result.current.triggerFullReindex).toBe('function')
|
||||
})
|
||||
|
||||
it('retryIndexing is the same reference as triggerFullReindex', () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
expect(result.current.retryIndexing).toBe(result.current.triggerFullReindex)
|
||||
})
|
||||
|
||||
it('triggerFullReindex sets scanning phase then completes', async () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
|
||||
await act(async () => { await result.current.triggerFullReindex() })
|
||||
// In non-Tauri mode, it goes to 'complete' then auto-dismisses
|
||||
expect(result.current.progress.phase).toBe('complete')
|
||||
})
|
||||
|
||||
it('triggerFullReindex sets lastIndexedTime on success', async () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
|
||||
expect(result.current.lastIndexedTime).toBeNull()
|
||||
await act(async () => { await result.current.triggerFullReindex() })
|
||||
expect(result.current.lastIndexedTime).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('triggerFullReindex sets error phase on failure', async () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
|
||||
mockInvoke.mockRejectedValueOnce(new Error('indexing failed'))
|
||||
await act(async () => { await result.current.triggerFullReindex() })
|
||||
expect(result.current.progress.phase).toBe('error')
|
||||
})
|
||||
|
||||
it('populates lastIndexedTime from backend metadata on mount', async () => {
|
||||
mockInvoke.mockResolvedValue({
|
||||
available: true,
|
||||
qmd_installed: true,
|
||||
collection_exists: true,
|
||||
indexed_count: 100,
|
||||
embedded_count: 80,
|
||||
pending_embed: 0,
|
||||
last_indexed_commit: 'abc123',
|
||||
last_indexed_at: 1709800000,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
|
||||
// Wait for the effect to run
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(10) })
|
||||
expect(result.current.lastIndexedTime).toBe(1709800000000) // seconds → ms
|
||||
})
|
||||
|
||||
it('starts with lastIndexedTime as null', () => {
|
||||
const { result } = renderHook(() => useIndexing('/test/vault'))
|
||||
expect(result.current.lastIndexedTime).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -17,6 +17,8 @@ interface IndexStatus {
|
||||
indexed_count: number
|
||||
embedded_count: number
|
||||
pending_embed: number
|
||||
last_indexed_commit: string | null
|
||||
last_indexed_at: number | null
|
||||
}
|
||||
|
||||
const IDLE: IndexingProgress = { phase: 'idle', current: 0, total: 0, done: false, error: null }
|
||||
@@ -27,6 +29,7 @@ function invokeCmd<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
|
||||
export function useIndexing(vaultPath: string) {
|
||||
const [progress, setProgress] = useState<IndexingProgress>(IDLE)
|
||||
const [lastIndexedTime, setLastIndexedTime] = useState<number | null>(null)
|
||||
const indexingRef = useRef(false)
|
||||
const vaultPathRef = useRef(vaultPath)
|
||||
|
||||
@@ -43,6 +46,9 @@ export function useIndexing(vaultPath: string) {
|
||||
setProgress(event.payload)
|
||||
if (event.payload.done) {
|
||||
indexingRef.current = false
|
||||
if (event.payload.phase === 'complete') {
|
||||
setLastIndexedTime(Date.now())
|
||||
}
|
||||
}
|
||||
})
|
||||
cleanup = () => { unlisten.then(fn => fn()) }
|
||||
@@ -61,6 +67,11 @@ export function useIndexing(vaultPath: string) {
|
||||
const status = await invokeCmd<IndexStatus>('get_index_status', { vaultPath })
|
||||
if (cancelled) return
|
||||
|
||||
// Populate last indexed time from backend metadata
|
||||
if (status.last_indexed_at) {
|
||||
setLastIndexedTime(status.last_indexed_at * 1000) // seconds → ms
|
||||
}
|
||||
|
||||
// If qmd not installed or no collection or pending embeds, trigger indexing
|
||||
const needsIndexing = !status.qmd_installed || !status.collection_exists || status.pending_embed > 0
|
||||
if (needsIndexing && !indexingRef.current) {
|
||||
@@ -116,17 +127,23 @@ export function useIndexing(vaultPath: string) {
|
||||
if (indexingRef.current) return
|
||||
try {
|
||||
await invokeCmd('trigger_incremental_index', { vaultPath: vaultPathRef.current })
|
||||
setLastIndexedTime(Date.now())
|
||||
} catch {
|
||||
// Incremental update failure is non-fatal
|
||||
}
|
||||
}, [])
|
||||
|
||||
const retryIndexing = useCallback(async () => {
|
||||
const triggerFullReindex = useCallback(async () => {
|
||||
if (indexingRef.current || !vaultPathRef.current) return
|
||||
indexingRef.current = true
|
||||
setProgress({ phase: 'scanning', current: 0, total: 0, done: false, error: null })
|
||||
try {
|
||||
await invokeCmd('start_indexing', { vaultPath: vaultPathRef.current })
|
||||
// In non-Tauri mode, mark complete immediately
|
||||
if (!isTauri()) {
|
||||
setProgress({ phase: 'complete', current: 0, total: 0, done: true, error: null })
|
||||
setLastIndexedTime(Date.now())
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = String(err)
|
||||
const isUnavailable = msg.includes('not installed') || msg.includes('not available')
|
||||
@@ -136,5 +153,7 @@ export function useIndexing(vaultPath: string) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { progress, triggerIncrementalIndex, retryIndexing }
|
||||
const retryIndexing = triggerFullReindex
|
||||
|
||||
return { progress, lastIndexedTime, triggerIncrementalIndex, triggerFullReindex, retryIndexing }
|
||||
}
|
||||
|
||||
@@ -66,9 +66,10 @@ describe('useMcpStatus', () => {
|
||||
})
|
||||
|
||||
it('install action calls register_mcp_tools and updates status', async () => {
|
||||
// Auto-register fails (e.g. node not found), leaving status as not_installed
|
||||
mockInvoke.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'check_mcp_status') return Promise.resolve('not_installed')
|
||||
if (cmd === 'register_mcp_tools') return Promise.resolve('registered')
|
||||
if (cmd === 'register_mcp_tools') return Promise.reject(new Error('no node'))
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
@@ -76,12 +77,11 @@ describe('useMcpStatus', () => {
|
||||
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.mcpStatus).toBe('installed')
|
||||
expect(result.current.mcpStatus).toBe('not_installed')
|
||||
})
|
||||
|
||||
// Reset to test install action directly
|
||||
// Now manual install succeeds
|
||||
mockInvoke.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'check_mcp_status') return Promise.resolve('not_installed')
|
||||
if (cmd === 'register_mcp_tools') return Promise.resolve('registered')
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
@@ -131,6 +131,33 @@ describe('useMcpStatus', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('install action shows restored toast when status was installed', async () => {
|
||||
// First call: status already installed
|
||||
mockInvoke.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'check_mcp_status') return Promise.resolve('installed')
|
||||
if (cmd === 'register_mcp_tools') return Promise.resolve('updated')
|
||||
return Promise.resolve(null)
|
||||
})
|
||||
|
||||
const onToast = vi.fn()
|
||||
const { result } = renderHook(() => useMcpStatus('/vault', onToast))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.mcpStatus).toBe('installed')
|
||||
})
|
||||
|
||||
// Clear toasts from auto-register
|
||||
onToast.mockClear()
|
||||
|
||||
// Now manually trigger install (restore)
|
||||
await act(async () => {
|
||||
await result.current.installMcp()
|
||||
})
|
||||
|
||||
expect(result.current.mcpStatus).toBe('installed')
|
||||
expect(onToast).toHaveBeenCalledWith('MCP server restored successfully')
|
||||
})
|
||||
|
||||
it('does not show toast when already registered', async () => {
|
||||
mockInvoke.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'check_mcp_status') return Promise.resolve('installed')
|
||||
|
||||
@@ -19,9 +19,11 @@ export function useMcpStatus(
|
||||
onToast: (msg: string) => void,
|
||||
) {
|
||||
const [status, setStatus] = useState<McpStatus>('checking')
|
||||
const statusRef = useRef<McpStatus>(status)
|
||||
const registeredRef = useRef<string | null>(null)
|
||||
const onToastRef = useRef(onToast)
|
||||
useEffect(() => { onToastRef.current = onToast })
|
||||
useEffect(() => { statusRef.current = status }, [status])
|
||||
|
||||
// Check MCP status on vault open / vault switch
|
||||
useEffect(() => {
|
||||
@@ -57,11 +59,12 @@ export function useMcpStatus(
|
||||
}, [vaultPath])
|
||||
|
||||
const install = useCallback(async () => {
|
||||
const wasInstalled = statusRef.current === 'installed'
|
||||
setStatus('checking')
|
||||
try {
|
||||
await tauriCall<string>('register_mcp_tools', { vaultPath })
|
||||
setStatus('installed')
|
||||
onToastRef.current('MCP server installed successfully')
|
||||
onToastRef.current(wasInstalled ? 'MCP server restored successfully' : 'MCP server installed successfully')
|
||||
} catch (e) {
|
||||
setStatus('not_installed')
|
||||
onToastRef.current(`MCP install failed: ${e}`)
|
||||
|
||||
@@ -34,6 +34,7 @@ function makeHandlers(): MenuEventHandlers {
|
||||
onResolveConflicts: vi.fn(),
|
||||
onViewChanges: vi.fn(),
|
||||
onInstallMcp: vi.fn(),
|
||||
onReindexVault: vi.fn(),
|
||||
activeTabPathRef: { current: '/vault/test.md' } as React.MutableRefObject<string | null>,
|
||||
handleCloseTabRef: { current: vi.fn() } as React.MutableRefObject<(path: string) => void>,
|
||||
activeTabPath: '/vault/test.md',
|
||||
@@ -226,12 +227,6 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onSelectFilter).toHaveBeenCalledWith('all')
|
||||
})
|
||||
|
||||
it('go-favorites selects favorites filter', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('go-favorites', h)
|
||||
expect(h.onSelectFilter).toHaveBeenCalledWith('favorites')
|
||||
})
|
||||
|
||||
it('go-archived selects archived filter', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('go-archived', h)
|
||||
@@ -305,6 +300,12 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onInstallMcp).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('vault-reindex triggers reindex vault', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('vault-reindex', h)
|
||||
expect(h.onReindexVault).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Edge cases
|
||||
it('unknown event ID does nothing', () => {
|
||||
const h = makeHandlers()
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface MenuEventHandlers {
|
||||
onResolveConflicts?: () => void
|
||||
onViewChanges?: () => void
|
||||
onInstallMcp?: () => void
|
||||
onReindexVault?: () => void
|
||||
onRepairVault?: () => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
|
||||
activeTabPath: string | null
|
||||
@@ -67,7 +69,6 @@ const SIMPLE_EVENT_MAP: Record<string, SimpleHandler> = {
|
||||
|
||||
const FILTER_MAP: Record<string, SidebarFilter> = {
|
||||
'go-all-notes': 'all',
|
||||
'go-favorites': 'favorites',
|
||||
'go-archived': 'archived',
|
||||
'go-trash': 'trash',
|
||||
'go-changes': 'changes',
|
||||
@@ -78,7 +79,7 @@ type OptionalHandler =
|
||||
| 'onCreateType' | 'onToggleRawEditor' | 'onToggleDiff' | 'onToggleAIChat'
|
||||
| 'onOpenVault' | 'onRemoveActiveVault' | 'onRestoreGettingStarted'
|
||||
| 'onCreateTheme' | 'onRestoreDefaultThemes'
|
||||
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp'
|
||||
| 'onCommitPush' | 'onResolveConflicts' | 'onViewChanges' | 'onInstallMcp' | 'onReindexVault' | 'onRepairVault'
|
||||
|
||||
const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
'view-go-back': 'onGoBack',
|
||||
@@ -97,6 +98,8 @@ const OPTIONAL_EVENT_MAP: Record<string, OptionalHandler> = {
|
||||
'vault-resolve-conflicts': 'onResolveConflicts',
|
||||
'vault-view-changes': 'onViewChanges',
|
||||
'vault-install-mcp': 'onInstallMcp',
|
||||
'vault-reindex': 'onReindexVault',
|
||||
'vault-repair': 'onRepairVault',
|
||||
}
|
||||
|
||||
function dispatchActiveTabEvent(id: string, h: MenuEventHandlers): boolean {
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock('../mock-tauri', () => ({
|
||||
isTauri: vi.fn(() => false),
|
||||
addMockEntry: vi.fn(),
|
||||
updateMockContent: vi.fn(),
|
||||
trackMockChange: vi.fn(),
|
||||
mockInvoke: vi.fn().mockResolvedValue(''),
|
||||
}))
|
||||
vi.mock('./mockFrontmatterHelpers', () => ({
|
||||
@@ -289,6 +290,8 @@ describe('frontmatterToEntryPatch', () => {
|
||||
['trashed', true, { trashed: true }],
|
||||
['order', 5, { order: 5 }],
|
||||
['template', '## Heading\n\n', { template: '## Heading\n\n' }],
|
||||
['visible', false, { visible: false }],
|
||||
['visible', true, { visible: null }],
|
||||
] as [string, unknown, Partial<VaultEntry>][])(
|
||||
'maps %s update to correct entry field',
|
||||
(key, value, expected) => {
|
||||
@@ -320,6 +323,7 @@ describe('frontmatterToEntryPatch', () => {
|
||||
['archived', { archived: false }],
|
||||
['order', { order: null }],
|
||||
['template', { template: null }],
|
||||
['visible', { visible: null }],
|
||||
] as [string, Partial<VaultEntry>][])(
|
||||
'maps delete of %s to null/default',
|
||||
(key, expected) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke, addMockEntry, updateMockContent } from '../mock-tauri'
|
||||
import { isTauri, mockInvoke, addMockEntry, updateMockContent, trackMockChange } from '../mock-tauri'
|
||||
import type { VaultEntry } from '../types'
|
||||
import type { FrontmatterValue } from '../components/Inspector'
|
||||
import { useTabManagement } from './useTabManagement'
|
||||
@@ -111,12 +111,14 @@ async function invokeFrontmatter(command: string, args: Record<string, unknown>)
|
||||
function applyMockFrontmatterUpdate(path: string, key: string, value: FrontmatterValue): string {
|
||||
const content = updateMockFrontmatter(path, key, value)
|
||||
updateMockContent(path, content)
|
||||
trackMockChange(path)
|
||||
return content
|
||||
}
|
||||
|
||||
function applyMockFrontmatterDelete(path: string, key: string): string {
|
||||
const content = deleteMockFrontmatterProperty(path, key)
|
||||
updateMockContent(path, content)
|
||||
trackMockChange(path)
|
||||
return content
|
||||
}
|
||||
|
||||
@@ -148,7 +150,7 @@ const ENTRY_DELETE_MAP: Record<string, Partial<VaultEntry>> = {
|
||||
icon: { icon: null }, owner: { owner: null }, cadence: { cadence: null },
|
||||
aliases: { aliases: [] }, belongs_to: { belongsTo: [] }, related_to: { relatedTo: [] },
|
||||
archived: { archived: false }, trashed: { trashed: false }, order: { order: null },
|
||||
template: { template: null }, sort: { sort: null },
|
||||
template: { template: null }, sort: { sort: null }, visible: { visible: null },
|
||||
}
|
||||
|
||||
/** Map a frontmatter key+value to the corresponding VaultEntry field(s). */
|
||||
@@ -168,6 +170,7 @@ export function frontmatterToEntryPatch(
|
||||
template: { template: str },
|
||||
sort: { sort: str },
|
||||
view: { view: str },
|
||||
visible: { visible: value === false ? false : null },
|
||||
}
|
||||
return updates[k] ?? {}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ function defaultMockInvoke(cmd: string, args?: Record<string, unknown>) {
|
||||
if (cmd === 'get_file_diff') return Promise.resolve('--- a/note.md\n+++ b/note.md')
|
||||
if (cmd === 'get_file_diff_at_commit') return Promise.resolve(`diff for ${(args as Record<string, string>)?.commitHash}`)
|
||||
if (cmd === 'git_commit') return Promise.resolve('committed')
|
||||
if (cmd === 'git_push') return Promise.resolve('pushed')
|
||||
if (cmd === 'git_push') return Promise.resolve({ status: 'ok', message: 'Pushed to remote' })
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
@@ -382,6 +382,49 @@ describe('useVaultLoader', () => {
|
||||
|
||||
expect(response).toBe('Committed and pushed')
|
||||
})
|
||||
|
||||
it('returns actionable message when push is rejected', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'git_commit') return Promise.resolve('committed')
|
||||
if (cmd === 'git_push') return Promise.resolve({ status: 'rejected', message: 'Push rejected: remote has new commits. Pull first, then push.' })
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
|
||||
|
||||
let response = ''
|
||||
await act(async () => {
|
||||
response = await result.current.commitAndPush('test commit')
|
||||
})
|
||||
|
||||
expect(response).toContain('Pull first')
|
||||
expect(response).not.toBe('Committed and pushed')
|
||||
})
|
||||
|
||||
it('returns network error message on network failure', async () => {
|
||||
mockInvokeFn.mockImplementation(((cmd: string) => {
|
||||
if (cmd === 'list_vault') return Promise.resolve(mockEntries)
|
||||
if (cmd === 'get_all_content') return Promise.resolve(mockContent)
|
||||
if (cmd === 'get_modified_files') return Promise.resolve([])
|
||||
if (cmd === 'git_commit') return Promise.resolve('committed')
|
||||
if (cmd === 'git_push') return Promise.resolve({ status: 'network_error', message: 'Push failed: network error. Check your connection and try again.' })
|
||||
return Promise.resolve(null)
|
||||
}) as typeof defaultMockInvoke)
|
||||
|
||||
const { result } = renderHook(() => useVaultLoader('/vault'))
|
||||
await waitFor(() => { expect(result.current.entries).toHaveLength(1) })
|
||||
|
||||
let response = ''
|
||||
await act(async () => {
|
||||
response = await result.current.commitAndPush('test commit')
|
||||
})
|
||||
|
||||
expect(response).toContain('network error')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadModifiedFiles', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useState, startTransition } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { isTauri, mockInvoke } from '../mock-tauri'
|
||||
import type { VaultEntry, GitCommit, ModifiedFile, NoteStatus } from '../types'
|
||||
import type { VaultEntry, GitCommit, ModifiedFile, NoteStatus, GitPushResult } from '../types'
|
||||
|
||||
function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockArgs?: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, tauriArgs) : mockInvoke<T>(command, mockArgs ?? tauriArgs)
|
||||
@@ -18,16 +18,12 @@ async function loadVaultData(vaultPath: string) {
|
||||
async function commitWithPush(vaultPath: string, message: string): Promise<string> {
|
||||
if (!isTauri()) {
|
||||
await mockInvoke<string>('git_commit', { message })
|
||||
await mockInvoke<string>('git_push', {})
|
||||
return 'Committed and pushed'
|
||||
const pushResult = await mockInvoke<GitPushResult>('git_push', {})
|
||||
return pushResult.status === 'ok' ? 'Committed and pushed' : pushResult.message
|
||||
}
|
||||
await invoke<string>('git_commit', { vaultPath, message })
|
||||
try {
|
||||
await invoke<string>('git_push', { vaultPath })
|
||||
return 'Committed and pushed'
|
||||
} catch {
|
||||
return 'Committed (push failed)'
|
||||
}
|
||||
const pushResult = await invoke<GitPushResult>('git_push', { vaultPath })
|
||||
return pushResult.status === 'ok' ? 'Committed and pushed' : pushResult.message
|
||||
}
|
||||
|
||||
function useNewNoteTracker() {
|
||||
|
||||
@@ -246,6 +246,22 @@
|
||||
}
|
||||
.ai-markdown a:hover { text-decoration: underline; }
|
||||
|
||||
.ai-markdown .chat-wikilink {
|
||||
display: inline;
|
||||
color: var(--primary);
|
||||
background: color-mix(in srgb, var(--primary) 10%, transparent);
|
||||
border-radius: 4px;
|
||||
padding: 0 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
.ai-markdown .chat-wikilink:hover {
|
||||
background: color-mix(in srgb, var(--primary) 20%, transparent);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ai-markdown hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
|
||||
@@ -5,18 +5,19 @@
|
||||
*/
|
||||
|
||||
import { MOCK_CONTENT } from './mock-content'
|
||||
import { mockHandlers, addMockEntry, updateMockContent } from './mock-handlers'
|
||||
import { mockHandlers, addMockEntry, updateMockContent, trackMockChange } from './mock-handlers'
|
||||
import { tryVaultApi } from './vault-api'
|
||||
|
||||
export { addMockEntry, updateMockContent }
|
||||
export { addMockEntry, updateMockContent, trackMockChange }
|
||||
|
||||
export function isTauri(): boolean {
|
||||
return typeof window !== 'undefined' && ('__TAURI__' in window || '__TAURI_INTERNALS__' in window)
|
||||
}
|
||||
|
||||
// Initialize window.__mockContent for browser testing
|
||||
// Initialize window globals for browser testing and Playwright overrides
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__mockContent = MOCK_CONTENT
|
||||
window.__mockHandlers = mockHandlers
|
||||
}
|
||||
|
||||
export async function mockInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Each handler simulates a Tauri backend command.
|
||||
*/
|
||||
|
||||
import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types'
|
||||
import type { VaultEntry, VaultConfig, ModifiedFile, Settings, DeviceFlowStart, DeviceFlowPollResult, GitHubUser, GitPullResult, GitPushResult, LastCommitInfo, ThemeFile, VaultSettings, PulseCommit } from '../types'
|
||||
import { MOCK_CONTENT } from './mock-content'
|
||||
import { MOCK_ENTRIES } from './mock-entries'
|
||||
|
||||
@@ -172,7 +172,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
get_build_number: () => 'bDEV',
|
||||
get_last_commit_info: (): LastCommitInfo => ({ shortHash: 'a1b2c3d', commitUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }),
|
||||
git_pull: (): GitPullResult => ({ status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }),
|
||||
git_push: () => 'Everything up-to-date',
|
||||
git_push: (): GitPushResult => ({ status: 'ok', message: 'Pushed to remote' }),
|
||||
get_vault_pulse: (args: { limit?: number }): PulseCommit[] => {
|
||||
const limit = args.limit ?? 30
|
||||
const ts = Math.floor(Date.now() / 1000)
|
||||
@@ -277,7 +277,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
create_getting_started_vault: () => '/Users/mock/Documents/Getting Started',
|
||||
register_mcp_tools: () => 'registered',
|
||||
check_mcp_status: () => 'installed',
|
||||
get_index_status: () => ({ available: true, qmd_installed: true, collection_exists: true, indexed_count: 100, embedded_count: 80, pending_embed: 0 }),
|
||||
get_index_status: () => ({ available: true, qmd_installed: true, collection_exists: true, indexed_count: 100, embedded_count: 80, pending_embed: 0, last_indexed_commit: 'abc123', last_indexed_at: Math.floor(Date.now() / 1000) - 3600 }),
|
||||
start_indexing: () => null,
|
||||
trigger_incremental_index: () => null,
|
||||
list_themes: (): ThemeFile[] => [...mockThemes],
|
||||
@@ -334,6 +334,7 @@ line-height-base: 1.6
|
||||
},
|
||||
ensure_vault_themes: (): null => null,
|
||||
restore_default_themes: (): string => 'Default themes restored',
|
||||
repair_vault: (): string => 'Vault repaired',
|
||||
get_vault_config: (): VaultConfig => ({ zoom: null, view_mode: null, tag_colors: null, status_colors: null, property_display_modes: null }),
|
||||
save_vault_config: (): null => null,
|
||||
}
|
||||
@@ -347,3 +348,7 @@ export function updateMockContent(path: string, content: string): void {
|
||||
MOCK_CONTENT[path] = content
|
||||
syncWindowContent()
|
||||
}
|
||||
|
||||
export function trackMockChange(path: string): void {
|
||||
mockSavedSinceCommit.add(path)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,11 @@ export interface GitPullResult {
|
||||
conflictFiles: string[]
|
||||
}
|
||||
|
||||
export interface GitPushResult {
|
||||
status: 'ok' | 'rejected' | 'auth_error' | 'network_error' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'conflict'
|
||||
|
||||
export interface DeviceFlowStart {
|
||||
@@ -170,7 +175,7 @@ export interface PulseCommit {
|
||||
deleted: number
|
||||
}
|
||||
|
||||
export type SidebarFilter = 'all' | 'favorites' | 'archived' | 'trash' | 'changes' | 'pulse'
|
||||
export type SidebarFilter = 'all' | 'archived' | 'trash' | 'changes' | 'pulse'
|
||||
|
||||
export type SidebarSelection =
|
||||
| { kind: 'filter'; filter: SidebarFilter }
|
||||
|
||||
@@ -23,6 +23,12 @@ describe('buildAgentSystemPrompt', () => {
|
||||
expect(prompt).toContain('Vault context:')
|
||||
expect(prompt).toContain('Recent notes: foo, bar')
|
||||
})
|
||||
|
||||
it('instructs AI to use wikilink syntax', () => {
|
||||
const prompt = buildAgentSystemPrompt()
|
||||
expect(prompt).toContain('[[')
|
||||
expect(prompt).toMatch(/wikilink/i)
|
||||
})
|
||||
})
|
||||
|
||||
// --- streamClaudeAgent ---
|
||||
@@ -44,7 +50,7 @@ describe('streamClaudeAgent', () => {
|
||||
// Wait for the setTimeout mock response
|
||||
await new Promise(r => setTimeout(r, 400))
|
||||
|
||||
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Claude CLI'))
|
||||
expect(onText).toHaveBeenCalledWith(expect.stringContaining('Build Laputa App'))
|
||||
expect(onDone).toHaveBeenCalled()
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(onToolStart).not.toHaveBeenCalled()
|
||||
|
||||
@@ -17,6 +17,7 @@ You have full shell access. Use bash for file operations, search, bulk edits.
|
||||
Use the provided MCP tools for: full-text search (search_notes), vault orientation (get_vault_context), parsed note reading (get_note), and opening notes in the UI (open_note).
|
||||
|
||||
When you create or edit a note, call open_note(path) so the user sees it in Laputa.
|
||||
When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.
|
||||
Be concise and helpful. When you've completed a task, briefly summarize what you did.`
|
||||
|
||||
export function buildAgentSystemPrompt(vaultContext?: string): string {
|
||||
@@ -57,7 +58,7 @@ export async function streamClaudeAgent(
|
||||
): Promise<void> {
|
||||
if (!isTauri()) {
|
||||
setTimeout(() => {
|
||||
callbacks.onText('AI Agent requires the Claude CLI. Install it and run the native app.')
|
||||
callbacks.onText('This note is related to [[Build Laputa App]] and [[Matteo Cellini]]. The AI Agent requires the Claude CLI for full functionality.')
|
||||
callbacks.onDone()
|
||||
}, 300)
|
||||
return
|
||||
|
||||
@@ -8,6 +8,8 @@ vi.mock('../mock-tauri', () => ({
|
||||
import {
|
||||
estimateTokens, buildSystemPrompt,
|
||||
nextMessageId, checkClaudeCli, streamClaudeChat,
|
||||
trimHistory, formatMessageWithHistory,
|
||||
type ChatMessage, MAX_HISTORY_TOKENS,
|
||||
} from './ai-chat'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
@@ -50,6 +52,14 @@ describe('buildSystemPrompt', () => {
|
||||
expect(result.prompt).toContain('Hello world')
|
||||
expect(result.totalTokens).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('instructs AI to use wikilink syntax', () => {
|
||||
const notes = [makeEntry('/test.md', 'Test Note')]
|
||||
const content = { '/test.md': 'content' }
|
||||
const result = buildSystemPrompt(notes, content)
|
||||
expect(result.prompt).toContain('[[')
|
||||
expect(result.prompt).toMatch(/wikilink/i)
|
||||
})
|
||||
})
|
||||
|
||||
// --- nextMessageId ---
|
||||
@@ -73,6 +83,107 @@ describe('checkClaudeCli', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// --- trimHistory ---
|
||||
|
||||
describe('trimHistory', () => {
|
||||
const msg = (role: 'user' | 'assistant', content: string): ChatMessage => ({
|
||||
role, content, id: `msg-${content}`,
|
||||
})
|
||||
|
||||
it('returns empty array for empty history', () => {
|
||||
expect(trimHistory([], 1000)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns all messages when under token limit', () => {
|
||||
const history = [msg('user', 'hi'), msg('assistant', 'hello')]
|
||||
expect(trimHistory(history, 1000)).toEqual(history)
|
||||
})
|
||||
|
||||
it('drops oldest messages when over token limit', () => {
|
||||
const history = [
|
||||
msg('user', 'a'.repeat(400)), // 100 tokens
|
||||
msg('assistant', 'b'.repeat(400)), // 100 tokens
|
||||
msg('user', 'c'.repeat(400)), // 100 tokens
|
||||
]
|
||||
const result = trimHistory(history, 200)
|
||||
// Should keep the two most recent messages (200 tokens)
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0].content).toBe('b'.repeat(400))
|
||||
expect(result[1].content).toBe('c'.repeat(400))
|
||||
})
|
||||
|
||||
it('keeps at least one message if it fits', () => {
|
||||
const history = [
|
||||
msg('user', 'a'.repeat(2000)), // 500 tokens
|
||||
msg('assistant', 'b'.repeat(80)), // 20 tokens
|
||||
]
|
||||
const result = trimHistory(history, 30)
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].content).toBe('b'.repeat(80))
|
||||
})
|
||||
|
||||
it('returns empty when single message exceeds limit', () => {
|
||||
const history = [msg('user', 'a'.repeat(4000))] // 1000 tokens
|
||||
expect(trimHistory(history, 10)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// --- formatMessageWithHistory ---
|
||||
|
||||
describe('formatMessageWithHistory', () => {
|
||||
const msg = (role: 'user' | 'assistant', content: string): ChatMessage => ({
|
||||
role, content, id: `msg-${content}`,
|
||||
})
|
||||
|
||||
it('returns bare message when no history', () => {
|
||||
expect(formatMessageWithHistory([], 'hello')).toBe('hello')
|
||||
})
|
||||
|
||||
it('includes conversation history before the new message', () => {
|
||||
const history = [msg('user', 'What is Rust?'), msg('assistant', 'A systems language.')]
|
||||
const result = formatMessageWithHistory(history, 'How does it compare to Go?')
|
||||
expect(result).toContain('What is Rust?')
|
||||
expect(result).toContain('A systems language.')
|
||||
expect(result).toContain('How does it compare to Go?')
|
||||
})
|
||||
|
||||
it('labels user and assistant messages correctly', () => {
|
||||
const history = [msg('user', 'Q1'), msg('assistant', 'A1')]
|
||||
const result = formatMessageWithHistory(history, 'Q2')
|
||||
expect(result).toContain('[user]: Q1')
|
||||
expect(result).toContain('[assistant]: A1')
|
||||
expect(result).toContain('[user]: Q2')
|
||||
})
|
||||
|
||||
it('preserves message order', () => {
|
||||
const history = [
|
||||
msg('user', 'first'),
|
||||
msg('assistant', 'second'),
|
||||
msg('user', 'third'),
|
||||
msg('assistant', 'fourth'),
|
||||
]
|
||||
const result = formatMessageWithHistory(history, 'fifth')
|
||||
const firstIdx = result.indexOf('first')
|
||||
const secondIdx = result.indexOf('second')
|
||||
const thirdIdx = result.indexOf('third')
|
||||
const fourthIdx = result.indexOf('fourth')
|
||||
const fifthIdx = result.indexOf('fifth')
|
||||
expect(firstIdx).toBeLessThan(secondIdx)
|
||||
expect(secondIdx).toBeLessThan(thirdIdx)
|
||||
expect(thirdIdx).toBeLessThan(fourthIdx)
|
||||
expect(fourthIdx).toBeLessThan(fifthIdx)
|
||||
})
|
||||
})
|
||||
|
||||
// --- MAX_HISTORY_TOKENS ---
|
||||
|
||||
describe('MAX_HISTORY_TOKENS', () => {
|
||||
it('is a reasonable token limit', () => {
|
||||
expect(MAX_HISTORY_TOKENS).toBeGreaterThan(10_000)
|
||||
expect(MAX_HISTORY_TOKENS).toBeLessThan(200_000)
|
||||
})
|
||||
})
|
||||
|
||||
// --- streamClaudeChat ---
|
||||
|
||||
describe('streamClaudeChat', () => {
|
||||
|
||||
@@ -34,6 +34,7 @@ export function buildSystemPrompt(
|
||||
const preamble = [
|
||||
'You are a helpful AI assistant integrated into Laputa, a personal knowledge management app.',
|
||||
'The user has selected the following notes as context. Use them to answer questions accurately.',
|
||||
'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
@@ -76,6 +77,34 @@ export function nextMessageId(): string {
|
||||
return `msg-${++msgIdCounter}-${Date.now()}`
|
||||
}
|
||||
|
||||
// --- Conversation history ---
|
||||
|
||||
/** Max tokens of history to include in each request. */
|
||||
export const MAX_HISTORY_TOKENS = 100_000
|
||||
|
||||
/** Keep the most recent messages that fit within `maxTokens`. Drops oldest first. */
|
||||
export function trimHistory(history: ChatMessage[], maxTokens: number): ChatMessage[] {
|
||||
let tokenCount = 0
|
||||
const result: ChatMessage[] = []
|
||||
for (let i = history.length - 1; i >= 0; i--) {
|
||||
const tokens = estimateTokens(history[i].content)
|
||||
if (tokenCount + tokens > maxTokens) break
|
||||
result.unshift(history[i])
|
||||
tokenCount += tokens
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Format conversation history + new message into a single prompt for the CLI. */
|
||||
export function formatMessageWithHistory(history: ChatMessage[], newMessage: string): string {
|
||||
if (history.length === 0) return newMessage
|
||||
|
||||
const lines = history.map(m => `[${m.role}]: ${m.content}`)
|
||||
lines.push(`[user]: ${newMessage}`)
|
||||
|
||||
return `<conversation_history>\n${lines.join('\n\n')}\n</conversation_history>\n\nContinue the conversation. Respond only to the latest [user] message.`
|
||||
}
|
||||
|
||||
// --- Claude CLI status ---
|
||||
|
||||
export interface ClaudeCliStatus {
|
||||
|
||||
@@ -331,6 +331,12 @@ describe('buildContextSnapshot', () => {
|
||||
expect(json.noteList).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes wikilink instruction in preamble', () => {
|
||||
const result = buildContextSnapshot({ activeEntry: active, allContent, entries })
|
||||
expect(result).toContain('[[Note Title]]')
|
||||
expect(result).toContain('wikilink')
|
||||
})
|
||||
|
||||
it('includes belongsTo and relatedTo in frontmatter', () => {
|
||||
const entryWithRels = makeEntry({
|
||||
path: '/vault/a.md', title: 'Alpha',
|
||||
|
||||
@@ -152,6 +152,7 @@ export function buildContextSnapshot(params: ContextSnapshotParams): string {
|
||||
'You are an AI assistant integrated into Laputa, a personal knowledge management app.',
|
||||
'The user is viewing a specific note. Use the structured context below to answer questions accurately.',
|
||||
'You can also use MCP tools to search, read, create, or edit notes in the vault.',
|
||||
'When you mention or reference a note by name, always use [[Note Title]] wikilink syntax so the user can click to open it.',
|
||||
].join('\n')
|
||||
|
||||
return `${preamble}\n\n## Context Snapshot\n\`\`\`json\n${JSON.stringify(snapshot, null, 2)}\n\`\`\``
|
||||
|
||||
35
src/utils/chatWikilinks.ts
Normal file
35
src/utils/chatWikilinks.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const WIKILINK_SCHEME = 'wikilink://'
|
||||
|
||||
export { WIKILINK_SCHEME }
|
||||
|
||||
function encodeTarget(target: string): string {
|
||||
return encodeURIComponent(target).replace(/\(/g, '%28').replace(/\)/g, '%29')
|
||||
}
|
||||
|
||||
function escapeLinkText(text: string): string {
|
||||
return text.replace(/[[\]]/g, '\\$&')
|
||||
}
|
||||
|
||||
function replaceWikilinksInText(text: string): string {
|
||||
return text.replace(/\[\[([^\]]+)\]\]/g, (_, inner: string) => {
|
||||
const pipeIdx = inner.indexOf('|')
|
||||
const target = pipeIdx !== -1 ? inner.slice(0, pipeIdx) : inner
|
||||
const display = pipeIdx !== -1 ? inner.slice(pipeIdx + 1) : inner
|
||||
return `[${escapeLinkText(display)}](${WIKILINK_SCHEME}${encodeTarget(target)})`
|
||||
})
|
||||
}
|
||||
|
||||
/** Convert [[Target]] to markdown links, but skip code blocks and inline code. */
|
||||
export function preprocessWikilinks(md: string): string {
|
||||
const segments: string[] = []
|
||||
const codeRegex = /(```[\s\S]*?```|`[^`\n]+`)/g
|
||||
let lastIndex = 0
|
||||
let match
|
||||
while ((match = codeRegex.exec(md)) !== null) {
|
||||
segments.push(replaceWikilinksInText(md.slice(lastIndex, match.index)))
|
||||
segments.push(match[0])
|
||||
lastIndex = match.index + match[0].length
|
||||
}
|
||||
segments.push(replaceWikilinksInText(md.slice(lastIndex)))
|
||||
return segments.join('')
|
||||
}
|
||||
10
src/utils/indexingHelpers.ts
Normal file
10
src/utils/indexingHelpers.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export function formatIndexedElapsed(lastIndexedTime: number | null): string {
|
||||
if (!lastIndexedTime) return ''
|
||||
const secs = Math.round((Date.now() - lastIndexedTime) / 1000)
|
||||
if (secs < 60) return 'Indexed just now'
|
||||
const mins = Math.floor(secs / 60)
|
||||
if (mins < 60) return `Indexed ${mins}m ago`
|
||||
const hrs = Math.floor(mins / 60)
|
||||
if (hrs < 24) return `Indexed ${hrs}h ago`
|
||||
return `Indexed ${Math.floor(hrs / 24)}d ago`
|
||||
}
|
||||
61
src/utils/mergeTabContent.test.ts
Normal file
61
src/utils/mergeTabContent.test.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { mergeTabContent } from './mergeTabContent'
|
||||
|
||||
describe('mergeTabContent', () => {
|
||||
it('adds tab content when allContent is empty', () => {
|
||||
const result = mergeTabContent({}, [
|
||||
{ entry: { path: '/vault/a.md' }, content: '# Hello\nBody text' },
|
||||
])
|
||||
expect(result['/vault/a.md']).toBe('# Hello\nBody text')
|
||||
})
|
||||
|
||||
it('overrides allContent with tab content (editor state is fresher)', () => {
|
||||
const result = mergeTabContent(
|
||||
{ '/vault/a.md': 'old saved content' },
|
||||
[{ entry: { path: '/vault/a.md' }, content: 'current editor content' }],
|
||||
)
|
||||
expect(result['/vault/a.md']).toBe('current editor content')
|
||||
})
|
||||
|
||||
it('preserves allContent for paths without open tabs', () => {
|
||||
const result = mergeTabContent(
|
||||
{ '/vault/b.md': 'other note content' },
|
||||
[{ entry: { path: '/vault/a.md' }, content: '# Hello' }],
|
||||
)
|
||||
expect(result['/vault/b.md']).toBe('other note content')
|
||||
expect(result['/vault/a.md']).toBe('# Hello')
|
||||
})
|
||||
|
||||
it('returns original object when no tabs have content to merge', () => {
|
||||
const original = { '/vault/a.md': 'content' }
|
||||
expect(mergeTabContent(original, [])).toBe(original)
|
||||
})
|
||||
|
||||
it('skips tabs with empty content', () => {
|
||||
const original = { '/vault/a.md': 'content' }
|
||||
const result = mergeTabContent(original, [
|
||||
{ entry: { path: '/vault/b.md' }, content: '' },
|
||||
])
|
||||
expect(result).toBe(original)
|
||||
expect(result['/vault/b.md']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('handles multiple tabs', () => {
|
||||
const result = mergeTabContent({}, [
|
||||
{ entry: { path: '/vault/a.md' }, content: 'Note A' },
|
||||
{ entry: { path: '/vault/b.md' }, content: 'Note B' },
|
||||
])
|
||||
expect(result['/vault/a.md']).toBe('Note A')
|
||||
expect(result['/vault/b.md']).toBe('Note B')
|
||||
})
|
||||
|
||||
it('does not mutate the original allContent object', () => {
|
||||
const original = { '/vault/a.md': 'old' }
|
||||
const result = mergeTabContent(original, [
|
||||
{ entry: { path: '/vault/a.md' }, content: 'new' },
|
||||
])
|
||||
expect(original['/vault/a.md']).toBe('old')
|
||||
expect(result['/vault/a.md']).toBe('new')
|
||||
expect(result).not.toBe(original)
|
||||
})
|
||||
})
|
||||
18
src/utils/mergeTabContent.ts
Normal file
18
src/utils/mergeTabContent.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Merge open tab content into the allContent dictionary.
|
||||
* Tab content takes priority (it reflects the current editor state).
|
||||
* Returns the original object if no changes are needed (stable reference for useMemo).
|
||||
*/
|
||||
export function mergeTabContent(
|
||||
allContent: Record<string, string>,
|
||||
tabs: ReadonlyArray<{ entry: { path: string }; content: string }>,
|
||||
): Record<string, string> {
|
||||
let merged: Record<string, string> | null = null
|
||||
for (const tab of tabs) {
|
||||
if (!tab.content) continue
|
||||
if (allContent[tab.entry.path] === tab.content) continue
|
||||
if (!merged) merged = { ...allContent }
|
||||
merged[tab.entry.path] = tab.content
|
||||
}
|
||||
return merged ?? allContent
|
||||
}
|
||||
66
tests/smoke/ai-chat-wikilink-clickable.spec.ts
Normal file
66
tests/smoke/ai-chat-wikilink-clickable.spec.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { sendShortcut } from './helpers'
|
||||
|
||||
test.describe('AI chat wikilink rendering', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Block vault API so mock entries are used (ensures "Build Laputa App" exists)
|
||||
await page.route('**/api/vault/ping', route => route.fulfill({ status: 503 }))
|
||||
|
||||
await page.goto('/')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Select a note so the AI panel has context
|
||||
const noteItem = page.locator('.app__note-list .cursor-pointer').first()
|
||||
await noteItem.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Open AI Chat with Ctrl+I
|
||||
await sendShortcut(page, 'i', ['Control'])
|
||||
await expect(page.getByTestId('ai-panel')).toBeVisible({ timeout: 3000 })
|
||||
|
||||
// Send a message to trigger mock response with [[Build Laputa App]] and [[Matteo Cellini]]
|
||||
const input = page.locator('input[placeholder*="Ask"]')
|
||||
await input.fill('Tell me about this note')
|
||||
await page.getByTestId('agent-send').click()
|
||||
|
||||
// Wait for wikilinks to render
|
||||
await expect(page.locator('.chat-wikilink').first()).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('[[Note]] in AI response renders as clickable wikilink', async ({ page }) => {
|
||||
const wikilink = page.locator('.chat-wikilink').first()
|
||||
|
||||
// Verify wikilink text and attributes
|
||||
await expect(wikilink).toHaveText('Build Laputa App')
|
||||
await expect(wikilink).toHaveAttribute('data-wikilink-target', 'Build Laputa App')
|
||||
await expect(wikilink).toHaveAttribute('role', 'link')
|
||||
|
||||
// Verify second wikilink
|
||||
const secondWikilink = page.locator('.chat-wikilink').nth(1)
|
||||
await expect(secondWikilink).toHaveText('Matteo Cellini')
|
||||
|
||||
// Verify multiple wikilinks rendered
|
||||
await expect(page.locator('.chat-wikilink')).toHaveCount(2)
|
||||
|
||||
// Verify wikilink has pointer cursor (is styled as clickable)
|
||||
const cursor = await wikilink.evaluate(el => getComputedStyle(el).cursor)
|
||||
expect(cursor).toBe('pointer')
|
||||
})
|
||||
|
||||
test('clicking a wikilink opens the note in a tab', async ({ page }) => {
|
||||
// Click the second wikilink ("Matteo Cellini") which is NOT already open in a tab
|
||||
const wikilink = page.locator('.chat-wikilink').nth(1)
|
||||
await expect(wikilink).toHaveText('Matteo Cellini')
|
||||
|
||||
// Verify "Matteo Cellini" is not yet in any tab
|
||||
const tabsBefore = await page.locator('span.truncate:has-text("Matteo Cellini")').count()
|
||||
|
||||
// Click the wikilink
|
||||
await wikilink.click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verify a new tab appeared with the note title
|
||||
const tabsAfter = await page.locator('span.truncate:has-text("Matteo Cellini")').count()
|
||||
expect(tabsAfter).toBeGreaterThan(tabsBefore)
|
||||
})
|
||||
})
|
||||
114
tests/smoke/ai-notes-visibility-fix.spec.ts
Normal file
114
tests/smoke/ai-notes-visibility-fix.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { WebSocketServer } from 'ws'
|
||||
|
||||
const NEW_NOTE_PATH = '/Users/luca/Laputa/note/ai-created-note.md'
|
||||
const NEW_NOTE_TITLE = 'AI Created Note'
|
||||
const NEW_NOTE_CONTENT = `---
|
||||
title: ${NEW_NOTE_TITLE}
|
||||
type: Note
|
||||
---
|
||||
|
||||
# ${NEW_NOTE_TITLE}
|
||||
|
||||
This note was created by the AI agent.
|
||||
`
|
||||
|
||||
function broadcast(wss: WebSocketServer, data: Record<string, unknown>) {
|
||||
const msg = JSON.stringify(data)
|
||||
for (const client of wss.clients) {
|
||||
if (client.readyState === 1) client.send(msg)
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait until at least one WebSocket client connects. */
|
||||
function waitForClient(wss: WebSocketServer, timeoutMs = 10000): Promise<void> {
|
||||
if (wss.clients.size > 0) return Promise.resolve()
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('No WS client connected')), timeoutMs)
|
||||
wss.on('connection', () => { clearTimeout(timer); resolve() })
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('AI-created note visibility', () => {
|
||||
let wss: WebSocketServer
|
||||
/** When true, the intercepted list_vault response includes the new note. */
|
||||
let injectNote = false
|
||||
|
||||
test.beforeEach(async () => {
|
||||
wss = new WebSocketServer({ port: 9711 })
|
||||
})
|
||||
|
||||
test.afterEach(async () => {
|
||||
wss.close()
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
})
|
||||
|
||||
test('vault_changed + open_tab from MCP makes note visible and opens tab', async ({ page }) => {
|
||||
// Intercept list_vault API calls. On reload (after vault_changed),
|
||||
// the injected note will be included in the response.
|
||||
await page.route('**/api/vault/list*', async (route) => {
|
||||
const response = await route.fetch()
|
||||
if (!injectNote) {
|
||||
await route.fulfill({ response })
|
||||
return
|
||||
}
|
||||
const entries = await response.json()
|
||||
const now = Date.now()
|
||||
entries.push({
|
||||
path: NEW_NOTE_PATH, filename: 'ai-created-note.md',
|
||||
title: NEW_NOTE_TITLE, isA: null,
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: now, createdAt: now, fileSize: NEW_NOTE_CONTENT.length,
|
||||
snippet: 'This note was created by the AI agent.', wordCount: 10,
|
||||
relationships: {}, icon: null, color: null, order: null,
|
||||
sidebarLabel: null, template: null, sort: null, view: null,
|
||||
visible: null, outgoingLinks: [], properties: {},
|
||||
})
|
||||
await route.fulfill({ json: entries })
|
||||
})
|
||||
|
||||
// Intercept content fetch for the new note
|
||||
await page.route('**/api/vault/content*ai-created-note*', async (route) => {
|
||||
await route.fulfill({ json: { content: NEW_NOTE_CONTENT } })
|
||||
})
|
||||
|
||||
// Intercept all-content to include new note
|
||||
await page.route('**/api/vault/all-content*', async (route) => {
|
||||
const response = await route.fetch()
|
||||
if (!injectNote) {
|
||||
await route.fulfill({ response })
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
data[NEW_NOTE_PATH] = NEW_NOTE_CONTENT
|
||||
await route.fulfill({ json: data })
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
|
||||
// Wait for note list to render
|
||||
const noteList = page.locator('.app__note-list')
|
||||
await expect(noteList).toBeVisible()
|
||||
await expect(noteList.getByText(NEW_NOTE_TITLE)).not.toBeVisible()
|
||||
|
||||
// Enable injection for subsequent reloads
|
||||
injectNote = true
|
||||
|
||||
// Wait for the frontend's useAiActivity WS to connect to our bridge
|
||||
await waitForClient(wss)
|
||||
|
||||
// Simulate MCP broadcasting vault_changed then open_tab
|
||||
// (This is what happens when Claude Code calls open_note via MCP)
|
||||
broadcast(wss, { type: 'ui_action', action: 'vault_changed', path: 'note/ai-created-note.md' })
|
||||
|
||||
// Verify: note appears in note list after vault reload
|
||||
await expect(noteList.getByText(NEW_NOTE_TITLE)).toBeVisible({ timeout: 8000 })
|
||||
|
||||
// Send open_tab after vault is reloaded so the entry is in entriesByPath
|
||||
broadcast(wss, { type: 'ui_action', action: 'open_tab', path: NEW_NOTE_PATH })
|
||||
|
||||
// Verify: note content is displayed in the editor (proves tab opened)
|
||||
await expect(page.getByText('This note was created by the AI agent.')).toBeVisible({ timeout: 8000 })
|
||||
})
|
||||
})
|
||||
71
tests/smoke/better-sync-error-ux.spec.ts
Normal file
71
tests/smoke/better-sync-error-ux.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { openCommandPalette, executeCommand } from './helpers'
|
||||
|
||||
test.describe('Sync error UX — actionable push error messages', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('rejected push shows actionable "Pull first" message in toast', async ({ page }) => {
|
||||
// Override git_push mock to return a rejected result
|
||||
await page.evaluate(() => {
|
||||
window.__mockHandlers!.git_push = () => ({
|
||||
status: 'rejected',
|
||||
message: 'Push rejected: remote has new commits. Pull first, then push.',
|
||||
})
|
||||
})
|
||||
|
||||
// Open commit dialog via command palette
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Commit & Push')
|
||||
|
||||
// Wait for the CommitDialog textarea
|
||||
const textarea = page.locator('textarea[placeholder="Commit message..."]')
|
||||
await textarea.waitFor({ timeout: 5000 })
|
||||
await textarea.fill('test commit')
|
||||
|
||||
// Click the "Commit & Push" button in the dialog
|
||||
const commitButton = page.getByRole('button', { name: 'Commit & Push' })
|
||||
await commitButton.click()
|
||||
|
||||
// Verify the toast shows the actionable rejection message
|
||||
const toast = page.locator('.fixed.bottom-8')
|
||||
await expect(toast).toContainText('Pull first', { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('auth error push shows authentication message in toast', async ({ page }) => {
|
||||
await page.evaluate(() => {
|
||||
window.__mockHandlers!.git_push = () => ({
|
||||
status: 'auth_error',
|
||||
message: 'Push failed: authentication error. Check your credentials.',
|
||||
})
|
||||
})
|
||||
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Commit & Push')
|
||||
|
||||
const textarea = page.locator('textarea[placeholder="Commit message..."]')
|
||||
await textarea.waitFor({ timeout: 5000 })
|
||||
await textarea.fill('test commit')
|
||||
|
||||
await page.getByRole('button', { name: 'Commit & Push' }).click()
|
||||
|
||||
const toast = page.locator('.fixed.bottom-8')
|
||||
await expect(toast).toContainText('authentication error', { timeout: 5000 })
|
||||
})
|
||||
|
||||
test('successful push shows normal success message', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Commit & Push')
|
||||
|
||||
const textarea = page.locator('textarea[placeholder="Commit message..."]')
|
||||
await textarea.waitFor({ timeout: 5000 })
|
||||
await textarea.fill('test commit')
|
||||
|
||||
await page.getByRole('button', { name: 'Commit & Push' }).click()
|
||||
|
||||
const toast = page.locator('.fixed.bottom-8')
|
||||
await expect(toast).toContainText('Committed and pushed', { timeout: 5000 })
|
||||
})
|
||||
})
|
||||
28
tests/smoke/indexing-reindex-status.spec.ts
Normal file
28
tests/smoke/indexing-reindex-status.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
openCommandPalette,
|
||||
findCommand,
|
||||
executeCommand,
|
||||
} from './helpers'
|
||||
|
||||
test.describe('Reindex Vault smoke tests', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('Reindex Vault command appears in command palette', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
const found = await findCommand(page, 'Reindex Vault')
|
||||
expect(found).toBe(true)
|
||||
})
|
||||
|
||||
test('Reindex Vault command triggers indexing progress', async ({ page }) => {
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Reindex Vault')
|
||||
|
||||
// After triggering reindex, the indexing badge should appear in the status bar
|
||||
const indexingBadge = page.locator('[data-testid="status-indexing"], [data-testid="status-indexed-time"]')
|
||||
await expect(indexingBadge.first()).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
})
|
||||
39
tests/smoke/trashed-archived-not-saved.spec.ts
Normal file
39
tests/smoke/trashed-archived-not-saved.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { openCommandPalette, executeCommand } from './helpers'
|
||||
|
||||
test.describe('Trash/archive notes appear in Changes', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('trashing a note increments the Changes badge', async ({ page }) => {
|
||||
const sidebar = page.locator('.app__sidebar')
|
||||
|
||||
// Wait for Changes nav item (mock starts with 3 modified files)
|
||||
const changesRow = sidebar.locator('div', { hasText: /^Changes/ }).first()
|
||||
await changesRow.waitFor({ timeout: 5000 })
|
||||
|
||||
// Read the initial badge count from the Changes row
|
||||
const badge = changesRow.locator('span').last()
|
||||
const initialCount = Number(await badge.textContent())
|
||||
expect(initialCount).toBeGreaterThan(0)
|
||||
|
||||
// Click the first note in the list to select it
|
||||
const noteListContainer = page.locator('[data-testid="note-list-container"]')
|
||||
await noteListContainer.waitFor({ timeout: 5000 })
|
||||
// Click on the first visible note item (border-b items inside the list)
|
||||
const firstNote = noteListContainer.locator('.cursor-pointer').first()
|
||||
await firstNote.click()
|
||||
|
||||
// Trash the active note via command palette
|
||||
await openCommandPalette(page)
|
||||
await executeCommand(page, 'Trash Note')
|
||||
|
||||
// Wait for the badge count to increase
|
||||
await expect(async () => {
|
||||
const text = await badge.textContent()
|
||||
expect(Number(text)).toBeGreaterThan(initialCount)
|
||||
}).toPass({ timeout: 5000 })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user