feat: clone the starter vault on demand

This commit is contained in:
Test
2026-04-07 23:28:02 +02:00
parent 58dfb039ca
commit b0950c92c5
35 changed files with 574 additions and 1027 deletions

View File

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
.DS_Store
*.swp
*.swo

View File

@@ -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.

View File

@@ -1,3 +0,0 @@
@AGENTS.md
This file is a Claude Code compatibility shim. Keep shared vault instructions in `AGENTS.md`.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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]]

View File

@@ -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.

View File

@@ -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.

View File

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

View File

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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -1,15 +0,0 @@
---
title: Untitled person
type: Person
---
## Role
## Contact
## Notes

View File

@@ -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.

View File

@@ -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.

View File

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

View File

@@ -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.*

View File

@@ -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.

View File

@@ -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.

View File

@@ -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());
}
}

View File

@@ -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.

View File

@@ -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());
}
}

View File

@@ -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>
)

View File

@@ -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()

View File

@@ -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>
)

View File

@@ -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 () => {

View File

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

View File

@@ -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',

View 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()
})