diff --git a/.oxlintrc.json b/.oxlintrc.json index 00985a4..668417e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -22,6 +22,7 @@ "__sentMessages", "__broadcast", "__failRefresh", + "__revalidating", "__tabOpen", "__DEV_RELOAD_ORIGIN__" ] diff --git a/README.md b/README.md index ab0eba9..5970f47 100644 --- a/README.md +++ b/README.md @@ -6,133 +6,51 @@ about. ## Features -- **There when you want it.** A new tab starts closed behind a launcher button - and costs nothing until you open it. Once open it is a floating window that - can be dragged, resized from any edge or corner, locked in place, or collapsed - to its header. -- **Or docked into the page.** One click drops the window into the empty gutter - github.com leaves to the left of its content, hanging below the site header - so it reads as part of the page rather than on top of it. On a viewport with - no gutter to spare, the page body is moved across to make room instead of - being covered, while the header goes on spanning the window. Collapse it to a - rail down the edge when you want the room back — it keeps counting. -- **Saved queries.** Any number of named GitHub search queries, switchable from - the header menu, in the same advanced syntax github.com's own search uses — - `AND`, `OR`, and parentheses included. -- **Live status.** Open/closed/merged/draft state, queued-to-merge, CI check - rollup, and review decision for every row, refreshed on a configurable - interval. Right-click any row to refresh just that one on demand. -- **Cached across tabs.** Results are stored in IndexedDB in the service worker - and shared by every github.com tab, so opening tabs costs no API calls. -- **Fast lists.** Cursor-paginated infinite scroll with windowed virtualisation, - so only the visible rows are ever in the DOM. -- **Opens in a new window.** Clicking a row pops the item out; ⌘/Ctrl-click opens - a tab instead. +
+There when you want it — a new tab starts closed and costs nothing until you open it. -## Install - -**Prerequisites** +Once open it is a floating window that can be dragged, resized from any edge or +corner, locked in place, or collapsed to its header. -- **Node.js 22.18+** (or 23.6+). The build and dev scripts are TypeScript files - run directly by Node, so they need a version with type stripping enabled by - default. Check with `node -v`. -- **npm 10+** (ships with the Node versions above). -- **Google Chrome** (or another Chromium browser) to load the extension. Chrome - is also required for the browser test suite. +#### Open state is per tab -**Build it** +Whether the panel is showing belongs to the tab, not the user, so it is the one +piece of state that is not in `chrome.storage.local`. Sharing it would mean +opening the panel once opened it in every tab from then on, which is the +opposite of a new tab starting out of the way. -```bash -git clone https://github.com/tmelliottjr/github-sidecar.git -cd github-sidecar -npm install -npm run build -``` +Instead the service worker keeps a flag in `chrome.storage.session` against the +tab's id. That gives each tab its own answer, survives reloads and navigation +within a tab, is cleared when the browser closes, and — unlike `sessionStorage` +— writes nothing to github.com's own storage. Tab ids get reused, so the flag is +dropped on `tabs.onRemoved`; otherwise the next tab to take that id would +inherit a panel nobody opened. -`npm run build` writes the unpacked extension to `dist/`. Neither `dist/` nor -`node_modules/` is committed, so a fresh clone must be built before it can be -loaded. +Out of the way is not the same as gone, though, so a tab that has not been +asked still leaves something to ask with: floating, a launcher in the corner; +docked, the rail, exactly where the panel would have been. Neither costs a +request — a rail is not an open panel — but neither sends anyone to the +browser's extensions menu to find a panel that is meant to be part of the page. -Then load it in Chrome: +Opening a panel for the first time does not mean going to the network. The +request goes to the worker like any other, which answers from its shared +IndexedDB cache and only refreshes if what it has has aged out. -1. Visit `chrome://extensions` and enable **Developer mode**. -2. Choose **Load unpacked** and select the `dist/` directory. -3. The options page opens on first install — paste a GitHub personal access - token and click **Verify**. +Everything else about the window — size, position, docked, collapsed, locked — +stays shared, because those are preferences rather than per-tab facts. -A classic token needs `repo` and `read:org` scope to see private repositories. -The token is stored in `chrome.storage.local` and is only ever sent to -`api.github.com`. +
-## Usage +
+Or docked into the page — one click drops it into the gutter github.com leaves empty, so nothing is covered. -| Action | How | -| --------------------- | ----------------------------------------------- | -| Move the window | Drag the empty strip in the header | -| Resize | Drag any edge or corner | -| Dock / undock | Panel button in the header | -| Resize while docked | Drag the panel's inner edge | -| Lock / unlock | Padlock button in the header | -| Collapse / expand | Chevron button in the header | -| Collapse a dock | Panel button in the header, then click the rail | -| Show / hide this tab | The corner launcher or rail, the ✕ button, or the toolbar icon | -| Switch queries | Click the query name in the header | -| Edit queries | Query menu → **Manage queries** | -| Open an item | Click a row (⌘/Ctrl-click for a tab) | -| Refresh one row | Right-click it → **Refresh this item** | -| Pin a row to the top | Right-click it → **Pin item** (again to unpin) | -| See a stack | Chevron on a stacked row, or right-click → **Show the stack** | +It hangs below the site header so it reads as part of the page rather than on +top of it. On a viewport with no gutter to spare, the page body is moved across +to make room instead of being covered, while the header goes on spanning the +window. Collapse it to a rail down the edge when you want the room back — it +keeps counting. -## Architecture - -``` -src/ - background/ Service worker: GitHub API proxy, IndexedDB cache, dev reload - content/ Shadow-DOM mount, font injection, colour-mode sync, page metrics - components/ Window chrome, list, rows, and shadcn-style primitives - hooks/ Storage sync, window geometry, dock layout, search, cache updates - lib/ GitHub GraphQL client, message protocol, storage schema - options/ Settings page -scripts/ Font copying, dev watch server -scripts/ Font copying, dev watch server -``` - -A few decisions worth knowing: - -- **All GitHub requests go through the background worker.** github.com's - Content-Security-Policy applies to `fetch` from a content script, so calls - made there would be blocked. The worker also keeps the token out of page - context. -- **One GraphQL search per refresh.** The REST search API returns no CI or - review data, which would mean an extra request per row. A single - `search(type: ISSUE_ADVANCED)` GraphQL query returns state, - `statusCheckRollup`, and `reviewDecision` for every result, so polling costs - one request. -- **`ISSUE_ADVANCED`, not `ISSUE`.** `ISSUE` is still the legacy query parser, - which does not understand advanced syntax and does not complain about it — it - matches nothing instead, so `(label:a OR label:b)` comes back empty rather - than failing. The two agree on everything the legacy parser did understand, - with one exception worth knowing: a space between `repo:`, `org:`, or `user:` - qualifiers now means AND where it used to mean OR, so a saved query like - `repo:acme/api repo:acme/web` has to be written - `repo:acme/api OR repo:acme/web`. -- **The UI lives in a shadow root** with its stylesheet adopted at runtime, so - GitHub's CSS cannot reach it and vice versa. Because the shadow host carries - `all: initial`, base typography is applied to the container inside the shadow - tree, where it wins over that inline style. -- **Geometry is written straight to the DOM** during a drag and only committed - to storage on release, so dragging never re-renders the list. The window's - `style` prop is frozen at mount for the same reason: recomputing it would let - an unrelated re-render snap the window back mid-gesture. -- **`@property` does not work inside a shadow root.** Tailwind registers its - internal custom properties that way, so in the shadow tree they have no - initial value and every declaration built on one — `box-shadow`, - `border-style`, the transform stack — computes to nothing. Tailwind emits the - same values as plain declarations behind an `@supports` test for engines - without `@property` at all, which Chrome fails, so the stylesheet is rewritten - at mount to drop that guard. Without it the panel has no border and no shadow. - -## Docked mode +#### Finding the gutter github.com centres its pages in a max-width column, which on a wide display leaves a wide empty gutter down the left. Docked mode puts the panel there, so @@ -160,6 +78,8 @@ selector would break the first time github.com reorganised its markup, so flush at the top. A bar also only has to be as wide as the page column, since the mini header is not full-bleed. +#### Making room when there is none + When the gutter is too narrow, the page is moved across by padding `` — the one element Turbo navigation never replaces, so the reservation survives navigation without being reapplied. The full width is reserved rather than just @@ -177,34 +97,45 @@ inset. A header stopping short of the window is the one thing that would give away that the page has been moved at all, and it is the part of github.com a reader is most likely to know by sight. -## Open state is per tab +#### Collapsing a dock -Whether the panel is showing belongs to the tab, not the user, so it is the one -piece of state that is not in `chrome.storage.local`. Sharing it would mean -opening the panel once opened it in every tab from then on, which is the -opposite of a new tab starting out of the way. +A floating window collapses up into its own header. A docked one has nowhere to +fold, so it collapses sideways into a rail down the edge of the gutter instead. -Instead the service worker keeps a flag in `chrome.storage.session` against the -tab's id. That gives each tab its own answer, survives reloads and navigation -within a tab, is cleared when the browser closes, and — unlike `sessionStorage` -— writes nothing to github.com's own storage. Tab ids get reused, so the flag is -dropped on `tabs.onRemoved`; otherwise the next tab to take that id would -inherit a panel nobody opened. +The rail is deliberately not a hidden edge: something has to stay on screen or +there is nothing left to click to bring the panel back. At 44px it fits in the +gutter of any page, so collapsing hands back whatever room had been taken from +the layout, and the whole height of it is one button. It keeps showing the +result count, which is the reason to leave it on screen rather than hide it. -Out of the way is not the same as gone, though, so a tab that has not been -asked still leaves something to ask with: floating, a launcher in the corner; -docked, the rail, exactly where the panel would have been. Neither costs a -request — a rail is not an open panel — but neither sends anyone to the -browser's extensions menu to find a panel that is meant to be part of the page. +The same rail stands in for a docked panel that is hidden in this tab, or that +has never been opened in it. Two states, one mark, and a click clears both at +once — expanding out of a rail always ends with the panel, never with the rail +redrawn because the other flag was still set. -Opening a panel for the first time does not mean going to the network. The -request goes to the worker like any other, which answers from its shared -IndexedDB cache and only refreshes if what it has has aged out. +Results therefore go on loading while a dock is collapsed — a count that stops +updating is decoration, not status. That is the one place the two collapsed +states differ: a collapsed floating window shows nothing, so it stops asking. +A rail standing in for a hidden panel stops asking too; it is showing that the +panel is there, not what is in it. -Everything else about the window — size, position, docked, collapsed, locked — -stays shared, because those are preferences rather than per-tab facts. +
+ +
+Saved queries — any number of named GitHub searches, switchable from the header menu. + +Written in the same advanced syntax github.com's own search uses — `AND`, `OR`, +and parentheses included. -## Row states +
+ +
+Live status — state, merge queue, CI rollup, and review decision on every row. + +Refreshed on a configurable interval. Right-click any row to refresh just that +one on demand. + +#### Row states The leading mark carries both what a row is and where it stands: @@ -223,7 +154,7 @@ A queued pull request is still open, so GitHub reports it as `OPEN` with the reader any more — it is on its way in — which is worth telling apart at a glance from an open pull request that is still waiting on someone. -## Refreshing a single row +#### Refreshing a single row Polling refreshes a whole query on an interval. That is the wrong tool for watching one pull request's checks go green, so a row's context menu re-reads @@ -241,7 +172,10 @@ fragments. A row that arrives on its own has to be indistinguishable from the same row arriving through a search, or refreshing one would quietly drop a badge. -## Stacked pull requests +
+ +
+Stacked pull requests — a layer/size badge and the whole chain from its own chevron. A stack is a chain of pull requests where each one targets the branch of the one below it. GitHub exposes membership directly: `PullRequest.stackEntry` @@ -264,36 +198,20 @@ Only the first 20 layers of a stack are read per row, but `size` comes from GitHub, so a deeper stack still reports its real size and says how many layers it is not showing. -### Pinned rows +
+ +
+Pinned rows — lift the rows you are watching to the top, whichever query they turn up in. Pins are stored as node ids under `pinnedIds`, apart from the queries that surface them, so a pinned row keeps its place whichever query it turns up in. Only rows already loaded can be lifted, so a pin on an item further down a result set surfaces once its page arrives. -### Collapsing a dock - -A floating window collapses up into its own header. A docked one has nowhere to -fold, so it collapses sideways into a rail down the edge of the gutter instead. - -The rail is deliberately not a hidden edge: something has to stay on screen or -there is nothing left to click to bring the panel back. At 44px it fits in the -gutter of any page, so collapsing hands back whatever room had been taken from -the layout, and the whole height of it is one button. It keeps showing the -result count, which is the reason to leave it on screen rather than hide it. - -The same rail stands in for a docked panel that is hidden in this tab, or that -has never been opened in it. Two states, one mark, and a click clears both at -once — expanding out of a rail always ends with the panel, never with the rail -redrawn because the other flag was still set. +
-Results therefore go on loading while a dock is collapsed — a count that stops -updating is decoration, not status. That is the one place the two collapsed -states differ: a collapsed floating window shows nothing, so it stops asking. -A rail standing in for a hidden panel stops asking too; it is showing that the -panel is there, not what is in it. - -## Caching +
+Cached across tabs — one shared IndexedDB cache in the service worker, so opening tabs costs no API calls. Every github.com tab runs its own copy of the UI, so a naive setup would make one API call per tab. Instead the service worker owns a single IndexedDB cache @@ -325,6 +243,13 @@ The pieces that make that work: - **Refreshes are pushed, not pulled.** When the active tab triggers a refresh, the worker broadcasts the result to every open tab, which update their caches without making their own request. +- **A background refresh says so.** Serving the cached copy resolves the tab's + own request immediately, so the network call that follows it happens with + nothing in flight locally to report. The page carries a `revalidating` flag + instead, and a green hairline along the header's bottom border pulses — with + a lighter crest running left to right across it — until the broadcast lands. + It is held briefly so a fast refresh does not flicker, and capped so a + refresh that failed cannot leave the panel looking busy forever. - **The refresh window follows your setting.** The refresh interval you choose is exactly how long a cached page is served for, with a 15s floor so a short interval cannot flood the API. With polling off, cached pages last 5 minutes. @@ -332,6 +257,124 @@ The pieces that make that work: first, so it is a true forced refresh. Entries older than a day are pruned on startup. +
+ +
+Fast lists — cursor-paginated infinite scroll with windowed virtualisation. + +Only the visible rows are ever in the DOM. + +
+ +
+Opens in a new window — clicking a row pops the item out. + +⌘/Ctrl-click opens a tab instead. + +
+ +## Install + +**Prerequisites** + +- **Node.js 22.18+** (or 23.6+). The build and dev scripts are TypeScript files + run directly by Node, so they need a version with type stripping enabled by + default. Check with `node -v`. +- **npm 10+** (ships with the Node versions above). +- **Google Chrome** (or another Chromium browser) to load the extension. Chrome + is also required for the browser test suite. + +**Build it** + +```bash +git clone https://github.com/tmelliottjr/github-sidecar.git +cd github-sidecar +npm install +npm run build +``` + +`npm run build` writes the unpacked extension to `dist/`. Neither `dist/` nor +`node_modules/` is committed, so a fresh clone must be built before it can be +loaded. + +Then load it in Chrome: + +1. Visit `chrome://extensions` and enable **Developer mode**. +2. Choose **Load unpacked** and select the `dist/` directory. +3. The options page opens on first install — paste a GitHub personal access + token and click **Verify**. + +A classic token needs `repo` and `read:org` scope to see private repositories. +The token is stored in `chrome.storage.local` and is only ever sent to +`api.github.com`. + +## Usage + +| Action | How | +| --------------------- | ----------------------------------------------- | +| Move the window | Drag the empty strip in the header | +| Resize | Drag any edge or corner | +| Dock / undock | Panel button in the header | +| Resize while docked | Drag the panel's inner edge | +| Lock / unlock | Padlock button in the header | +| Collapse / expand | Chevron button in the header | +| Collapse a dock | Panel button in the header, then click the rail | +| Show / hide this tab | The corner launcher or rail, the ✕ button, or the toolbar icon | +| Switch queries | Click the query name in the header | +| Edit queries | Query menu → **Manage queries** | +| Open an item | Click a row (⌘/Ctrl-click for a tab) | +| Refresh one row | Right-click it → **Refresh this item** | +| Pin a row to the top | Right-click it → **Pin item** (again to unpin) | +| See a stack | Chevron on a stacked row, or right-click → **Show the stack** | + +## Architecture + +``` +src/ + background/ Service worker: GitHub API proxy, IndexedDB cache, dev reload + content/ Shadow-DOM mount, font injection, colour-mode sync, page metrics + components/ Window chrome, list, rows, and shadcn-style primitives + hooks/ Storage sync, window geometry, dock layout, search, cache updates + lib/ GitHub GraphQL client, message protocol, storage schema + options/ Settings page +scripts/ Font copying, dev watch server +``` + +A few decisions worth knowing: + +- **All GitHub requests go through the background worker.** github.com's + Content-Security-Policy applies to `fetch` from a content script, so calls + made there would be blocked. The worker also keeps the token out of page + context. +- **One GraphQL search per refresh.** The REST search API returns no CI or + review data, which would mean an extra request per row. A single + `search(type: ISSUE_ADVANCED)` GraphQL query returns state, + `statusCheckRollup`, and `reviewDecision` for every result, so polling costs + one request. +- **`ISSUE_ADVANCED`, not `ISSUE`.** `ISSUE` is still the legacy query parser, + which does not understand advanced syntax and does not complain about it — it + matches nothing instead, so `(label:a OR label:b)` comes back empty rather + than failing. The two agree on everything the legacy parser did understand, + with one exception worth knowing: a space between `repo:`, `org:`, or `user:` + qualifiers now means AND where it used to mean OR, so a saved query like + `repo:acme/api repo:acme/web` has to be written + `repo:acme/api OR repo:acme/web`. +- **The UI lives in a shadow root** with its stylesheet adopted at runtime, so + GitHub's CSS cannot reach it and vice versa. Because the shadow host carries + `all: initial`, base typography is applied to the container inside the shadow + tree, where it wins over that inline style. +- **Geometry is written straight to the DOM** during a drag and only committed + to storage on release, so dragging never re-renders the list. The window's + `style` prop is frozen at mount for the same reason: recomputing it would let + an unrelated re-render snap the window back mid-gesture. +- **`@property` does not work inside a shadow root.** Tailwind registers its + internal custom properties that way, so in the shadow tree they have no + initial value and every declaration built on one — `box-shadow`, + `border-style`, the transform stack — computes to nothing. Tailwind emits the + same values as plain declarations behind an `@supports` test for engines + without `@property` at all, which Chrome fails, so the stylesheet is rewritten + at mount to drop that guard. Without it the panel has no border and no shadow. + ## Development ```bash diff --git a/src/components/sidebar-header.tsx b/src/components/sidebar-header.tsx index 23821fc..a2a125f 100644 --- a/src/components/sidebar-header.tsx +++ b/src/components/sidebar-header.tsx @@ -24,6 +24,7 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Hint } from '@/components/ui/tooltip' +import { IndeterminateBar } from '@/components/ui/progress-bar' import { sendMessage } from '@/lib/messages' import type { SavedQuery, WindowState } from '@/lib/storage' import { cn } from '@/lib/utils' @@ -34,6 +35,12 @@ interface Props { queries: SavedQuery[] activeQuery: SavedQuery | null isFetching: boolean + /** + * Any refresh at all, including the ones the worker runs on its own after + * answering from cache. Broader than `isFetching`, which only covers this + * tab's own request. + */ + isRefreshing: boolean canRefresh: boolean onSelectQuery: (id: string) => void onManageQueries: () => void @@ -49,6 +56,7 @@ export function SidebarHeader({ queries, activeQuery, isFetching, + isRefreshing, canRefresh, onSelectQuery, onManageQueries, @@ -66,8 +74,9 @@ export function SidebarHeader({ return (
+ + {/* + * Laid over the header's bottom border rather than added below it, so + * appearing and disappearing never shifts the list by a pixel. This is + * the only thing that reports a refresh the worker started on its own, + * where the request never passes through this tab at all. + */} + {isRefreshing && ( + + )}
) } diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index ab18eee..aca092b 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -17,6 +17,7 @@ import { Hint } from '@/components/ui/tooltip' import { useDockLayout } from '@/hooks/use-dock-layout' import { useDocumentVisible } from '@/hooks/use-document-visible' import { useIssueSearch } from '@/hooks/use-issue-search' +import { useRefreshActivity } from '@/hooks/use-refresh-activity' import { useSearchUpdates } from '@/hooks/use-search-updates' import { useStorageValue } from '@/hooks/use-storage-value' import { useTabOpen } from '@/hooks/use-tab-open' @@ -107,6 +108,14 @@ export function Sidebar() { enabled: hasToken && isVisible && !(isCollapsed && !isDocked) && !editing && isTabVisible, }) + // The worker answers from its cache straight away and only then goes to the + // network, so this tab's request has already resolved while the refresh it + // set off is still running. `isFetching` is false for that whole window, + // which is exactly the window worth reporting; the pages themselves say so + // instead, and stop saying so when the result is broadcast back. + const isRevalidating = search.data?.pages.some((page) => page.revalidating) ?? false + const isRefreshing = useRefreshActivity(search.isFetching || isRevalidating) + // Pinned rows are lifted to the top in the order they were pinned. Only the // pages already loaded can be reordered, so a pin on a row that has not been // fetched yet surfaces once its page arrives. @@ -235,6 +244,7 @@ export function Sidebar() { queries={savedQueries} activeQuery={activeQuery} isFetching={search.isFetching} + isRefreshing={isRefreshing} canRefresh={hasToken} onSelectQuery={selectQuery} onManageQueries={() => setEditing(true)} @@ -289,14 +299,16 @@ export function Sidebar() { )} - {lastFetchedAt - ? `updated ${relativeTime(new Date(lastFetchedAt).toISOString())}` - : 'idle'} + {isRefreshing + ? 'updating…' + : lastFetchedAt + ? `updated ${relativeTime(new Date(lastFetchedAt).toISOString())}` + : 'idle'} )} diff --git a/src/components/ui/progress-bar.tsx b/src/components/ui/progress-bar.tsx new file mode 100644 index 0000000..83be890 --- /dev/null +++ b/src/components/ui/progress-bar.tsx @@ -0,0 +1,41 @@ +import { cn } from '@/lib/utils' + +interface Props { + /** Announced to assistive tech, which gets no benefit from the animation. */ + label: string + className?: string +} + +/** + * A hairline bar for work whose progress cannot be measured — a poll, a + * revalidation — where the only honest thing to report is that it is happening + * at all. + * + * The whole width is lit rather than a lone travelling segment, so the bar + * reads as one continuous state that is running rather than as an object + * crossing the header. It breathes to say it is live, and a brighter crest + * runs left to right across it to give that breathing a direction. + * + * It carries no size of its own, so it can be laid over an edge that is + * already there rather than claiming a strip of its own and pushing the + * content below it around every time the work starts and stops. + */ +export function IndeterminateBar({ label, className }: Props) { + return ( +
+ {/* + * The crest lightens the track rather than being another shade of green + * on top of it. Over a solid green line that reads as a highlight in + * both colour modes, where a second green only reads as a seam. + */} +
+
+ ) +} diff --git a/src/hooks/use-refresh-activity.ts b/src/hooks/use-refresh-activity.ts new file mode 100644 index 0000000..e4706af --- /dev/null +++ b/src/hooks/use-refresh-activity.ts @@ -0,0 +1,46 @@ +import { useEffect, useRef, useState } from 'react' + +/** + * How long the indicator stays up once raised. A refresh answered from a warm + * cache can be over in tens of milliseconds, and a bar that appears and + * vanishes inside one frame reads as a glitch rather than as progress. + */ +const MIN_VISIBLE_MS = 400 + +/** + * A revalidation that fails is only logged in the worker — no `search-updated` + * broadcast is ever sent, so the page it was refreshing keeps its + * `revalidating` flag for as long as it stays cached. Without a ceiling the + * indicator would simply never come down again. + */ +const MAX_VISIBLE_MS = 20_000 + +/** + * Smooths a raw "something is in flight" signal into one that is worth showing + * a user: never so brief that it flickers, never so long that a refresh which + * quietly died leaves the panel claiming to be busy forever. + */ +export function useRefreshActivity(active: boolean): boolean { + const [visible, setVisible] = useState(false) + const raisedAt = useRef(0) + + useEffect(() => { + if (active) { + raisedAt.current = Date.now() + setVisible(true) + const ceiling = setTimeout(() => setVisible(false), MAX_VISIBLE_MS) + return () => clearTimeout(ceiling) + } + + const remaining = MIN_VISIBLE_MS - (Date.now() - raisedAt.current) + if (remaining <= 0) { + setVisible(false) + return + } + + const hold = setTimeout(() => setVisible(false), remaining) + return () => clearTimeout(hold) + }, [active]) + + return visible +} diff --git a/src/styles/app.css b/src/styles/app.css index ac9054e..bd6decd 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -38,6 +38,14 @@ */ --animate-spin-slow: spin-slow 3s linear infinite; + /* + * A poll has no measurable progress to report, so the bar breathes and a + * crest crosses it rather than either of them filling. Both run on the same + * period so the brightest point of the pulse always lands mid-crossing. + */ + --animate-progress-sweep: progress-sweep 1.6s ease-in-out infinite; + --animate-progress-pulse: progress-pulse 1.6s ease-in-out infinite; + --shadow-window: 0 0 0 1px oklch(0 0 0 / 0.06), 0 12px 32px -8px oklch(0 0 0 / 0.18), 0 4px 12px -4px oklch(0 0 0 / 0.1); @@ -89,6 +97,46 @@ } } +/* + * The crest is a third of its track, so it has to travel rather further than + * the track's own width to leave at one end having entered at the other. + */ +@keyframes progress-sweep { + from { + transform: translateX(-100%); + } + to { + transform: translateX(300%); + } +} + +/* Never far down: on a 2px line the pulse floor multiplies the track's own + * alpha, and anything lower than this fades out rather than breathes. */ +@keyframes progress-pulse { + 0%, + 100% { + opacity: 0.65; + } + 50% { + opacity: 1; + } +} + +/* + * An animation that loops until some network call comes back is precisely what + * this preference is asking us not to run. Unlayered, so it outranks the + * utilities it overrides. A still bar still says "busy" by being there. + */ +@media (prefers-reduced-motion: reduce) { + .animate-progress-pulse { + animation: none; + opacity: 0.9; + } + .animate-progress-sweep { + display: none; + } +} + @layer base { /* * The shadow host carries `all: initial` (an inline style) to block inherited diff --git a/tests/content.browser.test.ts b/tests/content.browser.test.ts index 649aff1..933271b 100644 --- a/tests/content.browser.test.ts +++ b/tests/content.browser.test.ts @@ -157,7 +157,7 @@ window.chrome = { hasNextPage: true, fetchedAt: Date.now(), source: 'cache', - revalidating: false, + revalidating: window.__revalidating === true, }, }; } @@ -1304,3 +1304,100 @@ describe('a docked tab that was never asked', { concurrency: false, skip }, () = assert.equal(await panelShowing(), true) }) }) + +describe('a refresh the worker started on its own', { concurrency: false, skip }, () => { + let busyBrowser: Browser + let busyPage: Page + + before(async () => { + busyBrowser = await puppeteer.launch({ executablePath, headless: true }) + busyPage = await busyBrowser.newPage() + await busyPage.setViewport({ width: 1280, height: 800 }) + await busyPage.setContent( + '', + ) + await busyPage.evaluate(CHROME_STUB) + // The worker answers from cache and goes to the network behind it, which + // is the state this tab can only learn about from the flag on the page. + await busyPage.evaluate(() => { + ;(window as unknown as { __revalidating: boolean }).__revalidating = true + }) + + const bundle = await readFile(fileURLToPath(new URL('content.js', distRoot)), 'utf8') + await busyPage.evaluate(bundle) + await busyPage.waitForSelector('#github-sidecar-root') + }) + + after(async () => { + await busyBrowser?.close() + }) + + const bar = () => + busyPage.evaluate(() => { + const shadow = document.getElementById('github-sidecar-root')!.shadowRoot! + const node = shadow.querySelector('[role="progressbar"]') + if (!node) return null + const segment = node.firstElementChild as HTMLElement + return { + label: node.getAttribute('aria-label'), + height: node.getBoundingClientRect().height, + // A bar that is in the DOM but not actually animating would report + // this state without ever looking like it. + pulse: getComputedStyle(node).animationName, + sweep: getComputedStyle(segment).animationName, + busyHeader: shadow.querySelector('header')?.getAttribute('aria-busy'), + } + }) + + it('reports itself, even though this tab issued no request', async () => { + await busyPage.waitForFunction(() => { + const shadow = document.getElementById('github-sidecar-root')?.shadowRoot + return (shadow?.querySelectorAll('[data-index]').length ?? 0) > 0 + }) + + const shown = await bar() + assert.ok(shown, 'expected the header to report a background refresh') + assert.equal(shown.label, 'Refreshing results') + assert.equal(shown.pulse, 'progress-pulse') + assert.equal(shown.sweep, 'progress-sweep') + assert.equal(shown.busyHeader, 'true') + assert.ok(shown.height > 0, 'expected the bar to have height') + }) + + it('says so in the footer instead of quoting a stale timestamp', async () => { + const footer = await busyPage.evaluate( + () => + document + .getElementById('github-sidecar-root')! + .shadowRoot!.querySelector('footer')?.textContent ?? '', + ) + assert.match(footer, /updating/) + }) + + it('stands down once the worker broadcasts the result back', async () => { + await busyPage.evaluate(() => { + ;(window as unknown as { __revalidating: boolean }).__revalidating = false + ;(window as unknown as { __broadcast: (m: unknown) => void }).__broadcast({ + type: 'search-updated', + query: 'is:open is:pr', + after: null, + page: { + items: [], + totalCount: 0, + endCursor: null, + hasNextPage: false, + fetchedAt: Date.now(), + }, + }) + }) + + await busyPage.waitForFunction( + () => + !document + .getElementById('github-sidecar-root')! + .shadowRoot!.querySelector('[role="progressbar"]'), + { timeout: 5_000 }, + ) + assert.equal(await bar(), null) + }) +})