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 4e82390..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.1.15", + "@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/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/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/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/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 3ac0451..e032d58 100644 --- a/apps/desktop/src/features/source-control/screens/ChangesScreen.tsx +++ b/apps/desktop/src/features/source-control/screens/ChangesScreen.tsx @@ -1,49 +1,37 @@ -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 { RepoActionErrorDialog } from "@/features/source-control/components/RepoActionErrorDialog"; 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"); useChangesSync(); return ( - } - content={} - /> + <> + } + content={} + /> + + ); } 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 +39,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/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 9f43350..68dfa21 100644 --- a/package.json +++ b/package.json @@ -12,6 +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", @@ -22,10 +25,18 @@ "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", "turbo": "^2.5.8" }, - "packageManager": "pnpm@10.28.0" + "packageManager": "pnpm@10.28.0", + "pnpm": { + "onlyBuiltDependencies": [ + "electron" + ] + } } diff --git a/patches/@pierre__diffs@1.2.4.patch.bak b/patches/@pierre__diffs@1.2.4.patch.bak new file mode 100644 index 0000000..688adb7 --- /dev/null +++ b/patches/@pierre__diffs@1.2.4.patch.bak @@ -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..3fe0698 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,16 +7,20 @@ settings: 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 +34,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.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) @@ -218,6 +222,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 +296,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 +359,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 +461,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 +952,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.7': + resolution: {integrity: sha512-YrHmFZLDtiLZ4DkiVqMDUFqTFIct3ML3t20nMp0UeuiUtqrmRcenYVxPb4IafiNkhZZURhCiwcs89tVb/HrSDA==} 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 +2165,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 +2355,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 +3001,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 +3050,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 +3253,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 +3704,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 +3804,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 +4273,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 +5086,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 +5113,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 +5493,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 +5605,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 +5670,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 +5805,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 +5871,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 +6230,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.7(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 +6298,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 +7363,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 +7397,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 +8304,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 +8319,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 +8365,8 @@ snapshots: env-paths@2.2.1: {} + env-paths@3.0.0: {} + err-code@2.0.3: {} error-ex@1.3.4: @@ -8526,6 +8624,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 +9123,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 +9197,8 @@ snapshots: lodash@4.17.23: {} + lodash@4.18.1: {} + log-symbols@4.1.0: dependencies: chalk: 4.1.2 @@ -9781,27 +9895,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 +10863,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 +10891,9 @@ snapshots: undici-types@7.16.0: {} + undici@7.26.0: + optional: true + unicorn-magic@0.3.0: {} unified@11.0.5: