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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
here. The rule is now the other way round: Haiku is the 200k exception and
everything else is 1M, which is what a model released after your build reads
without a new version.
- **A pull request with hundreds of changed files no longer locks the app.**
Every file in a diff panel starts expanded, and an expanded file builds its own
CodeMirror view — so a 191-file pull request built 191 editors in one go, each
followed by a language chunk re-configuring its document. Chromium offered to
kill the page, and the screen stayed unusable well after it recovered. A file's
editor is now built only once its card comes near the viewport; until then the
card holds a placeholder of the file's height, so the scrollbar still measures
the whole diff. An editor that has been built stays built, keeping its
selection and highlighting when you scroll back. That 191-file diff now settles
in 465 ms with 8 editors, against 13.7 s with 191. The dock's Review tab shares
the same cards and the same fix. Files changed also stopped refetching the diff
every time the window regained focus — a new commit still refreshes it.
- **The pull request screen no longer waits behind a bare "Loading…".** Both the
lookup and the diff are `gh` round-trips of about a second, and on a screen
that size a single muted line read as a failure. Each now holds the shape of
what is coming — title, actions, status line and tabs for the pull request;
file tree, toolbar and file cards for Files changed.

## [0.18.0] - 2026-07-25

Expand Down
45 changes: 44 additions & 1 deletion frontend/src/components/diff/FileDiff.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ import { useDiffEditor } from "./useDiffEditor"
// giant lockfile doesn't swamp the panel (expanding is one click away).
const LARGE_FILE_LINES = 500

// Height of one editor line, the knob sizing a file's placeholder before its
// editor exists. Measured against the diff theme's 12px font; a wrapped long
// line makes any single number a lower bound, so this only has to be close.
const LINE_HEIGHT_PX = 17

// How far outside the viewport a file's editor is built, so scrolling meets a
// rendered diff rather than a gap.
const MOUNT_MARGIN = "600px 0px"

interface FileDiffProps {
file: DiffFile
onInject: (text: string) => void
Expand Down Expand Up @@ -114,7 +123,7 @@ export function FileDiff({ file, onInject, onDiscard, bulk, viewed, onViewed }:
(file.binary ? (
<p className="px-9 py-2 text-xs text-muted-foreground">Binary file</p>
) : (
<DiffBody doc={doc} path={file.newPath} onInject={onInject} />
<LazyDiffBody doc={doc} path={file.newPath} onInject={onInject} />
))}
</section>
)
Expand All @@ -126,6 +135,40 @@ interface DiffBodyProps {
onInject: (text: string) => void
}

// A panel holds one CodeMirror view per expanded file, and a wide diff (a PR
// touching 191 files) built them all in a single commit — enough to lock the
// page. LazyDiffBody defers each one until its card comes near the viewport;
// once built, the editor stays, so scrolling back keeps its selection and
// highlighting. Collapsing the file still destroys it.
function LazyDiffBody({ doc, path, onInject }: DiffBodyProps) {
const placeholder = useRef<HTMLDivElement>(null)
const [mounted, setMounted] = useState(false)

useEffect(() => {
const target = placeholder.current
if (!target) {
return
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setMounted(true)
}
},
{ rootMargin: MOUNT_MARGIN },
)
observer.observe(target)
return () => observer.disconnect()
}, [])

if (mounted) {
return <DiffBody doc={doc} path={path} onInject={onInject} />
}
// The placeholder has to stand in for the file's height: zero-height cards
// would all sit in the viewport at once and nothing would be deferred.
return <div ref={placeholder} style={{ height: doc.lineMeta.length * LINE_HEIGHT_PX }} />
}

// DiffBody exists as its own component so collapsing the file unmounts it,
// destroying the CodeMirror view instead of keeping it alive off-screen.
//
Expand Down
42 changes: 41 additions & 1 deletion frontend/src/components/pulls/Pulls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,13 @@ import {
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { PullsChecks } from "./PullsChecks"
import { PullsFiles } from "./PullsFiles"

// Ragged widths, so the body placeholder reads as prose instead of a block.
const BODY_ROWS = ["w-full", "w-11/12", "w-4/5", "w-2/3", "w-5/6", "w-1/2"]

// Pulls is the per-project pull-request screen: it fills the main area on top of
// the persistent terminals (like Settings), showing the active session's branch
// PR — status, body and the full diff — with merge, create and open actions. It
Expand Down Expand Up @@ -124,14 +128,50 @@ export function Pulls() {
/>
)
} else if (loading) {
body = <CentredNotice>Loading…</CentredNotice>
body = <PullSkeleton />
} else {
body = <EmptyState path={path} branch={branch} onOpened={refresh} />
}

return <div className="absolute inset-0 z-10 flex flex-col bg-background">{body}</div>
}

// Looking the pull request up is a gh round-trip, and the screen covers the
// whole app while it runs — a lone "Loading…" in the middle of that much empty
// space reads as a failure. The skeleton holds the header the answer will fill:
// title, actions, status line, tabs, then the body.
function PullSkeleton() {
return (
<div aria-busy>
<div className="border-b border-border px-6 pt-5">
<div className="flex items-start gap-4">
<Skeleton className="h-6 w-80 max-w-full" />
<div className="ml-auto flex flex-none items-center gap-2">
<Skeleton className="h-8 w-28" />
<Skeleton className="size-8" />
<Skeleton className="h-8 w-24" />
</div>
</div>
<div className="mt-4 flex items-center gap-4">
<Skeleton className="h-3 w-14" />
<Skeleton className="h-3 w-56" />
<Skeleton className="h-3 w-36" />
</div>
<div className="mt-5 flex gap-6 pb-3">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-14" />
</div>
</div>
<div className="flex max-w-3xl flex-col gap-3 px-6 py-6">
{BODY_ROWS.map((width) => (
<Skeleton key={width} className={`h-3 ${width}`} />
))}
</div>
</div>
)
}

