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
}
}