feat: publish HoloLake model-native living system source

This commit is contained in:
冰朔 2026-08-03 10:04:41 +08:00
commit c395dd3a99
2467 changed files with 615073 additions and 0 deletions

View file

@ -0,0 +1,41 @@
import { afterEach, describe, expect, it } from 'vitest'
import { isTauri } from './index'
const originalIsTauri = (globalThis as { isTauri?: unknown }).isTauri
const windowWithLegacyMarkers = window as Window & {
__TAURI__?: unknown
__TAURI_INTERNALS__?: unknown
}
describe('isTauri', () => {
afterEach(() => {
if (originalIsTauri === undefined) {
delete (globalThis as { isTauri?: unknown }).isTauri
} else {
;(globalThis as { isTauri?: unknown }).isTauri = originalIsTauri
}
delete windowWithLegacyMarkers.__TAURI__
delete windowWithLegacyMarkers.__TAURI_INTERNALS__
})
it('prefers the Tauri v2 global runtime flag', () => {
;(globalThis as { isTauri?: unknown }).isTauri = true
expect(isTauri()).toBe(true)
})
it('respects an explicit false runtime flag', () => {
;(globalThis as { isTauri?: unknown }).isTauri = false
windowWithLegacyMarkers.__TAURI__ = {}
expect(isTauri()).toBe(false)
})
it('falls back to the legacy window markers when the runtime flag is absent', () => {
delete (globalThis as { isTauri?: unknown }).isTauri
windowWithLegacyMarkers.__TAURI_INTERNALS__ = {}
expect(isTauri()).toBe(true)
})
})

View file

@ -0,0 +1,46 @@
/**
* Mock Tauri invoke for browser testing.
* When running outside Tauri (e.g. in Chrome via localhost:5173),
* this provides realistic test data so the UI can be verified visually.
*/
import { MOCK_CONTENT } from './mock-content'
import { mockHandlers, addMockEntry, updateMockContent, trackMockChange } from './mock-handlers'
import { tryVaultApi } from './vault-api'
export { addMockEntry, updateMockContent, trackMockChange }
type MockHandler = (args: Record<string, unknown> | undefined) => unknown
export function isTauri(): boolean {
if (typeof globalThis !== 'undefined' && typeof (globalThis as { isTauri?: unknown }).isTauri === 'boolean') {
return Boolean((globalThis as { isTauri?: unknown }).isTauri)
}
return typeof window !== 'undefined' && ('__TAURI__' in window || '__TAURI_INTERNALS__' in window)
}
// Initialize window globals for browser testing and Playwright overrides
if (typeof window !== 'undefined') {
window.__mockContent = MOCK_CONTENT
window.__mockHandlers = mockHandlers
}
function resolveMockHandler(command: string) {
const windowHandler = typeof window === 'undefined' || !window.__mockHandlers
? undefined
: Reflect.get(window.__mockHandlers, command) as MockHandler | undefined
return windowHandler ?? Reflect.get(mockHandlers, command) as MockHandler | undefined
}
export async function mockInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
const vaultResult = await tryVaultApi<T>(cmd, args)
if (vaultResult !== undefined) return vaultResult
const handler = resolveMockHandler(cmd)
if (handler) {
await new Promise((r) => setTimeout(r, 100))
return handler(args) as T
}
throw new Error(`No mock handler for command: ${cmd}`)
}

View file

