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
5 changes: 3 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ Personal portfolio site built as an in-browser fake desktop environment. TypeScr
| `desktop-wm-sync.ts` | Shell `dataset.*` mirrors for CSS |
| `desktop-ps-snapshot.ts` | Simulated `ps` rows for terminal MOTD |
| `editor-ex-commands.ts` | Modal editor `:w` / `:q` / `:e` ex-mode parsing |
| `editor-vim-ops.ts` | Pure caret/line helpers for vim motions (`j`/`k`, `:N`, counts) |
| `editor-vim-ops.ts` | Pure caret/line/motion helpers for vim motions (`j`/`k`, `:N`, counts, dd/yy) |
| `editor-vim-keys.ts` | INSERT/NORMAL key-chord helpers (Esc, count prefix) |
| `editor-window-meta.ts` | Editor path compare + title string helpers |
| `terminal.ts` | xterm.js façade, scripted boot lines, Vim-style prompt, command dispatch |
| `bootstrap-shell.ts` | Boot sequence orchestration |
Expand Down Expand Up @@ -128,7 +129,7 @@ Sketches are seeded into `~/sketches/` in the VFS (`os-fs.ts`) at first visit.

## Testing

535 tests across 45 test files (plus Playwright smoke e2e). Tests co-located with source: `module.ts` → `module.test.ts`.
563 tests across 46 test files (plus Playwright smoke e2e). Tests co-located with source: `module.ts` → `module.test.ts`.

| Test File | Coverage |
|-----------|----------|
Expand Down
148 changes: 145 additions & 3 deletions src/desktop-wm-maximize.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,149 @@
import { describe, it, expect } from 'vitest'
import { describe, it, expect, vi, beforeAll } from 'vitest'
import {
toggleMaximizeContent,
toggleMaximizeFocused,
unmaximizeContent,
type WmMaximizeContext,
} from './desktop-wm-maximize'
import type { TiledWin } from './desktop-open-window'

class FakeEl {
className = ''
dataset: Record<string, string> = {}
readonly classList = {
add: (c: string) => {
const parts = new Set(this.className.split(/\s+/).filter(Boolean))
parts.add(c)
this.className = [...parts].join(' ')
},
remove: (c: string) => {
this.className = this.className
.split(/\s+/)
.filter(x => x && x !== c)
.join(' ')
},
contains: (c: string) => this.className.split(/\s+/).includes(c),
}
}

function mockWin(command: string): TiledWin & { el: FakeEl } {
const el = new FakeEl()
el.className = 'content-window'
return {
command,
el: el as unknown as HTMLElement,
isMaximized: () => el.classList.contains('maximized'),
} as TiledWin & { el: FakeEl }
}

function makeCtx() {
let maximizedId: string | null = null
const panes = new FakeEl()
const desktop = new FakeEl()
const wins = new Map<string, TiledWin>()
const syncDockVisibility = vi.fn()
const onAfterMaximizeLayout = vi.fn()
const attachVerticalSplitters = vi.fn()
const sync = vi.fn()

const ctx: WmMaximizeContext = {
getMaximizedId: () => maximizedId,
setMaximizedId: id => {
maximizedId = id
},
panes: panes as unknown as HTMLElement,
desktop: desktop as unknown as HTMLElement,
findOpenWindow: cmd => wins.get(cmd),
unmaximizeContent: win => unmaximizeContent(ctx, win),
syncDockVisibility,
onAfterMaximizeLayout,
attachVerticalSplitters,
sync,
}

return { ctx, panes, desktop, wins, syncDockVisibility, onAfterMaximizeLayout, attachVerticalSplitters, sync }
}

beforeAll(() => {
vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
fn(0)
return 0
})
})

