From 758dcb41660e7640b514a20833203ada7f6c245c Mon Sep 17 00:00:00 2001 From: Amit Haridas Date: Sun, 23 Aug 2026 14:28:43 +0530 Subject: [PATCH] feat(compare): implement Document Compare dialog with local-diff and git-HEAD-diff modes Amit Haridas --- src/main.js | 4 +- src/main/GitOperations.js | 8 +- src/renderer.js | 7 + src/renderer/document-compare-dialog.js | 341 ++++++++++++++++++++++++ src/styles/modal.css | 53 ++++ src/styles/tokens.css | 14 + src/utils/line-diff.js | 58 ++++ tests/document-compare-dialog.test.js | 181 +++++++++++++ tests/line-diff.test.js | 88 ++++++ tests/main/GitOperations.test.js | 18 ++ 10 files changed, 769 insertions(+), 3 deletions(-) create mode 100644 src/renderer/document-compare-dialog.js create mode 100644 src/utils/line-diff.js create mode 100644 tests/document-compare-dialog.test.js create mode 100644 tests/line-diff.test.js diff --git a/src/main.js b/src/main.js index 1675e4a..a663dfe 100644 --- a/src/main.js +++ b/src/main.js @@ -5384,9 +5384,9 @@ ipcMain.handle('git-log', async () => { const dir = currentFile ? path.dirname(currentFile) : process.cwd(); return GitOperations.log(dir); }); -ipcMain.handle('git-diff', async (event, { file } = {}) => { +ipcMain.handle('git-diff', async (event, { file, againstHead } = {}) => { const dir = currentFile ? path.dirname(currentFile) : process.cwd(); - return GitOperations.diff(dir, file); + return GitOperations.diff(dir, file, againstHead); }); ipcMain.handle('git-branches', async () => { const dir = currentFile ? path.dirname(currentFile) : process.cwd(); diff --git a/src/main/GitOperations.js b/src/main/GitOperations.js index 325226f..4860514 100644 --- a/src/main/GitOperations.js +++ b/src/main/GitOperations.js @@ -41,9 +41,15 @@ async function log(dir, maxCount = 20) { } } -async function diff(dir, file) { +// againstHead=true compares the working tree (staged + unstaged) against the last +// commit instead of against the index — used by the Document Compare dialog's +// "Git HEAD" mode. Default behavior (worktree vs index) is unchanged. +async function diff(dir, file, againstHead = false) { try { const git = getGitInstance(dir); + if (againstHead) { + return file ? await git.diff(['HEAD', '--', file]) : await git.diff('HEAD'); + } return file ? await git.diff([file]) : await git.diff(); } catch (err) { return { error: err.message }; diff --git a/src/renderer.js b/src/renderer.js index 8b68c69..221a6af 100644 --- a/src/renderer.js +++ b/src/renderer.js @@ -12,6 +12,7 @@ const hljs = require('highlight.js'); const { createEditor } = require('./editor/codemirror-setup'); const { undo, redo } = require('@codemirror/commands'); const { showMediaOperationsDialog } = require('./renderer/media-operations-dialog'); +const { showDocumentCompareDialog } = require('./renderer/document-compare-dialog'); const { csvToMarkdownTable } = require('./utils/csv-to-markdown-table'); /** @@ -3812,6 +3813,12 @@ ipcRenderer.on('show-audio-converter', () => { ipcRenderer.on('show-video-converter', () => { showMediaOperationsDialog('video'); }); + +// Show Document Compare Dialog (Tools > Document Compare) +ipcRenderer.on('show-document-compare', () => { + const tab = tabManager?.tabs.get(tabManager.activeTabId); + showDocumentCompareDialog({ filePath: tab?.filePath || null }); +}); function showPDFEditorDialog(operation, openedFilePath = null) { const title = document.getElementById('pdf-editor-title'); diff --git a/src/renderer/document-compare-dialog.js b/src/renderer/document-compare-dialog.js new file mode 100644 index 0000000..4d9ae69 --- /dev/null +++ b/src/renderer/document-compare-dialog.js @@ -0,0 +1,341 @@ +/** + * Document Compare Dialog + * + * Two-pane line diff opened from Tools > Document Compare (main.js sends + * `show-document-compare`; renderer.js registers the listener). + * + * Construction mirrors src/renderer/media-operations-dialog.js: a modal built from + * `.modal` / `.modal-content` / `.modal-header` / `.modal-body` / `.modal-footer` + * markup appended to document.body on first use, driven by ModalManager, with a + * status line reusing the `info-message` / `warning-message` classes. File + * selection reuses the app's existing convention: a plain `` + * whose `.path` is read directly (nodeIntegration is enabled for this renderer) — + * no new IPC channel for picking files. File *contents* are read through the + * existing `read-file` invoke channel (the same one the renderer's electronAPI + * adapters use), not through a new privileged renderer-side file path. + * + * Two modes: + * - Local: File A (prefilled with the current tab's file, changeable) vs File B, + * diffed locally by the pure LCS function in src/utils/line-diff.js. + * - Git HEAD: the current file against its last commit, via the existing + * `git-diff` invoke channel with `againstHead: true` (GitOperations.diff). + * Git's own raw diff text is rendered verbatim, one row per line, colored by + * leading character — it is already a diff and is never re-diffed through + * line-diff. When the current file is not inside a git repository (or no file + * is open), the option is disabled with a hint instead of erroring. + * + * @module document-compare-dialog + */ + +const { ipcRenderer } = require('electron'); +const { computeLineDiff } = require('../utils/line-diff'); + +const COMPARE_ACCEPT = '.md,.markdown,.txt,.text,.log,.json,.xml,.yml,.yaml,.csv'; + +const GIT_DIFF_ROW_CLASS = [ + { prefix: '@@', className: 'diff-hunk' }, + { prefix: '+++', className: 'diff-context' }, + { prefix: '---', className: 'diff-context' }, + { prefix: '+', className: 'diff-added' }, + { prefix: '-', className: 'diff-removed' }, +]; + +let modalEl = null; +let modalManager = null; +let els = null; +let currentFilePath = null; + +function buildDialogDom() { + modalEl = document.createElement('div'); + modalEl.id = 'document-compare-dialog'; + modalEl.className = 'modal hidden'; + modalEl.setAttribute('role', 'dialog'); + modalEl.setAttribute('aria-modal', 'true'); + modalEl.setAttribute('aria-labelledby', 'document-compare-title'); + modalEl.innerHTML = ` + + + `; + document.body.appendChild(modalEl); + + els = { + modeSelect: modalEl.querySelector('#compare-mode-select'), + gitHint: modalEl.querySelector('#compare-git-hint'), + localSection: modalEl.querySelector('#compare-local-section'), + fileAInput: modalEl.querySelector('#compare-file-a-input'), + fileABrowse: modalEl.querySelector('#compare-file-a-browse'), + fileBInput: modalEl.querySelector('#compare-file-b-input'), + fileBBrowse: modalEl.querySelector('#compare-file-b-browse'), + gitSection: modalEl.querySelector('#compare-git-section'), + gitFile: modalEl.querySelector('#compare-git-file'), + status: modalEl.querySelector('#compare-status-message'), + result: modalEl.querySelector('#compare-result'), + runBtn: modalEl.querySelector('#document-compare-run'), + cancelBtn: modalEl.querySelector('#document-compare-cancel'), + }; + + els.modeSelect.addEventListener('change', updateModeSections); + els.fileABrowse.addEventListener('click', () => chooseFile(els.fileAInput)); + els.fileBBrowse.addEventListener('click', () => chooseFile(els.fileBInput)); + els.runBtn.addEventListener('click', handleCompare); + els.cancelBtn.addEventListener('click', hideDialog); + + modalManager = new window.ModalManager(modalEl); +} + +function ensureDialog() { + if (!modalEl) { + buildDialogDom(); + } +} + +// Same renderer-side picker the Media Operations and PDF Editor dialogs use. +function chooseFile(input) { + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = COMPARE_ACCEPT; + fileInput.onchange = (e) => { + const file = e.target.files[0]; + if (file) { + input.value = file.path; + clearResult(); + } + }; + fileInput.click(); +} + +function clearStatus() { + if (!els.status) return; + els.status.textContent = ''; + els.status.classList.remove('info-message', 'warning-message', 'success-message'); + els.status.classList.add('hidden'); +} + +function showStatus(message, type = 'info') { + if (!els.status) return; + els.status.textContent = message; + els.status.classList.remove('hidden', 'info-message', 'warning-message', 'success-message'); + els.status.classList.add(`${type}-message`); +} + +function clearResult() { + if (!els.result) return; + els.result.innerHTML = ''; + els.result.classList.add('hidden'); +} + +function updateModeSections() { + const isGit = els.modeSelect.value === 'git'; + els.localSection.classList.toggle('hidden', isGit); + els.gitSection.classList.toggle('hidden', !isGit); + clearStatus(); + clearResult(); +} + +function appendDiffRow(className, text, marker) { + const row = document.createElement('div'); + row.className = `diff-row ${className}`; + const markerSpan = document.createElement('span'); + markerSpan.className = 'diff-marker'; + markerSpan.textContent = marker; + const textSpan = document.createElement('span'); + textSpan.className = 'diff-text'; + // Non-breaking space keeps empty lines one row tall under pre-wrap. + textSpan.textContent = text === '' ? ' ' : text; + row.appendChild(markerSpan); + row.appendChild(textSpan); + els.result.appendChild(row); +} + +// Mode 1: local two-file diff through the pure LCS line-diff function. +function renderLocalDiff(entries) { + clearResult(); + entries.forEach((entry) => { + const marker = entry.type === 'added' ? '+' : entry.type === 'removed' ? '-' : ''; + const rowClass = entry.type === 'unchanged' ? 'diff-context' : `diff-${entry.type}`; + appendDiffRow(rowClass, entry.text, marker); + }); + els.result.classList.remove('hidden'); +} + +// Mode 2: git's own diff text, rendered verbatim (one row per line), colored by +// leading character. This is presentation only — the text is never re-diffed. +function renderGitDiff(rawDiff) { + clearResult(); + rawDiff.split('\n').forEach((line) => { + const match = GIT_DIFF_ROW_CLASS.find(({ prefix }) => line.startsWith(prefix)); + const className = match ? match.className : 'diff-context'; + appendDiffRow(className, line, ''); + }); + els.result.classList.remove('hidden'); +} + +async function compareLocalFiles() { + const pathA = els.fileAInput.value.trim(); + const pathB = els.fileBInput.value.trim(); + if (!pathA || !pathB) { + showStatus('Choose both files to compare.', 'warning'); + return; + } + + clearStatus(); + clearResult(); + let contentA; + let contentB; + try { + contentA = await ipcRenderer.invoke('read-file', pathA); + contentB = await ipcRenderer.invoke('read-file', pathB); + } catch (err) { + showStatus(`Error reading file: ${err.message}`, 'warning'); + return; + } + + const entries = computeLineDiff(contentA, contentB); + const added = entries.filter((entry) => entry.type === 'added').length; + const removed = entries.filter((entry) => entry.type === 'removed').length; + if (added === 0 && removed === 0) { + showStatus('Files are identical.', 'info'); + return; + } + renderLocalDiff(entries); + showStatus(`${added} line(s) added, ${removed} line(s) removed.`, 'success'); +} + +async function compareWithGitHead() { + if (!currentFilePath) { + showStatus('No file is open — save the current document first.', 'warning'); + return; + } + + clearStatus(); + clearResult(); + let rawDiff; + try { + rawDiff = await ipcRenderer.invoke('git-diff', { file: currentFilePath, againstHead: true }); + } catch (err) { + showStatus(`Error: ${err.message}`, 'warning'); + return; + } + if (rawDiff && typeof rawDiff === 'object' && rawDiff.error) { + showStatus(`Git diff unavailable: ${rawDiff.error}`, 'warning'); + return; + } + if (!rawDiff || rawDiff.trim() === '') { + showStatus('No differences against HEAD.', 'info'); + return; + } + renderGitDiff(rawDiff); +} + +async function handleCompare() { + if (els.modeSelect.value === 'git') { + await compareWithGitHead(); + } else { + await compareLocalFiles(); + } +} + +// The Git HEAD option must degrade gracefully (disabled + hint), never error: +// no file open, or the file's directory is not a git repository. +async function refreshGitModeAvailability() { + const gitOption = els.modeSelect.querySelector('option[value="git"]'); + if (!gitOption) return; + + const disableGitOption = (hint) => { + gitOption.disabled = true; + els.gitHint.textContent = hint; + els.gitHint.classList.remove('hidden'); + if (els.modeSelect.value === 'git') { + els.modeSelect.value = 'local'; + updateModeSections(); + } + }; + + if (!currentFilePath) { + disableGitOption('Open (or save) a file to compare it with its git history.'); + return; + } + try { + const status = await ipcRenderer.invoke('git-status'); + if (status && typeof status === 'object' && status.error) { + disableGitOption('Current file is not inside a git repository.'); + return; + } + } catch (err) { + disableGitOption(`Git check failed: ${err.message}`); + return; + } + gitOption.disabled = false; + els.gitHint.textContent = ''; + els.gitHint.classList.add('hidden'); +} + +function hideDialog() { + if (modalManager) modalManager.close(); + clearStatus(); + clearResult(); +} + +/** + * Open the Document Compare dialog. + * @param {object} [context] - Current editor context from renderer.js. + * @param {string|null} [context.filePath] - Active tab's file path, used to + * prefill File A and to resolve the Git HEAD mode. + */ +function showDocumentCompareDialog({ filePath = null } = {}) { + ensureDialog(); + currentFilePath = filePath; + + els.fileAInput.value = filePath || ''; + els.fileBInput.value = ''; + els.gitFile.textContent = filePath || '(unsaved document)'; + els.modeSelect.value = 'local'; + updateModeSections(); + refreshGitModeAvailability(); + modalManager.open(); +} + +module.exports = { showDocumentCompareDialog }; diff --git a/src/styles/modal.css b/src/styles/modal.css index 249b72b..dbdd444 100644 --- a/src/styles/modal.css +++ b/src/styles/modal.css @@ -210,6 +210,59 @@ cursor: pointer; } +/* ============================================ + * Document Compare Diff View + * ============================================ */ + +.diff-view { + font-family: var(--font-mono, 'JetBrains Mono', monospace); + font-size: var(--text-sm, 0.875rem); + line-height: var(--leading-normal, 1.5); + border: 1px solid hsl(var(--border, 214.3 31.8% 91.4%)); + border-radius: var(--radius, 0.5rem); + overflow: auto; + max-height: 50vh; +} + +.diff-row { + display: flex; + align-items: flex-start; + padding: 0 var(--spacing-2, 0.5rem); + white-space: pre-wrap; + word-break: break-word; +} + +.diff-marker { + flex: none; + width: 1.25em; + user-select: none; +} + +.diff-text { + flex: 1; + min-width: 0; +} + +.diff-added { + background: hsl(var(--diff-added-bg, 142 76% 94%)); + color: hsl(var(--diff-added-fg, 142 76% 24%)); +} + +.diff-removed { + background: hsl(var(--diff-removed-bg, 0 84% 96%)); + color: hsl(var(--diff-removed-fg, 0 74% 30%)); +} + +.diff-context { + color: hsl(var(--foreground, 222.2 84% 4.9%)); +} + +.diff-hunk { + color: hsl(var(--diff-hunk-fg, 215 16% 47%)); + background: hsl(var(--muted, 210 40% 96.1%)); + font-weight: var(--font-semibold, 600); +} + /* ============================================ * Size Variants * ============================================ */ diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 92937c4..b102f2c 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -61,6 +61,13 @@ --info: 199 89% 48%; --info-foreground: 210 40% 98%; + /* Diff - added/removed/hunk line colors (Document Compare, git-style) */ + --diff-added-bg: 142 76% 94%; + --diff-added-fg: 142 76% 24%; + --diff-removed-bg: 0 84% 96%; + --diff-removed-fg: 0 74% 30%; + --diff-hunk-fg: 215 16% 47%; + /* Border and input */ --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; @@ -195,6 +202,13 @@ --info: 199 89% 38%; --info-foreground: 210 40% 98%; + /* Diff - darker backgrounds / lighter text for dark mode */ + --diff-added-bg: 142 45% 16%; + --diff-added-fg: 142 60% 66%; + --diff-removed-bg: 0 45% 18%; + --diff-removed-fg: 0 70% 70%; + --diff-hunk-fg: 215 20% 65%; + /* Border and input */ --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; diff --git a/src/utils/line-diff.js b/src/utils/line-diff.js new file mode 100644 index 0000000..777b649 --- /dev/null +++ b/src/utils/line-diff.js @@ -0,0 +1,58 @@ +/** + * Minimal LCS-based line diff for the Document Compare dialog. + * Textbook dynamic-programming formulation — the app has no diff library and + * document-sized inputs keep the (n+1) x (m+1) table affordable. + */ + +function splitLines(text) { + if (typeof text !== 'string' || text === '') return []; + const lines = text.split(/\r?\n/); + // Editor convention: "a\n" is one line, not a line plus an empty one — drop the + // trailing split artifact so purely trailing-newline differences stay invisible. + if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop(); + return lines; +} + +/** + * Compare two texts line by line. + * @param {string} oldText - Original text. + * @param {string} newText - Revised text. + * @returns {Array<{type: 'added'|'removed'|'unchanged', text: string}>} Edit script in + * reading order; within a change block, removals are emitted before additions. + */ +function computeLineDiff(oldText, newText) { + const a = splitLines(oldText); + const b = splitLines(newText); + const n = a.length; + const m = b.length; + + // lcs[i][j] = length of the longest common subsequence of a[i..] and b[j..] + const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]); + } + } + + const result = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + result.push({ type: 'unchanged', text: a[i] }); + i++; + j++; + } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { + result.push({ type: 'removed', text: a[i] }); + i++; + } else { + result.push({ type: 'added', text: b[j] }); + j++; + } + } + while (i < n) result.push({ type: 'removed', text: a[i++] }); + while (j < m) result.push({ type: 'added', text: b[j++] }); + return result; +} + +module.exports = { computeLineDiff }; diff --git a/tests/document-compare-dialog.test.js b/tests/document-compare-dialog.test.js new file mode 100644 index 0000000..e7aa044 --- /dev/null +++ b/tests/document-compare-dialog.test.js @@ -0,0 +1,181 @@ +/** + * Tests for the Document Compare dialog (local two-file diff and git-HEAD diff). + * Exercises the real dialog DOM in jsdom with the electron IPC surface mocked, + * following the jest.mock('electron') pattern in monospace-font-config.test.js. + */ + +jest.mock('electron', () => ({ + ipcRenderer: { + invoke: jest.fn(), + send: jest.fn(), + on: jest.fn(), + once: jest.fn(), + removeAllListeners: jest.fn(), + }, +})); + +require('../src/utils/ModalManager'); // sets window.ModalManager for the dialog +const { ipcRenderer } = require('electron'); +const { showDocumentCompareDialog } = require('../src/renderer/document-compare-dialog'); + +const flush = () => new Promise((resolve) => setTimeout(resolve, 20)); + +function openDialog(filePath = null) { + showDocumentCompareDialog({ filePath }); +} + +function clickCompare() { + document.getElementById('document-compare-run').click(); +} + +function resultRows() { + return Array.from(document.querySelectorAll('#compare-result .diff-row')); +} + +function statusText() { + return document.getElementById('compare-status-message').textContent; +} + +describe('Document Compare dialog', () => { + beforeEach(() => { + ipcRenderer.invoke.mockReset(); + openDialog('/notes/report.md'); + }); + + describe('local two-file mode', () => { + it('renders added, removed, and unchanged rows from the line diff', async () => { + ipcRenderer.invoke.mockImplementation((channel, filePath) => { + if (channel !== 'read-file') return {}; + if (filePath === '/notes/old.md') return '# Title\nold line\nshared tail'; + if (filePath === '/notes/new.md') return '# Title\nnew line\nshared tail'; + throw new Error(`unexpected path ${filePath}`); + }); + document.getElementById('compare-file-a-input').value = '/notes/old.md'; + document.getElementById('compare-file-b-input').value = '/notes/new.md'; + + clickCompare(); + await flush(); + + const rows = resultRows(); + expect(rows).toHaveLength(4); + expect(rows[0].className).toBe('diff-row diff-context'); + expect(rows[1].className).toBe('diff-row diff-removed'); + expect(rows[1].textContent).toContain('old line'); + expect(rows[2].className).toBe('diff-row diff-added'); + expect(rows[2].textContent).toContain('new line'); + expect(rows[3].className).toBe('diff-row diff-context'); + expect(statusText()).toBe('1 line(s) added, 1 line(s) removed.'); + }); + + it('reports identical files without rendering a diff view', async () => { + ipcRenderer.invoke.mockImplementation(() => 'same\ncontent'); + document.getElementById('compare-file-a-input').value = '/a.md'; + document.getElementById('compare-file-b-input').value = '/b.md'; + + clickCompare(); + await flush(); + + expect(statusText()).toBe('Files are identical.'); + expect(document.getElementById('compare-result').classList.contains('hidden')).toBe(true); + }); + + it('warns when one of the two files has not been chosen', async () => { + document.getElementById('compare-file-a-input').value = '/a.md'; + document.getElementById('compare-file-b-input').value = ''; + + clickCompare(); + await flush(); + + expect(statusText()).toBe('Choose both files to compare.'); + expect(ipcRenderer.invoke).not.toHaveBeenCalledWith('read-file', expect.anything()); + }); + + it('shows a warning when a file cannot be read', async () => { + ipcRenderer.invoke.mockRejectedValue(new Error('Invalid file path')); + document.getElementById('compare-file-a-input').value = '/a.md'; + document.getElementById('compare-file-b-input').value = '/b.md'; + + clickCompare(); + await flush(); + + expect(statusText()).toContain('Error reading file'); + }); + }); + + describe('git HEAD mode', () => { + function selectGitMode() { + const modeSelect = document.getElementById('compare-mode-select'); + modeSelect.value = 'git'; + modeSelect.dispatchEvent(new Event('change')); + } + + it('renders git raw diff text verbatim, colored by leading character', async () => { + ipcRenderer.invoke.mockImplementation((channel) => { + if (channel === 'git-status') return { current: 'master', files: [] }; + if (channel === 'git-diff') { + return 'diff --git a/report.md b/report.md\n@@ -1,2 +1,2 @@\n context\n-old line\n+new line'; + } + return {}; + }); + await flush(); // git-status availability check + selectGitMode(); + clickCompare(); + await flush(); + + const rows = resultRows(); + expect(rows).toHaveLength(5); + expect(rows[0].className).toBe('diff-row diff-context'); + expect(rows[1].className).toBe('diff-row diff-hunk'); + expect(rows[2].className).toBe('diff-row diff-context'); + expect(rows[3].className).toBe('diff-row diff-removed'); + expect(rows[3].textContent).toBe('-old line'); + expect(rows[4].className).toBe('diff-row diff-added'); + expect(rows[4].textContent).toBe('+new line'); + expect(ipcRenderer.invoke).toHaveBeenCalledWith('git-diff', { + file: '/notes/report.md', + againstHead: true, + }); + }); + + it('reports no differences when git returns an empty diff', async () => { + ipcRenderer.invoke.mockImplementation((channel) => { + if (channel === 'git-status') return { current: 'master', files: [] }; + if (channel === 'git-diff') return ''; + return {}; + }); + await flush(); + selectGitMode(); + clickCompare(); + await flush(); + + expect(statusText()).toBe('No differences against HEAD.'); + expect(document.getElementById('compare-result').classList.contains('hidden')).toBe(true); + }); + + it('degrades to a disabled option with a hint outside a git repository', async () => { + ipcRenderer.invoke.mockImplementation((channel) => { + if (channel === 'git-status') return { error: 'Not a git repository' }; + return {}; + }); + openDialog('/plain/file.md'); + await flush(); + + const gitOption = document.querySelector('#compare-mode-select option[value="git"]'); + expect(gitOption.disabled).toBe(true); + const hint = document.getElementById('compare-git-hint'); + expect(hint.classList.contains('hidden')).toBe(false); + expect(hint.textContent).toBe('Current file is not inside a git repository.'); + expect(ipcRenderer.invoke).not.toHaveBeenCalledWith('git-diff', expect.anything()); + }); + + it('degrades to a disabled option with a hint when no file is open', async () => { + openDialog(null); + + const gitOption = document.querySelector('#compare-mode-select option[value="git"]'); + expect(gitOption.disabled).toBe(true); + const hint = document.getElementById('compare-git-hint'); + expect(hint.textContent).toContain('Open (or save) a file'); + expect(ipcRenderer.invoke).not.toHaveBeenCalledWith('git-diff', expect.anything()); + }); + }); +}); diff --git a/tests/line-diff.test.js b/tests/line-diff.test.js new file mode 100644 index 0000000..60f815f --- /dev/null +++ b/tests/line-diff.test.js @@ -0,0 +1,88 @@ +/** + * Tests for the LCS line diff used by the Document Compare dialog + * Covers: identical texts, pure additions, pure deletions, mixed edits, empty sides + */ + +const { computeLineDiff } = require('../src/utils/line-diff'); + +describe('computeLineDiff', () => { + it('reports every line as unchanged for identical texts', () => { + const text = '# Title\n\nSome paragraph.\n- item 1\n- item 2'; + const result = computeLineDiff(text, text); + expect(result).toHaveLength(5); + expect(result.every((entry) => entry.type === 'unchanged')).toBe(true); + expect(result.map((entry) => entry.text)).toEqual([ + '# Title', + '', + 'Some paragraph.', + '- item 1', + '- item 2', + ]); + }); + + it('reports only additions when lines were appended', () => { + const result = computeLineDiff('# Title\n\nBody.', '# Title\n\nBody.\nNew line.\nAnother.'); + expect(result.filter((entry) => entry.type === 'removed')).toEqual([]); + expect(result).toEqual([ + { type: 'unchanged', text: '# Title' }, + { type: 'unchanged', text: '' }, + { type: 'unchanged', text: 'Body.' }, + { type: 'added', text: 'New line.' }, + { type: 'added', text: 'Another.' }, + ]); + }); + + it('reports only removals when lines were deleted', () => { + const result = computeLineDiff('Intro\nKeep me\nDrop me\nAlso drop', 'Intro\nKeep me'); + expect(result.filter((entry) => entry.type === 'added')).toEqual([]); + expect(result).toEqual([ + { type: 'unchanged', text: 'Intro' }, + { type: 'unchanged', text: 'Keep me' }, + { type: 'removed', text: 'Drop me' }, + { type: 'removed', text: 'Also drop' }, + ]); + }); + + it('reports adjacent removed-then-added entries for a mixed change', () => { + const result = computeLineDiff( + '# Heading\nold paragraph\ntrailer', + '# Heading\nnew paragraph\ntrailer' + ); + expect(result).toEqual([ + { type: 'unchanged', text: '# Heading' }, + { type: 'removed', text: 'old paragraph' }, + { type: 'added', text: 'new paragraph' }, + { type: 'unchanged', text: 'trailer' }, + ]); + }); + + it('treats an empty old text as a pure addition', () => { + expect(computeLineDiff('', 'a\nb')).toEqual([ + { type: 'added', text: 'a' }, + { type: 'added', text: 'b' }, + ]); + }); + + it('treats an empty new text as a pure removal', () => { + expect(computeLineDiff('a\nb', '')).toEqual([ + { type: 'removed', text: 'a' }, + { type: 'removed', text: 'b' }, + ]); + }); + + it('ignores carriage-return differences between the two texts', () => { + const result = computeLineDiff('one\r\ntwo\r\n', 'one\ntwo\n'); + expect(result).toEqual([ + { type: 'unchanged', text: 'one' }, + { type: 'unchanged', text: 'two' }, + ]); + }); + + it('ignores a purely trailing-newline difference', () => { + const result = computeLineDiff('a\nb\n', 'a\nb'); + expect(result).toEqual([ + { type: 'unchanged', text: 'a' }, + { type: 'unchanged', text: 'b' }, + ]); + }); +}); diff --git a/tests/main/GitOperations.test.js b/tests/main/GitOperations.test.js index f891620..e63af13 100644 --- a/tests/main/GitOperations.test.js +++ b/tests/main/GitOperations.test.js @@ -43,6 +43,24 @@ describe('GitOperations', () => { expect(result).toHaveProperty('error'); fs.rmSync(nonGitDir, { recursive: true, force: true }); }); + + test('includes staged changes when againstHead is true', async () => { + fs.writeFileSync(filePath, 'line1\nline2\n'); + await simpleGit(tmpDir).add('file.txt'); + + const unstaged = await GitOperations.diff(tmpDir, 'file.txt'); + expect(unstaged).toBe(''); + + const againstHead = await GitOperations.diff(tmpDir, 'file.txt', true); + expect(againstHead).toContain('+line2'); + }); + + test('returns error object for non-git directory when againstHead is true', async () => { + const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), 'notgit_')); + const result = await GitOperations.diff(nonGitDir, null, true); + expect(result).toHaveProperty('error'); + fs.rmSync(nonGitDir, { recursive: true, force: true }); + }); }); describe('branches', () => {