From af3087d0c3b8205a8c214aa150af62af95935ea3 Mon Sep 17 00:00:00 2001 From: everpcpc Date: Wed, 11 Feb 2026 13:32:18 +0800 Subject: [PATCH] Remove legacy ZedDiffViewer and react-diff-view dependency --- packages/ui/package.json | 3 +- .../panels/diff/ZedDiffViewer.test.tsx | 629 ------ .../components/panels/diff/ZedDiffViewer.tsx | 1725 ----------------- .../components/panels/diff/useFileContent.ts | 2 +- .../panels/diff/useVisibleHunkTracker.test.ts | 85 - .../panels/diff/useVisibleHunkTracker.ts | 75 - .../components/panels/diff/utils/diffUtils.ts | 97 - packages/ui/src/index.css | 1 - packages/ui/src/styles/diff.css | 29 - pnpm-lock.yaml | 53 - 10 files changed, 2 insertions(+), 2697 deletions(-) delete mode 100644 packages/ui/src/components/panels/diff/ZedDiffViewer.test.tsx delete mode 100644 packages/ui/src/components/panels/diff/ZedDiffViewer.tsx delete mode 100644 packages/ui/src/components/panels/diff/useVisibleHunkTracker.test.ts delete mode 100644 packages/ui/src/components/panels/diff/useVisibleHunkTracker.ts delete mode 100644 packages/ui/src/styles/diff.css diff --git a/packages/ui/package.json b/packages/ui/package.json index ba59c42d..f2d9153b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -39,8 +39,7 @@ "lucide-react": "^0.468.0", "marked": "^17.0.1", "react": "^19.0.0", - "react-diff-view": "^3.3.2", - "react-dom": "^19.0.0", +"react-dom": "^19.0.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "xterm": "^5.3.0", diff --git a/packages/ui/src/components/panels/diff/ZedDiffViewer.test.tsx b/packages/ui/src/components/panels/diff/ZedDiffViewer.test.tsx deleted file mode 100644 index 068833de..00000000 --- a/packages/ui/src/components/panels/diff/ZedDiffViewer.test.tsx +++ /dev/null @@ -1,629 +0,0 @@ -import { describe, it, expect, vi } from 'vitest'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { ZedDiffViewer } from './ZedDiffViewer'; -import { API } from '../../../utils/api'; - -vi.mock('../../../utils/api', () => ({ - API: { - sessions: { - stageHunk: vi.fn(), - changeFileStage: vi.fn(), - restoreHunk: vi.fn(), - }, - }, -})); - -const SAMPLE_DIFF_TWO_HUNKS = `diff --git a/test.txt b/test.txt -index 1234567..abcdefg 100644 ---- a/test.txt -+++ b/test.txt -@@ -1,3 +1,4 @@ - context --old -+new - end -@@ -10,2 +11,3 @@ - a -+b - c`; - -const SAMPLE_DIFF_TWO_FILES = `diff --git a/b.txt b/b.txt -index 1234567..abcdefg 100644 ---- a/b.txt -+++ b/b.txt -@@ -1,1 +1,1 @@ --x -+y -diff --git a/a.txt b/a.txt -index 1234567..abcdefg 100644 ---- a/a.txt -+++ b/a.txt -@@ -1,1 +1,1 @@ --x -+y`; - -describe('ZedDiffViewer', () => { - function mockRect(el: Element, rect: Partial & Pick) { - const full: DOMRect = { - x: 0, - y: rect.top ?? 0, - top: rect.top, - left: 0, - right: 0, - bottom: (rect.top ?? 0) + rect.height, - width: rect.width ?? 0, - height: rect.height, - toJSON: () => ({}), - } as any; - Object.defineProperty(el, 'getBoundingClientRect', { value: () => full }); - } - - async function hoverFirstHunk(container: HTMLElement) { - const codeCell = container.querySelector('td.diff-code') as HTMLElement | null; - expect(codeCell).toBeTruthy(); - fireEvent.mouseMove(codeCell!); - await waitFor(() => { - const controls = screen.getAllByTestId('diff-hunk-controls')[0] as HTMLElement; - expect(controls.classList.contains('st-hunk-hovered')).toBe(true); - }); - await waitFor(() => { - expect(screen.getByTestId('diff-hunk-actions-overlay')).toBeInTheDocument(); - expect(screen.getByTestId('diff-hunk-stage')).toBeInTheDocument(); - }); - } - - it('renders viewer', () => { - render(); - expect(screen.getByTestId('diff-viewer-zed')).toBeInTheDocument(); - }); - - it('expands modified files to full file when fileSources is provided', () => { - const diff = `diff --git a/a.txt b/a.txt -index 1234567..abcdefg 100644 ---- a/a.txt -+++ b/a.txt -@@ -2,1 +2,1 @@ --old -+new`; - - const { container } = render(); - // one (context) + old (delete) + new (insert) + three (context) - expect(container.querySelectorAll('tr.diff-line')).toHaveLength(4); - }); - - it('does not duplicate content when expanding a new file diff', () => { - const diff = `diff --git a/new.txt b/new.txt -new file mode 100644 -index 0000000..abcdefg ---- /dev/null -+++ b/new.txt -@@ -0,0 +1,2 @@ -+first -+second`; - - const { container } = render(); - expect(container.querySelectorAll('tr.diff-line')).toHaveLength(2); - }); - - it('stages a hunk when scope is unstaged', async () => { - (API.sessions.stageHunk as any).mockResolvedValue({ success: true, data: { success: true } }); - const { container } = render( - - ); - - await hoverFirstHunk(container); - const stage = screen.getAllByTestId('diff-hunk-stage')[0] as HTMLButtonElement; - const user = userEvent.setup(); - await user.click(stage); - - await waitFor(() => { - expect(API.sessions.stageHunk).toHaveBeenCalledWith('s1', expect.objectContaining({ isStaging: true })); - }); - }); - - it('unstages a hunk when scope is staged', async () => { - (API.sessions.stageHunk as any).mockResolvedValue({ success: true, data: { success: true } }); - const { container } = render( - - ); - - await hoverFirstHunk(container); - const stage = screen.getAllByTestId('diff-hunk-stage')[0] as HTMLButtonElement; - const user = userEvent.setup(); - await user.click(stage); - - await waitFor(() => { - expect(API.sessions.stageHunk).toHaveBeenCalledWith('s1', expect.objectContaining({ isStaging: false })); - }); - }); - - it('restores a hunk using the current scope', async () => { - (API.sessions.restoreHunk as any).mockResolvedValue({ success: true, data: { success: true } }); - const { container } = render( - - ); - - await hoverFirstHunk(container); - const restore = screen.getAllByTestId('diff-hunk-restore')[0] as HTMLButtonElement; - const user = userEvent.setup(); - await user.click(restore); - - await waitFor(() => { - expect(API.sessions.restoreHunk).toHaveBeenCalledWith('s1', expect.objectContaining({ scope: 'unstaged' })); - }); - }); - - it('stages an untracked file (file-level stage)', async () => { - (API.sessions.changeFileStage as any).mockResolvedValue({ success: true }); - const diff = `diff --git a/new.txt b/new.txt -new file mode 100644 -index 0000000..abcdefg ---- /dev/null -+++ b/new.txt -@@ -0,0 +1,1 @@ -+hello`; - - const { container } = render(); - - await hoverFirstHunk(container); - const stage = screen.getAllByTestId('diff-hunk-stage')[0] as HTMLButtonElement; - const user = userEvent.setup(); - await user.click(stage); - await waitFor(() => { - expect(API.sessions.changeFileStage).toHaveBeenCalledWith('s1', { filePath: 'new.txt', stage: true }); - }); - }); - - it('matches staged/unstaged hunks by signature + location (duplicate signatures)', async () => { - (API.sessions.stageHunk as any).mockResolvedValue({ success: true, data: { success: true } }); - const diff = `diff --git a/a.txt b/a.txt -index 1234567..abcdefg 100644 ---- a/a.txt -+++ b/a.txt -@@ -1,1 +1,2 @@ - x -+same -@@ -10,1 +11,2 @@ - y -+same`; - - const { container } = render(); - const user = userEvent.setup(); - await hoverFirstHunk(container); - const stageButtons = screen.getAllByTestId('diff-hunk-stage') as HTMLButtonElement[]; - await user.click(stageButtons[0]!); - - await waitFor(() => { - expect(API.sessions.stageHunk).toHaveBeenCalledWith( - 's1', - expect.objectContaining({ - filePath: 'a.txt', - isStaging: true, - hunkHeader: expect.stringContaining('@@ -1,1 +1,2 @@'), - }) - ); - }); - }); - - it('marks hovered hunk to keep controls stable', async () => { - const { container } = render( - - ); - const codeCell = container.querySelector('td.diff-code') as HTMLElement | null; - expect(codeCell).toBeTruthy(); - fireEvent.mouseMove(codeCell!); - - await waitFor(() => { - const controls = screen.getAllByTestId('diff-hunk-controls')[0] as HTMLElement; - expect(controls.classList.contains('st-hunk-hovered')).toBe(true); - }); - }); - - it('scrolls to a file header when scrollToFilePath changes', () => { - const scrollSpy = vi.spyOn(HTMLElement.prototype, 'scrollIntoView').mockImplementation(() => {}); - const { rerender } = render(); - - rerender(); - expect(scrollSpy).toHaveBeenCalled(); - scrollSpy.mockRestore(); - }); - - it('renders per-line widget anchors for hunk controls', () => { - const { container } = render( - - ); - const widgets = container.querySelectorAll('tr.diff-widget'); - expect(widgets.length).toBeGreaterThan(0); - expect(screen.getAllByTestId('diff-hunk-controls').length).toBeGreaterThan(0); - }); - - it('renders one control group per hunk', () => { - render( - - ); - expect(screen.getAllByTestId('diff-hunk-controls')).toHaveLength(2); - }); - - it('orders files based on fileOrder when provided', () => { - render(); - const filePaths = screen.getAllByTestId('diff-file-path').map((el) => el.textContent); - expect(filePaths[0]).toBe('a.txt'); - expect(filePaths[1]).toBe('b.txt'); - }); - - it('renders a distinct gutter style for staged hunks (hollow bar with border)', () => { - const { container } = render( - - ); - const css = container.querySelector('style')?.textContent || ''; - expect(css).toContain('st-hunk-status--staged'); - expect(css).toContain('td.diff-gutter:first-of-type::before'); - // Zed-style: staged hunks show hollow bar (30% opacity background + left/right border) - expect(css).toContain('border-left: 1px solid'); - expect(css).toContain('border-right: 1px solid'); - expect(css).toContain('box-sizing: border-box'); - }); - - it('keeps unified gutters sticky for horizontal scroll', () => { - const { container } = render(); - const css = container.querySelector('style')?.textContent || ''; - expect(css).toContain('.st-diff-table.diff-unified tr.diff-line > td.diff-gutter:nth-child(1)'); - expect(css).toContain('position: sticky'); - expect(css).toContain('left: 0'); - expect(css).toContain('left: var(--st-diff-gutter-width)'); - // Regression guard: don't override sticky on changed rows (we rely on ::before for the marker strip). - expect(css).toContain('td.diff-gutter:first-of-type::before'); - expect(css).not.toContain('td.diff-gutter:first-of-type {'); - }); - - it('shows a persistent staged badge for staged hunks via CSS ::after', () => { - const { container } = render( - - ); - const css = container.querySelector('style')?.textContent || ''; - // Badge is rendered via CSS ::after on first changed row of staged hunks - expect(css).toContain('tbody.diff-hunk.st-hunk-status--staged tr.diff-line.st-hunk-row-first td.diff-gutter:first-of-type::after'); - expect(css).toContain("content: '✓'"); - }); - - it('renders per-file horizontal scrollers with a global horizontal scrollbar', () => { - render(); - const root = screen.getByTestId('diff-scroll-container') as HTMLDivElement; - expect(root).toBeInTheDocument(); - expect(root.className).toContain('overflow-y-auto'); - expect(root.className).toContain('overflow-x-hidden'); - expect(screen.getAllByTestId('diff-hscroll-container').length).toBeGreaterThan(0); - expect(screen.getByTestId('diff-x-scrollbar')).toBeInTheDocument(); - expect(document.querySelector('.st-diff-x-scrollbar-track')).toBeTruthy(); - expect(document.querySelector('.st-diff-x-scrollbar-thumb')).toBeTruthy(); - }); - - it('syncs global horizontal scroll to all visible file scrollers', () => { - const callbacks = new Map(); - let nextId = 1; - const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { - const id = nextId++; - callbacks.set(id, cb); - return id; - }); - - render(); - - const xbar = screen.getByTestId('diff-x-scrollbar') as HTMLDivElement; - const scrollers = screen.getAllByTestId('diff-hscroll-container') as HTMLDivElement[]; - expect(scrollers.length).toBe(2); - - xbar.scrollLeft = 120; - fireEvent.scroll(xbar); - - // Flush scheduled rAF (including the "unlock" frame). - while (callbacks.size > 0) { - const batch = Array.from(callbacks.values()); - callbacks.clear(); - batch.forEach((cb) => cb(0)); - } - - expect(scrollers[0]!.scrollLeft).toBe(120); - expect(scrollers[1]!.scrollLeft).toBe(120); - rafSpy.mockRestore(); - }); - - it('does not drop rapid scroll updates while syncing', () => { - const queue: FrameRequestCallback[] = []; - const rafSpy = vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb: FrameRequestCallback) => { - queue.push(cb); - return queue.length; - }); - - render(); - const scrollers = screen.getAllByTestId('diff-hscroll-container') as HTMLDivElement[]; - expect(scrollers.length).toBe(2); - - // First user scroll schedules a flush rAF. - scrollers[0]!.scrollLeft = 100; - fireEvent.scroll(scrollers[0]!); - expect(queue.length).toBeGreaterThanOrEqual(1); - - // Run the flush, but not its unlock frame yet. - const flush1 = queue.shift()!; - flush1(0); - expect(queue.length).toBeGreaterThanOrEqual(1); - - // Second user scroll arrives while we're still syncing (before unlock). - scrollers[0]!.scrollLeft = 200; - fireEvent.scroll(scrollers[0]!); - expect(queue.length).toBeGreaterThanOrEqual(2); - - // Force the "second flush" to run before the unlock frame (this is the case that previously dropped updates). - const unlock1 = queue[0]!; - const flush2 = queue[1]!; - queue.splice(1, 1); - flush2(0); - - // Now unlock, then run any queued follow-up flush. - queue.shift()!(0); // unlock1 - while (queue.length > 0) { - const cb = queue.shift()!; - cb(0); - } - - expect(scrollers[1]!.scrollLeft).toBe(200); - rafSpy.mockRestore(); - }); - - it('pins hunk actions to the viewport right edge (overlay)', async () => { - const { container } = render( - - ); - - const scroller = screen.getByTestId('diff-scroll-container'); - const anchor = container.querySelector('[data-hunk-anchor="true"]') as HTMLElement | null; - expect(anchor).toBeTruthy(); - - // Force geometry so overlay position is deterministic in JSDOM. - mockRect(scroller, { top: 0, height: 400 }); - // rawTop=10 => clamped to 24px - mockRect(anchor!, { top: 10, height: 0 }); - - await hoverFirstHunk(container); - const overlay = screen.getByTestId('diff-hunk-actions-overlay'); - const overlayInner = overlay.querySelector('.st-diff-actions-overlay-inner') as HTMLElement | null; - expect(overlayInner).toBeTruthy(); - expect(overlayInner!.style.top).toBe('24px'); - }); - - it('hides overlay actions when the hovered hunk is offscreen', async () => { - const { container } = render( - - ); - - const scroller = screen.getByTestId('diff-scroll-container'); - const anchor = container.querySelector('[data-hunk-anchor="true"]') as HTMLElement | null; - expect(anchor).toBeTruthy(); - mockRect(scroller, { top: 0, height: 200 }); - // rawTop=999 => offscreen => overlay hidden - mockRect(anchor!, { top: 999, height: 0 }); - - const codeCell = container.querySelector('td.diff-code') as HTMLElement | null; - expect(codeCell).toBeTruthy(); - fireEvent.mouseMove(codeCell!); - await waitFor(() => { - const controls = screen.getAllByTestId('diff-hunk-controls')[0] as HTMLElement; - expect(controls.classList.contains('st-hunk-hovered')).toBe(true); - }); - const overlay = screen.getByTestId('diff-hunk-actions-overlay'); - expect(overlay).toBeInTheDocument(); - expect(overlay.getAttribute('aria-hidden')).toBe('true'); - }); - - it('keeps diff scroll container constrained to the panel', () => { - render(); - const scroller = screen.getByTestId('diff-scroll-container'); - expect(scroller.className).toContain('absolute'); - expect(scroller.className).toContain('inset-0'); - expect(scroller.className).toContain('overflow-y-auto'); - }); - - it('does not let the overlay block vertical scrolling gestures', () => { - const { container } = render(); - const css = container.querySelector('style')?.textContent || ''; - expect(css).toContain('.st-diff-actions-overlay'); - expect(css).toContain('.st-diff-actions-overlay-inner'); - expect(css).toContain('pointer-events: none'); - expect(css).toContain('.st-diff-actions-overlay-inner .st-diff-hunk-btn'); - expect(css).toContain('pointer-events: auto'); - expect(css).toContain('visibility: hidden'); - }); - - describe('Markdown Preview', () => { - const SAMPLE_MD_DIFF = `diff --git a/README.md b/README.md -index 1234567..abcdefg 100644 ---- a/README.md -+++ b/README.md -@@ -1,3 +1,4 @@ - # Title --Old content -+New content -+More text`; - - const SAMPLE_NON_MD_DIFF = `diff --git a/file.txt b/file.txt -index 1234567..abcdefg 100644 ---- a/file.txt -+++ b/file.txt -@@ -1,1 +1,1 @@ --old -+new`; - - it('shows preview button for markdown files when fileSources is provided', () => { - render( - - ); - const previewBtn = document.querySelector('.st-diff-preview-btn'); - expect(previewBtn).toBeInTheDocument(); - }); - - it('does not show preview button for non-markdown files', () => { - render( - - ); - const previewBtn = document.querySelector('.st-diff-preview-btn'); - expect(previewBtn).not.toBeInTheDocument(); - }); - - it('does not show preview button when fileSources is not provided', () => { - render(); - const previewBtn = document.querySelector('.st-diff-preview-btn'); - expect(previewBtn).not.toBeInTheDocument(); - }); - - it('toggles preview mode when clicking the preview button', async () => { - const user = userEvent.setup(); - const { container } = render( - - ); - - // Initially shows diff view - expect(container.querySelector('.st-diff-table')).toBeInTheDocument(); - expect(container.querySelector('.st-markdown-preview')).not.toBeInTheDocument(); - - // Click preview button - const previewBtn = document.querySelector('.st-diff-preview-btn') as HTMLButtonElement; - expect(previewBtn).toBeInTheDocument(); - await user.click(previewBtn); - - // Now shows markdown preview - await waitFor(() => { - expect(container.querySelector('.st-markdown-preview')).toBeInTheDocument(); - }); - - // Click again to toggle back - await user.click(previewBtn); - await waitFor(() => { - expect(container.querySelector('.st-diff-table')).toBeInTheDocument(); - }); - }); - - it('renders markdown content in preview mode', async () => { - const user = userEvent.setup(); - render( - - ); - - const previewBtn = document.querySelector('.st-diff-preview-btn') as HTMLButtonElement; - await user.click(previewBtn); - - await waitFor(() => { - expect(screen.getByText('Hello World')).toBeInTheDocument(); - expect(screen.getByText('bold')).toBeInTheDocument(); - }); - }); - - it('keeps file header sticky with top:0', () => { - const { container } = render(); - const css = container.querySelector('style')?.textContent || ''; - expect(css).toContain('.st-diff-file-header'); - expect(css).toContain('position: sticky'); - expect(css).toContain('top: 0'); - }); - - it('shows preview button for .mdx files', () => { - const mdxDiff = `diff --git a/docs/page.mdx b/docs/page.mdx -index 1234567..abcdefg 100644 ---- a/docs/page.mdx -+++ b/docs/page.mdx -@@ -1,1 +1,1 @@ --old -+new`; - render( - - ); - const previewBtn = document.querySelector('.st-diff-preview-btn'); - expect(previewBtn).toBeInTheDocument(); - }); - }); - - describe('Image Preview', () => { - const SAMPLE_IMAGE_DIFF = `diff --git a/image.png b/image.png -index 1234567..abcdefg 100644 ---- a/image.png -+++ b/image.png -@@ -1,1 +1,1 @@ --old -+new`; - - it('defaults to preview mode for image files', async () => { - const { container } = render( - - ); - - await waitFor(() => { - expect(screen.getByRole('img', { name: 'image.png' })).toBeInTheDocument(); - }); - expect(container.querySelector('.st-diff-table')).not.toBeInTheDocument(); - }); - }); -}); diff --git a/packages/ui/src/components/panels/diff/ZedDiffViewer.tsx b/packages/ui/src/components/panels/diff/ZedDiffViewer.tsx deleted file mode 100644 index 1c4a3e28..00000000 --- a/packages/ui/src/components/panels/diff/ZedDiffViewer.tsx +++ /dev/null @@ -1,1725 +0,0 @@ -import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; -import { Diff, Hunk, getChangeKey, parseDiff, type ChangeData, type DiffType, type HunkData } from 'react-diff-view'; -import 'react-diff-view/style/index.css'; -import { Eye, EyeOff, Plus, Minus, RotateCcw } from 'lucide-react'; -import { API } from '../../../utils/api'; -import { MarkdownPreview } from './MarkdownPreview'; -import { ImagePreview } from './ImagePreview'; -import { useFilePreviewState } from './useFilePreviewState'; -import { isImageFile, isPreviewableFile, isBinaryFile } from './utils/fileUtils'; -import { expandToFullFile, findMatchingHeader, hunkKind, hunkSignature, normalizeHunks, parseHunkHeader, toFilePath, type HunkHeaderEntry } from './utils/diffUtils'; - -export interface ZedDiffViewerHandle { - navigateToHunk: (direction: 'prev' | 'next') => void; - stageAll: (stage: boolean) => Promise; -} - -type Scope = 'staged' | 'unstaged' | 'untracked'; - -type FileModel = { - path: string; - diffType: DiffType; - hunks: Array< - HunkData & { - __st_hunkKey: string; - __st_hunkSig: string; - } - >; -}; - -export interface ZedDiffViewerProps { - diff: string; - className?: string; - sessionId?: string; - currentScope?: Scope; - stagedDiff?: string; - unstagedDiff?: string; - // File contents used for previews (markdown/images). If omitted, `fileSources` is used. - previewFileSources?: Record; - // File contents used to expand hunks into full-file context. If omitted, `fileSources` is used. - contextFileSources?: Record; - // Back-compat: when only one set of sources is available, use this for both preview + context expansion. - fileSources?: Record; - expandFileContext?: boolean; - scrollToFilePath?: string; - fileOrder?: string[]; - isCommitView?: boolean; - onChanged?: () => void; - onHunkInfo?: (current: number, total: number) => void; - onVisibleFileChange?: (path: string | null) => void; -} - -export const ZedDiffViewer = forwardRef(({ - diff, - className, - sessionId, - currentScope: _currentScope, - stagedDiff, - unstagedDiff, - previewFileSources, - contextFileSources, - fileSources, - expandFileContext = false, - scrollToFilePath, - fileOrder, - isCommitView, - onChanged, - onHunkInfo, - onVisibleFileChange, -}, ref) => { - const previewSources = previewFileSources ?? fileSources; - const contextSources = contextFileSources ?? fileSources; - const fileHeaderRefs = useRef>(new Map()); - const [pendingHunkKeys, setPendingHunkKeys] = useState>(() => new Set()); - - const setPending = useCallback((key: string, next: boolean) => { - setPendingHunkKeys((prev) => { - const copy = new Set(prev); - if (next) copy.add(key); - else copy.delete(key); - return copy; - }); - }, []); - - const stagedHunkHeaderBySig = useMemo(() => { - if (!stagedDiff || stagedDiff.trim() === '') return new Map(); - const parsed = parseDiff(stagedDiff, { nearbySequences: 'zip' }); - const byFile = new Map(); - for (const file of parsed) { - const path = toFilePath(file); - const list: HunkHeaderEntry[] = []; - for (const hunk of file.hunks || []) { - const sig = hunkSignature(hunk); - if (!sig) continue; - const header = parseHunkHeader(hunk.content); - list.push({ - sig, - oldStart: header?.oldStart ?? hunk.oldStart, - newStart: header?.newStart ?? hunk.newStart, - header: hunk.content, - }); - } - byFile.set(path, list); - } - return byFile; - }, [stagedDiff]); - - const unstagedHunkHeaderBySig = useMemo(() => { - if (!unstagedDiff || unstagedDiff.trim() === '') return new Map(); - const parsed = parseDiff(unstagedDiff, { nearbySequences: 'zip' }); - const byFile = new Map(); - for (const file of parsed) { - const path = toFilePath(file); - const list: HunkHeaderEntry[] = []; - for (const hunk of file.hunks || []) { - const sig = hunkSignature(hunk); - if (!sig) continue; - const header = parseHunkHeader(hunk.content); - list.push({ - sig, - oldStart: header?.oldStart ?? hunk.oldStart, - newStart: header?.newStart ?? hunk.newStart, - header: hunk.content, - }); - } - byFile.set(path, list); - } - return byFile; - }, [unstagedDiff]); - - const files = useMemo(() => { - if (!diff || diff.trim() === '') return []; - const parsed = parseDiff(diff, { nearbySequences: 'zip' }); - const ordered = (() => { - const order = Array.isArray(fileOrder) ? fileOrder.map((s) => (typeof s === 'string' ? s.trim() : '')).filter(Boolean) : []; - if (order.length === 0) return parsed; - const idx = new Map(); - order.forEach((p, i) => { - if (!idx.has(p)) idx.set(p, i); - }); - return parsed - .map((f, originalIndex) => ({ f, originalIndex, path: toFilePath(f) })) - .sort((a, b) => { - const ai = idx.get(a.path); - const bi = idx.get(b.path); - if (ai != null && bi != null) return ai - bi; - if (ai != null) return -1; - if (bi != null) return 1; - return a.originalIndex - b.originalIndex; - }) - .map((x) => x.f); - })(); - - return ordered.map((f) => { - const path = toFilePath(f); - const hasSource = Boolean(contextSources && Object.prototype.hasOwnProperty.call(contextSources, path)); - const source = hasSource ? (contextSources as Record)[path] : undefined; - const expandedHunks = (expandFileContext && hasSource) ? expandToFullFile(f.hunks || [], source || '') : normalizeHunks(f.hunks || []); - - const hunks = expandedHunks.map((hunk, idx) => { - const sig = hunkSignature(hunk); - const key = `${path}:${idx}:${hunk.oldStart}-${hunk.newStart}:${sig.length}`; - const next = Object.assign({}, hunk, { - __st_hunkKey: key, - __st_hunkSig: sig, - }); - return next; - }); - - return { - path, - diffType: f.type as DiffType, - hunks, - }; - }); - }, [diff, contextSources, expandFileContext, fileOrder]); - - const autoPreviewPaths = useMemo( - () => files.filter((file) => isImageFile(file.path)).map((file) => file.path), - [files] - ); - const { previewFiles, togglePreview } = useFilePreviewState(autoPreviewPaths, { defaultPreview: true }); - - const scrollToFile = useCallback((filePath: string) => { - const el = fileHeaderRefs.current.get(filePath); - if (!el) return; - el.scrollIntoView({ block: 'start' }); - }, []); - - useEffect(() => { - if (!scrollToFilePath) return; - scrollToFile(scrollToFilePath); - }, [scrollToFilePath, scrollToFile]); - - const stageOrUnstageHunk = useCallback( - async (filePath: string, isStaging: boolean, hunkHeader: string, hunkKey: string) => { - if (!sessionId) return; - try { - setPending(hunkKey, true); - await API.sessions.stageHunk(sessionId, { filePath, isStaging, hunkHeader }); - onChanged?.(); - } catch (err) { - console.error(`[Diff] Failed to ${isStaging ? 'stage' : 'unstage'} hunk`, { filePath, hunkHeader, err }); - } finally { - setPending(hunkKey, false); - // Prevent focus from sticking to the old hunk after staging (avoids "hover controls" lingering). - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - } - }, - [sessionId, onChanged, setPending] - ); - - const restoreHunk = useCallback( - async (filePath: string, scope: 'staged' | 'unstaged', hunkHeader: string, hunkKey: string) => { - if (!sessionId) return; - try { - setPending(hunkKey, true); - await API.sessions.restoreHunk(sessionId, { filePath, scope, hunkHeader }); - onChanged?.(); - } catch (err) { - console.error('[Diff] Failed to restore hunk', { filePath, hunkHeader, err }); - } finally { - setPending(hunkKey, false); - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - } - }, - [sessionId, onChanged, setPending] - ); - - const stageFile = useCallback( - async (filePath: string, stage: boolean, hunkKey: string) => { - if (!sessionId) return; - try { - setPending(hunkKey, true); - await API.sessions.changeFileStage(sessionId, { filePath, stage }); - onChanged?.(); - } catch (err) { - console.error(`[Diff] Failed to ${stage ? 'stage' : 'unstage'} file`, { filePath, err }); - } finally { - setPending(hunkKey, false); - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - } - }, - [sessionId, onChanged, setPending] - ); - - const restoreFile = useCallback( - async (filePath: string, hunkKey: string) => { - if (!sessionId) return; - try { - setPending(hunkKey, true); - await API.sessions.restoreFile(sessionId, { filePath }); - onChanged?.(); - } catch (err) { - console.error('[Diff] Failed to restore file', { filePath, err }); - } finally { - setPending(hunkKey, false); - if (document.activeElement instanceof HTMLElement) { - document.activeElement.blur(); - } - } - }, - [sessionId, onChanged, setPending] - ); - - const containerRef = useRef(null); - const scrollContainerRef = useRef(null); - const [currentHunkIdx, setCurrentHunkIdx] = useState(0); - const [focusedHunkKey, setFocusedHunkKey] = useState(null); - const [focusedHunkSig, setFocusedHunkSig] = useState<{ filePath: string; sig: string; oldStart: number; newStart: number } | null>(null); - const [hoveredHunkKey, setHoveredHunkKey] = useState(null); - const [overlayTopPx, setOverlayTopPx] = useState(24); - const [overlayVisible, setOverlayVisible] = useState(false); - const [overlayPinnedHunkKey, setOverlayPinnedHunkKey] = useState(null); - const [uiPendingVersion, setUiPendingVersion] = useState(0); - - const activeHunkKey = hoveredHunkKey ?? focusedHunkKey; - const overlayKey = activeHunkKey ?? overlayPinnedHunkKey; - - const uiPendingRef = useRef< - Map< - string, - | { kind: 'status'; expectedStaged: boolean; deadlineMs: number } - | { kind: 'gone'; deadlineMs: number } - > - >(new Map()); - - const stableHunkId = useCallback((h: { filePath: string; sig: string; oldStart: number; newStart: number }) => { - return `${h.filePath}:${h.sig}:${h.oldStart}:${h.newStart}`; - }, []); - - const setUiPending = useCallback( - ( - h: { filePath: string; sig: string; oldStart: number; newStart: number }, - next: - | { kind: 'status'; expectedStaged: boolean } - | { kind: 'gone' } - ) => { - const id = stableHunkId(h); - const deadlineMs = Date.now() + 4000; - uiPendingRef.current.set(id, { ...next, deadlineMs } as any); - setUiPendingVersion((v) => v + 1); - window.setTimeout(() => { - const cur = uiPendingRef.current.get(id); - if (!cur) return; - if (Date.now() < cur.deadlineMs) return; - uiPendingRef.current.delete(id); - setUiPendingVersion((v) => v + 1); - }, 4100); - }, - [stableHunkId] - ); - - const pinOverlay = useCallback((hunkKey: string) => { - setOverlayPinnedHunkKey(hunkKey); - }, []); - - useEffect(() => { - if (!overlayPinnedHunkKey) return; - if (pendingHunkKeys.has(overlayPinnedHunkKey)) return; - const t = window.setTimeout(() => { - // Clear only if nothing else is controlling the overlay. - if (activeHunkKey == null) setOverlayPinnedHunkKey(null); - }, 250); - return () => window.clearTimeout(t); - }, [overlayPinnedHunkKey, pendingHunkKeys, activeHunkKey]); - - const fileByPath = useMemo(() => { - const map = new Map(); - for (const f of files) map.set(f.path, f); - return map; - }, [files]); - - const hscrollRefs = useRef>(new Map()); - const visibleHScrollersRef = useRef>(new Set()); - const hscrollLeftRef = useRef(0); - const hscrollSyncingRef = useRef(false); - const hscrollRafRef = useRef(null); - const hscrollPendingRef = useRef<{ left: number; source?: string } | null>(null); - - const xScrollbarRef = useRef(null); - const xScrollbarTrackRef = useRef(null); - const xScrollbarThumbRef = useRef(null); - const [xScrollbarContentWidth, setXScrollbarContentWidth] = useState(0); - const scheduleXScrollbarWidthUpdateRef = useRef(null); - const xbarThumbRafRef = useRef(null); - const xbarDragRef = useRef<{ - pointerId: number; - startClientX: number; - startScrollLeft: number; - thumbWidthPx: number; - maxScrollLeft: number; - trackWidthPx: number; - } | null>(null); - - const scheduleXScrollbarWidthUpdate = useCallback(() => { - if (scheduleXScrollbarWidthUpdateRef.current != null) return; - scheduleXScrollbarWidthUpdateRef.current = requestAnimationFrame(() => { - scheduleXScrollbarWidthUpdateRef.current = null; - let max = 0; - for (const el of hscrollRefs.current.values()) { - max = Math.max(max, el.scrollWidth); - } - setXScrollbarContentWidth((prev) => (Math.abs(prev - max) < 1 ? prev : max)); - }); - }, []); - - const scheduleXbarThumbUpdate = useCallback(() => { - if (xbarThumbRafRef.current != null) return; - xbarThumbRafRef.current = requestAnimationFrame(() => { - xbarThumbRafRef.current = null; - const xbar = xScrollbarRef.current; - const thumb = xScrollbarThumbRef.current; - if (!xbar || !thumb) return; - - const clientWidth = xbar.clientWidth; - const scrollWidth = xbar.scrollWidth; - const max = Math.max(0, scrollWidth - clientWidth); - - if (clientWidth <= 0 || max <= 0) { - thumb.style.opacity = '0'; - thumb.style.width = `${Math.max(0, clientWidth)}px`; - thumb.style.transform = 'translateX(0px)'; - return; - } - - const minThumb = 28; - const thumbWidth = Math.max(minThumb, Math.round((clientWidth / scrollWidth) * clientWidth)); - const leftPx = Math.round((xbar.scrollLeft / max) * (clientWidth - thumbWidth)); - thumb.style.opacity = '1'; - thumb.style.width = `${thumbWidth}px`; - thumb.style.transform = `translateX(${leftPx}px)`; - }); - }, []); - - const flushHScrollSync = useCallback(() => { - hscrollRafRef.current = null; - - // Don't drop updates that arrive while we're still ignoring the synthetic - // scroll events caused by programmatic scrollLeft assignments. - if (hscrollSyncingRef.current) { - if (hscrollPendingRef.current) { - hscrollRafRef.current = requestAnimationFrame(flushHScrollSync); - } - return; - } - - const pending = hscrollPendingRef.current; - if (!pending) return; - hscrollPendingRef.current = null; - - hscrollSyncingRef.current = true; - try { - for (const [path, el] of hscrollRefs.current.entries()) { - if (pending.source && pending.source !== '__xbar' && path === pending.source) continue; - if (Math.abs(el.scrollLeft - pending.left) > 0.5) el.scrollLeft = pending.left; - } - - if (pending.source !== '__xbar') { - const xbar = xScrollbarRef.current; - if (xbar && Math.abs(xbar.scrollLeft - pending.left) > 0.5) { - xbar.scrollLeft = pending.left; - scheduleXbarThumbUpdate(); - } - } - } finally { - requestAnimationFrame(() => { - hscrollSyncingRef.current = false; - if (hscrollPendingRef.current && hscrollRafRef.current == null) { - hscrollRafRef.current = requestAnimationFrame(flushHScrollSync); - } - }); - } - }, [scheduleXbarThumbUpdate]); - - const scheduleHScrollSync = useCallback( - (left: number, source?: string) => { - hscrollLeftRef.current = left; - hscrollPendingRef.current = { left, source }; - if (hscrollRafRef.current != null) return; - hscrollRafRef.current = requestAnimationFrame(flushHScrollSync); - }, - [flushHScrollSync] - ); - - const allHunks = useMemo(() => { - const result: Array<{ filePath: string; hunkKey: string; sig: string; oldStart: number; newStart: number; hunkHeader: string; isStaged: boolean; isUntracked: boolean }> = []; - for (const file of files) { - const stagedEntries = stagedHunkHeaderBySig.get(file.path); - const unstagedEntries = unstagedHunkHeaderBySig.get(file.path); - for (const hunk of file.hunks) { - const sig = hunk.__st_hunkSig; - if (!sig) continue; - const stagedHeader = findMatchingHeader(stagedEntries, sig, hunk.oldStart, hunk.newStart); - const unstagedHeader = findMatchingHeader(unstagedEntries, sig, hunk.oldStart, hunk.newStart); - const isUntracked = !stagedHeader && !unstagedHeader; - result.push({ - filePath: file.path, - hunkKey: hunk.__st_hunkKey, - sig, - oldStart: hunk.oldStart, - newStart: hunk.newStart, - hunkHeader: stagedHeader || unstagedHeader || hunk.content, - isStaged: Boolean(stagedHeader), - isUntracked, - }); - } - } - return result; - }, [files, stagedHunkHeaderBySig, unstagedHunkHeaderBySig]); - - const activeHunk = useMemo(() => { - if (!activeHunkKey) return null; - return allHunks.find((h) => h.hunkKey === activeHunkKey) ?? null; - }, [allHunks, activeHunkKey]); - - const lastActiveHunkRef = useRef(null); - useEffect(() => { - if (activeHunk) lastActiveHunkRef.current = activeHunk; - }, [activeHunk]); - - const overlayHunk = useMemo(() => { - if (activeHunk) return activeHunk; - if (!overlayKey) return null; - if (pendingHunkKeys.has(overlayKey)) return lastActiveHunkRef.current; - if (overlayPinnedHunkKey === overlayKey) return lastActiveHunkRef.current; - return null; - }, [activeHunk, overlayKey, pendingHunkKeys, overlayPinnedHunkKey]); - - useEffect(() => { - const now = Date.now(); - let changed = false; - - for (const [id, entry] of uiPendingRef.current.entries()) { - if (now >= entry.deadlineMs) { - uiPendingRef.current.delete(id); - changed = true; - continue; - } - - const parts = id.split(':'); - if (parts.length < 4) continue; - const filePath = parts[0]!; - const sig = parts[1]!; - const oldStart = Number(parts[2]!); - const newStart = Number(parts[3]!); - - const found = - allHunks.find( - (h) => h.filePath === filePath && h.sig === sig && h.oldStart === oldStart && h.newStart === newStart - ) ?? null; - - if (entry.kind === 'gone') { - if (!found) { - uiPendingRef.current.delete(id); - changed = true; - } - continue; - } - - if (found && !found.isUntracked && found.isStaged === entry.expectedStaged) { - uiPendingRef.current.delete(id); - changed = true; - } - } - - if (changed) setUiPendingVersion((v) => v + 1); - }, [allHunks, uiPendingVersion]); - - const updateOverlayPosition = useCallback(() => { - const scroller = scrollContainerRef.current; - if (!scroller || !overlayKey) { - setOverlayVisible(false); - return; - } - - const keepVisibleWhilePending = pendingHunkKeys.has(overlayKey) || overlayPinnedHunkKey === overlayKey; - const anchor = scroller.querySelector(`[data-hunk-key="${overlayKey}"][data-hunk-anchor="true"]`) as HTMLElement | null; - if (!anchor) { - if (!keepVisibleWhilePending) setOverlayVisible(false); - return; - } - - const scrollerRect = scroller.getBoundingClientRect(); - const anchorRect = anchor.getBoundingClientRect(); - const rawTop = anchorRect.top - scrollerRect.top; - if (rawTop < 0 || rawTop > scrollerRect.height) { - if (!keepVisibleWhilePending) setOverlayVisible(false); - return; - } - const clamped = Math.max(24, Math.min(rawTop, scrollerRect.height - 24)); - setOverlayTopPx(clamped); - setOverlayVisible(true); - }, [overlayKey, pendingHunkKeys, overlayPinnedHunkKey]); - - const computeTopMostVisibleHunkIdx = useCallback((): number => { - const root = scrollContainerRef.current; - if (!root) return -1; - if (allHunks.length === 0) return -1; - - const rootRect = root.getBoundingClientRect(); - let bestIdx = -1; - let bestTop = Infinity; - - for (let i = 0; i < allHunks.length; i++) { - const key = allHunks[i]!.hunkKey; - const el = root.querySelector(`[data-hunk-key="${key}"]`) as HTMLElement | null; - if (!el) continue; - // Treat the "hunk position" as the first changed row (Zed's hunk ranges are based on changed rows). - const hunkRoot = el.closest('.diff-hunk') as HTMLElement | null; - let targetEl: HTMLElement = el; - if (hunkRoot) { - const rows = Array.from(hunkRoot.querySelectorAll('tr.diff-line')) as HTMLElement[]; - const firstChangedRow = rows.find((row) => Boolean(row.querySelector('.diff-code-insert, .diff-code-delete'))); - if (firstChangedRow) targetEl = firstChangedRow; - } - const rect = targetEl.getBoundingClientRect(); - if (rect.bottom <= rootRect.top || rect.top >= rootRect.bottom) continue; - const top = rect.top - rootRect.top; - if (top < bestTop) { - bestTop = top; - bestIdx = i; - } - } - - return bestIdx; - }, [allHunks]); - - useEffect(() => { - const root = scrollContainerRef.current; - if (!root) return; - - let raf = 0; - const onScroll = () => { - if (raf) cancelAnimationFrame(raf); - raf = requestAnimationFrame(() => { - const idx = computeTopMostVisibleHunkIdx(); - if (idx >= 0) setCurrentHunkIdx(idx); - updateOverlayPosition(); - }); - }; - - onScroll(); - root.addEventListener('scroll', onScroll, { passive: true }); - return () => { - if (raf) cancelAnimationFrame(raf); - root.removeEventListener('scroll', onScroll); - }; - }, [computeTopMostVisibleHunkIdx, updateOverlayPosition]); - - useEffect(() => { - const root = scrollContainerRef.current; - if (!root) return; - - // Track which file horizontal scrollers are visible; sync only those to avoid jank on large diffs. - const io = new IntersectionObserver( - (entries) => { - let changed = false; - for (const entry of entries) { - const el = entry.target as HTMLElement; - const path = el.dataset.diffFilePath; - if (!path) continue; - if (entry.isIntersecting) { - if (!visibleHScrollersRef.current.has(path)) { - visibleHScrollersRef.current.add(path); - changed = true; - } - } else { - if (visibleHScrollersRef.current.delete(path)) changed = true; - } - } - if (changed) scheduleHScrollSync(hscrollLeftRef.current, '__xbar'); - }, - { root, threshold: 0.1 } - ); - - const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => scheduleXScrollbarWidthUpdate()) : null; - - for (const el of hscrollRefs.current.values()) { - io.observe(el); - ro?.observe(el); - } - - scheduleXScrollbarWidthUpdate(); - scheduleXbarThumbUpdate(); - - // Horizontal wheel gestures anywhere in the diff should scroll all files in sync. - const onWheel = (e: WheelEvent) => { - if (!e.deltaX) return; - if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; - e.preventDefault(); - const xbar = xScrollbarRef.current; - const max = - xbar && xbar.clientWidth > 0 ? Math.max(0, xbar.scrollWidth - xbar.clientWidth) : Infinity; - const next = Math.max(0, Math.min(max, hscrollLeftRef.current + e.deltaX)); - scheduleHScrollSync(next); - }; - - root.addEventListener('wheel', onWheel, { passive: false, capture: true }); - return () => { - root.removeEventListener('wheel', onWheel as any, true); - ro?.disconnect(); - io.disconnect(); - }; - }, [scheduleHScrollSync, scheduleXScrollbarWidthUpdate, scheduleXbarThumbUpdate, files.length]); - - useEffect(() => { - scheduleXbarThumbUpdate(); - }, [xScrollbarContentWidth, scheduleXbarThumbUpdate]); - - useEffect(() => { - const onPointerMove = (e: PointerEvent) => { - const drag = xbarDragRef.current; - if (!drag) return; - if (e.pointerId !== drag.pointerId) return; - e.preventDefault(); - - const xbar = xScrollbarRef.current; - if (!xbar) return; - - const trackUsable = Math.max(1, drag.trackWidthPx - drag.thumbWidthPx); - const deltaPx = e.clientX - drag.startClientX; - const deltaScroll = (deltaPx / trackUsable) * drag.maxScrollLeft; - const next = Math.max(0, Math.min(drag.maxScrollLeft, drag.startScrollLeft + deltaScroll)); - xbar.scrollLeft = next; - scheduleHScrollSync(next, '__xbar'); - scheduleXbarThumbUpdate(); - }; - - const onPointerUp = (e: PointerEvent) => { - const drag = xbarDragRef.current; - if (!drag) return; - if (e.pointerId !== drag.pointerId) return; - xbarDragRef.current = null; - }; - - window.addEventListener('pointermove', onPointerMove, { passive: false }); - window.addEventListener('pointerup', onPointerUp); - window.addEventListener('pointercancel', onPointerUp); - return () => { - window.removeEventListener('pointermove', onPointerMove as any); - window.removeEventListener('pointerup', onPointerUp as any); - window.removeEventListener('pointercancel', onPointerUp as any); - }; - }, [scheduleHScrollSync, scheduleXbarThumbUpdate]); - - useEffect(() => { - const root = containerRef.current; - if (!root) return; - - const onMouseMove = (e: MouseEvent) => { - const target = e.target as Element | null; - if (!target) return; - - // Keep hover state stable when moving from the hunk body into the floating controls. - const anchorEl = target.closest('[data-hunk-key]') as HTMLElement | null; - if (anchorEl) { - const next = anchorEl.getAttribute('data-hunk-key') ?? null; - setHoveredHunkKey((prev) => (prev === next ? prev : next)); - return; - } - - const hunkRoot = target.closest('.diff-hunk') as HTMLElement | null; - if (!hunkRoot) return; - const anchor = hunkRoot.querySelector('[data-hunk-key]') as HTMLElement | null; - const next = anchor?.getAttribute('data-hunk-key') ?? null; - setHoveredHunkKey((prev) => (prev === next ? prev : next)); - }; - - const onMouseLeave = () => setHoveredHunkKey(null); - - root.addEventListener('mousemove', onMouseMove, { passive: true }); - root.addEventListener('mouseleave', onMouseLeave); - return () => { - root.removeEventListener('mousemove', onMouseMove); - root.removeEventListener('mouseleave', onMouseLeave); - }; - }, []); - - useEffect(() => { - updateOverlayPosition(); - }, [overlayKey, updateOverlayPosition]); - - useEffect(() => { - onHunkInfo?.(currentHunkIdx + 1, allHunks.length); - }, [currentHunkIdx, allHunks.length, onHunkInfo]); - - useEffect(() => { - if (!focusedHunkSig) return; - const candidates = allHunks - .map((h, idx) => ({ h, idx })) - .filter(({ h }) => h.filePath === focusedHunkSig.filePath && h.sig === focusedHunkSig.sig); - - if (candidates.length === 0) { - setFocusedHunkKey(null); - setFocusedHunkSig(null); - return; - } - - let best = candidates[0]!; - let bestScore = Math.abs(best.h.oldStart - focusedHunkSig.oldStart) + Math.abs(best.h.newStart - focusedHunkSig.newStart); - for (const c of candidates) { - const score = Math.abs(c.h.oldStart - focusedHunkSig.oldStart) + Math.abs(c.h.newStart - focusedHunkSig.newStart); - if (score < bestScore) { - best = c; - bestScore = score; - } - } - - const idx = best.idx; - const key = best.h.hunkKey ?? null; - if (key && key !== focusedHunkKey) setFocusedHunkKey(key); - if (idx !== currentHunkIdx) setCurrentHunkIdx(idx); - }, [allHunks, focusedHunkSig, focusedHunkKey, currentHunkIdx]); - - const navigateToHunk = useCallback((direction: 'prev' | 'next') => { - if (allHunks.length === 0) return; - const currentIdx = (() => { - if (focusedHunkSig) { - const candidates = allHunks - .map((h, idx) => ({ h, idx })) - .filter(({ h }) => h.filePath === focusedHunkSig.filePath && h.sig === focusedHunkSig.sig); - if (candidates.length > 0) { - let best = candidates[0]!; - let bestScore = Math.abs(best.h.oldStart - focusedHunkSig.oldStart) + Math.abs(best.h.newStart - focusedHunkSig.newStart); - for (const c of candidates) { - const score = Math.abs(c.h.oldStart - focusedHunkSig.oldStart) + Math.abs(c.h.newStart - focusedHunkSig.newStart); - if (score < bestScore) { - best = c; - bestScore = score; - } - } - return best.idx; - } - } - const visible = computeTopMostVisibleHunkIdx(); - return visible >= 0 ? visible : Math.max(0, Math.min(currentHunkIdx, allHunks.length - 1)); - })(); - - // Zed's behavior: wrap-around navigation. - const newIdx = direction === 'next' - ? (currentIdx >= allHunks.length - 1 ? 0 : currentIdx + 1) - : (currentIdx <= 0 ? allHunks.length - 1 : currentIdx - 1); - - setCurrentHunkIdx(newIdx); - const target = allHunks[newIdx]; - const targetKey = target?.hunkKey; - if (target && target.sig) setFocusedHunkSig({ filePath: target.filePath, sig: target.sig, oldStart: target.oldStart, newStart: target.newStart }); - setFocusedHunkKey(targetKey ?? null); - const scroller = scrollContainerRef.current; - if (targetKey && scroller) { - const el = scroller.querySelector(`[data-hunk-key="${targetKey}"]`) as HTMLElement | null; - if (el) { - const hunkRoot = el.closest('.diff-hunk') as HTMLElement | null; - let targetEl: HTMLElement = el; - if (hunkRoot) { - const rows = Array.from(hunkRoot.querySelectorAll('tr.diff-line')) as HTMLElement[]; - const firstChangedRow = rows.find((row) => Boolean(row.querySelector('.diff-code-insert, .diff-code-delete'))); - if (firstChangedRow) targetEl = firstChangedRow; - } - const scrollerRect = scroller.getBoundingClientRect(); - const elRect = targetEl.getBoundingClientRect(); - // Center the hunk controls anchor (similar to Zed's Autoscroll::center()). - const top = scroller.scrollTop + (elRect.top - scrollerRect.top) - (scroller.clientHeight / 2) + (elRect.height / 2); - const clamped = Math.max(0, Math.min(top, scroller.scrollHeight - scroller.clientHeight)); - scroller.scrollTo({ top: clamped, behavior: 'smooth' }); - } - } - }, [allHunks, focusedHunkSig, computeTopMostVisibleHunkIdx, currentHunkIdx]); - - const stageAll = useCallback(async (stage: boolean) => { - if (!sessionId) return; - for (const file of files) { - try { - await API.sessions.changeFileStage(sessionId, { filePath: file.path, stage }); - } catch (err) { - console.error(`[Diff] Failed to ${stage ? 'stage' : 'unstage'} file`, { filePath: file.path, err }); - } - } - onChanged?.(); - }, [sessionId, files, onChanged]); - - useImperativeHandle(ref, () => ({ - navigateToHunk, - stageAll, - }), [navigateToHunk, stageAll]); - - useEffect(() => { - const root = scrollContainerRef.current; - if (!root) return; - const observer = new IntersectionObserver( - (entries) => { - for (const entry of entries) { - if (entry.isIntersecting) { - const path = (entry.target as HTMLElement).dataset.diffFilePath; - if (path) { - onVisibleFileChange?.(path); - break; - } - } - } - }, - { root, threshold: 0.3 } - ); - const fileHeaders = root.querySelectorAll('[data-diff-file-path]'); - fileHeaders.forEach((el) => observer.observe(el)); - return () => observer.disconnect(); - }, [files, onVisibleFileChange]); - - // Apply status classes directly to tbody.diff-hunk elements based on the anchor inside - // Also mark first/last changed rows for proper border rendering - useEffect(() => { - const root = containerRef.current; - if (!root) return; - - // Find all diff-hunk tbodies and update their status class - const hunks = root.querySelectorAll('tbody.diff-hunk'); - for (const hunk of hunks) { - const anchor = hunk.querySelector('.st-diff-hunk-actions-anchor'); - const isStaged = anchor?.classList.contains('st-hunk-status--staged'); - hunk.classList.remove('st-hunk-status--staged', 'st-hunk-status--unstaged'); - hunk.classList.add(isStaged ? 'st-hunk-status--staged' : 'st-hunk-status--unstaged'); - - // Mark first and last changed rows for border caps - const changedRows = hunk.querySelectorAll('tr.diff-line:has(.diff-code-insert, .diff-code-delete)'); - const allRows = hunk.querySelectorAll('tr.diff-line'); - allRows.forEach((row) => { - row.classList.remove('st-hunk-row-first', 'st-hunk-row-last'); - }); - if (changedRows.length > 0) { - changedRows[0]?.classList.add('st-hunk-row-first'); - changedRows[changedRows.length - 1]?.classList.add('st-hunk-row-last'); - } - } - }, [files, stagedDiff, unstagedDiff]); - - if (!diff || diff.trim() === '' || files.length === 0) { - return
No changes
; - } - - return ( -
-
-
- {files.map((file) => { - const previewContent = previewSources?.[file.path]; - const canPreview = Boolean(previewContent) && isPreviewableFile(file.path); - const isPreviewing = canPreview && previewFiles.has(file.path); - - return ( -
-
{ - if (!el) { - fileHeaderRefs.current.delete(file.path); - return; - } - fileHeaderRefs.current.set(file.path, el); - }} - className="px-3 py-2 text-xs font-semibold st-diff-file-header" - style={{ - backgroundColor: 'var(--st-surface)', - borderBottom: '1px solid var(--st-border-variant)', - }} - onMouseEnter={() => setHoveredHunkKey(null)} - > -
- {file.path} -
- {canPreview && ( - - )} - {!isCommitView && (() => { - const hasStaged = stagedHunkHeaderBySig.has(file.path); - const hasUnstaged = unstagedHunkHeaderBySig.has(file.path); - const isFullyStaged = hasStaged && !hasUnstaged; - const isFullyUnstaged = !hasStaged && hasUnstaged; - return ( - <> - {!isFullyStaged && ( - - )} - {!isFullyUnstaged && hasStaged && ( - - )} - - - ); - })()} -
-
-
- -
- {isPreviewing && previewContent ? ( - isImageFile(file.path) ? ( - - ) : ( - - ) - ) : file.hunks.length === 0 ? ( -
- {isImageFile(file.path) ? 'Binary file (image)' : 'Binary file'} -
- ) : (isBinaryFile(file.path) && !isImageFile(file.path)) ? ( -
- Binary file -
- ) : ( -
{ - if (!el) { - hscrollRefs.current.delete(file.path); - visibleHScrollersRef.current.delete(file.path); - scheduleXScrollbarWidthUpdate(); - return; - } - hscrollRefs.current.set(file.path, el); - // Align newly mounted scrollers with current global horizontal position. - if (Math.abs(el.scrollLeft - hscrollLeftRef.current) > 0.5) el.scrollLeft = hscrollLeftRef.current; - scheduleXScrollbarWidthUpdate(); - }} - onScroll={(e) => { - const el = e.currentTarget; - if (hscrollSyncingRef.current) return; - scheduleHScrollSync(el.scrollLeft, file.path); - }} - > - { - const changes = hunk.changes as ChangeData[]; - const first = changes[0]; - if (!first) return null; - - const sig = (hunk as any).__st_hunkSig as string; - const hasEdits = Boolean(sig && sig.length > 0); - if (!hasEdits) return null; - - const stagedEntries = stagedHunkHeaderBySig.get(file.path); - const unstagedEntries = unstagedHunkHeaderBySig.get(file.path); - const stagedHeader = findMatchingHeader(stagedEntries, sig, hunk.oldStart, hunk.newStart); - const unstagedHeader = findMatchingHeader(unstagedEntries, sig, hunk.oldStart, hunk.newStart); - - const hunkStatus: 'staged' | 'unstaged' | 'untracked' = - stagedHeader ? 'staged' : unstagedHeader ? 'unstaged' : 'untracked'; - const statusClass = - hunkStatus === 'staged' - ? 'st-hunk-status--staged' - : hunkStatus === 'unstaged' || hunkStatus === 'untracked' - ? 'st-hunk-status--unstaged' - : ''; - - const kind = hunkKind(hunk); - const kindClass = - kind === 'added' - ? 'st-hunk-kind--added' - : kind === 'deleted' - ? 'st-hunk-kind--deleted' - : 'st-hunk-kind--modified'; - const hunkKey = (hunk as any).__st_hunkKey as string; - const isFocused = - focusedHunkKey === hunkKey || - (focusedHunkSig != null && - focusedHunkSig.filePath === file.path && - focusedHunkSig.sig === sig && - (Math.abs(focusedHunkSig.oldStart - hunk.oldStart) + Math.abs(focusedHunkSig.newStart - hunk.newStart) <= 4)); - const isHovered = hoveredHunkKey === hunkKey; - - // Anchor controls near the hunk start (Zed places controls at the start of the changed range, - // not on surrounding context lines). `react-diff-view` widgets render *after* the keyed line, - // so we prefer the line *before* the first changed line when available (i.e. the last context - // line right above the hunk), otherwise fall back to the first changed line. - const firstChangedIdx = changes.findIndex((c) => c.type === 'insert' || c.type === 'delete'); - const anchorChange = - firstChangedIdx > 0 - ? changes[firstChangedIdx - 1]! - : firstChangedIdx === 0 - ? changes[0]! - : first; - const changeKey = getChangeKey(anchorChange); - - const anchorElement: React.ReactElement | null = isCommitView ? null : ( -
-
- ); - - return [[changeKey, anchorElement] as const]; - }) - .filter((e): e is readonly [string, React.ReactElement | null] => e !== null) - ) as Record} - > - {(hunks) => hunks.map((hunk, index) => ( - - {index > 0 && ( - - - - )} - - - ))} -
-
- )} -
-
- ); - })} -
- - {!isCommitView && ( -
-
{ - scheduleXbarThumbUpdate(); - const el = e.currentTarget; - if (hscrollSyncingRef.current) return; - scheduleHScrollSync(el.scrollLeft, '__xbar'); - }} - > -
-
- -
{ - const xbar = xScrollbarRef.current; - const thumb = xScrollbarThumbRef.current; - const track = xScrollbarTrackRef.current; - if (!xbar || !thumb || !track) return; - - const clientWidth = xbar.clientWidth; - const scrollWidth = xbar.scrollWidth; - const max = Math.max(0, scrollWidth - clientWidth); - if (max <= 0) return; - - const rect = track.getBoundingClientRect(); - const clickX = e.clientX - rect.left; - const thumbWidth = thumb.getBoundingClientRect().width || 28; - const usable = Math.max(1, rect.width - thumbWidth); - const desiredLeft = Math.max(0, Math.min(usable, clickX - thumbWidth / 2)); - const next = (desiredLeft / usable) * max; - xbar.scrollLeft = next; - scheduleHScrollSync(next, '__xbar'); - scheduleXbarThumbUpdate(); - }} - > -
{ - const xbar = xScrollbarRef.current; - const track = xScrollbarTrackRef.current; - const thumb = xScrollbarThumbRef.current; - if (!xbar || !track || !thumb) return; - - const max = Math.max(0, xbar.scrollWidth - xbar.clientWidth); - if (max <= 0) return; - - const trackWidthPx = track.getBoundingClientRect().width; - const thumbWidthPx = thumb.getBoundingClientRect().width || 28; - xbarDragRef.current = { - pointerId: e.pointerId, - startClientX: e.clientX, - startScrollLeft: xbar.scrollLeft, - thumbWidthPx, - maxScrollLeft: max, - trackWidthPx, - }; - (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); - e.preventDefault(); - e.stopPropagation(); - }} - /> -
-
- )} - - {!isCommitView && ( -
- {overlayHunk && (() => { - const file = fileByPath.get(overlayHunk.filePath); - if (!file) return null; - const hunkStatus: 'staged' | 'unstaged' | 'untracked' = - overlayHunk.isUntracked ? 'untracked' : overlayHunk.isStaged ? 'staged' : 'unstaged'; - const stageLabel = hunkStatus === 'staged' ? 'Unstage' : 'Stage'; - const canStageOrUnstage = Boolean(sessionId); - const canRestore = Boolean(sessionId && (hunkStatus === 'staged' || hunkStatus === 'unstaged')); - const hunkKey = overlayHunk.hunkKey; - const isPending = pendingHunkKeys.has(hunkKey); - - return ( -
-
- {(() => { - const stable = stableHunkId({ - filePath: overlayHunk.filePath, - sig: overlayHunk.sig, - oldStart: overlayHunk.oldStart, - newStart: overlayHunk.newStart, - }); - const uiPending = uiPendingRef.current.get(stable) ?? null; - const visualPending = - isPending || - (uiPending != null && - (uiPending.kind === 'gone' || - (uiPending.kind === 'status' && uiPending.expectedStaged !== overlayHunk.isStaged))); - const disabled = !canStageOrUnstage || visualPending; - - return ( - - ); - })()} - - {(hunkStatus === 'staged' || hunkStatus === 'unstaged') && ( - - )} -
-
- ); - })()} -
- )} -
- - -
- ); -}); - -ZedDiffViewer.displayName = 'ZedDiffViewer'; - -export default ZedDiffViewer; diff --git a/packages/ui/src/components/panels/diff/useFileContent.ts b/packages/ui/src/components/panels/diff/useFileContent.ts index fcfd7c5b..0653351a 100644 --- a/packages/ui/src/components/panels/diff/useFileContent.ts +++ b/packages/ui/src/components/panels/diff/useFileContent.ts @@ -18,7 +18,7 @@ interface UseFileContentResult { /** * Hook to load file content from the file system. - * Used by both InlineDiffViewer and ZedDiffViewer for markdown/image preview. + * Used by InlineDiffViewer for markdown/image preview. */ export function useFileContent({ sessionId, diff --git a/packages/ui/src/components/panels/diff/useVisibleHunkTracker.test.ts b/packages/ui/src/components/panels/diff/useVisibleHunkTracker.test.ts deleted file mode 100644 index 04f70430..00000000 --- a/packages/ui/src/components/panels/diff/useVisibleHunkTracker.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { findTopMostVisibleHunk } from './useVisibleHunkTracker'; - -describe('findTopMostVisibleHunk', () => { - const createEntry = ( - hunkKey: string, - top: number, - isIntersecting: boolean - ): IntersectionObserverEntry => ({ - target: { dataset: { hunkKey } } as unknown as Element, - boundingClientRect: { top } as DOMRectReadOnly, - isIntersecting, - intersectionRatio: isIntersecting ? 0.5 : 0, - intersectionRect: {} as DOMRectReadOnly, - rootBounds: null, - time: 0, - }); - - it('returns -1 when no entries are intersecting', () => { - const hunkKeyToIdx = new Map([['h1', 0], ['h2', 1]]); - const entries = [ - createEntry('h1', 100, false), - createEntry('h2', 200, false), - ]; - expect(findTopMostVisibleHunk(entries, hunkKeyToIdx)).toBe(-1); - }); - - it('returns the index of the only intersecting entry', () => { - const hunkKeyToIdx = new Map([['h1', 0], ['h2', 1], ['h3', 2]]); - const entries = [ - createEntry('h1', 100, false), - createEntry('h2', 200, true), - createEntry('h3', 300, false), - ]; - expect(findTopMostVisibleHunk(entries, hunkKeyToIdx)).toBe(1); - }); - - it('returns the index of the topmost intersecting entry', () => { - const hunkKeyToIdx = new Map([['h1', 0], ['h2', 1], ['h3', 2]]); - const entries = [ - createEntry('h1', 300, true), - createEntry('h2', 100, true), - createEntry('h3', 200, true), - ]; - expect(findTopMostVisibleHunk(entries, hunkKeyToIdx)).toBe(1); - }); - - it('ignores entries with unknown hunk keys', () => { - const hunkKeyToIdx = new Map([['h1', 0], ['h2', 1]]); - const entries = [ - createEntry('unknown', 50, true), - createEntry('h2', 200, true), - ]; - expect(findTopMostVisibleHunk(entries, hunkKeyToIdx)).toBe(1); - }); - - it('ignores entries without hunk key', () => { - const hunkKeyToIdx = new Map([['h1', 0]]); - const entries = [ - { ...createEntry('', 50, true), target: { dataset: {} } as unknown as Element }, - createEntry('h1', 200, true), - ]; - expect(findTopMostVisibleHunk(entries, hunkKeyToIdx)).toBe(0); - }); - - it('handles empty entries array', () => { - const hunkKeyToIdx = new Map([['h1', 0]]); - expect(findTopMostVisibleHunk([], hunkKeyToIdx)).toBe(-1); - }); - - it('handles empty hunkKeyToIdx map', () => { - const entries = [createEntry('h1', 100, true)]; - expect(findTopMostVisibleHunk(entries, new Map())).toBe(-1); - }); - - it('selects entry with smallest top value when multiple at same position', () => { - const hunkKeyToIdx = new Map([['h1', 0], ['h2', 1]]); - const entries = [ - createEntry('h1', 100, true), - createEntry('h2', 100, true), - ]; - const result = findTopMostVisibleHunk(entries, hunkKeyToIdx); - expect([0, 1]).toContain(result); - }); -}); diff --git a/packages/ui/src/components/panels/diff/useVisibleHunkTracker.ts b/packages/ui/src/components/panels/diff/useVisibleHunkTracker.ts deleted file mode 100644 index b2d8293f..00000000 --- a/packages/ui/src/components/panels/diff/useVisibleHunkTracker.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useEffect, useRef, useCallback } from 'react'; - -export interface HunkEntry { - hunkKey: string; -} - -export interface UseVisibleHunkTrackerOptions { - hunks: HunkEntry[]; - containerRef: React.RefObject; - onVisibleHunkChange: (index: number) => void; - enabled?: boolean; -} - -export function findTopMostVisibleHunk( - entries: IntersectionObserverEntry[], - hunkKeyToIdx: Map -): number { - let topMostIdx = -1; - let topMostY = Infinity; - - for (const entry of entries) { - if (!entry.isIntersecting) continue; - - const key = (entry.target as HTMLElement).dataset.hunkKey; - if (!key) continue; - - const idx = hunkKeyToIdx.get(key); - if (idx === undefined) continue; - - const rect = entry.boundingClientRect; - if (rect.top < topMostY) { - topMostY = rect.top; - topMostIdx = idx; - } - } - - return topMostIdx; -} - -export function useVisibleHunkTracker({ - hunks, - containerRef, - onVisibleHunkChange, - enabled = true, -}: UseVisibleHunkTrackerOptions): void { - const hunkKeyToIdxRef = useRef>(new Map()); - - useEffect(() => { - hunkKeyToIdxRef.current = new Map(hunks.map((h, i) => [h.hunkKey, i])); - }, [hunks]); - - const handleIntersection = useCallback( - (entries: IntersectionObserverEntry[]) => { - const topMostIdx = findTopMostVisibleHunk(entries, hunkKeyToIdxRef.current); - if (topMostIdx >= 0) { - onVisibleHunkChange(topMostIdx); - } - }, - [onVisibleHunkChange] - ); - - useEffect(() => { - if (!enabled || !containerRef.current || hunks.length === 0) return; - - const observer = new IntersectionObserver(handleIntersection, { - root: containerRef.current, - threshold: 0.1, - }); - - const hunkEls = containerRef.current.querySelectorAll('[data-hunk-key]'); - hunkEls.forEach((el) => observer.observe(el)); - - return () => observer.disconnect(); - }, [enabled, hunks.length, containerRef, handleIntersection]); -} diff --git a/packages/ui/src/components/panels/diff/utils/diffUtils.ts b/packages/ui/src/components/panels/diff/utils/diffUtils.ts index 5d82d6c4..61514b7e 100644 --- a/packages/ui/src/components/panels/diff/utils/diffUtils.ts +++ b/packages/ui/src/components/panels/diff/utils/diffUtils.ts @@ -1,7 +1,3 @@ -import { textLinesToHunk, type ChangeData, type HunkData } from 'react-diff-view'; - -export type HunkKind = 'added' | 'deleted' | 'modified'; - export type HunkHeaderEntry = { sig: string; oldStart: number; @@ -9,99 +5,6 @@ export type HunkHeaderEntry = { header: string; }; -export function toFilePath(raw: { newPath: string; oldPath: string }) { - const newPath = (raw.newPath || '').trim(); - const oldPath = (raw.oldPath || '').trim(); - if (newPath && newPath !== '/dev/null') return newPath; - if (oldPath && oldPath !== '/dev/null') return oldPath; - return '(unknown)'; -} - -export function parseHunkHeader(content: string): { oldStart: number; oldLines: number; newStart: number; newLines: number } | null { - const match = content.match(/@@\s+-([0-9]+)(?:,([0-9]+))?\s+\+([0-9]+)(?:,([0-9]+))?\s+@@/); - if (!match) return null; - const oldStart = parseInt(match[1], 10); - const oldLines = match[2] == null ? 1 : parseInt(match[2], 10); - const newStart = parseInt(match[3], 10); - const newLines = match[4] == null ? 1 : parseInt(match[4], 10); - return { oldStart, oldLines, newStart, newLines }; -} - -export function hunkSignature(hunk: HunkData): string { - const changes = hunk.changes as ChangeData[]; - const parts: string[] = []; - for (const change of changes) { - if ((change as any).isInsert) parts.push(`+${change.content}`); - else if ((change as any).isDelete) parts.push(`-${change.content}`); - } - return parts.join('\n'); -} - -export function hunkKind(hunk: HunkData): HunkKind | null { - const changes = hunk.changes as ChangeData[]; - let hasInsert = false; - let hasDelete = false; - for (const change of changes) { - if ((change as any).isInsert) hasInsert = true; - else if ((change as any).isDelete) hasDelete = true; - } - if (!hasInsert && !hasDelete) return null; - if (hasInsert && hasDelete) return 'modified'; - return hasInsert ? 'added' : 'deleted'; -} - -export function normalizeHunks(hunks: HunkData[]): HunkData[] { - return hunks.map((h) => { - const parsed = parseHunkHeader(h.content); - if (!parsed) return h; - return Object.assign({}, h, { - oldStart: parsed.oldStart, - oldLines: parsed.oldLines, - newStart: parsed.newStart, - newLines: parsed.newLines, - }); - }); -} - -export function expandToFullFile(hunks: HunkData[], source: string): HunkData[] { - const lines = source.split('\n'); - const normalized = normalizeHunks(hunks).slice().sort((a, b) => (a.oldStart - b.oldStart) || (a.newStart - b.newStart)); - if (normalized.length === 0) { - const all = textLinesToHunk(lines, 1, 1); - return all ? [all] : []; - } - - // New file diffs typically use @@ -0,0 +1,N @@ and already contain the full content as insertions. - // Expanding using the worktree content would duplicate the file as "plain context" below the inserted hunk. - if (normalized.some((h) => h.oldStart === 0 && h.oldLines === 0)) { - return normalized; - } - - const output: HunkData[] = []; - let oldCursor = 1; - let delta = 0; // newLine = oldLine + delta for unchanged lines - - const pushPlain = (start: number, endExclusive: number) => { - if (endExclusive <= start) return; - const slice = lines.slice(start - 1, endExclusive - 1); - const h = textLinesToHunk(slice, start, start + delta); - if (h) output.push(h); - }; - - for (const h of normalized) { - const gapEnd = Math.min(Math.max(h.oldStart, 1), lines.length + 1); - pushPlain(oldCursor, gapEnd); - - output.push(h); - - oldCursor = Math.max(oldCursor, h.oldStart + Math.max(0, h.oldLines)); - delta += h.newLines - h.oldLines; - } - - pushPlain(oldCursor, lines.length + 1); - return output; -} - export function findMatchingHeader(entries: HunkHeaderEntry[] | undefined, sig: string, oldStart: number, newStart: number): string | null { if (!entries || entries.length === 0) return null; const candidates = entries.filter((e) => e.sig === sig); diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index e617f3d9..1c0e9afb 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1,6 +1,5 @@ /* Import modular stylesheets first */ @import './styles/themes.css'; -@import './styles/diff.css'; @tailwind base; @tailwind components; diff --git a/packages/ui/src/styles/diff.css b/packages/ui/src/styles/diff.css deleted file mode 100644 index 50cbf519..00000000 --- a/packages/ui/src/styles/diff.css +++ /dev/null @@ -1,29 +0,0 @@ -/* ============================================ - Diff Viewer Styles - For react-diff-view and ZedDiffViewer - ============================================ */ - -/* Diff container focus styles */ -.diff-container:focus-visible { - outline: 2px solid var(--st-diff-focus-ring); - outline-offset: -2px; -} - -/* Diff code typography - use monospace font */ -.diff-code, -.diff-gutter, -.diff-line, -.diff-code-text, -.diff .diff-code, -.diff-gutter-normal, -.diff-gutter-insert, -.diff-gutter-delete, -.diff-code-normal, -.diff-code-insert, -.diff-code-delete, -td.diff-code, -td.diff-gutter { - font-family: var(--st-font-mono) !important; - font-size: var(--st-font-sm) !important; - line-height: 1.6 !important; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd83a469..052805dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,9 +178,6 @@ importers: react: specifier: ^19.0.0 version: 19.1.0 - react-diff-view: - specifier: ^3.3.2 - version: 3.3.2(react@19.1.0) react-dom: specifier: ^19.0.0 version: 19.1.0(react@19.1.0) @@ -2242,9 +2239,6 @@ packages: resolution: {integrity: sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==} engines: {node: '>=8'} - classnames@2.5.1: - resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} - clean-stack@2.2.0: resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} engines: {node: '>=6'} @@ -2435,9 +2429,6 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} - diff-match-patch@1.0.5: - resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} - diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -2814,9 +2805,6 @@ packages: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} - gitdiff-parser@0.3.1: - resolution: {integrity: sha512-YQJnY8aew65id8okGxKCksH3efDCJ9HzV7M9rsvd65habf39Pkh4cgYJ27AaoDMqo1X98pgNJhNMrm/kpV7UVQ==} - github-from-package@0.0.0: resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} @@ -3215,10 +3203,6 @@ packages: longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - loupe@3.1.4: resolution: {integrity: sha512-wJzkKwJrheKtknCOKNEtDK4iqg/MxmZheEMtSTYvnzRdEYaZzmgH976nenp8WdJRdx5Vc1X/9MO0Oszl6ezeXg==} @@ -3955,11 +3939,6 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true - react-diff-view@3.3.2: - resolution: {integrity: sha512-wPVq4ktTcGOHbhnWKU/gHLtd3N2Xd+OZ/XQWcKA06dsxlSsESePAumQILwHtiak2nMCMiWcIfBpqZ5OiharUPA==} - peerDependencies: - react: '>=16.14.0' - react-dom@19.1.0: resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} peerDependencies: @@ -4166,9 +4145,6 @@ packages: resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} engines: {node: '>=10'} - shallow-equal@3.1.0: - resolution: {integrity: sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==} - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -4712,9 +4688,6 @@ packages: engines: {node: '>=20.0.0'} hasBin: true - warning@4.0.3: - resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} - wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -6894,8 +6867,6 @@ snapshots: ci-info@4.3.0: {} - classnames@2.5.1: {} - clean-stack@2.2.0: {} cli-cursor@3.1.0: @@ -7062,8 +7033,6 @@ snapshots: didyoumean@1.2.2: {} - diff-match-patch@1.0.5: {} - diff@8.0.3: {} dir-compare@4.2.0: @@ -7532,8 +7501,6 @@ snapshots: dependencies: pump: 3.0.3 - gitdiff-parser@0.3.1: {} - github-from-package@0.0.0: {} glob-parent@5.1.2: @@ -7997,10 +7964,6 @@ snapshots: longest-streak@3.1.0: {} - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - loupe@3.1.4: {} lowercase-keys@2.0.0: {} @@ -8980,16 +8943,6 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-diff-view@3.3.2(react@19.1.0): - dependencies: - classnames: 2.5.1 - diff-match-patch: 1.0.5 - gitdiff-parser: 0.3.1 - lodash: 4.17.21 - react: 19.1.0 - shallow-equal: 3.1.0 - warning: 4.0.3 - react-dom@19.1.0(react@19.1.0): dependencies: react: 19.1.0 @@ -9237,8 +9190,6 @@ snapshots: type-fest: 0.13.1 optional: true - shallow-equal@3.1.0: {} - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -9803,10 +9754,6 @@ snapshots: transitivePeerDependencies: - debug - warning@4.0.3: - dependencies: - loose-envify: 1.4.0 - wcwidth@1.0.1: dependencies: defaults: 1.0.4