feat: clone the starter vault on demand
This commit is contained in:
@@ -510,7 +510,7 @@ Per-vault settings stored locally and scoped by vault path:
|
||||
|
||||
`useOnboarding` hook detects first launch:
|
||||
- If vault path doesn't exist → show `WelcomeScreen`
|
||||
- User can create Getting Started vault or open existing folder
|
||||
- User can create a new empty vault, open an existing folder, or clone the public Getting Started vault into a chosen folder
|
||||
- Welcome state tracked in localStorage (`laputa_welcome_dismissed`)
|
||||
|
||||
### GitHub Integration
|
||||
|
||||
@@ -423,9 +423,12 @@ Per-vault UI settings stored locally per vault path (currently in browser/Tauri
|
||||
|
||||
### Getting Started Vault
|
||||
|
||||
On first launch, `useOnboarding` checks if the default vault exists. If not, shows `WelcomeScreen` with two options:
|
||||
- **Create Getting Started vault** → calls `create_getting_started_vault()` Tauri command
|
||||
On first launch, `useOnboarding` checks if the default vault exists. If not, it shows `WelcomeScreen` with three options:
|
||||
- **Create a new vault** → creates an empty git repo in a folder the user chooses
|
||||
- **Open an existing folder** → system file picker
|
||||
- **Get started with a template** → pick a folder, then call `create_getting_started_vault()` to clone the public starter repo at runtime
|
||||
|
||||
The starter content no longer lives in the app repo. `src-tauri/src/vault/getting_started.rs` only holds the public GitHub URL and delegates the actual clone to the existing git backend.
|
||||
|
||||
### GitHub OAuth Integration
|
||||
|
||||
@@ -589,7 +592,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
|
||||
| `reload_vault` | Invalidate cache and full rescan from filesystem → `Vec<VaultEntry>` |
|
||||
| `reload_vault_entry` | Re-read a single file from disk → `VaultEntry` |
|
||||
| `check_vault_exists` | Check if vault path exists |
|
||||
| `create_getting_started_vault` | Bootstrap demo vault |
|
||||
| `create_getting_started_vault` | Clone the public Getting Started vault into a chosen local folder |
|
||||
|
||||
### Frontmatter
|
||||
|
||||
|
||||
@@ -142,7 +142,7 @@ laputa-app/
|
||||
│ │ │ ├── rename.rs # Rename + cross-vault wikilink update
|
||||
│ │ │ ├── image.rs # Image attachment saving
|
||||
│ │ │ ├── migration.rs # Frontmatter migration
|
||||
│ │ │ └── getting_started.rs # Getting Started vault creation
|
||||
│ │ │ └── getting_started.rs # Getting Started vault clone orchestration
|
||||
│ │ ├── frontmatter/ # Frontmatter module
|
||||
│ │ │ ├── mod.rs, yaml.rs, ops.rs
|
||||
│ │ ├── git/ # Git module
|
||||
@@ -201,7 +201,7 @@ laputa-app/
|
||||
|------|---------------|
|
||||
| `src/hooks/useVaultLoader.ts` | How vault data is loaded and managed. The Tauri/mock branching pattern. |
|
||||
| `src/hooks/useNoteActions.ts` | Orchestrates note operations: composes `useNoteCreation`, `useNoteRename`, frontmatter CRUD, and wikilink navigation. |
|
||||
| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, Getting Started vault. |
|
||||
| `src/hooks/useVaultSwitcher.ts` | Multi-vault management, vault switching, and restoring the cloned Getting Started vault. |
|
||||
| `src/mock-tauri.ts` | Mock data for browser testing. Shows the shape of all Tauri responses. |
|
||||
|
||||
### Backend
|
||||
|
||||
3
getting-started-vault/.gitignore
vendored
3
getting-started-vault/.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
@@ -1,81 +0,0 @@
|
||||
# AGENTS.md — Laputa Vault
|
||||
|
||||
This is a [Laputa](https://github.com/refactoringhq/laputa-app) vault — a folder of markdown files with YAML frontmatter forming a personal knowledge graph.
|
||||
|
||||
## Note structure
|
||||
|
||||
Every note is a markdown file. The **first H1 heading in the body is the title** — there is no `title:` frontmatter field.
|
||||
|
||||
```yaml
|
||||
---
|
||||
is_a: TypeName # the note's type (must match the title of a type file in the vault)
|
||||
url: https://... # example property
|
||||
belongs_to: "[[other-note]]"
|
||||
related_to:
|
||||
- "[[note-a]]"
|
||||
- "[[note-b]]"
|
||||
---
|
||||
|
||||
# Note Title
|
||||
|
||||
Body content in markdown.
|
||||
```
|
||||
|
||||
System properties are prefixed with `_` (e.g. `_organized`, `_pinned`, `_icon`) — these are app-managed, do not set or show them to users unless specifically asked.
|
||||
|
||||
## Types
|
||||
|
||||
A type is a note with `is_a: Type`. Type files live in the vault root:
|
||||
|
||||
```yaml
|
||||
---
|
||||
is_a: Type
|
||||
_icon: books # Phosphor icon name in kebab-case
|
||||
_color: "#8b5cf6" # hex color
|
||||
---
|
||||
|
||||
# TypeName
|
||||
```
|
||||
|
||||
To find what types exist: look for files with `is_a: Type` in the vault root.
|
||||
|
||||
## Relationships
|
||||
|
||||
Any frontmatter property whose value is a wikilink is a relationship. Backlinks are computed automatically.
|
||||
|
||||
Standard names: `belongs_to`, `related_to`, `has`. Custom names are valid.
|
||||
|
||||
## Wikilinks
|
||||
|
||||
- `[[filename]]` or `[[Note Title]]` — link by filename or title
|
||||
- `[[filename|display text]]` — with custom display text
|
||||
- Works in frontmatter values and markdown body
|
||||
|
||||
## Views
|
||||
|
||||
Saved filters live in `views/` as `.view.json` files:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Active Notes",
|
||||
"filters": [
|
||||
{"property": "is_a", "operator": "equals", "value": "Note"},
|
||||
{"property": "status", "operator": "equals", "value": "Active"}
|
||||
],
|
||||
"sort": {"property": "title", "direction": "asc"}
|
||||
}
|
||||
```
|
||||
|
||||
## Filenames
|
||||
|
||||
Use kebab-case: `my-note-title.md`. One note per file.
|
||||
|
||||
## What you can do
|
||||
|
||||
- Create/edit notes with correct frontmatter and H1 title
|
||||
- Create new type files
|
||||
- Add or modify relationships
|
||||
- Create/edit views in `views/`
|
||||
- Edit `AGENTS.md` (this file)
|
||||
|
||||
Do not modify app configuration files — those are local to each installation.
|
||||
@@ -1,3 +0,0 @@
|
||||
@AGENTS.md
|
||||
|
||||
This file is a Claude Code compatibility shim. Keep shared vault instructions in `AGENTS.md`.
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
title: AI and Git
|
||||
is_a: Note
|
||||
belongs_to: "[[Getting Started]]"
|
||||
---
|
||||
|
||||
## Claude Code
|
||||
|
||||
Laputa integrates with [Claude Code](https://docs.anthropic.com/claude-code) — Anthropic's CLI agent. If you have `claude` installed, you can ask it to operate directly on your vault:
|
||||
|
||||
```
|
||||
claude "Create a note for the book Zero to One by Peter Thiel, with a rating and a topic"
|
||||
```
|
||||
|
||||
Claude understands Laputa's format (frontmatter, types, wikilinks, relationships) and creates or edits files accordingly. Your vault's `AGENTS.md` file gives any coding agent full context, and `CLAUDE.md` imports it for Claude Code compatibility.
|
||||
|
||||
## Git sync
|
||||
|
||||
Your vault is a Git repository. Every save in Laputa is tracked as a file change. Use the **Changes** view in the sidebar to see what's modified, commit with a message, and push to a remote.
|
||||
|
||||
```bash
|
||||
# From inside your vault folder
|
||||
git remote add origin https://github.com/you/my-vault.git
|
||||
git push -u origin main
|
||||
```
|
||||
|
||||
After that, Laputa can push and pull directly from the app.
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
title: Capturing People and Meetings
|
||||
is_a: Note
|
||||
related_to: "[[Personal Knowledge Management]]"
|
||||
author: "[[Luca Rossi]]"
|
||||
date: 2025-01-28
|
||||
---
|
||||
|
||||
The Person type is one of the most useful in my vault. Here's how I use it.
|
||||
|
||||
## One note per person
|
||||
|
||||
Every person I interact with meaningfully gets a Person note. Not just colleagues — also people I meet at conferences, authors whose work I follow, collaborators I might reach out to.
|
||||
|
||||
A minimal Person note looks like this:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Matteo Cellini
|
||||
is_a: Person
|
||||
role: Head of Partnerships
|
||||
related_to: "[[Refactoring Newsletter]]"
|
||||
---
|
||||
```
|
||||
|
||||
The body holds context: how we met, what they're working on, anything I want to remember.
|
||||
|
||||
## Meetings as connections
|
||||
|
||||
When I have a meeting, I create a note for it and link everyone present via `related_to`. This means every Person note accumulates backlinks over time — a natural history of interactions without any manual effort.
|
||||
|
||||
## Finding things later
|
||||
|
||||
The power comes when you need to remember something. Open a person's note, look at their backlinks — you see every meeting, every shared project, every note that mentioned them. It's the closest thing I've found to having a good memory.
|
||||
|
||||
## The pattern
|
||||
|
||||
Person notes are intentionally sparse upfront. I add context as I interact with people. A note that starts as just a name and a role grows into something genuinely useful over months.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
title: Getting Started
|
||||
is_a: Topic
|
||||
---
|
||||
|
||||
This topic groups all the onboarding notes for Laputa. Start with [[Welcome to Laputa]] for an overview, then explore each note at your own pace.
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
title: How I Organize My Vault
|
||||
is_a: Note
|
||||
related_to: "[[Personal Knowledge Management]]"
|
||||
author: "[[Luca Rossi]]"
|
||||
date: 2025-01-15
|
||||
---
|
||||
|
||||
My vault follows a structure loosely inspired by PARA, adapted to how I actually think and work.
|
||||
|
||||
## The four types I use
|
||||
|
||||
**Projects** — things with a clear outcome and an end date. Building a feature, writing an article, preparing a talk. Projects are active or done, never vague.
|
||||
|
||||
**Responsibilities** — areas I own ongoing, with no end date. Newsletter, health, finances, team. A Responsibility never "completes" — it just gets better or worse.
|
||||
|
||||
**Topics** — concepts, ideas, and subjects I care about. Personal Knowledge Management, Software Architecture, Cycling Training. Topics are the intellectual threads that run through everything else.
|
||||
|
||||
**People** — anyone I interact with meaningfully. Each person has a note with context, how we met, what we've worked on together.
|
||||
|
||||
## How they connect
|
||||
|
||||
A Project `belongs_to` a Responsibility. A note `related_to` a Topic. A meeting note `related_to` the people who attended. Over time, these connections turn a flat list of files into something closer to how memory actually works.
|
||||
|
||||
## Events
|
||||
|
||||
I also sync calendar events into my vault as Event notes — one note per meeting or important event, linked to the people present. [[Luca Rossi]]'s AI assistant Brian handles this automatically via a cron job.
|
||||
|
||||
## The rule I follow
|
||||
|
||||
If I create a note and don't connect it to anything within a day or two, it goes to Inbox and stays there until I organize it. The Inbox is the queue — not a dumping ground.
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
title: Luca Rossi
|
||||
is_a: Person
|
||||
role: Founder
|
||||
website: https://refactoring.fm
|
||||
twitter: https://twitter.com/lucaronin
|
||||
related_to:
|
||||
- "[[Personal Knowledge Management]]"
|
||||
- "[[Getting Started]]"
|
||||
---
|
||||
|
||||
Creator of Laputa and founder of [Refactoring](https://refactoring.fm), a newsletter about engineering leadership and software craft for senior engineers and engineering leaders.
|
||||
|
||||
Luca built Laputa to solve his own problem: after years of using Notion, Roam, and Obsidian, he wanted a knowledge base that was truly his — plain files, real version control, and AI that can operate on the vault directly.
|
||||
|
||||
Some of his writing on how he thinks about knowledge management:
|
||||
- [[How I Organize My Vault]]
|
||||
- [[Why Plain Files]]
|
||||
- [[Syncing Calendar Events into Laputa]]
|
||||
- [[Capturing People and Meetings]]
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
title: Note
|
||||
is_a: Type
|
||||
_icon: FileText
|
||||
_color: "#6366f1"
|
||||
---
|
||||
|
||||
A Note is a general-purpose document — ideas, references, meeting notes, or anything that doesn't fit a more specific type.
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
title: Person
|
||||
is_a: Type
|
||||
_icon: UserCircle
|
||||
_color: "#f59e0b"
|
||||
---
|
||||
|
||||
A Person is someone you interact with — colleagues, collaborators, mentors, or friends.
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
title: Personal Knowledge Management
|
||||
is_a: Topic
|
||||
---
|
||||
|
||||
Personal Knowledge Management (PKM) is the practice of collecting, organizing, and connecting the information you encounter — notes, ideas, references, and people — so it becomes a durable personal asset.
|
||||
|
||||
Laputa is designed as a PKM tool. Unlike traditional note-taking apps, it treats your notes as a graph of interconnected entities: types give structure, wikilinks create connections, views let you slice through the graph from different angles.
|
||||
|
||||
## How Luca uses Laputa for PKM
|
||||
|
||||
These notes describe the actual system behind this vault — written by [[Luca Rossi]] as examples you can learn from and adapt:
|
||||
|
||||
- [[How I Organize My Vault]] — the structure: Projects, Responsibilities, Topics, People
|
||||
- [[Syncing Calendar Events into Laputa]] — turning meetings into connected knowledge
|
||||
- [[Capturing People and Meetings]] — building a useful network of Person notes
|
||||
- [[Why Plain Files]] — why markdown + Git beats proprietary tools
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
title: Sidebar and Navigation
|
||||
is_a: Note
|
||||
belongs_to: "[[Getting Started]]"
|
||||
---
|
||||
|
||||
## Main sections
|
||||
|
||||
- **Inbox** — notes you haven't organized yet. A note leaves the Inbox when you mark it as "organized" using the ✓ button in the breadcrumb bar.
|
||||
- **All Notes** — every note in your vault
|
||||
- **Archive** — notes you've finished with but want to keep
|
||||
- **Trash** — deleted notes, recoverable for 30 days
|
||||
|
||||
## Types section
|
||||
|
||||
Below the main sections, the sidebar lists your custom types (Note, Topic, Person, and any you create). Click a type to see all notes of that type.
|
||||
|
||||
Use the sliders icon next to **TYPES** to show or hide types from the sidebar. Use the **+** to create a new type.
|
||||
|
||||
## Favorites
|
||||
|
||||
Star any note to pin it to the top of the sidebar. Click the ⭐ icon in the breadcrumb bar at the top of the editor, or use **Cmd+K → Favorite**.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
| Action | Shortcut |
|
||||
|--------|----------|
|
||||
| Quick open | Cmd+P |
|
||||
| Command palette | Cmd+K |
|
||||
| New note | Cmd+N |
|
||||
| Settings | Cmd+, |
|
||||
| Search | Cmd+F |
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
title: Syncing Calendar Events into Laputa
|
||||
is_a: Note
|
||||
related_to: "[[Personal Knowledge Management]]"
|
||||
author: "[[Luca Rossi]]"
|
||||
date: 2025-02-10
|
||||
---
|
||||
|
||||
One pattern I've found genuinely useful: every significant meeting or event gets a note in my vault.
|
||||
|
||||
## How it works
|
||||
|
||||
My AI assistant Brian runs a cron job that checks my calendar daily. For each meeting, it creates (or updates) an Event note in my vault with the relevant metadata — title, date, attendees — and links each attendee to their Person note.
|
||||
|
||||
The result: every person I meet has a trail of events in their backlinks. I can open [[Luca Rossi]]'s note and immediately see every meeting we've had, what was discussed, what followed.
|
||||
|
||||
## What an Event note looks like
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: 1:1 with Matteo — Jan 10
|
||||
is_a: Event
|
||||
date: 2025-01-10
|
||||
related_to:
|
||||
- "[[Matteo Cellini]]"
|
||||
- "[[Refactoring Newsletter]]"
|
||||
---
|
||||
```
|
||||
|
||||
The body holds notes from the meeting — decisions, action items, context.
|
||||
|
||||
## Why this matters
|
||||
|
||||
Without this, meetings exist only in my calendar and my memory. With it, they become searchable, connected knowledge. A year later I can search "Matteo sponsorship" and find the exact conversation where we made a decision.
|
||||
|
||||
You don't need a cron job to do this — you can create Event notes manually. The pattern is what matters.
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
title: Topic
|
||||
is_a: Type
|
||||
_icon: Hash
|
||||
_color: "#10b981"
|
||||
---
|
||||
|
||||
A Topic is a subject or area of interest that groups related notes together.
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
title: Types, Properties and Relationships
|
||||
is_a: Note
|
||||
belongs_to: "[[Getting Started]]"
|
||||
---
|
||||
|
||||
## Types
|
||||
|
||||
A **type** is a category for your notes — Person, Project, Topic, Book, or anything you invent. Each type gets its own icon, color, and section in the sidebar.
|
||||
|
||||
Create a type by adding a markdown file with `is_a: Type` in the frontmatter:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Book
|
||||
is_a: Type
|
||||
_icon: BookOpen
|
||||
_color: "#8b5cf6"
|
||||
---
|
||||
```
|
||||
|
||||
Then tag any note with `is_a: Book` to classify it as a book.
|
||||
|
||||
## Properties
|
||||
|
||||
Properties are any key-value pairs in the frontmatter:
|
||||
|
||||
```yaml
|
||||
rating: 4
|
||||
status: Active
|
||||
url: https://example.com
|
||||
```
|
||||
|
||||
They appear in the **Inspector** panel on the right. Click "+ Add property" to add one.
|
||||
|
||||
## Relationships
|
||||
|
||||
Relationships are properties whose values are wikilinks to other notes:
|
||||
|
||||
```yaml
|
||||
belongs_to: "[[Some Project]]"
|
||||
related_to:
|
||||
- "[[Note A]]"
|
||||
- "[[Note B]]"
|
||||
```
|
||||
|
||||
Standard relationships: `belongs_to`, `related_to`, `has`. You can define your own.
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
title: Untitled person
|
||||
type: Person
|
||||
---
|
||||
|
||||
## Role
|
||||
|
||||
|
||||
|
||||
## Contact
|
||||
|
||||
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
title: Using the Editor
|
||||
is_a: Note
|
||||
belongs_to: "[[Getting Started]]"
|
||||
---
|
||||
|
||||
Laputa uses a rich markdown editor. Every note has two parts: **frontmatter** and **body**.
|
||||
|
||||
## Frontmatter
|
||||
|
||||
The YAML block at the top (between `---`) stores metadata:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: My Note
|
||||
is_a: Note
|
||||
status: Active
|
||||
belongs_to: "[[Some Project]]"
|
||||
---
|
||||
```
|
||||
|
||||
- `title` — the note's display name (no H1 needed in the body)
|
||||
- `is_a` — the type of the note
|
||||
- Any other key becomes a property visible in the Inspector
|
||||
|
||||
## Body
|
||||
|
||||
Write in standard markdown: headings, lists, checkboxes, code blocks, bold, italic.
|
||||
|
||||
## Wikilinks
|
||||
|
||||
Type `[[` anywhere to search and link to another note:
|
||||
|
||||
```
|
||||
See also [[What is Laputa]] for context.
|
||||
```
|
||||
|
||||
Wikilinks create relationships between notes and power the graph view and backlinks panel.
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
title: Views and Search
|
||||
is_a: Note
|
||||
belongs_to: "[[Getting Started]]"
|
||||
---
|
||||
|
||||
## Search
|
||||
|
||||
Press **Cmd+P** to open the Quick Open palette and jump to any note by name.
|
||||
|
||||
Use the search bar in the sidebar to filter notes by text.
|
||||
|
||||
## Command Palette
|
||||
|
||||
Press **Cmd+K** to open the Command Palette — your shortcut to every action in Laputa: create a note, change a type, toggle a view, run a command.
|
||||
|
||||
## Views
|
||||
|
||||
Views are saved filters that show a subset of your vault. Create a view to answer questions like "all active projects" or "notes tagged design".
|
||||
|
||||
Click the **+** next to VIEWS in the sidebar to create a new view. Set filters by type, status, property value, or body content.
|
||||
|
||||
Views are saved as `.view.json` files in your vault's `views/` folder — they travel with your vault via Git.
|
||||
@@ -1,12 +0,0 @@
|
||||
name: Active Projects
|
||||
icon: rocket-launch
|
||||
color: purple
|
||||
sort: "modified:desc"
|
||||
filters:
|
||||
all:
|
||||
- field: type
|
||||
op: equals
|
||||
value: Project
|
||||
- field: Status
|
||||
op: equals
|
||||
value: Active
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
title: Welcome to Laputa
|
||||
is_a: Note
|
||||
belongs_to: "[[Getting Started]]"
|
||||
_pinned: true
|
||||
---
|
||||
|
||||
Welcome to Laputa — your personal knowledge base, stored as plain markdown files and versioned with Git.
|
||||
|
||||
This vault is your starting point. It contains real notes you can read, edit, and use as a model for your own.
|
||||
|
||||
## Start here
|
||||
|
||||
**Step 1 — Learn the basics**
|
||||
Read these three notes in order:
|
||||
1. [[What is Laputa]] — the philosophy (2 min)
|
||||
2. [[Using the Editor]] — how notes work (3 min)
|
||||
3. [[Types, Properties and Relationships]] — how to structure knowledge (3 min)
|
||||
|
||||
**Step 2 — Explore the app**
|
||||
4. [[Sidebar and Navigation]] — Inbox, Favorites, types
|
||||
5. [[Views and Search]] — Cmd+K, Cmd+P, saved views
|
||||
|
||||
**Step 3 — See it in action**
|
||||
Browse the [[Personal Knowledge Management]] topic to see how [[Luca Rossi]], Laputa's creator, actually uses the app — with real notes about his system, his workflows, and why he built it this way.
|
||||
|
||||
**Step 4 — Make it yours**
|
||||
Try editing this note. Create a new note (Cmd+N). Add a type. Connect two notes with a `[[wikilink]]`.
|
||||
|
||||
---
|
||||
|
||||
**Step 5 — AI and Git**
|
||||
Read [[AI and Git]] to learn how to use Claude Code to operate on your vault, and how to sync it with GitHub.
|
||||
|
||||
---
|
||||
|
||||
*This vault was created by [[Luca Rossi]]. You own it — edit everything.*
|
||||
@@ -1,15 +0,0 @@
|
||||
---
|
||||
title: What is Laputa
|
||||
is_a: Note
|
||||
belongs_to: "[[Getting Started]]"
|
||||
---
|
||||
|
||||
Laputa is a personal knowledge base built on three principles:
|
||||
|
||||
**Plain files.** Every note is a markdown file on your filesystem. No proprietary database, no lock-in. You can open, edit, and search your notes with any text editor.
|
||||
|
||||
**Git as sync.** Your vault is a Git repository. Version history, branching, and remote sync come for free. Use GitHub, GitLab, or any remote.
|
||||
|
||||
**Structure without rigidity.** Notes have types, properties, and relationships — but they're just YAML frontmatter. The structure lives in the file, not in a schema.
|
||||
|
||||
Laputa is designed to grow with you. Start with a few notes, add types as patterns emerge, connect ideas with wikilinks. Over time, your vault becomes a map of how you think.
|
||||
@@ -1,31 +0,0 @@
|
||||
---
|
||||
title: Why Plain Files
|
||||
is_a: Note
|
||||
related_to: "[[Personal Knowledge Management]]"
|
||||
author: "[[Luca Rossi]]"
|
||||
date: 2024-11-05
|
||||
---
|
||||
|
||||
I've used Notion, Roam, Bear, and Obsidian at different points. I kept switching. Here's what I eventually decided and why.
|
||||
|
||||
## The problem with databases
|
||||
|
||||
Notion stores your knowledge in a proprietary database. It's great for collaboration and structured data, but your notes are not really yours — they live in Notion's servers, in Notion's format. Export is lossy and awkward.
|
||||
|
||||
## The problem with sync-only tools
|
||||
|
||||
Obsidian keeps your files local, which I respect. But the sync story is fragile, and the plugin ecosystem means your setup is fragile too. I've lost time to broken plugins more than once.
|
||||
|
||||
## What I wanted
|
||||
|
||||
- Files I own, in a format that will be readable in 20 years
|
||||
- Version history that actually works (not "version history" as a feature — real Git history)
|
||||
- The ability to use AI to operate on my vault, which requires the AI to be able to read and write files
|
||||
|
||||
Markdown + Git gives me all three.
|
||||
|
||||
## Laputa's bet
|
||||
|
||||
Laputa is built on the same bet: your notes are files, your vault is a Git repo, and the app is just a great interface on top of that. If Laputa disappears tomorrow, your notes are still there, still readable, still version-controlled.
|
||||
|
||||
That's the kind of tool I wanted to build — and use.
|
||||
@@ -4,38 +4,21 @@ use std::process::Command;
|
||||
/// Clones a GitHub repo to a local path using HTTPS + token auth.
|
||||
pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
|
||||
if dest.exists()
|
||||
&& dest
|
||||
.read_dir()
|
||||
.map(|mut d| d.next().is_some())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not empty",
|
||||
local_path
|
||||
));
|
||||
}
|
||||
prepare_clone_destination(dest, local_path)?;
|
||||
|
||||
// Inject token into HTTPS URL: https://github.com/... → https://oauth2:TOKEN@github.com/...
|
||||
let auth_url = inject_token_into_url(url, token)?;
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["clone", "--progress", &auth_url, local_path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git clone: {}", e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Clean up partial clone on failure
|
||||
if dest.exists() {
|
||||
let _ = std::fs::remove_dir_all(dest);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("git clone failed: {}", stderr));
|
||||
if let Err(err) = run_clone(&auth_url, local_path) {
|
||||
cleanup_failed_clone(dest);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Configure the remote to use token auth for future pushes
|
||||
configure_remote_auth(local_path, url, token)?;
|
||||
if let Err(err) = configure_remote_auth(local_path, url, token) {
|
||||
cleanup_failed_clone(dest);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
// Ensure sensible .gitignore defaults (especially .DS_Store on macOS)
|
||||
crate::git::ensure_gitignore(local_path)?;
|
||||
@@ -43,6 +26,19 @@ pub fn clone_repo(url: &str, token: &str, local_path: &str) -> Result<String, St
|
||||
Ok(format!("Cloned to {}", local_path))
|
||||
}
|
||||
|
||||
/// Clones a public repo to a local path without modifying the remote URL.
|
||||
pub fn clone_public_repo(url: &str, local_path: &str) -> Result<String, String> {
|
||||
let dest = Path::new(local_path);
|
||||
prepare_clone_destination(dest, local_path)?;
|
||||
|
||||
if let Err(err) = run_clone(url, local_path) {
|
||||
cleanup_failed_clone(dest);
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
Ok(format!("Cloned to {}", local_path))
|
||||
}
|
||||
|
||||
/// Injects an OAuth token into an HTTPS GitHub URL.
|
||||
fn inject_token_into_url(url: &str, token: &str) -> Result<String, String> {
|
||||
if let Some(rest) = url.strip_prefix("https://github.com/") {
|
||||
@@ -58,6 +54,62 @@ fn inject_token_into_url(url: &str, token: &str) -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_clone_destination(dest: &Path, local_path: &str) -> Result<(), String> {
|
||||
if dest.exists() {
|
||||
if !dest.is_dir() {
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not a directory",
|
||||
local_path
|
||||
));
|
||||
}
|
||||
let has_entries = dest
|
||||
.read_dir()
|
||||
.map_err(|e| format!("Failed to inspect destination '{}': {}", local_path, e))?
|
||||
.next()
|
||||
.is_some();
|
||||
if has_entries {
|
||||
return Err(format!(
|
||||
"Destination '{}' already exists and is not empty",
|
||||
local_path
|
||||
));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(parent) = dest.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
format!(
|
||||
"Failed to create parent directory for '{}': {}",
|
||||
local_path, e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_clone(url: &str, local_path: &str) -> Result<(), String> {
|
||||
let output = Command::new("git")
|
||||
.args(["clone", "--progress", url, local_path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run git clone: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!("git clone failed: {}", stderr.trim()))
|
||||
}
|
||||
|
||||
fn cleanup_failed_clone(dest: &Path) {
|
||||
if dest.exists() && dest.is_dir() {
|
||||
let _ = std::fs::remove_dir_all(dest);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up the git remote to use token-based HTTPS auth.
|
||||
fn configure_remote_auth(local_path: &str, original_url: &str, token: &str) -> Result<(), String> {
|
||||
let auth_url = inject_token_into_url(original_url, token)?;
|
||||
@@ -203,4 +255,70 @@ mod tests {
|
||||
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
assert_eq!(url, "https://oauth2:gho_test123@github.com/user/repo.git");
|
||||
}
|
||||
|
||||
fn init_local_repo(path: &Path) {
|
||||
std::fs::create_dir_all(path).unwrap();
|
||||
std::fs::write(path.join("welcome.md"), "# Welcome\n").unwrap();
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.email", "laputa@app.local"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.name", "Laputa App"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["commit", "-m", "Initial vault"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_public_repo_clones_local_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("source");
|
||||
let dest = dir.path().join("dest");
|
||||
init_local_repo(&source);
|
||||
|
||||
let result = clone_public_repo(source.to_str().unwrap(), dest.to_str().unwrap());
|
||||
|
||||
assert_eq!(
|
||||
result.unwrap(),
|
||||
format!("Cloned to {}", dest.to_string_lossy())
|
||||
);
|
||||
assert!(dest.join("welcome.md").exists());
|
||||
|
||||
let status = StdCommand::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(&dest)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(String::from_utf8_lossy(&status.stdout).trim().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clone_public_repo_cleans_failed_clone_destination() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let dest = dir.path().join("dest");
|
||||
let missing = dir.path().join("missing-repo");
|
||||
|
||||
let result = clone_public_repo(missing.to_str().unwrap(), dest.to_str().unwrap());
|
||||
|
||||
assert!(result.unwrap_err().contains("git clone failed"));
|
||||
assert!(!dest.exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use api::{github_create_repo, github_get_user, github_list_repos};
|
||||
pub use auth::{github_device_flow_poll, github_device_flow_start};
|
||||
pub use clone::clone_repo;
|
||||
pub use clone::{clone_public_repo, clone_repo};
|
||||
|
||||
/// GitHub App client ID for OAuth device flow.
|
||||
/// To set up: GitHub Settings → Developer settings → GitHub Apps → New GitHub App.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Public starter vault cloned when the user chooses Getting Started.
|
||||
pub const GETTING_STARTED_REPO_URL: &str =
|
||||
"https://github.com/refactoringhq/laputa-getting-started.git";
|
||||
|
||||
/// Default location for the Getting Started vault.
|
||||
pub fn default_vault_path() -> Result<PathBuf, String> {
|
||||
dirs::document_dir()
|
||||
@@ -13,11 +16,6 @@ pub fn vault_exists(path: &str) -> bool {
|
||||
Path::new(path).is_dir()
|
||||
}
|
||||
|
||||
struct SampleFile {
|
||||
rel_path: &'static str,
|
||||
content: &'static str,
|
||||
}
|
||||
|
||||
/// Default AGENTS.md content — vault instructions for AI agents.
|
||||
/// Describes Laputa vault mechanics only; no vault-specific structure.
|
||||
/// The vault scanner will pick this up as a regular entry.
|
||||
@@ -104,466 +102,158 @@ Use kebab-case: `my-note-title.md`. One note per file.
|
||||
Do not modify app configuration files — those are local to each installation.
|
||||
"##;
|
||||
|
||||
const SAMPLE_FILES: &[SampleFile] = &[
|
||||
SampleFile {
|
||||
rel_path: "project.md",
|
||||
content: "---\ntype: Type\nicon: rocket-launch\ncolor: purple\norder: 1\n---\n\n# Project\n\nA Project is a time-bounded effort with a clear goal and an eventual completion date. Projects belong to a quarter or area and advance specific goals.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "note.md",
|
||||
content: "---\ntype: Type\nicon: note\ncolor: blue\norder: 2\n---\n\n# Note\n\nA Note is a general-purpose document — research notes, meeting notes, strategy docs, or anything that doesn't fit a more specific type.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "person.md",
|
||||
content: "---\ntype: Type\nicon: user\ncolor: green\norder: 3\n---\n\n# Person\n\nA Person represents someone you interact with — a colleague, friend, mentor, or collaborator.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "topic.md",
|
||||
content: "---\ntype: Type\nicon: tag\ncolor: yellow\norder: 4\n---\n\n# Topic\n\nA Topic is a subject area or interest category that groups related notes, projects, and people.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "theme.md",
|
||||
content: "---\ntype: Type\nicon: palette\ncolor: purple\norder: 50\n---\n\n# Theme\n\nA visual theme for Laputa. Each theme defines CSS custom properties that control colors, typography, and spacing.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "config.md",
|
||||
content: "---\ntype: Type\nicon: gear-six\ncolor: gray\norder: 90\nsidebar label: Config\n---\n\n# Config\n\nVault configuration files. These control how AI agents, tools, and other integrations interact with this vault.\n",
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "welcome-to-laputa.md",
|
||||
content: r#"---
|
||||
type: Note
|
||||
Related to:
|
||||
- "[[Editor Basics]]"
|
||||
- "[[Using Properties]]"
|
||||
- "[[Wiki-Links and Relationships]]"
|
||||
---
|
||||
|
||||
# Welcome to Laputa
|
||||
|
||||
Welcome to your new knowledge vault! Laputa helps you organize your thoughts, projects, and relationships using **wiki-linked markdown files**.
|
||||
|
||||
## How it works
|
||||
|
||||
Every note is a markdown file with optional YAML frontmatter at the top. All notes live at the vault root. The `type` field in the frontmatter determines the note's type — set `type: Project` for a Project, `type: Person` for a Person, and so on.
|
||||
|
||||
## What to explore
|
||||
|
||||
- [[Editor Basics]] — Learn about headings, lists, checkboxes, and formatting
|
||||
- [[Using Properties]] — See how frontmatter properties work (status, dates, relationships)
|
||||
- [[Wiki-Links and Relationships]] — Connect your notes with `[[wiki-links]]`
|
||||
- [[Sample Project]] — A sample project with relationships and status
|
||||
- [[Sample Collaborator]] — A sample person entry
|
||||
|
||||
## Tips
|
||||
|
||||
- Press **⌘P** to quick-open any note by title
|
||||
- Press **⌘K** to open the command palette
|
||||
- Press **⌘N** to create a new note
|
||||
- Use the **sidebar** on the left to browse by type
|
||||
- Use the **inspector** on the right to edit properties and see backlinks
|
||||
"#,
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "editor-basics.md",
|
||||
content: r#"---
|
||||
type: Note
|
||||
Related to: "[[Welcome to Laputa]]"
|
||||
---
|
||||
|
||||
# Editor Basics
|
||||
|
||||
Laputa uses a rich markdown editor. Here are the key formatting features:
|
||||
|
||||
## Headings
|
||||
|
||||
Use `#` for headings. The first H1 heading becomes the note's title.
|
||||
|
||||
## Lists
|
||||
|
||||
- Bullet lists use `-` or `*`
|
||||
- They can be nested
|
||||
- Like this
|
||||
- And this
|
||||
|
||||
1. Numbered lists work too
|
||||
2. Just start with a number
|
||||
|
||||
## Checkboxes
|
||||
|
||||
- [x] Completed task
|
||||
- [ ] Pending task
|
||||
- [ ] Another thing to do
|
||||
|
||||
## Text formatting
|
||||
|
||||
You can use **bold**, *italic*, `inline code`, and ~~strikethrough~~ text.
|
||||
|
||||
## Code blocks
|
||||
|
||||
```javascript
|
||||
function hello() {
|
||||
console.log("Hello from Laputa!");
|
||||
}
|
||||
```
|
||||
|
||||
## Blockquotes
|
||||
|
||||
> "The best way to have a good idea is to have lots of ideas." — Linus Pauling
|
||||
"#,
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "using-properties.md",
|
||||
content: r#"---
|
||||
type: Note
|
||||
Status: Active
|
||||
Related to:
|
||||
- "[[Welcome to Laputa]]"
|
||||
- "[[Wiki-Links and Relationships]]"
|
||||
---
|
||||
|
||||
# Using Properties
|
||||
|
||||
Every note can have **properties** defined in the YAML frontmatter at the top of the file. Properties appear in the inspector panel on the right side of the screen.
|
||||
|
||||
## Common properties
|
||||
|
||||
- **type** — The note's type (Project, Note, Person, etc.)
|
||||
- **Status** — Current state: Active, Done, Paused, Archived, Dropped
|
||||
- **Belongs to** — Parent relationship (e.g., a project belongs to a quarter)
|
||||
- **Related to** — Lateral connections to other notes
|
||||
- **Owner** — The person responsible
|
||||
|
||||
## How to edit properties
|
||||
|
||||
1. Open the **inspector panel** (right side)
|
||||
2. Click on any property value to edit it
|
||||
3. For relationship fields, type `[[` to search for notes
|
||||
4. Use the **+ Add property** button to add custom fields
|
||||
|
||||
## Custom properties
|
||||
|
||||
You can add any custom property. If the value contains `[[wiki-links]]`, Laputa will treat it as a relationship and show it as a clickable link in the inspector.
|
||||
"#,
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "wiki-links-and-relationships.md",
|
||||
content: r#"---
|
||||
type: Note
|
||||
Related to:
|
||||
- "[[Welcome to Laputa]]"
|
||||
- "[[Using Properties]]"
|
||||
---
|
||||
|
||||
# Wiki-Links and Relationships
|
||||
|
||||
Wiki-links are the core of Laputa's knowledge graph. They let you connect any note to any other note using the `[[double bracket]]` syntax.
|
||||
|
||||
## Creating links
|
||||
|
||||
Type `[[` in the editor to open the link suggestion menu. Start typing to search for a note, then select it. The link will look like this: [[Welcome to Laputa]].
|
||||
|
||||
## Backlinks
|
||||
|
||||
When note A links to note B, note B automatically shows a **backlink** to note A in the inspector panel. This means you never have to manually maintain bidirectional links.
|
||||
|
||||
## Relationships in frontmatter
|
||||
|
||||
You can also define relationships in the frontmatter:
|
||||
|
||||
```yaml
|
||||
Belongs to: "[[Sample Project]]"
|
||||
Related to:
|
||||
- "[[Editor Basics]]"
|
||||
- "[[Using Properties]]"
|
||||
```
|
||||
|
||||
These appear as clickable pills in the inspector and are navigable with a single click.
|
||||
|
||||
## Building your knowledge graph
|
||||
|
||||
Over time, your wiki-links form a rich web of connections. Use the **Referenced By** section in the inspector to discover how notes relate to each other.
|
||||
"#,
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "sample-project.md",
|
||||
content: r#"---
|
||||
type: Project
|
||||
Status: Active
|
||||
Owner: "[[Sample Collaborator]]"
|
||||
Related to: "[[Getting Started]]"
|
||||
---
|
||||
|
||||
# Sample Project
|
||||
|
||||
This is an example project to show how projects work in Laputa.
|
||||
|
||||
## Overview
|
||||
|
||||
Projects are time-bounded efforts with clear goals. They have a **status** (Active, Paused, Done, Dropped) and can be linked to people, topics, and other notes.
|
||||
|
||||
## Goals
|
||||
|
||||
- [ ] Explore the Laputa editor and its features
|
||||
- [ ] Create your first custom note
|
||||
- [ ] Link notes together using wiki-links
|
||||
- [ ] Try editing properties in the inspector
|
||||
|
||||
## Notes
|
||||
|
||||
This project is owned by [[Sample Collaborator]] and relates to [[Getting Started]]. You can see these relationships in the inspector panel on the right.
|
||||
"#,
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "sample-collaborator.md",
|
||||
content: r#"---
|
||||
type: Person
|
||||
---
|
||||
|
||||
# Sample Collaborator
|
||||
|
||||
This is an example person entry. In your vault, you might create entries for colleagues, friends, mentors, or anyone you interact with regularly.
|
||||
|
||||
## What person entries are for
|
||||
|
||||
- Track who owns which projects
|
||||
- Record meeting notes linked to specific people
|
||||
- Build a network of relationships between people, projects, and topics
|
||||
|
||||
## Connections
|
||||
|
||||
This person is the owner of [[Sample Project]]. Check the **Referenced By** section in the inspector to see all notes that link back here.
|
||||
"#,
|
||||
},
|
||||
SampleFile {
|
||||
rel_path: "getting-started.md",
|
||||
content: r#"---
|
||||
type: Topic
|
||||
---
|
||||
|
||||
# Getting Started
|
||||
|
||||
This topic groups notes related to learning and getting started with Laputa.
|
||||
|
||||
## Related notes
|
||||
|
||||
- [[Welcome to Laputa]] — Start here for an overview
|
||||
- [[Editor Basics]] — Formatting and editor features
|
||||
- [[Using Properties]] — Frontmatter and the inspector
|
||||
- [[Wiki-Links and Relationships]] — Building your knowledge graph
|
||||
- [[Sample Project]] — A sample project with relationships
|
||||
"#,
|
||||
},
|
||||
];
|
||||
|
||||
/// Create the Getting Started vault at the specified path.
|
||||
/// Returns the absolute path to the created vault.
|
||||
/// Clone the public starter vault into the requested path.
|
||||
pub fn create_getting_started_vault(target_path: &str) -> Result<String, String> {
|
||||
let vault_dir = Path::new(target_path);
|
||||
create_getting_started_vault_from_repo(target_path, &getting_started_repo_url())
|
||||
}
|
||||
|
||||
if vault_dir.exists()
|
||||
&& vault_dir
|
||||
.read_dir()
|
||||
.map(|mut d| d.next().is_some())
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Err(format!(
|
||||
"Directory already exists and is not empty: {}",
|
||||
target_path
|
||||
));
|
||||
fn create_getting_started_vault_from_repo(
|
||||
target_path: &str,
|
||||
repo_url: &str,
|
||||
) -> Result<String, String> {
|
||||
if target_path.trim().is_empty() {
|
||||
return Err("Target path is required".to_string());
|
||||
}
|
||||
|
||||
fs::create_dir_all(vault_dir)
|
||||
.map_err(|e| format!("Failed to create vault directory: {}", e))?;
|
||||
crate::github::clone_public_repo(repo_url, target_path)?;
|
||||
canonical_vault_path(target_path)
|
||||
}
|
||||
|
||||
// Write AGENTS.md with vault instructions at root (flat structure)
|
||||
fs::write(vault_dir.join("AGENTS.md"), AGENTS_MD)
|
||||
.map_err(|e| format!("Failed to write AGENTS.md: {}", e))?;
|
||||
fn getting_started_repo_url() -> String {
|
||||
std::env::var("LAPUTA_GETTING_STARTED_REPO_URL")
|
||||
.unwrap_or_else(|_| GETTING_STARTED_REPO_URL.to_string())
|
||||
}
|
||||
|
||||
for sample in SAMPLE_FILES {
|
||||
let file_path = vault_dir.join(sample.rel_path);
|
||||
if let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create directory {}: {}", parent.display(), e))?;
|
||||
}
|
||||
fs::write(&file_path, sample.content)
|
||||
.map_err(|e| format!("Failed to write {}: {}", sample.rel_path, e))?;
|
||||
}
|
||||
|
||||
crate::git::init_repo(target_path)?;
|
||||
|
||||
Ok(vault_dir
|
||||
fn canonical_vault_path(target_path: &str) -> Result<String, String> {
|
||||
let path = Path::new(target_path);
|
||||
let canonical = path
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| vault_dir.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string())
|
||||
.map_err(|e| format!("Failed to resolve vault path '{}': {}", target_path, e))?;
|
||||
Ok(canonical.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
fn init_source_repo(path: &Path) {
|
||||
fs::create_dir_all(path.join("views")).unwrap();
|
||||
fs::write(
|
||||
path.join("welcome.md"),
|
||||
"# Welcome to Laputa\n\nThis is the starter vault.\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
path.join("views").join("active-projects.yml"),
|
||||
"title: Active Projects\nfilters: []\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
StdCommand::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.email", "laputa@app.local"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["config", "user.name", "Laputa App"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
StdCommand::new("git")
|
||||
.args(["commit", "-m", "Initial starter vault"])
|
||||
.current_dir(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_vault_path_is_in_documents() {
|
||||
fn test_default_vault_path_appends_getting_started() {
|
||||
let path = default_vault_path().unwrap();
|
||||
let path_str = path.to_string_lossy();
|
||||
assert!(path_str.contains("Documents"));
|
||||
assert!(path_str.ends_with("Getting Started"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_vault_exists_false_for_missing() {
|
||||
assert!(!vault_exists("/nonexistent/vault/path/abc123"));
|
||||
fn test_create_getting_started_vault_clones_repo() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let source = dir.path().join("starter");
|
||||
let dest = dir.path().join("Getting Started");
|
||||
init_source_repo(&source);
|
||||
|
||||
let result = create_getting_started_vault_from_repo(
|
||||
dest.to_str().unwrap(),
|
||||
source.to_str().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result, dest.canonicalize().unwrap().to_string_lossy());
|
||||
assert!(dest.join("welcome.md").exists());
|
||||
assert!(dest.join("views").join("active-projects.yml").exists());
|
||||
assert!(dest.join(".git").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_creates_files() {
|
||||
fn test_create_getting_started_vault_rejects_nonempty_destination() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("test-vault");
|
||||
let result = create_getting_started_vault(vault_path.to_str().unwrap());
|
||||
assert!(result.is_ok());
|
||||
let source = dir.path().join("starter");
|
||||
let dest = dir.path().join("Getting Started");
|
||||
init_source_repo(&source);
|
||||
fs::create_dir_all(&dest).unwrap();
|
||||
fs::write(dest.join("existing.md"), "# Existing\n").unwrap();
|
||||
|
||||
// Verify key files exist (flat structure — no config/ or theme/ dirs)
|
||||
assert!(vault_path.join("AGENTS.md").exists());
|
||||
assert!(!vault_path.join("config").exists());
|
||||
assert!(vault_path.join("welcome-to-laputa.md").exists());
|
||||
assert!(vault_path.join("editor-basics.md").exists());
|
||||
assert!(vault_path.join("using-properties.md").exists());
|
||||
assert!(vault_path.join("wiki-links-and-relationships.md").exists());
|
||||
assert!(vault_path.join("sample-project.md").exists());
|
||||
assert!(vault_path.join("sample-collaborator.md").exists());
|
||||
assert!(vault_path.join("getting-started.md").exists());
|
||||
assert!(vault_path.join("project.md").exists());
|
||||
assert!(vault_path.join("note.md").exists());
|
||||
assert!(vault_path.join("person.md").exists());
|
||||
assert!(vault_path.join("topic.md").exists());
|
||||
assert!(vault_path.join("config.md").exists());
|
||||
let err = create_getting_started_vault_from_repo(
|
||||
dest.to_str().unwrap(),
|
||||
source.to_str().unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("already exists and is not empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_rejects_non_empty_directory() {
|
||||
fn test_create_getting_started_vault_cleans_partial_clone_on_failure() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("non-empty");
|
||||
fs::create_dir_all(&vault_path).unwrap();
|
||||
fs::write(vault_path.join("existing.md"), "# Existing").unwrap();
|
||||
let missing_repo = dir.path().join("missing");
|
||||
let dest = dir.path().join("Getting Started");
|
||||
|
||||
let result = create_getting_started_vault(vault_path.to_str().unwrap());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("not empty"));
|
||||
let err = create_getting_started_vault_from_repo(
|
||||
dest.to_str().unwrap(),
|
||||
missing_repo.to_str().unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(err.contains("git clone failed"));
|
||||
assert!(!dest.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_vault_allows_empty_directory() {
|
||||
fn test_create_getting_started_vault_leaves_clean_worktree() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("empty-dir");
|
||||
fs::create_dir_all(&vault_path).unwrap();
|
||||
let source = dir.path().join("starter");
|
||||
let dest = dir.path().join("Getting Started");
|
||||
init_source_repo(&source);
|
||||
|
||||
let result = create_getting_started_vault(vault_path.to_str().unwrap());
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sample_files_have_valid_frontmatter() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("validation-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
for sample in SAMPLE_FILES {
|
||||
let file_path = vault_path.join(sample.rel_path);
|
||||
let content = fs::read_to_string(&file_path).unwrap();
|
||||
// Verify each file has frontmatter delimiters
|
||||
assert!(
|
||||
content.starts_with("---\n"),
|
||||
"{} should start with frontmatter",
|
||||
sample.rel_path
|
||||
);
|
||||
assert!(
|
||||
content.matches("---").count() >= 2,
|
||||
"{} should have closing frontmatter delimiter",
|
||||
sample.rel_path
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sample_files_parseable_as_vault_entries() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("parse-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entries =
|
||||
crate::vault::scan_vault(&vault_path, &std::collections::HashMap::new()).unwrap();
|
||||
// SAMPLE_FILES + AGENTS.md
|
||||
assert_eq!(entries.len(), SAMPLE_FILES.len() + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_md_present_at_root_after_vault_creation() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("agents-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let agents_path = vault_path.join("AGENTS.md");
|
||||
assert!(agents_path.exists(), "AGENTS.md should exist at vault root");
|
||||
|
||||
let content = fs::read_to_string(&agents_path).unwrap();
|
||||
assert!(content.contains("Laputa Vault"));
|
||||
assert!(content.contains("## Note structure"));
|
||||
assert!(content.contains("## Types"));
|
||||
assert!(content.contains("## Wikilinks"));
|
||||
assert!(content.contains("## Relationships"));
|
||||
assert!(content.contains("## Views"));
|
||||
// Must NOT be a stub
|
||||
assert!(
|
||||
!content.contains("See config/agents.md"),
|
||||
"AGENTS.md should have full content, not a redirect"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_agents_md_parseable_as_vault_entry() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("agents-parse-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let entry = crate::vault::parse_md_file(&vault_path.join("AGENTS.md"), None).unwrap();
|
||||
// H1 is now the primary title source
|
||||
assert_eq!(entry.title, "AGENTS.md \u{2014} Laputa Vault");
|
||||
// Config files have no frontmatter type field — type is None
|
||||
assert_eq!(entry.is_a, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_initializes_git() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("git-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
assert!(vault_path.join(".git").exists());
|
||||
|
||||
let log = std::process::Command::new("git")
|
||||
.args(["log", "--oneline"])
|
||||
.current_dir(&vault_path)
|
||||
.output()
|
||||
create_getting_started_vault_from_repo(dest.to_str().unwrap(), source.to_str().unwrap())
|
||||
.unwrap();
|
||||
let log_str = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(log_str.contains("Initial vault setup"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_getting_started_vault_no_untracked_files() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let vault_path = dir.path().join("clean-vault");
|
||||
create_getting_started_vault(vault_path.to_str().unwrap()).unwrap();
|
||||
|
||||
let status = std::process::Command::new("git")
|
||||
let output = StdCommand::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(&vault_path)
|
||||
.current_dir(&dest)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
String::from_utf8_lossy(&status.stdout).trim().is_empty(),
|
||||
"All files should be committed, no untracked files"
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&output.stdout).trim().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -747,10 +747,12 @@ function WelcomeView({ onboarding }: { onboarding: OnboardingState }) {
|
||||
missingPath={state.status === 'vault-missing' ? state.vaultPath : undefined}
|
||||
defaultVaultPath={state.defaultPath}
|
||||
onCreateVault={onboarding.handleCreateVault}
|
||||
onRetryCreateVault={onboarding.retryCreateVault}
|
||||
onCreateNewVault={onboarding.handleCreateNewVault}
|
||||
onOpenFolder={onboarding.handleOpenFolder}
|
||||
creating={onboarding.creating}
|
||||
creatingAction={onboarding.creatingAction}
|
||||
error={onboarding.error}
|
||||
canRetryTemplate={onboarding.canRetryTemplate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,10 +6,12 @@ const defaultProps = {
|
||||
mode: 'welcome' as const,
|
||||
defaultVaultPath: '~/Documents/Laputa',
|
||||
onCreateVault: vi.fn(),
|
||||
onRetryCreateVault: vi.fn(),
|
||||
onCreateNewVault: vi.fn(),
|
||||
onOpenFolder: vi.fn(),
|
||||
creating: false,
|
||||
creatingAction: null as 'template' | 'empty' | null,
|
||||
error: null,
|
||||
canRetryTemplate: false,
|
||||
}
|
||||
|
||||
describe('WelcomeScreen', () => {
|
||||
@@ -54,20 +56,27 @@ describe('WelcomeScreen', () => {
|
||||
})
|
||||
|
||||
it('disables all buttons while creating', () => {
|
||||
render(<WelcomeScreen {...defaultProps} creating={true} />)
|
||||
render(<WelcomeScreen {...defaultProps} creatingAction="template" />)
|
||||
expect(screen.getByTestId('welcome-create-new')).toBeDisabled()
|
||||
expect(screen.getByTestId('welcome-open-folder')).toBeDisabled()
|
||||
expect(screen.getByTestId('welcome-create-vault')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('shows loading text on template button while creating', () => {
|
||||
render(<WelcomeScreen {...defaultProps} creating={true} />)
|
||||
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent(/Creating vault/)
|
||||
render(<WelcomeScreen {...defaultProps} creatingAction="template" />)
|
||||
expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent(/Downloading template/)
|
||||
expect(screen.getByTestId('welcome-status')).toHaveAttribute('aria-live', 'polite')
|
||||
})
|
||||
|
||||
it('shows loading text on create-new button while creating an empty vault', () => {
|
||||
render(<WelcomeScreen {...defaultProps} creatingAction="empty" />)
|
||||
expect(screen.getByTestId('welcome-create-new')).toHaveTextContent(/Creating vault/)
|
||||
})
|
||||
|
||||
it('shows error message when error is set', () => {
|
||||
render(<WelcomeScreen {...defaultProps} error="Permission denied" />)
|
||||
expect(screen.getByTestId('welcome-error')).toHaveTextContent('Permission denied')
|
||||
expect(screen.getByTestId('welcome-error')).toHaveAttribute('aria-live', 'assertive')
|
||||
})
|
||||
|
||||
it('does not show error when error is null', () => {
|
||||
@@ -75,6 +84,21 @@ describe('WelcomeScreen', () => {
|
||||
expect(screen.queryByTestId('welcome-error')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a retry button after template download errors', () => {
|
||||
const onRetryCreateVault = vi.fn()
|
||||
render(
|
||||
<WelcomeScreen
|
||||
{...defaultProps}
|
||||
error="Could not download Getting Started vault. Check your connection and try again."
|
||||
canRetryTemplate={true}
|
||||
onRetryCreateVault={onRetryCreateVault}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('welcome-retry-template'))
|
||||
expect(onRetryCreateVault).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not show path badge in welcome mode', () => {
|
||||
render(<WelcomeScreen {...defaultProps} />)
|
||||
expect(screen.queryByText('~/Laputa')).not.toBeInTheDocument()
|
||||
|
||||
@@ -6,10 +6,12 @@ interface WelcomeScreenProps {
|
||||
missingPath?: string
|
||||
defaultVaultPath: string
|
||||
onCreateVault: () => void
|
||||
onRetryCreateVault: () => void
|
||||
onCreateNewVault: () => void
|
||||
onOpenFolder: () => void
|
||||
creating: boolean
|
||||
creatingAction: 'template' | 'empty' | null
|
||||
error: string | null
|
||||
canRetryTemplate: boolean
|
||||
}
|
||||
|
||||
const CONTAINER_STYLE: React.CSSProperties = {
|
||||
@@ -110,21 +112,61 @@ const ERROR_STYLE: React.CSSProperties = {
|
||||
margin: 0,
|
||||
}
|
||||
|
||||
const STATUS_STYLE: React.CSSProperties = {
|
||||
fontSize: 13,
|
||||
color: 'var(--muted-foreground)',
|
||||
textAlign: 'center',
|
||||
margin: 0,
|
||||
}
|
||||
|
||||
const ERROR_BLOCK_STYLE: React.CSSProperties = {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
}
|
||||
|
||||
const RETRY_BUTTON_STYLE: React.CSSProperties = {
|
||||
borderRadius: 8,
|
||||
border: '1px solid var(--border)',
|
||||
background: 'var(--background)',
|
||||
color: 'var(--foreground)',
|
||||
cursor: 'pointer',
|
||||
padding: '8px 12px',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}
|
||||
|
||||
interface OptionButtonProps {
|
||||
icon: React.ReactNode
|
||||
iconBg: string
|
||||
label: string
|
||||
description: string
|
||||
loadingLabel?: string
|
||||
loadingDescription?: string
|
||||
onClick: () => void
|
||||
disabled: boolean
|
||||
loading?: boolean
|
||||
testId: string
|
||||
}
|
||||
|
||||
function OptionButton({ icon, iconBg, label, description, onClick, disabled, loading, testId }: OptionButtonProps) {
|
||||
function OptionButton({
|
||||
icon,
|
||||
iconBg,
|
||||
label,
|
||||
description,
|
||||
loadingLabel,
|
||||
loadingDescription,
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
testId,
|
||||
}: OptionButtonProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
...OPTION_BTN_STYLE,
|
||||
background: hover ? 'var(--sidebar)' : 'var(--background)',
|
||||
@@ -140,15 +182,26 @@ function OptionButton({ icon, iconBg, label, description, onClick, disabled, loa
|
||||
{loading ? <Loader2 size={18} className="animate-spin" style={{ color: 'var(--muted-foreground)' }} /> : icon}
|
||||
</div>
|
||||
<div>
|
||||
<p style={OPTION_LABEL_STYLE}>{loading ? 'Creating vault\u2026' : label}</p>
|
||||
<p style={OPTION_DESC_STYLE}>{description}</p>
|
||||
<p style={OPTION_LABEL_STYLE}>{loading ? (loadingLabel ?? label) : label}</p>
|
||||
<p style={OPTION_DESC_STYLE}>{loading ? (loadingDescription ?? description) : description}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function WelcomeScreen({ mode, defaultVaultPath, onCreateVault, onCreateNewVault, onOpenFolder, creating, error }: WelcomeScreenProps) {
|
||||
export function WelcomeScreen({
|
||||
mode,
|
||||
defaultVaultPath,
|
||||
onCreateVault,
|
||||
onRetryCreateVault,
|
||||
onCreateNewVault,
|
||||
onOpenFolder,
|
||||
creatingAction,
|
||||
error,
|
||||
canRetryTemplate,
|
||||
}: WelcomeScreenProps) {
|
||||
const isWelcome = mode === 'welcome'
|
||||
const busy = creatingAction !== null
|
||||
|
||||
return (
|
||||
<div style={CONTAINER_STYLE} data-testid="welcome-screen">
|
||||
@@ -185,8 +238,11 @@ export function WelcomeScreen({ mode, defaultVaultPath, onCreateVault, onCreateN
|
||||
iconBg="var(--accent-blue-light, #EBF4FF)"
|
||||
label="Create a new vault"
|
||||
description="Start fresh in a folder you choose"
|
||||
loadingLabel="Creating vault…"
|
||||
loadingDescription="Preparing an empty vault in the selected folder"
|
||||
onClick={onCreateNewVault}
|
||||
disabled={creating}
|
||||
disabled={busy}
|
||||
loading={creatingAction === 'empty'}
|
||||
testId="welcome-create-new"
|
||||
/>
|
||||
|
||||
@@ -196,7 +252,7 @@ export function WelcomeScreen({ mode, defaultVaultPath, onCreateVault, onCreateN
|
||||
label={isWelcome ? 'Open existing vault' : 'Choose a different folder'}
|
||||
description="Point to a folder you already have"
|
||||
onClick={onOpenFolder}
|
||||
disabled={creating}
|
||||
disabled={busy}
|
||||
testId="welcome-open-folder"
|
||||
/>
|
||||
|
||||
@@ -204,15 +260,39 @@ export function WelcomeScreen({ mode, defaultVaultPath, onCreateVault, onCreateN
|
||||
icon={<Rocket size={18} style={{ color: 'var(--accent-purple)' }} />}
|
||||
iconBg="var(--accent-purple-light, #F3E8FF)"
|
||||
label="Get started with a template"
|
||||
description={`A ready-made vault to explore first \u2014 ${defaultVaultPath}`}
|
||||
description={`Download the starter vault from GitHub \u2014 suggested path: ${defaultVaultPath}`}
|
||||
loadingLabel="Downloading template…"
|
||||
loadingDescription="Cloning the Getting Started vault from GitHub"
|
||||
onClick={onCreateVault}
|
||||
disabled={creating}
|
||||
loading={creating}
|
||||
disabled={busy}
|
||||
loading={creatingAction === 'template'}
|
||||
testId="welcome-create-vault"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p style={ERROR_STYLE} data-testid="welcome-error">{error}</p>}
|
||||
{creatingAction === 'template' && (
|
||||
<p style={STATUS_STYLE} data-testid="welcome-status" role="status" aria-live="polite">
|
||||
Downloading the Getting Started vault from GitHub…
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div style={ERROR_BLOCK_STYLE}>
|
||||
<p style={ERROR_STYLE} data-testid="welcome-error" role="alert" aria-live="assertive">
|
||||
{error}
|
||||
</p>
|
||||
{canRetryTemplate && (
|
||||
<button
|
||||
type="button"
|
||||
style={RETRY_BUTTON_STYLE}
|
||||
onClick={onRetryCreateVault}
|
||||
data-testid="welcome-retry-template"
|
||||
>
|
||||
Retry download
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -120,12 +120,13 @@ describe('useOnboarding', () => {
|
||||
})
|
||||
|
||||
it('handleCreateVault creates vault and transitions to ready', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
if (cmd === 'create_getting_started_vault') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'create_getting_started_vault') return (args as { targetPath: string }).targetPath
|
||||
return null
|
||||
})
|
||||
vi.mocked(pickFolder).mockResolvedValue('/mock/Documents/Getting Started')
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
@@ -138,16 +139,19 @@ describe('useOnboarding', () => {
|
||||
})
|
||||
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Getting Started' })
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('create_getting_started_vault', {
|
||||
targetPath: '/mock/Documents/Getting Started',
|
||||
})
|
||||
expect(localStorage.getItem('laputa_welcome_dismissed')).toBe('1')
|
||||
})
|
||||
|
||||
it('handleCreateVault sets error on failure', async () => {
|
||||
it('handleCreateVault does nothing when picker is cancelled', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
if (cmd === 'create_getting_started_vault') throw 'Permission denied'
|
||||
return null
|
||||
})
|
||||
vi.mocked(pickFolder).mockResolvedValue(null)
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
@@ -159,8 +163,67 @@ describe('useOnboarding', () => {
|
||||
await result.current.handleCreateVault()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Permission denied')
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
expect(mockInvokeFn).not.toHaveBeenCalledWith('create_getting_started_vault', expect.anything())
|
||||
})
|
||||
|
||||
it('handleCreateVault sets a friendly download error on clone failure', async () => {
|
||||
mockInvokeFn.mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
if (cmd === 'create_getting_started_vault') throw 'git clone failed: fatal: unable to access'
|
||||
return null
|
||||
})
|
||||
vi.mocked(pickFolder).mockResolvedValue('/mock/Documents/Getting Started')
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreateVault()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Could not download Getting Started vault. Check your connection and try again.')
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
|
||||
it('retryCreateVault reuses the last selected template path', async () => {
|
||||
let attempts = 0
|
||||
mockInvokeFn.mockImplementation(async (cmd: string, args?: Record<string, unknown>) => {
|
||||
if (cmd === 'get_default_vault_path') return '/mock/Documents/Getting Started'
|
||||
if (cmd === 'check_vault_exists') return false
|
||||
if (cmd === 'create_getting_started_vault') {
|
||||
attempts += 1
|
||||
if (attempts === 1) throw 'git clone failed: fatal: unable to access'
|
||||
return (args as { targetPath: string }).targetPath
|
||||
}
|
||||
return null
|
||||
})
|
||||
vi.mocked(pickFolder).mockResolvedValue('/mock/Documents/Getting Started')
|
||||
|
||||
const { result } = renderHook(() => useOnboarding('/vault/missing'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.state.status).toBe('welcome')
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleCreateVault()
|
||||
})
|
||||
|
||||
expect(result.current.canRetryTemplate).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.retryCreateVault()
|
||||
})
|
||||
|
||||
expect(result.current.state).toEqual({ status: 'ready', vaultPath: '/mock/Documents/Getting Started' })
|
||||
expect(mockInvokeFn).toHaveBeenLastCalledWith('create_getting_started_vault', {
|
||||
targetPath: '/mock/Documents/Getting Started',
|
||||
})
|
||||
})
|
||||
|
||||
it('handleCreateNewVault picks folder, creates empty vault, and transitions to ready', async () => {
|
||||
|
||||
@@ -9,6 +9,8 @@ type OnboardingState =
|
||||
| { status: 'vault-missing'; vaultPath: string; defaultPath: string }
|
||||
| { status: 'ready'; vaultPath: string }
|
||||
|
||||
type CreatingAction = 'template' | 'empty' | null
|
||||
|
||||
function tauriCall<T>(command: string, args: Record<string, unknown>): Promise<T> {
|
||||
return isTauri() ? invoke<T>(command, args) : mockInvoke<T>(command, args)
|
||||
}
|
||||
@@ -37,6 +39,26 @@ function markDismissed(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function formatTemplateError(err: unknown): string {
|
||||
const message =
|
||||
typeof err === 'string'
|
||||
? err
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: `${err}`
|
||||
|
||||
if (
|
||||
message.includes('already exists and is not empty')
|
||||
|| message.includes('already exists and is not a directory')
|
||||
|| message.includes('Failed to create parent directory')
|
||||
|| message.includes('Target path is required')
|
||||
) {
|
||||
return message
|
||||
}
|
||||
|
||||
return 'Could not download Getting Started vault. Check your connection and try again.'
|
||||
}
|
||||
|
||||
async function clearMissingActiveVault(missingPath: string): Promise<void> {
|
||||
try {
|
||||
const list = await tauriCall<PersistedVaultList>('load_vault_list', {})
|
||||
@@ -55,8 +77,9 @@ async function clearMissingActiveVault(missingPath: string): Promise<void> {
|
||||
|
||||
export function useOnboarding(initialVaultPath: string) {
|
||||
const [state, setState] = useState<OnboardingState>({ status: 'loading' })
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [creatingAction, setCreatingAction] = useState<CreatingAction>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastTemplatePath, setLastTemplatePath] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -95,38 +118,51 @@ export function useOnboarding(initialVaultPath: string) {
|
||||
return () => { cancelled = true }
|
||||
}, [initialVaultPath])
|
||||
|
||||
const handleCreateVault = useCallback(async () => {
|
||||
setCreating(true)
|
||||
const createTemplateVault = useCallback(async (targetPath: string) => {
|
||||
setCreatingAction('template')
|
||||
setError(null)
|
||||
setLastTemplatePath(targetPath)
|
||||
try {
|
||||
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath: null })
|
||||
const vaultPath = await tauriCall<string>('create_getting_started_vault', { targetPath })
|
||||
markDismissed()
|
||||
setState({ status: 'ready', vaultPath })
|
||||
} catch (err) {
|
||||
setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`)
|
||||
setError(formatTemplateError(err))
|
||||
} finally {
|
||||
setCreating(false)
|
||||
setCreatingAction(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCreateVault = useCallback(async () => {
|
||||
const path = await pickFolder('Choose where to clone the Getting Started vault')
|
||||
if (!path) return
|
||||
await createTemplateVault(path)
|
||||
}, [createTemplateVault])
|
||||
|
||||
const retryCreateVault = useCallback(async () => {
|
||||
if (!lastTemplatePath) return
|
||||
await createTemplateVault(lastTemplatePath)
|
||||
}, [createTemplateVault, lastTemplatePath])
|
||||
|
||||
const handleCreateNewVault = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const path = await pickFolder('Choose where to create your vault')
|
||||
if (!path) return
|
||||
setCreating(true)
|
||||
setError(null)
|
||||
setCreatingAction('empty')
|
||||
const vaultPath = await tauriCall<string>('create_empty_vault', { targetPath: path })
|
||||
markDismissed()
|
||||
setState({ status: 'ready', vaultPath })
|
||||
} catch (err) {
|
||||
setError(typeof err === 'string' ? err : `Failed to create vault: ${err}`)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
setCreatingAction(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
try {
|
||||
setError(null)
|
||||
const path = await pickFolder('Open vault folder')
|
||||
if (!path) return
|
||||
markDismissed()
|
||||
@@ -141,5 +177,16 @@ export function useOnboarding(initialVaultPath: string) {
|
||||
setState({ status: 'ready', vaultPath: initialVaultPath })
|
||||
}, [initialVaultPath])
|
||||
|
||||
return { state, creating, error, handleCreateVault, handleCreateNewVault, handleOpenFolder, handleDismiss }
|
||||
return {
|
||||
state,
|
||||
creating: creatingAction !== null,
|
||||
creatingAction,
|
||||
error,
|
||||
canRetryTemplate: !!error && !!lastTemplatePath && creatingAction === null,
|
||||
handleCreateVault,
|
||||
retryCreateVault,
|
||||
handleCreateNewVault,
|
||||
handleOpenFolder,
|
||||
handleDismiss,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
return args.path.includes('demo-vault-v2')
|
||||
},
|
||||
create_empty_vault: (args: { target_path: string }) => args.target_path || '/Users/mock/Documents/My Vault',
|
||||
create_getting_started_vault: () => '/Users/mock/Documents/Getting Started',
|
||||
create_getting_started_vault: (args: { targetPath?: string | null }) => args.targetPath || '/Users/mock/Documents/Getting Started',
|
||||
register_mcp_tools: () => 'registered',
|
||||
check_mcp_status: () => 'installed',
|
||||
repair_vault: (): string => 'Vault repaired',
|
||||
|
||||
56
tests/smoke/getting-started-template.spec.ts
Normal file
56
tests/smoke/getting-started-template.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('Getting Started template shows inline retry on clone failure and opens after retry @smoke', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.clear()
|
||||
|
||||
let ref: Record<string, unknown> | null = null
|
||||
let cloneAttempts = 0
|
||||
|
||||
Object.defineProperty(window, '__mockHandlers', {
|
||||
configurable: true,
|
||||
set(value) {
|
||||
ref = value as Record<string, unknown>
|
||||
ref.load_vault_list = () => ({
|
||||
vaults: [],
|
||||
active_vault: null,
|
||||
hidden_defaults: [],
|
||||
})
|
||||
ref.get_default_vault_path = () => '/Users/mock/Documents/Getting Started'
|
||||
ref.check_vault_exists = () => false
|
||||
ref.create_getting_started_vault = (args: { targetPath?: string | null }) => {
|
||||
cloneAttempts += 1
|
||||
if (cloneAttempts === 1) {
|
||||
throw 'git clone failed: fatal: unable to access'
|
||||
}
|
||||
return args.targetPath || '/Users/mock/Documents/Getting Started'
|
||||
}
|
||||
},
|
||||
get() {
|
||||
return ref
|
||||
},
|
||||
})
|
||||
|
||||
Object.defineProperty(window, 'prompt', {
|
||||
configurable: true,
|
||||
value: () => '/Users/mock/Documents/Getting Started',
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto('/')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await expect(page.getByTestId('welcome-screen')).toBeVisible()
|
||||
|
||||
await page.getByTestId('welcome-create-vault').click()
|
||||
|
||||
await expect(page.getByTestId('welcome-error')).toContainText(
|
||||
'Could not download Getting Started vault. Check your connection and try again.',
|
||||
)
|
||||
await expect(page.getByTestId('welcome-retry-template')).toBeVisible()
|
||||
|
||||
await page.getByTestId('welcome-retry-template').click()
|
||||
|
||||
await expect(page.getByTestId('welcome-screen')).not.toBeVisible()
|
||||
await expect(page.locator('[data-testid="note-list-container"]')).toBeVisible()
|
||||
})
|
||||
Reference in New Issue
Block a user