handling files when stages and dirty at once

This commit is contained in:
2026-07-28 08:57:36 +02:00
parent d3bcdb74c2
commit 03e16d49a1
29 changed files with 1597 additions and 191 deletions

167
test/git-two-rows.test.tsx Normal file
View File

@@ -0,0 +1,167 @@
// @vitest-environment jsdom
//
// A file can be staged and then edited again. Git calls that "MM": two rows,
// one per group. These tests drive the renderer with such a payload and check
// that Diff follows the row you clicked, while Original and Actual do not.
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import React from 'react'
vi.mock('../src/renderer/src/terminals', () => {
let n = 0
return { Terminal: () => null, lid: () => ++n }
})
import { App } from '../src/renderer/src/App'
import { ProjectProvider } from '../src/renderer/src/project'
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. */
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 },
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,
}
}
beforeAll(() => {
globalThis.ResizeObserver = class { observe(): void {} unobserve(): void {} disconnect(): void {} } as never
if (!Element.prototype.scrollIntoView) Element.prototype.scrollIntoView = (): void => {}
})
beforeEach(stubBridge)
afterEach(() => {
cleanup()
localStorage.clear()
delete (window as unknown as { helder?: unknown }).helder
})
/** Row text of the view currently on screen. */
function viewText(c: HTMLElement): string {
return Array.from(c.querySelectorAll('.editor .ln-row')).map((el) => el.textContent ?? '').join('\n')
}
function group(c: HTMLElement, label: 'Staged Changes' | 'Changes'): HTMLElement[] {
const heads = Array.from(c.querySelectorAll<HTMLElement>('.git-group'))
const head = heads.find((h) => h.textContent?.startsWith(label))!
const rows: HTMLElement[] = []
for (let el = head.nextElementSibling; el; el = el.nextElementSibling) {
if (el.classList.contains('git-group') || el.classList.contains('git-divider')) break
if (el.classList.contains('git-row')) rows.push(el as HTMLElement)
}
return rows
}
async function boot(): Promise<HTMLElement> {
const c = render(<ProjectProvider><App /></ProjectProvider>).container
await waitFor(() => {
if (c.querySelectorAll('.git-row').length < 2) throw new Error('git not ready')
})
return c
}
describe('a file that is staged and then edited again', () => {
it('shows up in both groups', async () => {
const c = await boot()
expect(group(c, 'Staged Changes').map((r) => r.getAttribute('title'))).toEqual(['demo.txt'])
expect(group(c, 'Changes').map((r) => r.getAttribute('title'))).toEqual(['demo.txt'])
})
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')
})
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')
// 'b' was already replaced before staging, so this half must not mention it.
expect(text.split('\n').some((l) => l.trim() === 'b')).toBe(false)
})
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'))
})
it('Original stays HEAD and Actual stays the file on disk, from either row', async () => {
const c = await boot()
for (const label of ['Staged Changes', 'Changes'] as const) {
fireEvent.click(group(c, label)[0])
await waitFor(() => expect(c.querySelector('.diff-bar')).toBeTruthy())
const original = Array.from(c.querySelectorAll<HTMLElement>('.seg button')).find((b) => b.textContent === 'Original')!
fireEvent.click(original)
await waitFor(() => expect(viewText(c)).toContain('b'))
expect(viewText(c)).not.toContain('AFTER-STAGING')
const actual = Array.from(c.querySelectorAll<HTMLElement>('.seg button')).find((b) => b.textContent === 'Actual')!
fireEvent.click(actual)
await waitFor(() => expect(c.querySelector('.ce-ta')).toBeTruthy())
expect((c.querySelector('.ce-ta') as HTMLTextAreaElement).value).toBe(DISK)
}
})
it('only the row you opened is highlighted', async () => {
const c = await boot()
fireEvent.click(group(c, 'Changes')[0])
await waitFor(() => expect(c.querySelector('.git-row.active')).toBeTruthy())
expect(c.querySelectorAll('.git-row.active')).toHaveLength(1)
expect(group(c, 'Changes')[0].classList.contains('active')).toBe(true)
})
})

View File