@ -0,0 +1,698 @@
/**
* Mock markdown content keyed by file path.
* Used by the mock Tauri layer when running in browser dev mode.
*/
export const MOCK_CONTENT: Record<string, string> = {
'/Users/luca/Laputa/26q1-laputa-app.md': `---
title: Build Laputa App
type: Project
status: Active
owner: Luca Rossi
deadline: 2026-03-31
published: true
archived: false
tags: [Tauri, React, TypeScript, CodeMirror]
tools: [Vite, Vitest, Playwright]
url: https://github.com/lucaong/laputa-app
belongs_to:
- "[[q1-2026]]"
related_to:
- "[[software-development]]"
---
# Build Laputa App
## Text Formatting
This paragraph has **bold text**, *italic text*, ***bold italic***, ~~strikethrough~~, and \`inline code\`. Here's a [regular link](https://example.com) and a wiki-link to [[Matteo Cellini]].
## Headings
### Third Level Heading
Content under H3.
#### Fourth Level Heading
Content under H4.
## Lists
### Bullet Lists (Nested)
- First level item this is a top-level bullet point
- Second level item indented one level
- Third level item indented two levels
- Another third level item with longer text that wraps to multiple lines to test alignment
- Back to second level
- Another first level item
- With a nested child
- Final first level item
### Numbered Lists
1. Step one do this first
2. Step two then do this
3. Step three finally this
1. Sub-step 3a
2. Sub-step 3b
### Checkboxes
- [x] Completed task with strikethrough
- [x] Another done item
- [ ] Pending task needs attention
- [ ] Future task with **bold** text inside
### Mixed Nesting
- Top level bullet
- Nested bullet
- Deep nested bullet
- Back to second
- Another top level
- With child
## Block Quotes
> This is a blockquote. It should have a left border and distinct styling.
> It can span multiple lines and contain **formatting**.
## Code Blocks
\`\`\`typescript
interface VaultEntry {
path: string;
title: string;
isA: string;
status: string | null;
}
function loadVault(path: string): VaultEntry[] {
// Load all markdown files from the vault
return entries.filter(e => e.isA !== 'Note');
}
\`\`\`
\`\`\`yaml
title: Some Title
type: Project
status: Active
\`\`\`
## Tables
| Feature | Status | Priority |
|---------|--------|----------|
| Editor | Done | High |
| Inspector | Done | High |
| Git Integration | Done | Medium |
| Mobile App | Planned | Low |
## Horizontal Rule
---
## Wiki-Links
See [[Stock Screener EMA200 Wick Bounce]] for the experiment approach.
Contact [[Matteo Cellini]] for sponsorship data.
Link to [[Grow Newsletter]] responsibility.
Check [[Software Development]] for tech notes.
See [[Laputa App Design Session]] event recap.
Read [[Write Weekly Essays]] procedure.
Also see [[Non-Existent Note]] which is a broken link.
## Paragraphs & Spacing
This is a normal paragraph with enough text to test line wrapping and spacing between elements. The paragraph should have comfortable line height and spacing from the heading above.
And this is a second paragraph to verify inter-paragraph spacing is correct. Good typography requires consistent vertical rhythm throughout the document.
`,
'/Users/luca/Laputa/grow-newsletter.md': `---
title: Grow Newsletter
type: Responsibility
status: Active
owner: Luca Rossi
---
# Grow Newsletter
## Purpose
Build a sustainable audience through high-quality weekly essays on **engineering leadership**, **AI**, and **personal systems**.
## Key Metrics
- Subscriber count (target: 100k by Q2 2026)
- Open rate (target: > 50%)
- Click-through rate
## Current Strategy
1. Publish one essay per week Tuesday morning
2. Promote via Twitter/X threads
3. Cross-post to LinkedIn with native formatting
4. Guest posts on other newsletters monthly
## Procedures
- [[Write Weekly Essays]] the core writing workflow
- Monthly audience analysis and topic planning
## Notes
The newsletter is the *engine* that drives everything else sponsorships, consulting leads, and brand building.
`,
'/Users/luca/Laputa/manage-sponsorships.md': `---
title: Manage Sponsorships
type: Responsibility
status: Active
owner: Matteo Cellini
---
# Manage Sponsorships
## Overview
Revenue stream from newsletter sponsorships. [[Matteo Cellini]] handles day-to-day operations.
## Process
1. Inbound leads via sponsorship page
2. Qualification call
3. Proposal and negotiation
4. Schedule and deliver
5. Report results to sponsor
## Metrics
- Monthly revenue
- Close rate
- Repeat sponsor rate
`,
'/Users/luca/Laputa/write-weekly-essays.md': `---
title: Write Weekly Essays
type: Procedure
status: Active
owner: Luca Rossi
cadence: Weekly
belongs_to:
- "[[grow-newsletter]]"
---
# Write Weekly Essays
## Schedule
- **Monday**: Pick topic, outline
- **Tuesday**: First draft
- **Wednesday**: Edit and polish
- **Thursday**: Schedule for Tuesday send
## Writing Guidelines
- 1500-2500 words
- One clear takeaway
- Use *real examples* from personal experience
- Include actionable advice, not just theory
### Checklist
- [ ] Pick a topic from the backlog
- [ ] Write outline with 3-5 sections
- [x] Set up newsletter template
- [x] Configure email scheduling
- [ ] Review analytics from last issue
### Nested Topics
- Content strategy for growing the newsletter audience through organic channels, referrals, and high-quality evergreen content that people want to share with their engineering teams
- Newsletter growth and subscriber acquisition including all the different channels we use to attract new readers to the publication
- Organic subscribers from search, Twitter, and word of mouth these are the highest quality subscribers with the best retention rates over time
- Paid acquisition through Facebook ads and newsletter cross-promotions with other engineering publications in the space
- Social media cross-posting
- Technical writing
- Code examples
- Architecture diagrams
1. First ordered item with a really long description that should definitely wrap to the next line when displayed in the editor, testing the hanging indent behavior for numbered lists
2. Second ordered item shorter
1. Nested ordered item that also has quite a long description to verify that the indentation works correctly for nested numbered lists too
`,
'/Users/luca/Laputa/run-sponsorships.md': `---
title: Run Sponsorships
type: Procedure
status: Active
owner: Matteo Cellini
cadence: Weekly
belongs_to:
- "[[manage-sponsorships]]"
---
# Run Sponsorships
## Weekly Tasks
- Review pipeline in CRM
- Follow up with pending proposals
- Schedule confirmed sponsors
- Send performance reports to completed sponsors
## Templates
- Proposal template: \`/templates/sponsorship-proposal.md\`
- Report template: \`/templates/sponsorship-report.md\`
`,
'/Users/luca/Laputa/stock-screener.md': `---
title: Stock Screener EMA200 Wick Bounce
type: Experiment
status: Active
owner: Luca Rossi
domains: [Finance, Quantitative Analysis]
tools: [Python, pandas, TradingView]
related_to:
- "[[trading]]"
- "[[algorithmic-trading]]"
---
# Stock Screener EMA200 Wick Bounce
## Hypothesis
Stocks that wick below the 200-day EMA and close above it show a **statistically significant bounce** in the following 5-10 days.
## Setup
- Scan for daily candles where:
- Low < EMA200
- Close > EMA200
- Volume > 1.5x average
- Filter for mid-cap stocks ($2B-$20B)
## Results So Far
| Date | Ticker | Entry | Exit | Return |
|------|--------|-------|------|--------|
| 2026-01-15 | AAPL | 182.30 | 189.50 | +3.9% |
| 2026-01-22 | MSFT | 410.20 | 418.80 | +2.1% |
## Next Steps
- [ ] Backtest on 10 years of data
- [ ] Add RSI filter for oversold confirmation
- [ ] Build automated alerts via Python script
`,
'/Users/luca/Laputa/facebook-ads-strategy.md': `---
title: Facebook Ads Strategy
type: Note
belongs_to:
- "[[26q1-laputa-app]]"
related_to:
- "[[growth]]"
- "[[ads]]"
---
# Facebook Ads Strategy
## Key Learnings
- **Lookalike audiences** from newsletter subscribers convert 3x better than interest-based targeting
- Video ads outperform static images by 40% on engagement
- Best performing CTA: "Join 50,000 engineers" (social proof)
## Budget
- Monthly budget: $2,000
- Cost per subscriber: ~$1.50 (down from $3.20 in Q3 2025)
## A/B Tests Running
1. Long-form vs short-form ad copy
2. Testimonial vs data-driven creative
`,
'/Users/luca/Laputa/budget-allocation.md': `---
title: Budget Allocation
type: Note
belongs_to:
- "[[26q1-laputa-app]]"
---
# Budget Allocation
## Q1 2026
| Category | Budget | Actual | Delta |
|----------|--------|--------|-------|
| Ads | $6,000 | $5,400 | -$600 |
| Tools | $500 | $480 | -$20 |
| Freelancers | $2,000 | $1,800 | -$200 |
## Notes
- Under budget on ads due to improved targeting efficiency
- Consider reallocating savings to content production
`,
'/Users/luca/Laputa/matteo-cellini.md': `---
title: Matteo Cellini
type: Person
aliases:
- Matteo
---
# Matteo Cellini
## Role
Sponsorship manager handles all sponsor relationships, proposals, and reporting.
## Contact
- Email: matteo@example.com
- Slack: @matteo
## Responsibilities
- [[Manage Sponsorships]]
- [[Run Sponsorships]]
`,
'/Users/luca/Laputa/2026-02-14-laputa-app-kickoff.md': `---
title: Laputa App Design Session
type: Event
related_to:
- "[[26q1-laputa-app]]"
- "[[matteo-cellini]]"
---
# Laputa App Design Session
## Date
2026-02-14
## Attendees
- Luca Rossi
- [[Matteo Cellini]]
## Notes
- Agreed on four-panel layout inspired by Bear Notes
- CodeMirror 6 for the editor live preview is critical
- MVP by end of Q1: sidebar + note list + editor working
- Inspector panel can wait for M4
## Action Items
- [ ] Luca: finalize ontology mapping
- [x] Luca: set up Tauri v2 project scaffold
- [ ] Matteo: test with real vault data
`,
'/Users/luca/Laputa/software-development.md': `---
title: Software Development
type: Topic
aliases:
- Dev
- Coding
---
# Software Development
A broad topic covering everything from frontend to systems programming.
## Subtopics of Interest
- **Frontend**: React, TypeScript, CSS
- **Desktop**: Tauri, Electron alternatives
- **AI/ML**: LLMs, agents, code generation
- **Systems**: Rust, performance optimization
`,
'/Users/luca/Laputa/trading.md': `---
title: Trading
type: Topic
aliases:
- Algorithmic Trading
---
# Trading
## Focus Areas
- Technical analysis (EMA, RSI, volume patterns)
- Algorithmic screening and alerts
- Risk management and position sizing
## Active Experiments
- [[Stock Screener EMA200 Wick Bounce]]
`,
'/Users/luca/Laputa/on-writing-well.md': `---
title: On Writing Well
type: Essay
Belongs to:
- "[[grow-newsletter]]"
---
# On Writing Well
Good writing is lean and confident. Every sentence should serve a purpose.
`,
'/Users/luca/Laputa/engineering-leadership-101.md': `---
title: Engineering Leadership 101
type: Essay
Belongs to:
- "[[grow-newsletter]]"
Related to:
- "[[software-development]]"
---
# Engineering Leadership 101
The transition from IC to manager is the hardest career shift in engineering.
`,
'/Users/luca/Laputa/ai-agents-primer.md': `---
title: AI Agents Primer
type: Essay
Belongs to:
- "[[grow-newsletter]]"
---
# AI Agents Primer
AI agents are autonomous systems that can plan, execute, and adapt to achieve goals.
`,
'/Users/luca/Laputa/maria-bianchi.md': `---
title: Maria Bianchi
type: Person
aliases:
- Maria
---
# Maria Bianchi
## Role
Product designer leads UX research and design sprints for the app.
## Contact
- Email: maria@example.com
- Slack: @maria
`,
'/Users/luca/Laputa/marco-verdi.md': `---
title: Marco Verdi
type: Person
aliases:
- Marco
---
# Marco Verdi
## Role
Frontend engineer focuses on React performance and accessibility.
## Contact
- Email: marco@example.com
`,
'/Users/luca/Laputa/elena-russo.md': `---
title: Elena Russo
type: Person
aliases:
- Elena
---
# Elena Russo
## Role
Content strategist plans newsletter topics and manages the editorial calendar.
`,
'/Users/luca/Laputa/project.md': `---
type: Type
order: 0
---
# Project
A **time-bound initiative** that advances a [[responsibility|Responsibility]]. Projects have a clear start, end, and deliverables.
## Properties
- **Status**: Active, Paused, Done, Dropped
- **Owner**: The person accountable
- **Belongs to**: Usually a Quarter or Responsibility
`,
'/Users/luca/Laputa/responsibility.md': `---
type: Type
order: 1
---
# Responsibility
An **ongoing area of ownership** something you're accountable for indefinitely. Responsibilities don't end; they have procedures, projects, and measures attached.
## Properties
- **Status**: Active, Paused, Archived
- **Owner**: The person accountable
`,
'/Users/luca/Laputa/procedure.md': `---
type: Type
order: 2
---
# Procedure
A **recurring process** tied to a [[responsibility|Responsibility]]. Procedures have a cadence (weekly, monthly) and describe how to do something.
## Properties
- **Status**: Active, Paused
- **Owner**: The person responsible
- **Cadence**: Weekly, Monthly, Quarterly
- **Belongs to**: A Responsibility
`,
'/Users/luca/Laputa/experiment.md': `---
type: Type
order: 3
---
# Experiment
A **hypothesis-driven investigation** with a clear test and measurable outcome. Experiments are time-bound and have explicit success criteria.
## Properties
- **Status**: Active, Done, Dropped
- **Owner**: The person running the experiment
`,
'/Users/luca/Laputa/person.md': `---
type: Type
order: 4
---
# Person
A **person** you interact with team members, collaborators, contacts. People can own projects, responsibilities, and procedures.
## Properties
- **Aliases**: Alternative names for wikilink resolution
`,
'/Users/luca/Laputa/event.md': `---
type: Type
order: 5
---
# Event
A **point-in-time occurrence** meetings, launches, milestones. Events are linked to the entities they relate to.
## Properties
- **Related to**: Entities this event is about
`,
'/Users/luca/Laputa/topic.md': `---
type: Type
order: 6
---
# Topic
A **subject area** for categorization. Topics group related notes, projects, and resources by theme.
## Properties
- **Aliases**: Alternative names
`,
'/Users/luca/Laputa/essay.md': `---
type: Type
order: 7
---
# Essay
A **published piece of writing** newsletter essays, blog posts, articles. Essays belong to a responsibility and may relate to topics.
## Properties
- **Belongs to**: Usually a Responsibility
`,
'/Users/luca/Laputa/note.md': `---
type: Type
order: 8
---
# Note
A **general-purpose document** research notes, meeting notes, strategy docs. Notes belong to projects or responsibilities.
## Properties
- **Belongs to**: A Project, Responsibility, or other parent
`,
'/Users/luca/Laputa/recipe.md': `---
type: Type
icon: cooking-pot
color: orange
---
# Recipe
A **recipe** for cooking or baking. Recipes have ingredients, steps, and serving info.
## Default Properties
- **Servings**: Number of servings
- **Prep Time**: Time to prepare
- **Cook Time**: Time to cook
`,
'/Users/luca/Laputa/book.md': `---
type: Type
icon: book-open
color: green
---
# Book
A **book** you're reading or have read. Track reading progress, notes, and key takeaways.
## Default Properties
- **Author**: The book's author
- **Status**: Reading, Finished, Abandoned
- **Rating**: 1-5 stars
`,
'/Users/luca/Laputa/25q3-website-redesign.md': `---
title: Website Redesign
type: Project
status: Done
archived: true
owner: Luca Rossi
belongs_to:
- "[[q3-2025]]"
---
# Website Redesign
Completed redesign of the company website. Migrated from WordPress to Next.js with improved performance and SEO.
## Results
- Page load time: 4.2s 1.1s
- Organic traffic: +35% in 3 months
- Bounce rate: 58% 42%
`,
'/Users/luca/Laputa/twitter-thread-experiment.md': `---
title: Twitter Thread Growth Experiment
type: Experiment
status: Done
archived: true
owner: Luca Rossi
related_to:
- "[[grow-newsletter]]"
---
# Twitter Thread Growth Experiment
## Hypothesis
Publishing 3 Twitter threads per week (instead of 1) will increase newsletter signups by 50%.
## Result
After 6 weeks, signups increased by only 12%. The additional threads had diminishing returns quality matters more than quantity.
## Decision
Reverted to 1 high-quality thread per week. Archived this experiment.
`,
'/Users/luca/Laputa/pasta-carbonara.md': `---
title: Pasta Carbonara
type: Recipe
servings: 4
prep_time: 10 min
cook_time: 20 min
---
# Pasta Carbonara
Classic Roman pasta dish with eggs, pecorino, guanciale, and black pepper.
## Ingredients
- 400g spaghetti
- 200g guanciale
- 4 egg yolks + 2 whole eggs
- 100g Pecorino Romano
- Black pepper
`,
'/Users/luca/Laputa/designing-data-intensive-applications.md': `---
title: Designing Data-Intensive Applications
type: Book
author: Martin Kleppmann
status: Finished
rating: 5
---
# Designing Data-Intensive Applications
Essential reading for anyone building distributed systems. Covers replication, partitioning, transactions, and stream processing.
`,
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,224 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
async function loadHandlers() {
vi.resetModules()
return import('./mock-handlers')
}
describe('mockHandlers coverage', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('renames a note, updates its frontmatter title, and rewrites backlinks', async () => {
const { mockHandlers } = await loadHandlers()
const vaultPath = '/Users/mock/Test Vault'
const oldPath = `${vaultPath}/old-note.md`
const referencePath = `${vaultPath}/reference.md`
mockHandlers.save_note_content({
path: oldPath,
content: '---\ntitle: Old Note\n---\n\n# Old Note',
})
mockHandlers.save_note_content({
path: referencePath,
content: 'See [[Old Note]] and [[old-note]].',
})
const result = mockHandlers.rename_note({
vault_path: vaultPath,
old_path: oldPath,
new_title: 'New Title',
old_title: 'Old Note',
})
const updatedContent = mockHandlers.get_all_content() as Record<string, string>
expect(result).toEqual({
new_path: `${vaultPath}/new-title.md`,
updated_files: 1,
failed_updates: 0,
})
expect(updatedContent[`${vaultPath}/new-title.md`]).toContain('title: New Title')
expect(updatedContent[referencePath]).toBe('See [[new-title]] and [[new-title]].')
})
it('treats an unchanged title as a no-op rename', async () => {
const { mockHandlers } = await loadHandlers()
const vaultPath = '/Users/mock/Test Vault'
const notePath = `${vaultPath}/same-title.md`
mockHandlers.save_note_content({
path: notePath,
content: '---\ntitle: Same Title\n---\n',
})
expect(mockHandlers.rename_note({
vault_path: vaultPath,
old_path: notePath,
new_title: 'Same Title',
old_title: 'Same Title',
})).toEqual({
new_path: notePath,
updated_files: 0,
failed_updates: 0,
})
})
it('validates filename-only renames and blocks collisions', async () => {
const { mockHandlers } = await loadHandlers()
const vaultPath = '/Users/mock/Test Vault'
const sourcePath = `${vaultPath}/draft.md`
mockHandlers.save_note_content({
path: sourcePath,
content: '# Draft',
})
mockHandlers.save_note_content({
path: `${vaultPath}/duplicate.md`,
content: '# Existing',
})
expect(() => mockHandlers.rename_note_filename({
vault_path: vaultPath,
old_path: sourcePath,
new_filename_stem: ' ',
})).toThrow('Invalid filename')
expect(() => mockHandlers.rename_note_filename({
vault_path: vaultPath,
old_path: sourcePath,
new_filename_stem: 'duplicate',
})).toThrow('A note with that name already exists')
})
it('tracks saved files, deduplicates modified-file listings, and clears them on commit', async () => {
const { mockHandlers } = await loadHandlers()
mockHandlers.save_note_content({
path: '/Users/luca/Laputa/26q1-laputa-app.md',
content: '# Updated project note',
})
mockHandlers.save_note_content({
path: '/Users/luca/Laputa/new-note.md',
content: '# New note',
})
const modifiedBeforeCommit = mockHandlers.get_modified_files()
const basePathCount = modifiedBeforeCommit.filter((entry) => entry.path === '/Users/luca/Laputa/26q1-laputa-app.md').length
expect(basePathCount).toBe(1)
expect(modifiedBeforeCommit.some((entry) => entry.path === '/Users/luca/Laputa/new-note.md')).toBe(true)
expect(mockHandlers.git_commit({ message: 'Save everything' })).toContain('6 files changed')
expect(mockHandlers.get_modified_files()).toEqual([])
})
it('searches mock content and slices pulse results to the requested limit', async () => {
const { mockHandlers } = await loadHandlers()
const projectPath = '/Users/luca/Laputa/26q1-laputa-app.md'
mockHandlers.save_note_content({
path: projectPath,
content: '# Project Plan\n\nStrategic coverage improvements',
})
const search = mockHandlers.search_vault({ query: 'strategic', mode: 'content' })
const pulse = mockHandlers.get_vault_pulse({ limit: 2 })
expect(search.query).toBe('strategic')
expect(search.results).toEqual([
expect.objectContaining({
path: projectPath,
title: 'Build Laputa App',
}),
])
expect(pulse).toHaveLength(2)
expect(pulse[0]?.shortHash).toBe('a1b2c3d')
})
it('applies setting defaults and keeps saved vault lists isolated from caller mutations', async () => {
const { mockHandlers } = await loadHandlers()
mockHandlers.save_settings({
settings: {
auto_pull_interval_minutes: undefined,
autogit_enabled: true,
autogit_idle_threshold_seconds: undefined,
autogit_inactive_threshold_seconds: undefined,
auto_advance_inbox_after_organize: true,
telemetry_consent: true,
crash_reporting_enabled: false,
analytics_enabled: true,
anonymous_id: 'anon-1',
release_channel: 'alpha',
ui_language: 'zh-CN',
default_ai_agent: 'codex',
},
})
expect(mockHandlers.get_settings()).toEqual({
auto_pull_interval_minutes: 5,
git_enabled: null,
git_path: null,
git_provider: null,
git_wsl_distro: null,
autogit_enabled: true,
autogit_idle_threshold_seconds: 90,
autogit_inactive_threshold_seconds: 30,
auto_advance_inbox_after_organize: true,
telemetry_consent: true,
crash_reporting_enabled: false,
analytics_enabled: true,
anonymous_id: 'anon-1',
release_channel: 'alpha',
automatic_update_checks_enabled: null,
theme_mode: null,
date_display_format: null,
note_width_mode: null,
sidebar_type_pluralization_enabled: null,
initial_h1_auto_rename_enabled: null,
ai_features_enabled: null,
ui_language: 'zh-CN',
default_ai_agent: 'codex',
default_ai_target: null,
ai_model_providers: null,
ai_workspace_conversations: null,
hide_gitignored_files: null,
all_notes_show_pdfs: null,
all_notes_show_images: null,
all_notes_show_unsupported: null,
multi_workspace_enabled: null,
})
const list = {
vaults: [{ label: 'Work', path: '/work' }],
active_vault: '/work',
}
mockHandlers.save_vault_list({ list })
const savedList = mockHandlers.load_vault_list()
savedList.vaults.push({ label: 'Leak', path: '/leak' })
expect(mockHandlers.load_vault_list()).toEqual({
vaults: [{ label: 'Work', path: '/work' }],
active_vault: '/work',
})
})
it('builds attachment paths for saved and copied images', async () => {
const { mockHandlers } = await loadHandlers()
vi.spyOn(Date, 'now').mockReturnValue(12345)
expect(mockHandlers.save_image({
vault_path: '/vault',
filename: 'diagram.png',
data: 'base64',
})).toBe('/vault/attachments/12345-diagram.png')
expect(mockHandlers.copy_image_to_vault({
vault_path: '/vault',
source_path: '/tmp/screenshot.jpg',
})).toBe('/vault/attachments/12345-screenshot.jpg')
})
})

