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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ diffui/
- `treeOrder()` in `FileTree.js` gives the file order that prev/next file and next-unreviewed follow. `_order_files` in `routes_diff.py` mirrors it for the TUI. Sort by risk turns the tree into one flat list.
- The top bar holds navigation and global actions. `FileHeader` above the active viewer holds the per-file ones (view mode, whitespace, blame, resolve all, Mark reviewed).
- `FullFileViewer` renders a window of lines. Hunk blocks use `content-visibility: auto`.
- Search is `SearchDialog`, opened with `/` or the command palette. It calls `/api/search` 150ms after the last keystroke, aborts the request it replaces, and `tab` switches `scope`. `handleSearchOpen` in `app.js` opens a hit in the diff and scrolls to the line the diff shows nearest to it (`nearestNewLine`). A file outside the diff sets `browsing` and opens in `FullFileViewer` with `readOnly`, because the server reports an unchanged file as all added. `browsing` swaps `FileHeader` for a short one, makes `handleToggleReview` return, stops `fetchFiles` from resetting the active file, and keeps `activeFile` out of the session save. It ends when the file joins the diff or another file is picked.
- Keep the accessibility basics: `role="treeitem"` on tree items, focus trapping in the settings dialog, `:focus-visible` outlines on buttons, and `+`/`-` prefixes next to the diff colors.

### Design system
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ diffui --tui # Review in the terminal (see Terminal UI)

Both run the same local server and read the same state, so comments and reviewed files carry over between them. Press `o` in the TUI to open the current file in the browser.

The browser has the agent terminal panel, Explain changes, markdown and mermaid preview, blame, the full file view, comments on part of a line, and Settings. The TUI keeps you in the terminal next to your agent, and it's the only one that searches the whole repo, not just the diff.
The browser has the agent terminal panel, Explain changes, markdown and mermaid preview, blame, the full file view, comments on part of a line, and Settings. The TUI keeps you in the terminal next to your agent. Both search the whole repo, not just the diff.

## Reviewing changes

