mirror of
https://github.com/nilbuild/diffity.git
synced 2026-09-19 07:26:16 +08:00
fix: highlighting missing on comments
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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,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 || []}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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/`);
|
||||
|
||||
@@ -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}-`);
|
||||
|
||||
Reference in New Issue
Block a user