diff --git a/apps/web/src/components/app/changes-diff-section.tsx b/apps/web/src/components/app/changes-diff-section.tsx index f52bb38d..f8890c64 100644 --- a/apps/web/src/components/app/changes-diff-section.tsx +++ b/apps/web/src/components/app/changes-diff-section.tsx @@ -18,10 +18,8 @@ import { import type { DiffViewType } from "@/lib/store"; import type { DraftComment } from "@/components/app/review-mode"; import type { ReviewFeedbackItem } from "@/hooks/use-agent-reviews"; -import { - UnifiedDiffView, - type LineSelection, -} from "@/components/app/unified-diff-view"; +import { UnifiedDiffView } from "@/components/app/unified-diff-view"; +import { type LineSelection } from "@/components/app/unified-diff-utils"; function statusIcon(status: DiffFileStatus): JSX.Element { switch (status) { diff --git a/apps/web/src/components/app/changes-tab.tsx b/apps/web/src/components/app/changes-tab.tsx index f1696982..9fc88074 100644 --- a/apps/web/src/components/app/changes-tab.tsx +++ b/apps/web/src/components/app/changes-tab.tsx @@ -19,7 +19,7 @@ import { useAllReviewFeedbackItems } from "@/hooks/use-agent-reviews"; import { findLastChangeKeyInRange, type LineSelection, -} from "@/components/app/unified-diff-view"; +} from "@/components/app/unified-diff-utils"; import { FileTree } from "@/components/app/changes-file-tree"; import { DiffPane } from "@/components/app/changes-diff-section"; diff --git a/apps/web/src/components/app/unified-diff-language.ts b/apps/web/src/components/app/unified-diff-language.ts new file mode 100644 index 00000000..e9d6015f --- /dev/null +++ b/apps/web/src/components/app/unified-diff-language.ts @@ -0,0 +1,118 @@ +import { refractor as baseRefractor } from "refractor"; +import jsx from "refractor/jsx"; +import tsx from "refractor/tsx"; +import scss from "refractor/scss"; +import toml from "refractor/toml"; +import diff from "refractor/diff"; +import docker from "refractor/docker"; +import graphql from "refractor/graphql"; +import elixir from "refractor/elixir"; +import haskell from "refractor/haskell"; +import lua from "refractor/lua"; +import php from "refractor/php"; +import scala from "refractor/scala"; +import dart from "refractor/dart"; +import r from "refractor/r"; +import perl from "refractor/perl"; +import zig from "refractor/zig"; +import nim from "refractor/nim"; +import objectivec from "refractor/objectivec"; +import shell from "refractor/shell-session"; + +baseRefractor.register(jsx); +baseRefractor.register(tsx); +baseRefractor.register(scss); +baseRefractor.register(toml); +baseRefractor.register(diff); +baseRefractor.register(docker); +baseRefractor.register(graphql); +baseRefractor.register(elixir); +baseRefractor.register(haskell); +baseRefractor.register(lua); +baseRefractor.register(php); +baseRefractor.register(scala); +baseRefractor.register(dart); +baseRefractor.register(r); +baseRefractor.register(perl); +baseRefractor.register(zig); +baseRefractor.register(nim); +baseRefractor.register(objectivec); +baseRefractor.register(shell); + +export const refractorAdapter = { + highlight(code: string, language: string) { + const root = baseRefractor.highlight(code, language); + return root.children; + }, + registered(language: string) { + return baseRefractor.registered(language); + }, +}; + +const EXT_TO_LANGUAGE: Record = { + ts: "typescript", + tsx: "tsx", + js: "javascript", + jsx: "jsx", + json: "json", + css: "css", + scss: "scss", + html: "markup", + xml: "markup", + svg: "markup", + md: "markdown", + yaml: "yaml", + yml: "yaml", + toml: "toml", + sql: "sql", + sh: "bash", + bash: "bash", + zsh: "bash", + py: "python", + rs: "rust", + go: "go", + rb: "ruby", + java: "java", + swift: "swift", + kt: "kotlin", + c: "c", + cpp: "cpp", + h: "c", + hpp: "cpp", + diff: "diff", + patch: "diff", + graphql: "graphql", + gql: "graphql", + dockerfile: "docker", + ex: "elixir", + exs: "elixir", + hs: "haskell", + lua: "lua", + php: "php", + scala: "scala", + dart: "dart", + r: "r", + pl: "perl", + pm: "perl", + zig: "zig", + nim: "nim", + m: "objectivec", +}; + +const FILENAME_TO_LANGUAGE: Record = { + Dockerfile: "docker", + Makefile: "makefile", +}; + +export function languageFromPath(filePath: string): string | null { + const basename = filePath.split("/").pop() ?? ""; + const byName = FILENAME_TO_LANGUAGE[basename]; + if (byName && refractorAdapter.registered(byName)) return byName; + + const ext = basename.split(".").pop()?.toLowerCase(); + if (!ext) return null; + const lang = EXT_TO_LANGUAGE[ext]; + if (!lang) return null; + if (!refractorAdapter.registered(lang)) return null; + return lang; +} diff --git a/apps/web/src/components/app/unified-diff-view.test.ts b/apps/web/src/components/app/unified-diff-utils.test.ts similarity index 96% rename from apps/web/src/components/app/unified-diff-view.test.ts rename to apps/web/src/components/app/unified-diff-utils.test.ts index e4bbb668..a1fab5bb 100644 --- a/apps/web/src/components/app/unified-diff-view.test.ts +++ b/apps/web/src/components/app/unified-diff-utils.test.ts @@ -5,7 +5,7 @@ import { parseDiff } from "react-diff-view"; import { refractor } from "refractor"; import javascript from "refractor/javascript"; -import { tokenizeHunksIndependently } from "./unified-diff-view"; +import { tokenizeHunksIndependently } from "./unified-diff-utils"; refractor.register(javascript); diff --git a/apps/web/src/components/app/unified-diff-utils.ts b/apps/web/src/components/app/unified-diff-utils.ts new file mode 100644 index 00000000..e3aa0ce8 --- /dev/null +++ b/apps/web/src/components/app/unified-diff-utils.ts @@ -0,0 +1,121 @@ +import { + markEdits, + tokenize, + getChangeKey, + type ChangeData, + type HunkTokens, + type HunkData, + type TokenizeOptions, +} from "react-diff-view"; + +export type LineSelection = { + filePath: string; + startLine: number; + endLine: number; + anchorLine: number; +}; + +/** + * Highlight each displayed hunk in isolation. A diff only includes a small + * amount of surrounding source, so a multi-line construct can begin in one + * hunk while its terminator is omitted before the next. Highlighting the + * assembled file would then incorrectly carry that lexical state forward. + */ +export function tokenizeHunksIndependently( + hunks: HunkData[], + options: TokenizeOptions +): HunkTokens { + const merged: HunkTokens = { old: [], new: [] }; + + for (const hunk of hunks) { + const oldOffset = hunk.oldStart - 1; + const newOffset = hunk.newStart - 1; + const localHunk: HunkData = { + ...hunk, + oldStart: 1, + newStart: 1, + changes: hunk.changes.map((change) => { + if (change.type === "delete") { + return { ...change, lineNumber: change.lineNumber - oldOffset }; + } + if (change.type === "insert") { + return { ...change, lineNumber: change.lineNumber - newOffset }; + } + return { + ...change, + oldLineNumber: change.oldLineNumber - oldOffset, + newLineNumber: change.newLineNumber - newOffset, + }; + }), + }; + const hunkTokens = tokenize([localHunk], { + ...options, + enhancers: [markEdits([localHunk])], + }); + + for (const side of ["old", "new"] as const) { + const offset = side === "old" ? oldOffset : newOffset; + for (const change of hunk.changes) { + const lineNumber = + side === "old" + ? change.type === "insert" + ? null + : change.type === "delete" + ? change.lineNumber + : change.oldLineNumber + : change.type === "delete" + ? null + : change.type === "insert" + ? change.lineNumber + : change.newLineNumber; + if (lineNumber === null) continue; + + const localLineNumber = lineNumber - offset; + merged[side][lineNumber - 1] = + hunkTokens[side][localLineNumber - 1] ?? []; + } + } + } + + return merged; +} + +export function getNewLineNumber(change: ChangeData): number | null { + if (change.type === "insert") return change.lineNumber; + if (change.type === "normal") return change.newLineNumber; + return null; +} + +export function collectSelectedChangeKeys( + hunks: HunkData[], + startLine: number, + endLine: number +): string[] { + const keys: string[] = []; + for (const hunk of hunks) { + for (const change of hunk.changes) { + const ln = getNewLineNumber(change); + if (ln !== null && ln >= startLine && ln <= endLine) { + keys.push(getChangeKey(change)); + } + } + } + return keys; +} + +export function findLastChangeKeyInRange( + hunks: HunkData[], + startLine: number, + endLine: number +): string | null { + let lastKey: string | null = null; + for (const hunk of hunks) { + for (const change of hunk.changes) { + const ln = getNewLineNumber(change); + if (ln !== null && ln >= startLine && ln <= endLine) { + lastKey = getChangeKey(change); + } + } + } + return lastKey; +} diff --git a/apps/web/src/components/app/unified-diff-view.tsx b/apps/web/src/components/app/unified-diff-view.tsx index 9a5c95c3..4cd888d9 100644 --- a/apps/web/src/components/app/unified-diff-view.tsx +++ b/apps/web/src/components/app/unified-diff-view.tsx @@ -1,258 +1,24 @@ import { memo, useEffect, useMemo, useRef, useState } from "react"; -import { - Diff, - Hunk, - markEdits, - parseDiff, - tokenize, - getChangeKey, - type ChangeData, - type HunkTokens, - type HunkData, - type TokenizeOptions, - type EventMap, -} from "react-diff-view"; +import { Diff, Hunk, parseDiff, type EventMap } from "react-diff-view"; import "react-diff-view/style/index.css"; -import { refractor as baseRefractor } from "refractor"; -import jsx from "refractor/jsx"; -import tsx from "refractor/tsx"; -import scss from "refractor/scss"; -import toml from "refractor/toml"; -import diff from "refractor/diff"; -import docker from "refractor/docker"; -import graphql from "refractor/graphql"; -import elixir from "refractor/elixir"; -import haskell from "refractor/haskell"; -import lua from "refractor/lua"; -import php from "refractor/php"; -import scala from "refractor/scala"; -import dart from "refractor/dart"; -import r from "refractor/r"; -import perl from "refractor/perl"; -import zig from "refractor/zig"; -import nim from "refractor/nim"; -import objectivec from "refractor/objectivec"; -import shell from "refractor/shell-session"; import { MessageSquare } from "lucide-react"; import { type DiffViewType } from "@/lib/store"; import { cn } from "@/lib/utils"; import { type DraftComment } from "@/components/app/review-mode"; -import { InlineCommentForm } from "@/components/app/diff-comment-form"; -import { InlineDraftAnnotation } from "@/components/app/diff-draft-annotation"; -import { InlineFeedbackAnnotation } from "@/components/app/diff-feedback-annotation"; +import { + languageFromPath, + refractorAdapter, +} from "@/components/app/unified-diff-language"; +import { + collectSelectedChangeKeys, + getNewLineNumber, + tokenizeHunksIndependently, + type LineSelection, +} from "@/components/app/unified-diff-utils"; +import { useDiffWidgets } from "@/components/app/use-diff-widgets"; import { type ReviewFeedbackItem } from "@/hooks/use-agent-reviews"; -baseRefractor.register(jsx); -baseRefractor.register(tsx); -baseRefractor.register(scss); -baseRefractor.register(toml); -baseRefractor.register(diff); -baseRefractor.register(docker); -baseRefractor.register(graphql); -baseRefractor.register(elixir); -baseRefractor.register(haskell); -baseRefractor.register(lua); -baseRefractor.register(php); -baseRefractor.register(scala); -baseRefractor.register(dart); -baseRefractor.register(r); -baseRefractor.register(perl); -baseRefractor.register(zig); -baseRefractor.register(nim); -baseRefractor.register(objectivec); -baseRefractor.register(shell); - -const refractorAdapter = { - highlight(code: string, language: string) { - const root = baseRefractor.highlight(code, language); - return root.children; - }, - registered(language: string) { - return baseRefractor.registered(language); - }, -}; - -export type LineSelection = { - filePath: string; - startLine: number; - endLine: number; - anchorLine: number; -}; - -const EXT_TO_LANGUAGE: Record = { - ts: "typescript", - tsx: "tsx", - js: "javascript", - jsx: "jsx", - json: "json", - css: "css", - scss: "scss", - html: "markup", - xml: "markup", - svg: "markup", - md: "markdown", - yaml: "yaml", - yml: "yaml", - toml: "toml", - sql: "sql", - sh: "bash", - bash: "bash", - zsh: "bash", - py: "python", - rs: "rust", - go: "go", - rb: "ruby", - java: "java", - swift: "swift", - kt: "kotlin", - c: "c", - cpp: "cpp", - h: "c", - hpp: "cpp", - diff: "diff", - patch: "diff", - graphql: "graphql", - gql: "graphql", - dockerfile: "docker", - ex: "elixir", - exs: "elixir", - hs: "haskell", - lua: "lua", - php: "php", - scala: "scala", - dart: "dart", - r: "r", - pl: "perl", - pm: "perl", - zig: "zig", - nim: "nim", - m: "objectivec", -}; - -const FILENAME_TO_LANGUAGE: Record = { - Dockerfile: "docker", - Makefile: "makefile", -}; - -function languageFromPath(filePath: string): string | null { - const basename = filePath.split("/").pop() ?? ""; - const byName = FILENAME_TO_LANGUAGE[basename]; - if (byName && refractorAdapter.registered(byName)) return byName; - - const ext = basename.split(".").pop()?.toLowerCase(); - if (!ext) return null; - const lang = EXT_TO_LANGUAGE[ext]; - if (!lang) return null; - if (!refractorAdapter.registered(lang)) return null; - return lang; -} - -/** - * Highlight each displayed hunk in isolation. A diff only includes a small - * amount of surrounding source, so a multi-line construct can begin in one - * hunk while its terminator is omitted before the next. Highlighting the - * assembled file would then incorrectly carry that lexical state forward. - */ -export function tokenizeHunksIndependently( - hunks: HunkData[], - options: TokenizeOptions -): HunkTokens { - const merged: HunkTokens = { old: [], new: [] }; - - for (const hunk of hunks) { - const oldOffset = hunk.oldStart - 1; - const newOffset = hunk.newStart - 1; - const localHunk: HunkData = { - ...hunk, - oldStart: 1, - newStart: 1, - changes: hunk.changes.map((change) => { - if (change.type === "delete") { - return { ...change, lineNumber: change.lineNumber - oldOffset }; - } - if (change.type === "insert") { - return { ...change, lineNumber: change.lineNumber - newOffset }; - } - return { - ...change, - oldLineNumber: change.oldLineNumber - oldOffset, - newLineNumber: change.newLineNumber - newOffset, - }; - }), - }; - const hunkTokens = tokenize([localHunk], { - ...options, - enhancers: [markEdits([localHunk])], - }); - - for (const side of ["old", "new"] as const) { - const offset = side === "old" ? oldOffset : newOffset; - for (const change of hunk.changes) { - const lineNumber = - side === "old" - ? change.type === "insert" - ? null - : change.type === "delete" - ? change.lineNumber - : change.oldLineNumber - : change.type === "delete" - ? null - : change.type === "insert" - ? change.lineNumber - : change.newLineNumber; - if (lineNumber === null) continue; - - const localLineNumber = lineNumber - offset; - merged[side][lineNumber - 1] = - hunkTokens[side][localLineNumber - 1] ?? []; - } - } - } - - return merged; -} - -function getNewLineNumber(change: ChangeData): number | null { - if (change.type === "insert") return change.lineNumber; - if (change.type === "normal") return change.newLineNumber; - return null; -} - -function collectSelectedChangeKeys( - hunks: HunkData[], - startLine: number, - endLine: number -): string[] { - const keys: string[] = []; - for (const hunk of hunks) { - for (const change of hunk.changes) { - const ln = getNewLineNumber(change); - if (ln !== null && ln >= startLine && ln <= endLine) { - keys.push(getChangeKey(change)); - } - } - } - return keys; -} - -export function findLastChangeKeyInRange( - hunks: HunkData[], - startLine: number, - endLine: number -): string | null { - let lastKey: string | null = null; - for (const hunk of hunks) { - for (const change of hunk.changes) { - const ln = getNewLineNumber(change); - if (ln !== null && ln >= startLine && ln <= endLine) { - lastKey = getChangeKey(change); - } - } - } - return lastKey; -} - type UnifiedDiffViewProps = { agentId: string | null; diffText: string; @@ -370,122 +136,24 @@ export const UnifiedDiffView = memo(function UnifiedDiffView({ ); }, [file, lineSelection]); - const widgets = useMemo(() => { - if (!file) return {}; - const w: Record = {}; - - if (feedbackItems) { - const grouped = new Map(); - for (const fi of feedbackItems) { - if (fi.lineStart == null) continue; - const key = findLastChangeKeyInRange( - file.hunks, - fi.lineStart, - fi.lineEnd ?? fi.lineStart - ); - if (!key) continue; - const list = grouped.get(key) ?? []; - list.push(fi); - grouped.set(key, list); - } - for (const [key, items] of grouped) { - w[key] = ( - <> - {items.map((fi) => { - const firstMsg = fi.messages[0]?.content?.body ?? ""; - const isResolved = fi.status === "resolved"; - return ( - - ); - })} - - ); - } - } - - if (draftComments) { - for (const draft of draftComments) { - const key = findLastChangeKeyInRange( - file.hunks, - draft.startLine, - draft.endLine - ); - if (!key) continue; - const draftWidget = ( - - ); - const existing = w[key]; - w[key] = existing ? ( - <> - {existing} - {draftWidget} - - ) : ( - draftWidget - ); - } - } - - if (lineSelection && agentId && commentOpen) { - const lastKey = findLastChangeKeyInRange( - file.hunks, - lineSelection.startLine, - lineSelection.endLine - ); - if (lastKey) { - w[lastKey] = ( - { - onCommentOpen(false); - onLineSelection(null); - }} - onSubmitted={() => { - onCommentOpen(false); - onLineSelection(null); - }} - /> - ); - } - } - - return w; - }, [ + const widgets = useDiffWidgets({ file, - lineSelection, agentId, filePath, + lineSelection, onLineSelection, commentOpen, onCommentOpen, reviewMode, - onStartReview, draftComments, onAddDraft, onRemoveDraft, onUpdateDraft, + onStartReview, feedbackItems, focusedFeedbackItemId, onFeedbackFocusComplete, - ]); + }); const diffRef = useRef(null); const scrollRef = useRef(null); diff --git a/apps/web/src/components/app/use-diff-widgets.tsx b/apps/web/src/components/app/use-diff-widgets.tsx new file mode 100644 index 00000000..eacce250 --- /dev/null +++ b/apps/web/src/components/app/use-diff-widgets.tsx @@ -0,0 +1,172 @@ +import { useMemo } from "react"; +import { type FileData } from "react-diff-view"; + +import { type DraftComment } from "@/components/app/review-mode"; +import { InlineCommentForm } from "@/components/app/diff-comment-form"; +import { InlineDraftAnnotation } from "@/components/app/diff-draft-annotation"; +import { InlineFeedbackAnnotation } from "@/components/app/diff-feedback-annotation"; +import { + findLastChangeKeyInRange, + type LineSelection, +} from "@/components/app/unified-diff-utils"; +import { type ReviewFeedbackItem } from "@/hooks/use-agent-reviews"; + +type UseDiffWidgetsOptions = { + file: FileData | null; + agentId: string | null; + filePath: string; + lineSelection: LineSelection | null; + onLineSelection: (sel: LineSelection | null) => void; + commentOpen: boolean; + onCommentOpen: (open: boolean) => void; + reviewMode?: boolean; + draftComments?: DraftComment[]; + onAddDraft?: ( + filePath: string, + startLine: number, + endLine: number, + comment: string + ) => void; + onRemoveDraft?: (id: string) => void; + onUpdateDraft?: (id: string, comment: string) => void; + onStartReview?: () => void; + feedbackItems?: ReviewFeedbackItem[]; + focusedFeedbackItemId?: number | null; + onFeedbackFocusComplete?: (feedbackItemId: number) => void; +}; + +export function useDiffWidgets({ + file, + agentId, + filePath, + lineSelection, + onLineSelection, + commentOpen, + onCommentOpen, + reviewMode, + draftComments, + onAddDraft, + onRemoveDraft, + onUpdateDraft, + onStartReview, + feedbackItems, + focusedFeedbackItemId, + onFeedbackFocusComplete, +}: UseDiffWidgetsOptions): Record { + return useMemo(() => { + if (!file) return {}; + const w: Record = {}; + + if (feedbackItems) { + const grouped = new Map(); + for (const fi of feedbackItems) { + if (fi.lineStart == null) continue; + const key = findLastChangeKeyInRange( + file.hunks, + fi.lineStart, + fi.lineEnd ?? fi.lineStart + ); + if (!key) continue; + const list = grouped.get(key) ?? []; + list.push(fi); + grouped.set(key, list); + } + for (const [key, items] of grouped) { + w[key] = ( + <> + {items.map((fi) => { + const firstMsg = fi.messages[0]?.content?.body ?? ""; + const isResolved = fi.status === "resolved"; + return ( + + ); + })} + + ); + } + } + + if (draftComments) { + for (const draft of draftComments) { + const key = findLastChangeKeyInRange( + file.hunks, + draft.startLine, + draft.endLine + ); + if (!key) continue; + const draftWidget = ( + + ); + const existing = w[key]; + w[key] = existing ? ( + <> + {existing} + {draftWidget} + + ) : ( + draftWidget + ); + } + } + + if (lineSelection && agentId && commentOpen) { + const lastKey = findLastChangeKeyInRange( + file.hunks, + lineSelection.startLine, + lineSelection.endLine + ); + if (lastKey) { + w[lastKey] = ( + { + onCommentOpen(false); + onLineSelection(null); + }} + onSubmitted={() => { + onCommentOpen(false); + onLineSelection(null); + }} + /> + ); + } + } + + return w; + }, [ + file, + lineSelection, + agentId, + filePath, + onLineSelection, + commentOpen, + onCommentOpen, + reviewMode, + onStartReview, + draftComments, + onAddDraft, + onRemoveDraft, + onUpdateDraft, + feedbackItems, + focusedFeedbackItemId, + onFeedbackFocusComplete, + ]); +}