From bc88caffc66bc6822e3c778ec6f5cfe07765c62e Mon Sep 17 00:00:00 2001 From: Dillon Sienko Date: Fri, 23 Oct 2026 09:48:38 -0700 Subject: [PATCH 1/5] Add repo search to the web UI --- diffui/static/app.js | 72 ++++++++-- diffui/static/components/FileHeader.js | 15 +- diffui/static/components/FullFileViewer.js | 9 +- diffui/static/components/SearchDialog.js | 143 ++++++++++++++++++++ diffui/static/components/ShortcutOverlay.js | 1 + diffui/static/components/SplitDiffViewer.js | 2 +- diffui/static/lib/utils.js | 12 ++ diffui/static/style.css | 81 +++++++++++ 8 files changed, 321 insertions(+), 14 deletions(-) create mode 100644 diffui/static/components/SearchDialog.js diff --git a/diffui/static/app.js b/diffui/static/app.js index ffa4385..417584d 100644 --- a/diffui/static/app.js +++ b/diffui/static/app.js @@ -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); @@ -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); @@ -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; @@ -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); @@ -176,7 +183,8 @@ function App() { setEmptyReason(null); } const current = activeFileRef.current; - if (data.length > 0 && (!current || !data.find(f => f.path === current))) { + if (data.some(f => f.path === current)) setBrowsing(false); + if (data.length > 0 && !browsingRef.current && (!current || !data.find(f => f.path === current))) { const firstUnreviewed = data.find(f => !f.reviewed); const newActive = (firstUnreviewed || data[0]).path; setActiveFile(newActive); @@ -199,7 +207,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; @@ -318,6 +331,7 @@ function App() { const handleViewChange = useCallback(async (newView) => { setView(newView); setActiveFile(null); + setBrowsing(false); setDiffData(null); diffCache.current.clear(); scrollPositions.current.clear(); @@ -332,6 +346,7 @@ function App() { body: JSON.stringify({ index }), }); setActiveFile(null); + setBrowsing(false); setDiffData(null); setView('all'); diffCache.current.clear(); @@ -350,9 +365,32 @@ function App() { setDiffData(cached); } setActiveFile(path); + setBrowsing(false); + setFileLine(null); fetchDiff(path); }, [fetchDiff]); + // A hit in the diff opens at the nearest line the diff shows. A file outside it opens read-only. + const handleSearchOpen = useCallback(({ path, line, changed }) => { + setShowRepoSearch(false); + if (!changed) { + if (activeFileRef.current && diffRef.current) { + scrollPositions.current.set(activeFileRef.current, diffRef.current.scrollTop); + } + 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, scrollToLineAndFlash]); + const handleCommentSelect = useCallback((filePath, lineIndex) => { scrollToLineRef.current = lineIndex; handleFileSelect(filePath); @@ -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'); @@ -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; @@ -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, @@ -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 @@ -799,6 +837,7 @@ function App() { setShowShortcuts(false); setShowCommandPalette(false); setShowGoToLine(false); + setShowRepoSearch(false); setSearchTerm(''); return; } @@ -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); } @@ -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' }, @@ -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; @@ -1033,6 +1077,7 @@ function App() { onBulkResolve=${() => handleBulkResolve(activeFile)} onToggleReview=${handleToggleReview} previewing=${previewing} + browsing=${browsing} onTogglePreview=${() => setShowPreview(v => !v)} keybindings=${keybindings} /> @@ -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} @@ -1076,7 +1121,7 @@ function App() { />` : html`<${DiffSkeleton} />` } - ${diffMode === 'unified' && diffData && html` + ${diffMode === 'unified' && diffData && !browsing && html` <${Minimap} diffData=${diffData} comments=${comments} containerRef=${diffRef} /> `} @@ -1100,6 +1145,13 @@ function App() { onClose=${() => setShowGoToLine(false)} /> `} + ${showRepoSearch && html` + <${SearchDialog} + view=${view} + onOpen=${handleSearchOpen} + onClose=${() => setShowRepoSearch(false)} + /> + `} ${showAgentTerminal && html` <${AgentTerminal} onClose=${handleCloseTerminal} diff --git a/diffui/static/components/FileHeader.js b/diffui/static/components/FileHeader.js index 753ddfe..6d1a947 100644 --- a/diffui/static/components/FileHeader.js +++ b/diffui/static/components/FileHeader.js @@ -7,13 +7,26 @@ 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'); + if (browsing) { + return html` +
+
+ + ${dir}${name} + + Not in diff, read only +
+
+ `; + } + return html`
diff --git a/diffui/static/components/FullFileViewer.js b/diffui/static/components/FullFileViewer.js index d576eb2..e373708 100644 --- a/diffui/static/components/FullFileViewer.js +++ b/diffui/static/components/FullFileViewer.js @@ -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 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); @@ -44,6 +44,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; @@ -67,7 +72,7 @@ export function FullFileViewer({ filePath, view, containerRef }) {
${data.lines.slice(startIdx, endIdx).map(line => html` -
+
${line.num} │ diff --git a/diffui/static/components/SearchDialog.js b/diffui/static/components/SearchDialog.js new file mode 100644 index 0000000..63f5e71 --- /dev/null +++ b/diffui/static/components/SearchDialog.js @@ -0,0 +1,143 @@ +import { h } from 'preact'; +import { useState, useEffect, useRef, useMemo } from 'preact/hooks'; +import htm from 'htm'; + +const html = htm.bind(h); + +const DEBOUNCE_MS = 150; +const SCOPES = [['changed', 'Changed'], ['all', 'All files']]; + +function Marked({ text, query }) { + if (!query) return text; + const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return text.split(new RegExp(`(${escaped})`, 'i')).map((part, i) => (i % 2 ? html`${part}` : part)); +} + +// Files outside the diff are dimmed and tagged, as in the terminal UI. +function NotInDiff({ changed }) { + return !changed && html`not in diff`; +} + +export function SearchDialog({ view, onOpen, onClose }) { + const [query, setQuery] = useState(''); + const [scope, setScope] = useState('changed'); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [selected, setSelected] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + + useEffect(() => { + if (inputRef.current) inputRef.current.focus(); + }, []); + + useEffect(() => { + if (!query) { setData(null); setError(null); return; } + const controller = new AbortController(); + const timer = setTimeout(async () => { + try { + const res = await fetch(`/api/search?${new URLSearchParams({ q: query, view, scope })}`, { signal: controller.signal }); + if (!res.ok) throw new Error(res.status); + setData(await res.json()); + setError(null); + } catch (err) { + if (err.name !== 'AbortError') { setData(null); setError(`Search failed: ${err.message}`); } + } + }, DEBOUNCE_MS); + return () => { clearTimeout(timer); controller.abort(); }; + }, [query, scope, view]); + + // `items` are the rows Enter can open. `rows` adds the headings between them. + const { rows, items } = useMemo(() => { + const rows = []; + const items = []; + const add = (item) => { rows.push({ item, idx: items.length }); items.push(item); }; + const files = data?.files || []; + if (files.length) { + rows.push({ heading: `Files (${files.length})` }); + for (const f of files) add({ path: f.path, line: null, changed: f.changed }); + } + let current = null; + for (const hit of data?.hits || []) { + if (hit.path !== current) { + current = hit.path; + rows.push({ path: current, changed: hit.changed }); + } + add({ path: hit.path, line: hit.line, text: hit.text.trim(), changed: hit.changed }); + } + return { rows, items }; + }, [data]); + + useEffect(() => { + setSelected(0); + }, [data]); + + useEffect(() => { + const el = listRef.current && listRef.current.querySelector('.selected'); + if (el) el.scrollIntoView({ block: 'nearest' }); + }, [selected]); + + const move = (step) => setSelected(i => Math.max(0, Math.min(i + step, items.length - 1))); + const toggleScope = () => setScope(s => (s === 'changed' ? 'all' : 'changed')); + + const handleKeyDown = (e) => { + if (e.key === 'Escape') { + onClose(); + } else if (e.key === 'Tab') { + e.preventDefault(); + toggleScope(); + } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'PageDown' || e.key === 'PageUp') { + e.preventDefault(); + move((e.key.endsWith('Down') ? 1 : -1) * (e.key.startsWith('Page') ? 10 : 1)); + } else if (e.key === 'Enter' && items[selected]) { + onOpen(items[selected]); + } + }; + + const itemClass = (idx, changed) => 'command-palette-item repo-search-item' + (idx === selected ? ' selected' : '') + (changed ? '' : ' dim'); + + return html` +
{ if (e.target.classList.contains('command-palette-overlay')) onClose(); }}> + +
+ `; +} diff --git a/diffui/static/components/ShortcutOverlay.js b/diffui/static/components/ShortcutOverlay.js index f44b043..b9d9720 100644 --- a/diffui/static/components/ShortcutOverlay.js +++ b/diffui/static/components/ShortcutOverlay.js @@ -17,6 +17,7 @@ const SHORTCUTS = [ { keys: 'w', action: 'Toggle ignore whitespace' }, { id: 'toggle-file-tree', keys: 'b', action: 'Toggle explorer' }, { keys: `${mod}+K`, action: 'Command palette' }, + { keys: '/', action: 'Search files and text' }, { keys: 'Ctrl+F', action: 'Search in diff' }, { keys: 'Ctrl+Shift+F', action: 'Filter files' }, { keys: `${mod}+G`, action: 'Go to line' }, diff --git a/diffui/static/components/SplitDiffViewer.js b/diffui/static/components/SplitDiffViewer.js index 379fb12..b68a714 100644 --- a/diffui/static/components/SplitDiffViewer.js +++ b/diffui/static/components/SplitDiffViewer.js @@ -49,7 +49,7 @@ function SplitLine({ line, side, onRightClick, commentRanges }) { const lineHtml = commentRanges && commentRanges.length ? highlightRanges(line.html, commentRanges) : line.html; return html` -
{ if (window.getSelection && window.getSelection().toString()) return; e.preventDefault(); onRightClick(line); }}> +
{ if (window.getSelection && window.getSelection().toString()) return; e.preventDefault(); onRightClick(line); }}> ${num || ''}
diff --git a/diffui/static/lib/utils.js b/diffui/static/lib/utils.js index fe621a7..2805bfa 100644 --- a/diffui/static/lib/utils.js +++ b/diffui/static/lib/utils.js @@ -8,6 +8,18 @@ export function kb(keybindings, id, fallback) { return keybindings?.[id] || fallback; } +// The diff's new-side line number closest to `line`, or null when it shows none. +export function nearestNewLine(data, line) { + let best = null; + for (const hunk of data?.hunks || []) { + for (const l of hunk.lines) { + const n = parseInt(l.new_num); + if (n && (best === null || Math.abs(n - line) < Math.abs(best - line))) best = n; + } + } + return best; +} + export function mergeRef(...refs) { return (el) => { for (const r of refs) { if (r) r.current = el; } }; } diff --git a/diffui/static/style.css b/diffui/static/style.css index 9e91471..6aad0ca 100644 --- a/diffui/static/style.css +++ b/diffui/static/style.css @@ -382,6 +382,12 @@ option { white-space: nowrap; } +.file-header-note { + font-size: 12px; + color: var(--fg-muted); + white-space: nowrap; +} + .file-header-actions { display: flex; flex-wrap: wrap; @@ -1561,6 +1567,76 @@ mark { font-size: 13px; } +/* --- Repo search --- */ + +.command-palette.repo-search { + width: 720px; + max-width: calc(100vw - 32px); + max-height: 70vh; +} + +.repo-search-head { + display: flex; + align-items: center; + gap: 8px; + padding-right: 12px; + border-bottom: 1px solid var(--border); +} + +.repo-search-head .command-palette-input { + flex: 1; + border-bottom: none; +} + +.repo-search-heading { + padding: 8px 16px 4px; + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + color: var(--accent); +} + +.repo-search-heading.dim, +.repo-search-item.dim { + color: var(--fg-muted); +} + +.repo-search-item { + justify-content: flex-start; + gap: 12px; + padding: 4px 16px; + color: var(--fg); + font-family: var(--font-mono); + font-size: 12px; +} + +.repo-search-line { + min-width: 40px; + text-align: right; + color: var(--gutter-fg); +} + +.repo-search-text, +.repo-search-path { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.repo-search-tag { + margin-left: 8px; + font-family: var(--font-ui); + font-size: 11px; + font-weight: 400; + color: var(--fg-muted); +} + +.repo-search-hint { + padding: 8px 16px; + font-size: 11px; + color: var(--fg-muted); +} + /* --- Completion Screen --- */ .completion-screen { @@ -2123,6 +2199,11 @@ html.word-wrap-enabled .diff-gutter { -moz-appearance: textfield; } +.diff-line.goto-target { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + /* --- Hunk stats --- */ .hunk-stats { From b8760d78459e37ed9c2c3014815e6e393e4b5c8b Mon Sep 17 00:00:00 2001 From: Dillon Sienko Date: Fri, 23 Oct 2026 09:48:38 -0700 Subject: [PATCH 2/5] Document web repo search and bump to 0.11.0 --- AGENTS.md | 1 + README.md | 4 +++- pyproject.toml | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d5ff875..bdde761 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 1c59487..d46bd3c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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 | diff --git a/pyproject.toml b/pyproject.toml index 3903eae..e30494e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "diffui" -version = "0.10.0" +version = "0.11.0" description = "UI for reviewing AI agent diffs" requires-python = ">=3.10" dependencies = [ From ca45599181254953118bb14bb70a0a4454b63c41 Mon Sep 17 00:00:00 2001 From: Dillon Sienko Date: Fri, 23 Oct 2026 10:15:20 -0700 Subject: [PATCH 3/5] Simplify web repo search --- diffui/static/app.js | 28 +++++++++++----------- diffui/static/components/FileHeader.js | 14 ++++++----- diffui/static/components/FullFileViewer.js | 4 +++- diffui/static/components/SearchDialog.js | 12 +++++----- 4 files changed, 31 insertions(+), 27 deletions(-) diff --git a/diffui/static/app.js b/diffui/static/app.js index 417584d..220447c 100644 --- a/diffui/static/app.js +++ b/diffui/static/app.js @@ -183,8 +183,9 @@ function App() { setEmptyReason(null); } const current = activeFileRef.current; - if (data.some(f => f.path === current)) setBrowsing(false); - if (data.length > 0 && !browsingRef.current && (!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); @@ -193,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; @@ -279,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(); @@ -356,9 +361,7 @@ 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) { @@ -368,15 +371,12 @@ function App() { setBrowsing(false); setFileLine(null); fetchDiff(path); - }, [fetchDiff]); + }, [fetchDiff, saveScrollPosition]); - // A hit in the diff opens at the nearest line the diff shows. A file outside it opens read-only. const handleSearchOpen = useCallback(({ path, line, changed }) => { setShowRepoSearch(false); if (!changed) { - if (activeFileRef.current && diffRef.current) { - scrollPositions.current.set(activeFileRef.current, diffRef.current.scrollTop); - } + saveScrollPosition(); setActiveFile(path); setDiffData(null); setBrowsing(true); @@ -389,7 +389,7 @@ function App() { if (diffMode === 'file') setFileLine(line); else if (opening) searchJumpRef.current = { path, line }; else scrollToLineAndFlash(nearestNewLine(diffData, line)); - }, [diffMode, diffData, handleFileSelect, scrollToLineAndFlash]); + }, [diffMode, diffData, handleFileSelect, saveScrollPosition, scrollToLineAndFlash]); const handleCommentSelect = useCallback((filePath, lineIndex) => { scrollToLineRef.current = lineIndex; diff --git a/diffui/static/components/FileHeader.js b/diffui/static/components/FileHeader.js index 6d1a947..888506c 100644 --- a/diffui/static/components/FileHeader.js +++ b/diffui/static/components/FileHeader.js @@ -14,13 +14,17 @@ export function FileHeader({ activeFile, stats, reviewed, diffMode, onDiffModeCh const diffView = activeFile && diffMode === 'unified' && !previewing; const reviewKey = kb(keybindings, 'toggle-review', 'r'); + const path = html` + + ${dir}${name} + + `; + if (browsing) { return html`
- - ${dir}${name} - + ${path} Not in diff, read only
@@ -31,9 +35,7 @@ export function FileHeader({ activeFile, stats, reviewed, diffMode, onDiffModeCh
${activeFile && html` - - ${dir}${name} - + ${path} ${stats && html` +${stats.adds} diff --git a/diffui/static/components/FullFileViewer.js b/diffui/static/components/FullFileViewer.js index e373708..a668754 100644 --- a/diffui/static/components/FullFileViewer.js +++ b/diffui/static/components/FullFileViewer.js @@ -18,10 +18,12 @@ export function FullFileViewer({ filePath, view, containerRef, gotoLine, readOnl 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(() => { diff --git a/diffui/static/components/SearchDialog.js b/diffui/static/components/SearchDialog.js index 63f5e71..0d66ab4 100644 --- a/diffui/static/components/SearchDialog.js +++ b/diffui/static/components/SearchDialog.js @@ -13,7 +13,6 @@ function Marked({ text, query }) { return text.split(new RegExp(`(${escaped})`, 'i')).map((part, i) => (i % 2 ? html`${part}` : part)); } -// Files outside the diff are dimmed and tagged, as in the terminal UI. function NotInDiff({ changed }) { return !changed && html`not in diff`; } @@ -70,14 +69,15 @@ export function SearchDialog({ view, onOpen, onClose }) { useEffect(() => { setSelected(0); + if (listRef.current) listRef.current.scrollTop = 0; }, [data]); - useEffect(() => { - const el = listRef.current && listRef.current.querySelector('.selected'); + const move = (step) => { + const next = Math.max(0, Math.min(selected + step, items.length - 1)); + setSelected(next); + const el = listRef.current && listRef.current.querySelectorAll('.repo-search-item')[next]; if (el) el.scrollIntoView({ block: 'nearest' }); - }, [selected]); - - const move = (step) => setSelected(i => Math.max(0, Math.min(i + step, items.length - 1))); + }; const toggleScope = () => setScope(s => (s === 'changed' ? 'all' : 'changed')); const handleKeyDown = (e) => { From ddcd84b67a5da560d3b3b546031a30613092e013 Mon Sep 17 00:00:00 2001 From: Dillon Sienko Date: Fri, 23 Oct 2026 10:12:05 -0700 Subject: [PATCH 4/5] Compare the repo path in test_explicit_paths, not the checkout's directory name --- tests/test_git_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index 224387e..f1676b2 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -150,7 +150,7 @@ def test_explicit_paths(self): repos, idx = resolve_repos([repo_path]) assert len(repos) == 1 assert idx == 0 - assert repos[0].name == "diffui" + assert repos[0].resolve() == Path(repo_path).resolve() def test_deduplicates(self): from diffui.git_utils import resolve_repos From c116b1a66531256e7be41c906fcf82c66ecd58a9 Mon Sep 17 00:00:00 2001 From: Dillon Sienko Date: Fri, 23 Oct 2026 10:18:57 -0700 Subject: [PATCH 5/5] Clear inherited GIT_DIR in tests so the pre-push hook works from a linked worktree --- tests/conftest.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 266c8ee..edd5309 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,6 +27,9 @@ def diffui_config_root(tmp_path_factory): mp.setattr(diffui.git_utils, "_CONFIG_ROOT", root) # A suite run from a herdr pane would otherwise write to the live sidebar. mp.delenv("HERDR_WORKSPACE_ID", raising=False) + # A pre-push hook in a linked worktree exports GIT_DIR, which points the temp repos' git calls at the checkout. + for var in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"): + mp.delenv(var, raising=False) yield root mp.undo()