Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions apps/web/src/components/app/changes-diff-section.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/app/changes-tab.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";

Expand Down
118 changes: 118 additions & 0 deletions apps/web/src/components/app/unified-diff-language.ts
Original file line numberDiff line numberDiff line change
@@ -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<string, string> = {
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<string, string> = {
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;
}
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);

Expand Down
121 changes: 121 additions & 0 deletions apps/web/src/components/app/unified-diff-utils.ts
Original file line numberDiff line numberDiff line change
@@ -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;
}
Loading
Loading