View file

@ -0,0 +1,234 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
async function loadHandlers() {
vi.resetModules()
return import('./mock-handlers')
}
describe('mockHandlers additional coverage', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('returns entry fallbacks, file history, diffs, and empty search results for empty queries', async () => {
const { mockHandlers } = await loadHandlers()
expect(mockHandlers.reload_vault_entry({ path: '/missing.md' })).toEqual(
expect.objectContaining({
path: '/missing.md',
title: 'Unknown',
filename: 'unknown.md',
}),
)
expect(mockHandlers.get_file_history({ path: '/vault/notes/strategy.md' })).toEqual(expect.arrayContaining([
expect.objectContaining({
shortHash: 'a1b2c3d',
message: 'Update strategy with latest changes',
}),
expect.objectContaining({
shortHash: 'm0n1o2p',
message: 'Create strategy',
}),
]))
expect(mockHandlers.get_file_diff({ path: '/vault/old-draft.md' })).toContain('deleted file mode 100644')
expect(mockHandlers.get_file_diff_at_commit({
path: '/vault/notes/strategy.md',
commitHash: 'abcdef1234567890',
})).toContain('Updated paragraph at commit abcdef1.')
expect(mockHandlers.search_vault({ query: '', mode: 'title' })).toEqual({
results: [],
elapsed_ms: 0,
query: '',
mode: 'title',
})
})
it('renames a filename successfully and rewrites wikilinks that target the old path stem', async () => {
const { mockHandlers } = await loadHandlers()
const vaultPath = '/Users/mock/Test Vault'
const sourcePath = `${vaultPath}/meeting-notes.md`
const backlinkPath = `${vaultPath}/backlinks.md`
mockHandlers.save_note_content({
path: sourcePath,
content: '# Meeting Notes',
})
mockHandlers.save_note_content({
path: backlinkPath,
content: 'Links: [[meeting-notes]] and [[Meeting Notes|alias]].',
})
expect(mockHandlers.rename_note_filename({
vault_path: vaultPath,
old_path: sourcePath,
new_filename_stem: 'weekly-notes',
})).toEqual({
new_path: `${vaultPath}/weekly-notes.md`,
updated_files: 1,
failed_updates: 0,
})
const content = mockHandlers.get_all_content() as Record<string, string>
expect(content[`${vaultPath}/weekly-notes.md`]).toBe('# Meeting Notes')
expect(content[backlinkPath]).toBe('Links: [[weekly-notes]] and [[Meeting Notes|alias]].')
})
it('moves a note into another workspace without rewriting links that still use the same relative path', async () => {
const { mockHandlers } = await loadHandlers()
const sourceVaultPath = '/Users/mock/Personal Vault'
const destinationVaultPath = '/Users/mock/Team Vault'
const sourcePath = `${sourceVaultPath}/areas/weekly-review.md`
const backlinkPath = `${sourceVaultPath}/backlinks.md`
mockHandlers.save_note_content({
path: sourcePath,
content: '# Weekly Review',
})
mockHandlers.save_note_content({
path: backlinkPath,
content: 'Links: [[areas/weekly-review]] and [[Weekly Review|alias]].',
})
expect(mockHandlers.move_note_to_workspace({
source_vault_path: sourceVaultPath,
destination_vault_path: destinationVaultPath,
old_path: sourcePath,
})).toEqual({
new_path: `${destinationVaultPath}/areas/weekly-review.md`,
updated_files: 0,
failed_updates: 0,
})
const content = mockHandlers.get_all_content() as Record<string, string>
expect(content[`${destinationVaultPath}/areas/weekly-review.md`]).toBe('# Weekly Review')
expect(content[backlinkPath]).toBe('Links: [[areas/weekly-review]] and [[Weekly Review|alias]].')
})
it('tracks remote state through create, clone, and add-remote flows', async () => {
const { mockHandlers } = await loadHandlers()
const emptyVaultPath = '/Users/mock/Documents/Brand New Vault'
const clonedVaultPath = '/Users/mock/Documents/Cloned Vault'
expect(mockHandlers.git_remote_status({ vaultPath: emptyVaultPath })).toEqual({
branch: 'main',
ahead: 0,
behind: 0,
hasRemote: true,
})
expect(mockHandlers.create_empty_vault({ targetPath: emptyVaultPath })).toBe(emptyVaultPath)
expect(mockHandlers.git_remote_status({ vaultPath: emptyVaultPath })).toEqual({
branch: 'main',
ahead: 0,
behind: 0,
hasRemote: false,
})
expect(mockHandlers.git_add_remote({
request: { vault_path: emptyVaultPath, remoteUrl: 'https://example.test/repo.git' },
})).toEqual({
status: 'connected',
message: 'Remote connected. This vault now tracks origin/main.',
})
expect(mockHandlers.git_remote_status({ vault_path: emptyVaultPath })).toEqual({
branch: 'main',
ahead: 0,
behind: 0,
hasRemote: true,
})
expect(mockHandlers.create_getting_started_vault({ targetPath: clonedVaultPath })).toBe(clonedVaultPath)
expect(mockHandlers.git_remote_status({ vaultPath: clonedVaultPath })).toEqual({
branch: 'main',
ahead: 0,
behind: 0,
hasRemote: false,
})
expect(mockHandlers.clone_repo({
url: 'https://example.test/repo.git',
local_path: clonedVaultPath,
})).toBe(`Cloned to ${clonedVaultPath}`)
expect(mockHandlers.git_remote_status({ vaultPath: clonedVaultPath })).toEqual({
branch: 'main',
ahead: 0,
behind: 0,
hasRemote: true,
})
})
it('persists last-vault state, reports vault existence, and restores AI guidance state', async () => {
const { mockHandlers } = await loadHandlers()
expect(mockHandlers.get_last_vault_path()).toBe('/Users/mock/demo-vault-v2')
expect(mockHandlers.set_last_vault_path({ path: '/Users/mock/Documents/Work' })).toBeNull()
expect(mockHandlers.get_last_vault_path()).toBe('/Users/mock/Documents/Work')
expect(mockHandlers.check_vault_exists({ path: '/tmp/demo-vault-v2-copy' })).toBe(true)
expect(mockHandlers.check_vault_exists({ path: '/tmp/random-vault' })).toBe(false)
expect(mockHandlers.get_vault_ai_guidance_status()).toEqual({
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
})
expect(mockHandlers.restore_vault_ai_guidance()).toEqual({
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
})
expect(mockHandlers.repair_vault()).toBe('Vault repaired')
})
it('persists theme mode through the mock settings backend', async () => {
const { mockHandlers } = await loadHandlers()
const settings = mockHandlers.get_settings()
mockHandlers.save_settings({
settings: {
...settings,
theme_mode: 'dark',
},
})
expect(mockHandlers.get_settings()).toEqual(expect.objectContaining({
theme_mode: 'dark',
}))
})
it('surfaces the simple command handlers for git, conflicts, trash, and telemetry', async () => {
const { mockHandlers } = await loadHandlers()
expect(mockHandlers.git_pull()).toEqual({
status: 'up_to_date',
message: 'Already up to date',
updatedFiles: [],
conflictFiles: [],
})
expect(mockHandlers.git_push()).toEqual({
status: 'ok',
message: 'Pushed to remote',
})
expect(mockHandlers.get_conflict_files()).toEqual([])
expect(mockHandlers.get_conflict_mode()).toBe('none')
expect(mockHandlers.purge_trash()).toEqual([])
expect(mockHandlers.empty_trash()).toEqual([])
expect(mockHandlers.delete_note({ path: '/vault/trash/me.md' })).toBe('/vault/trash/me.md')
expect(mockHandlers.batch_delete_notes({ paths: ['/a.md', '/b.md'] })).toEqual(['/a.md', '/b.md'])
expect(mockHandlers.batch_archive_notes({ paths: ['/a.md', '/b.md', '/c.md'] })).toBe(3)
expect(mockHandlers.batch_trash_notes({ paths: ['/a.md', '/b.md'] })).toBe(2)
expect(mockHandlers.migrate_is_a_to_type()).toBe(0)
expect(mockHandlers.register_mcp_tools()).toBe('registered')
expect(mockHandlers.check_mcp_status()).toBe('installed')
expect(mockHandlers.copy_text_to_clipboard()).toBeNull()
expect(mockHandlers.read_text_from_clipboard()).toBe('')
expect(mockHandlers.reinit_telemetry()).toBeNull()
expect(mockHandlers.stream_claude_chat()).toBe('mock-session')
expect(mockHandlers.stream_ai_agent()).toBeNull()
})
})

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { mockHandlers } from './mock-handlers'
describe('mockHandlers git remote state', () => {
it('keeps starter vaults local-only until a remote is added', () => {
const vaultPath = '/Users/mock/Documents/Getting Started Test'
expect(mockHandlers.create_getting_started_vault({ targetPath: vaultPath })).toBe(vaultPath)
expect(mockHandlers.git_remote_status({ vaultPath }).hasRemote).toBe(false)
expect(
mockHandlers.git_add_remote({
request: {
vaultPath,
remoteUrl: 'https://example.com/starter.git',
},
}).status,
).toBe('connected')
expect(mockHandlers.git_remote_status({ vaultPath }).hasRemote).toBe(true)
})
it('starts empty vaults without a remote and keeps cloned vaults remote-backed', () => {
const emptyVaultPath = '/Users/mock/Documents/Local Vault'
const clonedVaultPath = '/Users/mock/Documents/Cloned Vault'
expect(mockHandlers.create_empty_vault({ targetPath: emptyVaultPath })).toBe(emptyVaultPath)
expect(mockHandlers.git_remote_status({ vaultPath: emptyVaultPath }).hasRemote).toBe(false)
expect(mockHandlers.clone_repo({ url: 'https://example.com/repo.git', localPath: clonedVaultPath })).toContain(clonedVaultPath)
expect(mockHandlers.git_remote_status({ vaultPath: clonedVaultPath }).hasRemote).toBe(true)
})
})