@@ -9,17 +9,33 @@ import { classify, discard, load, stage } from '../src/main/git-service'
describe('classify', () => {
it('reads the index code as staged, working code as unstaged', () => {
expect(classify('M', ' ')).toEqual({ letter: 'M', staged: true })
expect(classify(' ', 'M')).toEqual({ letter: 'M', staged: false })
expect(classify('A', ' ')).toEqual({ letter: 'A', staged: true })
expect(classify('D', ' ')).toEqual({ letter: 'D', staged: true })
expect(classify('R', ' ')).toEqual({ letter: 'R', staged: true })
expect(classify('M', ' ')).toEqual([{ letter: 'M', staged: true }])
expect(classify(' ', 'M')).toEqual([{ letter: 'M', staged: false }])
expect(classify('A', ' ')).toEqual([{ letter: 'A', staged: true }])
expect(classify('D', ' ')).toEqual([{ letter: 'D', staged: true }])
expect(classify('R', ' ')).toEqual([{ letter: 'R', staged: true }])
})
it('treats untracked as a new (A) unstaged file', () => {
expect(classify('?', '?')).toEqual({ letter: 'A', staged: false })
expect(classify('?', '?')).toEqual([{ letter: 'A', staged: false }])
})
it('maps unmerged (U) to modified', () => {
expect(classify('U', 'U').letter).toBe('M')
it('splits a staged-then-edited file into two rows', () => {
expect(classify('M', 'M')).toEqual([
{ letter: 'M', staged: true },
{ letter: 'M', staged: false },
])
expect(classify('A', 'M')).toEqual([
{ letter: 'A', staged: true },
{ letter: 'M', staged: false },
])
expect(classify('M', 'D')).toEqual([
{ letter: 'M', staged: true },
{ letter: 'D', staged: false },
])
})
it('keeps a merge conflict as one row', () => {
expect(classify('U', 'U')).toEqual([{ letter: 'M', staged: true }])
expect(classify('A', 'A')).toEqual([{ letter: 'M', staged: true }])
expect(classify('D', 'D')).toEqual([{ letter: 'M', staged: true }])
})
})
@@ -77,6 +93,51 @@ describe('load (integration against a temp repo)', () => {
expect(res!.changes.find((c) => c.path === 'a.txt')?.staged).toBe(true)
})
it('shows a staged-then-edited file in both groups, each with its own pair', async () => {
dir = await repo()
await writeFile(join(dir, 'a.txt'), '1\nSTAGED\n3\n')
await stage(dir, ['a.txt'])
await writeFile(join(dir, 'a.txt'), '1\nSTAGED\n3\n4\n')
const res = await load(dir)
const rows = res!.changes.filter((c) => c.path === 'a.txt')
expect(rows).toHaveLength(2)
// Staged row: HEAD -> index. It must NOT include the newer edit.
const s = rows.find((c) => c.staged)!
expect(s.original).toBe('1\n2\n3\n')
expect(s.updated).toBe('1\nSTAGED\n3\n')
// Unstaged row: index -> disk. Only the newer edit.
const w = rows.find((c) => !c.staged)!
expect(w.original).toBe('1\nSTAGED\n3\n')
expect(w.updated).toBe('1\nSTAGED\n3\n4\n')
})
it('gives a staged-new file that was edited again both rows', async () => {
dir = await repo()
await writeFile(join(dir, 'fresh.txt'), 'one\n')
await stage(dir, ['fresh.txt'])
await writeFile(join(dir, 'fresh.txt'), 'one\ntwo\n')
const res = await load(dir)
const rows = res!.changes.filter((c) => c.path === 'fresh.txt')
expect(rows.map((r) => [r.status, r.staged])).toEqual([['A', true], ['M', false]])
expect(rows[0].original).toBe('')
expect(rows[0].updated).toBe('one\n')
expect(rows[1].original).toBe('one\n')
expect(rows[1].updated).toBe('one\ntwo\n')
})
it('keeps one row when a file is only staged', async () => {
dir = await repo()
await writeFile(join(dir, 'a.txt'), '1\n2\n3\n4\n')
await stage(dir, ['a.txt'])
const rows = (await load(dir))!.changes.filter((c) => c.path === 'a.txt')
expect(rows).toHaveLength(1)
expect(rows[0].staged).toBe(true)
expect(rows[0].original).toBe('1\n2\n3\n')
expect(rows[0].updated).toBe('1\n2\n3\n4\n')
})
it('discard reverts a modified tracked file to HEAD', async () => {
dir = await repo()
await writeFile(join(dir, 'a.txt'), '1\nCHANGED\n3\n')

151
test/logger.test.ts Normal file
View File

@@ -0,0 +1,151 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, readFileSync, rmSync, existsSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { _resetLogger, formatErr, formatLine, getLogPath, initLogger, log, logger } from '../src/main/logger'
let dir: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'helder-log-'))
_resetLogger()
})
afterEach(() => {
_resetLogger()
rmSync(dir, { recursive: true, force: true })
})
function read(): string {
return readFileSync(join(dir, 'helder.log'), 'utf8')
}
describe('formatLine', () => {
it('lays out ts, padded level, pid, scope and message', () => {
const line = formatLine('info', 'git', 'loaded', undefined, 42, '2026-07-15T10:00:00.000Z')
expect(line).toBe('2026-07-15T10:00:00.000Z INFO 42 git loaded\n')
})
it('appends context as JSON', () => {
const line = formatLine('warn', 'ipc', 'slow', { ms: 1200 }, 7, '2026-07-15T10:00:00.000Z')
expect(line).toContain('{"ms":1200}')
expect(line.endsWith('\n')).toBe(true)
})
it('indents a multi-line message so a continuation never looks like a new entry', () => {
// Electron's own console warnings arrive with embedded newlines.
const line = formatLine('warn', 'console', 'line one\nline two', undefined, 1, 'T')
expect(line).toBe('T WARN 1 console line one\n line two\n')
// Every line after the first is indented → an entry always starts at col 0.
for (const l of line.trimEnd().split('\n').slice(1)) expect(l.startsWith(' ')).toBe(true)
})
it('indents multi-line context too', () => {
const line = formatLine('error', 'x', 'boom', { stack: 'a\nb' }, 1, 'T')
// JSON escapes the \n inside the string value, so this stays a single line.
expect(line.split('\n').filter(Boolean)).toHaveLength(1)
})
})
describe('formatErr', () => {
it('keeps message and stack', () => {
const e = new Error('nope')
const out = formatErr(e)
expect(out.message).toBe('nope')
expect(out.stack).toContain('nope')
})
it('follows cause — the line that usually explains the failure', () => {
const root = new Error('EACCES')
const e = new Error('save failed', { cause: root })
expect(formatErr(e).cause).toContain('EACCES')
})
it('survives a thrown string', () => {
expect(formatErr('just a string').message).toBe('just a string')
})
it('survives a thrown non-Error object', () => {
expect(formatErr({ code: 7 }).message).toBe('{"code":7}')
})
it('does not throw on circular values', () => {
const a: Record<string, unknown> = {}
a.self = a
expect(() => formatErr(a)).not.toThrow()
expect(formatErr(a).message).toContain('Circular')
})
})
describe('log', () => {
it('creates the file and appends lines', () => {
initLogger({ dir })
logger.info('boot', 'hello')
logger.warn('boot', 'careful')
const out = read()
expect(out).toContain('INFO')
expect(out).toContain('hello')
expect(out).toContain('WARN')
expect(out.trim().split('\n')).toHaveLength(2)
})
it('creates a missing directory', () => {
const nested = join(dir, 'a', 'b')
initLogger({ dir: nested })
logger.info('x', 'y')
expect(existsSync(join(nested, 'helder.log'))).toBe(true)
})
it('records the error with its stack', () => {
initLogger({ dir })
logger.error('save', 'write failed', new Error('EACCES: permission denied'), { path: 'a.ts' })
const out = read()
expect(out).toContain('EACCES: permission denied')
expect(out).toContain('"path":"a.ts"')
expect(out).toContain('"stack"')
})
it('honours the level floor', () => {
initLogger({ dir, level: 'warn' })
logger.debug('x', 'debug line')
logger.info('x', 'info line')
logger.error('x', 'error line')
const out = read()
expect(out).not.toContain('debug line')
expect(out).not.toContain('info line')
expect(out).toContain('error line')
})
it('is a no-op before initLogger rather than throwing', () => {
expect(() => logger.info('x', 'y')).not.toThrow()
expect(getLogPath()).toBeNull()
})
it('exposes the active path', () => {
initLogger({ dir })
expect(getLogPath()).toBe(join(dir, 'helder.log'))
})
it('never throws even when the log path is unwritable', () => {
// Point at a path whose parent is a FILE: every append must fail.
const wall = join(dir, 'wall')
writeFileSync(wall, 'x')
initLogger({ dir: join(wall, 'sub') })
expect(() => logger.error('x', 'still fine')).not.toThrow()
})
})
describe('rotation', () => {
it('rolls the file once it passes the size cap and keeps writing', () => {
initLogger({ dir })
const big = 'x'.repeat(4000)
// 2MB cap / ~4KB per line → ~525 lines to trip it. 700 is comfortably past.
for (let i = 0; i < 700; i++) log('info', 'bulk', big)
expect(existsSync(join(dir, 'helder.1.log'))).toBe(true)
// The live file is the post-rotation one and is still being appended to.
logger.info('after', 'still logging')
expect(read()).toContain('still logging')
// And it's small again — proof the rotation actually moved the bytes.
expect(read().length).toBeLessThan(4000 * 700)
})
})