Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 1 | import { |
| 2 | ChevronDown, | |
| 3 | ChevronRight, | |
| 4 | Columns2, | |
| 5 | FileDiff as FileIcon, | |
| 6 | FileMinus, | |
| 7 | FilePlus, | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 8 | Files, |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 9 | Folder, |
| 10 | MessageSquarePlus, | |
| 11 | Rows3, | |
| 12 | Search, | |
| 13 | } from "lucide-react"; | |
| 14 | import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; | |
| 15 | import { Form, Link } from "react-router"; | |
| 16 | ||
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 17 | import type { Comment, Comparison, DiffLine, FileDiff } from "@g1t/contracts"; |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 18 | |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 19 | import { loadFile, peekFile } from "../lib/diff-files"; |
| Merge branch 'worktree-agent-a9934473c8cf2bca6' | 20 | import { |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 21 | type Deferred, |
| Merge branch 'worktree-agent-a9934473c8cf2bca6' | 22 | type FileHtml, |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 23 | type HighlightedComparison, |
| Merge branch 'worktree-agent-a9934473c8cf2bca6' | 24 | type HighlightedFile, |
| 25 | type HighlightedLine, | |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 26 | LARGE_FILE_LINES, |
| Merge branch 'worktree-agent-a9934473c8cf2bca6' | 27 | diffContent, |
| 28 | htmlOfFile, | |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 29 | linkedFile, |
| Merge branch 'worktree-agent-a9934473c8cf2bca6' | 30 | withHtml, |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 31 | } from "../lib/diff"; |
| 32 | import { Markdown } from "./markdown"; | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 33 | import { EmptyState, SubmitButton, Textarea, TimeAgo } from "./ui"; |
| 34 | import { Avatar } from "./ui/avatar"; | |
| 35 | import { Button } from "./ui/button"; | |
| 36 | import { Card } from "./ui/card"; | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 37 | import { Checkbox } from "./ui/checkbox"; |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 38 | import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "./ui/sheet"; |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 39 | |
| 40 | const ROW_STYLES: Record<DiffLine["kind"], string> = { | |
| 41 | context: "", | |
| People wear their own avatars wherever g1t knows them only by name, and a workspace is never shown as a person. The activity feeds, recent changes, home's most active faces, issue and pull request authors, assignees, comments and the audit log drew a person from their username alone, so everyone got their letter avatar instead of the picture they uploaded. An avatar given no picture now draws the letter at once and lays the person's own picture over it once it loads, from /-/avatar/user/<username> (routes/avatar-user.ts): a redirect to the picture on the usercontent origin, or not found, read from identity's people only, so a workspace's icon can never stand in for someone. Kept five minutes, public like the profile. Agents, the product tour's people, "someone" and workspaces' square avatars are never looked up (image null), so an account with the same name never lends them its face. | 42 | add: "bg-success/9", |
| 43 | delete: "bg-danger/9", | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 44 | }; |
| 45 | ||
| 46 | const GUTTER_STYLES: Record<DiffLine["kind"], string> = { | |
| 47 | context: "text-faint", | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 48 | add: "bg-success/[0.14] text-success/70", |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 49 | delete: "bg-danger/[0.14] text-danger/70", |
| 50 | }; | |
| 51 | ||
| 52 | const MARKERS: Record<DiffLine["kind"], string> = { | |
| 53 | context: " ", | |
| 54 | add: "+", | |
| 55 | delete: "−", | |
| 56 | }; | |
| 57 | ||
| 58 | /** Comments on lines of the change, and whether the viewer may add one. */ | |
| 59 | export type LineReview = { comments: Comment[]; canComment: boolean }; | |
| 60 | ||
| 61 | type Layout = "unified" | "split"; | |
| 62 | ||
| 63 | /** Reads a per-viewer preference; storage can be missing or refuse. */ | |
| 64 | function stored(key: string): string | null { | |
| 65 | try { | |
| 66 | return window.localStorage.getItem(key); | |
| 67 | } catch { | |
| 68 | return null; | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | function store(key: string, value: string | null) { | |
| 73 | try { | |
| 74 | if (value == null) window.localStorage.removeItem(key); | |
| 75 | else window.localStorage.setItem(key, value); | |
| 76 | } catch { | |
| 77 | // A private window: the preference lasts until the page is left. | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 81 | /** Whether the screen is phone-sized: a change is shown unified there, never split. */ |
| 82 | function useNarrow(): boolean { | |
| 83 | const [narrow, setNarrow] = useState(false); | |
| 84 | useEffect(() => { | |
| 85 | const query = window.matchMedia("(max-width: 639px)"); | |
| 86 | const update = () => setNarrow(query.matches); | |
| 87 | update(); | |
| 88 | query.addEventListener("change", update); | |
| 89 | return () => query.removeEventListener("change", update); | |
| 90 | }, []); | |
| 91 | return narrow; | |
| 92 | } | |
| 93 | ||
| 94 | /** `+12 −3` with a five-block bar (the bar from `sm` up). */ | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 95 | export function Stat({ additions, deletions }: { additions: number; deletions: number }) { |
| 96 | const total = additions + deletions; | |
| 97 | const green = total === 0 ? 0 : Math.round((additions / total) * 5); | |
| 98 | return ( | |
| 99 | <span className="flex shrink-0 items-center gap-2 font-mono text-xs"> | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 100 | <span className="text-success">+{additions}</span> |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 101 | <span className="text-danger">−{deletions}</span> |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 102 | <span className="hidden gap-px sm:flex" aria-hidden="true"> |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 103 | {Array.from({ length: 5 }, (_, i) => ( |
| 104 | <span | |
| 105 | key={i} | |
| 106 | className={`size-2 rounded-xs ${ | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 107 | total === 0 ? "bg-line" : i < green ? "bg-success" : "bg-danger" |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 108 | }`} |
| 109 | /> | |
| 110 | ))} | |
| 111 | </span> | |
| 112 | </span> | |
| 113 | ); | |
| 114 | } | |
| 115 | ||
| 116 | function fileIcon(status: HighlightedFile["status"]) { | |
| 117 | return status === "added" ? FilePlus : status === "deleted" ? FileMinus : FileIcon; | |
| 118 | } | |
| 119 | ||
| 120 | function fileTone(status: HighlightedFile["status"]) { | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 121 | return status === "added" ? "text-success" : status === "deleted" ? "text-danger" : "text-faint"; |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 122 | } |
| 123 | ||
| 124 | /** One comment made on a line, shown under that line. */ | |
| 125 | function LineComment({ comment }: { comment: Comment }) { | |
| 126 | return ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 127 | <Card tone="bg" radius="lg" className="font-sans"> |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 128 | <p className="flex items-center gap-2 border-b border-line px-3 py-1.5 text-xs text-muted"> |
| 129 | <Avatar name={comment.author.username} size={16} /> | |
| 130 | <span className="font-medium text-fg">{comment.author.username}</span> | |
| 131 | <TimeAgo at={comment.createdAt} /> | |
| 132 | </p> | |
| 133 | <div className="px-3 py-2 text-sm"> | |
| 134 | <Markdown source={comment.body} /> | |
| 135 | </div> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 136 | </Card> |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 137 | ); |
| 138 | } | |
| 139 | ||
| 140 | /** A line's text, highlighted when the server could. */ | |
| 141 | function Code({ line }: { line: HighlightedLine | null }) { | |
| 142 | if (!line) return null; | |
| 143 | if (line.html != null) { | |
| 144 | return <span dangerouslySetInnerHTML={{ __html: line.html || " " }} />; | |
| 145 | } | |
| 146 | return <>{line.text || " "}</>; | |
| 147 | } | |
| 148 | ||
| 149 | /** The comments on a line and, when open, the box to add one. */ | |
| 150 | function Thread({ | |
| 151 | path, | |
| 152 | line, | |
| 153 | comments, | |
| 154 | open, | |
| 155 | onClose, | |
| 156 | }: { | |
| 157 | path: string; | |
| 158 | line: number; | |
| 159 | comments: Comment[]; | |
| 160 | open: boolean; | |
| 161 | onClose: () => void; | |
| 162 | }) { | |
| 163 | // The box stays, saying it is posting, until the new comment shows above it. | |
| 164 | const count = useRef(comments.length); | |
| 165 | useEffect(() => { | |
| 166 | if (comments.length > count.current) onClose(); | |
| 167 | count.current = comments.length; | |
| 168 | // eslint-disable-next-line react-hooks/exhaustive-deps -- only a new comment closes it | |
| 169 | }, [comments.length]); | |
| 170 | return ( | |
| 171 | <div className="max-w-2xl space-y-2"> | |
| 172 | {comments.map((comment) => ( | |
| 173 | <LineComment key={comment.id} comment={comment} /> | |
| 174 | ))} | |
| 175 | {open && ( | |
| 176 | <Form method="post" className="space-y-2 font-sans"> | |
| 177 | <input type="hidden" name="action" value="comment" /> | |
| 178 | <input type="hidden" name="path" value={path} /> | |
| 179 | <input type="hidden" name="line" value={line} /> | |
| 180 | <Textarea name="body" rows={3} required autoFocus placeholder={`Comment on line ${line}`} /> | |
| 181 | <div className="flex gap-2"> | |
| 182 | <SubmitButton match={{ action: "comment", path, line: String(line) }} pending="Commenting…"> | |
| 183 | Comment | |
| 184 | </SubmitButton> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 185 | <Button variant="outline" type="button" onClick={onClose}> |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 186 | Cancel |
| 187 | </Button> | |
| 188 | </div> | |
| 189 | </Form> | |
| 190 | )} | |
| 191 | </div> | |
| 192 | ); | |
| 193 | } | |
| 194 | ||
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 195 | function CommentButton({ line, onClick }: { line: number; onClick: (event: React.MouseEvent) => void }) { |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 196 | return ( |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 197 | <Button |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 198 | type="button" |
| 199 | aria-label={`Comment on line ${line}`} | |
| 200 | onClick={onClick} | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 201 | variant="accent" size="icon" |
| 202 | className="absolute top-0 -right-2.5 z-10 hidden size-5 rounded group-hover:flex focus-visible:flex pointer-coarse:-right-1 pointer-coarse:flex pointer-coarse:size-7" | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 203 | > |
| 204 | <MessageSquarePlus size={12} /> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 205 | </Button> |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 206 | ); |
| 207 | } | |
| 208 | ||
| 209 | type FileProps = { | |
| 210 | file: HighlightedFile; | |
| 211 | comments: Comment[]; | |
| 212 | canComment: boolean; | |
| 213 | }; | |
| 214 | ||
| 215 | function UnifiedRows({ file, comments, canComment }: FileProps) { | |
| 216 | const [writing, setWriting] = useState<number | null>(null); | |
| 217 | return ( | |
| 218 | <table className="w-full border-collapse font-mono text-xs leading-5"> | |
| 219 | <tbody> | |
| 220 | {file.hunks.map((hunk, h) => ( | |
| 221 | <Fragment key={h}> | |
| 222 | {h > 0 && <HunkGap colSpan={4} />} | |
| 223 | {hunk.lines.map((line, index) => { | |
| 224 | const here = comments.filter((c) => line.new != null && c.line === line.new); | |
| 225 | const open = line.new != null && writing === line.new; | |
| 226 | return ( | |
| 227 | <Fragment key={index}> | |
| 228 | <tr className={`group ${ROW_STYLES[line.kind]}`}> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 229 | <td className={`w-9 px-1 text-right select-none sm:w-12 sm:px-2 ${GUTTER_STYLES[line.kind]}`}> |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 230 | {line.old} |
| 231 | </td> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 232 | <td |
| 233 | // A tap on the number comments on the line: a touch screen has no hover for the button. | |
| 234 | onClick={canComment && line.new != null ? () => setWriting(open ? null : line.new) : undefined} | |
| 235 | className={`relative w-9 px-1 text-right select-none sm:w-12 sm:px-2 ${canComment && line.new != null ? "cursor-pointer" : ""} ${GUTTER_STYLES[line.kind]}`} | |
| 236 | > | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 237 | {line.new} |
| 238 | {canComment && line.new != null && ( | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 239 | <CommentButton |
| 240 | line={line.new} | |
| 241 | onClick={(event) => { | |
| 242 | event.stopPropagation(); | |
| 243 | setWriting(open ? null : line.new); | |
| 244 | }} | |
| 245 | /> | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 246 | )} |
| 247 | </td> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 248 | <td className={`w-4 text-center select-none sm:w-5 ${fileToneFor(line.kind)}`}> |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 249 | {MARKERS[line.kind]} |
| 250 | </td> | |
| 251 | <td className="pr-4 whitespace-pre"> | |
| 252 | <Code line={line} /> | |
| 253 | </td> | |
| 254 | </tr> | |
| 255 | {(here.length > 0 || open) && line.new != null && ( | |
| 256 | <tr> | |
| 257 | <td colSpan={4} className="border-y border-line bg-surface p-3"> | |
| 258 | <Thread | |
| 259 | path={file.path} | |
| 260 | line={line.new} | |
| 261 | comments={here} | |
| 262 | open={open} | |
| 263 | onClose={() => setWriting(null)} | |
| 264 | /> | |
| 265 | </td> | |
| 266 | </tr> | |
| 267 | )} | |
| 268 | </Fragment> | |
| 269 | ); | |
| 270 | })} | |
| 271 | </Fragment> | |
| 272 | ))} | |
| 273 | </tbody> | |
| 274 | </table> | |
| 275 | ); | |
| 276 | } | |
| 277 | ||
| 278 | function fileToneFor(kind: DiffLine["kind"]) { | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 279 | return kind === "add" ? "text-success" : kind === "delete" ? "text-danger" : "text-faint"; |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 280 | } |
| 281 | ||
| 282 | /** Lines side by side: a run of deletions beside the additions that replaced it. */ | |
| 283 | function pairs(lines: HighlightedLine[]): [HighlightedLine | null, HighlightedLine | null][] { | |
| 284 | const rows: [HighlightedLine | null, HighlightedLine | null][] = []; | |
| 285 | let index = 0; | |
| 286 | while (index < lines.length) { | |
| 287 | const line = lines[index]!; | |
| 288 | if (line.kind === "context") { | |
| 289 | rows.push([line, line]); | |
| 290 | index++; | |
| 291 | continue; | |
| 292 | } | |
| 293 | const removed: HighlightedLine[] = []; | |
| 294 | const added: HighlightedLine[] = []; | |
| 295 | while (index < lines.length && lines[index]!.kind === "delete") removed.push(lines[index++]!); | |
| 296 | while (index < lines.length && lines[index]!.kind === "add") added.push(lines[index++]!); | |
| 297 | for (let n = 0; n < Math.max(removed.length, added.length); n++) { | |
| 298 | rows.push([removed[n] ?? null, added[n] ?? null]); | |
| 299 | } | |
| 300 | } | |
| 301 | return rows; | |
| 302 | } | |
| 303 | ||
| 304 | function SplitRows({ file, comments, canComment }: FileProps) { | |
| 305 | const [writing, setWriting] = useState<number | null>(null); | |
| 306 | const side = (line: HighlightedLine | null, which: "old" | "new") => { | |
| 307 | const kind = line ? (line.kind === "context" ? "context" : line.kind) : null; | |
| 308 | const empty = "bg-surface/60"; | |
| 309 | return ( | |
| 310 | <> | |
| 311 | <td | |
| 312 | className={`relative w-12 px-2 text-right align-top select-none ${kind ? GUTTER_STYLES[kind] : empty} ${ | |
| 313 | which === "new" ? "border-l border-line" : "" | |
| 314 | }`} | |
| 315 | > | |
| 316 | {line?.[which]} | |
| 317 | {which === "new" && canComment && line?.new != null && ( | |
| 318 | <CommentButton line={line.new} onClick={() => setWriting(writing === line.new ? null : line.new)} /> | |
| 319 | )} | |
| 320 | </td> | |
| 321 | <td className={`pr-3 pl-2 align-top break-all whitespace-pre-wrap ${kind ? ROW_STYLES[kind] : empty}`}> | |
| 322 | <Code line={line} /> | |
| 323 | </td> | |
| 324 | </> | |
| 325 | ); | |
| 326 | }; | |
| 327 | return ( | |
| 328 | <table className="w-full table-fixed border-collapse font-mono text-xs leading-5"> | |
| 329 | <colgroup> | |
| 330 | <col className="w-12" /> | |
| 331 | <col /> | |
| 332 | <col className="w-12" /> | |
| 333 | <col /> | |
| 334 | </colgroup> | |
| 335 | <tbody> | |
| 336 | {file.hunks.map((hunk, h) => ( | |
| 337 | <Fragment key={h}> | |
| 338 | {h > 0 && <HunkGap colSpan={4} />} | |
| 339 | {pairs(hunk.lines).map(([before, after], index) => { | |
| 340 | const line = after?.new ?? null; | |
| 341 | const here = comments.filter((c) => line != null && c.line === line); | |
| 342 | const open = line != null && writing === line; | |
| 343 | return ( | |
| 344 | <Fragment key={index}> | |
| 345 | <tr className="group"> | |
| 346 | {side(before && before.kind !== "add" ? before : null, "old")} | |
| 347 | {side(after && after.kind !== "delete" ? after : null, "new")} | |
| 348 | </tr> | |
| 349 | {(here.length > 0 || open) && line != null && ( | |
| 350 | <tr> | |
| 351 | <td colSpan={4} className="border-y border-line bg-surface p-3"> | |
| 352 | <Thread | |
| 353 | path={file.path} | |
| 354 | line={line} | |
| 355 | comments={here} | |
| 356 | open={open} | |
| 357 | onClose={() => setWriting(null)} | |
| 358 | /> | |
| 359 | </td> | |
| 360 | </tr> | |
| 361 | )} | |
| 362 | </Fragment> | |
| 363 | ); | |
| 364 | })} | |
| 365 | </Fragment> | |
| 366 | ))} | |
| 367 | </tbody> | |
| 368 | </table> | |
| 369 | ); | |
| 370 | } | |
| 371 | ||
| 372 | function HunkGap({ colSpan }: { colSpan: number }) { | |
| 373 | return ( | |
| 374 | <tr aria-hidden="true"> | |
| 375 | <td colSpan={colSpan} className="border-y border-line bg-surface py-0.5 text-center text-faint"> | |
| 376 | ⋯ | |
| 377 | </td> | |
| 378 | </tr> | |
| 379 | ); | |
| 380 | } | |
| 381 | ||
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 382 | /** How tall a line of a diff is (`leading-5`): a file not loaded yet holds its place at its height. */ |
| 383 | const LINE_PX = 20; | |
| 384 | ||
| 385 | /** Where a file sent without its lines shows them: a note and "Load diff", or room for them while they load. */ | |
| 386 | function NotLoaded({ | |
| 387 | deferred, | |
| 388 | failed, | |
| 389 | waiting, | |
| 390 | onLoad, | |
| 391 | }: { | |
| 392 | deferred: Deferred; | |
| 393 | failed: boolean; | |
| 394 | /** A large file nobody has asked for yet. */ | |
| 395 | waiting: boolean; | |
| 396 | onLoad: () => void; | |
| 397 | }) { | |
| 398 | if (failed || waiting) { | |
| 399 | return ( | |
| 400 | <div className="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-4 text-sm text-muted"> | |
| 401 | <span className="grow"> | |
| 402 | {failed | |
| 403 | ? "This file's diff could not be loaded." | |
| 404 | : `A large diff (${(deferred.lines ?? 0).toLocaleString("en-US")} lines) is not shown until you ask for it.`} | |
| 405 | </span> | |
| 406 | <Button type="button" variant="outline" size="sm" onClick={onLoad}> | |
| 407 | {failed ? "Try again" : "Load diff"} | |
| 408 | </Button> | |
| 409 | </div> | |
| 410 | ); | |
| 411 | } | |
| 412 | return ( | |
| 413 | <div | |
| 414 | aria-busy="true" | |
| 415 | className="px-4 py-3 text-xs text-faint" | |
| 416 | style={{ height: Math.max(3, Math.min(deferred.lines ?? 3, LARGE_FILE_LINES)) * LINE_PX }} | |
| 417 | > | |
| 418 | Loading the diff… | |
| 419 | </div> | |
| 420 | ); | |
| 421 | } | |
| 422 | ||
| 423 | /** How many files waiting after one are fetched with it, and about how many lines that may come to. */ | |
| 424 | const AHEAD_FILES = 7; | |
| 425 | const AHEAD_LINES = 3_000; | |
| 426 | ||
| 427 | /** For each file sent without its lines, the ones after it to fetch in the same request. */ | |
| 428 | function filesAhead(files: HighlightedFile[]): Map<string, string[]> { | |
| 429 | const waiting = files.filter((file) => file.deferred && !file.deferred.large); | |
| 430 | const ahead = new Map<string, string[]>(); | |
| 431 | waiting.forEach((file, index) => { | |
| 432 | const next: string[] = []; | |
| 433 | let lines = file.deferred!.lines ?? 0; | |
| 434 | for (const later of waiting.slice(index + 1, index + 1 + AHEAD_FILES)) { | |
| 435 | // One the comparison left out is counted as a small file. | |
| 436 | lines += later.deferred!.lines ?? 100; | |
| 437 | if (lines > AHEAD_LINES) break; | |
| 438 | next.push(later.path); | |
| 439 | } | |
| 440 | ahead.set(file.path, next); | |
| 441 | }); | |
| 442 | return ahead; | |
| 443 | } | |
| 444 | ||
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 445 | function FileSection({ |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 446 | file: given, |
| 447 | source, | |
| 448 | wanted, | |
| 449 | ahead, | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 450 | review, |
| 451 | layout, | |
| 452 | collapsed, | |
| 453 | viewed, | |
| 454 | onToggle, | |
| 455 | onViewed, | |
| 456 | fileBase, | |
| 457 | }: { | |
| 458 | file: HighlightedFile; | |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 459 | /** Where a file sent without its lines fetches them (lib/diff-files.ts). */ |
| 460 | source: string | null; | |
| 461 | /** Linked to from the address: its lines load at once, however large. */ | |
| 462 | wanted: boolean; | |
| 463 | /** The files waiting after it, fetched with it. */ | |
| 464 | ahead?: string[]; | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 465 | review?: LineReview; |
| 466 | layout: Layout; | |
| 467 | collapsed: boolean; | |
| 468 | viewed: boolean; | |
| 469 | onToggle: () => void; | |
| 470 | onViewed: (viewed: boolean) => void; | |
| 471 | /** Where the file can be read as of the change, e.g. `/acme/web/blob/<hash>`. */ | |
| 472 | fileBase?: string; | |
| 473 | }) { | |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 474 | const section = useRef<HTMLElement>(null); |
| 475 | // Sent without its lines (lib/diff.ts firstScreens): fetched as it comes | |
| 476 | // near the screen, at once when it has comments or is linked to, and a | |
| 477 | // large one when asked for. What was fetched stays through a refresh. | |
| 478 | const deferred = given.deferred ?? null; | |
| 479 | const fetchKey = deferred && source ? `${source}\n${given.path}` : null; | |
| 480 | const [fetched, setFetched] = useState<{ key: string; file: FileDiff | null } | null>(() => { | |
| 481 | const hit = fetchKey ? peekFile(source!, given.path) : undefined; | |
| 482 | return hit === undefined ? null : { key: fetchKey!, file: hit }; | |
| 483 | }); | |
| 484 | const [failed, setFailed] = useState(false); | |
| 485 | const [asked, setAsked] = useState(false); | |
| 486 | const got = fetched && fetched.key === fetchKey ? fetched : null; | |
| 487 | const ready = !deferred || got != null; | |
| 488 | const file: HighlightedFile = got?.file ?? given; | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 489 | const comments = review?.comments.filter((comment) => comment.path === file.path) ?? []; |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 490 | const eager = wanted || asked || comments.length > 0; |
| 491 | useEffect(() => { | |
| 492 | if (!fetchKey || got || collapsed || failed || !source) return; | |
| 493 | if (deferred?.large && !eager) return; | |
| 494 | const path = given.path; | |
| 495 | const load = () => | |
| 496 | loadFile(source, path, { also: ahead }).then( | |
| 497 | (found) => setFetched({ key: fetchKey, file: found }), | |
| 498 | () => setFailed(true), | |
| 499 | ); | |
| 500 | if (eager) { | |
| 501 | void load(); | |
| 502 | return; | |
| 503 | } | |
| 504 | const element = section.current; | |
| 505 | if (!element) return; | |
| 506 | const observer = new IntersectionObserver( | |
| 507 | ([entry]) => { | |
| 508 | if (!entry?.isIntersecting) return; | |
| 509 | observer.disconnect(); | |
| 510 | void load(); | |
| 511 | }, | |
| 512 | // Further ahead than the colours: a file's lines are a round trip away. | |
| 513 | { rootMargin: "2000px 0px" }, | |
| 514 | ); | |
| 515 | observer.observe(element); | |
| 516 | return () => observer.disconnect(); | |
| 517 | // The file's identity is its key; a refresh's new `deferred` object changes nothing. | |
| 518 | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 519 | }, [fetchKey, got != null, collapsed, failed, eager, deferred?.large]); | |
| 520 | // Linked to: once its lines are in, it is kept at the top while the files | |
| 521 | // above it load and change its place, until the reader scrolls or a | |
| 522 | // moment has passed. | |
| 523 | const linkedReady = wanted && (!deferred || got != null); | |
| 524 | useEffect(() => { | |
| 525 | const element = section.current; | |
| 526 | if (!linkedReady || !element) return; | |
| 527 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 528 | const align = () => { | |
| 529 | element.scrollIntoView({ block: "start" }); | |
| 530 | // Kept there until nothing has moved it for a moment. | |
| 531 | clearTimeout(timer); | |
| 532 | timer = setTimeout(stop, 1500); | |
| 533 | }; | |
| 534 | const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(align); | |
| 535 | observer?.observe(document.body); | |
| 536 | const stop = () => { | |
| 537 | observer?.disconnect(); | |
| 538 | for (const type of ["wheel", "touchstart", "keydown"]) window.removeEventListener(type, stop); | |
| 539 | }; | |
| 540 | for (const type of ["wheel", "touchstart", "keydown"]) window.addEventListener(type, stop, { passive: true }); | |
| 541 | align(); | |
| 542 | return () => { | |
| 543 | clearTimeout(timer); | |
| 544 | stop(); | |
| 545 | }; | |
| 546 | }, [linkedReady]); | |
| 547 | const Icon = fileIcon(file.status); | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 548 | const shown = new Set(file.hunks.flatMap((hunk) => hunk.lines.map((line) => line.new))); |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 549 | // Made against an earlier version of the change, on a line no longer in |
| 550 | // it. Not known until a file sent without its lines has them. | |
| 551 | const outdated = ready ? comments.filter((comment) => comment.line == null || !shown.has(comment.line)) : []; | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 552 | // Shown plain at once, and highlighted in the browser when it comes near |
| 553 | // the screen: the page never waits for colours. | |
| Merge branch 'worktree-agent-a9934473c8cf2bca6' | 554 | const [highlighted, setHighlighted] = useState<{ content: string; html: FileHtml } | null>(null); |
| 555 | // What the colours depend on. A refresh hands the page a new `file` with | |
| 556 | // the same lines: it keeps its colours rather than going plain and being | |
| 557 | // highlighted again, which flashed every coloured diff on the page. | |
| 558 | const content = useMemo(() => `${file.path}\n${diffContent(file)}`, [file]); | |
| 559 | const latest = useRef(file); | |
| 560 | latest.current = file; | |
| 561 | const kept = highlighted?.content === content ? highlighted.html : null; | |
| 562 | const drawn = useMemo(() => (kept ? withHtml(file, kept) : file), [file, kept]); | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 563 | useEffect(() => { |
| 564 | const element = section.current; | |
| Merge branch 'worktree-agent-a9934473c8cf2bca6' | 565 | const file = latest.current; |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 566 | // Highlighted by the server already: one of the change's first files. |
| 567 | const done = file.hunks.some((hunk) => hunk.lines.some((line) => (line as HighlightedLine).html != null)); | |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 568 | if (!element || collapsed || done || kept || file.hunks.length === 0) return; |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 569 | let cancelled = false; |
| 570 | const observer = new IntersectionObserver( | |
| 571 | ([entry]) => { | |
| 572 | if (!entry?.isIntersecting) return; | |
| 573 | observer.disconnect(); | |
| 574 | void import("../lib/shiki") | |
| 575 | .then(({ highlightFile }) => highlightFile(file)) | |
| 576 | .then((result) => { | |
| Merge branch 'worktree-agent-a9934473c8cf2bca6' | 577 | if (!cancelled && result) setHighlighted({ content, html: htmlOfFile(result) }); |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 578 | }) |
| 579 | .catch(() => {}); | |
| 580 | }, | |
| 581 | { rootMargin: "600px 0px" }, | |
| 582 | ); | |
| 583 | observer.observe(element); | |
| 584 | return () => { | |
| 585 | cancelled = true; | |
| 586 | observer.disconnect(); | |
| 587 | }; | |
| Merge branch 'worktree-agent-a9934473c8cf2bca6' | 588 | // `kept` is whether `content` is highlighted already: the lines, not the object they came in. |
| 589 | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 590 | }, [content, collapsed, kept != null]); | |
| 591 | const props = { file: drawn, comments, canComment: review?.canComment ?? false }; | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 592 | return ( |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 593 | <Card asChild tone="plain" className="scroll-mt-[calc(4rem+var(--diff-toolbar,3rem))]"> |
| 594 | <section | |
| 595 | ref={section} | |
| 596 | id={`file-${file.path}`} | |
| 597 | data-diff-file={file.path} | |
| 598 | // Lands below the site bar and the change's toolbar, whatever their height. | |
| 599 | ||
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 600 | > |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 601 | <header |
| 602 | // Sticks under the change's toolbar (its height is --diff-toolbar), not behind it. | |
| 603 | className={`sticky top-[calc(var(--topbar-h)+var(--diff-toolbar,0px))] z-20 flex items-center gap-2 border-line bg-surface/95 px-2 py-2 backdrop-blur sm:gap-2.5 sm:px-3 ${ | |
| 604 | collapsed ? "rounded-xl" : "rounded-t-xl border-b" | |
| 605 | }`}> | |
| 606 | <Button | |
| 607 | type="button" | |
| 608 | onClick={onToggle} | |
| 609 | aria-label={collapsed ? `Show ${file.path}` : `Hide ${file.path}`} | |
| 610 | aria-expanded={!collapsed} | |
| 611 | variant="ghost" | |
| 612 | size="inline" | |
| 613 | className="-m-1.5 rounded p-2 text-faint sm:m-0 sm:p-0.5" | |
| 614 | > | |
| 615 | {collapsed ? <ChevronRight size={15} /> : <ChevronDown size={15} />} | |
| 616 | </Button> | |
| 617 | <Icon size={15} className={`hidden shrink-0 sm:block ${fileTone(file.status)}`} /> | |
| 618 | {/* A long path is cut at its start, so the file's own name shows. */} | |
| 619 | <button | |
| 620 | type="button" | |
| 621 | onClick={onToggle} | |
| 622 | dir="rtl" | |
| 623 | className={`min-w-0 grow truncate text-left font-mono text-[0.8125rem] ${viewed ? "text-muted" : ""}`} | |
| 624 | > | |
| 625 | <bdi>{file.path}</bdi> | |
| 626 | </button> | |
| 627 | {comments.length > 0 && ( | |
| 628 | <span className="flex items-center gap-1 text-xs text-muted"> | |
| 629 | <MessageSquarePlus size={12} /> | |
| 630 | {comments.length} | |
| 631 | </span> | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 632 | )} |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 633 | {/* Not counted until its lines are read: one the comparison left out. */} |
| 634 | {!(deferred && deferred.lines == null && !ready) && <Stat additions={file.additions} deletions={file.deletions} />} | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 635 | {fileBase && file.status !== "deleted" && ( |
| 636 | <span className="hidden items-center gap-0.5 text-xs sm:flex"> | |
| 637 | <Link to={`${fileBase}/${file.path}`} className="rounded px-1.5 py-0.5 text-muted hover:bg-raised hover:text-fg"> | |
| 638 | View | |
| 639 | </Link> | |
| 640 | <Link | |
| 641 | to={`${fileBase}/${file.path}?blame=1`} | |
| 642 | className="rounded px-1.5 py-0.5 text-muted hover:bg-raised hover:text-fg" | |
| 643 | > | |
| 644 | Blame | |
| 645 | </Link> | |
| 646 | </span> | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 647 | )} |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 648 | <label |
| 649 | className={`ml-1 flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1.5 text-xs transition-colors select-none sm:py-0.5 ${ | |
| 650 | viewed ? "border-accent/40 bg-accent/10 text-accent" : "border-line text-muted hover:text-fg" | |
| 651 | }`} | |
| 652 | > | |
| 653 | <Checkbox | |
| 654 | checked={viewed} | |
| 655 | onCheckedChange={(checked) => onViewed(checked === true)} | |
| People wear their own avatars wherever g1t knows them only by name, and a workspace is never shown as a person. The activity feeds, recent changes, home's most active faces, issue and pull request authors, assignees, comments and the audit log drew a person from their username alone, so everyone got their letter avatar instead of the picture they uploaded. An avatar given no picture now draws the letter at once and lays the person's own picture over it once it loads, from /-/avatar/user/<username> (routes/avatar-user.ts): a redirect to the picture on the usercontent origin, or not found, read from identity's people only, so a workspace's icon can never stand in for someone. Kept five minutes, public like the profile. Agents, the product tour's people, "someone" and workspaces' square avatars are never looked up (image null), so an account with the same name never lends them its face. | 656 | className="size-3.5 rounded-sm [&_svg]:size-2.5" |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 657 | /> |
| 658 | <span className="sr-only sm:not-sr-only">Viewed</span> | |
| 659 | </label> | |
| 660 | </header> | |
| 661 | {!collapsed && ( | |
| 662 | <div className="overflow-hidden rounded-b-xl"> | |
| 663 | {outdated.length > 0 && ( | |
| 664 | <div className="space-y-2 border-b border-line bg-surface/50 p-3"> | |
| 665 | <p className="text-xs text-faint">On lines that have since changed</p> | |
| 666 | {outdated.map((comment) => ( | |
| 667 | <LineComment key={comment.id} comment={comment} /> | |
| 668 | ))} | |
| 669 | </div> | |
| 670 | )} | |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 671 | {deferred && !ready ? ( |
| 672 | <NotLoaded | |
| 673 | deferred={deferred} | |
| 674 | failed={failed} | |
| 675 | waiting={deferred.large && !eager} | |
| 676 | onLoad={() => { | |
| 677 | setFailed(false); | |
| 678 | setAsked(true); | |
| 679 | }} | |
| 680 | /> | |
| 681 | ) : deferred && got?.file === null ? ( | |
| 682 | <p className="px-4 py-6 text-sm text-muted">This file is no longer part of the change; reload the page to see it as it is now.</p> | |
| 683 | ) : file.binary ? ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 684 | <p className="px-4 py-6 text-sm text-muted">Binary or large file; its contents are not shown.</p> |
| 685 | ) : file.hunks.length === 0 ? ( | |
| 686 | <p className="px-4 py-6 text-sm text-muted">No line changes{file.status === "added" ? ": an empty file" : ""}.</p> | |
| 687 | ) : ( | |
| 688 | <div className="overflow-x-auto"> | |
| 689 | {layout === "split" ? <SplitRows {...props} /> : <UnifiedRows {...props} />} | |
| 690 | </div> | |
| 691 | )} | |
| 692 | </div> | |
| 693 | )} | |
| 694 | </section> | |
| 695 | </Card> | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 696 | ); |
| 697 | } | |
| 698 | ||
| 699 | type TreeNode = { name: string; path: string; children: TreeNode[]; file?: HighlightedFile }; | |
| 700 | ||
| 701 | /** The changed files as folders, with folders of one folder merged: `src/lib`. */ | |
| 702 | function treeOf(files: HighlightedFile[]): TreeNode[] { | |
| 703 | const root: TreeNode = { name: "", path: "", children: [] }; | |
| 704 | for (const file of files) { | |
| 705 | const parts = file.path.split("/"); | |
| 706 | let node = root; | |
| 707 | parts.forEach((part, index) => { | |
| 708 | const path = parts.slice(0, index + 1).join("/"); | |
| 709 | let child = node.children.find((c) => c.name === part && !c.file === (index < parts.length - 1)); | |
| 710 | if (!child) { | |
| 711 | child = { name: part, path, children: [] }; | |
| 712 | if (index === parts.length - 1) child.file = file; | |
| 713 | node.children.push(child); | |
| 714 | } | |
| 715 | node = child; | |
| 716 | }); | |
| 717 | } | |
| 718 | const squash = (node: TreeNode): TreeNode => { | |
| 719 | let current = node; | |
| 720 | while (!current.file && current.children.length === 1 && !current.children[0]!.file) { | |
| 721 | const only = current.children[0]!; | |
| 722 | current = { ...only, name: `${current.name}/${only.name}` }; | |
| 723 | } | |
| 724 | const children = current.children | |
| 725 | .map(squash) | |
| 726 | .sort((a, b) => Number(!!a.file) - Number(!!b.file) || a.name.localeCompare(b.name)); | |
| 727 | return { ...current, children }; | |
| 728 | }; | |
| 729 | return squash(root).children; | |
| 730 | } | |
| 731 | ||
| 732 | function Tree({ | |
| 733 | nodes, | |
| 734 | depth, | |
| 735 | active, | |
| 736 | viewed, | |
| 737 | onPick, | |
| 738 | }: { | |
| 739 | nodes: TreeNode[]; | |
| 740 | depth: number; | |
| 741 | active: string | null; | |
| 742 | viewed: Set<string>; | |
| 743 | onPick: (path: string) => void; | |
| 744 | }) { | |
| 745 | return ( | |
| 746 | <ul> | |
| 747 | {nodes.map((node) => { | |
| 748 | if (!node.file) { | |
| 749 | return ( | |
| 750 | <li key={node.path}> | |
| 751 | <p | |
| 752 | className="flex items-center gap-1.5 truncate py-1 font-mono text-xs text-faint" | |
| 753 | style={{ paddingLeft: depth * 12 + 8 }} | |
| 754 | > | |
| 755 | <Folder size={13} className="shrink-0" /> | |
| 756 | {node.name} | |
| 757 | </p> | |
| 758 | <Tree nodes={node.children} depth={depth + 1} active={active} viewed={viewed} onPick={onPick} /> | |
| 759 | </li> | |
| 760 | ); | |
| 761 | } | |
| 762 | const file = node.file; | |
| 763 | const Icon = fileIcon(file.status); | |
| 764 | return ( | |
| 765 | <li key={node.path}> | |
| 766 | <button | |
| 767 | type="button" | |
| 768 | onClick={() => onPick(file.path)} | |
| 769 | style={{ paddingLeft: depth * 12 + 8 }} | |
| 770 | className={`flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left font-mono text-xs transition-colors ${ | |
| 771 | active === file.path ? "bg-raised text-fg" : "text-muted hover:bg-raised/60 hover:text-fg" | |
| 772 | } ${viewed.has(file.path) ? "opacity-55" : ""}`} | |
| 773 | > | |
| 774 | <Icon size={13} className={`shrink-0 ${fileTone(file.status)}`} /> | |
| 775 | <span className="min-w-0 grow truncate">{node.name}</span> | |
| 776 | <span className="shrink-0 text-[0.625rem] tabular-nums"> | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 777 | {file.additions > 0 && <span className="text-success">+{file.additions}</span>} |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 778 | {file.deletions > 0 && <span className="ml-1 text-danger">−{file.deletions}</span>} |
| 779 | </span> | |
| 780 | </button> | |
| 781 | </li> | |
| 782 | ); | |
| 783 | })} | |
| 784 | </ul> | |
| 785 | ); | |
| 786 | } | |
| 787 | ||
| 788 | /** | |
| 789 | * A change, file by file: a tree of what changed beside the diffs, unified | |
| 790 | * or side by side, each file foldable and markable as viewed. `j` and `k` | |
| 791 | * move between files and `t` filters them. | |
| 792 | */ | |
| 793 | export function DiffView({ | |
| 794 | comparison, | |
| 795 | review, | |
| 796 | empty, | |
| 797 | fileBase, | |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 798 | repo, |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 799 | }: { |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 800 | comparison: Comparison | HighlightedComparison; |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 801 | review?: LineReview; |
| 802 | /** What to say when nothing changed. */ | |
| 803 | empty?: React.ReactNode; | |
| 804 | /** Where each file can be read as of the change, to link to it. */ | |
| 805 | fileBase?: string; | |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 806 | /** The repository's address, `/owner/name`: files sent without their lines are fetched from it. */ |
| 807 | repo?: string; | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 808 | }) { |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 809 | const { truncated } = comparison; |
| 810 | const files: HighlightedFile[] = comparison.files; | |
| 811 | // The commits compared, so a file fetched later is the same change's. | |
| 812 | const source = repo | |
| 813 | ? `${repo}/diff-files.json?base=${comparison.base ?? ""}&head=${encodeURIComponent(comparison.head)}` | |
| 814 | : null; | |
| 815 | // A file linked to (`#file-<path>`) loads at once and opens, wherever it is. | |
| 816 | const [wanted, setWanted] = useState<string | null>(null); | |
| 817 | const ahead = useMemo(() => filesAhead(files), [files]); | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 818 | const [chosenLayout, setLayout] = useState<Layout>("unified"); |
| 819 | // Side by side has no room on a phone. | |
| 820 | const narrow = useNarrow(); | |
| 821 | const layout: Layout = narrow ? "unified" : chosenLayout; | |
| 822 | const [picking, setPicking] = useState(false); | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 823 | const [collapsed, setCollapsed] = useState<Set<string>>(new Set()); |
| 824 | const [viewed, setViewed] = useState<Set<string>>(new Set()); | |
| 825 | const [active, setActive] = useState<string | null>(files[0]?.path ?? null); | |
| 826 | const [filter, setFilter] = useState(""); | |
| 827 | const filterInput = useRef<HTMLInputElement>(null); | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 828 | // The toolbar's height, as it wraps at each width, so the files' own |
| 829 | // sticky headers sit below it rather than under it. | |
| 830 | const frame = useRef<HTMLDivElement>(null); | |
| 831 | const toolbar = useRef<HTMLDivElement>(null); | |
| 832 | const noFiles = files.length === 0; | |
| 833 | useEffect(() => { | |
| 834 | const bar = toolbar.current; | |
| 835 | const root = frame.current; | |
| 836 | if (!bar || !root) return; | |
| 837 | const measure = () => root.style.setProperty("--diff-toolbar", `${bar.offsetHeight}px`); | |
| 838 | measure(); | |
| 839 | if (typeof ResizeObserver === "undefined") return; | |
| 840 | const observer = new ResizeObserver(measure); | |
| 841 | observer.observe(bar); | |
| 842 | return () => observer.disconnect(); | |
| 843 | }, [noFiles]); | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 844 | const viewedKey = `g1t:viewed:${comparison.head}`; |
| 845 | ||
| 846 | // Preferences live in the browser: read them once it is there. | |
| 847 | useEffect(() => { | |
| 848 | if (stored("g1t:diff-layout") === "split") setLayout("split"); | |
| 849 | const saved = stored(viewedKey); | |
| 850 | if (saved) { | |
| 851 | try { | |
| 852 | const paths = new Set<string>(JSON.parse(saved)); | |
| 853 | setViewed(paths); | |
| 854 | setCollapsed(new Set(paths)); | |
| 855 | } catch { | |
| 856 | // Something else wrote it. | |
| 857 | } | |
| 858 | } | |
| 859 | }, [viewedKey]); | |
| 860 | ||
| 861 | const changeLayout = (next: Layout) => { | |
| 862 | setLayout(next); | |
| 863 | store("g1t:diff-layout", next); | |
| 864 | }; | |
| 865 | const markViewed = (path: string, on: boolean) => { | |
| 866 | setViewed((previous) => { | |
| 867 | const next = new Set(previous); | |
| 868 | if (on) next.add(path); | |
| 869 | else next.delete(path); | |
| 870 | store(viewedKey, next.size ? JSON.stringify([...next]) : null); | |
| 871 | return next; | |
| 872 | }); | |
| 873 | // Marking a file viewed folds it away, as a reviewer moves on. | |
| 874 | setCollapsed((previous) => { | |
| 875 | const next = new Set(previous); | |
| 876 | if (on) next.add(path); | |
| 877 | else next.delete(path); | |
| 878 | return next; | |
| 879 | }); | |
| 880 | }; | |
| 881 | const toggle = (path: string) => | |
| 882 | setCollapsed((previous) => { | |
| 883 | const next = new Set(previous); | |
| 884 | if (next.has(path)) next.delete(path); | |
| 885 | else next.add(path); | |
| 886 | return next; | |
| 887 | }); | |
| 888 | ||
| 889 | const shownFiles = useMemo(() => { | |
| 890 | const words = filter.toLowerCase().split(/\s+/).filter(Boolean); | |
| 891 | return words.length === 0 | |
| 892 | ? files | |
| 893 | : files.filter((file) => words.every((word) => file.path.toLowerCase().includes(word))); | |
| 894 | }, [files, filter]); | |
| 895 | const tree = useMemo(() => treeOf(shownFiles), [shownFiles]); | |
| 896 | ||
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 897 | const jump = useCallback((path: string, behavior: ScrollBehavior = "smooth") => { |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 898 | setCollapsed((previous) => { |
| 899 | if (!previous.has(path)) return previous; | |
| 900 | const next = new Set(previous); | |
| 901 | next.delete(path); | |
| 902 | return next; | |
| 903 | }); | |
| 904 | setActive(path); | |
| 905 | requestAnimationFrame(() => | |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 906 | document.getElementById(`file-${path}`)?.scrollIntoView({ behavior, block: "start" }), |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 907 | ); |
| 908 | }, []); | |
| 909 | ||
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 910 | // After the viewed files are folded away (above), so a linked one opens. |
| 911 | useEffect(() => { | |
| 912 | const follow = () => { | |
| 913 | const path = linkedFile(window.location.hash, files); | |
| 914 | if (!path) return; | |
| 915 | setWanted(path); | |
| 916 | // At once: a long smooth scroll would race the files loading on the way. | |
| 917 | jump(path, "instant"); | |
| 918 | }; | |
| 919 | follow(); | |
| 920 | window.addEventListener("hashchange", follow); | |
| 921 | return () => window.removeEventListener("hashchange", follow); | |
| 922 | // The address is read when the page arrives and when it changes, not on every refresh. | |
| 923 | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 924 | }, [jump]); | |
| 925 | ||
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 926 | // The file at the top of the screen is the current one. |
| 927 | useEffect(() => { | |
| 928 | const sections = [...document.querySelectorAll<HTMLElement>("[data-diff-file]")]; | |
| 929 | if (sections.length === 0) return; | |
| 930 | const observer = new IntersectionObserver( | |
| 931 | (entries) => { | |
| 932 | const top = entries | |
| 933 | .filter((entry) => entry.isIntersecting) | |
| 934 | .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0]; | |
| 935 | if (top) setActive((top.target as HTMLElement).dataset.diffFile ?? null); | |
| 936 | }, | |
| 937 | { rootMargin: "-112px 0px -60% 0px" }, | |
| 938 | ); | |
| 939 | sections.forEach((section) => observer.observe(section)); | |
| 940 | return () => observer.disconnect(); | |
| 941 | }, [shownFiles, collapsed]); | |
| 942 | ||
| 943 | useEffect(() => { | |
| 944 | const onKey = (event: KeyboardEvent) => { | |
| 945 | const target = event.target as HTMLElement | null; | |
| 946 | if (event.metaKey || event.ctrlKey || event.altKey) return; | |
| 947 | if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return; | |
| 948 | const index = shownFiles.findIndex((file) => file.path === active); | |
| 949 | if (event.key === "j" || event.key === "k") { | |
| 950 | const next = shownFiles[Math.max(0, Math.min(shownFiles.length - 1, index + (event.key === "j" ? 1 : -1)))]; | |
| 951 | if (next) { | |
| 952 | event.preventDefault(); | |
| 953 | jump(next.path); | |
| 954 | } | |
| 955 | } else if (event.key === "t") { | |
| 956 | event.preventDefault(); | |
| 957 | filterInput.current?.focus(); | |
| 958 | } else if (event.key === "v" && active) { | |
| 959 | event.preventDefault(); | |
| 960 | markViewed(active, !viewed.has(active)); | |
| 961 | } | |
| 962 | }; | |
| 963 | window.addEventListener("keydown", onKey); | |
| 964 | return () => window.removeEventListener("keydown", onKey); | |
| 965 | }); | |
| 966 | ||
| 967 | if (files.length === 0) { | |
| 968 | return ( | |
| 969 | <EmptyState title="No changes"> | |
| 970 | {empty ?? "Nothing has been pushed to this pull request yet, or it matches the branch it would merge into."} | |
| 971 | </EmptyState> | |
| 972 | ); | |
| 973 | } | |
| 974 | const additions = files.reduce((sum, file) => sum + file.additions, 0); | |
| 975 | const deletions = files.reduce((sum, file) => sum + file.deletions, 0); | |
| 976 | const allCollapsed = files.every((file) => collapsed.has(file.path)); | |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 977 | // Files the comparison left out have no counts until they are read. |
| 978 | const uncounted = files.filter((file) => file.omitted).length; | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 979 | return ( |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 980 | <div ref={frame}> |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 981 | <div ref={toolbar} className="sticky top-(--topbar-h) z-30 -mx-1 mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 bg-bg/90 px-1 py-2 backdrop-blur"> |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 982 | <span className="text-sm text-muted"> |
| 983 | <span className="font-medium text-fg">{files.length}</span> {files.length === 1 ? "file" : "files"} | |
| 984 | </span> | |
| 985 | <Stat additions={additions} deletions={deletions} /> | |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 986 | {uncounted > 0 && ( |
| 987 | <span className="text-xs text-faint"> | |
| 988 | {uncounted} {uncounted === 1 ? "file" : "files"} not counted | |
| 989 | </span> | |
| 990 | )} | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 991 | <span className="flex items-center gap-2 text-xs text-muted"> |
| 992 | <span className="h-1.5 w-20 overflow-hidden rounded-full bg-line"> | |
| 993 | <span | |
| 994 | className="block h-full rounded-full bg-accent transition-all" | |
| 995 | style={{ width: `${(viewed.size / files.length) * 100}%` }} | |
| 996 | /> | |
| 997 | </span> | |
| 998 | {viewed.size}/{files.length} viewed | |
| 999 | </span> | |
| 1000 | <div className="ml-auto flex items-center gap-2"> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1001 | {files.length > 1 && ( |
| 1002 | // Below xl the tree is not beside the diffs: it opens as a sheet. | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 1003 | <Button |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1004 | type="button" |
| 1005 | onClick={() => setPicking(true)} | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 1006 | variant="outline" |
| 1007 | size="inline" | |
| 1008 | className="flex min-h-9 px-2.5 text-xs text-muted xl:hidden font-normal" | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1009 | > |
| 1010 | <Files size={13} /> | |
| 1011 | Files | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 1012 | </Button> |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1013 | )} |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 1014 | <Button |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 1015 | type="button" |
| 1016 | onClick={() => setCollapsed(allCollapsed ? new Set() : new Set(files.map((file) => file.path)))} | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 1017 | variant="ghost" |
| 1018 | size="inline" | |
| 1019 | className="px-2 py-1 text-xs font-normal" | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 1020 | > |
| 1021 | {allCollapsed ? "Expand all" : "Collapse all"} | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 1022 | </Button> |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1023 | <div className="hidden rounded-md border border-line p-0.5 sm:flex" role="radiogroup" aria-label="Layout"> |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 1024 | {( |
| 1025 | [ | |
| 1026 | ["unified", <Rows3 key="u" size={13} />, "Unified"], | |
| 1027 | ["split", <Columns2 key="s" size={13} />, "Split"], | |
| 1028 | ] as const | |
| 1029 | ).map(([value, icon, label]) => ( | |
| 1030 | <button | |
| 1031 | key={value} | |
| 1032 | type="button" | |
| 1033 | role="radio" | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1034 | aria-checked={chosenLayout === value} |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 1035 | onClick={() => changeLayout(value)} |
| 1036 | className={`flex items-center gap-1.5 rounded px-2 py-0.5 text-xs transition-colors ${ | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1037 | chosenLayout === value ? "bg-raised text-fg" : "text-muted hover:text-fg" |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 1038 | }`} |
| 1039 | > | |
| 1040 | {icon} | |
| 1041 | {label} | |
| 1042 | </button> | |
| 1043 | ))} | |
| 1044 | </div> | |
| 1045 | </div> | |
| 1046 | </div> | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1047 | {files.length > 1 && ( |
| 1048 | <Sheet open={picking} onOpenChange={setPicking}> | |
| 1049 | <SheetContent | |
| 1050 | side="left" | |
| 1051 | // No keyboard popping up over the list on a phone: the filter is a tap away. | |
| 1052 | onOpenAutoFocus={(event) => event.preventDefault()} | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 1053 | className="inset-x-0 top-auto bottom-0 h-[80dvh] max-w-none rounded-t-2xl pt-0 border-t border-r-0 data-[state=closed]:animate-fade-out data-[state=open]:animate-fade-in sm:max-w-none xl:hidden" |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1054 | > |
| 1055 | <SheetHeader> | |
| 1056 | <SheetTitle>Files changed</SheetTitle> | |
| 1057 | <SheetDescription> | |
| 1058 | {files.length} files · {viewed.size} viewed | |
| 1059 | </SheetDescription> | |
| 1060 | </SheetHeader> | |
| 1061 | <div className="relative px-4 pt-3"> | |
| 1062 | <Search size={14} className="pointer-events-none absolute top-1/2 left-6.5 mt-1.5 -translate-y-1/2 text-faint" /> | |
| 1063 | <input | |
| 1064 | value={filter} | |
| 1065 | onChange={(event) => setFilter(event.target.value)} | |
| 1066 | placeholder="Filter files" | |
| 1067 | aria-label="Filter files" | |
| 1068 | autoComplete="off" | |
| 1069 | data-1p-ignore | |
| 1070 | className="h-10 w-full rounded-md border border-line bg-bg pr-3 pl-8 text-sm outline-none placeholder:text-faint focus:border-accent-dim" | |
| 1071 | /> | |
| 1072 | </div> | |
| 1073 | <nav aria-label="Files changed" className="min-h-0 grow overflow-y-auto px-2 py-2 [&_button]:min-h-10 [&_button]:text-[0.8125rem] [&_p]:min-h-8 [&_p]:text-[0.8125rem]"> | |
| 1074 | <Tree | |
| 1075 | nodes={tree} | |
| 1076 | depth={0} | |
| 1077 | active={active} | |
| 1078 | viewed={viewed} | |
| 1079 | onPick={(path) => { | |
| 1080 | setPicking(false); | |
| 1081 | // Once the sheet has let the page go, so its scroll is not undone. | |
| 1082 | setTimeout(() => jump(path), 50); | |
| 1083 | }} | |
| 1084 | /> | |
| 1085 | {shownFiles.length === 0 && <p className="px-2 py-6 text-center text-sm text-muted">No file matches.</p>} | |
| 1086 | </nav> | |
| 1087 | </SheetContent> | |
| 1088 | </Sheet> | |
| 1089 | )} | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 1090 | <div className="flex gap-4"> |
| 1091 | {files.length > 1 && ( | |
| g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them. | 1092 | <aside className="sticky top-[calc(var(--topbar-h)+3.5rem)] hidden max-h-[calc(100vh-var(--topbar-h)-4.5rem)] w-60 shrink-0 flex-col self-start xl:flex"> |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 1093 | <div className="relative"> |
| 1094 | <Search size={13} className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 text-faint" /> | |
| 1095 | <input | |
| 1096 | ref={filterInput} | |
| 1097 | value={filter} | |
| 1098 | onChange={(event) => setFilter(event.target.value)} | |
| 1099 | onKeyDown={(event) => { | |
| 1100 | if (event.key === "Escape") { | |
| 1101 | setFilter(""); | |
| 1102 | event.currentTarget.blur(); | |
| 1103 | } else if (event.key === "Enter" && shownFiles[0]) { | |
| 1104 | jump(shownFiles[0].path); | |
| 1105 | event.currentTarget.blur(); | |
| 1106 | } | |
| 1107 | }} | |
| 1108 | placeholder="Filter files" | |
| 1109 | autoComplete="off" | |
| 1110 | data-1p-ignore | |
| 1111 | className="h-8 w-full rounded-md border border-line bg-surface/60 pr-7 pl-7 text-xs outline-none placeholder:text-faint focus:border-accent-dim" | |
| 1112 | /> | |
| 1113 | <kbd className="absolute top-1/2 right-2 -translate-y-1/2 rounded border border-line px-1 font-mono text-[0.625rem] text-faint"> | |
| 1114 | t | |
| 1115 | </kbd> | |
| 1116 | </div> | |
| 1117 | <nav className="mt-2 min-h-0 overflow-y-auto pb-2"> | |
| 1118 | <Tree nodes={tree} depth={0} active={active} viewed={viewed} onPick={jump} /> | |
| 1119 | </nav> | |
| 1120 | <p className="mt-auto border-t border-line pt-2 text-[0.6875rem] text-faint"> | |
| 1121 | <kbd className="font-mono">j</kbd>/<kbd className="font-mono">k</kbd> next and previous file,{" "} | |
| 1122 | <kbd className="font-mono">v</kbd> mark viewed | |
| 1123 | </p> | |
| 1124 | </aside> | |
| 1125 | )} | |
| 1126 | <div className="min-w-0 grow space-y-3"> | |
| 1127 | {review?.canComment && ( | |
| Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002) | 1128 | <p className="text-xs text-faint"> |
| 1129 | <span className="pointer-coarse:hidden">Hover a line and press the button beside its number to comment on it.</span> | |
| 1130 | <span className="hidden pointer-coarse:inline">Tap a line number to comment on it.</span> | |
| 1131 | </p> | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 1132 | )} |
| 1133 | {shownFiles.map((file) => ( | |
| 1134 | <FileSection | |
| 1135 | key={file.path} | |
| 1136 | file={file} | |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 1137 | source={source} |
| 1138 | wanted={wanted === file.path} | |
| 1139 | ahead={ahead.get(file.path)} | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 1140 | review={review} |
| 1141 | layout={layout} | |
| 1142 | collapsed={collapsed.has(file.path)} | |
| 1143 | viewed={viewed.has(file.path)} | |
| 1144 | onToggle={() => toggle(file.path)} | |
| 1145 | onViewed={(on) => markViewed(file.path, on)} | |
| 1146 | fileBase={fileBase} | |
| 1147 | /> | |
| 1148 | ))} | |
| 1149 | {shownFiles.length === 0 && <p className="py-8 text-center text-sm text-muted">No file matches.</p>} | |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 1150 | {truncated && ( |
| 1151 | <p className="text-center text-sm text-muted">More files changed than one page lists: these are the first {files.length}.</p> | |
| 1152 | )} | |
| Mission control: a place to ask g1t, shown and not on yet, with the actions under it | 1153 | </div> |
| 1154 | </div> | |
| 1155 | </div> | |
| 1156 | ); | |
| 1157 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.