View file

@ -0,0 +1,741 @@
/**
* Mock command handlers for Tauri invoke calls.
* Each handler simulates a Tauri backend command.
*/
import type {
VaultEntry,
ModifiedFile,
Settings,
GitProviderProbe,
GitProviderStatus,
GitAddRemoteResult,
GitPullResult,
GitPushResult,
GitRemoteStatus,
LastCommitInfo,
PulseCommit,
} from '../types'
import { MOCK_CONTENT } from './mock-content'
import { MOCK_ENTRIES } from './mock-entries'
function syncWindowContent(): void {
if (typeof window !== 'undefined') {
window.__mockContent = MOCK_CONTENT
}
}
function mockFileHistory(path: string) {
const filename = path.split('/').pop()?.replace('.md', '') ?? 'unknown'
const ts = Math.floor(Date.now() / 1000)
return [
{ hash: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', shortHash: 'a1b2c3d', message: `Update ${filename} with latest changes`, author: 'Luca Rossi', date: ts - 86400 * 2 },
{ hash: 'e4f5g6h7i8j9k0l1m2n3o4p5q6r7s8t9u0v1w2x3', shortHash: 'e4f5g6h', message: `Add new section to ${filename}`, author: 'Luca Rossi', date: ts - 86400 * 5 },
{ hash: 'i7j8k9l0m1n2o3p4q5r6s7t8u9v0w1x2y3z4a5b6', shortHash: 'i7j8k9l', message: `Fix formatting in ${filename}`, author: 'Luca Rossi', date: ts - 86400 * 12 },
{ hash: 'm0n1o2p3q4r5s6t7u8v9w0x1y2z3a4b5c6d7e8f9', shortHash: 'm0n1o2p', message: `Create ${filename}`, author: 'Luca Rossi', date: ts - 86400 * 30 },
]
}
function stripMockFrontmatter(content: string): string {
const lineEnding = content.startsWith('---\r\n')
? '\r\n'
: content.startsWith('---\n') ? '\n' : null
if (!lineEnding) return content
const afterOpen = content.slice(3 + lineEnding.length)
const closeIndex = afterOpen.indexOf(`${lineEnding}---`)
if (closeIndex === -1) return content
return afterOpen.slice(closeIndex + lineEnding.length + 3).trimStart()
}
function mockSearchContent(content: string, excludeFrontmatter?: boolean): string {
return excludeFrontmatter ? stripMockFrontmatter(content) : content
}
function mockModifiedFiles(): ModifiedFile[] {
return [
{ path: '/Users/luca/Laputa/26q1-laputa-app.md', relativePath: '26q1-laputa-app.md', status: 'modified' },
{ path: '/Users/luca/Laputa/facebook-ads-strategy.md', relativePath: 'facebook-ads-strategy.md', status: 'modified' },
{ path: '/Users/luca/Laputa/ai-agents-primer.md', relativePath: 'ai-agents-primer.md', status: 'added' },
{ path: '/Users/luca/Laputa/old-draft.md', relativePath: 'old-draft.md', status: 'deleted' },
]
}
function mockFileDiff(path: string): string {
const filename = path.split('/').pop() ?? 'unknown'
if (filename === 'old-draft.md') {
return `diff --git a/${filename} b/${filename}
deleted file mode 100644
index abc1234..0000000
--- a/${filename}
+++ /dev/null
@@ -1,7 +0,0 @@
----
-title: Old Draft
-type: Note
----
-
-# Old Draft
-
-This note was deleted.`
}
return `diff --git a/${filename} b/${filename}
index abc1234..def5678 100644
--- a/${filename}
+++ b/${filename}
@@ -1,8 +1,10 @@
---
title: Example Note
type: Note
+status: Active
---
# Example Note
-This is the original content.
+This is the updated content.
+
+A new paragraph has been added.`
}
function mockFileDiffAtCommit(path: string, commitHash: string): string {
const filename = path.split('/').pop() ?? 'unknown'
const shortHash = commitHash.slice(0, 7)
return `diff --git a/${filename} b/${filename}
index abc1234..${shortHash} 100644
--- a/${filename}
+++ b/${filename}
@@ -5,3 +5,5 @@
---
# Example Note
-Old paragraph from before ${shortHash}.
+Updated paragraph at commit ${shortHash}.
+
+New content added in this commit.`
}
let mockHasChanges = true
const mockSavedSinceCommit = new Set<string>()
let mockSettings: Settings = {
auto_pull_interval_minutes: 5,
git_enabled: null,
git_path: null,
git_provider: null,
git_wsl_distro: null,
autogit_enabled: false,
autogit_idle_threshold_seconds: 90,
autogit_inactive_threshold_seconds: 30,
auto_advance_inbox_after_organize: false,
telemetry_consent: false,
crash_reporting_enabled: null,
analytics_enabled: null,
anonymous_id: null,
release_channel: null,
automatic_update_checks_enabled: null,
theme_mode: null,
ui_language: null,
date_display_format: null,
note_width_mode: null,
sidebar_type_pluralization_enabled: null,
initial_h1_auto_rename_enabled: null,
ai_features_enabled: null,
default_ai_agent: 'claude_code',
default_ai_target: null,
ai_model_providers: null,
ai_workspace_conversations: null,
hide_gitignored_files: null,
all_notes_show_pdfs: null,
all_notes_show_images: null,
all_notes_show_unsupported: null,
multi_workspace_enabled: null,
}
const DEFAULT_MOCK_VAULT_PATH = '/Users/mock/demo-vault-v2'
const DEFAULT_MOCK_VAULT = {
label: 'demo-vault-v2',
path: DEFAULT_MOCK_VAULT_PATH,
}
let mockLastVaultPath: string | null = DEFAULT_MOCK_VAULT_PATH
const mockRemoteStateByVault = new Map<string, boolean>([[DEFAULT_MOCK_VAULT_PATH, true]])
let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vault: string | null } = {
vaults: [DEFAULT_MOCK_VAULT],
active_vault: DEFAULT_MOCK_VAULT_PATH,
}
let mockWorkspaceAiGuidanceStatus = {
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
} as const
function normalizeMockVaultPath(path: string | null | undefined): string | null {
const trimmed = path?.trim()
return trimmed ? trimmed : null
}
function setMockRemoteState(path: string | null | undefined, hasRemote: boolean): void {
const normalizedPath = normalizeMockVaultPath(path)
if (!normalizedPath) return
mockRemoteStateByVault.set(normalizedPath, hasRemote)
}
function getMockRemoteState(path: string | null | undefined): boolean {
const normalizedPath = normalizeMockVaultPath(path)
if (!normalizedPath) return true
return mockRemoteStateByVault.get(normalizedPath) ?? true
}
type MockContentPath = { path: string }
type MockContentWrite = MockContentPath & { content: string }
function readMockContent({ path }: MockContentPath): string {
const content = Reflect.get(MOCK_CONTENT, path)
return typeof content === 'string' ? content : ''
}
function writeMockContent({ path, content }: MockContentWrite): void {
Reflect.set(MOCK_CONTENT, path, content)
}
function deleteMockContent({ path }: MockContentPath): void {
Reflect.deleteProperty(MOCK_CONTENT, path)
}
function relativePathStem({ path, vaultPath }: { path: string; vaultPath: string }) {
const prefix = vaultPath.endsWith('/') ? vaultPath : `${vaultPath}/`
if (path.startsWith(prefix)) return path.slice(prefix.length).replace(/\.md$/, '')
return (path.split('/').pop() ?? path).replace(/\.md$/, '')
}
function canonicalRenameTargets({ oldTitle, oldPathStem }: { oldTitle: string; oldPathStem: string }) {
const oldFilenameStem = oldPathStem.split('/').pop() ?? oldPathStem
return [...new Set([oldTitle, oldPathStem, oldFilenameStem].filter(Boolean))]
}
function slugifyMockTitle({ title }: { title: string }) {
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
}
function buildRenamedMockPath({ oldPath, newTitle }: { oldPath: string; newTitle: string }) {
const parentDir = oldPath.replace(/\/[^/]+$/, '')
return `${parentDir}/${slugifyMockTitle({ title: newTitle })}.md`
}
function replaceMockTitleFrontmatter({ content, newTitle }: { content: string; newTitle: string }) {
return /^title:\s*/m.test(content)
? content.replace(/^title:\s*.*$/m, `title: ${newTitle}`)
: content
}
function replaceRenamedWikilinks({ content, oldTargets, newPathStem }: {
content: string
oldTargets: string[]
newPathStem: string
}) {
if (oldTargets.length === 0) return content
const targets = new Set(oldTargets)
let rewritten = ''
let cursor = 0
while (cursor < content.length) {
const start = content.indexOf('[[', cursor)
if (start === -1) break
const end = content.indexOf(']]', start + 2)
if (end === -1) break
rewritten += content.slice(cursor, start)
rewritten += renamedWikilinkToken({
newPathStem,
targets,
token: content.slice(start, end + 2),
})
cursor = end + 2
}
return rewritten + content.slice(cursor)
}
function renamedWikilinkToken({ newPathStem, targets, token }: {
newPathStem: string
targets: Set<string>
token: string
}) {
const body = token.slice(2, -2)
const pipeIndex = body.indexOf('|')
const target = pipeIndex === -1 ? body : body.slice(0, pipeIndex)
if (!targets.has(target)) return token
const pipe = pipeIndex === -1 ? '' : body.slice(pipeIndex)
return `[[${newPathStem}${pipe}]]`
}
function updateMockRenameReferences({ newPath, newPathStem, oldTargets }: {
newPath: string
newPathStem: string
oldTargets: string[]
}) {
let updatedFiles = 0
for (const [path, content] of Object.entries(MOCK_CONTENT)) {
if (path === newPath) continue
const replaced = replaceRenamedWikilinks({ content, oldTargets, newPathStem })
if (replaced === content) continue
writeMockContent({ path, content: replaced })
updatedFiles += 1
}
return updatedFiles
}
function handleRenameNote(args: { vault_path: string; old_path: string; new_title: string; old_title?: string | null }) {
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
const oldTitle = args.old_title ?? oldEntry?.title ?? ''
const oldContent = readMockContent({ path: args.old_path })
const newPath = buildRenamedMockPath({ oldPath: args.old_path, newTitle: args.new_title })
const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.vault_path })
const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path })
if (oldTitle === args.new_title && newPath === args.old_path) {
return { new_path: args.old_path, updated_files: 0, failed_updates: 0 }
}
const newContent = replaceMockTitleFrontmatter({ content: oldContent, newTitle: args.new_title })
deleteMockContent({ path: args.old_path })
writeMockContent({ path: newPath, content: newContent })
const oldTargets = canonicalRenameTargets({ oldTitle, oldPathStem })
const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets })
syncWindowContent()
return { new_path: newPath, updated_files: updatedFiles, failed_updates: 0 }
}
function handleRenameNoteFilename(args: {
vault_path: string
old_path: string
new_filename_stem: string
}) {
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
const oldContent = readMockContent({ path: args.old_path })
const oldTitle = oldEntry?.title ?? ''
const normalizedStem = args.new_filename_stem.trim().replace(/\.md$/, '')
const oldFilename = args.old_path.split('/').pop() ?? ''
const newFilename = `${normalizedStem}.md`
if (!normalizedStem) {
throw new Error('Invalid filename')
}
if (oldFilename === newFilename) {
return { new_path: args.old_path, updated_files: 0, failed_updates: 0 }
}
const parentDir = args.old_path.replace(/\/[^/]+$/, '')
const newPath = `${parentDir}/${newFilename}`
if (newPath !== args.old_path && Object.hasOwn(MOCK_CONTENT, newPath)) {
throw new Error('A note with that name already exists')
}
deleteMockContent({ path: args.old_path })
writeMockContent({ path: newPath, content: oldContent })
const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.vault_path })
const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path })
const oldTargets = canonicalRenameTargets({ oldTitle, oldPathStem })
const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets })
syncWindowContent()
return { new_path: newPath, updated_files: updatedFiles, failed_updates: 0 }
}
function handleMoveNoteToFolder(args: {
vault_path: string
old_path: string
folder_path: string
}) {
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
const oldContent = readMockContent({ path: args.old_path })
const oldTitle = oldEntry?.title ?? ''
const oldFilename = args.old_path.split('/').pop() ?? ''
const normalizedFolderPath = args.folder_path.trim().replace(/^\/+|\/+$/g, '')
if (!normalizedFolderPath) {
throw new Error('Folder path cannot be empty')
}
const vaultRoot = args.vault_path.replace(/\/+$/, '')
const newPath = `${vaultRoot}/${normalizedFolderPath}/${oldFilename}`
if (newPath === args.old_path) {
return { new_path: args.old_path, updated_files: 0, failed_updates: 0 }
}
if (Object.hasOwn(MOCK_CONTENT, newPath)) {
throw new Error('A note with that name already exists')
}
deleteMockContent({ path: args.old_path })
writeMockContent({ path: newPath, content: oldContent })
const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.vault_path })
const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path })
const oldTargets = canonicalRenameTargets({ oldTitle, oldPathStem })
const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets })
syncWindowContent()
return { new_path: newPath, updated_files: updatedFiles, failed_updates: 0 }
}
function handleMoveNoteToWorkspace(args: {
source_vault_path: string
destination_vault_path: string
old_path: string
replacement_target?: string | null
}) {
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
const oldContent = readMockContent({ path: args.old_path })
const oldTitle = oldEntry?.title ?? ''
const oldFilename = args.old_path.split('/').pop() ?? ''
const sourceRoot = args.source_vault_path.replace(/\/+$/, '')
const destinationRoot = args.destination_vault_path.replace(/\/+$/, '')
const relativePath = args.old_path.startsWith(`${sourceRoot}/`)
? args.old_path.slice(sourceRoot.length + 1)
: oldFilename
const newPath = `${destinationRoot}/${relativePath}`
if (newPath === args.old_path) {
return { new_path: args.old_path, updated_files: 0, failed_updates: 0 }
}
if (Object.hasOwn(MOCK_CONTENT, newPath)) {
throw new Error('A note with that name already exists')
}
deleteMockContent({ path: args.old_path })
writeMockContent({ path: newPath, content: oldContent })
const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.source_vault_path })
const newPathStem = args.replacement_target
?? relativePathStem({ path: newPath, vaultPath: args.destination_vault_path })
const oldTargets = canonicalRenameTargets({ oldTitle, oldPathStem })
const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets })
syncWindowContent()
return { new_path: newPath, updated_files: updatedFiles, failed_updates: 0 }
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map accepts heterogeneous arg types
export const mockHandlers: Record<string, (args: any) => any> = {
list_vault: () => MOCK_ENTRIES,
list_vault_folders: () => [],
list_views: () => [],
save_view_cmd: () => {},
delete_view_cmd: () => {},
reload_vault: () => MOCK_ENTRIES,
reload_vault_entry: (args: { path: string }) => MOCK_ENTRIES.find(e => e.path === args.path) ?? { path: args.path, title: 'Unknown', filename: 'unknown.md', aliases: [], belongsTo: [], relatedTo: [], archived: false, snippet: '', wordCount: 0, fileSize: 0, relationships: {}, outgoingLinks: [], properties: {} },
sync_note_title: () => false,
get_note_content: (args: { path: string }) => MOCK_CONTENT[args.path] ?? '',
validate_note_content: (args: { path: string; content: string }) => (MOCK_CONTENT[args.path] ?? '') === args.content,
get_all_content: () => MOCK_CONTENT,
get_file_history: (args: { path: string }) => mockFileHistory(args.path),
get_modified_files: () => {
const base = mockHasChanges ? mockModifiedFiles() : []
const basePaths = new Set(base.map(f => f.path))
const extra: ModifiedFile[] = [...mockSavedSinceCommit]
.filter(p => !basePaths.has(p))
.map(p => ({ path: p, relativePath: p.replace(/^.*?\/Laputa\//, ''), status: 'modified' as const }))
return [...base, ...extra]
},
get_file_diff: (args: { path: string }) => mockFileDiff(args.path),
get_file_diff_at_commit: (args: { path: string; commitHash: string }) => mockFileDiffAtCommit(args.path, args.commitHash),
git_discard_file: () => {},
git_commit: (args: { message: string }) => {
const count = (mockHasChanges ? mockModifiedFiles().length : 0) + mockSavedSinceCommit.size
mockHasChanges = false
mockSavedSinceCommit.clear()
return `[main abc1234] ${args.message}\n ${count} files changed`
},
git_author_identity: () => ({
name: 'Demo User',
email: 'demo@example.com',
source: 'global',
warning: null,
}),
get_build_number: () => 'bDEV',
should_use_external_media_preview: () => false,
get_last_commit_info: (): LastCommitInfo => ({ shortHash: 'a1b2c3d', commitUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }),
is_git_repo: () => true,
init_git_repo: () => null,
git_pull: (): GitPullResult => ({ status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }),
git_push: (): GitPushResult => ({ status: 'ok', message: 'Pushed to remote' }),
git_remote_status: (args?: { vaultPath?: string; vault_path?: string }): GitRemoteStatus => {
const vaultPath = args?.vaultPath ?? args?.vault_path ?? mockLastVaultPath ?? DEFAULT_MOCK_VAULT_PATH
return { branch: 'main', ahead: 0, behind: 0, hasRemote: getMockRemoteState(vaultPath) }
},
git_file_url: (args?: { vaultPath?: string; vault_path?: string; path?: string }): string | null => {
const vaultPath = args?.vaultPath ?? args?.vault_path ?? mockLastVaultPath ?? DEFAULT_MOCK_VAULT_PATH
if (!getMockRemoteState(vaultPath)) return null
const path = args?.path?.replace(/^.*?\/Laputa\//, '') ?? 'note.md'
return `https://github.com/lucaong/laputa-vault/blob/main/${encodeURI(path)}`
},
git_provider_status: (): GitProviderStatus => ({
selected_provider: mockSettings.git_provider ?? 'native',
selected_wsl_distro: mockSettings.git_wsl_distro ?? null,
native: {
provider: 'native',
label: 'Native Git',
available: true,
version: 'git version 2.45.0',
distro: null,
path: null,
message: 'Native Git is available: git version 2.45.0',
},
wsl_distributions: [{
provider: 'wsl',
label: 'WSL2 Git',
available: true,
version: 'git version 2.43.0',
distro: 'Ubuntu',
path: null,
message: 'WSL2 Git is available: git version 2.43.0',
}],
}),
test_git_provider: (args?: { provider?: string; distro?: string | null }): GitProviderProbe => {
const provider = args?.provider === 'wsl' ? 'wsl' : 'native'
return provider === 'wsl'
? {
provider,
label: 'WSL2 Git',
available: true,
version: 'git version 2.43.0',
distro: args?.distro ?? 'Ubuntu',
path: null,
message: 'WSL2 Git is available: git version 2.43.0',
}
: {
provider,
label: 'Native Git',
available: true,
version: 'git version 2.45.0',
distro: null,
path: null,
message: 'Native Git is available: git version 2.45.0',
}
},
git_add_remote: (args?: {
request?: { vaultPath?: string; vault_path?: string; remoteUrl?: string }
vaultPath?: string
vault_path?: string
remoteUrl?: string
}): GitAddRemoteResult => {
const request = args?.request ?? args ?? {}
const vaultPath = request.vaultPath ?? request.vault_path ?? mockLastVaultPath ?? DEFAULT_MOCK_VAULT_PATH
setMockRemoteState(vaultPath, true)
return {
status: 'connected',
message: 'Remote connected. This vault now tracks origin/main.',
}
},
get_vault_pulse: (args: { limit?: number }): PulseCommit[] => {
const limit = args.limit ?? 30
const ts = Math.floor(Date.now() / 1000)
const commits: PulseCommit[] = [
{ hash: 'a1b2c3d4e5f6', shortHash: 'a1b2c3d', message: 'Update project notes and add new experiment', date: ts - 3600, githubUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6', files: [{ path: '26q1-laputa-app.md', status: 'modified', title: '26q1 laputa app' }, { path: 'ai-search.md', status: 'added', title: 'ai search' }], added: 1, modified: 1, deleted: 0 },
{ hash: 'b2c3d4e5f6g7', shortHash: 'b2c3d4e', message: 'Reorganize people notes', date: ts - 86400, githubUrl: 'https://github.com/lucaong/laputa-vault/commit/b2c3d4e5f6g7', files: [{ path: 'alice-johnson.md', status: 'modified', title: 'alice johnson' }, { path: 'bob-smith.md', status: 'modified', title: 'bob smith' }, { path: 'old-contact.md', status: 'deleted', title: 'old contact' }], added: 0, modified: 2, deleted: 1 },
{ hash: 'c3d4e5f6g7h8', shortHash: 'c3d4e5f', message: 'Add daily journal entry', date: ts - 172800, githubUrl: null, files: [{ path: '2026-03-03.md', status: 'added', title: '2026 03 03' }], added: 1, modified: 0, deleted: 0 },
]
return commits.slice(0, limit)
},
get_conflict_files: (): string[] => [],
get_conflict_mode: () => 'none',
check_claude_cli: () => ({ installed: false, version: null }),
get_ai_agents_status: () => ({
claude_code: { installed: false, version: null },
codex: { installed: false, version: null },
copilot: { installed: false, version: null },
opencode: { installed: false, version: null },
pi: { installed: false, version: null },
antigravity: { installed: false, version: null },
kiro: { installed: false, version: null },
hermes: { installed: false, version: null },
}),
get_agent_docs_path: () => '/mock/Tolaria/resources/agent-docs',
get_vault_ai_guidance_status: () => ({ ...mockWorkspaceAiGuidanceStatus }),
restore_vault_ai_guidance: () => {
mockWorkspaceAiGuidanceStatus = {
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
}
return { ...mockWorkspaceAiGuidanceStatus }
},
stream_claude_chat: () => 'mock-session',
stream_ai_agent: () => null,
abort_ai_agent_stream: () => false,
save_note_content: (args: { path: string; content: string }) => {
MOCK_CONTENT[args.path] = args.content
mockSavedSinceCommit.add(args.path)
syncWindowContent()
return null
},
save_image: (args: { vault_path?: string; filename: string; data: string }) => {
const vault = args.vault_path ?? '/Users/luca/Laputa'
return `${vault}/attachments/${Date.now()}-${args.filename}`
},
copy_image_to_vault: (args: { vault_path?: string; source_path: string }) => {
const vault = args.vault_path ?? '/Users/luca/Laputa'
const filename = args.source_path.split('/').pop() ?? 'image.png'
return `${vault}/attachments/${Date.now()}-${filename}`
},
get_settings: () => ({ ...mockSettings }),
save_settings: (args: { settings: Settings }) => {
const s = args.settings
mockSettings = {
auto_pull_interval_minutes: s.auto_pull_interval_minutes ?? 5,
git_enabled: s.git_enabled ?? null,
git_path: s.git_path ?? null,
git_provider: s.git_provider ?? null,
git_wsl_distro: s.git_wsl_distro ?? null,
autogit_enabled: s.autogit_enabled ?? false,
autogit_idle_threshold_seconds: s.autogit_idle_threshold_seconds ?? 90,
autogit_inactive_threshold_seconds: s.autogit_inactive_threshold_seconds ?? 30,
auto_advance_inbox_after_organize: s.auto_advance_inbox_after_organize ?? false,
telemetry_consent: s.telemetry_consent,
crash_reporting_enabled: s.crash_reporting_enabled,
analytics_enabled: s.analytics_enabled,
anonymous_id: s.anonymous_id,
release_channel: s.release_channel,
automatic_update_checks_enabled: s.automatic_update_checks_enabled ?? null,
theme_mode: s.theme_mode ?? null,
ui_language: s.ui_language ?? null,
date_display_format: s.date_display_format ?? null,
note_width_mode: s.note_width_mode ?? null,
sidebar_type_pluralization_enabled: s.sidebar_type_pluralization_enabled ?? null,
initial_h1_auto_rename_enabled: s.initial_h1_auto_rename_enabled ?? null,
ai_features_enabled: s.ai_features_enabled ?? null,
default_ai_agent: s.default_ai_agent ?? null,
default_ai_target: s.default_ai_target ?? null,
ai_model_providers: s.ai_model_providers ?? null,
ai_workspace_conversations: s.ai_workspace_conversations ?? null,
hide_gitignored_files: s.hide_gitignored_files ?? null,
all_notes_show_pdfs: s.all_notes_show_pdfs ?? null,
all_notes_show_images: s.all_notes_show_images ?? null,
all_notes_show_unsupported: s.all_notes_show_unsupported ?? null,
multi_workspace_enabled: s.multi_workspace_enabled ?? null,
}
return null
},
load_vault_list: () => ({ ...mockVaultList, vaults: [...mockVaultList.vaults] }),
save_vault_list: (args: { list: typeof mockVaultList }) => { mockVaultList = { ...args.list }; return null },
rename_note: handleRenameNote,
rename_note_filename: handleRenameNoteFilename,
move_note_to_folder: handleMoveNoteToFolder,
move_note_to_workspace: handleMoveNoteToWorkspace,
clone_repo: (args: { url: string; localPath?: string; local_path?: string }) => {
const localPath = args.localPath ?? args.local_path ?? ''
setMockRemoteState(localPath, true)
return `Cloned to ${localPath}`
},
clone_git_repo: (args: { url: string; localPath?: string; local_path?: string }) => {
const localPath = args.localPath ?? args.local_path ?? ''
setMockRemoteState(localPath, true)
return `Cloned to ${localPath}`
},
purge_trash: () => [],
delete_note: (args: { path: string }) => args.path,
batch_delete_notes: (args: { paths: string[] }) => args.paths,
empty_trash: () => [],
migrate_is_a_to_type: () => 0,
batch_archive_notes: (args: { paths: string[] }) => args.paths.length,
batch_trash_notes: (args: { paths: string[] }) => args.paths.length,
search_vault: (args: { query: string; mode: string; excludeFrontmatter?: boolean }) => {
const q = (args.query ?? '').toLowerCase()
if (!q) return { results: [], elapsed_ms: 0, query: q, mode: args.mode }
const matches = MOCK_ENTRIES
.filter(e => {
const content = mockSearchContent(MOCK_CONTENT[e.path] ?? '', args.excludeFrontmatter)
return e.title.toLowerCase().includes(q) || content.toLowerCase().includes(q)
})
.slice(0, 20)
.map((e, i) => ({
title: e.title,
path: e.path,
snippet: e.snippet || '',
score: 1.0 - i * 0.05,
note_type: e.isA,
}))
return { results: matches, elapsed_ms: 42, query: q, mode: args.mode }
},
get_last_vault_path: () => mockLastVaultPath,
set_last_vault_path: (args: { path: string }) => { mockLastVaultPath = args.path; return null },
get_default_vault_path: () => '/Users/mock/Documents/Getting Started',
check_vault_exists: (args: { path: string }) => {
// In mock mode, the demo-vault-v2 path always "exists"
return args.path.includes('demo-vault-v2')
},
create_empty_vault: (args: { targetPath?: string; target_path?: string }) => {
const targetPath = args.targetPath || args.target_path || '/Users/mock/Documents/My Vault'
setMockRemoteState(targetPath, false)
return targetPath
},
create_getting_started_vault: (args: { targetPath?: string | null }) => {
const targetPath = args.targetPath || '/Users/mock/Documents/Getting Started'
setMockRemoteState(targetPath, false)
return targetPath
},
register_mcp_tools: () => 'registered',
check_mcp_status: () => 'installed',
get_mcp_config_snippet: () => JSON.stringify({
mcpServers: {
tolaria: {
type: 'stdio',
command: 'node',
args: ['/mock/Tolaria/mcp-server/index.js'],
env: {
WS_UI_PORT: '9711',
},
},
},
}, null, 2),
get_opencode_mcp_config_snippet: () => JSON.stringify({
$schema: 'https://opencode.ai/config.json',
mcp: {
tolaria: {
type: 'local',
command: ['node', '/mock/Tolaria/mcp-server/index.js'],
enabled: true,
environment: {
WS_UI_PORT: '9711',
},
},
},
}, null, 2),
copy_text_to_clipboard: () => null,
read_text_from_clipboard: () => '',
sync_mcp_bridge_vault: (args: { vaultPath?: string | null }) => args.vaultPath ? 'started' : 'stopped',
repair_vault: (): string => {
mockWorkspaceAiGuidanceStatus = {
agents_state: 'managed',
claude_state: 'managed',
gemini_state: 'managed',
can_restore: false,
}
return 'Vault repaired'
},
reinit_telemetry: (): null => null,
}
export function addMockEntry(_entry: VaultEntry, content: string): void {
writeMockContent({ path: _entry.path, content })
syncWindowContent()
}
export function updateMockContent(path: string, content: string): void {
writeMockContent({ path, content })
syncWindowContent()
}
export function trackMockChange(path: string): void {
mockSavedSinceCommit.add(path)
}

View file

@ -0,0 +1,118 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const originalFetch = globalThis.fetch
function jsonResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
function requestUrl(input: RequestInfo | URL) {
return input instanceof Request ? input.url : String(input)
}
function requestBody(init?: RequestInit) {
return JSON.parse(String(init?.body)) as Record<string, unknown>
}
function mockNoteContentFetch(content: string) {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = requestUrl(input)
if (url === '/api/vault/ping') {
return jsonResponse({ ok: true })
}
if (url === '/api/vault/content') {
expect(requestBody(init)).toEqual({ path: '/fixture/alpha.md' })
return jsonResponse({ content })
}
throw new Error(`Unexpected fetch: ${url}`)
})
globalThis.fetch = fetchMock as typeof fetch
return fetchMock
}
describe('tryVaultApi', () => {
afterEach(() => {
vi.resetModules()
vi.restoreAllMocks()
globalThis.fetch = originalFetch
})
it('retries vault API discovery after an unavailable response', async () => {
let vaultApiOnline = false
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = requestUrl(input)
if (url === '/api/vault/ping') {
return jsonResponse({ ok: vaultApiOnline }, vaultApiOnline ? 200 : 503)
}
if (url === '/api/vault/list') {
expect(requestBody(init)).toEqual({ path: '/fixture', reload: false })
return jsonResponse([{ title: 'Alpha Project' }])
}
throw new Error(`Unexpected fetch: ${url}`)
})
globalThis.fetch = fetchMock as typeof fetch
const { tryVaultApi } = await import('./vault-api')
await expect(tryVaultApi('list_vault', { path: '/fixture' })).resolves.toBeUndefined()
vaultApiOnline = true
await expect(tryVaultApi('list_vault', { path: '/fixture' })).resolves.toEqual([{ title: 'Alpha Project' }])
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/api/vault/ping')).toHaveLength(2)
})
it('unwraps note content responses from the vault API', async () => {
const fetchMock = mockNoteContentFetch('# Alpha Project')
const { tryVaultApi } = await import('./vault-api')
await expect(tryVaultApi('get_note_content', { path: '/fixture/alpha.md' })).resolves.toBe('# Alpha Project')
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/api/vault/ping')).toHaveLength(1)
})
it('validates cached note content through the vault API', async () => {
mockNoteContentFetch('# Alpha Project')
const { tryVaultApi } = await import('./vault-api')
await expect(tryVaultApi('validate_note_content', {
path: '/fixture/alpha.md',
content: '# Alpha Project',
})).resolves.toBe(true)
await expect(tryVaultApi('validate_note_content', {
path: '/fixture/alpha.md',
content: '# Stale',
})).resolves.toBe(false)
})
it('accepts nested Tauri command args when routing browser vault API writes', async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = requestUrl(input)
if (url === '/api/vault/ping') {
return jsonResponse({ ok: true })
}
if (url === '/api/vault/rename') {
expect(requestBody(init)).toEqual({
old_path: '/fixture/untitled-note-123.md',
new_title: 'Fresh Title',
vault_path: '/fixture',
})
return jsonResponse({ new_path: '/fixture/fresh-title.md', updated_files: 0 })
}
throw new Error(`Unexpected fetch: ${url}`)
})
globalThis.fetch = fetchMock as typeof fetch
const { tryVaultApi } = await import('./vault-api')
await expect(tryVaultApi('rename_note', {
args: {
old_path: '/fixture/untitled-note-123.md',
new_title: 'Fresh Title',
vault_path: '/fixture',
},
})).resolves.toEqual({ new_path: '/fixture/fresh-title.md', updated_files: 0 })
})
})

