Merge branch 'worktree-agent-a9934473c8cf2bca6'
12 files+336−1540/12 viewed
| 220 | 220 | const g1tBusy = withG1t && anyAnswering(messages); | |
| 221 | 221 | // References in g1t's answers point into the repository the latest question was asked from, if any. | |
| 222 | 222 | const askedRepo = useMemo(() => [...messages].reverse().find((m) => m.page)?.page?.repo ?? null, [messages]); | |
| 223 | − | const answerContext = useMemo<ResponseContext>(() => ({ slug, repo: askedRepo }), [slug, askedRepo]); | |
| 223 | + | // Kept while it names the same repository: each answer draws itself again | |
| 224 | + | // (and React replaces all of it) whenever this changes, so a new message | |
| 225 | + | // or a refresh, which hand over new objects, must not change it. | |
| 226 | + | const answerContext = useMemo<ResponseContext>( | |
| 227 | + | () => ({ slug, repo: askedRepo }), | |
| 228 | + | // eslint-disable-next-line react-hooks/exhaustive-deps -- the repository by name, not the object it came in | |
| 229 | + | [slug, askedRepo?.namespace, askedRepo?.name], | |
| 230 | + | ); | |
| 224 | 231 | const lastAnswer = withG1t ? ([...messages].reverse().find((m) => !m.deleted_at && !m.pending) ?? null) : null; | |
| 225 | 232 | const g1tSend = useCallback( | |
| 226 | 233 | async (intent: "g1t_retry" | "g1t_stop" | "g1t_reset") => { |
| 16 | 16 | ||
| 17 | 17 | import type { Comment, Comparison, DiffLine } from "@g1t/contracts"; | |
| 18 | 18 | ||
| 19 | − | import type { | |
| 20 | − | HighlightedFile, | |
| 21 | − | HighlightedLine, | |
| 19 | + | import { | |
| 20 | + | type FileHtml, | |
| 21 | + | type HighlightedFile, | |
| 22 | + | type HighlightedLine, | |
| 23 | + | diffContent, | |
| 24 | + | htmlOfFile, | |
| 25 | + | withHtml, | |
| 22 | 26 | } from "../lib/diff"; | |
| 23 | 27 | import { Markdown } from "./markdown"; | |
| 24 | 28 | import { EmptyState, SubmitButton, Textarea, TimeAgo } from "./ui"; | |
| ⋯ | |||
| 398 | 402 | // Shown plain at once, and highlighted in the browser when it comes near | |
| 399 | 403 | // the screen: the page never waits for colours. | |
| 400 | 404 | const section = useRef<HTMLElement>(null); | |
| 401 | − | const [highlighted, setHighlighted] = useState<HighlightedFile | null>(null); | |
| 405 | + | const [highlighted, setHighlighted] = useState<{ content: string; html: FileHtml } | null>(null); | |
| 406 | + | // What the colours depend on. A refresh hands the page a new `file` with | |
| 407 | + | // the same lines: it keeps its colours rather than going plain and being | |
| 408 | + | // highlighted again, which flashed every coloured diff on the page. | |
| 409 | + | const content = useMemo(() => `${file.path}\n${diffContent(file)}`, [file]); | |
| 410 | + | const latest = useRef(file); | |
| 411 | + | latest.current = file; | |
| 412 | + | const kept = highlighted?.content === content ? highlighted.html : null; | |
| 413 | + | const drawn = useMemo(() => (kept ? withHtml(file, kept) : file), [file, kept]); | |
| 402 | 414 | useEffect(() => { | |
| 403 | − | setHighlighted(null); | |
| 404 | 415 | const element = section.current; | |
| 416 | + | const file = latest.current; | |
| 405 | 417 | // Highlighted by the server already: one of the change's first files. | |
| 406 | 418 | const done = file.hunks.some((hunk) => hunk.lines.some((line) => (line as HighlightedLine).html != null)); | |
| 407 | − | if (!element || collapsed || done) return; | |
| 419 | + | if (!element || collapsed || done || kept) return; | |
| 408 | 420 | let cancelled = false; | |
| 409 | 421 | const observer = new IntersectionObserver( | |
| 410 | 422 | ([entry]) => { | |
| ⋯ | |||
| 413 | 425 | void import("../lib/shiki") | |
| 414 | 426 | .then(({ highlightFile }) => highlightFile(file)) | |
| 415 | 427 | .then((result) => { | |
| 416 | − | if (!cancelled && result) setHighlighted(result); | |
| 428 | + | if (!cancelled && result) setHighlighted({ content, html: htmlOfFile(result) }); | |
| 417 | 429 | }) | |
| 418 | 430 | .catch(() => {}); | |
| 419 | 431 | }, | |
| ⋯ | |||
| 424 | 436 | cancelled = true; | |
| 425 | 437 | observer.disconnect(); | |
| 426 | 438 | }; | |
| 427 | − | }, [file, collapsed]); | |
| 428 | − | const props = { file: highlighted ?? file, comments, canComment: review?.canComment ?? false }; | |
| 439 | + | // `kept` is whether `content` is highlighted already: the lines, not the object they came in. | |
| 440 | + | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 441 | + | }, [content, collapsed, kept != null]); | |
| 442 | + | const props = { file: drawn, comments, canComment: review?.canComment ?? false }; | |
| 429 | 443 | return ( | |
| 430 | 444 | <Card asChild tone="plain" className="scroll-mt-[calc(4rem+var(--diff-toolbar,3rem))]"> | |
| 431 | 445 | <section | |
| 10 | 10 | } from "lucide-react"; | |
| 11 | 11 | import type { Root } from "hast"; | |
| 12 | 12 | import type { Components } from "react-markdown"; | |
| 13 | − | import { type ReactNode, isValidElement, useEffect, useMemo, useRef, useState } from "react"; | |
| 13 | + | import { type ReactNode, createContext, isValidElement, useContext, useEffect, useMemo, useRef, useState } from "react"; | |
| 14 | 14 | import { Link } from "react-router"; | |
| 15 | 15 | import rehypeRaw from "rehype-raw"; | |
| 16 | 16 | import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; | |
| ⋯ | |||
| 206 | 206 | }) { | |
| 207 | 207 | // The same text renders to the same elements with the same components, so | |
| 208 | 208 | // a page that revalidates (a live refresh) leaves the text as it is | |
| 209 | − | // rather than mounting it again and reloading its pictures. | |
| 209 | + | // rather than mounting it again and reloading its pictures. The | |
| 210 | + | // components never change: where links and pictures point comes from | |
| 211 | + | // context, so a new commit (a README's pictures are that commit's files) | |
| 212 | + | // updates their addresses in place rather than replacing every element. | |
| 210 | 213 | const tree = useMemo(() => treeOf(source, repo), [source, repo?.namespace, repo?.name]); | |
| 211 | − | const components = useMemo(() => markdownComponents(base, rawBase), [base, rawBase]); | |
| 212 | − | const rendered = useMemo(() => renderMarkdownTree(tree, components), [tree, components]); | |
| 214 | + | const rendered = useMemo(() => renderMarkdownTree(tree, COMPONENTS), [tree]); | |
| 215 | + | const bases = useMemo(() => ({ base, rawBase }), [base, rawBase]); | |
| 213 | 216 | return ( | |
| 214 | − | // A long word or address breaks rather than widening the page. | |
| 215 | − | <div className="prose wrap-break-word">{rendered}</div> | |
| 217 | + | <MarkdownBases.Provider value={bases}> | |
| 218 | + | {/* A long word or address breaks rather than widening the page. */} | |
| 219 | + | <div className="prose wrap-break-word">{rendered}</div> | |
| 220 | + | </MarkdownBases.Provider> | |
| 216 | 221 | ); | |
| 217 | 222 | } | |
| 218 | 223 | ||
| 219 | − | /** How each element is drawn: links relative to `base`, pictures under `rawBase`. */ | |
| 220 | − | function markdownComponents(base: string | undefined, rawBase: string | undefined): Components { | |
| 221 | − | return { | |
| 222 | − | h1: ({ children }) => <Heading level={1}>{children}</Heading>, | |
| 223 | − | h2: ({ children }) => <Heading level={2}>{children}</Heading>, | |
| 224 | − | h3: ({ children }) => <Heading level={3}>{children}</Heading>, | |
| 225 | − | h4: ({ children }) => <Heading level={4}>{children}</Heading>, | |
| 226 | − | a({ href = "", children, node }) { | |
| 227 | − | const ref = (node?.properties as { dataRef?: string } | undefined)?.dataRef; | |
| 228 | − | if (ref === "mention") { | |
| 229 | − | // `@name` may be a person (with a card) or a workspace (none). | |
| 230 | − | const name = href === G1T_MENTION_HREF ? "g1t" : href.replace(/^\//, ""); | |
| 231 | − | return ( | |
| 232 | − | <UserCard username={name}> | |
| 233 | − | <Link to={href} prefetch="intent" className="font-medium"> | |
| 234 | − | {children} | |
| 235 | − | </Link> | |
| 236 | − | </UserCard> | |
| 237 | − | ); | |
| 238 | − | } | |
| 239 | − | if (ref) { | |
| 240 | − | return ( | |
| 241 | − | <Link | |
| 242 | − | to={href} | |
| 243 | − | prefetch="intent" | |
| 244 | − | className={ref === "commit" ? "font-mono text-[0.9em]" : ref === "mention" ? "font-medium" : ""} | |
| 245 | − | > | |
| 246 | − | {children} | |
| 247 | − | </Link> | |
| 248 | − | ); | |
| 249 | − | } | |
| 250 | − | if (href.startsWith("#")) return <a href={href}>{children}</a>; | |
| 251 | − | if (isExternal(href)) { | |
| 252 | − | return ( | |
| 253 | − | <a href={href} rel="noreferrer nofollow ugc" target="_blank"> | |
| 254 | − | {children} | |
| 255 | − | </a> | |
| 256 | − | ); | |
| 257 | − | } | |
| 258 | − | // A link within the site, or relative to the document's folder. | |
| 259 | − | const to = href.startsWith("/") || !base ? href : `${base}/${href.replace(/^\.\//, "")}`; | |
| 260 | − | return <Link to={to}>{children}</Link>; | |
| 261 | − | }, | |
| 262 | − | blockquote({ children, node }) { | |
| 263 | − | const kind = (node?.properties as { dataAlert?: AlertKind } | undefined)?.dataAlert; | |
| 264 | − | if (!kind || !ALERT[kind]) return <blockquote>{children}</blockquote>; | |
| 265 | − | const alert = ALERT[kind]; | |
| 266 | − | return ( | |
| 267 | − | <div className={`markdown-alert border-l-2 py-1 pl-4 ${alert.tone}`}> | |
| 268 | − | <p className="alert-title flex items-center gap-2 text-sm font-medium"> | |
| 269 | − | {alert.icon} | |
| 270 | − | {alert.title} | |
| 271 | − | </p> | |
| 272 | − | <div className="mt-1 [&>*+*]:mt-3">{children}</div> | |
| 273 | − | </div> | |
| 274 | − | ); | |
| 275 | − | }, | |
| 276 | − | pre({ children }) { | |
| 277 | − | const code = Array.isArray(children) ? children[0] : children; | |
| 278 | − | if (isValidElement<{ className?: string; children?: ReactNode }>(code)) { | |
| 279 | − | const language = /language-([\w+-]+)/.exec(code.props.className ?? "")?.[1] ?? null; | |
| 280 | − | return <CodeBlock language={language} code={textOf(code.props.children).replace(/\n$/, "")} />; | |
| 281 | − | } | |
| 282 | − | return <pre>{children}</pre>; | |
| 283 | − | }, | |
| 284 | − | // A wide table scrolls on its own, inside the text, not the page. | |
| 285 | − | table: ({ children }) => ( | |
| 286 | − | <div className="overflow-x-auto"> | |
| 287 | − | <table className="max-sm:[&_td]:min-w-32 max-sm:[&_th]:whitespace-nowrap">{children}</table> | |
| 224 | + | /** Where a document's relative links (`base`) and pictures (`rawBase`) point. */ | |
| 225 | + | const MarkdownBases = createContext<{ base?: string; rawBase?: string }>({}); | |
| 226 | + | ||
| 227 | + | /** A link: a reference or mention within the site, an anchor, an address elsewhere, or a file beside the document. */ | |
| 228 | + | function MarkdownLink({ href = "", children, node }: { href?: string; children?: ReactNode; node?: { properties?: unknown } }) { | |
| 229 | + | const { base } = useContext(MarkdownBases); | |
| 230 | + | const ref = (node?.properties as { dataRef?: string } | undefined)?.dataRef; | |
| 231 | + | if (ref === "mention") { | |
| 232 | + | // `@name` may be a person (with a card) or a workspace (none). | |
| 233 | + | const name = href === G1T_MENTION_HREF ? "g1t" : href.replace(/^\//, ""); | |
| 234 | + | return ( | |
| 235 | + | <UserCard username={name}> | |
| 236 | + | <Link to={href} prefetch="intent" className="font-medium"> | |
| 237 | + | {children} | |
| 238 | + | </Link> | |
| 239 | + | </UserCard> | |
| 240 | + | ); | |
| 241 | + | } | |
| 242 | + | if (ref) { | |
| 243 | + | return ( | |
| 244 | + | <Link | |
| 245 | + | to={href} | |
| 246 | + | prefetch="intent" | |
| 247 | + | className={ref === "commit" ? "font-mono text-[0.9em]" : ref === "mention" ? "font-medium" : ""} | |
| 248 | + | > | |
| 249 | + | {children} | |
| 250 | + | </Link> | |
| 251 | + | ); | |
| 252 | + | } | |
| 253 | + | if (href.startsWith("#")) return <a href={href}>{children}</a>; | |
| 254 | + | if (isExternal(href)) { | |
| 255 | + | return ( | |
| 256 | + | <a href={href} rel="noreferrer nofollow ugc" target="_blank"> | |
| 257 | + | {children} | |
| 258 | + | </a> | |
| 259 | + | ); | |
| 260 | + | } | |
| 261 | + | // A link within the site, or relative to the document's folder. | |
| 262 | + | const to = href.startsWith("/") || !base ? href : `${base}/${href.replace(/^\.\//, "")}`; | |
| 263 | + | return <Link to={to}>{children}</Link>; | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | /** A picture; a relative one is the document's commit's file. */ | |
| 267 | + | function MarkdownImage({ src, alt }: { src?: unknown; alt?: string }) { | |
| 268 | + | const { rawBase } = useContext(MarkdownBases); | |
| 269 | + | const at = typeof src === "string" ? imageSource(src, rawBase) : undefined; | |
| 270 | + | return <img src={at} alt={alt ?? ""} loading="lazy" className="inline max-w-full rounded" />; | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | /** | |
| 274 | + | * How each element is drawn, made once: a component that is the same on | |
| 275 | + | * every render keeps the elements it drew, where a new one (even an | |
| 276 | + | * identical function) has React take them down and put them back. | |
| 277 | + | */ | |
| 278 | + | const COMPONENTS: Components = { | |
| 279 | + | h1: ({ children }) => <Heading level={1}>{children}</Heading>, | |
| 280 | + | h2: ({ children }) => <Heading level={2}>{children}</Heading>, | |
| 281 | + | h3: ({ children }) => <Heading level={3}>{children}</Heading>, | |
| 282 | + | h4: ({ children }) => <Heading level={4}>{children}</Heading>, | |
| 283 | + | a: MarkdownLink, | |
| 284 | + | blockquote({ children, node }) { | |
| 285 | + | const kind = (node?.properties as { dataAlert?: AlertKind } | undefined)?.dataAlert; | |
| 286 | + | if (!kind || !ALERT[kind]) return <blockquote>{children}</blockquote>; | |
| 287 | + | const alert = ALERT[kind]; | |
| 288 | + | return ( | |
| 289 | + | <div className={`markdown-alert border-l-2 py-1 pl-4 ${alert.tone}`}> | |
| 290 | + | <p className="alert-title flex items-center gap-2 text-sm font-medium"> | |
| 291 | + | {alert.icon} | |
| 292 | + | {alert.title} | |
| 293 | + | </p> | |
| 294 | + | <div className="mt-1 [&>*+*]:mt-3">{children}</div> | |
| 288 | 295 | </div> | |
| 289 | − | ), | |
| 290 | − | input({ type, checked, disabled }) { | |
| 291 | − | // Task list boxes: shown, not editable. | |
| 292 | − | return type === "checkbox" ? ( | |
| 293 | − | <Checkbox | |
| 294 | − | checked={checked === true} | |
| 295 | − | disabled={disabled !== false} | |
| 296 | − | aria-label={checked ? "Done" : "Not done"} | |
| 297 | − | className="mr-1.5 inline-flex translate-y-0.5 disabled:cursor-default disabled:opacity-100" | |
| 298 | − | /> | |
| 299 | − | ) : null; | |
| 300 | − | }, | |
| 301 | − | img({ src, alt }) { | |
| 302 | − | const at = typeof src === "string" ? imageSource(src, rawBase) : undefined; | |
| 303 | − | return <img src={at} alt={alt ?? ""} loading="lazy" className="inline max-w-full rounded" />; | |
| 304 | − | }, | |
| 305 | − | } satisfies Components; | |
| 306 | − | } | |
| 296 | + | ); | |
| 297 | + | }, | |
| 298 | + | pre({ children }) { | |
| 299 | + | const code = Array.isArray(children) ? children[0] : children; | |
| 300 | + | if (isValidElement<{ className?: string; children?: ReactNode }>(code)) { | |
| 301 | + | const language = /language-([\w+-]+)/.exec(code.props.className ?? "")?.[1] ?? null; | |
| 302 | + | return <CodeBlock language={language} code={textOf(code.props.children).replace(/\n$/, "")} />; | |
| 303 | + | } | |
| 304 | + | return <pre>{children}</pre>; | |
| 305 | + | }, | |
| 306 | + | // A wide table scrolls on its own, inside the text, not the page. | |
| 307 | + | table: ({ children }) => ( | |
| 308 | + | <div className="overflow-x-auto"> | |
| 309 | + | <table className="max-sm:[&_td]:min-w-32 max-sm:[&_th]:whitespace-nowrap">{children}</table> | |
| 310 | + | </div> | |
| 311 | + | ), | |
| 312 | + | input({ type, checked, disabled }) { | |
| 313 | + | // Task list boxes: shown, not editable. | |
| 314 | + | return type === "checkbox" ? ( | |
| 315 | + | <Checkbox | |
| 316 | + | checked={checked === true} | |
| 317 | + | disabled={disabled !== false} | |
| 318 | + | aria-label={checked ? "Done" : "Not done"} | |
| 319 | + | className="mr-1.5 inline-flex translate-y-0.5 disabled:cursor-default disabled:opacity-100" | |
| 320 | + | /> | |
| 321 | + | ) : null; | |
| 322 | + | }, | |
| 323 | + | img: MarkdownImage, | |
| 324 | + | }; | |
| 36 | 36 | const image = useRef<HTMLImageElement>(null); | |
| 37 | 37 | const [at, setAt] = useState(0); | |
| 38 | 38 | const [loaded, setLoaded] = useState(false); | |
| 39 | + | // The picture last shown, kept under the next one until that has loaded | |
| 40 | + | // and faded in: a new deploy's screenshot replaces the old one rather | |
| 41 | + | // than the frame going blank while it comes. | |
| 42 | + | const [shown, setShown] = useState<CardSource | null>(null); | |
| 39 | 43 | const key = sources.map((source) => source.src).join("\n"); | |
| 40 | 44 | const source = sources[at] ?? null; | |
| 45 | + | const under = source && shown && shown.src !== source.src ? shown : null; | |
| 41 | 46 | ||
| 42 | 47 | // Other sources (another deploy, a new upload): from the first again. | |
| 43 | 48 | useEffect(() => { | |
| ⋯ | |||
| 53 | 58 | else setAt((index) => index + 1); | |
| 54 | 59 | }, [source?.src]); | |
| 55 | 60 | ||
| 61 | + | // Once the new picture has faded in over it (300 ms), the old one goes. | |
| 62 | + | useEffect(() => { | |
| 63 | + | if (!loaded || !source || shown?.src === source.src) return; | |
| 64 | + | const timer = setTimeout(() => setShown(source), 350); | |
| 65 | + | return () => clearTimeout(timer); | |
| 66 | + | // eslint-disable-next-line react-hooks/exhaustive-deps -- `source` is the one `src` names | |
| 67 | + | }, [loaded, source?.src, shown?.src]); | |
| 68 | + | ||
| 56 | 69 | const empty = source == null; | |
| 57 | 70 | const waiting = !empty && !loaded ? (source.kind === "screenshot" ? "Taking a screenshot…" : "Loading the image…") : null; | |
| 58 | 71 | ||
| ⋯ | |||
| 63 | 76 | href && "transition-colors group-hover:border-line-strong", | |
| 64 | 77 | )} | |
| 65 | 78 | > | |
| 66 | − | {!loaded && <Placeholder label={label} waiting={waiting} note={empty && !addImage ? (note ?? null) : null} />} | |
| 79 | + | {!loaded && !under && <Placeholder label={label} waiting={waiting} note={empty && !addImage ? (note ?? null) : null} />} | |
| 80 | + | {under && ( | |
| 81 | + | <img | |
| 82 | + | key={under.src} | |
| 83 | + | src={under.src} | |
| 84 | + | alt="" | |
| 85 | + | aria-hidden="true" | |
| 86 | + | width={1280} | |
| 87 | + | height={800} | |
| 88 | + | className="absolute inset-0 size-full object-cover object-top" | |
| 89 | + | /> | |
| 90 | + | )} | |
| 67 | 91 | {source && ( | |
| 68 | 92 | <img | |
| 69 | 93 | key={source.src} | |
| 5 | 5 | export const REFRESH_MS = 4000; | |
| 6 | 6 | ||
| 7 | 7 | /** | |
| 8 | − | * Revalidates the page every `everyMs` while `on` and the tab is visible. | |
| 8 | + | * Revalidates the page `everyMs` after it last loaded while `on` and the | |
| 9 | + | * tab is visible. Whatever refreshed it last (this, a live change, a form, | |
| 10 | + | * a navigation) starts the count again, so a page that also follows live | |
| 11 | + | * changes is not refreshed twice in a row. Out of view it waits, and asks | |
| 12 | + | * once when the tab comes back if the time has passed meanwhile. | |
| 13 | + | * | |
| 9 | 14 | * Never while a navigation is pending: a revalidation then restarts the | |
| 10 | 15 | * navigation, aborting its loaders, so a click to a page slower than the | |
| 11 | 16 | * interval would never arrive. Never on top of a revalidation still going. | |
| 12 | 17 | */ | |
| 13 | 18 | export function useRefreshWhile(on: boolean, everyMs = REFRESH_MS) { | |
| 14 | 19 | const revalidator = useRevalidator(); | |
| 15 | − | const navigating = useNavigation().state !== "idle"; | |
| 20 | + | const idle = useNavigation().state === "idle" && revalidator.state === "idle"; | |
| 16 | 21 | useEffect(() => { | |
| 17 | − | if (!on || navigating) return; | |
| 18 | − | const timer = setInterval(() => { | |
| 19 | − | if (document.visibilityState === "visible" && revalidator.state === "idle") revalidator.revalidate(); | |
| 20 | − | }, everyMs); | |
| 21 | − | return () => clearInterval(timer); | |
| 22 | − | }, [on, navigating, everyMs, revalidator]); | |
| 22 | + | if (!on || !idle) return; | |
| 23 | + | let due = false; | |
| 24 | + | const ask = () => { | |
| 25 | + | if (document.visibilityState === "visible") revalidator.revalidate(); | |
| 26 | + | else due = true; | |
| 27 | + | }; | |
| 28 | + | const timer = setTimeout(ask, everyMs); | |
| 29 | + | const back = () => { | |
| 30 | + | if (due && document.visibilityState === "visible") ask(); | |
| 31 | + | }; | |
| 32 | + | document.addEventListener("visibilitychange", back); | |
| 33 | + | return () => { | |
| 34 | + | clearTimeout(timer); | |
| 35 | + | document.removeEventListener("visibilitychange", back); | |
| 36 | + | }; | |
| 37 | + | // `revalidator` is a new object whenever its state changes, which `idle` already follows. | |
| 38 | + | // eslint-disable-next-line react-hooks/exhaustive-deps | |
| 39 | + | }, [on, idle, everyMs]); | |
| 23 | 40 | } |
| 4 | 4 | import { | |
| 5 | 5 | FALLBACK_FOCUS_MS, | |
| 6 | 6 | FALLBACK_POLL_MS, | |
| 7 | − | HIDDEN_MIN_MS, | |
| 8 | 7 | LIVE_DEBOUNCE_MS, | |
| 9 | 8 | LIVE_HOLD_CHECK_MS, | |
| 10 | 9 | LIVE_MAX_WAIT_MS, | |
| 10 | + | LIVE_MIN_GAP_MS, | |
| 11 | 11 | type RepoChange, | |
| 12 | 12 | atBottom, | |
| 13 | 13 | branchOf, | |
| ⋯ | |||
| 53 | 53 | assert.equal(overview(change("comment.created", { number: 3 })), false); | |
| 54 | 54 | }); | |
| 55 | 55 | ||
| 56 | + | test("the overview hears its own commit's checks, and every pull request's finished ones", () => { | |
| 57 | + | const overview = repoPages.overview("abc"); | |
| 58 | + | assert.equal(overview(change("status.created", { sha: "abc" })), true); | |
| 59 | + | assert.equal(overview(change("run.updated", { sha: "abc", run: "run_1" })), true); | |
| 60 | + | assert.equal(overview(change("status.created", { sha: "f00d" })), false, "another commit's"); | |
| 61 | + | assert.equal(overview(change("run.updated", { sha: "f00d", run: "run_2" })), false); | |
| 62 | + | assert.equal(overview(change("checks.completed", { sha: "f00d", number: 4 })), true, "how often checks pass"); | |
| 63 | + | assert.equal(overview(change("status.created")), true, "a check that names no commit may be its"); | |
| 64 | + | }); | |
| 65 | + | ||
| 56 | 66 | test("the files page follows refs, checks, releases and deployments", () => { | |
| 57 | 67 | const files = repoPages.files(); | |
| 58 | 68 | for (const c of [push("main"), push("other"), change("git.deleted", { ref: "refs/heads/old" }), change("status.created", { sha: "abc" }), change("release.published"), change("deployment_status.created")]) { | |
| ⋯ | |||
| 62 | 72 | assert.equal(files(change("comment.created", { number: 3 })), false); | |
| 63 | 73 | }); | |
| 64 | 74 | ||
| 75 | + | test("a page of files hears its own branch and its commit's checks, not every branch's", () => { | |
| 76 | + | const files = repoPages.files({ branch: "main", branches: ["main", "feature"], sha: "abc" }); | |
| 77 | + | assert.equal(files(push("main")), true, "its files and latest commit"); | |
| 78 | + | assert.equal(files(push("feature")), false, "another branch moving changes nothing here"); | |
| 79 | + | assert.equal(files(push("brand-new")), true, "a new branch: the menu and count"); | |
| 80 | + | assert.equal(files(change("git.deleted", { ref: "refs/heads/feature" })), true, "one of the menu's gone"); | |
| 81 | + | assert.equal(files(change("git.deleted", { ref: "refs/heads/never-listed" })), false); | |
| 82 | + | assert.equal(files(change("git.push", { ref: "refs/tags/v2" })), true, "the About's tags"); | |
| 83 | + | assert.equal(files(change("branch.renamed", { ref: "refs/heads/feature" })), true); | |
| 84 | + | assert.equal(files(change("repo.default_branch_changed")), true); | |
| 85 | + | assert.equal(files(change("status.created", { sha: "abc" })), true, "its latest commit's checks"); | |
| 86 | + | assert.equal(files(change("check_run.completed", { sha: "f00d" })), false, "another commit's checks"); | |
| 87 | + | assert.equal(files(change("run.updated", { run: "run_1" })), true, "a check that names no commit may be its"); | |
| 88 | + | assert.equal(files(change("deployment_status.created")), true); | |
| 89 | + | assert.equal(files(change("release.published")), true); | |
| 90 | + | // A tag or a commit shown: no branch of its own moves it. | |
| 91 | + | const tag = repoPages.files({ branch: null, branches: ["main"], sha: "abc" }); | |
| 92 | + | assert.equal(tag(push("main")), false); | |
| 93 | + | // Without the branches, any branch may be new: only its own and deletions count. | |
| 94 | + | const unknown = repoPages.files({ branch: "main", branches: null, sha: "abc" }); | |
| 95 | + | assert.equal(unknown(push("other")), false); | |
| 96 | + | assert.equal(unknown(change("git.deleted", { ref: "refs/heads/other" })), true); | |
| 97 | + | }); | |
| 98 | + | ||
| 65 | 99 | test("the commits page hears pushes to its own branch only", () => { | |
| 66 | 100 | const commits = repoPages.commits("main"); | |
| 67 | 101 | assert.equal(commits(push("main")), true); | |
| ⋯ | |||
| 69 | 103 | assert.equal(commits(change("git.push", { ref: "refs/tags/v1" })), false); | |
| 70 | 104 | assert.equal(commits(change("workflow.completed", { sha: "abc", run: "run_1" })), true, "checks on its commits"); | |
| 71 | 105 | assert.equal(commits(change("pull.opened", { number: 1 })), false); | |
| 106 | + | const shown = repoPages.commits("main", ["abc", "def"]); | |
| 107 | + | assert.equal(shown(change("status.created", { sha: "def" })), true, "checks on a commit it lists"); | |
| 108 | + | assert.equal(shown(change("status.created", { sha: "f00d" })), false, "not on one it does not"); | |
| 72 | 109 | }); | |
| 73 | 110 | ||
| 74 | 111 | test("branches, tags, and the lists of pull requests and issues", () => { | |
| ⋯ | |||
| 183 | 220 | assert.deepEqual(gate(due, { now: NOW, visible: true, busy: false, navigating: false }), { action: "run" }); | |
| 184 | 221 | }); | |
| 185 | 222 | ||
| 186 | − | test("a tab out of view refreshes at most once a minute", () => { | |
| 223 | + | test("a burst of changes refreshes at most every two seconds", () => { | |
| 224 | + | const idle = { now: NOW, visible: true, busy: false, navigating: false }; | |
| 225 | + | // Quiet for a second, but the page refreshed half a second ago: it waits out the gap. | |
| 226 | + | const justRan = { pendingSince: NOW - LIVE_DEBOUNCE_MS, lastChangeAt: NOW - LIVE_DEBOUNCE_MS, lastRunAt: NOW - 500 }; | |
| 227 | + | assert.deepEqual(gate(justRan, idle), { action: "wait", ms: LIVE_MIN_GAP_MS - 500 }); | |
| 228 | + | assert.deepEqual(gate(justRan, { ...idle, now: NOW - 500 + LIVE_MIN_GAP_MS }), { action: "run" }); | |
| 229 | + | // A steady stream after a refresh: one more at most five seconds on, never sooner than the gap. | |
| 230 | + | const stream = { pendingSince: NOW - LIVE_MAX_WAIT_MS, lastChangeAt: NOW, lastRunAt: NOW - LIVE_MAX_WAIT_MS - 10 }; | |
| 231 | + | assert.deepEqual(gate(stream, idle), { action: "run" }); | |
| 232 | + | // Changes arriving every 100 ms for a minute refresh no more than once per gap. | |
| 233 | + | let state = { pendingSince: null as number | null, lastChangeAt: null as number | null, lastRunAt: NOW }; | |
| 234 | + | let runs = 0; | |
| 235 | + | for (let t = NOW; t < NOW + 60_000; t += 100) { | |
| 236 | + | state = { ...state, pendingSince: state.pendingSince ?? t, lastChangeAt: t }; | |
| 237 | + | if (gate(state, { ...idle, now: t }).action === "run") { | |
| 238 | + | runs++; | |
| 239 | + | state = { pendingSince: null, lastChangeAt: null, lastRunAt: t }; | |
| 240 | + | } | |
| 241 | + | } | |
| 242 | + | assert.ok(runs <= 60_000 / LIVE_MIN_GAP_MS, `${runs} runs`); | |
| 243 | + | assert.ok(runs >= 60_000 / LIVE_MAX_WAIT_MS - 1, `${runs} runs: still kept up to date`); | |
| 244 | + | }); | |
| 245 | + | ||
| 246 | + | test("a tab out of view never refreshes; back in view, it catches up at once", () => { | |
| 187 | 247 | const hidden = { now: NOW, visible: false, busy: false, navigating: false }; | |
| 188 | − | const ranLately = { pendingSince: NOW - 5000, lastChangeAt: NOW - 5000, lastRunAt: NOW - 20_000 }; | |
| 189 | − | assert.deepEqual(gate(ranLately, hidden), { action: "wait", ms: HIDDEN_MIN_MS - 20_000 }); | |
| 190 | − | assert.deepEqual(gate({ ...ranLately, lastRunAt: NOW - HIDDEN_MIN_MS }, hidden), { action: "run" }); | |
| 191 | − | // Back in view: at once. | |
| 192 | − | assert.deepEqual(gate(ranLately, { ...hidden, visible: true }), { action: "run" }); | |
| 248 | + | const waiting = { pendingSince: NOW - 5000, lastChangeAt: NOW - 5000, lastRunAt: NOW - 20 * 60_000 }; | |
| 249 | + | assert.deepEqual(gate(waiting, hidden), { action: "hold" }, "no timer: coming back asks again"); | |
| 250 | + | assert.deepEqual(gate({ pendingSince: null, lastChangeAt: null, lastRunAt: null }, hidden), { action: "idle" }); | |
| 251 | + | assert.deepEqual(gate(waiting, { ...hidden, visible: true }), { action: "run" }); | |
| 193 | 252 | }); | |
| 194 | 253 | ||
| 195 | 254 | test("without the socket: back in view every 30 s at most, a poll every minute in view, never with it", () => { | |
| 8 | 8 | * then every push, pull request, issue, comment, review, check, run, | |
| 9 | 9 | * release and security alert there arrives as a small `RepoChange`. A page | |
| 10 | 10 | * refreshes its loader when a change matches it, a second after the last | |
| 11 | − | * one, and never under someone typing or with a dialog open. Signed out, | |
| 12 | − | * or while the socket is down, it refreshes when the tab comes back into | |
| 13 | − | * view and once a minute while it is in view instead. | |
| 11 | + | * one, at most every two seconds, never under someone typing or with a | |
| 12 | + | * dialog open, and not at all while the tab is out of view: it catches up | |
| 13 | + | * once when it comes back. A busy repository sends changes all the time | |
| 14 | + | * (every branch's pushes, every commit's checks), so each page matches | |
| 15 | + | * only what it shows: the Code page its own branch and the checks of the | |
| 16 | + | * commit it shows, not another branch's. Signed out, or while the socket | |
| 17 | + | * is down, it refreshes when the tab comes back into view and once a | |
| 18 | + | * minute while it is in view instead. | |
| 14 | 19 | */ | |
| 15 | 20 | import type { RepoChange } from "@g1t/contracts"; | |
| 16 | 21 | ||
| ⋯ | |||
| 23 | 28 | export const LIVE_DEBOUNCE_MS = 1000; | |
| 24 | 29 | /** However steady the changes, a page waits no longer than this before refreshing. */ | |
| 25 | 30 | export const LIVE_MAX_WAIT_MS = 5000; | |
| 31 | + | /** However many changes arrive, a page refreshes at most this often. */ | |
| 32 | + | export const LIVE_MIN_GAP_MS = 2000; | |
| 26 | 33 | /** How often a page held back (someone typing, a dialog open) looks again. */ | |
| 27 | 34 | export const LIVE_HOLD_CHECK_MS = 1000; | |
| 28 | − | /** A tab out of view refreshes at most this often. */ | |
| 29 | − | export const HIDDEN_MIN_MS = 60_000; | |
| 30 | 35 | /** Without the socket: coming back into view refreshes at most this often. */ | |
| 31 | 36 | export const FALLBACK_FOCUS_MS = 30_000; | |
| 32 | 37 | /** Without the socket: how often a page in view refreshes. */ | |
| ⋯ | |||
| 73 | 78 | ||
| 74 | 79 | const ALERTS = ["secret_scanning_alert", "code_scanning_alert", "vulnerability_alert"]; | |
| 75 | 80 | ||
| 81 | + | /** A check on one of `shas`: any check when they are not known, and one that names no commit. */ | |
| 82 | + | function isCheckOn(change: RepoChange, shas: readonly string[] | null | undefined): boolean { | |
| 83 | + | return isCheck(change) && (shas == null || !change.sha || shas.includes(change.sha)); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /** What a page of files shows, for matching only what can change it. */ | |
| 87 | + | export type ShownFiles = { | |
| 88 | + | /** The branch shown; null for a tag or a commit. */ | |
| 89 | + | branch: string | null; | |
| 90 | + | /** Every branch, for the branch menu and count: a new one, or one of them deleted, changes them. */ | |
| 91 | + | branches?: readonly string[] | null; | |
| 92 | + | /** The commit shown, whose checks are on the page. */ | |
| 93 | + | sha?: string | null; | |
| 94 | + | }; | |
| 95 | + | ||
| 76 | 96 | /** What each kind of repository page shows. */ | |
| 77 | 97 | export const repoPages = { | |
| 78 | 98 | /** | |
| 79 | − | * Files and a folder: any ref moving (the files and latest | |
| 80 | − | * commit, the branch menu, recent pushes), checks on commits, releases | |
| 81 | − | * and deployments. | |
| 99 | + | * Files and a folder: its own branch moving (the files and the latest | |
| 100 | + | * commit), a branch made, deleted or renamed and the default changing | |
| 101 | + | * (the branch menu and count), any tag (the About's), checks on the | |
| 102 | + | * commit shown, releases and deployments. Without `shown`, any ref and | |
| 103 | + | * any check. | |
| 82 | 104 | */ | |
| 83 | − | files(): RepoMatch { | |
| 84 | − | return (change) => isRefChange(change) || isCheck(change) || ["release", "deployment", "deployment_status"].includes(family(change)); | |
| 105 | + | files(shown?: ShownFiles): RepoMatch { | |
| 106 | + | return (change) => { | |
| 107 | + | if (["release", "deployment", "deployment_status"].includes(family(change))) return true; | |
| 108 | + | if (!shown) return isRefChange(change) || isCheck(change); | |
| 109 | + | if (isCheck(change)) return isCheckOn(change, shown.sha ? [shown.sha] : null); | |
| 110 | + | if (!isRefChange(change)) return false; | |
| 111 | + | const branch = branchOf(change.ref); | |
| 112 | + | if (branch == null || change.kind === "branch.renamed" || change.kind === "repo.default_branch_changed") return true; | |
| 113 | + | if (branch === shown.branch) return true; | |
| 114 | + | // Another branch: only made or deleted matters, by the menu it is in or not. | |
| 115 | + | const known = shown.branches == null || shown.branches.includes(branch); | |
| 116 | + | return change.kind === "git.deleted" ? known : !known; | |
| 117 | + | }; | |
| 85 | 118 | }, | |
| 86 | 119 | /** | |
| 87 | 120 | * A project's overview: what it runs and how it is doing (deployments, | |
| 88 | 121 | * checks, releases), who is working on it (claims and sessions), and the | |
| 89 | 122 | * pull requests that may need someone (opened, readied, reviewed, merged). | |
| 123 | + | * Of the checks, only the shown commit's (`sha`) and finished pull | |
| 124 | + | * requests' (how often checks pass); without `sha`, all. | |
| 90 | 125 | */ | |
| 91 | − | overview(): RepoMatch { | |
| 126 | + | overview(sha?: string | null): RepoMatch { | |
| 92 | 127 | return (change) => | |
| 93 | − | isCheck(change) || | |
| 128 | + | (change.kind === "checks.completed" || isCheckOn(change, sha ? [sha] : null)) || | |
| 94 | 129 | ["release", "deployment", "deployment_status", "work", "pull", "review"].includes(family(change)) || | |
| 95 | 130 | SESSION_KINDS.includes(change.kind); | |
| 96 | 131 | }, | |
| 97 | − | /** Commits: pushes to the branch shown, and checks on commits. */ | |
| 98 | − | commits(branch: string | null): RepoMatch { | |
| 99 | − | return (change) => (branch == null ? change.kind === "git.push" && !tagOf(change.ref) : isPushTo(change, branch)) || isCheck(change); | |
| 132 | + | /** Commits: pushes to the branch shown, and checks on the commits shown (on any without `shas`). */ | |
| 133 | + | commits(branch: string | null, shas?: readonly string[] | null): RepoMatch { | |
| 134 | + | return (change) => (branch == null ? change.kind === "git.push" && !tagOf(change.ref) : isPushTo(change, branch)) || isCheckOn(change, shas); | |
| 100 | 135 | }, | |
| 101 | 136 | /** Branches and tags: any ref moving, pull requests (a branch's), checks. */ | |
| 102 | 137 | refs(): RepoMatch { | |
| ⋯ | |||
| 196 | 231 | navigating: boolean; | |
| 197 | 232 | }; | |
| 198 | 233 | ||
| 199 | − | export type GateDecision = { action: "idle" } | { action: "run" } | { action: "wait"; ms: number }; | |
| 234 | + | /** | |
| 235 | + | * `hold`: nothing to do until the tab comes back into view, which asks | |
| 236 | + | * again; no timer runs meanwhile. | |
| 237 | + | */ | |
| 238 | + | export type GateDecision = { action: "idle" } | { action: "run" } | { action: "hold" } | { action: "wait"; ms: number }; | |
| 200 | 239 | ||
| 201 | 240 | /** | |
| 202 | 241 | * Whether to refresh now for the changes waiting, or how long to wait | |
| 203 | 242 | * before asking again. A second after the last change (at most five after | |
| 204 | − | * the first), never under someone typing or with a dialog open, and at | |
| 205 | − | * most once a minute for a tab out of view, which refreshes as soon as it | |
| 206 | − | * comes back instead. | |
| 243 | + | * the first), at most every two seconds, never under someone typing or | |
| 244 | + | * with a dialog open, and never for a tab out of view: what waited is | |
| 245 | + | * fetched once, as soon as it comes back. | |
| 207 | 246 | */ | |
| 208 | 247 | export function gate(state: GateState, input: GateInput): GateDecision { | |
| 209 | 248 | if (state.pendingSince == null) return { action: "idle" }; | |
| 249 | + | if (!input.visible) return { action: "hold" }; | |
| 210 | 250 | if (input.busy || input.navigating) return { action: "wait", ms: LIVE_HOLD_CHECK_MS }; | |
| 211 | − | if (!input.visible && state.lastRunAt != null && input.now - state.lastRunAt < HIDDEN_MIN_MS) { | |
| 212 | − | return { action: "wait", ms: HIDDEN_MIN_MS - (input.now - state.lastRunAt) }; | |
| 213 | − | } | |
| 214 | 251 | const quiet = input.now - (state.lastChangeAt ?? state.pendingSince); | |
| 215 | 252 | const waited = input.now - state.pendingSince; | |
| 216 | − | if (quiet < LIVE_DEBOUNCE_MS && waited < LIVE_MAX_WAIT_MS) { | |
| 217 | − | return { action: "wait", ms: Math.min(LIVE_DEBOUNCE_MS - quiet, LIVE_MAX_WAIT_MS - waited) }; | |
| 218 | − | } | |
| 219 | − | return { action: "run" }; | |
| 253 | + | const since = state.lastRunAt == null ? Infinity : input.now - state.lastRunAt; | |
| 254 | + | const ms = Math.max(quiet < LIVE_DEBOUNCE_MS && waited < LIVE_MAX_WAIT_MS ? Math.min(LIVE_DEBOUNCE_MS - quiet, LIVE_MAX_WAIT_MS - waited) : 0, LIVE_MIN_GAP_MS - since); | |
| 255 | + | return ms > 0 ? { action: "wait", ms } : { action: "run" }; | |
| 220 | 256 | } | |
| 221 | 257 | ||
| 222 | 258 | /** | |
| 6 | 6 | * Signed in, the tab watches the repository over its live socket: the | |
| 7 | 7 | * site checks the person may read it (`/-/notify`, `watch_repo`), then | |
| 8 | 8 | * every change there arrives as it happens, and the page's loader runs | |
| 9 | − | * again a second after the last matching one. Never under someone typing | |
| 10 | − | * on the page or with a dialog open (it waits until they are done), and | |
| 11 | − | * at most once a minute for a tab out of view. Signed out, or while the | |
| 9 | + | * again a second after the last matching one, at most every two seconds. | |
| 10 | + | * Never under someone typing on the page or with a dialog open (it waits | |
| 11 | + | * until they are done), and never for a tab out of view: it catches up | |
| 12 | + | * once when the tab comes back. Signed out, or while the | |
| 12 | 13 | * socket is down, the page refreshes when the tab comes back into view | |
| 13 | 14 | * (at most every 30 s) and once a minute while it is in view. | |
| 14 | 15 | * | |
| ⋯ | |||
| 147 | 148 | const decision = gate(state.current, { now: Date.now(), visible: visibleNow(), busy: busyNow(), navigating: latest.current.navigating }); | |
| 148 | 149 | if (decision.action === "run") run(); | |
| 149 | 150 | else if (decision.action === "wait") timer.current = setTimeout(check, decision.ms); | |
| 151 | + | // "hold": out of view; coming back into view (`back` below) asks again. | |
| 150 | 152 | }, [run]); | |
| 151 | 153 | ||
| 152 | 154 | /** Something to refresh for: `now` skips the second's wait for more. */ | |
| 41 | 41 | ||
| 42 | 42 | export default function Code({ loaderData }: Route.ComponentProps) { | |
| 43 | 43 | const project = useProject(); | |
| 44 | − | // The files and latest commit follow pushes; checks update as they report. | |
| 45 | − | useRepoLive({ match: repoPages.files() }); | |
| 44 | + | // The files and latest commit follow pushes to the branch; the latest | |
| 45 | + | // commit's checks update as they report. | |
| 46 | + | const { tree, branches } = loaderData; | |
| 47 | + | useRepoLive({ match: repoPages.files({ branch: tree.ref, branches: branches?.map((branch) => branch.name), sha: tree.head?.hash }) }); | |
| 46 | 48 | return ( | |
| 47 | 49 | <TreeView | |
| 48 | 50 | tree={loaderData.tree} |
| 64 | 64 | // moving the list under the reader; checks on the shown ones update. | |
| 65 | 65 | const branch = useProject()?.repo.defaultBranch ?? null; | |
| 66 | 66 | const pushed = (change: Parameters<typeof isPushTo>[0]) => change.kind === "git.push" && isPushTo(change, branch); | |
| 67 | − | const { held, show } = useRepoLive({ match: repoPages.commits(branch), hold: commits.length > 0 ? pushed : undefined }); | |
| 67 | + | const { held, show } = useRepoLive({ match: repoPages.commits(branch, commits.map((commit) => commit.hash)), hold: commits.length > 0 ? pushed : undefined }); | |
| 68 | 68 | if (commits.length === 0) { | |
| 69 | 69 | return <EmptyState title="No commits yet" />; | |
| 70 | 70 | } |
| 819 | 819 | const elsewhereUrl = loaderData.deployment?.url ?? project?.productionUrl ?? null; | |
| 820 | 820 | const working = claims.length + sessions.length > 0; | |
| 821 | 821 | // Deployments, checks, releases, claims and sessions show as they happen. | |
| 822 | − | useRepoLive({ match: repoPages.overview() }); | |
| 822 | + | useRepoLive({ match: repoPages.overview(loaderData.commit?.hash) }); | |
| 823 | 823 | // Claims lapse and sessions go idle with no event to say so. | |
| 824 | 824 | useRefreshWhile(working, 15_000); | |
| 825 | 825 | const live = liveByIssue(claims, sessions); |
| 45 | 45 | ||
| 46 | 46 | export default function Tree({ loaderData }: Route.ComponentProps) { | |
| 47 | 47 | const project = useProject(); | |
| 48 | − | // The files and latest commit follow pushes; checks update as they report. | |
| 49 | − | useRepoLive({ match: repoPages.files() }); | |
| 48 | + | // The files and latest commit follow pushes to the branch (none for a | |
| 49 | + | // tag or a commit); the latest commit's checks update as they report. | |
| 50 | + | const { tree, branches } = loaderData; | |
| 51 | + | const names = branches?.map((branch) => branch.name); | |
| 52 | + | useRepoLive({ match: repoPages.files({ branch: !names || names.includes(tree.ref) ? tree.ref : null, branches: names, sha: tree.head?.hash }) }); | |
| 50 | 53 | return ( | |
| 51 | 54 | <TreeView | |
| 52 | 55 | tree={loaderData.tree} |