// EditState carries a pending "edit commit message" merge: which method to run
// and the message the dialog is editing. null when the dialog is closed.
interface EditState {
Expand Down
49 changes: 48 additions & 1 deletion frontend/src/components/pulls/PullsFiles.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { CollapseAllAction, useDiffBulk } from "@/components/diff/diff-bulk"
import { FileDiff } from "@/components/diff/FileDiff"
import { DiffStat } from "@/components/DiffStat"
import { FileTree } from "@/components/FileTree"
import { Skeleton } from "@/components/ui/skeleton"
import { buildTree } from "@/lib/git/file-tree"
import {
fileFingerprint,
Expand All @@ -21,6 +22,52 @@ import { usePullRequestDiff } from "@/lib/pulls/use-pull-request-diff"
// so the choice survives leaving the screen.
const TREE_HIDDEN_KEY = "lich.pulls.tree.hidden"

// Ragged widths, so the placeholders read as file names and code rather than a
// stack of identical bars.
const TREE_ROWS = ["w-28", "w-40", "w-24", "w-36", "w-32", "w-44", "w-24", "w-36"]
const CODE_ROWS = ["w-3/5", "w-4/5", "w-2/5", "w-11/12", "w-1/2", "w-3/4", "w-1/3", "w-2/3"]

// The diff is a gh round-trip, so this tab is empty for about a second. A bare
// "Loading…" line reads as a broken tab on a screen that wide; the skeleton
// stands in for the shape that arrives — tree, toolbar, then file cards.
function FilesSkeleton({ tree }: { tree: boolean }) {
return (
<div className="flex h-full" aria-busy>
{tree && (
<div className="flex w-60 shrink-0 flex-col gap-3 border-r border-border p-3">
{TREE_ROWS.map((width) => (
<Skeleton key={width} className={`h-3 ${width}`} />
))}
</div>
)}
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-none items-center gap-2 border-b border-border px-3 py-2">
<Skeleton className="size-3.5" />
<Skeleton className="ml-auto h-3 w-20" />
</div>
<div className="flex flex-col gap-7 p-3">
{[0, 1, 2].map((card) => (
<div key={card} className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Skeleton className="size-3.5" />
<Skeleton className="size-5" />
<Skeleton className="h-3 w-40" />
<Skeleton className="h-3 w-24" />
<Skeleton className="ml-auto h-3 w-14" />
</div>
<div className="flex max-w-3xl flex-col gap-2 pl-9">
{CODE_ROWS.map((width) => (
<Skeleton key={width} className={`h-2.5 ${width}`} />
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}

interface PullsFilesProps {
path: string
/** The checkout's HEAD; a new commit refetches the diff. */
Expand Down Expand Up @@ -59,7 +106,7 @@ export function PullsFiles({ path, head, pullRequest, onInject }: PullsFilesProp
return <Notice className="px-4 py-6 text-sm">Couldn’t load the diff: {error}</Notice>
}
if (files === null) {
return <Notice className="px-4 py-6 text-sm">Loading…</Notice>
return <FilesSkeleton tree={treeOpen} />
}
if (files.length === 0) {
return <Notice className="px-4 py-6 text-sm">No file changes</Notice>
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/components/ui/skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { cn } from "@/lib/utils"

function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
// Stock shadcn fills with bg-muted, which in this palette is the same
// value as the hover fill and all but vanishes against the dark ground —
// and the pulse spends half its cycle at half opacity on top of that.
className={cn("animate-pulse rounded-md bg-foreground/20", className)}
{...props}
/>
)
}

export { Skeleton }
9 changes: 4 additions & 5 deletions frontend/src/lib/pulls/use-pull-request-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ export interface PullRequestDiffState {

// usePullRequestDiff fetches and parses the PR's unified diff (gh pr diff) for
// the Files changed tab. Fetched on demand — the diff is a gh round-trip and can
// be large — and refetched when the checkout's HEAD moves or on window focus,
// like the PR detail. files is null while loading or on error; an empty array
// is a PR with no file changes.
// be large — and refetched when the checkout's HEAD moves. Unlike the PR detail
// next door it does not refresh on window focus: re-rendering a wide diff is far
// too expensive to spend on every alt-tab back into the app. files is null while
// loading or on error; an empty array is a PR with no file changes.
export function usePullRequestDiff(path: string, head: string): PullRequestDiffState {
const [files, setFiles] = useState<DiffFile[] | null>(null)
const [error, setError] = useState<string | null>(null)
Expand Down Expand Up @@ -40,10 +41,8 @@ export function usePullRequestDiff(path: string, head: string): PullRequestDiffS

useEffect(() => {
refresh()
window.addEventListener("focus", refresh)
return () => {
seq.current++
window.removeEventListener("focus", refresh)
}
}, [refresh, head])

Expand Down