View file

@ -0,0 +1,217 @@
/**
* Vault API detection and proxy for browser dev mode.
* When a local vault API server is running, routes read and write commands
* through it instead of returning hardcoded mock data.
*/
let vaultApiAvailable: boolean | null = null
async function detectVaultApiAvailability(): Promise<boolean> {
try {
const res = await fetch('/api/vault/ping', { signal: AbortSignal.timeout(500) })
return res.ok
} catch (error) {
void error
return false
}
}
async function checkVaultApi(): Promise<boolean> {
if (vaultApiAvailable === true) return true
const available = await detectVaultApiAvailability()
vaultApiAvailable = available
console.info(`[mock-tauri] Vault API available: ${vaultApiAvailable}`)
return available
}
interface VaultApiGetRequest {
body: Record<string, unknown>
kind: 'all-content' | 'content' | 'entry' | 'list' | 'search'
}
interface VaultApiPostRequest {
body: Record<string, unknown>
kind: 'delete' | 'rename' | 'rename-filename' | 'save'
}
type VaultApiRequest = VaultApiGetRequest | VaultApiPostRequest
/** Tracks last vault path for commands that don't receive it as an argument. */
let lastVaultPath: string | null = null
type PathQueryCommand =
| 'reload_vault_entry'
| 'get_note_content'
| 'validate_note_content'
| 'get_all_content'
function argText(args: Record<string, unknown>, key: string): string | null {
const value = Reflect.get(args, key)
return value ? String(value) : null
}
function commandArgs(args: Record<string, unknown>): Record<string, unknown> {
const nestedArgs = Reflect.get(args, 'args')
if (!nestedArgs || typeof nestedArgs !== 'object') return args
return nestedArgs as Record<string, unknown>
}
function buildListRequest(args: Record<string, unknown>, reload: boolean): VaultApiRequest | null {
const payload = commandArgs(args)
const path = argText(payload, 'path')
if (!path) return null
lastVaultPath = path
return { kind: 'list', body: { path, reload } }
}
function buildPathQueryRequest(cmd: PathQueryCommand, args: Record<string, unknown>): VaultApiRequest | null {
const payload = commandArgs(args)
const path = argText(payload, 'path')
if (!path) return null
return { kind: pathQueryKind(cmd), body: { path } }
}
function buildRequiredPostRequest(
kind: VaultApiPostRequest['kind'],
required: unknown,
body: Record<string, unknown>,
): VaultApiRequest | null {
return required ? { kind, body } : null
}
function buildRequiredPathPostRequest(
kind: VaultApiPostRequest['kind'],
args: Record<string, unknown>,
body: Record<string, unknown>,
): VaultApiRequest | null {
return buildRequiredPostRequest(kind, args.path, body)
}
function buildSearchRequest(args: Record<string, unknown>): VaultApiRequest | null {
const payload = commandArgs(args)
const query = argText(payload, 'query')
if (!query || !lastVaultPath) return null
const mode = argText(payload, 'mode') ?? 'all'
const body: Record<string, unknown> = { mode, query, vault_path: lastVaultPath }
if (Reflect.get(payload, 'excludeFrontmatter') === true) body.exclude_frontmatter = true
return { kind: 'search', body }
}
function isPathQueryCommand(cmd: string): cmd is PathQueryCommand {
return cmd === 'reload_vault_entry'
|| cmd === 'get_note_content'
|| cmd === 'validate_note_content'
|| cmd === 'get_all_content'
}
function pathQueryKind(command: PathQueryCommand): VaultApiGetRequest['kind'] {
if (command === 'reload_vault_entry') return 'entry'
if (command === 'get_all_content') return 'all-content'
return 'content'
}
function buildPostRequest(cmd: string, args: Record<string, unknown>): VaultApiRequest | null {
const payload = commandArgs(args)
if (cmd === 'save_note_content') {
return buildRequiredPathPostRequest('save', payload, {
content: payload.content,
path: payload.path,
})
}
if (cmd === 'rename_note') {
return buildRequiredPostRequest('rename', payload.old_path, {
new_title: payload.new_title,
old_path: payload.old_path,
vault_path: payload.vault_path,
})
}
if (cmd === 'rename_note_filename') {
return buildRequiredPostRequest('rename-filename', payload.old_path, {
new_filename_stem: payload.new_filename_stem,
old_path: payload.old_path,
vault_path: payload.vault_path,
})
}
if (cmd === 'delete_note') return buildRequiredPathPostRequest('delete', payload, { path: payload.path })
return null
}
function buildVaultApiRequest(cmd: string, args?: Record<string, unknown>): VaultApiRequest | null {
if (!args) return null
if (cmd === 'list_vault') return buildListRequest(args, false)
if (cmd === 'reload_vault') return buildListRequest(args, true)
if (cmd === 'search_vault') return buildSearchRequest(args)
if (isPathQueryCommand(cmd)) return buildPathQueryRequest(cmd, args)
return buildPostRequest(cmd, args)
}
function buildFetchOptions(request: { body: Record<string, unknown> }): RequestInit {
return {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(request.body),
}
}
async function fetchVaultApiResponse(request: VaultApiRequest) {
const res = await fetchVaultApiRequest(request)
if (!res.ok) return undefined
return res.json()
}
function isGetRequest(request: VaultApiRequest): request is VaultApiGetRequest {
return request.kind === 'all-content'
|| request.kind === 'content'
|| request.kind === 'entry'
|| request.kind === 'list'
|| request.kind === 'search'
}
function fetchVaultApiGetRequest(request: VaultApiGetRequest): Promise<Response> {
if (request.kind === 'all-content') {
return fetch('/api/vault/all-content', buildFetchOptions(request))
}
if (request.kind === 'content') {
return fetch('/api/vault/content', buildFetchOptions(request))
}
if (request.kind === 'entry') {
return fetch('/api/vault/entry', buildFetchOptions(request))
}
if (request.kind === 'list') {
return fetch('/api/vault/list', buildFetchOptions(request))
}
return fetch('/api/vault/search', buildFetchOptions(request))
}
function fetchVaultApiPostRequest(request: VaultApiPostRequest): Promise<Response> {
if (request.kind === 'delete') return fetch('/api/vault/delete', buildFetchOptions(request))
if (request.kind === 'rename') return fetch('/api/vault/rename', buildFetchOptions(request))
if (request.kind === 'rename-filename') return fetch('/api/vault/rename-filename', buildFetchOptions(request))
return fetch('/api/vault/save', buildFetchOptions(request))
}
function fetchVaultApiRequest(request: VaultApiRequest): Promise<Response> {
return isGetRequest(request)
? fetchVaultApiGetRequest(request)
: fetchVaultApiPostRequest(request)
}
export async function tryVaultApi<T>(cmd: string, args?: Record<string, unknown>): Promise<T | undefined> {
const request = buildVaultApiRequest(cmd, args)
if (!request) return undefined
if (!await checkVaultApi()) return undefined
try {
const data = await fetchVaultApiResponse(request)
if (data === undefined) return undefined
if (cmd === 'get_note_content') return data.content as T
if (cmd === 'validate_note_content') return (data.content === args?.content) as T
return data as T
} catch (err) {
console.warn(`[mock-tauri] Vault API call failed for ${cmd}, falling back to mock:`, err)
return undefined
}
}