describe('toggleMaximizeContent', () => {
it('maximizes a content window and sets shell dataset', () => {
const { ctx, panes, desktop, wins, syncDockVisibility, onAfterMaximizeLayout } = makeCtx()
const win = mockWin('resume')
wins.set('resume', win)

toggleMaximizeContent(ctx, win)

expect(win.el.classList.contains('maximized')).toBe(true)
expect(panes.classList.contains('max-content')).toBe(true)
expect(ctx.getMaximizedId()).toBe('resume')
expect(desktop.dataset.maximized).toBe('1')
expect(syncDockVisibility).toHaveBeenCalledOnce()
expect(onAfterMaximizeLayout).toHaveBeenCalledOnce()
})

it('unmaximizes when the window is already maximized', () => {
const { ctx, panes, wins, attachVerticalSplitters, sync } = makeCtx()
const win = mockWin('whoami')
win.el.classList.add('maximized')
panes.classList.add('max-content')
ctx.setMaximizedId('whoami')
wins.set('whoami', win)

toggleMaximizeContent(ctx, win)

expect(win.el.classList.contains('maximized')).toBe(false)
expect(panes.classList.contains('max-content')).toBe(false)
expect(ctx.getMaximizedId()).toBeNull()
expect(attachVerticalSplitters).toHaveBeenCalledOnce()
expect(sync).toHaveBeenCalledOnce()
})

it('demotes the previously maximized window when switching maximize target', () => {
const { ctx, wins } = makeCtx()
const a = mockWin('resume')
const b = mockWin('links')
wins.set('resume', a)
wins.set('links', b)

toggleMaximizeContent(ctx, a)
toggleMaximizeContent(ctx, b)

expect(a.el.classList.contains('maximized')).toBe(false)
expect(b.el.classList.contains('maximized')).toBe(true)
expect(ctx.getMaximizedId()).toBe('links')
})
})