Expand All @@ -73,6 +73,7 @@ The browser has the agent terminal panel, Explain changes, markdown and mermaid
- Press `r` to mark a file reviewed. The top bar shows reviewed files out of the total, next to the added and removed line counts. If a reviewed file changes on disk, it is unmarked. When everything is reviewed, a completion screen shows your stats. `]` jumps to the next unreviewed file.
- `j` and `k` jump between hunks. Click a hunk header to collapse it. The raw `diff --git`, `index`, `---` and `+++` lines are hidden, and a rename, mode change, new or deleted file, or binary file gets one short line.
- `ctrl+f` searches within the diff and highlights the matches. `ctrl+g` jumps to a line. `ctrl+shift+f`, or the Search files button, filters the file list.
- `/` opens a search over file names and text. It starts on the files in the current scope, and `tab` switches to the whole repo, which skips `.gitignore`d and binary files. Results group by file with line numbers, up to 200 lines. Enter opens the file at that line, and when the diff does not show the line, the view goes to the nearest one it does. Files that are not in the diff are dimmed and marked "not in diff". They open read-only in the file view, with no comments and no Mark reviewed, and picking a file from the explorer returns to the diff. The terminal UI has the same search, described in [Search and find](#search-and-find).
- Files are scored for risk from their path and size: migration, infra, schema and deploy paths, config files, large deletions, high churn and test removal. Medium and high risk files get a colored dot in the file tree, with the level in the tooltip. `s` sorts by risk.
- A `.diffuiignore` file in the repo root hides matching files from the file list and from search. It takes one glob per line, and `#` starts a comment. `diffui --json` still lists them.

Expand Down Expand Up @@ -229,6 +230,7 @@ The header shows the current scope. The browser has the same choices in its view
| `ctrl+f` | Search in diff |
| `ctrl+g` / `cmd+g` | Go to line |
| `ctrl+shift+f` | Search files |
| `/` | Search file names and text, in the scope's files or the whole repo (`tab` switches) |
| `ctrl+click` / `cmd+click` | Open in editor |
| `right-click` | Comment on line (shows the native menu when text is selected, so you can copy) |
| select text | Floating "Comment" button to comment on the exact substring |
Expand Down
86 changes: 69 additions & 17 deletions diffui/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import { AgentTerminal } from './components/AgentTerminal.js';
import { CommentsPanel } from './components/CommentsPanel.js';
import { FileFilterBar } from './components/FileFilterBar.js';
import { GoToLineDialog } from './components/GoToLineDialog.js';
import { shortName, mod } from './lib/utils.js';
import { SearchDialog } from './components/SearchDialog.js';
import { shortName, mod, nearestNewLine } from './lib/utils.js';

const html = htm.bind(h);

Expand Down Expand Up @@ -91,6 +92,9 @@ function App() {
const [showCommandPalette, setShowCommandPalette] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [showGoToLine, setShowGoToLine] = useState(false);
const [showRepoSearch, setShowRepoSearch] = useState(false);
const [browsing, setBrowsing] = useState(false);
const [fileLine, setFileLine] = useState(null);
const [wsConnected, setWsConnected] = useState(false);
const [ignoreWhitespace, setIgnoreWhitespace] = useState(false);
const [showIgnored, setShowIgnored] = useState(false);
Expand All @@ -105,6 +109,7 @@ function App() {
const scrollPositions = useRef(new Map());
const hoveredLineRef = useRef(null);
const scrollToLineRef = useRef(null);
const searchJumpRef = useRef(null);
const latestCallbacks = useRef({});
const activeFileRef = useRef(activeFile);
activeFileRef.current = activeFile;
Expand All @@ -115,6 +120,8 @@ function App() {
const branchRef = useRef(branch);
branchRef.current = branch;
const visibleFilesRef = useRef([]);
const browsingRef = useRef(false);
browsingRef.current = browsing;
const showCompletionRef = useRef(false);
const dialogOpenRef = useRef(false);
const ignoreWsRef = useRef(false);
Expand Down Expand Up @@ -176,7 +183,9 @@ function App() {
setEmptyReason(null);
}
const current = activeFileRef.current;
if (data.length > 0 && (!current || !data.find(f => f.path === current))) {
const inDiff = data.some(f => f.path === current);
if (inDiff) setBrowsing(false);
if (data.length > 0 && !browsingRef.current && !inDiff) {
const firstUnreviewed = data.find(f => !f.reviewed);
const newActive = (firstUnreviewed || data[0]).path;
setActiveFile(newActive);
Expand All @@ -185,6 +194,12 @@ function App() {
setLoading(false);
}, [view, fetchDiff]);

const saveScrollPosition = useCallback(() => {
if (activeFileRef.current && diffRef.current) {
scrollPositions.current.set(activeFileRef.current, diffRef.current.scrollTop);
}
}, []);

const flashLineRef = useRef(null);
const scrollToLineAndFlash = useCallback((lineNum) => {
if (!diffRef.current) return false;
Expand All @@ -199,7 +214,12 @@ function App() {

useEffect(() => {
if (!activeFile || !diffRef.current) return;
const targetLine = scrollToLineRef.current;
let targetLine = scrollToLineRef.current;
const jump = searchJumpRef.current;
if (jump && diffData?.file_path === jump.path) {
searchJumpRef.current = null;
targetLine = nearestNewLine(diffData, jump.line);
}
let rafId;
if (targetLine !== null) {
scrollToLineRef.current = null;
Expand Down Expand Up @@ -266,9 +286,7 @@ function App() {
const cb = latestCallbacks.current;
if (events.includes('git_changed') || events.includes('files_changed')) {
diffCache.current.clear();
if (cb.activeFile && diffRef.current) {
scrollPositions.current.set(cb.activeFile, diffRef.current.scrollTop);
}
saveScrollPosition();
cb.fetchFiles();
cb.fetchCommits();
cb.fetchRepos();
Expand Down Expand Up @@ -318,6 +336,7 @@ function App() {
const handleViewChange = useCallback(async (newView) => {
setView(newView);
setActiveFile(null);
setBrowsing(false);
setDiffData(null);
diffCache.current.clear();
scrollPositions.current.clear();
Expand All @@ -332,6 +351,7 @@ function App() {
body: JSON.stringify({ index }),
});
setActiveFile(null);
setBrowsing(false);
setDiffData(null);
setView('all');
diffCache.current.clear();
Expand All @@ -341,17 +361,35 @@ function App() {
}, [fetchRepos, fetchBranch, fetchCommits, fetchFiles, fetchComments]);

const handleFileSelect = useCallback((path) => {
if (activeFileRef.current && diffRef.current) {
scrollPositions.current.set(activeFileRef.current, diffRef.current.scrollTop);
}
saveScrollPosition();
const cacheKey = `${path}:${viewRef.current}:c${contextRef.current}:ws${ignoreWsRef.current}`;
const cached = diffCache.current.get(cacheKey);
if (cached) {
setDiffData(cached);
}
setActiveFile(path);
setBrowsing(false);
setFileLine(null);
fetchDiff(path);
}, [fetchDiff]);
}, [fetchDiff, saveScrollPosition]);

const handleSearchOpen = useCallback(({ path, line, changed }) => {
setShowRepoSearch(false);
if (!changed) {
saveScrollPosition();
setActiveFile(path);
setDiffData(null);
setBrowsing(true);
setFileLine(line);
return;
}
const opening = path !== activeFileRef.current || browsingRef.current;
if (opening) handleFileSelect(path);
if (!line) return;
if (diffMode === 'file') setFileLine(line);
else if (opening) searchJumpRef.current = { path, line };
else scrollToLineAndFlash(nearestNewLine(diffData, line));
}, [diffMode, diffData, handleFileSelect, saveScrollPosition, scrollToLineAndFlash]);

const handleCommentSelect = useCallback((filePath, lineIndex) => {
scrollToLineRef.current = lineIndex;
Expand All @@ -360,7 +398,7 @@ function App() {

const handleToggleReview = useCallback(async () => {
const af = activeFileRef.current;
if (!af) return;
if (!af || browsingRef.current) return;
const r = await fetch(`/api/reviewed/${encodeURIComponent(af)}`, { method: 'POST' });
const data = await r.json();
showToast(data.reviewed ? 'Marked as reviewed' : 'Marked as unreviewed', 'success');
Expand Down Expand Up @@ -727,7 +765,7 @@ function App() {
}
return treeOrder(filtered, groupMode, sortByRisk);
}, [files, showReviewed, sortByRisk, fileFilter, showIgnored, groupMode]);
const previewing = showPreview && !!activeFile && isPreviewable(activeFile);
const previewing = showPreview && !!activeFile && !browsing && isPreviewable(activeFile);
const activeFileReviewed = useMemo(() => files.find(f => f.path === activeFile)?.reviewed, [files, activeFile]);

visibleFilesRef.current = visibleFiles;
Expand Down Expand Up @@ -758,7 +796,7 @@ function App() {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
activeFile,
activeFile: browsingRef.current ? undefined : activeFile,
diffMode,
showFileTree,
showReviewed,
Expand All @@ -769,7 +807,7 @@ function App() {
}, [activeFile, diffMode, showFileTree, showReviewed]);

showCompletionRef.current = showCompletion;
dialogOpenRef.current = showSettings || showSearch || showFileFilter || showShortcuts || showCommandPalette || showGoToLine;
dialogOpenRef.current = showSettings || showSearch || showFileFilter || showShortcuts || showCommandPalette || showGoToLine || showRepoSearch;
const prevReviewedRef = useRef(null);
useEffect(() => {
if (prevReviewedRef.current !== null && files.length > 0
Expand Down Expand Up @@ -799,6 +837,7 @@ function App() {
setShowShortcuts(false);
setShowCommandPalette(false);
setShowGoToLine(false);
setShowRepoSearch(false);
setSearchTerm('');
return;
}
Expand Down Expand Up @@ -859,6 +898,9 @@ function App() {
scrollToHunk(e.key === 'j' ? 'next' : 'prev');
} else if (e.key === 'w') {
handleToggleWhitespace();
} else if (e.key === '/') {
e.preventDefault();
setShowRepoSearch(true);
} else if (e.key === '?') {
setShowShortcuts(v => !v);
}
Expand All @@ -880,6 +922,7 @@ function App() {
{ id: 'copy-gitlab-link', label: 'Copy GitLab link', keys: 'Y', category: 'Actions' },
{ id: 'toggle-file-tree', label: 'Toggle file tree', keys: keybindings['toggle-file-tree'] || 'b', category: 'Actions' },
{ id: 'search', label: 'Search in diff', keys: 'Ctrl+F', category: 'Actions' },
{ id: 'repo-search', label: 'Search files and text', keys: '/', category: 'Navigation' },
{ id: 'shortcuts', label: 'Show keyboard shortcuts', keys: '?', category: 'Help' },
{ id: 'settings', label: 'Open settings', category: 'Settings' },
{ id: 'mode-unified', label: 'Switch to unified diff', category: 'Settings' },
Expand Down Expand Up @@ -915,6 +958,7 @@ function App() {
case 'copy-gitlab-link': handleCopyGitLabLink(); break;
case 'toggle-file-tree': setShowFileTree(v => !v); break;
case 'search': setShowSearch(v => !v); break;
case 'repo-search': setShowRepoSearch(true); break;
case 'shortcuts': setShowShortcuts(v => !v); break;
case 'settings': setShowSettings(v => !v); break;
case 'mode-unified': setDiffMode('unified'); break;
Expand Down Expand Up @@ -1033,6 +1077,7 @@ function App() {
onBulkResolve=${() => handleBulkResolve(activeFile)}
onToggleReview=${handleToggleReview}
previewing=${previewing}
browsing=${browsing}
onTogglePreview=${() => setShowPreview(v => !v)}
keybindings=${keybindings}
/>
Expand All @@ -1043,8 +1088,8 @@ function App() {
? html`<${EmptyState} files=${files} visibleFiles=${visibleFiles} fileFilter=${fileFilter} showReviewed=${showReviewed} emptyReason=${emptyReason} />`
: previewing
? html`<${PreviewViewer} containerRef=${diffRef} filePath=${activeFile} />`
: diffMode === 'file'
? html`<${FullFileViewer} containerRef=${diffRef} filePath=${activeFile} view=${view} />`
: browsing || diffMode === 'file'
? html`<${FullFileViewer} containerRef=${diffRef} filePath=${activeFile} view=${view} gotoLine=${fileLine} readOnly=${browsing} />`
: diffMode === 'split' && diffData
? html`<${SplitDiffViewer}
containerRef=${diffRef}
Expand Down Expand Up @@ -1076,7 +1121,7 @@ function App() {
/>`
: html`<${DiffSkeleton} />`
}
${diffMode === 'unified' && diffData && html`
${diffMode === 'unified' && diffData && !browsing && html`
<${Minimap} diffData=${diffData} comments=${comments} containerRef=${diffRef} />
`}
</div>
Expand All @@ -1100,6 +1145,13 @@ function App() {
onClose=${() => setShowGoToLine(false)}
/>
`}
${showRepoSearch && html`
<${SearchDialog}
view=${view}
onOpen=${handleSearchOpen}
onClose=${() => setShowRepoSearch(false)}
/>
`}
${showAgentTerminal && html`
<${AgentTerminal}
onClose=${handleCloseTerminal}
Expand Down
23 changes: 19 additions & 4 deletions diffui/static/components/FileHeader.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,35 @@ const html = htm.bind(h);

const MODES = [['unified', 'Unified'], ['split', 'Split'], ['file', 'File']];

export function FileHeader({ activeFile, stats, reviewed, diffMode, onDiffModeChange, ignoreWhitespace, onToggleWhitespace, showReviewed, onToggleShowReviewed, showBlame, onToggleBlame, contextLines, onExpandContext, openCommentCount, onBulkResolve, onToggleReview, previewing, onTogglePreview, keybindings }) {
export function FileHeader({ activeFile, stats, reviewed, diffMode, onDiffModeChange, ignoreWhitespace, onToggleWhitespace, showReviewed, onToggleShowReviewed, showBlame, onToggleBlame, contextLines, onExpandContext, openCommentCount, onBulkResolve, onToggleReview, previewing, browsing, onTogglePreview, keybindings }) {
const name = activeFile ? shortName(activeFile) : '';
const dir = activeFile ? activeFile.slice(0, activeFile.length - name.length) : '';
const previewable = activeFile && isPreviewable(activeFile);
const diffView = activeFile && diffMode === 'unified' && !previewing;
const reviewKey = kb(keybindings, 'toggle-review', 'r');

const path = html`
<span class="file-path" title=${activeFile}>
<span class="file-path-dir">${dir}</span><span class="file-path-name">${name}</span>
</span>
`;

if (browsing) {
return html`
<div class="file-header">
<div class="file-header-path">
${path}
<span class="file-header-note">Not in diff, read only</span>
</div>
</div>
`;
}

return html`
<div class="file-header">
<div class="file-header-path">
${activeFile && html`
<span class="file-path" title=${activeFile}>
<span class="file-path-dir">${dir}</span><span class="file-path-name">${name}</span>
</span>
${path}
${stats && html`
<span class="file-header-stats">
<span class="stats-add">+${stats.adds}</span>
Expand Down
13 changes: 10 additions & 3 deletions diffui/static/components/FullFileViewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const OVERSCAN = 40;

// All line HTML is server-generated from Pygments — source code is html.escape'd
// in highlight.py before wrapping in <span> tags. No user content is rendered as HTML.
export function FullFileViewer({ filePath, view, containerRef }) {
export function FullFileViewer({ filePath, view, containerRef, gotoLine, readOnly }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [scrollTop, setScrollTop] = useState(0);
Expand All @@ -18,10 +18,12 @@ export function FullFileViewer({ filePath, view, containerRef }) {
const rafRef = useRef(0);

useEffect(() => {
let stale = false;
setLoading(true);
fetch(`/api/file/${encodeURIComponent(filePath)}?view=${view}`)
.then(r => r.json())
.then(d => { setData(d); setLoading(false); });
.then(d => { if (!stale) { setData(d); setLoading(false); } });
return () => { stale = true; };
}, [filePath, view]);

const onScroll = useCallback(() => {
Expand All @@ -44,6 +46,11 @@ export function FullFileViewer({ filePath, view, containerRef }) {
}
}, [containerRef, onScroll]);

useEffect(() => {
const el = scrollRef.current;
if (el && data && gotoLine) el.scrollTop = Math.max(0, (gotoLine - 1) * LINE_HEIGHT - el.clientHeight / 2);
}, [data, gotoLine]);

useEffect(() => {
return () => {
const el = scrollRef.current;
Expand All @@ -67,7 +74,7 @@ export function FullFileViewer({ filePath, view, containerRef }) {
<div style="height: ${totalHeight}px; position: relative;">
<div style="transform: translateY(${offsetY}px); will-change: transform;">
${data.lines.slice(startIdx, endIdx).map(line => html`
<div key=${line.num} class=${'diff-line' + (line.type === 'add' ? ' add' : line.type === 'remove' ? ' remove' : '')} style="height: ${LINE_HEIGHT}px">
<div key=${line.num} class=${'diff-line' + (readOnly ? '' : line.type === 'add' ? ' add' : line.type === 'remove' ? ' remove' : '') + (line.num === gotoLine ? ' goto-target' : '')} style="height: ${LINE_HEIGHT}px">
<div class="diff-gutter">
<span class="gutter-new">${line.num}</span>
<span class="gutter-sep">│</span>
Expand Down
Loading
Loading