fixing lines overlapping
Some checks failed
CI / check (push) Has been cancelled

This commit is contained in:
2026-09-10 09:57:06 +02:00
parent 008adbfdb6
commit 1ebb9a0e74
18 changed files with 704 additions and 205 deletions

View File

@@ -14,59 +14,21 @@ vi.mock('../src/renderer/src/terminals', () => {
import { App } from '../src/renderer/src/App'
import { ProjectProvider } from '../src/renderer/src/project'
import { installBridge, removeBridge } from './stub-bridge'
const HEAD = 'a\nb\nc\n'
const INDEX = 'a\nSTAGED\nc\n'
const DISK = 'a\nSTAGED\nc\nAFTER-STAGING\n'
/** Minimal preload bridge: enough for the store to boot with real git rows. */
/** Exactly what git-service returns for porcelain "MM". */
function stubBridge(): void {
const noop = (): void => {}
const off = (): (() => void) => noop
;(window as unknown as { helder: unknown }).helder = {
platform: 'darwin',
clipboard: { writeText: noop, readText: () => '' },
project: {
current: async () => ({ root: '/repo', name: 'repo' }),
open: async () => ({ root: '/repo', name: 'repo' }),
openPath: async () => ({ root: '/repo', name: 'repo' }),
recent: async () => [],
},
fs: {
tree: async () => ({ name: 'repo', type: 'dir', path: '', children: [{ name: 'demo.txt', type: 'file', path: 'demo.txt' }] }),
readDir: async () => [],
files: async () => ({ 'demo.txt': DISK }),
read: async () => DISK,
imageDataUrl: async () => '',
write: async () => {},
delete: async () => {},
create: async () => {},
mkdir: async () => {},
},
shell: { reveal: noop },
notes: { read: async () => '', write: async () => {} },
git: {
// Exactly what git-service now returns for porcelain "MM".
load: async () => ({
branch: 'main',
changes: [
{ path: 'demo.txt', status: 'M', staged: true, original: HEAD, updated: INDEX },
{ path: 'demo.txt', status: 'M', staged: false, original: INDEX, updated: DISK },
],
}),
stage: async () => {}, unstage: async () => {}, commit: async () => {},
push: async () => ({ ok: true, message: '' }), discard: async () => {},
},
pty: { available: async () => false, create: async () => 1, write: noop, resize: noop, kill: noop, onData: off, onExit: off },
config: { get: async () => (await import('../src/renderer/src/types')).DEFAULT_CONFIG, theme: async () => '' },
recent: { get: async () => [], set: async () => {} },
search: { content: async () => [], files: async () => [] },
dialog: { unsavedClose: async () => 'cancel' },
log: { write: noop, path: async () => null, open: async () => {}, reveal: async () => {} },
onProjectChanged: off,
onConfigChanged: off,
onRefresh: off,
}
installBridge(
[
{ path: 'demo.txt', status: 'M', staged: true, original: HEAD, updated: INDEX },
{ path: 'demo.txt', status: 'M', staged: false, original: INDEX, updated: DISK },
],
{ 'demo.txt': DISK },
)
}
beforeAll(() => {
@@ -77,12 +39,17 @@ beforeEach(stubBridge)
afterEach(() => {
cleanup()
localStorage.clear()
delete (window as unknown as { helder?: unknown }).helder
removeBridge()
})
/** Row text of the view currently on screen. */
/** Row text of the read-only view currently on screen. */
function viewText(c: HTMLElement): string {
return Array.from(c.querySelectorAll('.editor .ln-row')).map((el) => el.textContent ?? '').join('\n')
return Array.from(c.querySelectorAll('.editor-wrap .ln-row')).map((el) => el.textContent ?? '').join('\n')
}
/** The two panes of the full-screen Diff, as line text. */
function splitText(c: HTMLElement, pane: 'left' | 'right'): string[] {
return Array.from(c.querySelectorAll(`.split-pane.${pane} .ln-row`)).map((el) => (el.textContent ?? '').replace(/^\d*/, ''))
}
function group(c: HTMLElement, label: 'Staged Changes' | 'Changes'): HTMLElement[] {
@@ -104,6 +71,14 @@ async function boot(): Promise<HTMLElement> {
return c
}
/** Open one git row, then lift its pair into the full-screen Diff. */
async function openSplit(c: HTMLElement, label: 'Staged Changes' | 'Changes'): Promise<void> {
fireEvent.click(group(c, label)[0])
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
fireEvent.click(Array.from(c.querySelectorAll<HTMLElement>('.seg button')).find((b) => b.textContent === 'Diff')!)
await waitFor(() => expect(c.querySelector('.split-overlay')).toBeTruthy())
}
describe('a file that is staged and then edited again', () => {
it('shows up in both groups', async () => {
const c = await boot()
@@ -113,31 +88,49 @@ describe('a file that is staged and then edited again', () => {
it('Diff on the staged row compares HEAD with the staged copy', async () => {
const c = await boot()
fireEvent.click(group(c, 'Staged Changes')[0])
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
const text = viewText(c)
expect(text).toContain('b')
expect(text).toContain('STAGED')
// The later edit is not part of what is staged, so it must not show here.
expect(text).not.toContain('AFTER-STAGING')
await openSplit(c, 'Staged Changes')
expect(splitText(c, 'left')).toEqual(['a', 'b', 'c'])
expect(splitText(c, 'right')).toEqual(['a', 'STAGED', 'c'])
// The later edit is not part of what is staged, so neither side may show it.
expect(splitText(c, 'right').join('\n')).not.toContain('AFTER-STAGING')
})
it('Diff on the unstaged row compares the staged copy with disk', async () => {
const c = await boot()
fireEvent.click(group(c, 'Changes')[0])
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
const text = viewText(c)
expect(text).toContain('AFTER-STAGING')
await openSplit(c, 'Changes')
// 'b' was already replaced before staging, so this half must not mention it.
expect(text.split('\n').some((l) => l.trim() === 'b')).toBe(false)
expect(splitText(c, 'left')).toEqual(['a', 'STAGED', 'c', ''])
expect(splitText(c, 'right')).toEqual(['a', 'STAGED', 'c', 'AFTER-STAGING'])
})
it('the hover panel behind Actual always reaches back to HEAD', async () => {
const c = await boot()
fireEvent.click(group(c, 'Changes')[0])
const scroll = await waitFor(() => {
const el = c.querySelector<HTMLElement>('.ce-scroll')
if (!el || !c.querySelector('.ce-band')) throw new Error('bands not ready')
return el
})
// Line 2 is 'STAGED'. Unwrapped, its band runs from 10 + (2-1)*20.
fireEvent.mouseMove(scroll, { clientY: 10 + 20 + 5 })
const peek = await waitFor(() => {
const p = c.querySelector<HTMLElement>('.peek')
if (!p) throw new Error('peek not ready')
return p
})
const removed = Array.from(peek.querySelectorAll('.ln-row.del'))
expect(removed.map((el) => el.textContent?.replace(/^\d+/, ''))).toEqual(['b'])
fireEvent.mouseLeave(scroll)
await waitFor(() => expect(c.querySelector('.peek')).toBeNull())
})
it('labels which pair the diff is comparing', async () => {
const c = await boot()
fireEvent.click(group(c, 'Staged Changes')[0])
await waitFor(() => expect(c.querySelector('.db-side')?.textContent).toBe('HEAD → staged'))
fireEvent.click(group(c, 'Changes')[0])
await waitFor(() => expect(c.querySelector('.db-side')?.textContent).toBe('staged → actual'))
await openSplit(c, 'Staged Changes')
expect(c.querySelector('.diff-bar .db-side')?.textContent).toBe('HEAD → staged')
await openSplit(c, 'Changes')
expect(c.querySelector('.diff-bar .db-side')?.textContent).toBe('staged → actual')
})
it('Original stays HEAD and Actual stays the file on disk, from either row', async () => {