describe('toggleMaximizeFocused', () => {
it('is covered by desktop WM integration tests', () => {
expect(true).toBe(true)
it('no-ops when nothing is focused', () => {
const { ctx } = makeCtx()
toggleMaximizeFocused(ctx, null)
expect(ctx.getMaximizedId()).toBeNull()
})

it('maximizes the focused window command', () => {
const { ctx, wins } = makeCtx()
const win = mockWin('projects')
wins.set('projects', win)

toggleMaximizeFocused(ctx, 'projects')

expect(win.el.classList.contains('maximized')).toBe(true)
expect(ctx.getMaximizedId()).toBe('projects')
})
})

describe('unmaximizeContent', () => {
it('ignores windows that are not maximized', () => {
const { ctx, sync } = makeCtx()
const win = mockWin('paint')
unmaximizeContent(ctx, win)
expect(sync).not.toHaveBeenCalled()
})
})
31 changes: 31 additions & 0 deletions src/editor-vim-keys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import { insertModeKeyAction, tryAppendCountDigit } from './editor-vim-keys'

describe('insertModeKeyAction', () => {
it('leaves insert on Escape', () => {
expect(insertModeKeyAction('Escape', { ctrlKey: false, metaKey: false })).toBe('leave-normal')
})

it('leaves insert on Ctrl+[', () => {
expect(insertModeKeyAction('[', { ctrlKey: true, metaKey: false })).toBe('leave-normal')
})

it('passes through ordinary keys', () => {
expect(insertModeKeyAction('a', { ctrlKey: false, metaKey: false })).toBe('pass')
})
})

describe('tryAppendCountDigit', () => {
it('starts a count with 1-9', () => {
expect(tryAppendCountDigit('', '3')).toBe('3')
})

it('appends 0 only when digits already buffered', () => {
expect(tryAppendCountDigit('', '0')).toBeNull()
expect(tryAppendCountDigit('12', '0')).toBe('120')
})

it('caps at six digits', () => {
expect(tryAppendCountDigit('123456', '7')).toBe('123456')
})
})
22 changes: 22 additions & 0 deletions src/editor-vim-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* Pure key-chord helpers for modal editor vim modes (no DOM).
*/

/** INSERT mode: Esc or Ctrl+[ returns to NORMAL. */
export function insertModeKeyAction(
key: string,
opts: { ctrlKey: boolean; metaKey: boolean },
): 'leave-normal' | 'pass' {
if (key === 'Escape' || (opts.ctrlKey && key === '[')) return 'leave-normal'
return 'pass'
}

/** NORMAL mode count prefix — returns updated digits or null if not a count key. */
export function tryAppendCountDigit(digits: string, key: string): string | null {
if (/^[1-9]$/.test(key) || (key === '0' && digits !== '')) {
let next = digits + key
if (next.length > 6) next = next.slice(0, 6)
return next
}
return null
}
135 changes: 134 additions & 1 deletion src/editor-vim-ops.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
import { describe, it, expect } from 'vitest'
import {
applyReplaceRunsText,
deleteLineBlockText,
findNextOnLine,
joinLinesText,
lineEndCaretPos,
moveHorizPos,
pasteYankText,
reverseFindKind,
wordBackPos,
wordEndForwardPos,
wordForwardPos,
yankLineBlockText,
} from './editor-vim-ops'
import { insertModeKeyAction, tryAppendCountDigit } from './editor-vim-keys'
import {
consumeCountDigits,
getLineCol,
Expand Down Expand Up @@ -46,8 +61,126 @@ describe('consumeCountDigits', () => {
describe('moveVertPos', () => {
it('moves down preserving 0-based column offset (clamped to line length)', () => {
const text = 'ab\nc\nde'
// caret on 'b' (index 1) → same column on "c" clamps to index 4 (after sole char)
expect(moveVertPos(text, 1, 1)).toBe(4)
expect(moveVertPos(text, 0, 1)).toBe(3)
})
})

describe('moveHorizPos', () => {
it('steps left and right with clamping', () => {
expect(moveHorizPos('abc', 1, -1, 2)).toBe(0)
expect(moveHorizPos('abc', 1, 1, 2)).toBe(3)
})
})

describe('lineEndCaretPos', () => {
it('lands on last character before newline', () => {
expect(lineEndCaretPos('hello\nworld', 2)).toBe(4)
})
})

describe('word motions', () => {
const text = 'foo bar baz'

it('wordForwardPos skips token and punctuation', () => {
expect(wordForwardPos(text, 0)).toBe(3)
expect(wordForwardPos(text, 4)).toBe(7)
expect(wordForwardPos(text, 8)).toBe(11)
})

it('wordBackPos finds previous token start', () => {
expect(wordBackPos(text, 11)).toBe(8)
expect(wordBackPos(text, 8)).toBe(4)
})

it('wordEndForwardPos finds end of current/next token', () => {
expect(wordEndForwardPos(text, 0)).toBe(2)
expect(wordEndForwardPos(text, 8)).toBe(10)
})
})

describe('findNextOnLine', () => {
const line = 'alpha beta gamma'

it('finds forward character', () => {
expect(findNextOnLine(line, 'f', 'b', 0)).toBe(6)
})

it('finds backward character', () => {
expect(findNextOnLine(line, 'F', 'b', 12)).toBe(6)
})

it('reverseFindKind swaps directions', () => {
expect(reverseFindKind('f')).toBe('F')
expect(reverseFindKind('t')).toBe('T')
})
})

describe('deleteLineBlockText', () => {
it('removes lines and leaves caret at block start', () => {
const text = 'one\ntwo\nthree'
const result = deleteLineBlockText(text, 2, 2)
expect(result).toEqual({ text: 'one\n', pos: 4 })
})
})

describe('yankLineBlockText', () => {
it('includes trailing newline in register', () => {
const result = yankLineBlockText('a\nb\nc', 2, 1)
expect(result?.yank).toBe('b\n')
expect(result?.lineCount).toBe(1)
})
})

describe('joinLinesText', () => {
it('merges consecutive lines', () => {
const result = joinLinesText('aa\nbb\ncc', 1, 2)
expect(result).toEqual({ text: 'aabbcc', pos: 4 })
})
})

describe('applyReplaceRunsText', () => {
it('replaces run and positions caret on last replaced char', () => {
const result = applyReplaceRunsText('hello', 1, 2, 'X')
expect(result).toEqual({ text: 'hXXlo', pos: 2 })
})
})

describe('pasteYankText', () => {
it('pastes after line when p', () => {
const result = pasteYankText('a\nb', 0, 'Y\n', true)
expect(result?.text).toBe('a\nY\nb')
})

it('pastes before line when P', () => {
const result = pasteYankText('a\nb', 2, 'Y\n', false)
expect(result?.text).toBe('a\nY\nb')
})
})

describe('vim editing scenarios', () => {
it('yank-delete-paste round trip restores buffer', () => {
const original = 'line1\nline2\nline3'
const yanked = yankLineBlockText(original, 2, 1)
expect(yanked?.yank).toBe('line2\n')

const deleted = deleteLineBlockText(original, 2, 1)
expect(deleted?.text).toBe('line1\nline3')

const pasted = pasteYankText(deleted!.text, 0, yanked!.yank, true)
expect(pasted?.text).toBe(original)
})

it('insert mode Esc is detected before normal-mode count keys', () => {
expect(insertModeKeyAction('Escape', { ctrlKey: false, metaKey: false })).toBe('leave-normal')
expect(tryAppendCountDigit('', '3')).toBe('3')
})

it('find motion chains forward on repeated f', () => {
const text = 'ab ab ab'
const first = findNextOnLine(text, 'f', 'a', 0)
expect(first).toBe(3)
const second = findNextOnLine(text, 'f', 'a', first!)
expect(second).toBe(6)
})
})
Loading
Loading