From f36afc0180433ee7fc4c893aca5a74c8eb48ac68 Mon Sep 17 00:00:00 2001 From: wangqiongkaka <34996871+wangqiongkaka@users.noreply.github.com> Date: Sat, 22 Aug 2026 23:13:35 +0800 Subject: [PATCH] feat: collapse diff files by default (#270) * feat: collapse diff files by default * feat: expand source diffs by default --------- Co-authored-by: wangqiong --- src/client/DiffView.tsx | 57 ++++++++++++++++++++++++----- src/client/sidebar.module.css | 25 +++++++++++++ tests/diff-view-collapse.spec.tsx | 59 +++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 tests/diff-view-collapse.spec.tsx diff --git a/src/client/DiffView.tsx b/src/client/DiffView.tsx index 0edd3fc..e6705b7 100644 --- a/src/client/DiffView.tsx +++ b/src/client/DiffView.tsx @@ -9,7 +9,7 @@ * The parser is a pure function (`parseUnifiedDiff`) so the interesting * cases are unit-tested without a DOM. */ -import { useMemo, useState, type ReactNode } from 'react' +import { useEffect, useMemo, useState, type ReactNode } from 'react' import clsx from 'clsx' import { t } from './locales.ts' import css from './sidebar.module.css' @@ -171,6 +171,25 @@ function fileTag(file: DiffFile): string | null { /** Cap the flattened rows like DiffBlock: head + tail, expand button between. */ const MAX_DIFF_ROWS = 500 +const TEST_PATH = /(^|\/)(?:__tests__|tests?|specs?|fixtures?|mocks?|snapshots?)(?:\/|$)|\.(?:test|spec)\.[^/]+$/i +const DOC_PATH = /(^|\/)(?:docs?|documentation)(?:\/|$)|(^|\/)(?:readme|changelog|contributing|license|authors|notice)(?:\.[^/]*)?$/i +const GENERATED_PATH = /(^|\/)(?:dist|build|coverage|generated|vendor|node_modules)(?:\/|$)|(^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|composer\.lock|cargo\.lock|poetry\.lock)$/i +const SOURCE_PATH = /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts|py|pyw|rb|php|java|kt|kts|scala|go|rs|swift|c|h|cc|cpp|cxx|hpp|hh|hxx|cs|fs|fsx|vb|dart|lua|r|ex|exs|erl|hrl|clj|cljs|cljc|groovy|sh|bash|zsh|fish|ps1|sql|vue|svelte|astro|html|htm|css|scss|sass|less)$/i + +/** Source files open by default; tests, docs, generated files and unknown types stay folded. */ +function defaultExpandedFiles(files: DiffFile[]): Set { + const expanded = new Set() + files.forEach((file, index) => { + const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath) + if (!file.binary && file.hunks.length > 0 + && !TEST_PATH.test(path) && !DOC_PATH.test(path) && !GENERATED_PATH.test(path) + && SOURCE_PATH.test(path)) { + expanded.add(index) + } + }) + return expanded +} + export interface DiffViewProps { /** Unified diff text (`git.diff` or `git.commit-diff` payloads). */ diff: string @@ -187,22 +206,25 @@ export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProp return parseUnifiedDiff(diff) }, [diff, untrackedPath, untrackedContent]) const [expanded, setExpanded] = useState(false) + const [expandedFiles, setExpandedFiles] = useState>(() => defaultExpandedFiles(parsed.files)) + + useEffect(() => { setExpandedFiles(defaultExpandedFiles(parsed.files)) }, [parsed]) // Flatten into display rows so the cap can slice a single list. const rows = useMemo(() => { - const out: Array<{ key: string; file: DiffFile; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = [] + const out: Array<{ key: string; file: DiffFile; fileIndex: number; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = [] parsed.files.forEach((file, fileIndex) => { - out.push({ key: `f${fileIndex}`, file, type: 'path' }) - if (file.binary) return + out.push({ key: `f${fileIndex}`, file, fileIndex, type: 'path' }) + if (file.binary || !expandedFiles.has(fileIndex)) return file.hunks.forEach((hunk, hunkIndex) => { - out.push({ key: `f${fileIndex}h${hunkIndex}`, file, type: 'hunk', hunk }) + out.push({ key: `f${fileIndex}h${hunkIndex}`, file, fileIndex, type: 'hunk', hunk }) hunk.lines.forEach((line, lineIndex) => { - out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, type: 'line', hunk, line }) + out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, fileIndex, type: 'line', hunk, line }) }) }) }) return out - }, [parsed]) + }, [parsed, expandedFiles]) const hidden = rows.length - MAX_DIFF_ROWS const capped = hidden > 0 && !expanded @@ -218,12 +240,29 @@ export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProp const tag = fileTag(row.file) const from = displayPath(row.file.oldPath) const to = displayPath(row.file.newPath) + const expandable = !row.file.binary && row.file.hunks.length > 0 + const fileExpanded = expandedFiles.has(row.fileIndex) return ( -
+
+ ) } if (row.type === 'hunk') { diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index f648d4c..2d4befa 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -2132,10 +2132,34 @@ body[data-dsh-sidebar-dragging] .bottomPanel { /* One changed file's section: path header, hunk headers, aligned lines. */ .gitDiffFile { + width: 100%; display: flex; align-items: baseline; gap: 6px; padding: 8px 2px 2px; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; +} + +.gitDiffFile:disabled { + cursor: default; +} + +.gitDiffFile:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); +} + +.gitDiffFileChevron { + flex: none; + color: var(--dsw-alias-label-tertiary); + transform: rotate(0deg); +} + +.gitDiffFileChevronExpanded { + transform: rotate(90deg); } .gitDiffFilePath { @@ -2441,6 +2465,7 @@ body[data-dsh-sidebar-dragging] .bottomPanel { .gitCommitButton:focus-visible, .gitLogRow:focus-visible, .gitLogMore:focus-visible, +.gitDiffFile:focus-visible, .gitDiffExpand:focus-visible, .terminalRetry:focus-visible, .editorModeButton:focus-visible, diff --git a/tests/diff-view-collapse.spec.tsx b/tests/diff-view-collapse.spec.tsx new file mode 100644 index 0000000..d6785e5 --- /dev/null +++ b/tests/diff-view-collapse.spec.tsx @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest' +import { createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react-dom/test-utils' +import { DiffView } from '../src/client/DiffView.tsx' + +;(globalThis as Record).IS_REACT_ACT_ENVIRONMENT = true + +const diff = [ + 'diff --git a/src/a.ts b/src/a.ts', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -1 +1 @@', + '-old-a', + '+new-a', + 'diff --git a/tests/b.spec.ts b/tests/b.spec.ts', + '--- a/tests/b.spec.ts', + '+++ b/tests/b.spec.ts', + '@@ -1 +1 @@', + '-old-b', + '+new-b', + 'diff --git a/README.md b/README.md', + '--- a/README.md', + '+++ b/README.md', + '@@ -1 +1 @@', + '-old-doc', + '+new-doc', +].join('\n') + +afterEach(() => { document.body.innerHTML = '' }) + +describe('DiffView file folding', () => { + it('expands source by default while tests and docs stay collapsed', () => { + const container = document.createElement('div') + document.body.append(container) + const root: Root = createRoot(container) + try { + act(() => { root.render(createElement(DiffView, { diff })) }) + const headers = [...container.querySelectorAll('button[aria-expanded]')] + expect(headers).toHaveLength(3) + expect(headers.map(header => header.getAttribute('aria-expanded'))).toEqual(['true', 'false', 'false']) + expect(container.textContent).toContain('new-a') + expect(container.textContent).not.toContain('new-b') + expect(container.textContent).not.toContain('new-doc') + + act(() => { headers[1]!.click() }) + expect(headers[1]!.getAttribute('aria-expanded')).toBe('true') + expect(container.textContent).toContain('new-b') + + act(() => { headers[0]!.click() }) + expect(headers[0]!.getAttribute('aria-expanded')).toBe('false') + expect(container.textContent).not.toContain('new-a') + } finally { + act(() => { root.unmount() }) + container.remove() + } + }) +})