From a9501b14e7b79a92886faf8e5fb6b5b8d1ed3cb6 Mon Sep 17 00:00:00 2001 From: opariffazman Date: Sat, 11 Jul 2026 07:21:21 +0800 Subject: [PATCH 01/16] docs: design spec for beginner-friendly file explorer Co-Authored-By: Claude Fable 5 --- ...026-07-11-beginner-file-explorer-design.md | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 docs/specs/2026-07-11-beginner-file-explorer-design.md diff --git a/docs/specs/2026-07-11-beginner-file-explorer-design.md b/docs/specs/2026-07-11-beginner-file-explorer-design.md new file mode 100644 index 0000000..ded29f7 --- /dev/null +++ b/docs/specs/2026-07-11-beginner-file-explorer-design.md @@ -0,0 +1,168 @@ +# Gitverse — Beginner-Friendly File Explorer + +**Date:** 2026-07-11 +**Repo:** `opariffazman/gitverse` +**Status:** Draft +**Goal:** Make Gitverse approachable for git beginners: a live IDE-style file +tree, one-click example files, and minimal visual cues that teach the +working → staged → committed model. + +--- + +## 1. Overview + +Three additions, all UI/shell layer — **zero engine changes** (every needed +query already exists: `getVFS().listDir()`, `getUntrackedFiles()`, +`getModifiedFiles()`, `getStagedFiles()`, `getDeletedFiles()`): + +1. **Live file explorer** — a persistent left sidebar (VS Code style) showing + the virtual file system with per-file git status badges, updating after + every command. +2. **"+ Example files" button** — seeds a tiny starter project by running + real `mkdir`/`touch` commands *through the terminal*, so the beginner sees + exactly what happened and can repeat it by hand. +3. **"✎ Simulate changes" button** — appends a line to tracked files via + `echo "…" >> ` (already supported), so there is always something to + `git add` / `git commit`. Fulfils the "Simulate Changes" item from the + original design spec that was never built. + +### Layout (user-approved) + +``` +┌──────────┬──────────────────────────────┐ +│ EXPLORER │ │ +│ + Example│ GRAPH (DAG) │ +│──────────│ │ +│ ▾ src/ │ │ +│ app.js U │ +│ README M ├──────────────────────────────┤ +│ index ● │ $ git add . │ +│ │ $ git commit -m "..." │ +│ U=new │ TERMINAL │ +└──────────┴──────────────────────────────┘ +``` + +Sidebar ≈ 230px on desktop, collapsible. On mobile (`max-sm`) it is hidden +behind a toggle button (drawer over the graph) so the terminal keeps priority. + +### Non-Goals + +- No in-browser text editing of file contents (design decision: simulated + changes only). +- No tutorials/levels — the explorer is passive; it visualizes, never blocks. +- No engine API changes. + +--- + +## 2. Derived file-status store — `src/store/files.ts` + +Pure derivation, headless-testable: + +```ts +export type FileStatus = 'untracked' | 'modified' | 'staged' | 'deleted' | 'clean'; +export type TreeEntry = { + path: string; // full VFS path, e.g. "src/app.js" + name: string; // display name, e.g. "app.js" + dir: string | null; // parent dir or null for root (VFS is flat + 1 level) + status: FileStatus; +}; + +export function buildFileTree(engine: GitEngine): TreeEntry[]; // pure fn +export const fileTree = derived([engine, engineVersion], ...); // thin wrapper +``` + +**Status precedence** (one badge per file, beginner-simple): +`deleted` > `modified` > `staged` > `untracked` > `clean`. +A file both staged and re-modified shows `M` — the working-tree change is +what the beginner must act on next, matching VS Code's single-letter habit. + +Deleted files still appear in the tree (name struck through, `D` badge) until +the deletion is committed — they come from `getDeletedFiles()`, not the VFS. + +Directories come from `listDir()` root markers (`"src/"`); entries are grouped +dirs-first, then root files, both alphabetical. + +--- + +## 3. Explorer component — `src/ui/FileTree.svelte` + +- Header `EXPLORER` + two action buttons (section 4). +- Folder rows toggle collapse (`▾`/`▸`); default expanded. Collapse state is + local component state — not persisted. +- File row: icon-less name + colored badge letter, matching the existing + terminal palette: + - `U` untracked — green + - `M` modified — yellow + - `●` staged — cyan (git's "ready" color in the existing prompt) + - `D` deleted — red, name struck through + - clean — dim, no badge +- Badge `title` tooltip spells it out (e.g. "modified — not staged"). +- **Click a file** → `prefillTerminal('cat ')` (reuses the mechanism + Graph already uses): input is pre-filled + focused, user presses Enter. + Teaches the command instead of hiding it. +- **Empty state:** when the VFS has no files, show a short centered hint: + "No files yet — click + Example files, or type `touch `". +- **Legend:** one dim line pinned at the bottom: + `U new · M modified · ● staged · D deleted`. + +### Layout.svelte changes + +Root becomes a horizontal flex: `` (shrink-0, `w-[230px]`, +`max-sm:hidden` + drawer toggle) beside the existing graph/terminal column. +A collapse chevron in the sidebar header shrinks it to a slim rail; a mobile +toggle button (top-left, next to Reset) opens it as an overlay drawer. + +--- + +## 4. Action buttons + +Both buttons pipe **real commands through `executeCommand()`** so every action +is visible and replayable in the terminal. + +**+ Example files** — runs, in order: + +``` +mkdir src +touch README.md +touch index.html +touch src/app.js +``` + +(`touch` already auto-generates plausible content per file type.) +Skips files that already exist (checked via VFS before dispatch) so the button +is idempotent. Enabled always — works before or after `git init`. + +**✎ Simulate changes** — picks the first 2 tracked files (committed or +staged, alphabetical, still present in the VFS) and runs +`echo "" >> ` for each, cycling through a small fixed set +of realistic lines ("fix typo", "add TODO note", …) with a module counter — +no randomness. Disabled (with tooltip "commit a file first") when nothing is +tracked. + +### New builtin: `mkdir` + +`src/shell/builtins.ts` gains `mkdir ` → `vfs.createDir(name)`, with the +same error style as the other builtins: missing operand → exit 1; nested path +(`a/b`) rejected per the flat+1-level VFS rule; existing entry → +`mkdir: cannot create directory '': File exists` (exit 1, real-shell +semantics). Added to `help` output and tab-completion. + +--- + +## 5. Testing + +- **Unit (headless):** `tests/engine`-style tests for `buildFileTree` — + status precedence, dir grouping, deleted-file visibility; + `mkdir` builtin behavior (creates dir, rejects `a/b`, duplicate errors with + "File exists"). +- **E2E (Playwright):** one flow — click + Example files → tree shows 3 files + + `src/` with `U` badges → `git init`, `git add .` → badges flip to `●` → + `git commit` → badges clear → click ✎ Simulate changes → `M` appears → + click a file → terminal input pre-filled with `cat `. + +--- + +## 6. Release + +Feature branch → RC bump (`npm version preminor --preid=rc`) → PR → merge +deploys per the standard flow. `feat:` commits → preminor. From 026c43b6e58d7b240612c04de76f481e1f5fe11d Mon Sep 17 00:00:00 2001 From: opariffazman Date: Sat, 11 Jul 2026 07:42:36 +0800 Subject: [PATCH 02/16] docs: implementation plan for beginner-friendly file explorer Co-Authored-By: Claude Fable 5 --- .../2026-07-11-beginner-file-explorer.md | 1087 +++++++++++++++++ 1 file changed, 1087 insertions(+) create mode 100644 docs/plans/2026-07-11-beginner-file-explorer.md diff --git a/docs/plans/2026-07-11-beginner-file-explorer.md b/docs/plans/2026-07-11-beginner-file-explorer.md new file mode 100644 index 0000000..525a324 --- /dev/null +++ b/docs/plans/2026-07-11-beginner-file-explorer.md @@ -0,0 +1,1087 @@ +# Beginner-Friendly File Explorer Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a live IDE-style file explorer sidebar with git status badges, a "+ Example files" button, and a "✎ Simulate changes" button, so beginners can *see* the working → staged → committed model. + +**Architecture:** Zero engine changes. A pure derivation (`buildFileTree`) maps the existing engine queries (`getUntrackedFiles`/`getModifiedFiles`/`getStagedFiles`/`getDeletedFiles`/VFS) into a renderable model; a Svelte 5 component renders it as a left sidebar; buttons dispatch *real shell commands* through the existing `executeCommand()` so every action is visible in the terminal. One new shell builtin: `mkdir`. + +**Tech Stack:** Svelte 5 (runes + `$store` auto-subscription), TypeScript strict, UnoCSS (theme colors `terminal-*` from `uno.config.ts`), Vitest (headless, `tests/**` except `tests/e2e`), Playwright (`tests/e2e`). + +**Spec:** `docs/specs/2026-07-11-beginner-file-explorer-design.md` + +## Global Constraints + +- Work on branch `feat/beginner-file-explorer` (create from `main` before Task 1). +- Path aliases (vite.config.ts): `$engine`, `$shell`, `$ui`, `$graph`, `$store`, `$persistence`. +- VFS is flat + 1 directory level; directory entries are stored as `"name/"` keys. +- `git init` creates a `.git/` VFS dir — the explorer must never show `.git`. +- Badge colors: `U` = `text-terminal-green`, `M` = `text-terminal-yellow`, `●` (staged) = `text-terminal-blue`, `D` = `text-terminal-red` + strikethrough. (NOT `terminal-cyan` — it is visually identical to green.) +- Status precedence (one badge per file): `deleted` > `modified` > `staged` > `untracked` > `clean`. +- No `Math.random()` anywhere — simulate-changes cycles a module counter. +- Every command a button triggers must go through `executeCommand()` from `$store/engine` so it echoes in the terminal. +- Verify commands: `npm run test` (Vitest), `npm run typecheck`, `npm run lint`, `npm run test:e2e` (Playwright). +- Commit style: conventional commits (`feat:`, `test:`, `docs:`), no scopes required but `feat(ui):` style is used in history. + +--- + +### Task 1: `mkdir` builtin + +**Files:** +- Modify: `src/shell/parser.ts:1` (BUILTINS set) +- Modify: `src/shell/builtins.ts` (new case + help text) +- Modify: `src/shell/complete.ts:49,107` (top-level completion lists) +- Test: `tests/shell/builtins.test.ts`, `tests/shell/parser.test.ts` + +**Interfaces:** +- Consumes: `vfs.createDir(name)`, `vfs.exists(path)` (existing). +- Produces: shell command `mkdir ` — Task 4's `exampleFileCommands` emits `'mkdir src'`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/shell/builtins.test.ts` (imports for `GitEngine`, `executeBuiltin`, `ShellRouter` already exist at the top): + +```ts +describe('mkdir', () => { + it('creates a directory listable via ls', () => { + const r = executeBuiltin(engine, 'mkdir', ['src']); + expect(r.exitCode).toBe(0); + expect(r.output).toBe(''); + expect(executeBuiltin(engine, 'ls', []).output).toContain('src/'); + }); + + it('accepts a trailing slash', () => { + const r = executeBuiltin(engine, 'mkdir', ['docs/']); + expect(r.exitCode).toBe(0); + expect(executeBuiltin(engine, 'ls', []).output).toContain('docs/'); + }); + + it('errors on missing operand', () => { + const r = executeBuiltin(engine, 'mkdir', []); + expect(r.exitCode).toBe(1); + expect(r.output).toBe('mkdir: missing operand'); + }); + + it('rejects nested paths (flat + 1 level VFS)', () => { + const r = executeBuiltin(engine, 'mkdir', ['a/b']); + expect(r.exitCode).toBe(1); + expect(r.output).toBe( + "mkdir: cannot create directory 'a/b': only one level of nesting is supported", + ); + }); + + it('errors when the entry already exists', () => { + executeBuiltin(engine, 'mkdir', ['src']); + const r = executeBuiltin(engine, 'mkdir', ['src']); + expect(r.exitCode).toBe(1); + expect(r.output).toBe("mkdir: cannot create directory 'src': File exists"); + }); + + it('errors when a file with the same name exists', () => { + engine.getVFS().createFile('src', 'i am a file'); + const r = executeBuiltin(engine, 'mkdir', ['src']); + expect(r.exitCode).toBe(1); + expect(r.output).toBe("mkdir: cannot create directory 'src': File exists"); + }); + + it('routes through the shell router and enables touch into it', () => { + const router = new ShellRouter(engine); + expect(router.execute('mkdir lib').exitCode).toBe(0); + expect(router.execute('touch lib/util.js').exitCode).toBe(0); + expect(executeBuiltin(engine, 'ls', ['lib']).output).toContain('util.js'); + }); +}); +``` + +Append to `tests/shell/parser.test.ts` inside its existing top-level `describe` (match surrounding style — it tests `parseInput` from `$shell/parser`): + +```ts +it('classifies mkdir as a builtin', () => { + const p = parseInput('mkdir src'); + expect(p).toEqual({ type: 'builtin', command: 'mkdir', args: ['src'] }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test -- tests/shell` +Expected: the new `mkdir` tests FAIL — `parseInput('mkdir src')` returns `{ type: 'unknown', ... }` and `executeBuiltin` returns `mkdir: command not found` (exit 127). + +- [ ] **Step 3: Implement** + +`src/shell/parser.ts:1` — add `'mkdir'`: + +```ts +const BUILTINS = new Set(['ls', 'cat', 'touch', 'mkdir', 'rm', 'mv', 'clear', 'help', 'echo']); +``` + +`src/shell/builtins.ts` — insert a new case directly after the `touch` case (after its closing `}`): + +```ts + case 'mkdir': { + if (args.length === 0) { + return { output: 'mkdir: missing operand', exitCode: 1 }; + } + const raw = args[0]; + const name = raw.endsWith('/') ? raw.slice(0, -1) : raw; + if (name.includes('/')) { + return { + output: `mkdir: cannot create directory '${raw}': only one level of nesting is supported`, + exitCode: 1, + }; + } + if (vfs.exists(name + '/') || vfs.exists(name)) { + return { output: `mkdir: cannot create directory '${raw}': File exists`, exitCode: 1 }; + } + vfs.createDir(name); + return { output: '', exitCode: 0 }; + } +``` + +`src/shell/builtins.ts` help case — insert after the `touch` help line (`' touch — create file …'`): + +```ts + ' mkdir — create a folder (one level max)', +``` + +`src/shell/complete.ts` — add `'mkdir'` to both top-level command lists: + +Line 49: `const TOP_LEVEL_PREINIT = ['git', 'ls', 'cat', 'touch', 'mkdir', 'rm', 'mv', 'clear', 'help'];` +Line 107: `const allCommands = ['git', 'ls', 'cat', 'touch', 'mkdir', 'rm', 'mv', 'clear', 'help'];` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test -- tests/shell` then `npm run typecheck && npm run lint` +Expected: all PASS, no type/lint errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/shell/parser.ts src/shell/builtins.ts src/shell/complete.ts tests/shell/builtins.test.ts tests/shell/parser.test.ts +git commit -m "feat(shell): add mkdir builtin" +``` + +--- + +### Task 2: File-status model — `src/store/files.ts` + +**Files:** +- Create: `src/store/files.ts` +- Test: `tests/store/files.test.ts` (new directory — Vitest picks up everything outside `tests/e2e`) + +**Interfaces:** +- Consumes: `engine`, `engineVersion` writables from `$store/engine`; `GitEngine` getters `getVFS()`, `getUntrackedFiles()`, `getModifiedFiles()`, `getStagedFiles()`, `getDeletedFiles()` (all safe pre-init: `getCommittedTree()` returns an empty map with no HEAD). +- Produces (Tasks 3–4 rely on these exact names): + +```ts +export type FileStatus = 'untracked' | 'modified' | 'staged' | 'deleted' | 'clean'; +export type TreeEntry = { path: string; name: string; dir: string | null; status: FileStatus }; +export type FileTreeModel = { dirs: Array<{ name: string; files: TreeEntry[] }>; rootFiles: TreeEntry[] }; +export function buildFileTree(eng: GitEngine): FileTreeModel; +export const fileTree: Readable; +``` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/store/files.test.ts`: + +```ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { GitEngine } from '$engine/index'; +import { buildFileTree } from '$store/files'; + +let engine: GitEngine; + +beforeEach(() => { + engine = new GitEngine(); + engine.execute('git init'); +}); + +function flat(engine: GitEngine) { + const t = buildFileTree(engine); + return [...t.dirs.flatMap((d) => d.files), ...t.rootFiles]; +} + +describe('buildFileTree', () => { + it('returns an empty model for an empty VFS (hides .git)', () => { + const t = buildFileTree(engine); + expect(t.rootFiles).toEqual([]); + expect(t.dirs).toEqual([]); + }); + + it('marks a new file untracked', () => { + engine.getVFS().createFile('a.txt', 'hi'); + expect(flat(engine)).toEqual([ + { path: 'a.txt', name: 'a.txt', dir: null, status: 'untracked' }, + ]); + }); + + it('marks an added file staged', () => { + engine.getVFS().createFile('a.txt', 'hi'); + engine.execute('git add a.txt'); + expect(flat(engine)[0].status).toBe('staged'); + }); + + it('marks a committed file clean, then modified after an edit', () => { + engine.getVFS().createFile('a.txt', 'hi'); + engine.execute('git add a.txt'); + engine.execute('git commit -m "add"'); + expect(flat(engine)[0].status).toBe('clean'); + + engine.getVFS().createFile('a.txt', 'hi\nedited'); + expect(flat(engine)[0].status).toBe('modified'); + }); + + it('modified wins over staged when a staged file is re-edited', () => { + engine.getVFS().createFile('a.txt', 'v1'); + engine.execute('git add a.txt'); + engine.execute('git commit -m "v1"'); + engine.getVFS().createFile('a.txt', 'v2'); + engine.execute('git add a.txt'); // staged + engine.getVFS().createFile('a.txt', 'v3'); // re-edited after staging + expect(flat(engine)[0].status).toBe('modified'); + }); + + it('keeps a deleted tracked file visible with status deleted', () => { + engine.getVFS().createFile('a.txt', 'hi'); + engine.execute('git add a.txt'); + engine.execute('git commit -m "add"'); + engine.getVFS().deleteFile('a.txt'); + expect(flat(engine)).toEqual([ + { path: 'a.txt', name: 'a.txt', dir: null, status: 'deleted' }, + ]); + }); + + it('groups directory files under dirs, root files under rootFiles', () => { + engine.getVFS().createDir('src'); + engine.getVFS().createFile('src/app.js', 'x'); + engine.getVFS().createFile('README.md', 'x'); + const t = buildFileTree(engine); + expect(t.dirs).toEqual([ + { + name: 'src', + files: [{ path: 'src/app.js', name: 'app.js', dir: 'src', status: 'untracked' }], + }, + ]); + expect(t.rootFiles.map((f) => f.path)).toEqual(['README.md']); + }); + + it('lists an empty directory with zero files', () => { + engine.getVFS().createDir('empty'); + const t = buildFileTree(engine); + expect(t.dirs).toEqual([{ name: 'empty', files: [] }]); + }); + + it('still shows the parent dir of a deleted file even if the dir was removed', () => { + engine.getVFS().createDir('src'); + engine.getVFS().createFile('src/app.js', 'x'); + engine.execute('git add src/app.js'); + engine.execute('git commit -m "add"'); + engine.getVFS().deleteFile('src/app.js'); + engine.getVFS().deleteFile('src/'); // dir entry removed too + const t = buildFileTree(engine); + expect(t.dirs).toEqual([ + { + name: 'src', + files: [{ path: 'src/app.js', name: 'app.js', dir: 'src', status: 'deleted' }], + }, + ]); + }); + + it('sorts dirs and files alphabetically', () => { + engine.getVFS().createDir('zeta'); + engine.getVFS().createDir('alpha'); + engine.getVFS().createFile('b.txt', 'x'); + engine.getVFS().createFile('a.txt', 'x'); + const t = buildFileTree(engine); + expect(t.dirs.map((d) => d.name)).toEqual(['alpha', 'zeta']); + expect(t.rootFiles.map((f) => f.name)).toEqual(['a.txt', 'b.txt']); + }); + + it('works before git init (all files untracked)', () => { + const fresh = new GitEngine(); + fresh.getVFS().createFile('a.txt', 'hi'); + expect(flat(fresh)[0].status).toBe('untracked'); + }); +}); +``` + +Note: `vfs.deleteFile('src/')` works because VFS stores dir entries under the `"src/"` key and `deleteFile` only checks key existence. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npm run test -- tests/store` +Expected: FAIL — `Cannot find module '$store/files'` (or equivalent resolve error). + +- [ ] **Step 3: Implement `src/store/files.ts`** + +```ts +import { derived } from 'svelte/store'; +import type { GitEngine } from '$engine/index'; +import { engine, engineVersion } from './engine'; + +export type FileStatus = 'untracked' | 'modified' | 'staged' | 'deleted' | 'clean'; + +export type TreeEntry = { + path: string; // full VFS path, e.g. "src/app.js" + name: string; // display name, e.g. "app.js" + dir: string | null; // parent dir, or null for root (VFS is flat + 1 level) + status: FileStatus; +}; + +export type FileTreeModel = { + dirs: Array<{ name: string; files: TreeEntry[] }>; + rootFiles: TreeEntry[]; +}; + +/** + * Pure derivation of the explorer model from engine state. + * One badge per file; precedence: deleted > modified > staged > untracked. + * Deleted-but-tracked files stay visible (struck through in the UI) until + * the deletion is committed. `.git` is never shown. + */ +export function buildFileTree(eng: GitEngine): FileTreeModel { + const vfs = eng.getVFS(); + const deleted = new Set(eng.getDeletedFiles()); + const modified = new Set(eng.getModifiedFiles()); + const staged = new Set(eng.getStagedFiles()); + const untracked = new Set(eng.getUntrackedFiles()); + + const statusOf = (path: string): FileStatus => { + if (deleted.has(path)) return 'deleted'; + if (modified.has(path)) return 'modified'; + if (staged.has(path)) return 'staged'; + if (untracked.has(path)) return 'untracked'; + return 'clean'; + }; + + const toEntry = (path: string): TreeEntry => { + const slash = path.indexOf('/'); + return { + path, + name: slash === -1 ? path : path.slice(slash + 1), + dir: slash === -1 ? null : path.slice(0, slash), + status: statusOf(path), + }; + }; + + // Live files plus tracked-but-deleted paths; the two sets are disjoint. + const entries = [...vfs.allFilePaths(), ...deleted] + .filter((p) => p !== '.git' && !p.startsWith('.git/')) + .map(toEntry); + + // Dirs come from the VFS root listing; a deleted file may reference a dir + // the VFS no longer has, so union in parents from the entries as well. + const dirNames = new Set( + vfs + .listDir() + .filter((e) => e.endsWith('/') && !e.startsWith('.')) + .map((e) => e.slice(0, -1)), + ); + for (const e of entries) { + if (e.dir !== null) dirNames.add(e.dir); + } + + const byName = (a: TreeEntry, b: TreeEntry) => a.name.localeCompare(b.name); + return { + dirs: [...dirNames].sort().map((name) => ({ + name, + files: entries.filter((e) => e.dir === name).sort(byName), + })), + rootFiles: entries.filter((e) => e.dir === null).sort(byName), + }; +} + +/** Live explorer model — recomputed after every executed command. */ +export const fileTree = derived([engine, engineVersion], ([$engine]) => buildFileTree($engine)); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npm run test -- tests/store` then `npm run typecheck && npm run lint` +Expected: all PASS. + +- [ ] **Step 5: Amend the spec's interface note and commit** + +In `docs/specs/2026-07-11-beginner-file-explorer-design.md`, replace the two-line code block + +```ts +export function buildFileTree(engine: GitEngine): TreeEntry[]; // pure fn +export const fileTree = derived([engine, engineVersion], ...); // thin wrapper +``` + +with + +```ts +export type FileTreeModel = { dirs: { name: string; files: TreeEntry[] }[]; rootFiles: TreeEntry[] }; +export function buildFileTree(engine: GitEngine): FileTreeModel; // pure fn +export const fileTree = derived([engine, engineVersion], ...); // thin wrapper +``` + +```bash +git add src/store/files.ts tests/store/files.test.ts docs/specs/2026-07-11-beginner-file-explorer-design.md +git commit -m "feat(store): derived file-tree model with git status per file" +``` + +--- + +### Task 3: Explorer component + layout integration (desktop) + +**Files:** +- Create: `src/ui/FileTree.svelte` +- Modify: `src/ui/Layout.svelte` +- Modify: `docs/specs/2026-07-11-beginner-file-explorer-design.md` (staged badge cyan → blue, one word) + +No headless test — this repo has no Svelte component test setup; behavior is covered by the Task 6 e2e spec. Verify via typecheck/lint and `npm run dev` smoke check. + +**Interfaces:** +- Consumes: `fileTree`, `TreeEntry` from `$store/files`; `prefillTerminal` from `$store/ui`. +- Produces: `` component; an ` {:else} -
+