diff --git a/.gitignore b/.gitignore index 78d40f8..ee8c7d0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ dist/ test-results/ playwright-report/ playwright/.cache/ +.superpowers/ diff --git a/CLAUDE.md b/CLAUDE.md index 17e0e6c..3c0c871 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,20 +67,23 @@ src/ ├── shell/ # Command routing + builtins │ ├── parser.ts # Input tokenization │ ├── router.ts # Git vs builtin dispatch -│ ├── builtins.ts # ls, cat, touch, rm, mv, clear, help +│ ├── builtins.ts # ls, cat, touch, rm, mv, mkdir, clear, help │ ├── history.ts # Command history │ ├── complete.ts # Tab autocomplete │ └── prompt.ts # Powerlevel10k-style prompt ├── ui/ # Svelte 5 components │ ├── Terminal.svelte │ ├── Graph.svelte -│ ├── FilePanel.svelte +│ ├── FileTree.svelte │ ├── Layout.svelte │ ├── Prompt.svelte │ ├── CommitDetail.svelte -│ └── MobileToolbar.svelte +│ ├── GithubLink.svelte +│ └── ResetButton.svelte ├── graph/ # D3 layout logic ├── store/ # Svelte stores +│ ├── files.ts # File-tree model derived from engine state +│ └── actions.ts # Command planners for explorer buttons ├── persistence/ # IndexedDB save/load ├── App.svelte └── main.ts @@ -93,7 +96,7 @@ tests/ ## Key Design Decisions - **No shell emulation** — terminal handles git commands + minimal file builtins only -- **Simulated file changes** — no text editing; "Simulate Changes" button/command mutates tracked files +- **Simulated file changes** — no text editing; the explorer's "✎ Simulate changes" button appends a line to tracked files via real `echo >>` commands run through the terminal - **Flat + 1 level VFS** — files and one level of directories max - **Left-to-right DAG** — mermaid gitGraph style, SVG rendered by Svelte, d3-dag for layout - **Layered UI** — graph as background, terminal as semi-transparent overlay, toggle focus 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 `