Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ A personal portfolio built as an **in-browser desktop OS** — tiling window man
| **Stack** | TypeScript · Vite 8 · vanilla DOM (no framework) |
| **Terminal** | [@xterm/xterm](https://github.com/xtermjs/xterm.js) + vim input layer |
| **3D** | Three.js (Rubik cube — lazy chunk only) |
| **Tests** | **583** unit · **3** e2e smoke · CI on every `main` push |
| **Tests** | **587** unit · **3** e2e smoke · CI on every `main` push |
| **Deploy** | GitHub Actions → GitHub Pages (`dist/`) |

---
Expand All @@ -33,7 +33,7 @@ Most portfolios list skills. This one **runs** them: modular TypeScript, lazy co
```bash
npm install
npm run dev # desktop → http://localhost:5173/
npm test # 583 unit tests
npm test # 587 unit tests
npm run build && npm run test:e2e
```

Expand Down
17 changes: 11 additions & 6 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ static/index.html
| `commands/app-commands.ts` | Tile stubs returning `[]` |
| `os-fs.ts` | VFS — key `portfolio-vfs-v8-namefailed-home` |
| `os-registry.ts` | Breaks circular import: terminal → desktop ref |
| `editor-vim-ops.ts` | Pure editor motions — **preferred home for new vim text helpers** |
| `editor-vim-motions.ts` | Pure caret/motion helpers — **no buffer mutation** |
| `editor-vim-edits.ts` | Pure `{ text, pos }` buffer edits — `BufferEditResult` |
| `editor-vim-ops.ts` | Barrel re-export of motions + edits |
| `editor-buffer.ts` | Apply layer between pure edits and textarea state |
| `editor-normal-handlers.ts` | NORMAL-mode single-key handler map |
| `editor-vim-keys.ts` | Pure editor key-chord helpers |
| `vim.ts` | Terminal one-line vim widget (separate from editor tile) |
| `bsp-layout.ts` | Two-column BSP + splitters |
Expand All @@ -73,7 +77,7 @@ static/index.html
| `focusedId === null` | No right-pane tile focused (not “legacy terminal focused”) |
| `#panes` contains only `#right-pane` | Terminal is a tile, not static HTML column |
| BSP `maxVisible = 4` | Fifth tile bumps oldest to dock |
| Editor buffer mutations | Prefer `editor-vim-ops.ts` + tests before touching `editor-window.ts` |
| Editor buffer mutations | Prefer `editor-vim-edits.ts` + `editor-vim-motions.ts` + tests before touching `editor-window.ts` |

---

Expand Down Expand Up @@ -111,12 +115,13 @@ Prefer editing the extracted module, not `desktop.ts`:
| Shell CSS dataset | `desktop-wm-sync.ts` |
| Host bindings | `desktop-wm-hosts.ts` |

### Editor vim motion
### Editor vim motion or edit

```
1. Add pure function to editor-vim-ops.ts
2. Test in editor-vim-ops.test.ts
3. Wire one-liner in editor-window.ts
1. Add pure function to editor-vim-motions.ts (caret) or editor-vim-edits.ts (mutation)
2. Test in editor-vim-motions.test.ts or editor-vim-edits.test.ts
3. Wire handler in editor-normal-handlers.ts OR chord in editor-window.ts
4. Buffer apply goes through editor-buffer.ts when mutating textarea state
```

---
Expand Down
17 changes: 13 additions & 4 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,17 +250,24 @@ class VimInput {

## Editor vim ops (tile)

Pure functions in `editor-vim-ops.ts` — safe to unit test without DOM:
Split pure modules — safe to unit test without DOM:

| Module | Exports |
|--------|---------|
| `editor-vim-motions.ts` | Caret positions, word/find motions — **no buffer mutation** |
| `editor-vim-edits.ts` | `{ text, pos }` mutations — `BufferEditResult` contract |
| `editor-vim-ops.ts` | Barrel re-export of both |
| `editor-buffer.ts` | `applyBufferEditToState`, `runIndentBufferEdit` — shared apply layer |
| `editor-normal-handlers.ts` | `EDITOR_NORMAL_KEY_HANDLERS`, `dispatchEditorNormalKey` |

```typescript
// Motions
// Motions (editor-vim-motions.ts)
moveVertPos / moveVertRepeat / moveHorizPos
wordForwardPos / wordBackPos / wordEndForwardPos
wordForwardRepeat / wordBackRepeat / wordEndForwardRepeat
findNextOnLine / repeatFindPos / reverseFindKind
lineEndCaretPos / appendLineEndPos / firstNonBlankOnLine

// Edits
// Edits (editor-vim-edits.ts)
indentLinesText / unindentLinesText // >> / <<
toggleCaseRunText // ~
substituteCharsText // s
Expand All @@ -274,6 +281,8 @@ applyReplaceRunsText / pasteYankText

Key chords: `editor-vim-keys.ts` — `insertModeKeyAction`, `tryAppendCountDigit`.

Multi-key chords (`gg`, `dd`, `>>`, find-await) remain in `editor-window.ts`.

Ex commands: `parseEditorExCommand()` in `editor-ex-commands.ts`.

---
Expand Down
12 changes: 8 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,12 @@ Three.js ships **only** in the `rubik-window` lazy chunk.

| Module | Responsibility |
|--------|----------------|
| `editor-window.ts` | DOM, modes, key routing, VFS I/O |
| `editor-vim-ops.ts` | Pure text/cursor/motion helpers (unit tested) |
| `editor-window.ts` | DOM, modes, chord routing, VFS I/O |
| `editor-normal-handlers.ts` | NORMAL-mode single-key handler map |
| `editor-buffer.ts` | Buffer state apply layer (textarea ↔ pure edits) |
| `editor-vim-ops.ts` | Barrel re-export of motions + edits |
| `editor-vim-motions.ts` | Pure caret/motion helpers (no mutation) |
| `editor-vim-edits.ts` | Pure `{ text, pos }` buffer mutations |
| `editor-vim-keys.ts` | Pure INSERT/NORMAL key-chord helpers |
| `editor-ex-commands.ts` | `:w` / `:q` / `:e` ex-mode parsing |
| `editor-window-meta.ts` | Path compare, title strings |
Expand Down Expand Up @@ -228,14 +232,14 @@ Bump VFS key version in `os-fs.ts` to reset visitor filesystems.

## Testing

**583 tests** · **46 files** · Vitest in Node · Playwright e2e smoke.
**587 tests** · **48 files** · Vitest in Node · Playwright e2e smoke.

### By domain

| Domain | Example test files |
|--------|-------------------|
| WM / desktop | `desktop.test.ts`, `desktop-wm-*.test.ts`, `desktop-keyboard-handler.test.ts`, `bsp-layout.test.ts` |
| Editor vim | `editor-vim-ops.test.ts`, `editor-vim-keys.test.ts`, `editor-ex-commands.test.ts` |
| Editor vim | `editor-vim-motions.test.ts`, `editor-vim-edits.test.ts`, `editor-buffer.integration.test.ts`, `editor-vim-keys.test.ts`, `editor-ex-commands.test.ts` |
| Terminal / vim | `vim.test.ts`, `terminal-motd.test.ts` |
| VFS / commands | `os-fs.test.ts`, `commands/*.test.ts` |
| Tiles / launcher | `launcher-catalog.test.ts`, `desktop-open-window.test.ts`, `window-chrome.test.ts` |
Expand Down
2 changes: 1 addition & 1 deletion docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ Run before opening a PR:

```bash
npm run lint # ESLint
npm test # Vitest — 583 unit tests
npm test # Vitest — 587 unit tests
npm run build # TypeScript + Vite
npm run test:e2e # Playwright (requires preview build)
```
Expand Down
6 changes: 3 additions & 3 deletions docs/OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ Each window tile (`editor-window.ts`, `rubik-window.ts`, etc.) is loaded with **
| Layer | Module | Purpose |
|-------|--------|---------|
| Terminal one-liner | `vim.ts` | Shell prompt — insert/normal/visual, history, completion |
| Modal editor tile | `editor-window.ts` + `editor-vim-ops.ts` | Full buffer editor over the VFS — motions, operators, ex commands |
| Modal editor tile | `editor-window.ts` + vim stack | Full buffer editor over the VFS — motions, operators, ex commands |

Pure caret helpers in `editor-vim-ops.ts` are **unit-tested without a DOM**, which keeps editor logic maintainable.
Pure caret and edit helpers (`editor-vim-motions.ts`, `editor-vim-edits.ts`) are **unit-tested without a DOM**, which keeps editor logic maintainable.

### Virtual filesystem (VFS v8)

Expand All @@ -100,7 +100,7 @@ Files persist in `localStorage` under `portfolio-vfs-v8-namefailed-home`. First

| Stage | Tool |
|-------|------|
| Unit | Vitest — **583 tests**, **46 files**, Node environment with DOM stubs where needed |
| Unit | Vitest — **587 tests**, **48 files**, Node environment with DOM stubs where needed |
| Lint | ESLint 9 + TypeScript-eslint |
| E2e | Playwright — production build smoke (desktop shell, brochure, nav) |
| Deploy | GitHub Actions → `dist/` → GitHub Pages |
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Personal portfolio site ([mrgrey.site](https://mrgrey.site)) built as an in-brow

| Metric | Value |
|--------|-------|
| Unit tests | **583** across **46** files (`npm test`) |
| Unit tests | **587** across **48** files (`npm test`) |
| E2e smoke | **3** Playwright specs (`npm run test:e2e`) |
| CI | lint → unit tests → build → e2e → GitHub Pages deploy |
| Stack | TypeScript, Vite 8, vanilla DOM — no React/Vue/Svelte |
Expand Down
2 changes: 1 addition & 1 deletion docs/STYLE_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,7 @@ el.innerHTML = escapeHtml(userInput)
## Tooling

- `npm run lint` — ESLint (TypeScript, e2e specs, maintainer scripts)
- `npm test` — **583** Vitest unit tests (46 files)
- `npm test` — **587** Vitest unit tests (48 files)
- `npm run test:coverage` — Vitest v8 coverage (`coverage/` gitignored)
- `npm run test:e2e` — Playwright smoke against `vite preview`
- Desktop CSS: `src/styles/*.css` via `src/style.css` hub; regenerate with `node scripts/split-style-css.mjs`
Expand Down
52 changes: 52 additions & 0 deletions src/editor-buffer.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest'
import {
applyBufferEditToState,
applyStateToTextarea,
bufferStateFromTextarea,
runIndentBufferEdit,
type EditorBufferState,
type TextareaLike,
} from './editor-buffer'

function fakeTextarea(value: string, pos: number): TextareaLike {
return { value, selectionStart: pos, selectionEnd: pos }
}

describe('editor-buffer integration', () => {
it('>> indents from caret line through apply pipeline', () => {
const textarea = fakeTextarea(' foo\nbar', 2)
const savedText = ' foo\nbar'
const state = bufferStateFromTextarea(textarea, savedText, false)

expect(runIndentBufferEdit(state, 1)).toBe(true)
expect(state.text).toBe(' foo\nbar')
expect(state.selectionStart).toBe(4)
expect(state.dirty).toBe(true)

applyStateToTextarea(textarea, state)
expect(textarea.value).toBe(' foo\nbar')
expect(textarea.selectionStart).toBe(4)
})

it('applyBufferEditToState is a no-op for null edits', () => {
const state: EditorBufferState = {
text: 'hello',
selectionStart: 0,
selectionEnd: 0,
savedText: 'hello',
dirty: false,
}
expect(applyBufferEditToState(state, null)).toBe(false)
expect(state.text).toBe('hello')
})

it('2>> indents multiple lines from caret', () => {
const textarea = fakeTextarea('a\nb\nc', 2)
const state = bufferStateFromTextarea(textarea, 'a\nb\nc', false)

expect(runIndentBufferEdit(state, 2)).toBe(true)
expect(state.text).toBe('a\n b\n c')
applyStateToTextarea(textarea, state)
expect(textarea.value).toBe('a\n b\n c')
})
})
67 changes: 67 additions & 0 deletions src/editor-buffer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Shared buffer-apply layer between pure vim edits and the editor tile.
*
* Contract: `applyBufferEditToState` writes text + selection and sets `dirty`.
* Undo snapshots and DOM updates remain the caller's responsibility.
*/

import type { BufferEditResult } from './editor-vim-edits'
import { indentLinesText } from './editor-vim-edits'

export type { BufferEditResult }

export interface EditorBufferState {
text: string
selectionStart: number
selectionEnd: number
savedText: string
dirty: boolean
}

/** Minimal textarea surface used by integration tests and EditorWindow. */
export interface TextareaLike {
value: string
selectionStart: number
selectionEnd: number
}

export function bufferStateFromTextarea(
textarea: TextareaLike,
savedText: string,
dirty: boolean,
): EditorBufferState {
return {
text: textarea.value,
selectionStart: textarea.selectionStart,
selectionEnd: textarea.selectionEnd,
savedText,
dirty,
}
}

export function applyStateToTextarea(textarea: TextareaLike, state: EditorBufferState): void {
textarea.value = state.text
textarea.selectionStart = state.selectionStart
textarea.selectionEnd = state.selectionEnd
}

/** Apply a pure edit result; returns false when `result` is null/undefined. */
export function applyBufferEditToState(
state: EditorBufferState,
result: BufferEditResult | null | undefined,
): boolean {
if (!result) return false
state.text = result.text
state.selectionStart = result.pos
state.selectionEnd = result.pos
state.dirty = result.text !== state.savedText
return true
}

/** `>>` pipeline: indent N lines from caret — used by handlers and integration tests. */
export function runIndentBufferEdit(state: EditorBufferState, nLines: number): boolean {
return applyBufferEditToState(
state,
indentLinesText(state.text, state.selectionStart, nLines),
)
}
Loading
Loading