Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"__sentMessages",
"__broadcast",
"__failRefresh",
"__revalidating",
"__tabOpen",
"__DEV_RELOAD_ORIGIN__"
]
Expand Down
369 changes: 206 additions & 163 deletions README.md

Large diffs are not rendered by default.

24 changes: 23 additions & 1 deletion src/components/sidebar-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand All @@ -49,6 +56,7 @@ export function SidebarHeader({
queries,
activeQuery,
isFetching,
isRefreshing,
canRefresh,
onSelectQuery,
onManageQueries,
Expand All @@ -66,8 +74,9 @@ export function SidebarHeader({
return (
<header
onPointerDown={docked ? undefined : onPointerDown}
aria-busy={isRefreshing}
className={cn(
'flex h-11 shrink-0 items-center gap-1 border-b border-border px-2',
'relative flex h-11 shrink-0 items-center gap-1 border-b border-border px-2',
docked || windowState.locked
? 'cursor-default'
: 'cursor-grab active:cursor-grabbing',
Expand Down Expand Up @@ -197,6 +206,19 @@ export function SidebarHeader({
</Button>
</Hint>
</div>

{/*
* 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 && (
<IndeterminateBar
label="Refreshing results"
className="absolute inset-x-0 -bottom-px h-0.5"
/>
)}
</header>
)
}
20 changes: 16 additions & 4 deletions src/components/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -235,6 +244,7 @@ export function Sidebar() {
queries={savedQueries}
activeQuery={activeQuery}
isFetching={search.isFetching}
isRefreshing={isRefreshing}
canRefresh={hasToken}
onSelectQuery={selectQuery}
onManageQueries={() => setEditing(true)}
Expand Down Expand Up @@ -289,14 +299,16 @@ export function Sidebar() {
<span
className={cn(
'size-1.5 rounded-full',
search.isFetching ? 'animate-pulse bg-open' : 'bg-border',
isRefreshing ? 'animate-pulse bg-open' : 'bg-border',
)}
aria-hidden
/>
)}
{lastFetchedAt
? `updated ${relativeTime(new Date(lastFetchedAt).toISOString())}`
: 'idle'}
{isRefreshing
? 'updating…'
: lastFetchedAt
? `updated ${relativeTime(new Date(lastFetchedAt).toISOString())}`
: 'idle'}
</span>
</footer>
)}
Expand Down
41 changes: 41 additions & 0 deletions src/components/ui/progress-bar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
role="progressbar"
aria-label={label}
className={cn(
'pointer-events-none overflow-hidden bg-open/85 animate-progress-pulse',
className,
)}
>
{/*
* 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.
*/}
<div className="h-full w-1/3 bg-linear-to-r from-transparent via-white/55 to-transparent animate-progress-sweep" />
</div>
)
}
46 changes: 46 additions & 0 deletions src/hooks/use-refresh-activity.ts
Original file line number Diff line number Diff line change
@@ -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
}
48 changes: 48 additions & 0 deletions src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
99 changes: 98 additions & 1 deletion tests/content.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ window.chrome = {
hasNextPage: true,
fetchedAt: Date.now(),
source: 'cache',
revalidating: false,
revalidating: window.__revalidating === true,
},
};
}
Expand Down Expand Up @@ -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(
'<!doctype html><html data-color-mode="light"><body></body></html>',
)
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)
})
})