From b0950c92c5d63756c603d13fdea4c86fcc90796d Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 7 Apr 2026 23:28:02 +0200 Subject: [PATCH] feat: clone the starter vault on demand --- docs/ABSTRACTIONS.md | 2 +- docs/ARCHITECTURE.md | 9 +- docs/GETTING-STARTED.md | 4 +- getting-started-vault/.gitignore | 3 - getting-started-vault/AGENTS.md | 81 --- getting-started-vault/CLAUDE.md | 3 - getting-started-vault/ai-and-git.md | 27 - .../capturing-people-and-meetings.md | 38 -- getting-started-vault/getting-started.md | 6 - .../how-i-organize-my-vault.md | 31 - getting-started-vault/luca-rossi.md | 20 - getting-started-vault/note.md | 8 - getting-started-vault/person.md | 8 - .../personal-knowledge-management.md | 17 - .../sidebar-and-navigation.md | 32 -- .../syncing-calendar-events.md | 36 -- getting-started-vault/topic.md | 8 - .../types-properties-relationships.md | 47 -- getting-started-vault/untitled-person.md | 15 - getting-started-vault/using-the-editor.md | 38 -- getting-started-vault/views-and-search.md | 23 - .../views/active-projects.yml | 12 - getting-started-vault/welcome.md | 37 -- getting-started-vault/what-is-laputa.md | 15 - getting-started-vault/why-plain-files.md | 31 - src-tauri/src/github/clone.rs | 168 +++++- src-tauri/src/github/mod.rs | 2 +- src-tauri/src/vault/getting_started.rs | 544 ++++-------------- src/App.tsx | 4 +- src/components/WelcomeScreen.test.tsx | 32 +- src/components/WelcomeScreen.tsx | 102 +++- src/hooks/useOnboarding.test.ts | 73 ++- src/hooks/useOnboarding.ts | 67 ++- src/mock-tauri/mock-handlers.ts | 2 +- tests/smoke/getting-started-template.spec.ts | 56 ++ 35 files changed, 574 insertions(+), 1027 deletions(-) delete mode 100644 getting-started-vault/.gitignore delete mode 100644 getting-started-vault/AGENTS.md delete mode 100644 getting-started-vault/CLAUDE.md delete mode 100644 getting-started-vault/ai-and-git.md delete mode 100644 getting-started-vault/capturing-people-and-meetings.md delete mode 100644 getting-started-vault/getting-started.md delete mode 100644 getting-started-vault/how-i-organize-my-vault.md delete mode 100644 getting-started-vault/luca-rossi.md delete mode 100644 getting-started-vault/note.md delete mode 100644 getting-started-vault/person.md delete mode 100644 getting-started-vault/personal-knowledge-management.md delete mode 100644 getting-started-vault/sidebar-and-navigation.md delete mode 100644 getting-started-vault/syncing-calendar-events.md delete mode 100644 getting-started-vault/topic.md delete mode 100644 getting-started-vault/types-properties-relationships.md delete mode 100644 getting-started-vault/untitled-person.md delete mode 100644 getting-started-vault/using-the-editor.md delete mode 100644 getting-started-vault/views-and-search.md delete mode 100644 getting-started-vault/views/active-projects.yml delete mode 100644 getting-started-vault/welcome.md delete mode 100644 getting-started-vault/what-is-laputa.md delete mode 100644 getting-started-vault/why-plain-files.md create mode 100644 tests/smoke/getting-started-template.spec.ts diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 8e733057..0e02bb87 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2d62dd9a..d164615b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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` | | `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 diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 7ad8cccb..4cc7c7fa 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -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 diff --git a/getting-started-vault/.gitignore b/getting-started-vault/.gitignore deleted file mode 100644 index 482e34b1..00000000 --- a/getting-started-vault/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.DS_Store -*.swp -*.swo diff --git a/getting-started-vault/AGENTS.md b/getting-started-vault/AGENTS.md deleted file mode 100644 index 75f14225..00000000 --- a/getting-started-vault/AGENTS.md +++ /dev/null @@ -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. diff --git a/getting-started-vault/CLAUDE.md b/getting-started-vault/CLAUDE.md deleted file mode 100644 index e035fbfa..00000000 --- a/getting-started-vault/CLAUDE.md +++ /dev/null @@ -1,3 +0,0 @@ -@AGENTS.md - -This file is a Claude Code compatibility shim. Keep shared vault instructions in `AGENTS.md`. diff --git a/getting-started-vault/ai-and-git.md b/getting-started-vault/ai-and-git.md deleted file mode 100644 index 2f497826..00000000 --- a/getting-started-vault/ai-and-git.md +++ /dev/null @@ -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. diff --git a/getting-started-vault/capturing-people-and-meetings.md b/getting-started-vault/capturing-people-and-meetings.md deleted file mode 100644 index e4c9b73d..00000000 --- a/getting-started-vault/capturing-people-and-meetings.md +++ /dev/null @@ -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. diff --git a/getting-started-vault/getting-started.md b/getting-started-vault/getting-started.md deleted file mode 100644 index 2a1debbb..00000000 --- a/getting-started-vault/getting-started.md +++ /dev/null @@ -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. diff --git a/getting-started-vault/how-i-organize-my-vault.md b/getting-started-vault/how-i-organize-my-vault.md deleted file mode 100644 index d9009b34..00000000 --- a/getting-started-vault/how-i-organize-my-vault.md +++ /dev/null @@ -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. diff --git a/getting-started-vault/luca-rossi.md b/getting-started-vault/luca-rossi.md deleted file mode 100644 index f4f60df8..00000000 --- a/getting-started-vault/luca-rossi.md +++ /dev/null @@ -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]] diff --git a/getting-started-vault/note.md b/getting-started-vault/note.md deleted file mode 100644 index a7d9303f..00000000 --- a/getting-started-vault/note.md +++ /dev/null @@ -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. diff --git a/getting-started-vault/person.md b/getting-started-vault/person.md deleted file mode 100644 index 87ad702f..00000000 --- a/getting-started-vault/person.md +++ /dev/null @@ -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. diff --git a/getting-started-vault/personal-knowledge-management.md b/getting-started-vault/personal-knowledge-management.md deleted file mode 100644 index 02d7ea40..00000000 --- a/getting-started-vault/personal-knowledge-management.md +++ /dev/null @@ -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 diff --git a/getting-started-vault/sidebar-and-navigation.md b/getting-started-vault/sidebar-and-navigation.md deleted file mode 100644 index 3cf6b4e0..00000000 --- a/getting-started-vault/sidebar-and-navigation.md +++ /dev/null @@ -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 | diff --git a/getting-started-vault/syncing-calendar-events.md b/getting-started-vault/syncing-calendar-events.md deleted file mode 100644 index ffbb32cb..00000000 --- a/getting-started-vault/syncing-calendar-events.md +++ /dev/null @@ -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. diff --git a/getting-started-vault/topic.md b/getting-started-vault/topic.md deleted file mode 100644 index c455581f..00000000 --- a/getting-started-vault/topic.md +++ /dev/null @@ -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. diff --git a/getting-started-vault/types-properties-relationships.md b/getting-started-vault/types-properties-relationships.md deleted file mode 100644 index 1d40746f..00000000 --- a/getting-started-vault/types-properties-relationships.md +++ /dev/null @@ -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. diff --git a/getting-started-vault/untitled-person.md b/getting-started-vault/untitled-person.md deleted file mode 100644 index 9c101265..00000000 --- a/getting-started-vault/untitled-person.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -title: Untitled person -type: Person ---- - -## Role - - - -## Contact - - - -## Notes - diff --git a/getting-started-vault/using-the-editor.md b/getting-started-vault/using-the-editor.md deleted file mode 100644 index 87a851c8..00000000 --- a/getting-started-vault/using-the-editor.md +++ /dev/null @@ -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. diff --git a/getting-started-vault/views-and-search.md b/getting-started-vault/views-and-search.md deleted file mode 100644 index de2f1033..00000000 --- a/getting-started-vault/views-and-search.md +++ /dev/null @@ -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. diff --git a/getting-started-vault/views/active-projects.yml b/getting-started-vault/views/active-projects.yml deleted file mode 100644 index 075ced1a..00000000 --- a/getting-started-vault/views/active-projects.yml +++ /dev/null @@ -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 diff --git a/getting-started-vault/welcome.md b/getting-started-vault/welcome.md deleted file mode 100644 index 5b816f1f..00000000 --- a/getting-started-vault/welcome.md +++ /dev/null @@ -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.* diff --git a/getting-started-vault/what-is-laputa.md b/getting-started-vault/what-is-laputa.md deleted file mode 100644 index 803d4a9b..00000000 --- a/getting-started-vault/what-is-laputa.md +++ /dev/null @@ -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. diff --git a/getting-started-vault/why-plain-files.md b/getting-started-vault/why-plain-files.md deleted file mode 100644 index 993b7a35..00000000 --- a/getting-started-vault/why-plain-files.md +++ /dev/null @@ -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. diff --git a/src-tauri/src/github/clone.rs b/src-tauri/src/github/clone.rs index e6f8f56b..83b228df 100644 --- a/src-tauri/src/github/clone.rs +++ b/src-tauri/src/github/clone.rs @@ -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 { 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 Result { + 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 { if let Some(rest) = url.strip_prefix("https://github.com/") { @@ -58,6 +54,62 @@ fn inject_token_into_url(url: &str, token: &str) -> Result { } } +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()); + } } diff --git a/src-tauri/src/github/mod.rs b/src-tauri/src/github/mod.rs index c5be593c..56ac1aac 100644 --- a/src-tauri/src/github/mod.rs +++ b/src-tauri/src/github/mod.rs @@ -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. diff --git a/src-tauri/src/vault/getting_started.rs b/src-tauri/src/vault/getting_started.rs index de7167e4..ecb9745a 100644 --- a/src-tauri/src/vault/getting_started.rs +++ b/src-tauri/src/vault/getting_started.rs @@ -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 { 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 { - 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 { + 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 { + 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()); } } diff --git a/src/App.tsx b/src/App.tsx index 9e122d03..3018b477 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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} /> ) diff --git a/src/components/WelcomeScreen.test.tsx b/src/components/WelcomeScreen.test.tsx index 23017b28..0873ea9d 100644 --- a/src/components/WelcomeScreen.test.tsx +++ b/src/components/WelcomeScreen.test.tsx @@ -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() + render() 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() - expect(screen.getByTestId('welcome-create-vault')).toHaveTextContent(/Creating vault/) + render() + 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() + expect(screen.getByTestId('welcome-create-new')).toHaveTextContent(/Creating vault/) }) it('shows error message when error is set', () => { render() 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( + , + ) + + fireEvent.click(screen.getByTestId('welcome-retry-template')) + expect(onRetryCreateVault).toHaveBeenCalledOnce() + }) + it('does not show path badge in welcome mode', () => { render() expect(screen.queryByText('~/Laputa')).not.toBeInTheDocument() diff --git a/src/components/WelcomeScreen.tsx b/src/components/WelcomeScreen.tsx index 8ab7af7c..d6b13c5e 100644 --- a/src/components/WelcomeScreen.tsx +++ b/src/components/WelcomeScreen.tsx @@ -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 ( ) } -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 (
@@ -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={} 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" />
- {error &&

{error}

} + {creatingAction === 'template' && ( +

+ Downloading the Getting Started vault from GitHub… +

+ )} + + {error && ( +
+

+ {error} +

+ {canRetryTemplate && ( + + )} +
+ )} ) diff --git a/src/hooks/useOnboarding.test.ts b/src/hooks/useOnboarding.test.ts index 662215b6..3f0b51dc 100644 --- a/src/hooks/useOnboarding.test.ts +++ b/src/hooks/useOnboarding.test.ts @@ -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) => { 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) => { + 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 () => { diff --git a/src/hooks/useOnboarding.ts b/src/hooks/useOnboarding.ts index cf7dd7d2..5e285476 100644 --- a/src/hooks/useOnboarding.ts +++ b/src/hooks/useOnboarding.ts @@ -9,6 +9,8 @@ type OnboardingState = | { status: 'vault-missing'; vaultPath: string; defaultPath: string } | { status: 'ready'; vaultPath: string } +type CreatingAction = 'template' | 'empty' | null + function tauriCall(command: string, args: Record): Promise { return isTauri() ? invoke(command, args) : mockInvoke(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 { try { const list = await tauriCall('load_vault_list', {}) @@ -55,8 +77,9 @@ async function clearMissingActiveVault(missingPath: string): Promise { export function useOnboarding(initialVaultPath: string) { const [state, setState] = useState({ status: 'loading' }) - const [creating, setCreating] = useState(false) + const [creatingAction, setCreatingAction] = useState(null) const [error, setError] = useState(null) + const [lastTemplatePath, setLastTemplatePath] = useState(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('create_getting_started_vault', { targetPath: null }) + const vaultPath = await tauriCall('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('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, + } } diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index ec6dfb53..7eab20b3 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -287,7 +287,7 @@ export const mockHandlers: Record 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', diff --git a/tests/smoke/getting-started-template.spec.ts b/tests/smoke/getting-started-template.spec.ts new file mode 100644 index 00000000..85c2d886 --- /dev/null +++ b/tests/smoke/getting-started-template.spec.ts @@ -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 | null = null + let cloneAttempts = 0 + + Object.defineProperty(window, '__mockHandlers', { + configurable: true, + set(value) { + ref = value as Record + 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() +})