fix: highlighting missing on comments

This commit is contained in:
Kamran Ahmed
2026-03-15 16:46:21 +00:00
parent f80a1e2f84
commit bfe9c2803b
13 changed files with 226 additions and 44 deletions
+35
View File
@@ -1,4 +1,5 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { createHash } from 'node:crypto';
import { readFileSync, existsSync } from 'node:fs';
import { join, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -6,6 +7,7 @@ import { dirname } from 'node:path';
import { parseDiff, type ParsedDiff } from '@diffity/parser';
import {
getDiff,
getDiffStat,
getUntrackedFiles,
getUntrackedDiff,
getRepoInfo,
@@ -269,6 +271,39 @@ export function startServer(options: ServerOptions): Promise<ServerResult> {
return;
}
if (pathname === '/api/diff-fingerprint') {
const ref = url.searchParams.get('ref');
let stat: string;
if (ref) {
switch (ref) {
case 'work':
case 'working':
stat = getDiffStat(['HEAD']) + '\n' + getUntrackedFiles().join('\n');
break;
case 'staged':
stat = getDiffStat(['--staged']);
break;
case 'unstaged':
stat = getDiffStat([]);
break;
case 'untracked':
stat = getUntrackedFiles().join('\n');
break;
default:
stat = getDiffStat([ref]);
break;
}
} else {
stat = getDiffStat(diffArgs);
if (includeUntracked) {
stat += '\n' + getUntrackedFiles().join('\n');
}
}
const hash = createHash('sha1').update(stat).digest('hex').slice(0, 12);
sendJson(res, { fingerprint: hash });
return;
}
if (pathname === '/api/diff') {
const ref = url.searchParams.get('ref');
const whitespace = url.searchParams.get('whitespace');
+9
View File
@@ -79,6 +79,15 @@ export function applySuggestion(filePath: string, startLine: number, endLine: nu
writeFileSync(filePath, lines.join('\n'));
}
export function getDiffStat(args: string[] = []): string {
const cmd = ['git', 'diff', '--stat', ...args].join(' ');
try {
return execLarge(cmd);
} catch {
return '';
}
}
export function getMergeBase(a: string, b: string): string {
return exec(`git merge-base ${a} ${b}`);
}
+1 -1
View File
@@ -1,5 +1,5 @@
export type { Commit, RepoInfo } from './types.js';
export { isGitRepo, getRepoRoot, getRepoName, getCurrentBranch, getRepoInfo, getHeadHash, getDiffityDir, isActionableRef } from './repo.js';
export { getDiff, getUntrackedFiles, getUntrackedDiff, getFileContent, getFileLineCount, getMergeBase, resolveRef, revertFile, revertHunk, applySuggestion } from './diff.js';
export { getDiff, getDiffStat, getUntrackedFiles, getUntrackedDiff, getFileContent, getFileLineCount, getMergeBase, resolveRef, revertFile, revertHunk, applySuggestion } from './diff.js';
export { getStagedFiles, getUnstagedFiles } from './status.js';
export { getRecentCommits } from './commits.js';
+13 -3
View File
@@ -14,6 +14,16 @@ You are starting the diffity diff viewer so the user can see their changes in th
2. Determine which mode to start in:
- If the user said "staged" or there are staged changes they want to review: `{{binary}} --staged`
- Otherwise default to: `{{binary}}`
3. Start diffity in the background: run `{{binary}} --quiet &` (or `{{binary}} --staged --quiet &`). Use `--quiet` to reduce noise. The browser will open automatically. The session is auto-created on startup.
4. Wait briefly (1-2 seconds) for the server to start, then verify `.diffity/current-session` exists.
5. Tell the user the server is running.
3. Start the server using the Bash tool with `run_in_background: true`:
- Command: `{{binary}}` (or `{{binary}} --staged`)
- Do NOT use `&` or `--quiet` — let the Bash tool handle backgrounding
- The browser will open automatically and the session is auto-created on startup
4. Wait 2 seconds, then verify the session exists by checking that `.diffity/current-session` file is present.
5. Tell the user diffity is running and show them what they can do next. Keep it short — don't show session IDs, hashes, or other internals. Example:
> Diffity is running — check your browser.
>
> Here's what you can do:
> - **{{slash}}review** — get a code review on your changes
> - **{{slash}}self-review** — quick pre-push sanity check
> - **{{slash}}summarize** — generate a PR summary
+10
View File
@@ -10,8 +10,10 @@ import { Toolbar } from './toolbar';
import { DiffView, type DiffViewHandle } from './diff-view';
import { Sidebar } from './sidebar';
import { ShortcutModal } from './shortcut-modal';
import { StaleDiffBanner } from './stale-diff-banner';
import { CheckCircleIcon } from './icons/check-circle-icon';
import { PageLoader } from './skeleton';
import { useDiffStaleness } from '../hooks/use-diff-staleness';
import { type ViewMode, getFilePath, getAutoCollapsedPaths, isWorkingTreeRef } from '../lib/diff-utils';
import { getFileBlocks, getHunkHeaders, scrollToElement } from '../lib/dom-utils';
@@ -39,6 +41,8 @@ export function DiffPage(props: DiffPageProps) {
const reviewsEnabled = !!info?.capabilities?.reviews;
const sessionId = info?.sessionId ?? null;
const isWorkingTree = isWorkingTreeRef(refParam);
const { isStale, resetStaleness } = useDiffStaleness(refParam, isWorkingTree);
useEffect(() => {
if (!diff || diff === initializedDiffRef.current) {
@@ -188,6 +192,11 @@ export function DiffPage(props: DiffPageProps) {
queryClient.invalidateQueries({ queryKey: ['diff'] });
}, [queryClient]);
const handleRefreshDiff = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['diff'] });
resetStaleness();
}, [queryClient, resetStaleness]);
const handleSidebarFileClick = useCallback((path: string) => {
scrollTargetRef.current = path;
setActiveFile(path);
@@ -262,6 +271,7 @@ export function DiffPage(props: DiffPageProps) {
diff={diff || undefined}
diffRef={refParam}
/>
{isStale && <StaleDiffBanner onRefresh={handleRefreshDiff} />}
<div className="flex flex-1 overflow-hidden">
<Sidebar
files={diff?.files || []}
+6 -1
View File
@@ -152,8 +152,13 @@ export function FileBlock(props: FileBlockProps) {
if (pendingSelection && pendingSelection.filePath === filePath && pendingSelection.side === side) {
return line >= pendingSelection.startLine && line <= pendingSelection.endLine;
}
for (const thread of fileThreads) {
if (thread.side === side && line >= thread.startLine && line <= thread.endLine && thread.status === 'open') {
return true;
}
}
return false;
}, [isLineInSelection, pendingSelection, filePath]);
}, [isLineInSelection, pendingSelection, filePath, fileThreads]);
const { ref: inViewRef } = useInView({
threshold: 0.1,
@@ -0,0 +1,21 @@
interface StaleDiffBannerProps {
onRefresh: () => void;
}
export function StaleDiffBanner(props: StaleDiffBannerProps) {
const { onRefresh } = props;
return (
<div className="sticky top-0 z-30 flex items-center justify-center gap-3 px-4 py-2 bg-accent/10 border-b border-accent/20 text-sm animate-slide-down">
<span className="text-accent font-medium">
Files have changed since this diff was loaded
</span>
<button
onClick={onRefresh}
className="px-3 py-1 bg-accent text-white rounded-md text-xs font-medium hover:bg-accent-hover transition-colors cursor-pointer"
>
Refresh diff
</button>
</div>
);
}
+36 -36
View File
@@ -12,52 +12,52 @@ interface SuggestionDiffProps {
thread: CommentThread;
}
function lcs(a: string[], b: string[]): number[][] {
const m = a.length;
const n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp;
}
function diffLines(original: string, suggested: string): { type: 'context' | 'delete' | 'add'; content: string }[] {
const oldLines = original.split('\n');
const newLines = suggested.split('\n');
const dp = lcs(oldLines, newLines);
const result: { type: 'context' | 'delete' | 'add'; content: string }[] = [];
const maxLen = Math.max(oldLines.length, newLines.length);
let oi = 0;
let ni = 0;
let i = oldLines.length;
let j = newLines.length;
while (oi < oldLines.length || ni < newLines.length) {
if (oi < oldLines.length && ni < newLines.length && oldLines[oi] === newLines[ni]) {
result.push({ type: 'context', content: oldLines[oi] });
oi++;
ni++;
const reversed: typeof result = [];
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) {
reversed.push({ type: 'context', content: oldLines[i - 1] });
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
reversed.push({ type: 'add', content: newLines[j - 1] });
j--;
} else {
let found = false;
for (let ahead = 1; ahead <= maxLen && !found; ahead++) {
if (oi + ahead < oldLines.length && ni < newLines.length && oldLines[oi + ahead] === newLines[ni]) {
for (let k = oi; k < oi + ahead; k++) {
result.push({ type: 'delete', content: oldLines[k] });
}
oi = oi + ahead;
found = true;
}
if (!found && ni + ahead < newLines.length && oi < oldLines.length && newLines[ni + ahead] === oldLines[oi]) {
for (let k = ni; k < ni + ahead; k++) {
result.push({ type: 'add', content: newLines[k] });
}
ni = ni + ahead;
found = true;
}
}
if (!found) {
if (oi < oldLines.length) {
result.push({ type: 'delete', content: oldLines[oi] });
oi++;
}
if (ni < newLines.length) {
result.push({ type: 'add', content: newLines[ni] });
ni++;
}
}
reversed.push({ type: 'delete', content: oldLines[i - 1] });
i--;
}
}
for (let k = reversed.length - 1; k >= 0; k--) {
result.push(reversed[k]);
}
return result;
}
@@ -0,0 +1,58 @@
import { useEffect, useRef, useState } from 'react';
import { fetchDiffFingerprint } from '../lib/api';
const POLL_INTERVAL = 3000;
export function useDiffStaleness(ref?: string, enabled = true) {
const [isStale, setIsStale] = useState(false);
const baselineRef = useRef<string | null>(null);
function resetStaleness() {
baselineRef.current = null;
setIsStale(false);
}
useEffect(() => {
if (!enabled) {
return;
}
let timer: ReturnType<typeof setTimeout>;
let cancelled = false;
async function poll() {
if (cancelled) {
return;
}
try {
const fingerprint = await fetchDiffFingerprint(ref);
if (cancelled) {
return;
}
if (baselineRef.current === null) {
baselineRef.current = fingerprint;
} else if (fingerprint !== baselineRef.current) {
setIsStale(true);
}
} catch {
// ignore fetch errors
}
if (!cancelled) {
timer = setTimeout(poll, POLL_INTERVAL);
}
}
poll();
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [ref, enabled]);
return { isStale, resetStaleness };
}
+15
View File
@@ -48,6 +48,21 @@ export async function fetchDiff(hideWhitespace: boolean, ref?: string): Promise<
return res.json();
}
export async function fetchDiffFingerprint(ref?: string): Promise<string> {
const params = new URLSearchParams();
if (ref) {
params.set('ref', ref);
}
const query = params.toString();
const url = query ? `/api/diff-fingerprint?${query}` : '/api/diff-fingerprint';
const res = await fetch(url);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const json = await res.json();
return json.fingerprint;
}
export async function fetchRepoInfo(ref?: string): Promise<RepoInfo> {
const params = new URLSearchParams();
if (ref) {
+15
View File
@@ -106,6 +106,21 @@
background: var(--color-text-muted);
}
@keyframes slide-down {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-slide-down {
animation: slide-down 0.2s ease-out;
}
.diff-empty-cell {
background:
repeating-linear-gradient(
+1 -1
View File
@@ -31,6 +31,6 @@ for (const transformer of transformers) {
cleanDir(localClaudeSkillsDir);
for (const skill of skills) {
claudeCode(skill, rootDir, { binary: 'diffity-dev', namePrefix: 'diffity-dev' });
claudeCode(skill, rootDir, { binary: 'diffity-dev', namePrefix: 'diffity-dev', slashPrefix: '/diffity-dev-' });
}
console.log(`Synced ${skills.length} dev skills to .claude/skills/`);
+6 -2
View File
@@ -17,6 +17,7 @@ export interface Skill {
export interface TransformOptions {
binary: string;
namePrefix?: string;
slashPrefix?: string;
}
export function readSkills(sourceDir: string): Skill[] {
@@ -41,8 +42,11 @@ export function readSkills(sourceDir: string): Skill[] {
return skills;
}
export function renderSkill(skill: Skill, { binary, namePrefix }: TransformOptions): string {
const body = skill.content.replaceAll('{{binary}}', binary);
export function renderSkill(skill: Skill, { binary, namePrefix, slashPrefix }: TransformOptions): string {
const slash = slashPrefix ?? '/diffity-';
const body = skill.content
.replaceAll('{{binary}}', binary)
.replaceAll('{{slash}}', slash);
const data = { ...skill.data };
if (namePrefix) {
data.name = data.name.replace('diffity-', `${namePrefix}-`);