From d55ffbd17e9d038dcfef0df80fa5eda8997c70c4 Mon Sep 17 00:00:00 2001 From: Shpetim Date: Sun, 31 May 2026 15:50:16 +0200 Subject: [PATCH 1/4] update to use code viewer component instead --- apps/desktop/package.json | 2 +- .../src/features/diff-view/DiffWorkspace.tsx | 18 +- .../components/DiagnosticTokenPopover.tsx | 205 +---- .../components/DiffHeaderMetadataControls.tsx | 4 +- .../components/DiffScrollbarMarkers.tsx | 178 +++++ .../diff-view/components/DiffViewer.tsx | 37 +- .../hooks/useDiffCommentAnnotations.tsx | 49 +- .../diff-view/hooks/useDiffDiagnostics.ts | 31 - .../hooks/useMultiDiffCodeViewOptions.ts | 429 ++++++++++ .../hooks/useMultiDiffDiagnostics.ts | 133 +++ .../src/features/diff-view/util/lsp_token.ts | 79 ++ .../lsp/components/LspStatusNotice.tsx | 147 +++- .../lsp/components/LspSymbolPeek.test.tsx | 110 ++- .../features/lsp/components/LspSymbolPeek.tsx | 208 ++--- .../lsp/hooks/useCurrentLspDocument.ts | 50 +- .../features/lsp/hooks/useDiffDiagnostics.ts | 15 - .../PullRequestCodeViewDiffPane.tsx | 695 ++++++++++++++++ .../components/PullRequestWindowedDiff.tsx | 1 - .../screens/PullRequestFiles.tsx | 134 +--- .../screens/PullRequestReviewFilesScreen.tsx | 129 +-- .../components/ChangesCodeViewDiffPane.tsx | 756 ++++++++++++++++++ .../components/GeneralFileViewer.tsx | 21 +- .../source-control/screens/ChangesScreen.tsx | 133 +-- package.json | 3 + patches/@pierre__diffs@1.2.4.patch | 21 + pnpm-lock.yaml | 408 ++++++---- pnpm-workspace.yaml | 3 + 27 files changed, 3087 insertions(+), 912 deletions(-) create mode 100644 apps/desktop/src/features/diff-view/components/DiffScrollbarMarkers.tsx delete mode 100644 apps/desktop/src/features/diff-view/hooks/useDiffDiagnostics.ts create mode 100644 apps/desktop/src/features/diff-view/hooks/useMultiDiffCodeViewOptions.ts create mode 100644 apps/desktop/src/features/diff-view/hooks/useMultiDiffDiagnostics.ts delete mode 100644 apps/desktop/src/features/lsp/hooks/useDiffDiagnostics.ts create mode 100644 apps/desktop/src/features/pull-requests/components/PullRequestCodeViewDiffPane.tsx create mode 100644 apps/desktop/src/features/source-control/components/ChangesCodeViewDiffPane.tsx create mode 100644 patches/@pierre__diffs@1.2.4.patch diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 4e82390..61f422d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -29,7 +29,7 @@ "@base-ui/react": "^1.1.0", "@hookform/resolvers": "^5.2.2", "@m2d/react-markdown": "^1.0.0", - "@pierre/diffs": "1.1.15", + "@pierre/diffs": "1.2.4", "@pierre/trees": "1.0.0-beta.3", "@reduxjs/toolkit": "^2.9.0", "@tanstack/react-hotkeys": "^0.1.0", diff --git a/apps/desktop/src/features/diff-view/DiffWorkspace.tsx b/apps/desktop/src/features/diff-view/DiffWorkspace.tsx index f5d524f..ed4674f 100644 --- a/apps/desktop/src/features/diff-view/DiffWorkspace.tsx +++ b/apps/desktop/src/features/diff-view/DiffWorkspace.tsx @@ -28,7 +28,7 @@ import type { } from "@/features/source-control/hunkOperations"; import { DiffViewer, type DiffViewerHandle } from "@/features/diff-view/components/DiffViewer"; import { useDiffCommentAnnotations } from "@/features/diff-view/hooks/useDiffCommentAnnotations"; -import { useDiffDiagnostics } from "@/features/diff-view/hooks/useDiffDiagnostics"; +import { useMultiDiffDiagnostics } from "@/features/diff-view/hooks/useMultiDiffDiagnostics"; import { useDiffAnnotationRenderer } from "@/features/diff-view/hooks/useDiffAnnotationRenderer"; import { type DiffLineAnnotation, type FileDiffOptions } from "@pierre/diffs"; @@ -54,6 +54,8 @@ type Props = { onHunkAction?: (operation: DiffHunkOperation, payload: DiffHunkActionPayload) => void; }; +const SINGLE_DIFF_ITEM_ID = "single-diff"; + function buildReturnToDiffTarget( jumpContextKind: "changes" | "review" | "pull-request", source: { lineNumber: number; lineIndex: string | null }, @@ -155,7 +157,11 @@ export function DiffWorkspace({ getReturnToDiffTarget, }); - const diagnostics = useDiffDiagnostics(lspDiagnostics); + const diagnosticsByItem = useMemo( + () => new Map([[SINGLE_DIFF_ITEM_ID, lspDiagnostics]]), + [lspDiagnostics], + ); + const diagnostics = useMultiDiffDiagnostics(diagnosticsByItem); const comments = useDiffCommentAnnotations({ activePath, @@ -245,15 +251,19 @@ export function DiffWorkspace({ enableLineSelection: canComment, enableGutterUtility: canComment, onTokenClick: handleTokenClick, - onTokenEnter: diagnostics.onTokenEnter, + onTokenEnter: (props) => diagnostics.onTokenEnter(SINGLE_DIFF_ITEM_ID, props), onTokenLeave: diagnostics.onTokenLeave, onLineSelected: canComment ? comments.onLineSelected : undefined, + onLineSelectionStart: canComment ? comments.onLineSelectionStart : undefined, + onLineSelectionChange: canComment ? comments.onLineSelectionChange : undefined, onLineSelectionEnd: canComment ? comments.onLineSelectionEnd : undefined, - onPostRender: diagnostics.onPostRender, + onPostRender: (rootNode) => diagnostics.onPostRender(SINGLE_DIFF_ITEM_ID, rootNode), }), [ canComment, comments.onLineSelected, + comments.onLineSelectionStart, + comments.onLineSelectionChange, comments.onLineSelectionEnd, diagnostics.onPostRender, diagnostics.onTokenEnter, diff --git a/apps/desktop/src/features/diff-view/components/DiagnosticTokenPopover.tsx b/apps/desktop/src/features/diff-view/components/DiagnosticTokenPopover.tsx index 4e3cd41..e6d62c0 100644 --- a/apps/desktop/src/features/diff-view/components/DiagnosticTokenPopover.tsx +++ b/apps/desktop/src/features/diff-view/components/DiagnosticTokenPopover.tsx @@ -1,217 +1,16 @@ -import { useCallback, useRef, useState } from "react"; -import type { DiffTokenEventBaseProps } from "@pierre/diffs"; import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; import type { LspDiagnostic } from "@/features/source-control/types"; - -type AnchorRect = { - top: number; - left: number; - width: number; - height: number; -}; +import type { DiagnosticPopoverAnchorRect } from "@/features/diff-view/util/lsp_token"; type Props = { open: boolean; - anchorRect: AnchorRect | null; + anchorRect: DiagnosticPopoverAnchorRect | null; diagnostics: LspDiagnostic[]; onClose: () => void; onPointerEnter: () => void; onPointerLeave: () => void; }; -const DIAGNOSTIC_SEVERITY_PRIORITY: Record = { - error: 4, - warning: 3, - information: 2, - hint: 1, -}; - -function tokenCanRenderDiagnostic(token: HTMLElement): boolean { - const lineElement = token.closest("[data-line]"); - if (!lineElement) { - return false; - } - - const lineType = lineElement.getAttribute("data-line-type"); - if (lineType === "change-deletion") { - return false; - } - - if (token.closest("[data-additions]")) { - return true; - } - - if (token.closest("[data-deletions]")) { - return false; - } - - return true; -} - -function getTokenLineNumber(token: HTMLElement): number | null { - const lineElement = token.closest("[data-line]"); - if (!lineElement) { - return null; - } - - const value = Number.parseInt(lineElement.getAttribute("data-line") ?? "", 10); - return Number.isFinite(value) ? value : null; -} - -function getTokenCharRange(token: HTMLElement): { start: number; end: number } | null { - const startValue = Number.parseInt(token.getAttribute("data-char") ?? "", 10); - if (!Number.isFinite(startValue)) { - return null; - } - - const tokenText = token.textContent ?? ""; - const start = startValue + 1; - const end = start + tokenText.length; - return { start, end }; -} - -function tokenOverlapsDiagnostic( - lineNumber: number, - tokenStart: number, - tokenEnd: number, - diagnostic: LspDiagnostic, -): boolean { - if (lineNumber < diagnostic.startLine || lineNumber > diagnostic.endLine) { - return false; - } - - const rangeStart = lineNumber === diagnostic.startLine ? diagnostic.startCharacter : 1; - const rangeEndRaw = - lineNumber === diagnostic.endLine ? diagnostic.endCharacter : Number.MAX_SAFE_INTEGER; - const rangeEnd = Math.max(rangeEndRaw, rangeStart + 1); - return tokenStart < rangeEnd && tokenEnd > rangeStart; -} - -function findDiagnosticsForToken( - token: HTMLElement, - diagnosticsByLine: Map, -): LspDiagnostic[] { - if (!tokenCanRenderDiagnostic(token)) { - return []; - } - - const lineNumber = getTokenLineNumber(token); - if (!lineNumber) { - return []; - } - - const diagnostics = diagnosticsByLine.get(lineNumber); - if (!diagnostics || diagnostics.length === 0) { - return []; - } - - const charRange = getTokenCharRange(token); - if (!charRange) { - return []; - } - - const matches = diagnostics.filter((diagnostic) => - tokenOverlapsDiagnostic(lineNumber, charRange.start, charRange.end, diagnostic), - ); - return matches.toSorted( - (left, right) => - DIAGNOSTIC_SEVERITY_PRIORITY[right.severity] - DIAGNOSTIC_SEVERITY_PRIORITY[left.severity], - ); -} - -function readAnchorRect(tokenElement: HTMLElement): AnchorRect { - const rect = tokenElement.getBoundingClientRect(); - return { - top: rect.top, - left: rect.left, - width: rect.width, - height: rect.height, - }; -} - -export function useDiagnosticTokenPopover(diagnosticsByLine: Map) { - const diagnosticCloseTimerRef = useRef | null>(null); - const isDiagnosticPopoverHoveredRef = useRef(false); - const [state, setState] = useState<{ - open: boolean; - diagnostics: LspDiagnostic[]; - anchorRect: AnchorRect | null; - }>({ - open: false, - diagnostics: [], - anchorRect: null, - }); - - const closePopover = useCallback(() => { - if (diagnosticCloseTimerRef.current) { - clearTimeout(diagnosticCloseTimerRef.current); - diagnosticCloseTimerRef.current = null; - } - - setState({ - open: false, - diagnostics: [], - anchorRect: null, - }); - }, []); - - const onTokenEnter = useCallback( - (props: DiffTokenEventBaseProps) => { - if (diagnosticCloseTimerRef.current) { - clearTimeout(diagnosticCloseTimerRef.current); - diagnosticCloseTimerRef.current = null; - } - - const diagnostics = findDiagnosticsForToken(props.tokenElement, diagnosticsByLine); - if (diagnostics.length === 0) { - closePopover(); - return; - } - - setState({ - open: true, - diagnostics, - anchorRect: readAnchorRect(props.tokenElement), - }); - }, - [closePopover, diagnosticsByLine], - ); - - const onTokenLeave = useCallback(() => { - if (diagnosticCloseTimerRef.current) { - clearTimeout(diagnosticCloseTimerRef.current); - } - - diagnosticCloseTimerRef.current = setTimeout(() => { - if (!isDiagnosticPopoverHoveredRef.current) { - closePopover(); - } - }, 120); - }, [closePopover]); - - const onPopoverEnter = useCallback(() => { - isDiagnosticPopoverHoveredRef.current = true; - if (diagnosticCloseTimerRef.current) { - clearTimeout(diagnosticCloseTimerRef.current); - diagnosticCloseTimerRef.current = null; - } - }, []); - - const onPopoverLeave = useCallback(() => { - isDiagnosticPopoverHoveredRef.current = false; - closePopover(); - }, [closePopover]); - - return { - state, - onTokenEnter, - onTokenLeave, - onPopoverEnter, - onPopoverLeave, - closePopover, - }; -} - function diagnosticSeverityBadgeClasses(severity: LspDiagnostic["severity"]) { switch (severity) { case "warning": diff --git a/apps/desktop/src/features/diff-view/components/DiffHeaderMetadataControls.tsx b/apps/desktop/src/features/diff-view/components/DiffHeaderMetadataControls.tsx index 068f660..c1720ee 100644 --- a/apps/desktop/src/features/diff-view/components/DiffHeaderMetadataControls.tsx +++ b/apps/desktop/src/features/diff-view/components/DiffHeaderMetadataControls.tsx @@ -19,6 +19,7 @@ type Props = { commentContext: CommentContext; expandUnchanged: boolean; fileViewerRevision?: string | null; + enableCopyHotkey?: boolean; onToggleExpandUnchanged: () => void; }; @@ -32,6 +33,7 @@ export function DiffHeaderMetadataControls({ commentContext, expandUnchanged, fileViewerRevision, + enableCopyHotkey = true, onToggleExpandUnchanged, }: Props) { const dispatch = useAppDispatch(); @@ -59,7 +61,7 @@ export function DiffHeaderMetadataControls({ void onCopyFileComments(); }, { - enabled: canComment && !!activePath && currentFileComments.length > 0, + enabled: enableCopyHotkey && canComment && !!activePath && currentFileComments.length > 0, }, ); diff --git a/apps/desktop/src/features/diff-view/components/DiffScrollbarMarkers.tsx b/apps/desktop/src/features/diff-view/components/DiffScrollbarMarkers.tsx new file mode 100644 index 0000000..a7a56e4 --- /dev/null +++ b/apps/desktop/src/features/diff-view/components/DiffScrollbarMarkers.tsx @@ -0,0 +1,178 @@ +import { useCallback, type PointerEvent, type RefObject } from "react"; +import type { FileDiffMetadata } from "@pierre/diffs"; + +type DiffStyle = "unified" | "split"; + +export type DiffScrollbarMarker = { + key: string; + type: "addition" | "deletion"; + top: number; + height: number; +}; + +export type MultiDiffScrollbarEntry = { + id: string; + fileDiff: FileDiffMetadata; +}; + +function clampPercent(value: number) { + return Math.min(100, Math.max(0, value)); +} + +export function getDiffTotalLines(fileDiff: FileDiffMetadata, diffStyle: DiffStyle) { + return diffStyle === "split" ? fileDiff.splitLineCount : fileDiff.unifiedLineCount; +} + +export function buildDiffScrollbarMarkers( + fileDiff: FileDiffMetadata, + diffStyle: DiffStyle, +): DiffScrollbarMarker[] { + const totalLines = getDiffTotalLines(fileDiff, diffStyle); + if (totalLines <= 0) return []; + + const markers: DiffScrollbarMarker[] = []; + + fileDiff.hunks.forEach((hunk, hunkIndex) => { + let splitOffset = 0; + let unifiedOffset = 0; + + hunk.hunkContent.forEach((content, contentIndex) => { + if (content.type === "context") { + splitOffset += content.lines; + unifiedOffset += content.lines; + return; + } + + const splitStart = hunk.splitLineStart + splitOffset; + const unifiedStart = hunk.unifiedLineStart + unifiedOffset; + const splitRows = Math.max(content.additions, content.deletions); + + if (content.deletions > 0) { + const start = diffStyle === "split" ? splitStart : unifiedStart; + const rows = diffStyle === "split" ? splitRows : content.deletions; + markers.push({ + key: `${hunkIndex}-${contentIndex}-deletions`, + type: "deletion", + top: clampPercent((start / totalLines) * 100), + height: clampPercent((Math.max(1, rows) / totalLines) * 100), + }); + } + + if (content.additions > 0) { + const start = diffStyle === "split" ? splitStart : unifiedStart + content.deletions; + const rows = diffStyle === "split" ? splitRows : content.additions; + markers.push({ + key: `${hunkIndex}-${contentIndex}-additions`, + type: "addition", + top: clampPercent((start / totalLines) * 100), + height: clampPercent((Math.max(1, rows) / totalLines) * 100), + }); + } + + splitOffset += splitRows; + unifiedOffset += content.deletions + content.additions; + }); + }); + + return markers; +} + +export function buildMultiDiffScrollbarMarkers( + entries: readonly MultiDiffScrollbarEntry[], + diffStyle: DiffStyle, +): DiffScrollbarMarker[] { + const lineCounts = entries.map((entry) => + Math.max(1, getDiffTotalLines(entry.fileDiff, diffStyle)), + ); + const totalLines = lineCounts.reduce((sum, lineCount) => sum + lineCount, 0); + if (totalLines <= 0) return []; + + let lineOffset = 0; + const markers: DiffScrollbarMarker[] = []; + + entries.forEach((entry, entryIndex) => { + const fileLineCount = lineCounts[entryIndex] ?? 1; + const fileMarkers = buildDiffScrollbarMarkers(entry.fileDiff, diffStyle); + + for (const marker of fileMarkers) { + const markerLineTop = lineOffset + (marker.top / 100) * fileLineCount; + const markerLineHeight = (marker.height / 100) * fileLineCount; + markers.push({ + ...marker, + key: `${entry.id}:${marker.key}`, + top: clampPercent((markerLineTop / totalLines) * 100), + height: clampPercent((Math.max(1, markerLineHeight) / totalLines) * 100), + }); + } + + lineOffset += fileLineCount; + }); + + return markers; +} + +export function DiffScrollbarMarkers({ + markers, + viewportRef, +}: { + markers: DiffScrollbarMarker[]; + viewportRef: RefObject; +}) { + const scrollToPercent = useCallback( + (percent: number) => { + const viewportElement = viewportRef.current; + const scrollElement = viewportElement?.matches(".diff-viewport-scroll") + ? viewportElement + : viewportElement?.querySelector(".diff-viewport-scroll"); + if (!scrollElement) return; + + const maxScrollTop = scrollElement.scrollHeight - scrollElement.clientHeight; + scrollElement.scrollTop = maxScrollTop * (clampPercent(percent) / 100); + }, + [viewportRef], + ); + + const handleTrackPointerDown = useCallback( + (event: PointerEvent) => { + const rect = event.currentTarget.getBoundingClientRect(); + if (rect.height <= 0) return; + + event.preventDefault(); + const percent = ((event.clientY - rect.top) / rect.height) * 100; + scrollToPercent(percent); + }, + [scrollToPercent], + ); + + if (markers.length === 0) return null; + + return ( +
+ {markers.map((marker) => ( +
+ ); +} diff --git a/apps/desktop/src/features/diff-view/components/DiffViewer.tsx b/apps/desktop/src/features/diff-view/components/DiffViewer.tsx index c416818..6b89142 100644 --- a/apps/desktop/src/features/diff-view/components/DiffViewer.tsx +++ b/apps/desktop/src/features/diff-view/components/DiffViewer.tsx @@ -109,6 +109,35 @@ pre[data-diff-type='single'] { text-decoration-color: rgb(5 150 105 / 0.95); } +[data-line][data-lsp-diagnostic-line] { + --app-diagnostic-line-color: rgb(2 132 199 / 0.65); + --app-diagnostic-line-bg: color-mix( + in srgb, + var(--app-diagnostic-line-color) 14%, + var(--diffs-computed-diff-line-bg, var(--diffs-bg)) + ); + --diffs-line-bg: var(--app-diagnostic-line-bg); + box-shadow: + inset 3px 0 0 var(--app-diagnostic-line-color), + inset 0 0 0 1px color-mix(in srgb, var(--app-diagnostic-line-color) 18%, transparent); +} + +[data-line][data-lsp-diagnostic-line='error'] { + --app-diagnostic-line-color: rgb(220 38 38 / 0.78); +} + +[data-line][data-lsp-diagnostic-line='warning'] { + --app-diagnostic-line-color: rgb(217 119 6 / 0.78); +} + +[data-line][data-lsp-diagnostic-line='information'] { + --app-diagnostic-line-color: rgb(2 132 199 / 0.68); +} + +[data-line][data-lsp-diagnostic-line='hint'] { + --app-diagnostic-line-color: rgb(5 150 105 / 0.64); +} + [data-interactive-line-numbers] [data-column-number] { padding-left: 2.7ch; } @@ -480,8 +509,10 @@ function DiffScrollbarMarkers({ }) { const scrollToPercent = useCallback( (percent: number) => { - const scrollElement = - viewportRef.current?.querySelector(".diff-viewport-scroll"); + const viewportElement = viewportRef.current; + const scrollElement = viewportElement?.matches(".diff-viewport-scroll") + ? viewportElement + : viewportElement?.querySelector(".diff-viewport-scroll"); if (!scrollElement) return; const maxScrollTop = scrollElement.scrollHeight - scrollElement.clientHeight; @@ -509,7 +540,7 @@ function DiffScrollbarMarkers({ aria-label="Diff change markers" role="scrollbar" aria-orientation="vertical" - className="absolute bottom-2 right-1.5 top-2 z-20 w-2 cursor-pointer bg-background/45 shadow-[0_0_0_1px_hsl(var(--border)/0.55)_inset] transition-[width,background-color] hover:w-4 hover:bg-background/70" + className="absolute bottom-2 right-6 top-2 z-20 w-2 cursor-pointer bg-background/45 shadow-[0_0_0_1px_hsl(var(--border)/0.55)_inset] transition-[width,background-color] hover:w-3 hover:bg-background/70" onPointerDown={handleTrackPointerDown} > {markers.map((marker) => ( diff --git a/apps/desktop/src/features/diff-view/hooks/useDiffCommentAnnotations.tsx b/apps/desktop/src/features/diff-view/hooks/useDiffCommentAnnotations.tsx index 9cfeaba..7b3a88e 100644 --- a/apps/desktop/src/features/diff-view/hooks/useDiffCommentAnnotations.tsx +++ b/apps/desktop/src/features/diff-view/hooks/useDiffCommentAnnotations.tsx @@ -56,6 +56,11 @@ type UseDiffCommentAnnotationsOptions = { commentMentions?: MentionConfig; }; +type CurrentSelection = { + range: SelectionRange; + showComposer: boolean; +}; + export function useDiffCommentAnnotations({ activePath, commentContext, @@ -64,7 +69,9 @@ export function useDiffCommentAnnotations({ commentMentions, }: UseDiffCommentAnnotationsOptions) { const activeRepo = useAppSelector((state) => state.sourceControl.activeRepo); - const [selectedRange, setSelectedRange] = useState(null); + const [selection, setSelection] = useState(null); + const selectedRange = selection?.range ?? null; + const composerRange = selection?.showComposer ? selection.range : null; const { annotations: commentAnnotations } = useCurrentFileComments( activeRepo, @@ -80,33 +87,45 @@ export function useDiffCommentAnnotations({ const { showFirstCommentTip } = useFirstCommentTip(); + const setSelectedRange = useCallback((range: SelectionRange | null) => { + setSelection(range ? { range, showComposer: false } : null); + }, []); + + const onLineSelectionStart = useCallback((range: SelectionRange | null) => { + setSelection(range ? { range, showComposer: false } : null); + }, []); + + const onLineSelectionChange = useCallback((range: SelectionRange | null) => { + setSelection(range ? { range, showComposer: false } : null); + }, []); + const onLineSelected = useCallback((range: SelectionRange | null) => { - setSelectedRange(range); + setSelection(range ? { range, showComposer: true } : null); }, []); const onLineSelectionEnd = useCallback((range: SelectionRange | null) => { - setSelectedRange(range); + setSelection(range ? { range, showComposer: true } : null); }, []); const onCloseCommentComposer = useCallback(() => { - setSelectedRange(null); + setSelection(null); }, []); const composerAnnotation = useMemo | null>(() => { - if (!selectedRange) return null; + if (!composerRange) return null; return { - lineNumber: selectedRange.end, + lineNumber: composerRange.end, metadata: { type: "composer", - side: selectedRange.side ?? "deletions", - endSide: selectedRange.endSide, - startLine: selectedRange.start, - endLine: selectedRange.end, + side: composerRange.side ?? "deletions", + endSide: composerRange.endSide, + startLine: composerRange.start, + endLine: composerRange.end, }, - side: selectedRange.side ?? "deletions", + side: composerRange.side ?? "deletions", }; - }, [selectedRange]); + }, [composerRange]); const annotations = useMemo[]>(() => { if (!composerAnnotation) return commentAnnotations; @@ -120,7 +139,7 @@ export function useDiffCommentAnnotations({ buildDiagnosticsByLine(lspDiagnostics), [lspDiagnostics]); - - const diagnosticPopover = useDiagnosticTokenPopover(diagnosticsByLine); - - const onPostRender = useCallback( - (rootNode: HTMLElement) => applyDiagnosticTokenDecorations(rootNode, diagnosticsByLine), - [diagnosticsByLine], - ); - - return { - onTokenEnter: diagnosticPopover.onTokenEnter, - onTokenLeave: diagnosticPopover.onTokenLeave, - onPostRender, - popoverState: diagnosticPopover.state, - popoverHandlers: { - onClose: diagnosticPopover.closePopover, - onPointerEnter: diagnosticPopover.onPopoverEnter, - onPointerLeave: diagnosticPopover.onPopoverLeave, - }, - }; -} diff --git a/apps/desktop/src/features/diff-view/hooks/useMultiDiffCodeViewOptions.ts b/apps/desktop/src/features/diff-view/hooks/useMultiDiffCodeViewOptions.ts new file mode 100644 index 0000000..ad78fd4 --- /dev/null +++ b/apps/desktop/src/features/diff-view/hooks/useMultiDiffCodeViewOptions.ts @@ -0,0 +1,429 @@ +import { useEffect, useMemo, useReducer, useRef } from "react"; +import { + processFile, + type CodeViewLayout, + type CodeViewOptions, + type DiffLineAnnotation, + type DiffTokenEventBaseProps, + type FileDiffMetadata, +} from "@pierre/diffs"; + +import { MAX_DIFF_LINE_LENGTH } from "@/features/diff-view/services/diffRenderLimits"; +import { + getParsedDiffRequest, + loadParsedDiff, + peekCachedParsedDiff, +} from "@/features/diff-view/services/parsedDiffCache"; +import { DIFF_LINE_FOCUS_CSS } from "@/features/source-control/diffLineFocus"; +import type { DiffAnnotationItem, DiffFile, SelectionRange } from "@/features/source-control/types"; + +type DiffStyle = "split" | "unified"; + +export const MULTI_DIFF_CODE_VIEW_CSS = ` +:host { + min-width: 0; + max-width: 100%; +} + +[data-diffs-header] { + background-color: color-mix(in lab, var(--diffs-bg) 94%, var(--diffs-fg)); + border-bottom: 1px solid color-mix(in lab, var(--diffs-bg) 84%, var(--diffs-fg)); + box-shadow: inset 0 1px 0 color-mix(in lab, var(--diffs-fg) 7%, transparent); + min-width: 0; + overflow: hidden; +} + +[data-diffs-header][data-sticky] { + z-index: 10; +} + +pre[data-diff-type='single'] { + overflow: hidden; + min-width: 0; +} + +[data-lsp-diagnostic-token] { + text-decoration-line: underline; + text-decoration-style: wavy; + text-decoration-thickness: 2px; + text-underline-offset: 2px; +} + +[data-lsp-diagnostic-token='error'] { + text-decoration-color: rgb(220 38 38 / 0.95); +} + +[data-lsp-diagnostic-token='warning'] { + text-decoration-color: rgb(217 119 6 / 0.95); +} + +[data-lsp-diagnostic-token='information'] { + text-decoration-color: rgb(2 132 199 / 0.95); +} + +[data-lsp-diagnostic-token='hint'] { + text-decoration-color: rgb(5 150 105 / 0.95); +} + +[data-line][data-lsp-diagnostic-line] { + --app-diagnostic-line-color: rgb(2 132 199 / 0.65); + --app-diagnostic-line-bg: color-mix( + in srgb, + var(--app-diagnostic-line-color) 14%, + var(--diffs-computed-diff-line-bg, var(--diffs-bg)) + ); + --diffs-line-bg: var(--app-diagnostic-line-bg); + box-shadow: + inset 3px 0 0 var(--app-diagnostic-line-color), + inset 0 0 0 1px color-mix(in srgb, var(--app-diagnostic-line-color) 18%, transparent); +} + +[data-line][data-lsp-diagnostic-line='error'] { + --app-diagnostic-line-color: rgb(220 38 38 / 0.78); +} + +[data-line][data-lsp-diagnostic-line='warning'] { + --app-diagnostic-line-color: rgb(217 119 6 / 0.78); +} + +[data-line][data-lsp-diagnostic-line='information'] { + --app-diagnostic-line-color: rgb(2 132 199 / 0.68); +} + +[data-line][data-lsp-diagnostic-line='hint'] { + --app-diagnostic-line-color: rgb(5 150 105 / 0.64); +} + +[data-interactive-line-numbers] [data-column-number] { + padding-left: 2.7ch; +} + +[data-gutter-utility-slot] { + left: 0; + right: auto; + justify-content: flex-start; +} + +[data-utility-button] { + background-color: transparent; + color: var(--diffs-fg); + width: 0.8lh; + height: 0.8lh; + margin-right: 0; + margin-left: 0.70ch; + border-radius: 999px; +} +${DIFF_LINE_FOCUS_CSS} +`; + +export const MULTI_DIFF_CODE_VIEW_LAYOUT: CodeViewLayout = { + paddingTop: 0, + gap: 1, + paddingBottom: 0, +}; + +export const MULTI_DIFF_SCROLLBAR_CSS = ` +.diff-viewport-scroll { + scrollbar-width: thin; + scrollbar-color: hsl(var(--muted-foreground) / 0.32) transparent; + scrollbar-gutter: stable; +} + +.diff-viewport-scroll::-webkit-scrollbar { + width: 12px; +} + +.diff-viewport-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.diff-viewport-scroll::-webkit-scrollbar-thumb { + background-color: hsl(var(--muted-foreground) / 0.24); + border: 4px solid transparent; + border-radius: 0px; + background-clip: padding-box; +} + +.diff-viewport-scroll::-webkit-scrollbar-thumb:hover { + background-color: hsl(var(--muted-foreground) / 0.38); +} +`; + +export type MultiDiffCodeViewTarget = { + id: string; + path: string; +}; + +type FileVersionData = { + oldFile: DiffFile | null; + newFile: DiffFile | null; +}; + +type MultiDiffTargetResult = { + target: TTarget; + result: { + data?: FileVersionData | null; + }; +}; + +export type ParsedMultiDiff = { + target: TTarget; + fileDiff: FileDiffMetadata; +}; + +type MultiDiffDiagnosticsHandlers = { + onTokenEnter: (itemId: string, props: DiffTokenEventBaseProps) => void; + onTokenLeave: () => void; + onPostRender: (itemId: string, rootNode: HTMLElement) => void; +}; + +type BuildSelectionInput = { + itemId: string; + target: TTarget; + range: SelectionRange; +}; + +type UseMultiDiffCodeViewOptionsInput = { + diffStyle: DiffStyle; + theme: CodeViewOptions["theme"]; + themeType: CodeViewOptions["themeType"]; + expandUnchanged: boolean; + activeItemId: string | null; + targetById: Map; + diagnostics: MultiDiffDiagnosticsHandlers; + onHoverTokenClick: (props: DiffTokenEventBaseProps, event: MouseEvent) => boolean; + onNavigationTokenClick: (props: DiffTokenEventBaseProps, event: MouseEvent) => void; + onSelectItem: (itemId: string) => void; + buildSelection: (input: BuildSelectionInput) => TSelection; + setSelectedRange: (selection: TSelection | null) => void; + setComposerRange: (selection: TSelection | null) => void; +}; + +type MultiDiffCodeViewInteraction = Pick< + UseMultiDiffCodeViewOptionsInput, + | "activeItemId" + | "targetById" + | "diagnostics" + | "onHoverTokenClick" + | "onNavigationTokenClick" + | "onSelectItem" + | "buildSelection" + | "setSelectedRange" + | "setComposerRange" +>; + +export function countFileLines(file: DiffFile | null) { + if (!file) return null; + if (file.contents.length === 0) return 1; + + let lines = 1; + for (let index = 0; index < file.contents.length; index += 1) { + if (file.contents[index] === "\n") lines += 1; + } + return lines; +} + +export function getCodeViewItemNextVersion(item: { version?: unknown }) { + return typeof item.version === "number" ? item.version + 1 : 1; +} + +export function createPlaceholderDiff(path: string, message: string): FileDiffMetadata { + const fileName = path || "Diff unavailable"; + const patch = `diff --git a/${fileName} b/${fileName}\n--- a/${fileName}\n+++ b/${fileName}\n@@ -0,0 +1 @@\n+${message}\n`; + const fileDiff = processFile(patch, { + cacheKey: `placeholder:${fileName}:${message}`, + isGitDiff: true, + }); + + if (!fileDiff) { + throw new Error(`Unable to create placeholder diff for ${fileName}`); + } + + return fileDiff; +} + +export function getAnnotationsKey(annotations: DiffLineAnnotation[]): string { + return annotations + .map((annotation) => { + const type = (annotation.metadata as { type?: string } | undefined)?.type ?? ""; + return `${annotation.side}:${annotation.lineNumber}:${type}`; + }) + .join("\u0001"); +} + +export function useParsedMultiFileDiffs( + targetResults: MultiDiffTargetResult[], + cacheSalt: string, +) { + const [, forceUpdate] = useReducer((count: number) => count + 1, 0); + + const requests = targetResults + .map(({ target, result }) => { + if (!result.data) return null; + const request = getParsedDiffRequest( + target.path, + result.data.oldFile, + result.data.newFile, + cacheSalt, + ); + return request ? { target, request } : null; + }) + .filter((request): request is NonNullable => request !== null); + const requestKey = requests + .map(({ target, request }) => `${target.id}:${request.key}`) + .join("\u0001"); + + useEffect(() => { + let cancelled = false; + const uncachedRequests = requests.filter( + ({ request }) => peekCachedParsedDiff(request.key) === undefined, + ); + + if (uncachedRequests.length === 0) { + if (requests.length > 0) { + queueMicrotask(() => { + if (!cancelled) forceUpdate(); + }); + } + } else { + void Promise.all(uncachedRequests.map(({ request }) => loadParsedDiff(request, "low"))).then( + () => { + if (!cancelled) forceUpdate(); + }, + ); + } + + return () => { + cancelled = true; + }; + }, [requestKey]); + + return requests + .map(({ target, request }) => { + const parsedDiff = peekCachedParsedDiff(request.key); + return parsedDiff ? { target, fileDiff: parsedDiff } : null; + }) + .filter((entry): entry is ParsedMultiDiff => entry !== null); +} + +export function useMultiDiffCodeViewOptions({ + diffStyle, + theme, + themeType, + expandUnchanged, + activeItemId, + targetById, + diagnostics, + onHoverTokenClick, + onNavigationTokenClick, + onSelectItem, + buildSelection, + setSelectedRange, + setComposerRange, +}: UseMultiDiffCodeViewOptionsInput) { + const interactionRef = useRef>({ + activeItemId, + targetById, + diagnostics, + onHoverTokenClick, + onNavigationTokenClick, + onSelectItem, + buildSelection, + setSelectedRange, + setComposerRange, + }); + interactionRef.current = { + activeItemId, + targetById, + diagnostics, + onHoverTokenClick, + onNavigationTokenClick, + onSelectItem, + buildSelection, + setSelectedRange, + setComposerRange, + }; + + return useMemo>( + () => ({ + diffStyle, + layout: MULTI_DIFF_CODE_VIEW_LAYOUT, + theme, + themeType, + unsafeCSS: MULTI_DIFF_CODE_VIEW_CSS, + maxLineDiffLength: MAX_DIFF_LINE_LENGTH, + expansionLineCount: 20, + expandUnchanged, + enableLineSelection: true, + enableGutterUtility: true, + stickyHeaders: true, + onLineClick: (_props, context) => { + interactionRef.current.onSelectItem(context.item.id); + }, + onLineNumberClick: (_props, context) => { + interactionRef.current.onSelectItem(context.item.id); + }, + onTokenClick: (props, event, context) => { + const { + activeItemId: currentActiveItemId, + onHoverTokenClick: handleHoverTokenClick, + onNavigationTokenClick: handleNavigationTokenClick, + onSelectItem: handleSelectItem, + } = interactionRef.current; + + if (context.item.id !== currentActiveItemId) { + handleSelectItem(context.item.id); + return; + } + + const diffTokenProps = props as DiffTokenEventBaseProps; + if (handleHoverTokenClick(diffTokenProps, event)) return; + handleNavigationTokenClick(diffTokenProps, event); + }, + onTokenEnter: (props, _event, context) => { + interactionRef.current.diagnostics.onTokenEnter( + context.item.id, + props as DiffTokenEventBaseProps, + ); + }, + onTokenLeave: () => { + interactionRef.current.diagnostics.onTokenLeave(); + }, + onLineSelectionStart: (range, context) => { + updateSelection(interactionRef.current, context.item.id, range, false); + }, + onLineSelected: (range, context) => { + updateSelection(interactionRef.current, context.item.id, range, true); + }, + onLineSelectionChange: (range, context) => { + updateSelection(interactionRef.current, context.item.id, range, false); + }, + onLineSelectionEnd: (range, context) => { + updateSelection(interactionRef.current, context.item.id, range, true); + }, + onPostRender: (node, _instance, _phase, context) => { + interactionRef.current.diagnostics.onPostRender(context.item.id, node); + }, + }), + [diffStyle, expandUnchanged, theme, themeType], + ); +} + +function updateSelection( + interaction: MultiDiffCodeViewInteraction, + itemId: string, + range: SelectionRange | null, + showComposer: boolean, +) { + const target = interaction.targetById.get(itemId); + if (!range || !target) { + interaction.setSelectedRange(null); + interaction.setComposerRange(null); + return; + } + + interaction.onSelectItem(itemId); + const selection = interaction.buildSelection({ itemId, target, range }); + interaction.setSelectedRange(selection); + interaction.setComposerRange(showComposer ? selection : null); +} diff --git a/apps/desktop/src/features/diff-view/hooks/useMultiDiffDiagnostics.ts b/apps/desktop/src/features/diff-view/hooks/useMultiDiffDiagnostics.ts new file mode 100644 index 0000000..4d0ff2a --- /dev/null +++ b/apps/desktop/src/features/diff-view/hooks/useMultiDiffDiagnostics.ts @@ -0,0 +1,133 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { DiffTokenEventBaseProps } from "@pierre/diffs"; + +import { + findDiagnosticsForToken, + readDiagnosticAnchorRect, + type DiagnosticPopoverAnchorRect, +} from "@/features/diff-view/util/lsp_token"; +import { + applyDiagnosticTokenDecorations, + buildDiagnosticsByLine, +} from "@/features/diff-view/util/lsp_token"; +import type { LspDiagnostic } from "@/features/source-control/types"; + +export function useMultiDiffDiagnostics(diagnosticsByItem: Map) { + const diagnosticCloseTimerRef = useRef | null>(null); + const isDiagnosticPopoverHoveredRef = useRef(false); + const renderedRootNodesRef = useRef(new Map()); + const [state, setState] = useState<{ + open: boolean; + diagnostics: LspDiagnostic[]; + anchorRect: DiagnosticPopoverAnchorRect | null; + }>({ + open: false, + diagnostics: [], + anchorRect: null, + }); + + const diagnosticsByLineByItem = useMemo(() => { + const next = new Map>(); + for (const [itemId, diagnostics] of diagnosticsByItem) { + next.set(itemId, buildDiagnosticsByLine(diagnostics)); + } + return next; + }, [diagnosticsByItem]); + + const closePopover = useCallback(() => { + if (diagnosticCloseTimerRef.current) { + clearTimeout(diagnosticCloseTimerRef.current); + diagnosticCloseTimerRef.current = null; + } + + setState({ open: false, diagnostics: [], anchorRect: null }); + }, []); + + const onTokenEnter = useCallback( + (itemId: string, props: DiffTokenEventBaseProps) => { + if (diagnosticCloseTimerRef.current) { + clearTimeout(diagnosticCloseTimerRef.current); + diagnosticCloseTimerRef.current = null; + } + + const diagnosticsByLine = diagnosticsByLineByItem.get(itemId); + const diagnostics = diagnosticsByLine + ? findDiagnosticsForToken(props.tokenElement, diagnosticsByLine) + : []; + if (diagnostics.length === 0) { + closePopover(); + return; + } + + setState({ + open: true, + diagnostics, + anchorRect: readDiagnosticAnchorRect(props.tokenElement), + }); + }, + [closePopover, diagnosticsByLineByItem], + ); + + const onTokenLeave = useCallback(() => { + if (diagnosticCloseTimerRef.current) { + clearTimeout(diagnosticCloseTimerRef.current); + } + + diagnosticCloseTimerRef.current = setTimeout(() => { + if (!isDiagnosticPopoverHoveredRef.current) { + closePopover(); + } + }, 120); + }, [closePopover]); + + const onPopoverEnter = useCallback(() => { + isDiagnosticPopoverHoveredRef.current = true; + if (diagnosticCloseTimerRef.current) { + clearTimeout(diagnosticCloseTimerRef.current); + diagnosticCloseTimerRef.current = null; + } + }, []); + + const onPopoverLeave = useCallback(() => { + isDiagnosticPopoverHoveredRef.current = false; + closePopover(); + }, [closePopover]); + + useEffect(() => { + // LSP diagnostics can arrive after CodeView renders, so repaint existing roots. + for (const [itemId, rootNode] of renderedRootNodesRef.current) { + if (!rootNode.isConnected) { + renderedRootNodesRef.current.delete(itemId); + continue; + } + + applyDiagnosticTokenDecorations( + rootNode, + diagnosticsByLineByItem.get(itemId) ?? new Map(), + ); + } + }, [diagnosticsByLineByItem]); + + const onPostRender = useCallback( + (itemId: string, rootNode: HTMLElement) => { + renderedRootNodesRef.current.set(itemId, rootNode); + applyDiagnosticTokenDecorations( + rootNode, + diagnosticsByLineByItem.get(itemId) ?? new Map(), + ); + }, + [diagnosticsByLineByItem], + ); + + return { + onTokenEnter, + onTokenLeave, + onPostRender, + popoverState: state, + popoverHandlers: { + onClose: closePopover, + onPointerEnter: onPopoverEnter, + onPointerLeave: onPopoverLeave, + }, + }; +} diff --git a/apps/desktop/src/features/diff-view/util/lsp_token.ts b/apps/desktop/src/features/diff-view/util/lsp_token.ts index 07a8213..e63c78b 100644 --- a/apps/desktop/src/features/diff-view/util/lsp_token.ts +++ b/apps/desktop/src/features/diff-view/util/lsp_token.ts @@ -1,5 +1,12 @@ import type { LspDiagnostic } from "@/features/source-control/types"; +export type DiagnosticPopoverAnchorRect = { + top: number; + left: number; + width: number; + height: number; +}; + const DIAGNOSTIC_SEVERITY_PRIORITY: Record = { error: 4, warning: 3, @@ -30,6 +37,23 @@ function readLineNumber(lineElement: HTMLElement): number | null { return Number.isFinite(value) ? value : null; } +function getHighestDiagnosticSeverity( + diagnostics: LspDiagnostic[], +): LspDiagnostic["severity"] | null { + let winningSeverity: LspDiagnostic["severity"] | null = null; + let winningPriority = -1; + + for (const diagnostic of diagnostics) { + const priority = DIAGNOSTIC_SEVERITY_PRIORITY[diagnostic.severity]; + if (priority > winningPriority) { + winningPriority = priority; + winningSeverity = diagnostic.severity; + } + } + + return winningSeverity; +} + function lineCanRenderDiagnostic(lineElement: HTMLElement): boolean { const lineType = lineElement.getAttribute("data-line-type"); if (lineType === "change-deletion") { @@ -136,6 +160,48 @@ export function findDiagnosticSeverityForToken( return winningSeverity; } +export function findDiagnosticsForToken( + token: HTMLElement, + diagnosticsByLine: Map, +): LspDiagnostic[] { + if (!tokenCanRenderDiagnostic(token)) { + return []; + } + + const lineNumber = getTokenLineNumber(token); + if (!lineNumber) { + return []; + } + + const diagnostics = diagnosticsByLine.get(lineNumber); + if (!diagnostics || diagnostics.length === 0) { + return []; + } + + const charRange = getTokenCharRange(token); + if (!charRange) { + return []; + } + + const matches = diagnostics.filter((diagnostic) => + tokenOverlapsDiagnostic(lineNumber, charRange.start, charRange.end, diagnostic), + ); + return matches.toSorted( + (left, right) => + DIAGNOSTIC_SEVERITY_PRIORITY[right.severity] - DIAGNOSTIC_SEVERITY_PRIORITY[left.severity], + ); +} + +export function readDiagnosticAnchorRect(tokenElement: HTMLElement): DiagnosticPopoverAnchorRect { + const rect = tokenElement.getBoundingClientRect(); + return { + top: rect.top, + left: rect.left, + width: rect.width, + height: rect.height, + }; +} + // Applies diagnostic attributes with minimal DOM churn by updating only tokens // whose severity changed and skipping lines that cannot render diagnostics. export function applyDiagnosticTokenDecorations( @@ -153,6 +219,11 @@ export function applyDiagnosticTokenDecorations( for (const token of tokens) { token.removeAttribute("data-lsp-diagnostic-token"); } + + const lines = root.querySelectorAll("[data-lsp-diagnostic-line]"); + for (const line of lines) { + line.removeAttribute("data-lsp-diagnostic-line"); + } } return; } @@ -169,6 +240,7 @@ export function applyDiagnosticTokenDecorations( const canRender = diagnostics != null && diagnostics.length > 0 && lineCanRenderDiagnostic(line); if (!canRender) { + line.removeAttribute("data-lsp-diagnostic-line"); const markedTokens = line.querySelectorAll( "[data-char][data-lsp-diagnostic-token]", ); @@ -178,6 +250,13 @@ export function applyDiagnosticTokenDecorations( continue; } + const lineSeverity = getHighestDiagnosticSeverity(diagnostics); + if (lineSeverity) { + line.setAttribute("data-lsp-diagnostic-line", lineSeverity); + } else { + line.removeAttribute("data-lsp-diagnostic-line"); + } + const tokens = line.querySelectorAll("[data-char]"); for (const token of tokens) { const nextSeverity = getSeverityForTokenInLine(token, lineNumber, diagnostics); diff --git a/apps/desktop/src/features/lsp/components/LspStatusNotice.tsx b/apps/desktop/src/features/lsp/components/LspStatusNotice.tsx index a957eda..26cfbaf 100644 --- a/apps/desktop/src/features/lsp/components/LspStatusNotice.tsx +++ b/apps/desktop/src/features/lsp/components/LspStatusNotice.tsx @@ -1,8 +1,11 @@ import { AlertCircle, Copy, LoaderCircle, Search, TriangleAlert } from "lucide-react"; import { toast } from "sonner"; +import { shallowEqual } from "react-redux"; + import { useAppSelector } from "@/app/hooks"; import { selectLspFileStateForFile } from "@/features/lsp/selectors"; +import { formatRange } from "@/features/source-control/utils"; import type { LspDiagnostic } from "@/platform/desktop"; type Props = { @@ -11,6 +14,17 @@ type Props = { active: boolean; }; +type LspDiagnosticsDocument = { + repoPath: string; + relPath: string; +}; + +type SummaryProps = { + documents: LspDiagnosticsDocument[]; + active: boolean; + isLoading?: boolean; +}; + function diagnosticsLabel(count: number) { return `${count} diagnostic${count === 1 ? "" : "s"}`; } @@ -20,13 +34,28 @@ function diagnosticMetadataLabel(diagnostic: LspDiagnostic) { return metadata ? ` (${metadata})` : ""; } -function formatDiagnostic(diagnostic: LspDiagnostic, index: number) { - return `${index + 1}. [${diagnostic.severity.toUpperCase()}] ${diagnostic.startLine}:${diagnostic.startCharacter}-${diagnostic.endLine}:${diagnostic.endCharacter} ${diagnostic.message}${diagnosticMetadataLabel(diagnostic)}`; +function formatDiagnostic(relPath: string, diagnostic: LspDiagnostic) { + return `@${relPath}#${formatRange(diagnostic.startLine, diagnostic.endLine)} - [${diagnostic.severity.toUpperCase()}] ${diagnostic.message}${diagnosticMetadataLabel(diagnostic)}`; } function formatDiagnosticsForClipboard(relPath: string, diagnostics: LspDiagnostic[]) { - const lines = diagnostics.map((diagnostic, index) => formatDiagnostic(diagnostic, index)); - return [`Diagnostics for ${relPath}`, ...lines].join("\n"); + return diagnostics.map((diagnostic) => formatDiagnostic(relPath, diagnostic)).join("\n"); +} + +function formatAllDiagnosticsForClipboard( + entries: { relPath: string; diagnostics: LspDiagnostic[] }[], +) { + return entries + .flatMap((entry) => + entry.diagnostics.map((diagnostic) => formatDiagnostic(entry.relPath, diagnostic)), + ) + .join("\n"); +} + +function diagnosticsSummaryLabel(count: number, fileCount: number) { + const problemLabel = `${count} problem${count === 1 ? "" : "s"}`; + const fileLabel = `${fileCount} file${fileCount === 1 ? "" : "s"}`; + return `${problemLabel} reported across ${fileLabel}.`; } function errorMessage(error: unknown) { @@ -37,6 +66,112 @@ function errorMessage(error: unknown) { return String(error); } +export function LspDiagnosticsSummaryNotice({ + documents, + active, + isLoading = false, +}: SummaryProps) { + const fileStates = useAppSelector( + (state) => + documents.map((document) => + selectLspFileStateForFile(state, document.repoPath, document.relPath), + ), + shallowEqual, + ); + + const diagnosticEntries = documents + .map((document, index) => ({ + relPath: document.relPath, + diagnostics: fileStates[index]?.reason ? [] : (fileStates[index]?.diagnostics ?? []), + })) + .filter((entry) => entry.diagnostics.length > 0); + const diagnosticsCount = diagnosticEntries.reduce( + (count, entry) => count + entry.diagnostics.length, + 0, + ); + const diagnosticFileCount = diagnosticEntries.length; + const pendingCount = fileStates.filter((fileState) => !fileState).length; + const unavailableCount = fileStates.filter((fileState) => fileState?.reason).length; + + const copyDiagnostics = async () => { + if (diagnosticEntries.length === 0) { + return; + } + + try { + await navigator.clipboard.writeText(formatAllDiagnosticsForClipboard(diagnosticEntries)); + toast.success("Diagnostics copied"); + } catch (error) { + toast.error("Failed to copy diagnostics", { + description: errorMessage(error), + }); + } + }; + + if (!active) { + return null; + } + + if (diagnosticsCount === 0 && (isLoading || (documents.length > 0 && pendingCount > 0))) { + return ( +
+
+ +
+ Checking diagnostics… +
+ ); + } + + if (diagnosticsCount === 0 && unavailableCount > 0) { + return ( +
+
+ +
+ + Diagnostics unavailable for {unavailableCount} file + {unavailableCount === 1 ? "" : "s"}. + +
+ ); + } + + if (diagnosticsCount === 0) { + return ( +
+
+ +
+ No problems reported. +
+ ); + } + + return ( +
+
+ +
+ + {diagnosticsSummaryLabel(diagnosticsCount, diagnosticFileCount)} + {isLoading || pendingCount > 0 ? " Checking remaining files…" : null} + + +
+ ); +} + export function LspStatusNotice({ repoPath, relPath, active }: Props) { const fileState = useAppSelector((state) => { if (!repoPath || !relPath) { @@ -56,7 +191,9 @@ export function LspStatusNotice({ repoPath, relPath, active }: Props) { await navigator.clipboard.writeText(formatDiagnosticsForClipboard(relPath, diagnostics)); toast.success("Diagnostics copied"); } catch (error) { - toast.error("Failed to copy diagnostics", { description: errorMessage(error) }); + toast.error("Failed to copy diagnostics", { + description: errorMessage(error), + }); } }; diff --git a/apps/desktop/src/features/lsp/components/LspSymbolPeek.test.tsx b/apps/desktop/src/features/lsp/components/LspSymbolPeek.test.tsx index 5b14ad3..0fa79e4 100644 --- a/apps/desktop/src/features/lsp/components/LspSymbolPeek.test.tsx +++ b/apps/desktop/src/features/lsp/components/LspSymbolPeek.test.tsx @@ -74,28 +74,37 @@ function cssLengthToPixels(value: string) { return numeric; } -function SymbolPeekHarness() { +function SymbolPeekHarness({ + clientHeight = 400, + scrollTop = 0, +}: { + clientHeight?: number; + scrollTop?: number; +}) { const containerRef = useRef(null); - const setContainerRef = useCallback((node: HTMLDivElement | null) => { - containerRef.current = node; + const setContainerRef = useCallback( + (node: HTMLDivElement | null) => { + containerRef.current = node; - if (!node) { - return; - } - - const host = document.createElement("div"); - node.appendChild(host); + if (!node) { + return; + } - Object.defineProperty(node, "clientHeight", { - configurable: true, - value: 400, - }); - Object.defineProperty(node, "scrollTop", { - configurable: true, - writable: true, - value: 0, - }); - }, []); + const host = document.createElement("div"); + node.appendChild(host); + + Object.defineProperty(node, "clientHeight", { + configurable: true, + value: clientHeight, + }); + Object.defineProperty(node, "scrollTop", { + configurable: true, + writable: true, + value: scrollTop, + }); + }, + [clientHeight, scrollTop], + ); return (
@@ -141,7 +150,10 @@ describe("LspSymbolPeek", () => { if (arg.relPath === "src/a.ts") { return { data: { name: "src/a.ts", contents: "alpha\nbeta hit\ngamma second" }, - currentData: { name: "src/a.ts", contents: "alpha\nbeta hit\ngamma second" }, + currentData: { + name: "src/a.ts", + contents: "alpha\nbeta hit\ngamma second", + }, isFetching: false, error: undefined, }; @@ -150,7 +162,10 @@ describe("LspSymbolPeek", () => { if (arg.relPath === "src/b.ts") { return { data: { name: "src/b.ts", contents: "zero\nomega target\nlast" }, - currentData: { name: "src/b.ts", contents: "zero\nomega target\nlast" }, + currentData: { + name: "src/b.ts", + contents: "zero\nomega target\nlast", + }, isFetching: false, error: undefined, }; @@ -289,6 +304,59 @@ describe("LspSymbolPeek", () => { expect(topPx + heightPx).toBeLessThanOrEqual(400); }); + it("positions against the visible viewport when the code view is scrolled", () => { + mocks.getRenderedLineOffset.mockReturnValue({ + line: document.createElement("div"), + top: 860, + bottom: 884, + height: 24, + }); + + const store = createStore(); + store.dispatch( + openSymbolPeek({ + kind: "definitions", + locations: [ + { + repoPath: "/repo", + relPath: "src/a.ts", + uri: "file:///repo/src/a.ts", + line: 2, + character: 1, + endLine: 2, + endCharacter: 4, + }, + ], + activeIndex: 0, + query: "", + sourceDocument: { + repoPath: "/repo", + relPath: "src/current.ts", + }, + anchor: { + lineNumber: 3, + lineIndex: "2,9", + }, + }), + ); + + render( + + + + + , + ); + + const closeButton = screen.getByLabelText("Close symbol peek"); + const popover = closeButton.parentElement?.parentElement as HTMLDivElement | null; + expect(popover).not.toBeNull(); + + const topPx = cssLengthToPixels(popover?.style.top ?? "0px"); + + expect(topPx).toBe(88); + }); + it("commits the active selection on Enter and closes the peek", () => { const store = createStore(); store.dispatch( diff --git a/apps/desktop/src/features/lsp/components/LspSymbolPeek.tsx b/apps/desktop/src/features/lsp/components/LspSymbolPeek.tsx index ca1aea6..01b1222 100644 --- a/apps/desktop/src/features/lsp/components/LspSymbolPeek.tsx +++ b/apps/desktop/src/features/lsp/components/LspSymbolPeek.tsx @@ -116,6 +116,12 @@ function rootFontSizePx() { return Number.isFinite(parsed) && parsed > 0 ? parsed : 16; } +function getLayerRelativeViewportTop(container: HTMLElement, layerMarker: HTMLElement) { + const containerRect = container.getBoundingClientRect(); + const layerRect = layerMarker.getBoundingClientRect(); + return containerRect.top - layerRect.top; +} + function buildSymbolPeekGroups( locations: LspLocation[], query: string, @@ -237,7 +243,11 @@ export function LspSymbolPeek({ document, containerRef, symbolPeek }: LspSymbolP const navigate = useNavigate(); const location = useLocation(); const deferredQuery = useDeferredValue(symbolPeek.query); - const [popoverLayout, setPopoverLayout] = useState<{ top: number; height: number } | null>(null); + const [popoverLayout, setPopoverLayout] = useState<{ + top: number; + height: number; + } | null>(null); + const layerMarkerRef = useRef(null); const listContainerRef = useRef(null); const locations = symbolPeek.locations; @@ -357,7 +367,8 @@ export function LspSymbolPeek({ document, containerRef, symbolPeek }: LspSymbolP } const container = containerRef.current; - if (!container) { + const layerMarker = layerMarkerRef.current; + if (!container || !layerMarker) { return; } @@ -383,10 +394,13 @@ export function LspSymbolPeek({ document, containerRef, symbolPeek }: LspSymbolP Math.max(1, viewportHeight - SYMBOL_PEEK_OFFSET_PX * 2), ); - const minTop = container.scrollTop + SYMBOL_PEEK_OFFSET_PX; - const maxTop = container.scrollTop + viewportHeight - popoverHeight - SYMBOL_PEEK_OFFSET_PX; - const preferredBelowTop = offset.bottom + SYMBOL_PEEK_OFFSET_PX; - const preferredAboveTop = offset.top - popoverHeight - SYMBOL_PEEK_OFFSET_PX; + const viewportTop = getLayerRelativeViewportTop(container, layerMarker); + const lineTop = viewportTop + offset.top - container.scrollTop; + const lineBottom = viewportTop + offset.bottom - container.scrollTop; + const minTop = viewportTop + SYMBOL_PEEK_OFFSET_PX; + const maxTop = viewportTop + viewportHeight - popoverHeight - SYMBOL_PEEK_OFFSET_PX; + const preferredBelowTop = lineBottom + SYMBOL_PEEK_OFFSET_PX; + const preferredAboveTop = lineTop - popoverHeight - SYMBOL_PEEK_OFFSET_PX; const nextTop = clamp( preferredBelowTop > maxTop && preferredAboveTop >= minTop ? preferredAboveTop @@ -470,99 +484,113 @@ export function LspSymbolPeek({ document, containerRef, symbolPeek }: LspSymbolP }, ); - if (symbolPeek === null || popoverLayout === null) { + if (symbolPeek === null) { return null; } return ( -
-
-
{activeLocation?.relPath ?? document.relPath}
-
- - {getSymbolPeekTitle(symbolPeek.kind, locations.length)} -
- -
- -
-
- {previewError ? ( -
{previewError}
- ) : previewQuery.isFetching && previewFile === undefined ? ( -
Loading preview...
- ) : ( - - )} -
- -
-
- - { - dispatch(setSymbolPeekQuery(event.target.value)); - }} - className="text-popover-foreground placeholder:text-muted-foreground h-full min-w-0 flex-1 bg-transparent text-[11px] outline-none" - placeholder="Filter" - /> +
+
+ {activeLocation?.relPath ?? document.relPath} +
+
+ - {getSymbolPeekTitle(symbolPeek.kind, locations.length)} +
+
-
- {groups.length === 0 ? ( -
- No matching symbols. +
+
+ {previewError ? ( +
{previewError}
+ ) : previewQuery.isFetching && previewFile === undefined ? ( +
Loading preview...
+ ) : ( + + )} +
+ +
+
+ + { + dispatch(setSymbolPeekQuery(event.target.value)); + }} + className="text-popover-foreground placeholder:text-muted-foreground h-full min-w-0 flex-1 bg-transparent text-[11px] outline-none" + placeholder="Filter" + />
- ) : ( - groups.map((group) => ( -
-
- {group.relPath} - {group.items.length} + +
+ {groups.length === 0 ? ( +
+ No matching symbols.
- {group.items.map(({ index, location }) => { - const isActive = index === activeIndex; - return ( - - ); - })} -
- )) - )} + ) : ( + groups.map((group) => ( +
+
+ {group.relPath} + {group.items.length} +
+ {group.items.map(({ index, location }) => { + const isActive = index === activeIndex; + return ( + + ); + })} +
+ )) + )} +
+
-
-
-
+
+ )} + ); } diff --git a/apps/desktop/src/features/lsp/hooks/useCurrentLspDocument.ts b/apps/desktop/src/features/lsp/hooks/useCurrentLspDocument.ts index 3717060..cb8fa1c 100644 --- a/apps/desktop/src/features/lsp/hooks/useCurrentLspDocument.ts +++ b/apps/desktop/src/features/lsp/hooks/useCurrentLspDocument.ts @@ -10,49 +10,47 @@ type ActiveDocument = { relPath: string; }; -function sameDocument(current: ActiveDocument | null, next: ActiveDocument | null) { - if (!current || !next) { - return current === next; - } +export type CurrentLspDocument = ActiveDocument & { + text: string; +}; - return current.repoPath === next.repoPath && current.relPath === next.relPath; +function documentKey(document: ActiveDocument) { + return `${document.repoPath}\u0000${document.relPath}`; } -export function useCurrentLspDocument(repoPath: string, relPath: string, text: string | null) { +export function useCurrentLspDocuments(documents: CurrentLspDocument[]) { const dispatch = useAppDispatch(); - const activeDocumentRef = useRef(null); + const activeDocumentsRef = useRef(new Map()); + const documentsKey = documents + .map((document) => `${documentKey(document)}\u0000${document.text.length}`) + .join("\u0001"); useEffect(() => { - const nextDocument = repoPath && relPath && text !== null ? { repoPath, relPath } : null; - const currentDocument = activeDocumentRef.current; + const nextDocuments = new Map(documents.map((document) => [documentKey(document), document])); - if (!sameDocument(currentDocument, nextDocument) && currentDocument) { + for (const [key, currentDocument] of activeDocumentsRef.current) { + if (nextDocuments.has(key)) continue; void desktop.closeLspDocument(currentDocument); dispatch(clearLspFile(currentDocument)); + activeDocumentsRef.current.delete(key); } - activeDocumentRef.current = nextDocument; + for (const [key, nextDocument] of nextDocuments) { + const currentDocument = activeDocumentsRef.current.get(key); + if (currentDocument?.text === nextDocument.text) continue; - if (!nextDocument || text === null) { - return; + activeDocumentsRef.current.set(key, nextDocument); + void desktop.syncLspDocument(nextDocument); } - - void desktop.syncLspDocument({ - ...nextDocument, - text, - }); - }, [dispatch, relPath, repoPath, text]); + }, [dispatch, documents, documentsKey]); useEffect(() => { return () => { - const currentDocument = activeDocumentRef.current; - if (!currentDocument) { - return; + for (const currentDocument of activeDocumentsRef.current.values()) { + void desktop.closeLspDocument(currentDocument); + dispatch(clearLspFile(currentDocument)); } - - void desktop.closeLspDocument(currentDocument); - dispatch(clearLspFile(currentDocument)); - activeDocumentRef.current = null; + activeDocumentsRef.current.clear(); }; }, [dispatch]); } diff --git a/apps/desktop/src/features/lsp/hooks/useDiffDiagnostics.ts b/apps/desktop/src/features/lsp/hooks/useDiffDiagnostics.ts deleted file mode 100644 index 00a64b3..0000000 --- a/apps/desktop/src/features/lsp/hooks/useDiffDiagnostics.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { useAppSelector } from "@/app/hooks"; - -import { selectLspDiagnosticsForFile } from "../selectors"; - -const EMPTY_DIAGNOSTICS: ReturnType = []; - -export function useDiffDiagnostics(repoPath: string, relPath: string) { - return useAppSelector((state) => { - if (!repoPath || !relPath) { - return EMPTY_DIAGNOSTICS; - } - - return selectLspDiagnosticsForFile(state, repoPath, relPath); - }); -} diff --git a/apps/desktop/src/features/pull-requests/components/PullRequestCodeViewDiffPane.tsx b/apps/desktop/src/features/pull-requests/components/PullRequestCodeViewDiffPane.tsx new file mode 100644 index 0000000..fce64d9 --- /dev/null +++ b/apps/desktop/src/features/pull-requests/components/PullRequestCodeViewDiffPane.tsx @@ -0,0 +1,695 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { shallowEqual } from "react-redux"; +import { CodeView, type CodeViewHandle, type CodeViewItem } from "@pierre/diffs/react"; +import { type DiffLineAnnotation, type FileDiffMetadata } from "@pierre/diffs"; +import { useTheme } from "next-themes"; + +import { useAppDispatch, useAppSelector } from "@/app/hooks"; +import { CommentAnnotation } from "@/features/diff-view/components/CommentAnnotation"; +import { CommentComposer } from "@/features/diff-view/components/CommentComposer"; +import { DiagnosticTokenPopover } from "@/features/diff-view/components/DiagnosticTokenPopover"; +import { + buildMultiDiffScrollbarMarkers, + DiffScrollbarMarkers, +} from "@/features/diff-view/components/DiffScrollbarMarkers"; +import { DiffHeaderMetadataControls } from "@/features/diff-view/components/DiffHeaderMetadataControls"; +import { + getDiffTheme, + getDiffThemeCacheSalt, + getDiffThemeType, +} from "@/features/diff-view/diffRenderConfig"; +import { useDiffAnnotationRenderer } from "@/features/diff-view/hooks/useDiffAnnotationRenderer"; +import { + countFileLines, + createPlaceholderDiff, + getAnnotationsKey, + getCodeViewItemNextVersion, + MULTI_DIFF_SCROLLBAR_CSS, + useMultiDiffCodeViewOptions, + useParsedMultiFileDiffs, +} from "@/features/diff-view/hooks/useMultiDiffCodeViewOptions"; +import { useMultiDiffDiagnostics } from "@/features/diff-view/hooks/useMultiDiffDiagnostics"; +import { + getParsedDiffRequest, + peekCachedParsedDiff, +} from "@/features/diff-view/services/parsedDiffCache"; +import { + DiffLspHoverPopover, + type LspHoverDocument, + useDiffLspHover, +} from "@/features/diff-view/useDiffLspHover"; +import { LspDiagnosticsSummaryNotice } from "@/features/lsp/components/LspStatusNotice"; +import { LspSymbolPeekContainer } from "@/features/lsp/components/LspSymbolPeek"; +import { useCurrentLspDocuments } from "@/features/lsp/hooks/useCurrentLspDocument"; +import { selectLspDiagnosticsForFile } from "@/features/lsp/selectors"; +import { useLspTokenNavigation } from "@/features/lsp/useLspTokenNavigation"; +import { usePullRequestMentionCandidates } from "@/features/pull-requests/hooks/usePullRequestMentionCandidates"; +import { usePullRequestReviewAnchors } from "@/features/pull-requests/hooks/usePullRequestReviewAnchors"; +import { PullRequestInlineAnchorAnnotation } from "@/features/pull-requests/components/PullRequestInlineAnchorAnnotation"; +import { PullRequestInlineReviewThread } from "@/features/pull-requests/components/PullRequestInlineReviewThread"; +import { buildPullRequestAnchorAnnotations } from "@/features/pull-requests/utils/reviewAnchors"; +import { gitApi } from "@/features/source-control/api"; +import { + buildSourceControlFileTree, + type SourceControlTreeNode, +} from "@/features/source-control/fileTree"; +import { useDiffLineFocus } from "@/features/source-control/diffLineFocus"; +import { errorMessageFrom } from "@/features/source-control/shared-utils/errorMessage"; +import type { + CommentContext, + DiffAnnotationItem, + DiffReturnTarget, + FileBrowserMode, + FileItem, + SelectionRange, +} from "@/features/source-control/types"; +import type { GitProviderId, PullRequestConversation } from "@/platform/desktop"; + +type ReviewDiffTarget = FileItem & { + id: string; +}; + +type BranchVersionResult = ReturnType< + ReturnType +>; + +type TargetResult = { + target: ReviewDiffTarget; + result: BranchVersionResult; +}; + +type SelectedDiffRange = { + itemId: string; + path: string; + range: SelectionRange; +}; + +type LoadedCodeViewItemState = { + annotations: DiffLineAnnotation[]; + fileDiff: FileDiffMetadata; + annotationsKey: string; +}; + +type Props = { + activeRepo: string; + reviewRepoPath: string; + reviewProviderId?: GitProviderId; + pullRequestNumber: number; + reviewBaseRef: string; + reviewHeadRef: string; + readyForDiff: boolean; + branchFiles: FileItem[]; + activePath: string; + onSelectPath: (path: string) => void; + conversation: PullRequestConversation | null; + focusedLineNumber: number | null; + focusedLineIndex: string | null; + focusedLineKey: number | null; +}; + +function reviewDiffItemId(path: string) { + return path; +} + +function buildReturnToDiffTarget( + activeRepo: string, + target: ReviewDiffTarget | null, + source: { lineNumber: number; lineIndex: string | null }, +): DiffReturnTarget | null { + if (!activeRepo || !target || source.lineNumber <= 0) return null; + + return { + kind: "pull-request", + repoPath: activeRepo, + path: target.path, + lineNumber: source.lineNumber, + lineIndex: source.lineIndex, + }; +} + +const SORT_LOCALE_OPTIONS: Intl.CollatorOptions = { + numeric: true, + sensitivity: "base", +}; + +function collectTreeFiles(nodes: ReadonlyArray>): TFile[] { + const files: TFile[] = []; + + for (const node of nodes) { + if (node.kind === "file") { + files.push(node.file); + continue; + } + + files.push(...collectTreeFiles(node.children)); + } + + return files; +} + +function orderFilesLikeFileList(files: FileItem[], mode: FileBrowserMode) { + if (mode === "list") { + return files.toSorted((left, right) => + left.path.localeCompare(right.path, undefined, SORT_LOCALE_OPTIONS), + ); + } + + return collectTreeFiles(buildSourceControlFileTree(files)); +} + +function buildReviewTargets(files: FileItem[], mode: FileBrowserMode) { + return orderFilesLikeFileList(files, mode).map((file) => ({ + ...file, + id: reviewDiffItemId(file.path), + })); +} + +function useEnsureBranchFileVersionQueries({ + activeRepo, + baseRef, + headRef, + readyForDiff, + targets, +}: { + activeRepo: string; + baseRef: string; + headRef: string; + readyForDiff: boolean; + targets: ReviewDiffTarget[]; +}) { + const dispatch = useAppDispatch(); + const targetKey = targets.map((target) => target.id).join("\u0001"); + + useEffect(() => { + if (!readyForDiff || !activeRepo || !baseRef || !headRef || targets.length === 0) return; + + const subscriptions = targets.map((target) => + dispatch( + gitApi.endpoints.getBranchFileVersions.initiate( + { + repoPath: activeRepo, + baseRef, + headRef, + relPath: target.path, + previousPath: target.previousPath ?? undefined, + }, + { + subscriptionOptions: { + refetchOnFocus: true, + refetchOnReconnect: true, + }, + }, + ), + ), + ); + + return () => { + for (const subscription of subscriptions) { + subscription.unsubscribe(); + } + }; + }, [activeRepo, baseRef, dispatch, headRef, readyForDiff, targetKey, targets]); +} + +function useBranchFileVersionResults({ + activeRepo, + baseRef, + headRef, + targets, +}: { + activeRepo: string; + baseRef: string; + headRef: string; + targets: ReviewDiffTarget[]; +}) { + const queryResults = useAppSelector( + (state) => + targets.map((target) => + gitApi.endpoints.getBranchFileVersions.select({ + repoPath: activeRepo, + baseRef, + headRef, + relPath: target.path, + previousPath: target.previousPath ?? undefined, + })(state), + ), + shallowEqual, + ); + + return targets.map( + (target, index): TargetResult => ({ + target, + result: queryResults[index], + }), + ); +} + +export function PullRequestCodeViewDiffPane({ + activeRepo, + reviewRepoPath, + reviewProviderId, + pullRequestNumber, + reviewBaseRef, + reviewHeadRef, + readyForDiff, + branchFiles, + activePath, + onSelectPath, + conversation, + focusedLineNumber, + focusedLineIndex, + focusedLineKey, +}: Props) { + const { resolvedTheme } = useTheme(); + const diffStyle = useAppSelector((state) => state.sourceControl.diffStyle); + const fileBrowserMode = useAppSelector( + (state) => state.settings.appSettings.sourceControl.fileTreeRenderMode, + ); + const [expandUnchanged, setExpandUnchanged] = useState(false); + const [selectedRange, setSelectedRange] = useState(null); + const [composerRange, setComposerRange] = useState(null); + const codeViewRef = useRef | null>(null); + const loadedItemsRef = useRef(new Map()); + const viewportRef = useRef(null); + const skipNextActiveScrollRef = useRef(false); + const commentContext: CommentContext = useMemo( + () => ({ kind: "review", baseRef: reviewBaseRef, headRef: reviewHeadRef }), + [reviewBaseRef, reviewHeadRef], + ); + const diffThemeType = getDiffThemeType(resolvedTheme); + const diffThemeCacheSalt = getDiffThemeCacheSalt(diffThemeType); + const diffTheme = useMemo(() => getDiffTheme(), []); + const targets = useMemo( + () => buildReviewTargets(branchFiles, fileBrowserMode), + [branchFiles, fileBrowserMode], + ); + const targetKey = targets.map((target) => target.id).join("\u0001"); + const codeViewKey = `${targetKey}\u0001${expandUnchanged ? "expanded" : "collapsed"}`; + const activeTarget = targets.find((target) => target.path === activePath) ?? null; + const activeItemId = activeTarget?.id ?? null; + const commentMentions = usePullRequestMentionCandidates(conversation); + const { anchorsByFile } = usePullRequestReviewAnchors({ + repoPath: reviewRepoPath, + compareBaseRef: reviewBaseRef, + compareHeadRef: reviewHeadRef, + files: branchFiles, + reviewThreads: conversation?.reviewThreads ?? [], + }); + + useEnsureBranchFileVersionQueries({ + activeRepo, + baseRef: reviewBaseRef, + headRef: reviewHeadRef, + readyForDiff, + targets, + }); + const targetResults = useBranchFileVersionResults({ + activeRepo, + baseRef: reviewBaseRef, + headRef: reviewHeadRef, + targets, + }); + const parsedDiffs = useParsedMultiFileDiffs(targetResults, diffThemeCacheSalt); + const activeResult = targetResults.find((entry) => entry.target.id === activeItemId)?.result; + const activeFileVersions = activeResult?.data ?? null; + const activeNewFile = activeFileVersions?.newFile ?? null; + const activeLineCount = countFileLines(activeNewFile); + const activeErrorMessage = activeResult?.error ? errorMessageFrom(activeResult.error, "") : ""; + const isLoadingInitialDiffs = + targets.length > 0 && + parsedDiffs.length === 0 && + targetResults.some( + ({ result }) => result.status === "pending" || result.status === "uninitialized", + ); + + const lspText = activeNewFile?.contents ?? null; + const lspHoverDocument: LspHoverDocument | undefined = + activeTarget && lspText !== null + ? { repoPath: activeRepo, relPath: activeTarget.path } + : undefined; + const lspDocuments = useMemo( + () => + targetResults + .map(({ target, result }) => + result.data?.newFile + ? { + repoPath: activeRepo, + relPath: target.path, + text: result.data.newFile.contents, + } + : null, + ) + .filter((document): document is { repoPath: string; relPath: string; text: string } => + Boolean(document), + ), + [activeRepo, targetResults], + ); + useCurrentLspDocuments(lspDocuments); + const diagnosticResults = useAppSelector( + (state) => targets.map((target) => selectLspDiagnosticsForFile(state, activeRepo, target.path)), + shallowEqual, + ); + const diagnosticsByItem = useMemo( + () => + new Map(targets.map((target, index) => [target.id, diagnosticResults[index] ?? []] as const)), + [diagnosticResults, targets], + ); + const diagnostics = useMultiDiffDiagnostics(diagnosticsByItem); + const scrollbarMarkers = useMemo( + () => + buildMultiDiffScrollbarMarkers( + parsedDiffs.map(({ target, fileDiff }) => ({ id: target.id, fileDiff })), + diffStyle, + ), + [diffStyle, parsedDiffs], + ); + const isLoadingLspDocuments = targetResults.some( + ({ result }) => result.status === "pending" || result.status === "uninitialized", + ); + const { + hoverState, + onTokenClick: onHoverTokenClick, + popoverRef, + } = useDiffLspHover({ + document: lspHoverDocument, + resetKey: activeItemId ?? "", + }); + const { onTokenClick: onNavigationTokenClick } = useLspTokenNavigation(lspHoverDocument, { + getReturnToDiffTarget: (source) => buildReturnToDiffTarget(activeRepo, activeTarget, source), + }); + + useDiffLineFocus({ + containerRef: viewportRef, + lineNumber: focusedLineNumber, + lineIndex: focusedLineIndex, + lineCount: activeLineCount, + focusKey: focusedLineKey, + enabled: Boolean(activeItemId), + }); + + useEffect(() => { + if (!activeItemId || !codeViewRef.current?.getItem(activeItemId)) return; + + if (skipNextActiveScrollRef.current) { + skipNextActiveScrollRef.current = false; + return; + } + + if (focusedLineNumber) { + codeViewRef.current.scrollTo({ + type: "line", + id: activeItemId, + lineNumber: focusedLineNumber, + side: "additions", + align: "center", + behavior: "instant", + }); + return; + } + + codeViewRef.current.scrollTo({ + type: "item", + id: activeItemId, + align: "start", + behavior: "instant", + }); + }, [activeItemId, codeViewKey, focusedLineNumber, parsedDiffs.length]); + + const annotationEntries = useMemo( + () => + parsedDiffs.map(({ target }) => { + const anchorAnnotations = buildPullRequestAnchorAnnotations({ + anchors: anchorsByFile[target.path] ?? [], + repoPath: reviewRepoPath, + pullRequestNumber, + compareBaseRef: reviewBaseRef, + compareHeadRef: reviewHeadRef, + providerId: reviewProviderId, + }); + const composerAnnotation: DiffLineAnnotation[] = + composerRange?.itemId === target.id + ? [ + { + lineNumber: composerRange.range.end, + metadata: { + type: "composer", + side: composerRange.range.side ?? "deletions", + endSide: composerRange.range.endSide, + startLine: composerRange.range.start, + endLine: composerRange.range.end, + }, + side: composerRange.range.side ?? "deletions", + }, + ] + : []; + + return { + id: target.id, + annotations: [...anchorAnnotations, ...composerAnnotation], + }; + }), + [ + anchorsByFile, + parsedDiffs, + pullRequestNumber, + reviewBaseRef, + reviewHeadRef, + reviewProviderId, + reviewRepoPath, + composerRange, + ], + ); + const annotationsById = useMemo( + () => new Map(annotationEntries.map((entry) => [entry.id, entry.annotations])), + [annotationEntries], + ); + + useEffect(() => { + loadedItemsRef.current.clear(); + }, [codeViewKey]); + + useEffect(() => { + const viewer = codeViewRef.current; + if (!viewer) return; + + for (const target of targets) { + if (viewer.getItem(target.id)) continue; + + const annotations = annotationsById.get(target.id) ?? []; + const fileDiff = createPlaceholderDiff(target.path, "Loading diff..."); + viewer.addItems([ + { + id: target.id, + type: "diff", + fileDiff, + annotations, + version: 0, + }, + ]); + loadedItemsRef.current.set(target.id, { + annotations, + fileDiff, + annotationsKey: getAnnotationsKey(annotations), + }); + } + + for (const target of targets) { + const result = targetResults.find((entry) => entry.target.id === target.id)?.result; + const request = result?.data + ? getParsedDiffRequest( + target.path, + result.data.oldFile, + result.data.newFile, + diffThemeCacheSalt, + ) + : null; + const parsedDiff = request ? peekCachedParsedDiff(request.key) : undefined; + const fileDiff = + parsedDiff ?? + (result?.status === "rejected" || (result?.data && (!request || parsedDiff === null)) + ? createPlaceholderDiff(target.path, "Diff unavailable. This file may be binary.") + : null); + if (!fileDiff) continue; + + const annotations = annotationsById.get(target.id) ?? []; + const annotationsKey = getAnnotationsKey(annotations); + const loadedItem = loadedItemsRef.current.get(target.id); + const viewerItem = viewer.getItem(target.id); + if ( + !viewerItem || + viewerItem.type !== "diff" || + (loadedItem?.fileDiff === fileDiff && loadedItem.annotationsKey === annotationsKey) + ) { + continue; + } + + viewerItem.fileDiff = fileDiff; + viewerItem.annotations = annotations; + viewerItem.version = getCodeViewItemNextVersion(viewerItem); + if (viewer.updateItem(viewerItem)) { + loadedItemsRef.current.set(target.id, { annotations, fileDiff, annotationsKey }); + } + } + }, [annotationsById, diffThemeCacheSalt, parsedDiffs, targetResults, targets]); + + const selectedLines = selectedRange + ? { id: selectedRange.itemId, range: selectedRange.range } + : null; + + const parsedDiffById = useMemo( + () => new Map(parsedDiffs.map(({ target, fileDiff }) => [target.id, fileDiff])), + [parsedDiffs], + ); + const initialCodeViewItems = useMemo[]>( + () => + targets.map((target) => ({ + id: target.id, + type: "diff", + fileDiff: + parsedDiffById.get(target.id) ?? createPlaceholderDiff(target.path, "Loading diff..."), + annotations: annotationsById.get(target.id) ?? [], + version: 0, + })), + [annotationsById, parsedDiffById, targets], + ); + + const targetById = useMemo( + () => new Map(targets.map((target) => [target.id, target])), + [targets], + ); + + const handleSelectItem = useCallback( + (itemId: string) => { + const target = targetById.get(itemId); + if (!target) return; + if (target.path === activePath) return; + skipNextActiveScrollRef.current = true; + onSelectPath(target.path); + }, + [activePath, onSelectPath, targetById], + ); + + const options = useMultiDiffCodeViewOptions({ + diffStyle, + theme: diffTheme, + themeType: diffThemeType, + expandUnchanged, + activeItemId, + targetById, + diagnostics, + onHoverTokenClick, + onNavigationTokenClick, + onSelectItem: handleSelectItem, + buildSelection: ({ itemId, target, range }) => ({ + itemId, + path: target.path, + range, + }), + setSelectedRange, + setComposerRange, + }); + + const renderAnnotation = useDiffAnnotationRenderer({ + composer: () => { + if (!composerRange) return null; + return ( + { + setSelectedRange(null); + setComposerRange(null); + }} + mentions={commentMentions} + /> + ); + }, + "pull-request-anchor": (data) => ( + + ), + "pull-request-thread": (data) => ( + + ), + annotation: (data) => , + }); + + const renderHeaderMetadata = useCallback( + (item: CodeViewItem) => { + const target = targetById.get(item.id); + if (!target) return null; + + return ( + setExpandUnchanged((previous) => !previous)} + /> + ); + }, + [activeItemId, commentContext, expandUnchanged, reviewHeadRef, targetById], + ); + + if (!activePath) { + return
Select a file to view diff.
; + } + + return ( +
+ + + + + {activeErrorMessage ? ( +
{activeErrorMessage}
+ ) : null} + {isLoadingInitialDiffs ? ( +
Loading diffs...
+ ) : parsedDiffs.length === 0 ? ( +
No renderable diff content.
+ ) : null} +
+ + + +
+
+ ); +} diff --git a/apps/desktop/src/features/pull-requests/components/PullRequestWindowedDiff.tsx b/apps/desktop/src/features/pull-requests/components/PullRequestWindowedDiff.tsx index 9af2015..4cb8c92 100644 --- a/apps/desktop/src/features/pull-requests/components/PullRequestWindowedDiff.tsx +++ b/apps/desktop/src/features/pull-requests/components/PullRequestWindowedDiff.tsx @@ -271,7 +271,6 @@ function WindowedFileDiff({ renderHeaderPrefix: undefined, renderHeaderMetadata: undefined, renderGutterUtility: undefined, - renderHoverUtility: undefined, getHoveredLine, lineAnnotations, }), diff --git a/apps/desktop/src/features/pull-requests/screens/PullRequestFiles.tsx b/apps/desktop/src/features/pull-requests/screens/PullRequestFiles.tsx index 40ecc48..9541c2d 100644 --- a/apps/desktop/src/features/pull-requests/screens/PullRequestFiles.tsx +++ b/apps/desktop/src/features/pull-requests/screens/PullRequestFiles.tsx @@ -12,27 +12,21 @@ import { EmptyMedia, EmptyTitle, } from "@/components/ui/empty"; -import { DiffWorkspace } from "@/features/diff-view/DiffWorkspace"; import { useGetPullRequestConversationQuery, useGetPullRequestFilesQuery, usePreparePullRequestCompareRefsQuery, useResolveHostedRepoQuery, } from "@/features/hosted-repos/api"; +import { PullRequestCodeViewDiffPane } from "@/features/pull-requests/components/PullRequestCodeViewDiffPane"; import ReviewCommentsCopyToolbar from "@/features/pull-requests/components/ReviewCopyBar"; -import { usePullRequestMentionCandidates } from "@/features/pull-requests/hooks/usePullRequestMentionCandidates"; -import { usePullRequestReviewAnchors } from "@/features/pull-requests/hooks/usePullRequestReviewAnchors"; import FilesSidebar from "@/features/pull-requests/screens/PullRequestFileList"; import { setPullRequestPreviewActiveFilePath } from "@/features/pull-requests/pullRequestsSlice"; -import { buildPullRequestAnchorAnnotations } from "@/features/pull-requests/utils/reviewAnchors"; -import { useGetBranchFileVersionsQuery } from "@/features/source-control/api"; -import { useThrottledDiffSelection } from "@/features/source-control/hooks/useThrottledDiffSelection"; import { errorMessageFrom } from "@/features/source-control/shared-utils/errorMessage"; import type { GitProviderId, PullRequestChangedFile, PullRequestConversation, - PullRequestReviewThread, } from "@/platform/desktop"; export const PullRequestFiles = () => { @@ -87,10 +81,9 @@ export const PullRequestFiles = () => { }), }); - const { conversation, reviewThreads } = useGetPullRequestConversationQuery(filesQueryArg, { + const { conversation } = useGetPullRequestConversationQuery(filesQueryArg, { selectFromResult: ({ data }) => ({ conversation: data ?? null, - reviewThreads: data?.reviewThreads ?? [], }), pollingInterval: 10000, refetchOnFocus: true, @@ -127,7 +120,6 @@ export const PullRequestFiles = () => { isLoadingCompareRefs={isLoadingCompareRefs} files={files} conversation={conversation} - reviewThreads={reviewThreads} /> } @@ -170,7 +162,6 @@ function FilesDiffViewer({ isLoadingCompareRefs, files, conversation, - reviewThreads, }: { providerId?: string; repoPath: string; @@ -181,73 +172,22 @@ function FilesDiffViewer({ isLoadingCompareRefs: boolean; files: PullRequestChangedFile[]; conversation: PullRequestConversation | null; - reviewThreads: PullRequestReviewThread[]; }) { + const dispatch = useAppDispatch(); const selectedPath = useAppSelector((state) => state.pullRequests.previewActiveFilePath); const previewFileJumpTarget = useAppSelector((state) => state.pullRequests.previewFileJumpTarget); - const { anchorsByFile } = usePullRequestReviewAnchors({ - repoPath, - compareBaseRef, - compareHeadRef, - files, - reviewThreads, - }); - const commentMentions = usePullRequestMentionCandidates(conversation); const selectedFile = files.find((file) => file.path === selectedPath) ?? null; - const previewSelection = useThrottledDiffSelection( - selectedFile - ? { path: selectedFile.path, previousPath: selectedFile.previousPath ?? undefined } - : null, - ); - const previewPath = previewSelection?.path ?? selectedFile?.path ?? ""; - const previewFile = files.find((file) => file.path === previewPath) ?? selectedFile; const hasCompareRefs = Boolean(compareBaseRef && compareHeadRef); - - const branchFileVersionsQuery = useGetBranchFileVersionsQuery( - previewPath && hasCompareRefs && previewFile - ? { - repoPath, - baseRef: compareBaseRef, - headRef: compareHeadRef, - relPath: previewPath, - previousPath: previewFile.previousPath ?? undefined, - } - : skipToken, - ); - - const branchFileVersions = - branchFileVersionsQuery.currentData ?? branchFileVersionsQuery.data ?? null; - const selectedOldFile = branchFileVersions?.oldFile ?? null; - const selectedNewFile = branchFileVersions?.newFile ?? null; - const branchFileVersionsError = branchFileVersions - ? "" - : errorMessageFrom(branchFileVersionsQuery.error, ""); - const isLoadingBranchFileVersions = - Boolean(previewPath && hasCompareRefs && !branchFileVersions) && - (branchFileVersionsQuery.isUninitialized || - branchFileVersionsQuery.isLoading || - branchFileVersionsQuery.isFetching); - - const anchorAnnotations = previewFile - ? buildPullRequestAnchorAnnotations({ - anchors: anchorsByFile[previewFile.path] ?? [], - repoPath, - pullRequestNumber, - compareBaseRef, - compareHeadRef, - providerId: providerId as GitProviderId | undefined, - }) - : []; const focusedLineNumber = - previewFileJumpTarget && previewFileJumpTarget.path === previewPath + previewFileJumpTarget && previewFileJumpTarget.path === selectedPath ? previewFileJumpTarget.lineNumber : null; const focusedLineIndex = - previewFileJumpTarget && previewFileJumpTarget.path === previewPath + previewFileJumpTarget && previewFileJumpTarget.path === selectedPath ? previewFileJumpTarget.lineIndex : null; const focusedLineKey = - previewFileJumpTarget && previewFileJumpTarget.path === previewPath + previewFileJumpTarget && previewFileJumpTarget.path === selectedPath ? previewFileJumpTarget.focusKey : null; @@ -302,41 +242,6 @@ function FilesDiffViewer({ ); } - if (isLoadingBranchFileVersions) { - return ( -
- - Loading diff... -
- ); - } - - if (branchFileVersionsError) { - return ( -
- {branchFileVersionsError} -
- ); - } - - if (!selectedOldFile && !selectedNewFile) { - return ( -
- - - - - - Diff unavailable - - This file may be binary or the prepared refs did not return file contents. - - - -
- ); - } - return (
@@ -345,23 +250,24 @@ function FilesDiffViewer({ pullRequestNumber={pullRequestNumber} compareBaseRef={compareBaseRef} compareHeadRef={compareHeadRef} - activePath={previewFile?.path ?? selectedFile.path} - activePreviousPath={previewFile?.previousPath ?? selectedFile.previousPath ?? undefined} + activePath={selectedFile.path} + activePreviousPath={selectedFile.previousPath ?? undefined} /> - dispatch(setPullRequestPreviewActiveFilePath(path))} + conversation={conversation} focusedLineNumber={focusedLineNumber} focusedLineIndex={focusedLineIndex} focusedLineKey={focusedLineKey} - annotationItems={anchorAnnotations} - commentMentions={commentMentions} />
diff --git a/apps/desktop/src/features/pull-requests/screens/PullRequestReviewFilesScreen.tsx b/apps/desktop/src/features/pull-requests/screens/PullRequestReviewFilesScreen.tsx index 35b520c..5bc172a 100644 --- a/apps/desktop/src/features/pull-requests/screens/PullRequestReviewFilesScreen.tsx +++ b/apps/desktop/src/features/pull-requests/screens/PullRequestReviewFilesScreen.tsx @@ -3,27 +3,17 @@ import { useEffect } from "react"; import { useAppDispatch, useAppSelector } from "@/app/hooks"; import { ResizableSidebarLayout } from "@/components/layout/ResizableSidebarLayout"; -import { DiffWorkspace } from "@/features/diff-view/DiffWorkspace"; import { useGetPullRequestConversationQuery } from "@/features/hosted-repos/api"; -import { LspStatusNotice } from "@/features/lsp/components/LspStatusNotice"; -import { useCurrentLspDocument } from "@/features/lsp/hooks/useCurrentLspDocument"; -import { useDiffDiagnostics } from "@/features/lsp/hooks/useDiffDiagnostics"; +import { PullRequestCodeViewDiffPane } from "@/features/pull-requests/components/PullRequestCodeViewDiffPane"; import ReviewCommentsCopyToolbar from "@/features/pull-requests/components/ReviewCopyBar"; import { PullRequestFilesSidebar } from "@/features/pull-requests/components/PullRequestFilesSidebar"; -import { usePullRequestMentionCandidates } from "@/features/pull-requests/hooks/usePullRequestMentionCandidates"; -import { usePullRequestReviewAnchors } from "@/features/pull-requests/hooks/usePullRequestReviewAnchors"; import { clearPullRequestFileJumpTarget, setPullRequestFilesViewMode, } from "@/features/pull-requests/pullRequestsSlice"; -import { buildPullRequestAnchorAnnotations } from "@/features/pull-requests/utils/reviewAnchors"; -import { - useGetBranchFilesQuery, - useGetBranchFileVersionsQuery, -} from "@/features/source-control/api"; +import { useGetBranchFilesQuery } from "@/features/source-control/api"; import { GeneralFileViewer } from "@/features/source-control/components/GeneralFileViewer"; -import { useThrottledDiffSelection } from "@/features/source-control/hooks/useThrottledDiffSelection"; -import { errorMessageFrom } from "@/features/source-control/shared-utils/errorMessage"; +import { setReviewActivePath } from "@/features/source-control/sourceControlSlice"; import type { FileItem } from "@/features/source-control/types"; import type { GitProviderId, PullRequestConversation } from "@/platform/desktop"; @@ -63,63 +53,9 @@ function PullRequestDiffPane({ focusedLineIndex, focusedLineKey, }: PullRequestDiffPaneProps) { + const dispatch = useAppDispatch(); const reviewActivePath = useAppSelector((state) => state.sourceControl.reviewActivePath); const selectedReviewFile = branchFiles.find((file) => file.path === reviewActivePath); - const previewSelection = useThrottledDiffSelection( - reviewActivePath - ? { - path: reviewActivePath, - previousPath: selectedReviewFile?.previousPath ?? undefined, - } - : null, - ); - const previewPath = previewSelection?.path ?? reviewActivePath; - const commentMentions = usePullRequestMentionCandidates(conversation); - const { anchorsByFile } = usePullRequestReviewAnchors({ - repoPath: reviewRepoPath, - compareBaseRef: reviewBaseRef, - compareHeadRef: reviewHeadRef, - files: branchFiles, - reviewThreads: conversation?.reviewThreads ?? [], - }); - const annotationItems = previewPath - ? buildPullRequestAnchorAnnotations({ - anchors: anchorsByFile[previewPath] ?? [], - repoPath: reviewRepoPath, - pullRequestNumber, - compareBaseRef: reviewBaseRef, - compareHeadRef: reviewHeadRef, - providerId: reviewProviderId, - }) - : []; - - const branchFileVersionsQuery = useGetBranchFileVersionsQuery( - readyForDiff && previewSelection - ? { - repoPath: activeRepo, - baseRef: reviewBaseRef, - headRef: reviewHeadRef, - relPath: previewSelection.path, - previousPath: previewSelection.previousPath, - } - : skipToken, - ); - - const reviewVersions = branchFileVersionsQuery.currentData ?? branchFileVersionsQuery.data; - const oldFile = reviewVersions?.oldFile ?? null; - const newFile = reviewVersions?.newFile ?? null; - const loadingPatch = !reviewVersions && branchFileVersionsQuery.isLoading; - const errorMessage = reviewVersions ? "" : errorMessageFrom(branchFileVersionsQuery.error, ""); - const lspText = !loadingPatch && newFile ? newFile.contents : null; - const lspHoverDocument = - activeRepo && previewPath && lspText !== null - ? { repoPath: activeRepo, relPath: previewPath } - : undefined; - const lspDiagnostics = useDiffDiagnostics(activeRepo, previewPath ?? ""); - - useCurrentLspDocument(activeRepo, previewPath ?? "", lspText); - - const hasContent = oldFile || newFile; return (
@@ -128,47 +64,26 @@ function PullRequestDiffPane({ pullRequestNumber={pullRequestNumber} compareBaseRef={reviewBaseRef} compareHeadRef={reviewHeadRef} - activePath={previewPath ?? ""} - activePreviousPath={previewSelection?.previousPath} + activePath={reviewActivePath} + activePreviousPath={selectedReviewFile?.previousPath ?? undefined} />
- {!reviewActivePath ? ( -
Select a file to view diff.
- ) : ( -
- - - {errorMessage ? ( -
-
{errorMessage}
-
- ) : loadingPatch ? ( -
-
Loading diff...
-
- ) : !hasContent ? ( -
-
No diff content.
-
- ) : null} -
- )} + dispatch(setReviewActivePath(path))} + conversation={conversation} + focusedLineNumber={focusedLineNumber} + focusedLineIndex={focusedLineIndex} + focusedLineKey={focusedLineKey} + />
); diff --git a/apps/desktop/src/features/source-control/components/ChangesCodeViewDiffPane.tsx b/apps/desktop/src/features/source-control/components/ChangesCodeViewDiffPane.tsx new file mode 100644 index 0000000..67aa6a6 --- /dev/null +++ b/apps/desktop/src/features/source-control/components/ChangesCodeViewDiffPane.tsx @@ -0,0 +1,756 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { shallowEqual } from "react-redux"; +import { CodeView, type CodeViewHandle, type CodeViewItem } from "@pierre/diffs/react"; +import { type DiffLineAnnotation, type FileDiffMetadata } from "@pierre/diffs"; +import { Minus, Plus, Trash2 } from "lucide-react"; +import { useTheme } from "next-themes"; + +import { useAppDispatch, useAppSelector } from "@/app/hooks"; +import { DiagnosticTokenPopover } from "@/features/diff-view/components/DiagnosticTokenPopover"; +import { DiffHeaderMetadataControls } from "@/features/diff-view/components/DiffHeaderMetadataControls"; +import { CommentAnnotation } from "@/features/diff-view/components/CommentAnnotation"; +import { CommentComposer } from "@/features/diff-view/components/CommentComposer"; +import { + DiffLspHoverPopover, + type LspHoverDocument, + useDiffLspHover, +} from "@/features/diff-view/useDiffLspHover"; +import { + getDiffTheme, + getDiffThemeCacheSalt, + getDiffThemeType, +} from "@/features/diff-view/diffRenderConfig"; +import { useDiffAnnotationRenderer } from "@/features/diff-view/hooks/useDiffAnnotationRenderer"; +import { + countFileLines, + createPlaceholderDiff, + getAnnotationsKey, + getCodeViewItemNextVersion, + MULTI_DIFF_SCROLLBAR_CSS, + useMultiDiffCodeViewOptions, + useParsedMultiFileDiffs, +} from "@/features/diff-view/hooks/useMultiDiffCodeViewOptions"; +import { useMultiDiffDiagnostics } from "@/features/diff-view/hooks/useMultiDiffDiagnostics"; +import { + getParsedDiffRequest, + peekCachedParsedDiff, +} from "@/features/diff-view/services/parsedDiffCache"; +import { LspDiagnosticsSummaryNotice } from "@/features/lsp/components/LspStatusNotice"; +import { LspSymbolPeekContainer } from "@/features/lsp/components/LspSymbolPeek"; +import { useCurrentLspDocuments } from "@/features/lsp/hooks/useCurrentLspDocument"; +import { selectLspDiagnosticsForFile } from "@/features/lsp/selectors"; +import { useLspTokenNavigation } from "@/features/lsp/useLspTokenNavigation"; +import { fileComments, toLineAnnotations } from "@/features/comments/actions"; +import { useFirstCommentTip } from "@/features/comments/useFirstCommentTip"; +import { gitApi } from "@/features/source-control/api"; +import { + buildUnifiedChangeTreeFiles, + compareUnifiedChangeListEntries, + compareUnifiedChangeTreeDirectories, + compareUnifiedChangeTreeEntries, +} from "@/features/source-control/components/changesUnifiedPierreTree"; +import { buildTreeOptions } from "@/features/source-control/components/pierreFileTree"; +import { applyHunkToIndexAction, selectFile } from "@/features/source-control/actions"; +import { + buildSourceControlFileTree, + type SourceControlTreeNode, +} from "@/features/source-control/fileTree"; +import { + buildIndexContentsForHunkOperation, + type DiffHunkActionAnnotation, + type DiffHunkActionPayload, + type DiffHunkOperation, +} from "@/features/source-control/hunkOperations"; +import { useDiffLineFocus } from "@/features/source-control/diffLineFocus"; +import type { + Bucket, + BucketedFile, + CommentContext, + DiffAnnotationItem, + DiffReturnTarget, + FileBrowserMode, + FileItem, + GitSnapshot, + SelectionRange, +} from "@/features/source-control/types"; +import { errorMessageFrom } from "@/features/source-control/shared-utils/errorMessage"; + +const COMMENT_CONTEXT: CommentContext = { kind: "changes" }; + +type ChangesDiffTarget = BucketedFile & { + id: string; +}; + +type FileVersionResult = ReturnType>; + +type TargetResult = { + target: ChangesDiffTarget; + result: FileVersionResult; +}; + +type SelectedDiffRange = { + itemId: string; + path: string; + bucket: Bucket; + range: SelectionRange; +}; + +type LoadedCodeViewItemState = { + annotations: DiffLineAnnotation[]; + fileDiff: FileDiffMetadata; + annotationsKey: string; +}; + +type Props = { + activeRepo: string; + snapshot: GitSnapshot; +}; + +function toBucketedFile(file: FileItem, bucket: Bucket) { + return { + path: file.path, + previousPath: file.previousPath, + status: file.status, + bucket, + } satisfies BucketedFile; +} + +function changesDiffItemId(bucket: Bucket, path: string) { + return `${bucket}\u0000${path}`; +} + +function hunkOperationsForBucket(bucket: Bucket): DiffHunkOperation[] { + if (bucket === "unstaged") return ["stage", "discard"]; + if (bucket === "staged") return ["unstage"]; + return []; +} + +function buildReturnToDiffTarget( + activeRepo: string, + target: ChangesDiffTarget | null, + source: { lineNumber: number; lineIndex: string | null }, +): DiffReturnTarget | null { + if (!activeRepo || !target || source.lineNumber <= 0) return null; + + return { + kind: "changes", + repoPath: activeRepo, + path: target.path, + bucket: target.bucket, + lineNumber: source.lineNumber, + lineIndex: source.lineIndex, + }; +} + +function collectTreeFiles(nodes: ReadonlyArray>): TFile[] { + const files: TFile[] = []; + + for (const node of nodes) { + if (node.kind === "file") { + files.push(node.file); + continue; + } + + files.push(...collectTreeFiles(node.children)); + } + + return files; +} + +function buildChangeTargets(snapshot: GitSnapshot, mode: FileBrowserMode) { + const stagedRows = snapshot.staged + .filter((file) => file.status !== "unmerged") + .map((file) => toBucketedFile(file, "staged")); + const changedRows = [ + ...snapshot.unstaged + .filter((file) => file.status !== "unmerged") + .map((file) => toBucketedFile(file, "unstaged")), + ...snapshot.untracked.map((file) => toBucketedFile(file, "untracked")), + ]; + const unifiedFiles = buildUnifiedChangeTreeFiles(stagedRows, changedRows, [], mode); + const sort = mode === "list" ? compareUnifiedChangeListEntries : compareUnifiedChangeTreeEntries; + const orderedFiles = collectTreeFiles( + buildSourceControlFileTree( + unifiedFiles, + buildTreeOptions(compareUnifiedChangeTreeDirectories, false, sort), + ), + ); + + return orderedFiles.map((file) => ({ + ...file, + path: file.realPath, + id: changesDiffItemId(file.bucket, file.realPath), + })); +} + +function useEnsureFileVersionQueries(activeRepo: string, targets: ChangesDiffTarget[]) { + const dispatch = useAppDispatch(); + const targetKey = targets.map((target) => target.id).join("\u0001"); + + useEffect(() => { + if (!activeRepo || targets.length === 0) return; + + const subscriptions = targets.map((target) => + dispatch( + gitApi.endpoints.getFileVersions.initiate( + { repoPath: activeRepo, bucket: target.bucket, relPath: target.path }, + { + subscriptionOptions: { + refetchOnFocus: true, + refetchOnReconnect: true, + }, + }, + ), + ), + ); + + return () => { + for (const subscription of subscriptions) { + subscription.unsubscribe(); + } + }; + }, [activeRepo, dispatch, targetKey, targets]); +} + +function useFileVersionResults(activeRepo: string, targets: ChangesDiffTarget[]) { + const queryResults = useAppSelector( + (state) => + targets.map((target) => + gitApi.endpoints.getFileVersions.select({ + repoPath: activeRepo, + bucket: target.bucket, + relPath: target.path, + })(state), + ), + shallowEqual, + ); + + return targets.map( + (target, index): TargetResult => ({ + target, + result: queryResults[index], + }), + ); +} + +function buildHunkActionAnnotations( + fileDiff: FileDiffMetadata, + target: ChangesDiffTarget, + diffStyle: "split" | "unified", + onHunkAction: ( + target: ChangesDiffTarget, + operation: DiffHunkOperation, + payload: DiffHunkActionPayload, + ) => void, +): DiffLineAnnotation[] { + const operations = hunkOperationsForBucket(target.bucket); + if (operations.length === 0) return []; + + return fileDiff.hunks.map((hunk, hunkIndex) => { + let additionOffset = 0; + let deletionOffset = 0; + + for (const content of hunk.hunkContent) { + if (content.type === "context") { + additionOffset += content.lines; + deletionOffset += content.lines; + continue; + } + + const side = content.additions > 0 ? "additions" : "deletions"; + const firstChangedLine = + side === "additions" + ? hunk.additionStart + additionOffset + : hunk.deletionStart + deletionOffset; + const hunkStartLine = side === "additions" ? hunk.additionStart : hunk.deletionStart; + + // In split mode, place the button on the first actual changed line rather + // than the context line above it. Shared context rows are estimated by the + // virtualizer as plain text lines; injecting an annotation into only one + // column causes a height mismatch and scroll jump when the row is measured. + // Changed rows are already asymmetric, so the estimate is closer. + const lineNumber = + diffStyle === "split" + ? firstChangedLine + : firstChangedLine > hunkStartLine + ? firstChangedLine - 1 + : firstChangedLine; + + const metadata: DiffHunkActionAnnotation = { + type: "hunk-action", + operations, + fileDiff, + hunkIndex, + onAction: (operation, payload) => onHunkAction(target, operation, payload), + }; + + return { side, lineNumber, metadata }; + } + + const metadata: DiffHunkActionAnnotation = { + type: "hunk-action", + operations, + fileDiff, + hunkIndex, + onAction: (operation, payload) => onHunkAction(target, operation, payload), + }; + return { side: "additions", lineNumber: hunk.additionStart, metadata }; + }); +} + +export function ChangesCodeViewDiffPane({ activeRepo, snapshot }: Props) { + const dispatch = useAppDispatch(); + const { resolvedTheme } = useTheme(); + const activeBucket = useAppSelector((state) => state.sourceControl.activeBucket); + const activePath = useAppSelector((state) => state.sourceControl.activePath); + const diffStyle = useAppSelector((state) => state.sourceControl.diffStyle); + const fileBrowserMode = useAppSelector( + (state) => state.settings.appSettings.sourceControl.fileTreeRenderMode, + ); + const diffFocusTarget = useAppSelector((state) => state.sourceControl.diffFocusTarget); + const comments = useAppSelector((state) => state.comments); + const [expandUnchanged, setExpandUnchanged] = useState(false); + const [selectedRange, setSelectedRange] = useState(null); + const [composerRange, setComposerRange] = useState(null); + const codeViewRef = useRef | null>(null); + const loadedItemsRef = useRef(new Map()); + const viewportRef = useRef(null); + const skipNextActiveScrollRef = useRef(false); + const diffThemeType = getDiffThemeType(resolvedTheme); + const diffThemeCacheSalt = getDiffThemeCacheSalt(diffThemeType); + const diffTheme = useMemo(() => getDiffTheme(), []); + const targets = useMemo( + () => buildChangeTargets(snapshot, fileBrowserMode), + [fileBrowserMode, snapshot], + ); + const targetKey = targets.map((target) => target.id).join("\u0001"); + const codeViewKey = `${targetKey}\u0001${expandUnchanged ? "expanded" : "collapsed"}`; + const activeTarget = + targets.find((target) => target.bucket === activeBucket && target.path === activePath) ?? null; + const activeItemId = activeTarget?.id ?? null; + + useEnsureFileVersionQueries(activeRepo, targets); + const targetResults = useFileVersionResults(activeRepo, targets); + const parsedDiffs = useParsedMultiFileDiffs(targetResults, diffThemeCacheSalt); + + const activeResult = targetResults.find((entry) => entry.target.id === activeItemId)?.result; + const activeFileVersions = activeResult?.data ?? null; + const activeNewFile = activeFileVersions?.newFile ?? null; + const activeLineCount = countFileLines(activeNewFile); + const activeErrorMessage = activeResult?.error ? errorMessageFrom(activeResult.error, "") : ""; + const isLoadingInitialDiffs = + targets.length > 0 && + parsedDiffs.length === 0 && + targetResults.some( + ({ result }) => result.status === "pending" || result.status === "uninitialized", + ); + + const lspText = activeNewFile?.contents ?? null; + const lspHoverDocument: LspHoverDocument | undefined = + activeTarget && lspText !== null + ? { repoPath: activeRepo, relPath: activeTarget.path } + : undefined; + const lspDocuments = useMemo( + () => + targetResults + .map(({ target, result }) => + result.data?.newFile + ? { + repoPath: activeRepo, + relPath: target.path, + text: result.data.newFile.contents, + } + : null, + ) + .filter((document): document is { repoPath: string; relPath: string; text: string } => + Boolean(document), + ), + [activeRepo, targetResults], + ); + useCurrentLspDocuments(lspDocuments); + + const diagnosticResults = useAppSelector( + (state) => targets.map((target) => selectLspDiagnosticsForFile(state, activeRepo, target.path)), + shallowEqual, + ); + const diagnosticsByItem = useMemo( + () => + new Map(targets.map((target, index) => [target.id, diagnosticResults[index] ?? []] as const)), + [diagnosticResults, targets], + ); + const diagnostics = useMultiDiffDiagnostics(diagnosticsByItem); + const isLoadingLspDocuments = targetResults.some( + ({ result }) => result.status === "pending" || result.status === "uninitialized", + ); + + const lspResetKey = activeItemId ?? ""; + const { + hoverState, + onTokenClick: onHoverTokenClick, + popoverRef, + } = useDiffLspHover({ + document: lspHoverDocument, + resetKey: lspResetKey, + }); + const { onTokenClick: onNavigationTokenClick } = useLspTokenNavigation(lspHoverDocument, { + getReturnToDiffTarget: (source) => buildReturnToDiffTarget(activeRepo, activeTarget, source), + }); + + const { showFirstCommentTip } = useFirstCommentTip(); + const repoCommentCount = comments.filter((comment) => comment.repoPath === activeRepo).length; + + const focusedLineNumber = + diffFocusTarget?.kind === "changes" && diffFocusTarget.path === activePath + ? diffFocusTarget.lineNumber + : null; + const focusedLineIndex = + diffFocusTarget?.kind === "changes" && diffFocusTarget.path === activePath + ? diffFocusTarget.lineIndex + : null; + const focusedLineKey = + diffFocusTarget?.kind === "changes" && diffFocusTarget.path === activePath + ? diffFocusTarget.focusKey + : null; + + useDiffLineFocus({ + containerRef: viewportRef, + lineNumber: focusedLineNumber, + lineIndex: focusedLineIndex, + lineCount: activeLineCount, + focusKey: focusedLineKey, + enabled: Boolean(activeItemId), + }); + + useEffect(() => { + if (!activeItemId || !codeViewRef.current?.getItem(activeItemId)) return; + + if (skipNextActiveScrollRef.current) { + skipNextActiveScrollRef.current = false; + return; + } + + codeViewRef.current.scrollTo({ + type: "item", + id: activeItemId, + align: "start", + behavior: "instant", + }); + }, [activeItemId, codeViewKey]); + + // ------------------------------------------------------------------------- + // Hunk actions + // ------------------------------------------------------------------------- + const handleHunkAction = useCallback( + (target: ChangesDiffTarget, operation: DiffHunkOperation, payload: DiffHunkActionPayload) => { + const contents = buildIndexContentsForHunkOperation({ + fileDiff: payload.fileDiff, + hunkIndex: payload.hunkIndex, + operation, + }); + + void dispatch( + applyHunkToIndexAction({ + filePath: target.path, + contents, + operation, + }), + ); + }, + [dispatch], + ); + + // ------------------------------------------------------------------------- + // Build per-item annotations + // ------------------------------------------------------------------------- + const commentsByPath = useMemo(() => { + const next = new Map>(); + for (const target of targets) { + next.set( + target.id, + toLineAnnotations(fileComments(comments, activeRepo, target.path, COMMENT_CONTEXT)), + ); + } + return next; + }, [activeRepo, comments, targets]); + + const annotationEntries = useMemo( + () => + parsedDiffs.map(({ target, fileDiff }) => { + const commentAnnotations = commentsByPath.get(target.id) ?? []; + const composerAnnotation: DiffLineAnnotation[] = + composerRange?.itemId === target.id + ? [ + { + lineNumber: composerRange.range.end, + metadata: { + type: "composer", + side: composerRange.range.side ?? "deletions", + endSide: composerRange.range.endSide, + startLine: composerRange.range.start, + endLine: composerRange.range.end, + }, + side: composerRange.range.side ?? "deletions", + }, + ] + : []; + return { + id: target.id, + annotations: [ + ...buildHunkActionAnnotations(fileDiff, target, diffStyle, handleHunkAction), + ...commentAnnotations, + ...composerAnnotation, + ], + }; + }), + [commentsByPath, composerRange, diffStyle, handleHunkAction, parsedDiffs], + ); + const annotationsById = useMemo( + () => new Map(annotationEntries.map((entry) => [entry.id, entry.annotations])), + [annotationEntries], + ); + + useEffect(() => { + loadedItemsRef.current.clear(); + }, [codeViewKey]); + + useEffect(() => { + const viewer = codeViewRef.current; + if (!viewer) return; + + for (const target of targets) { + if (viewer.getItem(target.id)) continue; + + const annotations = annotationsById.get(target.id) ?? []; + const fileDiff = createPlaceholderDiff(target.path, "Loading diff..."); + viewer.addItems([ + { + id: target.id, + type: "diff", + fileDiff, + annotations, + version: 0, + }, + ]); + loadedItemsRef.current.set(target.id, { + annotations, + fileDiff, + annotationsKey: getAnnotationsKey(annotations), + }); + } + + for (const target of targets) { + const result = targetResults.find((entry) => entry.target.id === target.id)?.result; + const request = result?.data + ? getParsedDiffRequest( + target.path, + result.data.oldFile, + result.data.newFile, + diffThemeCacheSalt, + ) + : null; + const parsedDiff = request ? peekCachedParsedDiff(request.key) : undefined; + const fileDiff = + parsedDiff ?? + (result?.status === "rejected" || (result?.data && (!request || parsedDiff === null)) + ? createPlaceholderDiff(target.path, "Diff unavailable. This file may be binary.") + : null); + if (!fileDiff) continue; + + const annotations = annotationsById.get(target.id) ?? []; + const annotationsKey = getAnnotationsKey(annotations); + const loadedItem = loadedItemsRef.current.get(target.id); + const viewerItem = viewer.getItem(target.id); + if ( + !viewerItem || + viewerItem.type !== "diff" || + (loadedItem?.fileDiff === fileDiff && loadedItem.annotationsKey === annotationsKey) + ) { + continue; + } + + viewerItem.fileDiff = fileDiff; + viewerItem.annotations = annotations; + viewerItem.version = getCodeViewItemNextVersion(viewerItem); + if (viewer.updateItem(viewerItem)) { + loadedItemsRef.current.set(target.id, { annotations, fileDiff, annotationsKey }); + } + } + }, [annotationsById, diffThemeCacheSalt, parsedDiffs, targetResults, targets]); + + const targetById = useMemo( + () => new Map(targets.map((target) => [target.id, target])), + [targets], + ); + + const handleSelectItem = useCallback( + (itemId: string) => { + const target = targetById.get(itemId); + if (!target) return; + if (target.bucket === activeBucket && target.path === activePath) return; + skipNextActiveScrollRef.current = true; + void dispatch(selectFile(target.bucket, target.path)); + }, + [activeBucket, activePath, dispatch, targetById], + ); + + const options = useMultiDiffCodeViewOptions({ + diffStyle, + theme: diffTheme, + themeType: diffThemeType, + expandUnchanged, + activeItemId, + targetById, + diagnostics, + onHoverTokenClick, + onNavigationTokenClick, + onSelectItem: handleSelectItem, + buildSelection: ({ itemId, target, range }) => ({ + itemId, + path: target.path, + bucket: target.bucket, + range, + }), + setSelectedRange, + setComposerRange, + }); + + const selectedLines = selectedRange + ? { id: selectedRange.itemId, range: selectedRange.range } + : null; + + const parsedDiffById = useMemo( + () => new Map(parsedDiffs.map(({ target, fileDiff }) => [target.id, fileDiff])), + [parsedDiffs], + ); + const initialCodeViewItems = useMemo[]>( + () => + targets.map((target) => ({ + id: target.id, + type: "diff", + fileDiff: + parsedDiffById.get(target.id) ?? createPlaceholderDiff(target.path, "Loading diff..."), + annotations: annotationsById.get(target.id) ?? [], + version: 0, + })), + [annotationsById, parsedDiffById, targets], + ); + + const renderAnnotation = useDiffAnnotationRenderer({ + "hunk-action": (data: DiffHunkActionAnnotation) => { + const actionMeta = { + stage: { label: "Stage hunk", Icon: Plus }, + unstage: { label: "Unstage hunk", Icon: Minus }, + discard: { label: "Discard hunk", Icon: Trash2 }, + } as const; + + return ( +
+ {data.operations.map((operation) => { + const { label, Icon } = actionMeta[operation]; + + return ( + + ); + })} +
+ ); + }, + composer: () => { + if (!composerRange) return null; + return ( + { + setSelectedRange(null); + setComposerRange(null); + }} + onBeforeSubmit={repoCommentCount === 0 ? showFirstCommentTip : undefined} + /> + ); + }, + annotation: (data) => , + }); + + const renderHeaderMetadata = useCallback( + (item: CodeViewItem) => { + const target = targetById.get(item.id); + if (!target) return null; + + return ( + setExpandUnchanged((previous) => !previous)} + /> + ); + }, + [activeItemId, expandUnchanged, targetById], + ); + + if (targets.length === 0) { + return
No changed files.
; + } + + return ( +
+ + + + + {activeErrorMessage ? ( +
{activeErrorMessage}
+ ) : null} + {isLoadingInitialDiffs ? ( +
Loading diffs...
+ ) : parsedDiffs.length === 0 ? ( +
No renderable diff content.
+ ) : null} +
+ + +
+
+ ); +} diff --git a/apps/desktop/src/features/source-control/components/GeneralFileViewer.tsx b/apps/desktop/src/features/source-control/components/GeneralFileViewer.tsx index 1523df8..cee9f70 100644 --- a/apps/desktop/src/features/source-control/components/GeneralFileViewer.tsx +++ b/apps/desktop/src/features/source-control/components/GeneralFileViewer.tsx @@ -1,4 +1,4 @@ -import { useRef } from "react"; +import { useMemo, useRef } from "react"; import { skipToken } from "@reduxjs/toolkit/query"; import { File as PierreFile, Virtualizer } from "@pierre/diffs/react"; import { ArrowLeft } from "lucide-react"; @@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button"; import { DIFF_LINE_FOCUS_CSS, useDiffLineFocus } from "@/features/source-control/diffLineFocus"; import { getDiffTheme, getDiffThemeType } from "@/features/diff-view/diffRenderConfig"; import { useGetRepoFileQuery } from "@/features/source-control/api"; -import { useCurrentLspDocument } from "@/features/lsp/hooks/useCurrentLspDocument"; +import { useCurrentLspDocuments } from "@/features/lsp/hooks/useCurrentLspDocument"; import { LspSymbolPeekContainer } from "@/features/lsp/components/LspSymbolPeek"; import { useLspTokenNavigation } from "@/features/lsp/useLspTokenNavigation"; import { navigateBackToDiffFromFileViewer } from "@/features/source-control/actions"; @@ -104,6 +104,21 @@ export function GeneralFileViewer(props: GeneralFileViewerProps) { const selectedLine = target?.line && target.line > 0 ? target.line : null; const focusKey = target?.focusKey ?? null; const lspText = file?.contents ?? null; + const targetRepoPath = target?.repoPath; + const targetRelPath = target?.relPath; + const lspDocuments = useMemo( + () => + targetRepoPath && targetRelPath && lspText !== null + ? [ + { + repoPath: targetRepoPath, + relPath: targetRelPath, + text: lspText, + }, + ] + : [], + [lspText, targetRelPath, targetRepoPath], + ); const lineCount = file ? countFileLines(file.contents) : null; const { onTokenClick } = useLspTokenNavigation( target ? { repoPath: target.repoPath, relPath: target.relPath } : undefined, @@ -112,7 +127,7 @@ export function GeneralFileViewer(props: GeneralFileViewerProps) { }, ); - useCurrentLspDocument(target?.repoPath ?? "", target?.relPath ?? "", lspText); + useCurrentLspDocuments(lspDocuments); useDiffLineFocus({ containerRef: viewerRef, lineNumber: file ? selectedLine : null, diff --git a/apps/desktop/src/features/source-control/screens/ChangesScreen.tsx b/apps/desktop/src/features/source-control/screens/ChangesScreen.tsx index 3ac0451..0a736da 100644 --- a/apps/desktop/src/features/source-control/screens/ChangesScreen.tsx +++ b/apps/desktop/src/features/source-control/screens/ChangesScreen.tsx @@ -1,24 +1,11 @@ -import { skipToken } from "@reduxjs/toolkit/query"; - -import { useAppDispatch, useAppSelector } from "@/app/hooks"; +import { useAppSelector } from "@/app/hooks"; import { ResizableSidebarLayout } from "@/components/layout/ResizableSidebarLayout"; -import { DiffWorkspace } from "@/features/diff-view/DiffWorkspace"; -import { LspStatusNotice } from "@/features/lsp/components/LspStatusNotice"; -import { useCurrentLspDocument } from "@/features/lsp/hooks/useCurrentLspDocument"; -import { useDiffDiagnostics } from "@/features/lsp/hooks/useDiffDiagnostics"; -import { applyHunkToIndexAction } from "@/features/source-control/actions"; -import { useGetFileVersionsQuery, useGetGitSnapshotQuery } from "@/features/source-control/api"; +import { ChangesCodeViewDiffPane } from "@/features/source-control/components/ChangesCodeViewDiffPane"; import { ChangesSidebar } from "@/features/source-control/components/ChangesSidebar"; import { MergeConflictViewer } from "@/features/source-control/components/MergeConflictViewer"; import { useChangesKeyboardNav } from "@/features/source-control/hooks/useChangesKeyboardNav"; import { useChangesSync } from "@/features/source-control/hooks/useChangesSync"; -import { useThrottledDiffSelection } from "@/features/source-control/hooks/useThrottledDiffSelection"; -import { - buildIndexContentsForHunkOperation, - type DiffHunkActionPayload, - type DiffHunkOperation, -} from "@/features/source-control/hunkOperations"; -import { errorMessageFrom } from "@/features/source-control/shared-utils/errorMessage"; +import { useGetGitSnapshotQuery } from "@/features/source-control/api"; export function ChangesScreen() { useChangesKeyboardNav("changes"); @@ -37,13 +24,10 @@ export function ChangesScreen() { } function ChangesDiffPane() { - const dispatch = useAppDispatch(); const activeRepo = useAppSelector((state) => state.sourceControl.activeRepo); - const activeBucket = useAppSelector((state) => state.sourceControl.activeBucket); const activePath = useAppSelector((state) => state.sourceControl.activePath); - const diffFocusTarget = useAppSelector((state) => state.sourceControl.diffFocusTarget); - const { data: snapshot } = useGetGitSnapshotQuery(activeRepo ?? "", { + const { data: snapshot, isLoading } = useGetGitSnapshotQuery(activeRepo ?? "", { skip: !activeRepo, refetchOnFocus: true, refetchOnReconnect: true, @@ -51,116 +35,23 @@ function ChangesDiffPane() { const isMergeConflict = activePath && - (snapshot?.unstaged.some((f) => f.path === activePath && f.status === "unmerged") ?? false); - - const previewSelection = useThrottledDiffSelection( - activePath && !isMergeConflict - ? { - bucket: activeBucket, - path: activePath, - } - : null, - ); - - const workingFileVersions = useGetFileVersionsQuery( - activeRepo && previewSelection - ? { repoPath: activeRepo, bucket: previewSelection.bucket, relPath: previewSelection.path } - : skipToken, - { - refetchOnFocus: true, - refetchOnReconnect: true, - }, - ); - const fileVersions = workingFileVersions.currentData ?? workingFileVersions.data; - const loadingPatch = !fileVersions && workingFileVersions.isFetching; - const oldFile = fileVersions?.oldFile ?? null; - const newFile = fileVersions?.newFile ?? null; - const errorMessage = fileVersions ? "" : errorMessageFrom(workingFileVersions.error, ""); - const previewPath = previewSelection?.path ?? activePath ?? ""; - const lspText = !loadingPatch && newFile ? newFile.contents : null; - const lspHoverDocument = - activeRepo && previewPath && lspText !== null && !isMergeConflict - ? { repoPath: activeRepo, relPath: previewPath } - : undefined; - - useCurrentLspDocument(activeRepo, previewPath, lspText); - - const lspDiagnostics = useDiffDiagnostics(activeRepo, previewPath); - const focusedLineNumber = - diffFocusTarget?.kind === "changes" && diffFocusTarget.path === previewPath - ? diffFocusTarget.lineNumber - : null; - const focusedLineIndex = - diffFocusTarget?.kind === "changes" && diffFocusTarget.path === previewPath - ? diffFocusTarget.lineIndex - : null; - const focusedLineKey = - diffFocusTarget?.kind === "changes" && diffFocusTarget.path === previewPath - ? diffFocusTarget.focusKey - : null; - const hunkOperations: DiffHunkOperation[] = - previewSelection?.bucket === "unstaged" - ? ["stage", "discard"] - : previewSelection?.bucket === "staged" - ? ["unstage"] - : []; - - function handleHunkAction(operation: DiffHunkOperation, payload: DiffHunkActionPayload) { - if (!previewPath) { - return; - } - - const contents = buildIndexContentsForHunkOperation({ - fileDiff: payload.fileDiff, - hunkIndex: payload.hunkIndex, - operation, - }); - - void dispatch( - applyHunkToIndexAction({ - filePath: previewPath, - contents, - operation, - }), - ); - } + (snapshot?.unstaged.some((file) => file.path === activePath && file.status === "unmerged") ?? + false); return (
- {errorMessage ? ( -
{errorMessage}
- ) : loadingPatch ? ( -
Loading diff...
- ) : !activePath ? ( -
Select a file to view diff.
- ) : isMergeConflict && activeRepo ? ( + {!activeRepo ? ( +
Select a repository.
+ ) : isLoading || !snapshot ? ( +
Loading changes...
+ ) : isMergeConflict ? (
- ) : !oldFile && !newFile ? ( -
No diff content.
) : ( -
- - -
+ )}
diff --git a/package.json b/package.json index 9f43350..8209edc 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,9 @@ "format:check": "pnpm fmt:check", "check": "pnpm lint && pnpm typecheck && pnpm test && pnpm fmt:check" }, + "dependencies": { + "electron": "^42.3.0" + }, "devDependencies": { "oxfmt": "^0.40.0", "oxlint": "^1.55.0", diff --git a/patches/@pierre__diffs@1.2.4.patch b/patches/@pierre__diffs@1.2.4.patch new file mode 100644 index 0000000..688adb7 --- /dev/null +++ b/patches/@pierre__diffs@1.2.4.patch @@ -0,0 +1,21 @@ +diff --git a/dist/components/VirtualizedFileDiff.js b/dist/components/VirtualizedFileDiff.js +index a487779494bf281f4dff3aa8323c902fdbe80426..5304aa9d4d514a39c0dc8ce03dd542b6fca6e855 100644 +--- a/dist/components/VirtualizedFileDiff.js ++++ b/dist/components/VirtualizedFileDiff.js +@@ -301,6 +301,7 @@ var VirtualizedFileDiff = class extends FileDiff { + this.hunksRenderer.expandHunk(hunkIndex, direction, expansionLineCountOverride); + this.resetLayoutCache({ includeEstimatedHeights: true }); + this.computeApproximateSize(); ++ this.forceRenderOverride = true; + this.virtualizer.instanceChanged(this, true); + }; + setVisibility(visible) { +@@ -408,7 +409,7 @@ var VirtualizedFileDiff = class extends FileDiff { + renderRange, + oldFile, + newFile, +- forceRender: forceRenderOverride ?? forceRender, ++ forceRender: forceRenderOverride ?? (forceRender || this.isAdvancedMode()), + ...props + }); + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34807ba..76f3a86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,19 +4,28 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +patchedDependencies: + '@pierre/diffs@1.2.4': + hash: 2e3aa00eef52df9a9fb2b400fce519fe2973178bb419f9a485ff55b9ad668db1 + path: patches/@pierre__diffs@1.2.4.patch + importers: .: + dependencies: + electron: + specifier: ^42.3.0 + version: 42.3.0 devDependencies: oxfmt: specifier: ^0.40.0 version: 0.40.0 oxlint: specifier: ^1.55.0 - version: 1.55.0 + version: 1.67.0 turbo: specifier: ^2.5.8 - version: 2.8.17 + version: 2.9.16 apps/desktop: dependencies: @@ -30,8 +39,8 @@ importers: specifier: ^1.0.0 version: 1.0.0(@types/react@19.2.13)(react@19.2.4) '@pierre/diffs': - specifier: 1.1.15 - version: 1.1.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 1.2.4 + version: 1.2.4(patch_hash=2e3aa00eef52df9a9fb2b400fce519fe2973178bb419f9a485ff55b9ad668db1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@pierre/trees': specifier: 1.0.0-beta.3 version: 1.0.0-beta.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -218,6 +227,10 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/compat-data@7.29.0': resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} @@ -288,6 +301,10 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -347,6 +364,10 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -445,6 +466,10 @@ packages: resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} engines: {node: '>=14'} + '@electron/get@5.0.0': + resolution: {integrity: sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==} + engines: {node: '>=22.12.0'} + '@electron/notarize@2.5.0': resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} engines: {node: '>= 10.0.0'} @@ -932,128 +957,128 @@ packages: cpu: [x64] os: [win32] - '@oxlint/binding-android-arm-eabi@1.55.0': - resolution: {integrity: sha512-NhvgAhncTSOhRahQSCnkK/4YIGPjTmhPurQQ2dwt2IvwCMTvZRW5vF2K10UBOxFve4GZDMw6LtXZdC2qeuYIVQ==} + '@oxlint/binding-android-arm-eabi@1.67.0': + resolution: {integrity: sha512-VrSi571rDv1N8HaEDM+DEX8nmT0y9jJo8tzzW13vsOWTx59xQczCIJx68n2zWOXRT5YKZsOZXp4qkHN/10x4mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxlint/binding-android-arm64@1.55.0': - resolution: {integrity: sha512-P9iWRh+Ugqhg+D7rkc7boHX8o3H2h7YPcZHQIgvVBgnua5tk4LR2L+IBlreZs58/95cd2x3/004p5VsQM9z4SA==} + '@oxlint/binding-android-arm64@1.67.0': + resolution: {integrity: sha512-l6+NdYxMoRohix5r5bbigW16LPicceCwGcQ6LKKuE1kUdjgFfQolJjrJsQYPFetIs78Gxj/G/f5TEGoTCwj9nQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxlint/binding-darwin-arm64@1.55.0': - resolution: {integrity: sha512-esakkJIt7WFAhT30P/Qzn96ehFpzdZ1mNuzpOb8SCW7lI4oB8VsyQnkSHREM671jfpuBb/o2ppzBCx5l0jpgMA==} + '@oxlint/binding-darwin-arm64@1.67.0': + resolution: {integrity: sha512-jOzXxS1AxFxhImLIRbtGIMrEwaXcgMw3gR57WB1cRk8ai+vpr6726kxXqVvlNsrXtJ/FrmOm8RxlC0m8SW24Qg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxlint/binding-darwin-x64@1.55.0': - resolution: {integrity: sha512-xDMFRCCAEK9fOH6As2z8ELsC+VDGSFRHwIKVSilw+xhgLwTDFu37rtmRbmUlx8rRGS6cWKQPTc47AVxAZEVVPQ==} + '@oxlint/binding-darwin-x64@1.67.0': + resolution: {integrity: sha512-3DFAVY94OqjIZHXIPz37yGRSWwOFTAqChQ64/M69GYLawzP0KiwdhDNfqdKKYT0bTR/DNxmMnQsj3ns+8+X/Lg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxlint/binding-freebsd-x64@1.55.0': - resolution: {integrity: sha512-mYZqnwUD7ALCRxGenyLd1uuG+rHCL+OTT6S8FcAbVm/ZT2AZMGjvibp3F6k1SKOb2aeqFATmwRykrE41Q0GWVw==} + '@oxlint/binding-freebsd-x64@1.67.0': + resolution: {integrity: sha512-e4dDKZuLu8TR9DEBssWSDahlPgZBwojTTHZUvnjBRJfJJbpxYCjfjKfi0Z1+CSLMiJBwI2yCDtRM1XJQaARjmg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxlint/binding-linux-arm-gnueabihf@1.55.0': - resolution: {integrity: sha512-LcX6RYcF9vL9ESGwJW3yyIZ/d/ouzdOKXxCdey1q0XJOW1asrHsIg5MmyKdEBR4plQx+shvYeQne7AzW5f3T1w==} + '@oxlint/binding-linux-arm-gnueabihf@1.67.0': + resolution: {integrity: sha512-BKytFdcQzbITV3xlnzDUDTEDtbUMCCiC4EaNTDZ4FyT8gdNvBC4gfiLucXp/sQl0XU3p7syTlorUWVVVBZab2g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm-musleabihf@1.55.0': - resolution: {integrity: sha512-C+8GS1rPtK+dI7mJFkqoRBkDuqbrNihnyYQsJPS9ez+8zF9JzfvU19lawqt4l/Y23o5uQswE/DORa8aiXUih3w==} + '@oxlint/binding-linux-arm-musleabihf@1.67.0': + resolution: {integrity: sha512-XYAv0esBDX7BpTzRDjVX2Vdj+zndd8ll2dFQiaeQ6zTZr7A8GRDTN7fH3FP3jU+O0vCDx85oH/EtG7BzPgAXuw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxlint/binding-linux-arm64-gnu@1.55.0': - resolution: {integrity: sha512-ErLE4XbmcCopA4/CIDiH6J1IAaDOMnf/KSx/aFObs4/OjAAM3sFKWGZ57pNOMxhhyBdcmcXwYymph9GwcpcqgQ==} + '@oxlint/binding-linux-arm64-gnu@1.67.0': + resolution: {integrity: sha512-zizRMjA0i6u/2B0evgda04iycu+MoNuf1pBy6Eh+1CjC5wMEG7qN5zdDKTCvFc0KSYSDM9QTG3gjZHirgtQuKg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxlint/binding-linux-arm64-musl@1.55.0': - resolution: {integrity: sha512-/kp65avi6zZfqEng56TTuhiy3P/3pgklKIdf38yvYeJ9/PgEeRA2A2AqKAKbZBNAqUzrzHhz9jF6j/PZvhJzTQ==} + '@oxlint/binding-linux-arm64-musl@1.67.0': + resolution: {integrity: sha512-zB/Tf6sUjmmvvbva9Gj3JTJ8rJ9t4I8/U0o6vSRtd0DRIsIuyegBwJAzhSUFQHdMijIRJkW0exs/yBhpw2S20w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - '@oxlint/binding-linux-ppc64-gnu@1.55.0': - resolution: {integrity: sha512-A6pTdXwcEEwL/nmz0eUJ6WxmxcoIS+97GbH96gikAyre3s5deC7sts38ZVVowjS2QQFuSWkpA4ZmQC0jZSNvJQ==} + '@oxlint/binding-linux-ppc64-gnu@1.67.0': + resolution: {integrity: sha512-kgU40Gt74CK0TCsF51KZymkIwN9U0BajKsMijB52zPqOeZU9NAHkA/NSQkZDHEaCakx42DxhXkODiAqf2b4Gug==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - '@oxlint/binding-linux-riscv64-gnu@1.55.0': - resolution: {integrity: sha512-clj0lnIN+V52G9tdtZl0LbdTSurnZ1NZj92Je5X4lC7gP5jiCSW+Y/oiDiSauBAD4wrHt2S7nN3pA0zfKYK/6Q==} + '@oxlint/binding-linux-riscv64-gnu@1.67.0': + resolution: {integrity: sha512-tOYhkk/iaG9aD3FvGpBFd1Lrw0x0RaVoJBxjUkfNzS50rC5NS5BteNCwgr8A2zCdADrIIoze6D7u6U5Ic++/iQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxlint/binding-linux-riscv64-musl@1.55.0': - resolution: {integrity: sha512-NNu08pllN5x/O94/sgR3DA8lbrGBnTHsINZZR0hcav1sj79ksTiKKm1mRzvZvacwQ0hUnGinFo+JO75ok2PxYg==} + '@oxlint/binding-linux-riscv64-musl@1.67.0': + resolution: {integrity: sha512-sEtywrPb+0b+tHYl1SDCrw903fiC4eyKoNqzP3v+f2JT3Xcv4NEYG+P8rj+eEnX7IWhqV/xj8/JmcmVj21CXaA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] - '@oxlint/binding-linux-s390x-gnu@1.55.0': - resolution: {integrity: sha512-BvfQz3PRlWZRoEZ17dZCqgQsMRdpzGZomJkVATwCIGhHVVeHJMQdmdXPSjcT1DCNUrOjXnVyj1RGDj5+/Je2+Q==} + '@oxlint/binding-linux-s390x-gnu@1.67.0': + resolution: {integrity: sha512-BvR8Moa0zCLxroOx4vZaZN9nUfwAUpSTwjZdxZyKy4bv3PrzrXrxKR/ZQ0L9wNSvlPhnMJeZfa3q5w6ZCTuN6Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - '@oxlint/binding-linux-x64-gnu@1.55.0': - resolution: {integrity: sha512-ngSOoFCSBMKVQd24H8zkbcBNc7EHhjnF1sv3mC9NNXQ/4rRjI/4Dj9+9XoDZeFEkF1SX1COSBXF1b2Pr9rqdEw==} + '@oxlint/binding-linux-x64-gnu@1.67.0': + resolution: {integrity: sha512-mm2cxM6fksOpq6l0uFws8BUGKAR4dNa/cZCn37Npq7PFbhD5HDJqWfnoIvTaeRKMy5XdS2tO0MA0qbHDrnXAAA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxlint/binding-linux-x64-musl@1.55.0': - resolution: {integrity: sha512-BDpP7W8GlaG7BR6QjGZAleYzxoyKc/D24spZIF2mB3XsfALQJJT/OBmP8YpeTb1rveFSBHzl8T7l0aqwkWNdGA==} + '@oxlint/binding-linux-x64-musl@1.67.0': + resolution: {integrity: sha512-WmbMuLapKyDlobMkXAaAL0Y+Uczh4LETfIfQsUpbId4Ip8Ai82/jqeYTOoUCkuuhBFapgqP253+d83tLKOksJg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - '@oxlint/binding-openharmony-arm64@1.55.0': - resolution: {integrity: sha512-PS6GFvmde/pc3fCA2Srt51glr8Lcxhpf6WIBFfLphndjRrD34NEcses4TSxQrEcxYo6qVywGfylM0ZhSCF2gGA==} + '@oxlint/binding-openharmony-arm64@1.67.0': + resolution: {integrity: sha512-9g/PqxYJelzzTAOR5Y+RiRqdeydhEuXv2KxNeFcAKQ7UsvnWSY1OP4MsuPMbTO2Pf70tz7mFhl1j13H3fyh+8g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxlint/binding-win32-arm64-msvc@1.55.0': - resolution: {integrity: sha512-P6JcLJGs/q1UOvDLzN8otd9JsH4tsuuPDv+p7aHqHM3PrKmYdmUvkNj4K327PTd35AYcznOCN+l4ZOaq76QzSw==} + '@oxlint/binding-win32-arm64-msvc@1.67.0': + resolution: {integrity: sha512-2VhwE6Gatb0vJGnN0TBuQMbKCOiZlSQ/zJvVWYLK4a9d4iDiJOen/yVQkGpmsJ90MuH66fzi0kEKI0jRQMDxGA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxlint/binding-win32-ia32-msvc@1.55.0': - resolution: {integrity: sha512-gzkk4zE2zsE+WmRxFOiAZHpCpUNDFytEakqNXoNHW+PnYEOTPKDdW6nrzgSeTbGKVPXNAKQnRnMgrh7+n3Xueg==} + '@oxlint/binding-win32-ia32-msvc@1.67.0': + resolution: {integrity: sha512-EQ3VExXfeM1InbE5+JjufhZZTWy+kHUwgt3yZR7gQ47Je/mE0WspQPan0OJznh493L5anM210YNJtH1PXjTSFg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxlint/binding-win32-x64-msvc@1.55.0': - resolution: {integrity: sha512-ZFALNow2/og75gvYzNP7qe+rREQ5xunktwA+lgykoozHZ6hw9bqg4fn5j2UvG4gIn1FXqrZHkOAXuPf5+GOYTQ==} + '@oxlint/binding-win32-x64-msvc@1.67.0': + resolution: {integrity: sha512-bw24y+/1MHS4QDkons3YyHkPT9uCMoLHHgQhb+mb8NOjTYwub1CZ+K9Ngr8aO5DMrDrkqHwTzlTwFP2vS8Y/ZQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@pierre/diffs@1.1.15': - resolution: {integrity: sha512-Gj863E+aSpc0H3C4cH0fQTaF/tP9yYfhnilR7/dS72qq8thqNpR3fo3jURHRtRKz6KJJ10anxcurHP7b3ZUQkw==} + '@pierre/diffs@1.2.4': + resolution: {integrity: sha512-SEuYxGpSCHVvfoLly/Q/OYpJSBLWaVLV3M3wI/VBW7aZmzYenNe4aXjOf5sIKJMWW5gbZe9WdLvtKUt6cQ1k1A==} peerDependencies: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 - '@pierre/theme@0.0.28': - resolution: {integrity: sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw==} + '@pierre/theme@1.0.3': + resolution: {integrity: sha512-sWHv11TMoqKxKDgTIk5VbhQjdPhs8DCcBxbjh3mRlS3YOM/OcrWoGX6MM8eBGn9cUu3M46Py0JnxsG2nJaFTuA==} engines: {vscode: ^1.0.0} '@pierre/trees@1.0.0-beta.3': @@ -2145,6 +2170,36 @@ packages: '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + '@turbo/darwin-64@2.9.16': + resolution: {integrity: sha512-jLjApWTSNd7JZ5JaLYfelW1ytnGQOvB7ivl+2RD1xQvJTbi8I9gBjzcga7tDZVPyaxpl10YTfJt3BrYXR18KDw==} + cpu: [x64] + os: [darwin] + + '@turbo/darwin-arm64@2.9.16': + resolution: {integrity: sha512-YPgrn+5HIGzrx0O2a631SV4MBQUe4W/DafMFUuBVgaU32PW9/OTT0ehviF0QSxTXuRJlHvW2eUTemddF5/spmw==} + cpu: [arm64] + os: [darwin] + + '@turbo/linux-64@2.9.16': + resolution: {integrity: sha512-vAEf1H6l26lTpl9FJ/peQo1NUB8RC0sbEJJz5mPcUhHA2bPDup2x3CZPgo/bH8S4cUcBLm4FN3UHd5iUO2RAew==} + cpu: [x64] + os: [linux] + + '@turbo/linux-arm64@2.9.16': + resolution: {integrity: sha512-xDBLR2PZg4BrQOchfG6svgpv5FCNJ2TOtT2psLdEJcdKo1BH+pnPs9Xj6pvUjgfkHbuvBOfeE4R6tvxMoQKDHQ==} + cpu: [arm64] + os: [linux] + + '@turbo/windows-64@2.9.16': + resolution: {integrity: sha512-NBAJnaUiGdgkSzQwUIdOvkCkcpTSu58G/sBGa0mvBtzfvFOOgrQwepKOOQ8cp6sWM6OcKDNFj2p1dsZA1OWjPg==} + cpu: [x64] + os: [win32] + + '@turbo/windows-arm64@2.9.16': + resolution: {integrity: sha512-Y7SJppD0Z8wjO3Ec0ZGd9KQ4Yv0BMnA8CIowj5Vp+OEVsosXDG2weK6/t1RRLfJmc2Ozrnd6y4DOgQys+mn3WQ==} + cpu: [arm64] + os: [win32] + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -2305,6 +2360,7 @@ packages: '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version abbrev@3.0.1: resolution: {integrity: sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==} @@ -2950,6 +3006,11 @@ packages: engines: {node: '>= 12.20.55'} hasBin: true + electron@42.3.0: + resolution: {integrity: sha512-9ZiLdRXk+WDxW1OgIUz8J2rIQ5TYU9o629gCOjU48Q3dQiOmym7osWsH5Ubs/Jh4uuFLn6m6SBD2rmRXLAPz9g==} + engines: {node: '>= 22.12.0'} + hasBin: true + embla-carousel-react@8.6.0: resolution: {integrity: sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==} peerDependencies: @@ -2994,6 +3055,10 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} @@ -3193,6 +3258,10 @@ packages: resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} engines: {node: '>=14.14'} + fs-extra@11.3.5: + resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} + engines: {node: '>=14.14'} + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -3640,6 +3709,9 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3737,6 +3809,9 @@ packages: lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + log-symbols@4.1.0: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} @@ -4203,15 +4278,18 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true - oxlint@1.55.0: - resolution: {integrity: sha512-T+FjepiyWpaZMhekqRpH8Z3I4vNM610p6w+Vjfqgj5TZUxHXl7N8N5IPvmOU8U4XdTRxqtNNTh9Y4hLtr7yvFg==} + oxlint@1.67.0: + resolution: {integrity: sha512-blwwaHPdoH8piQ5/z0KHeoHFR7FZgl12WluKJfu4qFLPkZl6mK04PkLE45Fw1NxfBRSlh40Gu7MkxHUw++ociQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: - oxlint-tsgolint: '>=0.15.0' + oxlint-tsgolint: '>=0.22.1' + vite-plus: '*' peerDependenciesMeta: oxlint-tsgolint: optional: true + vite-plus: + optional: true p-cancelable@2.1.1: resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} @@ -5013,38 +5091,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - turbo-darwin-64@2.8.17: - resolution: {integrity: sha512-ZFkv2hv7zHpAPEXBF6ouRRXshllOavYc+jjcrYyVHvxVTTwJWsBZwJ/gpPzmOKGvkSjsEyDO5V6aqqtZzwVF+Q==} - cpu: [x64] - os: [darwin] - - turbo-darwin-arm64@2.8.17: - resolution: {integrity: sha512-5DXqhQUt24ycEryXDfMNKEkW5TBHs+QmU23a2qxXwwFDaJsWcPo2obEhBxxdEPOv7qmotjad+09RGeWCcJ9JDw==} - cpu: [arm64] - os: [darwin] - - turbo-linux-64@2.8.17: - resolution: {integrity: sha512-KLUbz6w7F73D/Ihh51hVagrKR0/CTsPEbRkvXLXvoND014XJ4BCrQUqSxlQ4/hu+nqp1v5WlM85/h3ldeyujuA==} - cpu: [x64] - os: [linux] - - turbo-linux-arm64@2.8.17: - resolution: {integrity: sha512-pJK67XcNJH40lTAjFu7s/rUlobgVXyB3A3lDoq+/JccB3hf+SysmkpR4Itlc93s8LEaFAI4mamhFuTV17Z6wOg==} - cpu: [arm64] - os: [linux] - - turbo-windows-64@2.8.17: - resolution: {integrity: sha512-EijeQ6zszDMmGZLP2vT2RXTs/GVi9rM0zv2/G4rNu2SSRSGFapgZdxgW4b5zUYLVaSkzmkpWlGfPfj76SW9yUg==} - cpu: [x64] - os: [win32] - - turbo-windows-arm64@2.8.17: - resolution: {integrity: sha512-crpfeMPkfECd4V1PQ/hMoiyVcOy04+bWedu/if89S15WhOalHZ2BYUi6DOJhZrszY+mTT99OwpOsj4wNfb/GHQ==} - cpu: [arm64] - os: [win32] - - turbo@2.8.17: - resolution: {integrity: sha512-YwPsNSqU2f/RXU/+Kcb7cPkPZARxom4+me7LKEdN5jsvy2tpfze3zDZ4EiGrJnvOm9Avu9rK0aaYsP7qZ3iz7A==} + turbo@2.9.16: + resolution: {integrity: sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg==} hasBin: true tw-animate-css@1.4.0: @@ -5070,6 +5118,10 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + undici@7.26.0: + resolution: {integrity: sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==} + engines: {node: '>=20.18.1'} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -5446,6 +5498,12 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/compat-data@7.29.0': {} '@babel/core@7.29.0': @@ -5552,6 +5610,8 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/helper-validator-option@7.27.1': {} '@babel/helpers@7.28.6': @@ -5615,6 +5675,8 @@ snapshots: '@babel/runtime@7.28.6': {} + '@babel/runtime@7.29.7': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -5748,6 +5810,19 @@ snapshots: transitivePeerDependencies: - supports-color + '@electron/get@5.0.0': + dependencies: + debug: 4.4.3 + env-paths: 3.0.0 + graceful-fs: 4.2.11 + progress: 2.0.3 + semver: 7.7.4 + sumchecker: 3.0.1 + optionalDependencies: + undici: 7.26.0 + transitivePeerDependencies: + - supports-color + '@electron/notarize@2.5.0': dependencies: debug: 4.4.3 @@ -5801,7 +5876,7 @@ snapshots: dependencies: cross-dirname: 0.1.0 debug: 4.4.3 - fs-extra: 11.3.3 + fs-extra: 11.3.5 minimist: 1.2.8 postject: 1.0.0-alpha.6 transitivePeerDependencies: @@ -6160,66 +6235,66 @@ snapshots: '@oxfmt/binding-win32-x64-msvc@0.40.0': optional: true - '@oxlint/binding-android-arm-eabi@1.55.0': + '@oxlint/binding-android-arm-eabi@1.67.0': optional: true - '@oxlint/binding-android-arm64@1.55.0': + '@oxlint/binding-android-arm64@1.67.0': optional: true - '@oxlint/binding-darwin-arm64@1.55.0': + '@oxlint/binding-darwin-arm64@1.67.0': optional: true - '@oxlint/binding-darwin-x64@1.55.0': + '@oxlint/binding-darwin-x64@1.67.0': optional: true - '@oxlint/binding-freebsd-x64@1.55.0': + '@oxlint/binding-freebsd-x64@1.67.0': optional: true - '@oxlint/binding-linux-arm-gnueabihf@1.55.0': + '@oxlint/binding-linux-arm-gnueabihf@1.67.0': optional: true - '@oxlint/binding-linux-arm-musleabihf@1.55.0': + '@oxlint/binding-linux-arm-musleabihf@1.67.0': optional: true - '@oxlint/binding-linux-arm64-gnu@1.55.0': + '@oxlint/binding-linux-arm64-gnu@1.67.0': optional: true - '@oxlint/binding-linux-arm64-musl@1.55.0': + '@oxlint/binding-linux-arm64-musl@1.67.0': optional: true - '@oxlint/binding-linux-ppc64-gnu@1.55.0': + '@oxlint/binding-linux-ppc64-gnu@1.67.0': optional: true - '@oxlint/binding-linux-riscv64-gnu@1.55.0': + '@oxlint/binding-linux-riscv64-gnu@1.67.0': optional: true - '@oxlint/binding-linux-riscv64-musl@1.55.0': + '@oxlint/binding-linux-riscv64-musl@1.67.0': optional: true - '@oxlint/binding-linux-s390x-gnu@1.55.0': + '@oxlint/binding-linux-s390x-gnu@1.67.0': optional: true - '@oxlint/binding-linux-x64-gnu@1.55.0': + '@oxlint/binding-linux-x64-gnu@1.67.0': optional: true - '@oxlint/binding-linux-x64-musl@1.55.0': + '@oxlint/binding-linux-x64-musl@1.67.0': optional: true - '@oxlint/binding-openharmony-arm64@1.55.0': + '@oxlint/binding-openharmony-arm64@1.67.0': optional: true - '@oxlint/binding-win32-arm64-msvc@1.55.0': + '@oxlint/binding-win32-arm64-msvc@1.67.0': optional: true - '@oxlint/binding-win32-ia32-msvc@1.55.0': + '@oxlint/binding-win32-ia32-msvc@1.67.0': optional: true - '@oxlint/binding-win32-x64-msvc@1.55.0': + '@oxlint/binding-win32-x64-msvc@1.67.0': optional: true - '@pierre/diffs@1.1.15(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@pierre/diffs@1.2.4(patch_hash=2e3aa00eef52df9a9fb2b400fce519fe2973178bb419f9a485ff55b9ad668db1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@pierre/theme': 0.0.28 + '@pierre/theme': 1.0.3 '@shikijs/transformers': 3.22.0 diff: 8.0.3 hast-util-to-html: 9.0.5 @@ -6228,7 +6303,7 @@ snapshots: react-dom: 19.2.4(react@19.2.4) shiki: 3.22.0 - '@pierre/theme@0.0.28': {} + '@pierre/theme@1.0.3': {} '@pierre/trees@1.0.0-beta.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: @@ -7293,8 +7368,8 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/runtime': 7.28.6 + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -7327,6 +7402,24 @@ snapshots: minimatch: 10.1.2 path-browserify: 1.0.1 + '@turbo/darwin-64@2.9.16': + optional: true + + '@turbo/darwin-arm64@2.9.16': + optional: true + + '@turbo/linux-64@2.9.16': + optional: true + + '@turbo/linux-arm64@2.9.16': + optional: true + + '@turbo/windows-64@2.9.16': + optional: true + + '@turbo/windows-arm64@2.9.16': + optional: true + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -8216,7 +8309,7 @@ snapshots: '@electron/asar': 3.4.1 debug: 4.4.3 fs-extra: 7.0.1 - lodash: 4.17.23 + lodash: 4.18.1 temp: 0.9.4 optionalDependencies: '@electron/windows-sign': 1.2.2 @@ -8231,6 +8324,14 @@ snapshots: transitivePeerDependencies: - supports-color + electron@42.3.0: + dependencies: + '@electron/get': 5.0.0 + '@types/node': 24.10.11 + extract-zip: 2.0.1 + transitivePeerDependencies: + - supports-color + embla-carousel-react@8.6.0(react@19.2.4): dependencies: embla-carousel: 8.6.0 @@ -8269,6 +8370,8 @@ snapshots: env-paths@2.2.1: {} + env-paths@3.0.0: {} + err-code@2.0.3: {} error-ex@1.3.4: @@ -8526,6 +8629,13 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 + fs-extra@11.3.5: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + optional: true + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -9018,6 +9128,13 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + optional: true + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -9085,6 +9202,8 @@ snapshots: lodash@4.17.23: {} + lodash@4.18.1: {} + log-symbols@4.1.0: dependencies: chalk: 4.1.2 @@ -9781,27 +9900,27 @@ snapshots: '@oxfmt/binding-win32-ia32-msvc': 0.40.0 '@oxfmt/binding-win32-x64-msvc': 0.40.0 - oxlint@1.55.0: + oxlint@1.67.0: optionalDependencies: - '@oxlint/binding-android-arm-eabi': 1.55.0 - '@oxlint/binding-android-arm64': 1.55.0 - '@oxlint/binding-darwin-arm64': 1.55.0 - '@oxlint/binding-darwin-x64': 1.55.0 - '@oxlint/binding-freebsd-x64': 1.55.0 - '@oxlint/binding-linux-arm-gnueabihf': 1.55.0 - '@oxlint/binding-linux-arm-musleabihf': 1.55.0 - '@oxlint/binding-linux-arm64-gnu': 1.55.0 - '@oxlint/binding-linux-arm64-musl': 1.55.0 - '@oxlint/binding-linux-ppc64-gnu': 1.55.0 - '@oxlint/binding-linux-riscv64-gnu': 1.55.0 - '@oxlint/binding-linux-riscv64-musl': 1.55.0 - '@oxlint/binding-linux-s390x-gnu': 1.55.0 - '@oxlint/binding-linux-x64-gnu': 1.55.0 - '@oxlint/binding-linux-x64-musl': 1.55.0 - '@oxlint/binding-openharmony-arm64': 1.55.0 - '@oxlint/binding-win32-arm64-msvc': 1.55.0 - '@oxlint/binding-win32-ia32-msvc': 1.55.0 - '@oxlint/binding-win32-x64-msvc': 1.55.0 + '@oxlint/binding-android-arm-eabi': 1.67.0 + '@oxlint/binding-android-arm64': 1.67.0 + '@oxlint/binding-darwin-arm64': 1.67.0 + '@oxlint/binding-darwin-x64': 1.67.0 + '@oxlint/binding-freebsd-x64': 1.67.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.67.0 + '@oxlint/binding-linux-arm-musleabihf': 1.67.0 + '@oxlint/binding-linux-arm64-gnu': 1.67.0 + '@oxlint/binding-linux-arm64-musl': 1.67.0 + '@oxlint/binding-linux-ppc64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-gnu': 1.67.0 + '@oxlint/binding-linux-riscv64-musl': 1.67.0 + '@oxlint/binding-linux-s390x-gnu': 1.67.0 + '@oxlint/binding-linux-x64-gnu': 1.67.0 + '@oxlint/binding-linux-x64-musl': 1.67.0 + '@oxlint/binding-openharmony-arm64': 1.67.0 + '@oxlint/binding-win32-arm64-msvc': 1.67.0 + '@oxlint/binding-win32-ia32-msvc': 1.67.0 + '@oxlint/binding-win32-x64-msvc': 1.67.0 p-cancelable@2.1.1: {} @@ -10749,32 +10868,14 @@ snapshots: tslib@2.8.1: {} - turbo-darwin-64@2.8.17: - optional: true - - turbo-darwin-arm64@2.8.17: - optional: true - - turbo-linux-64@2.8.17: - optional: true - - turbo-linux-arm64@2.8.17: - optional: true - - turbo-windows-64@2.8.17: - optional: true - - turbo-windows-arm64@2.8.17: - optional: true - - turbo@2.8.17: + turbo@2.9.16: optionalDependencies: - turbo-darwin-64: 2.8.17 - turbo-darwin-arm64: 2.8.17 - turbo-linux-64: 2.8.17 - turbo-linux-arm64: 2.8.17 - turbo-windows-64: 2.8.17 - turbo-windows-arm64: 2.8.17 + '@turbo/darwin-64': 2.9.16 + '@turbo/darwin-arm64': 2.9.16 + '@turbo/linux-64': 2.9.16 + '@turbo/linux-arm64': 2.9.16 + '@turbo/windows-64': 2.9.16 + '@turbo/windows-arm64': 2.9.16 tw-animate-css@1.4.0: {} @@ -10795,6 +10896,9 @@ snapshots: undici-types@7.16.0: {} + undici@7.26.0: + optional: true + unicorn-magic@0.3.0: {} unified@11.0.5: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index df03b1e..7ff73fa 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,6 @@ packages: - "apps/*" - "crates/*" + +patchedDependencies: + "@pierre/diffs@1.2.4": patches/@pierre__diffs@1.2.4.patch From 38d81f54ffe2cccb17d03511c238f68f1d43c1f5 Mon Sep 17 00:00:00 2001 From: Shpetim Date: Thu, 4 Jun 2026 15:42:41 +0200 Subject: [PATCH 2/4] update pierre libary and add better git errors --- .vite-hooks/pre-commit | 3 +- apps/desktop/electron/desktop-api.ts | 2 + apps/desktop/electron/git.ts | 67 ++++++++++- apps/desktop/package.json | 2 +- .../features/source-control/actions.test.ts | 1 + .../src/features/source-control/actions.ts | 85 +++++++++++++- .../src/features/source-control/api.ts | 34 +++++- .../components/RepoActionErrorDialog.tsx | 104 ++++++++++++++++++ .../source-control/screens/ChangesScreen.tsx | 20 ++-- .../features/source-control/services/git.ts | 4 + .../source-control/sourceControlSlice.ts | 8 ++ .../src/features/source-control/types.ts | 7 ++ apps/desktop/src/platform/desktop/browser.ts | 8 ++ .../desktop/src/platform/desktop/contracts.ts | 1 + .../src/platform/desktop/desktopApiMethods.ts | 1 + .../src/platform/desktop/index.test.ts | 1 + package.json | 8 +- pnpm-lock.yaml | 15 +-- pnpm-workspace.yaml | 3 - 19 files changed, 338 insertions(+), 36 deletions(-) create mode 100644 apps/desktop/src/features/source-control/components/RepoActionErrorDialog.tsx diff --git a/.vite-hooks/pre-commit b/.vite-hooks/pre-commit index 6543840..698e332 100755 --- a/.vite-hooks/pre-commit +++ b/.vite-hooks/pre-commit @@ -3,7 +3,6 @@ set -e echo "Running pre-commit checks..." -pnpm lint -pnpm fmt:check +pnpm precommit echo "Pre-commit checks passed." diff --git a/apps/desktop/electron/desktop-api.ts b/apps/desktop/electron/desktop-api.ts index 0ea0948..0abfd92 100644 --- a/apps/desktop/electron/desktop-api.ts +++ b/apps/desktop/electron/desktop-api.ts @@ -19,6 +19,7 @@ import { } from "./hostedRepos"; import { commitStaged, + getLastGitCommandErrorLogPath, discardAll, discardFile, discardFiles, @@ -92,6 +93,7 @@ export const desktopApi: DesktopApi = { discardFiles, discardAll, commitStaged, + getLastGitCommandErrorLogPath, getRepoFile, syncLspDocument: (input) => lspSessionManager.syncDocument(input), closeLspDocument: (input) => lspSessionManager.closeDocument(input), diff --git a/apps/desktop/electron/git.ts b/apps/desktop/electron/git.ts index 0026959..59c1dcd 100644 --- a/apps/desktop/electron/git.ts +++ b/apps/desktop/electron/git.ts @@ -21,12 +21,16 @@ const MAX_BUFFER = 32 * 1024 * 1024; const GIT_TIMEOUT_MS = 30_000; const GIT_WRITE_RETRY_COUNT = 3; const GIT_WRITE_RETRY_DELAY_MS = 120; +const GIT_ERROR_LOG_DIR = path.join(os.tmpdir(), "open-warden-git-logs"); + +let lastGitCommandErrorLog: { repoPath: string; path: string } | null = null; class GitCommandError extends Error { constructor( readonly args: string[], readonly stderr: string, readonly code: number | null, + readonly logPath: string | null, ) { super(stderr || `git ${args.join(" ")} failed`); this.name = "GitCommandError"; @@ -71,6 +75,53 @@ function decodeUtf8(buffer: Buffer, label: string) { } } +function commandOutputToString(value: unknown) { + if (Buffer.isBuffer(value)) return value.toString("utf8"); + if (typeof value === "string") return value; + return ""; +} + +function formatGitCommand(args: string[]) { + return `git ${args.join(" ")}`; +} + +async function writeGitCommandErrorLog(input: { + repoPath: string; + args: string[]; + stderr: string; + stdout: string; + code: number | null; +}) { + try { + await fs.mkdir(GIT_ERROR_LOG_DIR, { recursive: true }); + const logPath = path.join(GIT_ERROR_LOG_DIR, `git-${Date.now()}-${process.pid}.log`); + const content = [ + `> ${formatGitCommand(input.args)}`, + `cwd: ${input.repoPath}`, + `exit code: ${input.code ?? "unknown"}`, + "", + "stderr:", + input.stderr || "(empty)", + "", + "stdout:", + input.stdout || "(empty)", + "", + ].join("\n"); + + await fs.writeFile(logPath, content, "utf8"); + lastGitCommandErrorLog = { repoPath: input.repoPath, path: logPath }; + return logPath; + } catch { + return null; + } +} + +export async function getLastGitCommandErrorLogPath(repoPath?: string) { + if (!lastGitCommandErrorLog) return null; + if (repoPath && lastGitCommandErrorLog.repoPath !== repoPath) return null; + return lastGitCommandErrorLog.path; +} + async function runGit( repoPath: string, args: string[], @@ -95,16 +146,24 @@ async function runGit( const rawCode = "code" in error ? error.code : null; if (rawCode === "ENOENT") { - throw new GitCommandError(args, "git is not installed or not available in PATH", null); + throw new GitCommandError(args, "git is not installed or not available in PATH", null, null); } if ("killed" in error && error.killed) { - throw new GitCommandError(args, `git command timed out after ${GIT_TIMEOUT_MS}ms`, null); + throw new GitCommandError( + args, + `git command timed out after ${GIT_TIMEOUT_MS}ms`, + null, + null, + ); } - const stderr = "stderr" in error ? String(error.stderr ?? "").trim() : error.message; + const stdout = "stdout" in error ? commandOutputToString(error.stdout) : ""; + const rawStderr = "stderr" in error ? commandOutputToString(error.stderr) : ""; + const stderr = rawStderr.trim() || error.message; const code = typeof rawCode === "number" ? rawCode : null; - const commandError = new GitCommandError(args, stderr, code); + const logPath = await writeGitCommandErrorLog({ repoPath, args, stderr, stdout, code }); + const commandError = new GitCommandError(args, stderr, code, logPath); if (options?.allowFailure) { throw commandError; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 61f422d..62a9d90 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -29,7 +29,7 @@ "@base-ui/react": "^1.1.0", "@hookform/resolvers": "^5.2.2", "@m2d/react-markdown": "^1.0.0", - "@pierre/diffs": "1.2.4", + "@pierre/diffs": "1.2.7", "@pierre/trees": "1.0.0-beta.3", "@reduxjs/toolkit": "^2.9.0", "@tanstack/react-hotkeys": "^0.1.0", diff --git a/apps/desktop/src/features/source-control/actions.test.ts b/apps/desktop/src/features/source-control/actions.test.ts index b20a59c..f7fa085 100644 --- a/apps/desktop/src/features/source-control/actions.test.ts +++ b/apps/desktop/src/features/source-control/actions.test.ts @@ -52,6 +52,7 @@ vi.mock("@/platform/desktop", () => ({ discardFiles: vi.fn(), discardAll: vi.fn(), commitStaged: vi.fn(), + getLastGitCommandErrorLogPath: vi.fn(), getUpdateState: vi.fn(), checkForUpdates: vi.fn(), downloadUpdate: vi.fn(), diff --git a/apps/desktop/src/features/source-control/actions.ts b/apps/desktop/src/features/source-control/actions.ts index e620fc7..e821bb0 100644 --- a/apps/desktop/src/features/source-control/actions.ts +++ b/apps/desktop/src/features/source-control/actions.ts @@ -15,8 +15,16 @@ import { setPullRequestFileJumpTarget, } from "@/features/pull-requests/pullRequestsSlice"; import { createFileViewerFocusKey } from "@/features/source-control/fileViewerNavigation"; +import { errorMessageFrom } from "@/features/source-control/shared-utils/errorMessage"; import { gitApi } from "./api"; -import type { Bucket, BucketedFile, GitSnapshot, RunningAction, SelectedFile } from "./types"; +import type { + Bucket, + BucketedFile, + GitSnapshot, + RepoActionError, + RunningAction, + SelectedFile, +} from "./types"; import { findExistingBucket } from "./utils"; import { closeFileViewer, @@ -40,6 +48,7 @@ import { setSelectedFiles, setSelectionAnchor, setRunningAction, + setRepoActionError, } from "./sourceControlSlice"; function nextChangedFileAfterStage(snapshot: GitSnapshot | null | undefined, filePath: string) { @@ -412,6 +421,75 @@ function repoActionLabel(action: RunningAction): string { return "run repository action"; } +const ANSI_ESCAPE_PATTERN = new RegExp( + `[${String.fromCharCode(27)}${String.fromCharCode(155)}]\\[[0-?]*[ -/]*[@-~]`, + "gu", +); + +function stripAnsi(message: string) { + return message.replace(ANSI_ESCAPE_PATTERN, "").replace(/\[\d+(?:;\d+)*m/gu, ""); +} + +function cleanDesktopErrorMessage(message: string) { + return stripAnsi(message) + .replace(/^Error invoking remote method '[^']+':\s*(?:Error:\s*)?/u, "") + .replace(/^GitCommandError:\s*/u, "") + .trim(); +} + +function firstErrorLine(message: string) { + const cleanMessage = cleanDesktopErrorMessage(message); + return ( + cleanMessage + .split("\n") + .find((line) => line.trim()) + ?.trim() || cleanMessage + ); +} + +function errorDetailsFrom(error: unknown, fallback: string) { + if ( + error && + typeof error === "object" && + "details" in error && + typeof (error as { details?: unknown }).details === "string" && + (error as { details: string }).details.trim() + ) { + return cleanDesktopErrorMessage((error as { details: string }).details); + } + return cleanDesktopErrorMessage(fallback); +} + +function errorLogPathFrom(error: unknown) { + if ( + error && + typeof error === "object" && + "logPath" in error && + typeof (error as { logPath?: unknown }).logPath === "string" && + (error as { logPath: string }).logPath.trim() + ) { + return (error as { logPath: string }).logPath; + } + return null; +} + +function buildRepoActionError(action: RunningAction, error: unknown): RepoActionError { + const actionLabel = repoActionLabel(action); + const message = errorMessageFrom(error, `Failed to ${actionLabel}`); + const details = errorDetailsFrom(error, message); + const summary = firstErrorLine(details || message); + + return { + title: action === "commit" ? `Git: ${summary}` : `Failed to ${actionLabel}`, + message: + action === "commit" + ? "The commit was blocked before Git could create it. Review the command output, fix the failing check, then commit again." + : summary, + details, + logPath: errorLogPathFrom(error), + }; +} + const runRepoAction = (action: RunningAction, thunk: AppThunk>): AppThunk => async (dispatch, getState) => { @@ -421,8 +499,9 @@ const runRepoAction = try { await dispatch(thunk); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - toast.error(`Failed to ${repoActionLabel(action)}: ${message}`); + const actionError = buildRepoActionError(action, error); + dispatch(setRepoActionError(actionError)); + toast.error(`Failed to ${repoActionLabel(action)}: ${firstErrorLine(actionError.details)}`); } finally { dispatch(setRunningAction("")); } diff --git a/apps/desktop/src/features/source-control/api.ts b/apps/desktop/src/features/source-control/api.ts index de3ea4f..020881c 100644 --- a/apps/desktop/src/features/source-control/api.ts +++ b/apps/desktop/src/features/source-control/api.ts @@ -23,6 +23,7 @@ import { getRepoFiles, getRepoFile, getGitSnapshot, + getLastGitCommandErrorLogPath, stageAll, stageFile, unstageAll, @@ -31,7 +32,7 @@ import { updateWorktreeFileContents, } from "./services/git"; -type ErrorResult = { message: string }; +type ErrorResult = { message: string; details?: string; logPath?: string | null }; type CommitHistoryArgs = { repoPath: string; limit?: number }; type BranchFilesArgs = { repoPath: string; baseRef: string; headRef: string }; @@ -60,8 +61,33 @@ type DiscardFileArgs = { repoPath: string; relPath: string; bucket: Bucket }; type DiscardFilesArgs = { repoPath: string; files: Array<{ relPath: string; bucket: Bucket }> }; type CommitStagedArgs = { repoPath: string; message: string }; -function toErrorResult(error: unknown): ErrorResult { - return { message: error instanceof Error ? error.message : String(error) }; +function errorMessage(error: unknown) { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + if ( + error && + typeof error === "object" && + "message" in error && + typeof (error as { message?: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + return String(error); +} + +function toErrorResult(error: unknown, options?: { logPath?: string | null }): ErrorResult { + const message = errorMessage(error); + return { message, details: message, logPath: options?.logPath ?? null }; +} + +async function toGitErrorResult(error: unknown, repoPath: string): Promise { + let logPath: string | null = null; + try { + logPath = await getLastGitCommandErrorLogPath(repoPath); + } catch { + logPath = null; + } + return toErrorResult(error, { logPath }); } function normalizeFilePath(path: string): string { @@ -326,7 +352,7 @@ export const gitApi = createApi({ try { return { data: await commitStaged(repoPath, message) }; } catch (error) { - return { error: toErrorResult(error) }; + return { error: await toGitErrorResult(error, repoPath) }; } }, invalidatesTags: (_result, _error, { repoPath }) => [ diff --git a/apps/desktop/src/features/source-control/components/RepoActionErrorDialog.tsx b/apps/desktop/src/features/source-control/components/RepoActionErrorDialog.tsx new file mode 100644 index 0000000..16d026c --- /dev/null +++ b/apps/desktop/src/features/source-control/components/RepoActionErrorDialog.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import { ExternalLink, ListTree, TriangleAlert } from "lucide-react"; +import { toast } from "sonner"; + +import { useAppDispatch, useAppSelector } from "@/app/hooks"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogTitle, +} from "@/components/ui/dialog"; +import { desktop } from "@/platform/desktop"; +import { errorMessageFrom } from "@/features/source-control/shared-utils/errorMessage"; +import { setRepoActionError } from "@/features/source-control/sourceControlSlice"; + +export function RepoActionErrorDialog() { + const dispatch = useAppDispatch(); + const repoActionError = useAppSelector((state) => state.sourceControl.repoActionError); + const [showOutput, setShowOutput] = useState(false); + + if (!repoActionError) return null; + + const closeDialog = () => { + dispatch(setRepoActionError(null)); + setShowOutput(false); + }; + + const openGitLog = async () => { + if (!repoActionError.logPath) return; + + try { + await desktop.openPath(repoActionError.logPath); + } catch (error) { + toast.error("Failed to open Git log", { + description: errorMessageFrom(error, "Unknown error"), + }); + } + }; + + return ( + { + if (!open) closeDialog(); + }} + > + +
+
+ +
+
+ + {repoActionError.title} + + + {repoActionError.message} + +
+
+ + {showOutput ? ( +
+
+              {repoActionError.details}
+            
+
+ ) : null} + +
+ + + + + {repoActionError.logPath ? ( + + ) : null} +
+
+
+ ); +} diff --git a/apps/desktop/src/features/source-control/screens/ChangesScreen.tsx b/apps/desktop/src/features/source-control/screens/ChangesScreen.tsx index 0a736da..e032d58 100644 --- a/apps/desktop/src/features/source-control/screens/ChangesScreen.tsx +++ b/apps/desktop/src/features/source-control/screens/ChangesScreen.tsx @@ -3,6 +3,7 @@ import { ResizableSidebarLayout } from "@/components/layout/ResizableSidebarLayo import { ChangesCodeViewDiffPane } from "@/features/source-control/components/ChangesCodeViewDiffPane"; import { ChangesSidebar } from "@/features/source-control/components/ChangesSidebar"; import { MergeConflictViewer } from "@/features/source-control/components/MergeConflictViewer"; +import { RepoActionErrorDialog } from "@/features/source-control/components/RepoActionErrorDialog"; import { useChangesKeyboardNav } from "@/features/source-control/hooks/useChangesKeyboardNav"; import { useChangesSync } from "@/features/source-control/hooks/useChangesSync"; import { useGetGitSnapshotQuery } from "@/features/source-control/api"; @@ -12,14 +13,17 @@ export function ChangesScreen() { useChangesSync(); return ( - } - content={} - /> + <> + } + content={} + /> + + ); } diff --git a/apps/desktop/src/features/source-control/services/git.ts b/apps/desktop/src/features/source-control/services/git.ts index 9556e3f..359c64c 100644 --- a/apps/desktop/src/features/source-control/services/git.ts +++ b/apps/desktop/src/features/source-control/services/git.ts @@ -116,3 +116,7 @@ export async function unstageAll(repoPath: string) { export async function commitStaged(repoPath: string, message: string) { return desktop.commitStaged(repoPath, message); } + +export async function getLastGitCommandErrorLogPath(repoPath: string) { + return desktop.getLastGitCommandErrorLogPath(repoPath); +} diff --git a/apps/desktop/src/features/source-control/sourceControlSlice.ts b/apps/desktop/src/features/source-control/sourceControlSlice.ts index d08500a..353d68f 100644 --- a/apps/desktop/src/features/source-control/sourceControlSlice.ts +++ b/apps/desktop/src/features/source-control/sourceControlSlice.ts @@ -9,6 +9,7 @@ import type { DiffStyle, FileViewerTarget, HistoryNavTarget, + RepoActionError, RunningAction, SelectedFile, SymbolPeekState, @@ -31,6 +32,7 @@ type SourceControlState = { commitMessage: string; lastCommitId: string; runningAction: RunningAction; + repoActionError: RepoActionError | null; selectedFiles: SelectedFile[]; selectionAnchor: SelectedFile | null; reviewBaseRef: string; @@ -58,6 +60,7 @@ const initialState: SourceControlState = { commitMessage: "", lastCommitId: "", runningAction: "", + repoActionError: null, selectedFiles: [], selectionAnchor: null, reviewBaseRef: "", @@ -159,6 +162,9 @@ const sourceControlSlice = createSlice({ state.runningAction = action.payload; } }, + setRepoActionError(state, action: PayloadAction) { + state.repoActionError = action.payload; + }, resetRepoViewState(state) { state.historyFilter = ""; state.historyCommitId = ""; @@ -170,6 +176,7 @@ const sourceControlSlice = createSlice({ state.commitMessage = ""; state.lastCommitId = ""; state.runningAction = ""; + state.repoActionError = null; state.selectedFiles = []; state.selectionAnchor = null; state.reviewBaseRef = ""; @@ -318,6 +325,7 @@ export const { setSelectedFiles, setSelectionAnchor, setRunningAction, + setRepoActionError, setRepos, setReviewActivePath, setReviewBaseRef, diff --git a/apps/desktop/src/features/source-control/types.ts b/apps/desktop/src/features/source-control/types.ts index e15cece..f1c2e16 100644 --- a/apps/desktop/src/features/source-control/types.ts +++ b/apps/desktop/src/features/source-control/types.ts @@ -108,6 +108,13 @@ export type ChangesSidebarMode = "changes" | "files" | "pull-requests" | "pull-r export type HistoryCommit = ContractHistoryCommit; +export type RepoActionError = { + title: string; + message: string; + details: string; + logPath: string | null; +}; + export type LspDiagnostic = ContractLspDiagnostic; export type SelectionRange = { diff --git a/apps/desktop/src/platform/desktop/browser.ts b/apps/desktop/src/platform/desktop/browser.ts index c443eec..60596f2 100644 --- a/apps/desktop/src/platform/desktop/browser.ts +++ b/apps/desktop/src/platform/desktop/browser.ts @@ -171,6 +171,8 @@ function browserUnsupportedFeature(method: DesktopApiMethod): string { return "Discarding all changes"; case "commitStaged": return "Creating commits"; + case "getLastGitCommandErrorLogPath": + return "Git command logs"; default: return "Desktop runtime"; } @@ -209,6 +211,9 @@ const browserDesktopApiCore = createDesktopApiWithDefaults({ async resolveActivePullRequestForBranch() { return null; }, + async getLastGitCommandErrorLogPath() { + return null; + }, async getRepoFile() { return null; }, @@ -301,6 +306,9 @@ const unavailableDesktopApiCore = createDesktopApiWithDefaults({ async resolveActivePullRequestForBranch() { return null; }, + async getLastGitCommandErrorLogPath() { + return null; + }, async syncLspDocument() {}, async closeLspDocument() {}, async getLspHover() { diff --git a/apps/desktop/src/platform/desktop/contracts.ts b/apps/desktop/src/platform/desktop/contracts.ts index df6c91a..0e89e03 100644 --- a/apps/desktop/src/platform/desktop/contracts.ts +++ b/apps/desktop/src/platform/desktop/contracts.ts @@ -437,6 +437,7 @@ export type DesktopApi = { discardFiles(repoPath: string, files: DiscardFileInput[]): Promise; discardAll(repoPath: string): Promise; commitStaged(repoPath: string, message: string): Promise; + getLastGitCommandErrorLogPath(repoPath?: string): Promise; getRepoFile(input: GetRepoFileInput): Promise; syncLspDocument(input: SyncLspDocumentInput): Promise; closeLspDocument(input: CloseLspDocumentInput): Promise; diff --git a/apps/desktop/src/platform/desktop/desktopApiMethods.ts b/apps/desktop/src/platform/desktop/desktopApiMethods.ts index fdc3d84..6efa8cc 100644 --- a/apps/desktop/src/platform/desktop/desktopApiMethods.ts +++ b/apps/desktop/src/platform/desktop/desktopApiMethods.ts @@ -46,6 +46,7 @@ export const DESKTOP_API_METHODS = [ "discardFiles", "discardAll", "commitStaged", + "getLastGitCommandErrorLogPath", "getRepoFile", "syncLspDocument", "closeLspDocument", diff --git a/apps/desktop/src/platform/desktop/index.test.ts b/apps/desktop/src/platform/desktop/index.test.ts index 6fcd12b..a17ada6 100644 --- a/apps/desktop/src/platform/desktop/index.test.ts +++ b/apps/desktop/src/platform/desktop/index.test.ts @@ -65,6 +65,7 @@ test("desktop API resolves Electron runtime lazily after import", async () => { discardFiles: vi.fn(), discardAll: vi.fn(), commitStaged: vi.fn(), + getLastGitCommandErrorLogPath: vi.fn(), getRepoFile: vi.fn(), syncLspDocument: vi.fn(), closeLspDocument: vi.fn(), diff --git a/package.json b/package.json index 8209edc..10b3690 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "make:electron": "pnpm --filter desktop make:electron", "electron:make": "pnpm --filter desktop electron:make", "lint": "oxlint --report-unused-disable-directives", + "precommit": "pnpm lint && pnpm fmt:check", "typecheck": "pnpm --filter desktop typecheck", "test": "turbo run test", "test:e2e": "pnpm --filter desktop test:e2e", @@ -30,5 +31,10 @@ "oxlint": "^1.55.0", "turbo": "^2.5.8" }, - "packageManager": "pnpm@10.28.0" + "packageManager": "pnpm@10.28.0", + "pnpm": { + "onlyBuiltDependencies": [ + "electron" + ] + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 76f3a86..3fe0698 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,11 +4,6 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false -patchedDependencies: - '@pierre/diffs@1.2.4': - hash: 2e3aa00eef52df9a9fb2b400fce519fe2973178bb419f9a485ff55b9ad668db1 - path: patches/@pierre__diffs@1.2.4.patch - importers: .: @@ -39,8 +34,8 @@ importers: specifier: ^1.0.0 version: 1.0.0(@types/react@19.2.13)(react@19.2.4) '@pierre/diffs': - specifier: 1.2.4 - version: 1.2.4(patch_hash=2e3aa00eef52df9a9fb2b400fce519fe2973178bb419f9a485ff55b9ad668db1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: 1.2.7 + version: 1.2.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@pierre/trees': specifier: 1.0.0-beta.3 version: 1.0.0-beta.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -1071,8 +1066,8 @@ packages: cpu: [x64] os: [win32] - '@pierre/diffs@1.2.4': - resolution: {integrity: sha512-SEuYxGpSCHVvfoLly/Q/OYpJSBLWaVLV3M3wI/VBW7aZmzYenNe4aXjOf5sIKJMWW5gbZe9WdLvtKUt6cQ1k1A==} + '@pierre/diffs@1.2.7': + resolution: {integrity: sha512-YrHmFZLDtiLZ4DkiVqMDUFqTFIct3ML3t20nMp0UeuiUtqrmRcenYVxPb4IafiNkhZZURhCiwcs89tVb/HrSDA==} peerDependencies: react: ^18.3.1 || ^19.0.0 react-dom: ^18.3.1 || ^19.0.0 @@ -6292,7 +6287,7 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.67.0': optional: true - '@pierre/diffs@1.2.4(patch_hash=2e3aa00eef52df9a9fb2b400fce519fe2973178bb419f9a485ff55b9ad668db1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@pierre/diffs@1.2.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@pierre/theme': 1.0.3 '@shikijs/transformers': 3.22.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7ff73fa..df03b1e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,3 @@ packages: - "apps/*" - "crates/*" - -patchedDependencies: - "@pierre/diffs@1.2.4": patches/@pierre__diffs@1.2.4.patch From 7ea565b6b45b162177df199a8070d8bda66ae21b Mon Sep 17 00:00:00 2001 From: Shpetim Date: Thu, 4 Jun 2026 15:43:09 +0200 Subject: [PATCH 3/4] add precommit fix script --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index 10b3690..68dfa21 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "make:electron": "pnpm --filter desktop make:electron", "electron:make": "pnpm --filter desktop electron:make", "lint": "oxlint --report-unused-disable-directives", + "lint:fix": "oxlint --fix --report-unused-disable-directives", "precommit": "pnpm lint && pnpm fmt:check", + "precommit:fix": "pnpm lint:fix && pnpm fmt && pnpm precommit", "typecheck": "pnpm --filter desktop typecheck", "test": "turbo run test", "test:e2e": "pnpm --filter desktop test:e2e", From 7b04a2ff367f684d5aa786e1bd60bb86e5473076 Mon Sep 17 00:00:00 2001 From: Shpetim <32248437+ShpetimA@users.noreply.github.com> Date: Thu, 25 Jun 2026 23:41:36 +0200 Subject: [PATCH 4/4] Remove outdated patch file for @pierre/diffs 1.2.4 The patch file was for version 1.2.4 but the dependency has been upgraded to 1.2.7. The patch may no longer be needed or may need to be updated for the new version. Removing it to avoid potential conflicts. --- ...{@pierre__diffs@1.2.4.patch => @pierre__diffs@1.2.4.patch.bak} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename patches/{@pierre__diffs@1.2.4.patch => @pierre__diffs@1.2.4.patch.bak} (100%) diff --git a/patches/@pierre__diffs@1.2.4.patch b/patches/@pierre__diffs@1.2.4.patch.bak similarity index 100% rename from patches/@pierre__diffs@1.2.4.patch rename to patches/@pierre__diffs@1.2.4.patch.bak