diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
new file mode 100644
index 0000000..1d30836
--- /dev/null
+++ b/.github/CODEOWNERS
@@ -0,0 +1 @@
+* @hyanmandian
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..18fd450
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,75 @@
+name: Bug report
+description: Something in Focus Diff doesn't work as described.
+labels: [bug]
+body:
+ - type: markdown
+ attributes:
+ value: Thanks for taking the time. Please leave out anything private, like code from a private repository.
+ - type: textarea
+ id: what
+ attributes:
+ label: What happened?
+ description: What you saw, and what you expected instead.
+ validations:
+ required: true
+ - type: textarea
+ id: steps
+ attributes:
+ label: Steps to reproduce
+ placeholder: |
+ 1. Open a pull request's Files changed tab
+ 2. Pick the "Frontend" filter
+ 3. …
+ validations:
+ required: true
+ - type: input
+ id: pull-request
+ attributes:
+ label: Pull request link
+ description: A public pull request where it happens, if there is one.
+ placeholder: https://github.com/owner/repo/pull/123
+ - type: dropdown
+ id: view
+ attributes:
+ label: GitHub diff view
+ description: GitHub has two. The newer one shows a "Changes" tab with a file tree on the left.
+ options:
+ - Classic (Files changed)
+ - Newer (Changes)
+ - Not sure
+ validations:
+ required: true
+ - type: dropdown
+ id: browser
+ attributes:
+ label: Browser
+ options:
+ - Chrome
+ - Edge
+ - Brave
+ - Arc
+ - Another Chromium browser
+ - Firefox
+ - Safari
+ validations:
+ required: true
+ - type: input
+ id: version
+ attributes:
+ label: Focus Diff version
+ description: It's at the bottom of the Focus Diff settings page.
+ placeholder: '1.0.0'
+ validations:
+ required: true
+ - type: textarea
+ id: extra
+ attributes:
+ label: Screenshots or anything else
+ description: Errors from the browser console help too.
+ - type: checkboxes
+ id: searched
+ attributes:
+ label: Before you send it
+ options:
+ - label: I searched the existing issues and this isn't one of them.
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..92a957f
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Report a security issue
+ url: https://github.com/hyanmandian/focus-diff/security/advisories/new
+ about: Please report vulnerabilities privately, not in a public issue.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..8092ffb
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,23 @@
+name: Idea
+description: Suggest something that would make reviewing easier.
+labels: [enhancement]
+body:
+ - type: textarea
+ id: problem
+ attributes:
+ label: What's slowing your review down?
+ description: The situation and the problem, before any solution.
+ validations:
+ required: true
+ - type: textarea
+ id: idea
+ attributes:
+ label: What would help?
+ description: How you picture it working.
+ validations:
+ required: true
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: What do you do today?
+ description: Workarounds, other tools, or ideas you've ruled out.
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..c2ebe03
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,21 @@
+version: 2
+updates:
+ - package-ecosystem: npm
+ directory: /
+ schedule:
+ interval: monthly
+ groups:
+ dev-dependencies:
+ dependency-type: development
+ commit-message:
+ prefix: chore
+ include: scope
+ - package-ecosystem: github-actions
+ directory: /
+ schedule:
+ interval: monthly
+ groups:
+ actions:
+ patterns: ['*']
+ commit-message:
+ prefix: ci
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..ae778a6
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,13 @@
+
+
+## What and why
+
+
+
+## How it was tested
+
+
+
+- [ ] `npm run check`, `npm test` and `npm run e2e` pass
+- [ ] Tried on a real pull request (say which view: classic or newer)
+- [ ] New text is in both `en.json` and `pt_BR.json`
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b870d1a..82d106a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -24,7 +24,7 @@ jobs:
- run: npx playwright install --with-deps chromium
- run: npm run e2e
- run: npm run lint:firefox
- - run: npm run zip && npm run zip:firefox
+ - run: npm run zip && npm run zip:firefox && npm run zip:safari
- uses: actions/upload-artifact@v4
with:
name: extension
diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml
new file mode 100644
index 0000000..8cadbb0
--- /dev/null
+++ b/.github/workflows/pr-title.yml
@@ -0,0 +1,17 @@
+name: Pull request title
+
+# Release notes and version bumps come from squash-merged pull request titles, so they follow Conventional Commits.
+on:
+ pull_request:
+ types: [opened, edited, synchronize, reopened]
+
+permissions:
+ pull-requests: read
+
+jobs:
+ conventional:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: amannn/action-semantic-pull-request@v5
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ff69f76..1a1ade4 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,14 +1,31 @@
name: Release
+# Every merge to main updates a release pull request with the next version and its changelog, worked out from the
+# conventional pull request titles. Merging that pull request tags the release, attaches the builds and publishes them.
on:
push:
- tags: ['v*']
+ branches: [main]
permissions:
contents: write
+ pull-requests: write
jobs:
- release:
+ release-please:
+ runs-on: ubuntu-latest
+ outputs:
+ created: ${{ steps.release.outputs.release_created }}
+ tag: ${{ steps.release.outputs.tag_name }}
+ steps:
+ - id: release
+ uses: googleapis/release-please-action@v4
+ with:
+ config-file: release-please-config.json
+ manifest-file: .release-please-manifest.json
+
+ publish:
+ needs: release-please
+ if: needs.release-please.outputs.created == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -17,26 +34,25 @@ jobs:
node-version: 22
cache: npm
- run: npm ci
-
- - name: Check the tag matches the package version
- run: |
- version=$(node -p "require('./package.json').version")
- if [ "v$version" != "$GITHUB_REF_NAME" ]; then
- echo "::error::Tag $GITHUB_REF_NAME doesn't match package.json version $version"
- exit 1
- fi
-
- run: npm run check
- run: npm test
- run: npx playwright install --with-deps chromium
- run: npm run e2e
- run: npm run lint:firefox
- - run: npm run zip && npm run zip:firefox
+ - run: npm run zip && npm run zip:firefox && npm run zip:safari
- - name: Create the GitHub release
+ # Fixed names, so the site can link to the latest release's files: one build for every Chromium browser
+ # (Chrome, Edge, Brave, Arc, Opera), one for Firefox and the browsers built on it, one for Safari.
+ - name: Attach the builds to the release
env:
GH_TOKEN: ${{ github.token }}
- run: gh release create "$GITHUB_REF_NAME" .output/*.zip --title "$GITHUB_REF_NAME" --generate-notes
+ run: |
+ mkdir release
+ cp .output/*-chrome.zip release/focus-diff-chromium.zip
+ cp .output/*-firefox.zip release/focus-diff-firefox.zip
+ cp .output/*-safari.zip release/focus-diff-safari.zip
+ cp .output/*-sources.zip release/focus-diff-sources.zip
+ gh release upload "${{ needs.release-please.outputs.tag }}" release/*.zip --clobber
- name: Publish to the stores
env:
diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml
new file mode 100644
index 0000000..edaee4b
--- /dev/null
+++ b/.github/workflows/site.yml
@@ -0,0 +1,31 @@
+name: Site
+
+on:
+ push:
+ branches: [main]
+ paths: ['site/**', '.github/workflows/site.yml']
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: true
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/configure-pages@v5
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: site
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
new file mode 100644
index 0000000..37fcefa
--- /dev/null
+++ b/.release-please-manifest.json
@@ -0,0 +1,3 @@
+{
+ ".": "1.0.0"
+}
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..a30e16a
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,5 @@
+# Code of conduct
+
+Focus Diff follows the [Contributor Covenant, version 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). In short: be kind, assume good intent, and keep discussions about the work.
+
+Report unacceptable behaviour to [contact@hyan.com.br](mailto:contact@hyan.com.br). Every report is reviewed and kept confidential.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..3c6e3bf
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,47 @@
+# Contributing
+
+Thanks for helping make reviews calmer. Bug reports, ideas and pull requests are all welcome; for anything bigger than a small fix, open an [issue](https://github.com/hyanmandian/focus-diff/issues/new/choose) first so we can agree on the approach.
+
+By taking part, you agree to the [code of conduct](CODE_OF_CONDUCT.md).
+
+## Development
+
+Focus Diff is built with [WXT](https://wxt.dev) and TypeScript, and has no runtime dependencies.
+
+```sh
+npm install # also generates WXT's types
+npm run dev # Chrome with the extension loaded and hot reload (dev:firefox for Firefox)
+npm run check # types, Oxlint and Oxfmt
+npm test # unit tests with Vitest
+npm run e2e # builds, then runs Playwright against the real extension, with axe accessibility checks
+npm run fmt # format everything
+npm run zip # store zips in .output/ (zip:firefox also packs the sources for review)
+```
+
+```text
+src/
+ entrypoints/ what the browser loads: background, the GitHub content script, options and welcome pages
+ content/ the content script's logic: reading the page, filtering it, moving between conversations
+ components/ the UI: functions that build elements and return how to update them. A component with
+ its own CSS gets a folder; panel/ lays the others out in a shadow root
+ assets/ global.css: the extension pages' colours, reset and type
+ utils/ filters, storage, the GitHub page adapter, formatting
+ locales/ English and Brazilian Portuguese messages
+tests/
+ unit/ Vitest with WXT's fake browser
+ e2e/ Playwright, on a local copy of a pull request page
+```
+
+The end-to-end tests serve a saved pull request page in place of github.com, so they don't depend on the network.
+
+## Releasing
+
+Pull requests are squash-merged, and their titles follow [Conventional Commits](https://www.conventionalcommits.org) (`feat: …`, `fix: …`), which a check enforces. Every merge to `main` updates a release pull request with the next version and its changelog. Merging it tags the release, attaches the Chrome and Firefox builds, and publishes them to the stores when their credentials are set as repository secrets.
+
+## Pull requests
+
+- Keep each pull request to one change, and its title a [Conventional Commit](https://www.conventionalcommits.org): it becomes the changelog entry.
+- Add or update tests: unit tests for logic, end-to-end tests for anything on the page.
+- Put new text in both `src/locales/en.json` and `src/locales/pt_BR.json`.
+- Prefer selectors GitHub is unlikely to change: roles, ARIA attributes, ids and `data-` attributes, not generated class names.
+- Check the panel in GitHub's light and dark themes, and with the keyboard.
diff --git a/README.md b/README.md
index 4ce0cb9..24037e8 100644
--- a/README.md
+++ b/README.md
@@ -20,9 +20,10 @@ Large pull requests mix the code you need to read with tests, stories, generated
- **Your own filters.** Each filter is a button with an _Include_ regex and an optional _Exclude_ regex, tested against the full file path. **All** is always there.
- **Everywhere or per repository.** Keep filters for every pull request, and add extra ones for `owner/name` or a whole `owner/*`.
- **Real numbers.** Files changed and the line totals show only what the filter keeps, and update as GitHub loads more files.
-- **Combine filters.** Shift-click to look at several filters at once, like _Frontend + Docs_.
-- **See where the changes are.** The breakdown shows how much of the pull request falls under each filter before you start.
-- **Review time.** A rough estimate next to the line totals, at about 400 changed lines per hour.
+- **Combine filters.** Turn on as many as you like, like _Frontend + Docs_. Turn them all off and you're back on **All**.
+- **See where the changes are.** The breakdown shows, per filter, how many files you've marked as viewed, the lines changed and the time left.
+- **Review time left.** An estimate next to the line totals, from about 400 changed lines per hour, that drops as you mark files as viewed.
+- **Conversations.** Step through review threads one by one. Each shows its file and line, and whether it's waiting on you, answered or resolved.
- **Keyboard friendly.** Arrow keys inside the bar, plus global shortcuts.
- **Made to share.** Copy your filters and send them to a teammate, who imports them in one step.
- **Feels like GitHub.** Follows your GitHub theme, checked against WCAG 2.1 AA, respects reduced motion, and speaks English and Portuguese.
@@ -54,37 +55,17 @@ On a Mac, Alt is Option . Change them at `chrome://extensio
## Install
-Store listings are on the way. Until then, build it from source with `npm install` and:
-
-- **Chrome or Edge:** run `npm run build`, open `chrome://extensions` (or `edge://extensions`), turn on **Developer mode**, click **Load unpacked** and pick `.output/chrome-mv3`.
-- **Firefox:** run `npm run build:firefox`, open `about:debugging#/runtime/this-firefox`, click **Load Temporary Add-on** and pick `.output/firefox-mv3/manifest.json`.
-
-## Development
-
-Focus Diff is built with [WXT](https://wxt.dev) and TypeScript, and has no runtime dependencies.
-
-```sh
-npm install # also generates WXT's types
-npm run dev # Chrome with the extension loaded and hot reload (dev:firefox for Firefox)
-npm run check # types, Oxlint and Oxfmt
-npm test # unit tests with Vitest
-npm run e2e # builds, then runs Playwright against the real extension, with axe accessibility checks
-npm run fmt # format everything
-npm run zip # store zips in .output/ (zip:firefox also packs the sources for review)
-```
-
-```text
-src/
- entrypoints/ background, the GitHub content script, options and welcome pages
- components/panel/ the floating bar, rendered in a shadow root
- utils/ filters, storage, the GitHub page adapter, formatting
- locales/ English and Brazilian Portuguese messages
-tests/
- unit/ Vitest with WXT's fake browser
- e2e/ Playwright, on a local copy of a pull request page
-```
-
-The end-to-end tests serve a saved pull request page in place of github.com, so they don't depend on the network.
+Store listings are on the way. Meanwhile, every [release](https://github.com/hyanmandian/focus-diff/releases/latest) has builds you can load yourself:
+
+- **Chrome, Edge, Brave, Arc and other Chromium browsers:** unzip `focus-diff-chromium.zip`, open `chrome://extensions` (or `edge://extensions`), turn on **Developer mode**, click **Load unpacked** and pick the unzipped folder.
+- **Firefox and browsers built on it:** open `about:debugging#/runtime/this-firefox`, click **Load Temporary Add-on** and pick `focus-diff-firefox.zip`. It stays until Firefox restarts.
+- **Safari (Mac with Xcode):** unzip `focus-diff-safari.zip`, run `xcrun safari-web-extension-converter focus-diff-safari --app-name "Focus Diff" --macos-only` and click **Run** in Xcode. In Safari, turn on **Settings › Advanced › Show features for web developers**, then **Develop › Allow Unsigned Extensions**, and enable Focus Diff in **Settings › Extensions**.
+
+To build from source instead, run `npm install` and `npm run build` (or `build:firefox`, `build:safari`); the builds land in `.output/`.
+
+## Contributing
+
+Bug reports, ideas and pull requests are welcome. [CONTRIBUTING.md](CONTRIBUTING.md) explains how to run it locally, test it and cut a release. Please report security issues [privately](SECURITY.md).
## License
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..0944148
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,11 @@
+# Security policy
+
+Focus Diff runs on github.com pages and has access to what they show, so security reports are taken seriously.
+
+## Reporting a vulnerability
+
+Please report it privately through [GitHub's vulnerability reporting](https://github.com/hyanmandian/focus-diff/security/advisories/new), not in a public issue. Include what an attacker could do and the steps to reproduce it. You'll get a reply within a week, and credit in the release notes if you'd like it.
+
+## Supported versions
+
+Only the latest release gets fixes. Store builds update on their own.
diff --git a/package.json b/package.json
index 6f91782..45f99a2 100644
--- a/package.json
+++ b/package.json
@@ -11,8 +11,10 @@
"dev:firefox": "wxt -b firefox",
"build": "wxt build",
"build:firefox": "wxt build -b firefox",
+ "build:safari": "wxt build -b safari",
"zip": "wxt zip",
"zip:firefox": "wxt zip -b firefox",
+ "zip:safari": "wxt zip -b safari",
"compile": "tsc --noEmit",
"lint": "oxlint",
"lint:firefox": "wxt build -b firefox && web-ext lint --source-dir .output/firefox-mv3 --self-hosted",
diff --git a/release-please-config.json b/release-please-config.json
new file mode 100644
index 0000000..0d745c2
--- /dev/null
+++ b/release-please-config.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
+ "packages": {
+ ".": {
+ "release-type": "node",
+ "package-name": "focus-diff",
+ "include-component-in-tag": false
+ }
+ }
+}
diff --git a/site/favicon.png b/site/favicon.png
new file mode 100644
index 0000000..66f721b
Binary files /dev/null and b/site/favicon.png differ
diff --git a/site/focus-diff.mp4 b/site/focus-diff.mp4
new file mode 100644
index 0000000..e13b19f
Binary files /dev/null and b/site/focus-diff.mp4 differ
diff --git a/site/icon.webp b/site/icon.webp
new file mode 100644
index 0000000..03a81a8
Binary files /dev/null and b/site/icon.webp differ
diff --git a/site/index.html b/site/index.html
new file mode 100644
index 0000000..0cee162
--- /dev/null
+++ b/site/index.html
@@ -0,0 +1,462 @@
+
+
+
+
+
+ Focus Diff · Review only the code that matters
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Focus Diff
+
+ A browser extension for GitHub pull requests. Filter the diff to the files you care about , see how much is left
+ to review, and step through every conversation without hunting for it.
+
+
+
+
+
+
+
+
+
+
+
+ Install it yourself
+ Every release comes with ready-made builds. Download one and load it straight into your browser, no store needed.
+
+
+ Chromium
+
+ Firefox
+
+ Safari
+
+
+
+
Chrome, Edge, Brave, Arc, Opera and every other Chromium browser.
+
+ Unzip focus-diff-chromium.zip.
+
+ Open chrome://extensions (edge://extensions in Edge) and turn on Developer mode .
+
+ Click Load unpacked and pick the unzipped folder.
+
+
Download for Chromium
+
+
+
+
Firefox, Zen, LibreWolf, Floorp and other browsers built on Firefox.
+
+ Open about:debugging#/runtime/this-firefox.
+ Click Load Temporary Add-on and pick focus-diff-firefox.zip.
+
+
It stays until Firefox restarts. The store version stays for good.
+
Download for Firefox
+
+
+
+
Safari on a Mac with Xcode.
+
+
+ Unzip focus-diff-safari.zip, run this in Terminal, then click Run in Xcode:
+ xcrun safari-web-extension-converter focus-diff-safari --app-name "Focus Diff" --macos-only
+
+
+ In Safari, turn on Settings › Advanced › Show features for web developers , then
+ Develop › Allow Unsigned Extensions .
+
+
+ Turn on Focus Diff in Settings › Extensions . Safari asks again for unsigned extensions after it restarts.
+
+
+
Download for Safari
+
+
+
+
+
+
+ Free and open source, under the MIT license.
+ No tracking.
+ Privacy
+
+
+
+
diff --git a/site/og.jpg b/site/og.jpg
new file mode 100644
index 0000000..e85d997
Binary files /dev/null and b/site/og.jpg differ
diff --git a/site/poster.jpg b/site/poster.jpg
new file mode 100644
index 0000000..eae80b8
Binary files /dev/null and b/site/poster.jpg differ
diff --git a/src/assets/global.css b/src/assets/global.css
new file mode 100644
index 0000000..9d24fc5
--- /dev/null
+++ b/src/assets/global.css
@@ -0,0 +1,147 @@
+/* Pages stay hidden until translated and rendered, so nothing shifts while they fill in. */
+.pending {
+ visibility: hidden;
+}
+
+:root {
+ --bg: #f6f8fa;
+ --surface: #ffffff;
+ --surface-muted: #f6f8fa;
+ --line: #d1d9e0;
+ --fg: #1f2328;
+ --muted: #59636e;
+ --accent: #1f883d;
+ --accent-hover: #1a7f37;
+ --accent-fg: #ffffff;
+ --focus: #0969da;
+ --ok: #1a7f37;
+ --warn: #9a6700;
+ --danger: #d1242f;
+ --danger-bg: #ffebe9;
+ --input: #ffffff;
+ --code: rgba(129, 139, 152, 0.15);
+ --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
+ --target: 32px;
+ --mono: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
+ color-scheme: light;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #0d1117;
+ --surface: #151b23;
+ --surface-muted: #10151c;
+ --line: #3d444d;
+ --fg: #f0f6fc;
+ --muted: #9198a1;
+ --accent: #238636;
+ --accent-hover: #29903b;
+ --accent-fg: #ffffff;
+ --focus: #4493f8;
+ --ok: #3fb950;
+ --warn: #d29922;
+ --danger: #f85149;
+ --danger-bg: rgba(248, 81, 73, 0.1);
+ --input: #0d1117;
+ --code: rgba(101, 108, 118, 0.2);
+ color-scheme: dark;
+ }
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+html {
+ background: var(--bg);
+ accent-color: var(--accent);
+}
+::selection {
+ background: color-mix(in srgb, var(--focus) 28%, transparent);
+ color: var(--fg);
+}
+input,
+textarea {
+ caret-color: var(--focus);
+}
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--fg);
+ font: 14px/1.5 var(--sans);
+ padding-inline: 16px;
+ padding-block: 40px 112px;
+}
+main {
+ max-width: 860px;
+ margin: 0 auto;
+ display: grid;
+ gap: 24px;
+}
+h1 {
+ margin: 0;
+ font-size: 24px;
+ line-height: 1.25;
+ font-weight: 600;
+ text-wrap: balance;
+}
+h2 {
+ margin: 0;
+ font-size: 16px;
+ line-height: 1.4;
+ font-weight: 600;
+ text-wrap: balance;
+}
+h2:focus {
+ outline: none;
+}
+p {
+ margin: 0;
+ max-width: 70ch;
+}
+code {
+ font: 12px var(--mono);
+ background: var(--code);
+ padding: 2px 6px;
+ border-radius: 6px;
+}
+.hint {
+ color: var(--muted);
+ font-size: 13px;
+}
+[hidden] {
+ display: none !important;
+}
+
+.skip {
+ position: absolute;
+ left: 16px;
+ top: -48px;
+ padding: 8px 12px;
+ border-radius: 6px;
+ background: var(--surface);
+ color: var(--fg);
+ border: 1px solid var(--line);
+}
+.skip:focus {
+ top: 12px;
+}
+
+:focus-visible {
+ outline: 2px solid var(--focus);
+ outline-offset: 2px;
+}
+
+@media (pointer: coarse) {
+ :root {
+ --target: 44px;
+ }
+}
+
+h3 {
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.4;
+ font-weight: 600;
+}
diff --git a/src/components/breakdown/breakdown.css b/src/components/breakdown/breakdown.css
new file mode 100644
index 0000000..6c91c1c
--- /dev/null
+++ b/src/components/breakdown/breakdown.css
@@ -0,0 +1,149 @@
+.columns .column-time {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+}
+.info {
+ display: inline-grid;
+ place-items: center;
+ width: 20px;
+ min-height: 20px;
+ padding: 0;
+ border-radius: 50%;
+ color: var(--fd-muted);
+}
+.info:hover,
+.info:focus-visible {
+ color: var(--fd-fg);
+}
+.tooltip {
+ position: absolute;
+ right: 12px;
+ z-index: 1;
+ box-sizing: border-box;
+ width: max-content;
+ max-width: min(280px, calc(100% - 24px));
+ padding: 8px 10px;
+ border-radius: 8px;
+ background: var(--fd-fg);
+ color: var(--fd-bg);
+ box-shadow: var(--fd-shadow);
+ font: 400 12px/18px var(--fd-font);
+}
+.tooltip[hidden] {
+ display: none;
+}
+.tooltip::after {
+ content: '';
+ position: absolute;
+ bottom: -5px;
+ left: var(--fd-arrow-x, calc(100% - 24px));
+ width: 10px;
+ height: 10px;
+ translate: -50% 0;
+ rotate: 45deg;
+ border-bottom-right-radius: 2px;
+ background: inherit;
+}
+.rows {
+ display: grid;
+ grid-template-columns: 16px minmax(96px, max-content) repeat(3, max-content);
+ column-gap: 16px;
+ max-height: min(52vh, 420px);
+ padding: 8px;
+ overflow-y: auto;
+ overscroll-behavior: contain;
+}
+.columns,
+.row {
+ display: grid;
+ grid-column: 1 / -1;
+ grid-template-columns: subgrid;
+ align-items: center;
+ padding: 0 8px;
+}
+.columns {
+ padding-bottom: 6px;
+ border-bottom: 1px solid var(--fd-border-muted);
+ font-size: 12px;
+ line-height: 16px;
+ color: var(--fd-muted);
+}
+.columns > :nth-child(n + 4),
+.row > :nth-child(n + 4) {
+ justify-self: end;
+}
+/* Viewed reads with the filter's name; the line counts start a new group. */
+.columns > :nth-child(3),
+.row > :nth-child(3) {
+ justify-self: start;
+ margin-left: -6px;
+}
+.columns > :nth-child(4),
+.row > :nth-child(4) {
+ margin-left: 20px;
+}
+.row {
+ position: relative;
+ min-height: 36px;
+ border-radius: 6px;
+ text-align: left;
+ font-weight: 400;
+}
+.row + .row::before {
+ content: '';
+ position: absolute;
+ inset: -1px 8px auto;
+ border-top: 1px solid var(--fd-border-muted);
+}
+.row:hover::before,
+.row:hover + .row::before {
+ border-color: transparent;
+}
+.row-check {
+ display: grid;
+ color: var(--fd-accent);
+}
+.row[aria-pressed='true'] .row-name {
+ font-weight: 600;
+}
+.row-lines {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+.diffstat {
+ display: inline-flex;
+ gap: 1px;
+}
+.diffstat span {
+ width: 8px;
+ height: 8px;
+ border-radius: 1px;
+ background: var(--fd-track);
+}
+.diffstat .add {
+ background: var(--fd-add);
+}
+.diffstat .del {
+ background: var(--fd-del);
+}
+.row-time {
+ font-weight: 600;
+}
+.row-viewed {
+ color: var(--fd-muted);
+}
+.row-viewed.complete {
+ color: var(--fd-add);
+ font-weight: 600;
+}
+
+@media (forced-colors: active) {
+ .row[aria-pressed='true'] {
+ outline: 2px solid Highlight;
+ }
+ .diffstat span {
+ border: 1px solid CanvasText;
+ }
+}
diff --git a/src/components/breakdown/breakdown.ts b/src/components/breakdown/breakdown.ts
new file mode 100644
index 0000000..ddefcd0
--- /dev/null
+++ b/src/components/breakdown/breakdown.ts
@@ -0,0 +1,186 @@
+import './breakdown.css';
+import { i18n } from '#i18n';
+import { breakdownIcon, checkIcon, infoIcon } from '@/components/icons';
+import { h } from '@/utils/dom';
+import { formatDuration, formatNumber as format } from '@/utils/format';
+import { LINES_PER_HOUR } from '@/utils/review-time';
+import type { PanelOption } from '@/components/filters/filters';
+import type { PanelContext } from '@/components/panel/panel';
+import { centreOver, pointAt, returnFocus } from '@/components/popover';
+import type { Totals } from '@/components/stats/stats';
+
+/** One filter's share of the pull request. */
+export interface BreakdownRow extends PanelOption, Totals {}
+
+interface BreakdownActions {
+ onToggle: (id: string) => void;
+ /** Called as it opens, so other popovers can close. */
+ onOpen: () => void;
+ /** Where focus goes when it closes and its toggle is gone. */
+ fallback: HTMLElement;
+}
+
+/** GitHub's five-square diffstat: the share of added and removed lines. */
+const diffstat = (additions: number, deletions: number) => {
+ const total = additions + deletions;
+ const added = total ? Math.round((additions / total) * 5) : 0;
+ const removed = total ? Math.min(5 - added, Math.round((deletions / total) * 5)) : 0;
+ return h(
+ 'span',
+ { className: 'diffstat', 'aria-hidden': 'true' },
+ ...Array.from({ length: 5 }, (_, index) => h('span', { className: index < added ? 'add' : index < added + removed ? 'del' : '' })),
+ );
+};
+
+/** How the pull request splits across the filters: files viewed, lines and time left for each. Rows toggle filters. */
+export const createBreakdown = ({ host, focused }: PanelContext, { onToggle, onOpen, fallback }: BreakdownActions) => {
+ const rows = h('div', { className: 'rows' });
+ const timeInfo = h('div', {
+ className: 'tooltip',
+ id: 'focus-diff-time-info',
+ role: 'tooltip',
+ hidden: true,
+ textContent: i18n.t('timeHint', [LINES_PER_HOUR]),
+ });
+ const popover = h(
+ 'div',
+ { className: 'breakdown popover', id: 'focus-diff-breakdown', role: 'dialog', hidden: true, 'aria-label': i18n.t('panelBreakdown') },
+ rows,
+ timeInfo,
+ );
+ const toggle = h(
+ 'button',
+ {
+ type: 'button',
+ className: 'icon-button breakdown-toggle',
+ title: i18n.t('panelBreakdown'),
+ 'aria-label': i18n.t('panelBreakdown'),
+ 'aria-expanded': 'false',
+ 'aria-controls': 'focus-diff-breakdown',
+ },
+ breakdownIcon(),
+ );
+
+ // Closing waits a moment so the pointer can move onto the tooltip and keep it open.
+ let timeInfoClosing = 0;
+ const closeTimeInfo = () => {
+ timeInfoClosing = window.setTimeout(() => (timeInfo.hidden = true), 120);
+ };
+ const keepTimeInfo = () => clearTimeout(timeInfoClosing);
+ timeInfo.addEventListener('mouseenter', keepTimeInfo);
+ timeInfo.addEventListener('mouseleave', closeTimeInfo);
+
+ /** The (i) beside "Time left": explains the estimate on hover or focus. */
+ const timeInfoButton = () => {
+ const button = h(
+ 'button',
+ { type: 'button', className: 'info', 'aria-label': i18n.t('panelTimeInfo'), 'aria-describedby': 'focus-diff-time-info' },
+ infoIcon(),
+ );
+ // Sits above the icon, its arrow pointing down at it, like the breakdown does over its button.
+ const open = () => {
+ const box = button.getBoundingClientRect();
+ timeInfo.style.bottom = `${Math.round(popover.getBoundingClientRect().bottom - box.top + 10)}px`;
+ timeInfo.hidden = false;
+ pointAt(timeInfo, button);
+ };
+ button.addEventListener('mouseenter', () => (keepTimeInfo(), open()));
+ button.addEventListener('focus', () => (keepTimeInfo(), open()));
+ button.addEventListener('mouseleave', closeTimeInfo);
+ button.addEventListener('blur', closeTimeInfo);
+ return button;
+ };
+
+ const columns = () =>
+ h(
+ 'div',
+ { className: 'columns' },
+ h('span'),
+ h('span', { textContent: i18n.t('panelColumnFilter') }),
+ h('span', { textContent: i18n.t('panelColumnViewed') }),
+ h('span', { textContent: i18n.t('panelColumnLines') }),
+ h('span', { className: 'column-time' }, i18n.t('panelColumnTime'), timeInfoButton()),
+ );
+
+ const row = (data: BreakdownRow, selected: boolean) => {
+ // A complete row already reads as done through its green Viewed count, so its time stays empty.
+ const time = data.minutesLeft === 0 ? '' : formatDuration(data.minutesLeft);
+ const complete = data.visible > 0 && data.viewed === data.visible;
+ return h(
+ 'button',
+ {
+ type: 'button',
+ className: 'row',
+ 'data-row': data.id,
+ 'aria-pressed': String(selected),
+ 'aria-label': i18n.t('panelBreakdownRow', [
+ data.name,
+ i18n.t('fileCount', data.visible, [format(data.visible)]),
+ format(data.viewed),
+ format(data.additions),
+ format(data.deletions),
+ data.minutesLeft === 0 ? i18n.t('timeDone') : i18n.t('timeLeft', [time]),
+ ]),
+ onClick: () => onToggle(data.id),
+ },
+ h('span', { className: 'row-check' }, selected ? checkIcon() : null),
+ h('span', { className: 'row-name', textContent: data.name }),
+ h('span', { className: `row-viewed${complete ? ' complete' : ''}`, textContent: `${format(data.viewed)}/${format(data.visible)}` }),
+ h(
+ 'span',
+ { className: 'row-lines' },
+ h('span', { className: 'additions', textContent: `+${format(data.additions)}` }),
+ h('span', { className: 'deletions', textContent: `−${format(data.deletions)}` }),
+ diffstat(data.additions, data.deletions),
+ ),
+ h('span', { className: 'row-time', textContent: time }),
+ );
+ };
+
+ let source: { rows: () => BreakdownRow[]; selected: string[] } = { rows: () => [], selected: [] };
+ let drawnKey = '';
+ const draw = () => {
+ if (popover.hidden) return;
+ const list = source.rows();
+ const { selected } = source;
+ const key = JSON.stringify([list, selected]);
+ if (key === drawnKey) return;
+ drawnKey = key;
+ const keepFocus = focused()?.dataset.row;
+ rows.replaceChildren(columns(), ...list.map((data) => row(data, selected.includes(data.id))));
+ if (keepFocus) rows.querySelector(`[data-row="${CSS.escape(keepFocus)}"]`)?.focus();
+ };
+
+ const reposition = () => centreOver(popover, toggle, host);
+
+ const setOpen = (open: boolean) => {
+ if (!open) {
+ returnFocus(popover, toggle, fallback, focused());
+ timeInfo.hidden = true;
+ }
+ popover.hidden = !open;
+ toggle.setAttribute('aria-expanded', String(open));
+ if (open) {
+ onOpen();
+ draw();
+ reposition();
+ } else drawnKey = '';
+ };
+ toggle.addEventListener('click', () => setOpen(popover.hidden));
+
+ /** Rows are only computed while it's open. */
+ const render = (list: () => BreakdownRow[], selected: string[]) => {
+ source = { rows: list, selected };
+ draw();
+ };
+
+ /** Escape closes the tooltip first, then the breakdown; returns whether there was something to close. */
+ const dismiss = () => {
+ if (!timeInfo.hidden) timeInfo.hidden = true;
+ else if (!popover.hidden) setOpen(false);
+ else return false;
+ return true;
+ };
+
+ return { toggle, popover, render, setOpen, dismiss, reposition, isOpen: () => !popover.hidden };
+};
diff --git a/src/components/button.css b/src/components/button.css
new file mode 100644
index 0000000..4c5a61d
--- /dev/null
+++ b/src/components/button.css
@@ -0,0 +1,71 @@
+.btn {
+ justify-self: start;
+ min-height: var(--target);
+ padding: 0 14px;
+ border: 1px solid var(--line);
+ border-radius: 6px;
+ background: var(--surface);
+ color: var(--fg);
+ font: 600 14px/20px var(--sans);
+ cursor: pointer;
+}
+.btn:hover {
+ background: var(--surface-muted);
+ border-color: var(--muted);
+}
+.btn.primary {
+ background: var(--accent);
+ border-color: transparent;
+ color: var(--accent-fg);
+ min-width: 88px;
+}
+.btn.primary:hover {
+ background: var(--accent-hover);
+}
+.btn.danger {
+ color: var(--danger);
+}
+.btn.danger:hover {
+ background: var(--danger-bg);
+ border-color: var(--danger);
+}
+.icon-btn {
+ display: inline-grid;
+ place-items: center;
+ width: var(--target);
+ height: var(--target);
+ padding: 0;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+}
+.icon-btn:hover {
+ color: var(--danger);
+ background: var(--danger-bg);
+}
+
+.btn:disabled,
+.btn:disabled:hover {
+ cursor: not-allowed;
+ color: var(--muted);
+ background: var(--surface-muted);
+ border-color: var(--line);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .btn,
+ .icon-btn {
+ transition:
+ background-color 120ms ease-out,
+ border-color 120ms ease-out;
+ }
+}
+
+@media (forced-colors: active) {
+ .btn,
+ .icon-btn {
+ border: 1px solid ButtonText;
+ }
+}
diff --git a/src/components/conversations/conversations.css b/src/components/conversations/conversations.css
new file mode 100644
index 0000000..49ace05
--- /dev/null
+++ b/src/components/conversations/conversations.css
@@ -0,0 +1,100 @@
+.navigation {
+ display: inline-flex;
+ align-items: center;
+ gap: 2px;
+ padding-left: 8px;
+ border-left: 1px solid var(--fd-border);
+}
+.comments {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 8px 0 10px;
+}
+.comments[hidden] {
+ display: none;
+}
+.comments[aria-expanded='true'] {
+ background: var(--fd-hover);
+}
+.comments-icon {
+ display: inline-grid;
+ color: var(--fd-muted);
+}
+.comments[aria-expanded='true'] .comments-icon {
+ color: var(--fd-fg);
+}
+
+/* A fixed width, so the card doesn't resize with each file name. */
+.conversations {
+ display: grid;
+ grid-template-columns: 16px minmax(0, 1fr) auto;
+ grid-template-rows: auto auto;
+ align-items: center;
+ column-gap: 10px;
+ row-gap: 2px;
+ width: min(272px, calc(100vw - 32px));
+ padding: 8px 8px 8px 12px;
+ font-size: 13px;
+}
+.conversation-icon {
+ display: grid;
+ grid-row: 1 / span 2;
+ color: var(--fd-attention);
+}
+.conversations[data-state='answered'] .conversation-icon {
+ color: var(--fd-accent);
+}
+.conversations[data-state='resolved'] .conversation-icon {
+ color: var(--fd-add);
+}
+.conversation-file {
+ display: flex;
+ grid-column: 2;
+ min-width: 0;
+ font-weight: 600;
+ line-height: 20px;
+}
+.conversation-name,
+.conversation-state {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.conversation-line {
+ flex: none;
+ color: var(--fd-muted);
+ font-weight: 400;
+}
+.conversation-state {
+ grid-column: 2;
+ color: var(--fd-muted);
+ font-size: 12px;
+ line-height: 18px;
+}
+/* The position sits over the arrows that change it. */
+.conversation-position {
+ grid-row: 1;
+ grid-column: 3;
+ color: var(--fd-muted);
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 16px;
+ text-align: center;
+}
+.conversation-steps {
+ display: flex;
+ grid-row: 2;
+ grid-column: 3;
+}
+.conversation-steps .icon-button {
+ min-width: 28px;
+ min-height: 24px;
+ border-radius: 6px;
+}
+@media (pointer: coarse) {
+ .conversation-steps .icon-button {
+ min-width: 44px;
+ min-height: 44px;
+ }
+}
diff --git a/src/components/conversations/conversations.ts b/src/components/conversations/conversations.ts
new file mode 100644
index 0000000..b76a4d2
--- /dev/null
+++ b/src/components/conversations/conversations.ts
@@ -0,0 +1,130 @@
+import './conversations.css';
+import { i18n } from '#i18n';
+import { chevronIcon, commentIcon, threadStateIcons } from '@/components/icons';
+import { h } from '@/utils/dom';
+import { formatNumber as format } from '@/utils/format';
+import type { PanelContext } from '@/components/panel/panel';
+import { centreOver, returnFocus } from '@/components/popover';
+import type { ThreadState } from '@/utils/github';
+
+export interface Conversation {
+ path: string;
+ /** Line in the new version of the file, when known. */
+ line: number | null;
+ state: ThreadState;
+}
+
+export interface Conversations {
+ list: Conversation[];
+ /** The one the reader last jumped to, 1-based; 0 before the first jump. */
+ current: number;
+}
+
+interface ConversationActions {
+ onStep: (direction: 1 | -1) => void;
+ /** Called as it opens, so other popovers can close. */
+ onOpen: () => void;
+ /** Where focus goes when it closes and its toggle is gone. */
+ fallback: HTMLElement;
+}
+
+const STATE_LABEL = { waiting: 'panelStateWaiting', answered: 'panelStateAnswered', resolved: 'panelStateResolved' } as const;
+
+/**
+ * The conversations button and its card: which file and line the reader is on, whether it waits on them, and arrows to
+ * the previous and next one. The card stays open while they step through.
+ */
+export const createConversations = ({ host, focused }: PanelContext, { onStep, onOpen, fallback }: ConversationActions) => {
+ const count = h('span', { className: 'option-count', 'aria-hidden': 'true' });
+ const toggle = h(
+ 'button',
+ { type: 'button', className: 'comments', 'aria-expanded': 'false', 'aria-controls': 'focus-diff-conversations' },
+ h('span', { className: 'comments-icon', 'aria-hidden': 'true' }, commentIcon()),
+ count,
+ );
+ const icon = h('span', { className: 'conversation-icon', 'aria-hidden': 'true' });
+ const file = h('span', { className: 'conversation-file' });
+ const state = h('span', { className: 'conversation-state' });
+ const position = h('span', { className: 'conversation-position', 'aria-hidden': 'true' });
+ const step = (direction: 1 | -1) => {
+ const label = i18n.t(direction > 0 ? 'panelNextComment' : 'panelPreviousComment');
+ return h(
+ 'button',
+ { type: 'button', className: 'icon-button', 'aria-label': label, title: label, onClick: () => onStep(direction) },
+ chevronIcon(direction > 0 ? 'right' : 'left'),
+ );
+ };
+ const popover = h(
+ 'div',
+ {
+ className: 'conversations popover',
+ id: 'focus-diff-conversations',
+ role: 'dialog',
+ 'aria-label': i18n.t('panelComments'),
+ hidden: true,
+ },
+ icon,
+ file,
+ state,
+ position,
+ h('span', { className: 'conversation-steps' }, step(-1), step(1)),
+ );
+ // Like the breakdown, the card follows its toggle and is positioned against the host.
+ const element = h('div', { className: 'navigation' }, toggle, popover);
+
+ let source: Conversations = { current: 0, list: [] };
+
+ /** The conversation the reader is on (the first one while the jump to it lands). */
+ const draw = () => {
+ if (popover.hidden) return;
+ const { current, list } = source;
+ const target = list[current - 1] ?? list[0];
+ if (!target) return;
+ const name = target.path.slice(target.path.lastIndexOf('/') + 1);
+ // The name gives way to an ellipsis; the line number always shows.
+ file.replaceChildren(
+ h('span', { className: 'conversation-name', textContent: name }),
+ target.line ? h('span', { className: 'conversation-line', textContent: `:${format(target.line)}` }) : '',
+ );
+ file.title = target.path;
+ if (popover.dataset.state !== target.state) {
+ popover.dataset.state = target.state;
+ icon.replaceChildren(threadStateIcons[target.state]());
+ }
+ state.textContent = i18n.t(STATE_LABEL[target.state]);
+ position.textContent = `${format(Math.max(current, 1))}/${format(list.length)}`;
+ };
+
+ const reposition = () => centreOver(popover, toggle, host);
+
+ /** Only its toggle or Escape closes it. */
+ const setOpen = (open: boolean) => {
+ if (popover.hidden === !open) return;
+ if (!open) returnFocus(popover, toggle, fallback, focused());
+ popover.hidden = !open;
+ toggle.setAttribute('aria-expanded', String(open));
+ if (open) {
+ onOpen();
+ // Opening lands on a conversation straight away, so the card always has one to show.
+ if (source.current === 0) onStep(1);
+ draw();
+ reposition();
+ }
+ };
+ toggle.addEventListener('click', () => setOpen(popover.hidden));
+
+ const render = (conversations: Conversations) => {
+ const total = conversations.list.length;
+ toggle.hidden = total === 0;
+ if (!total) setOpen(false);
+ count.textContent = format(total);
+ const where = conversations.current
+ ? i18n.t('panelCommentPosition', [format(conversations.current), format(total)])
+ : i18n.t('panelCommentCount', total, [format(total)]);
+ toggle.setAttribute('aria-label', `${i18n.t('panelComments')}, ${where}`);
+ source = conversations;
+ draw();
+ };
+
+ return { element, popover, render, setOpen, reposition, isOpen: () => !popover.hidden };
+};
diff --git a/src/components/filters/filters.css b/src/components/filters/filters.css
new file mode 100644
index 0000000..2df0aeb
--- /dev/null
+++ b/src/components/filters/filters.css
@@ -0,0 +1,62 @@
+.filters {
+ position: relative;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2px;
+ isolation: isolate;
+}
+.indicator {
+ position: absolute;
+ inset: 0;
+ z-index: -1;
+ clip-path: inset(0 100% 100% 0 round 8px);
+ background: var(--fd-selected-bg);
+ pointer-events: none;
+}
+.option {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0 8px 0 12px;
+ font-weight: 500;
+}
+.option[aria-pressed='true'] .option-count {
+ background: rgba(0, 0, 0, 0.16);
+ color: var(--fd-selected-fg);
+}
+.option.empty:not([aria-pressed='true']) {
+ color: var(--fd-muted);
+}
+.option[aria-pressed='true'],
+.option[aria-pressed='true']:hover {
+ background: transparent;
+ color: var(--fd-selected-fg);
+}
+.filters.combined .indicator {
+ opacity: 0;
+}
+.filters.combined .option[aria-pressed='true'] {
+ background: var(--fd-selected-bg);
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .indicator.ready {
+ transition: clip-path 260ms var(--fd-ease);
+ }
+}
+@media (forced-colors: active) {
+ .indicator,
+ .option[aria-pressed='true'],
+ .filters.combined .option[aria-pressed='true'] {
+ forced-color-adjust: none;
+ }
+ .indicator {
+ background: Highlight;
+ }
+ .option[aria-pressed='true'] {
+ color: HighlightText;
+ }
+ .filters.combined .option[aria-pressed='true'] {
+ background: Highlight;
+ }
+}
diff --git a/src/components/filters/filters.ts b/src/components/filters/filters.ts
new file mode 100644
index 0000000..375e58b
--- /dev/null
+++ b/src/components/filters/filters.ts
@@ -0,0 +1,97 @@
+import './filters.css';
+import { i18n } from '#i18n';
+import { h } from '@/utils/dom';
+import { formatNumber as format } from '@/utils/format';
+import type { PanelContext } from '@/components/panel/panel';
+
+export interface PanelOption {
+ id: string;
+ name: string;
+ /** Files this option shows. */
+ count?: number;
+}
+
+const ARROW_STEPS: Record = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
+
+/**
+ * The filter chips: one roving tab stop, arrow keys between chips, and a highlight that slides to the pressed one.
+ * `render` returns true when the list of filters itself changed.
+ */
+export const createFilters = ({ focused }: PanelContext, onToggle: (id: string) => void) => {
+ const indicator = h('span', { className: 'indicator', 'aria-hidden': 'true' });
+ const element = h('div', { className: 'filters', role: 'group', 'aria-label': i18n.t('panelShowFiles') }, indicator);
+ const options = () => [...element.querySelectorAll('.option')];
+
+ const moveIndicator = () => {
+ const pressed = element.querySelectorAll('[aria-pressed="true"]');
+ element.classList.toggle('combined', pressed.length > 1);
+ const target = pressed[0];
+ if (pressed.length !== 1 || !target?.offsetWidth) return;
+ const right = element.clientWidth - target.offsetLeft - target.offsetWidth;
+ const bottom = element.clientHeight - target.offsetTop - target.offsetHeight;
+ indicator.style.clipPath = `inset(${target.offsetTop}px ${right}px ${bottom}px ${target.offsetLeft}px round 8px)`;
+ if (!indicator.classList.contains('ready')) requestAnimationFrame(() => indicator.classList.add('ready'));
+ };
+
+ element.addEventListener('keydown', (event) => {
+ const list = options();
+ const index = list.indexOf(focused() as HTMLButtonElement);
+ if (index === -1) return;
+ const target = event.key === 'Home' ? 0 : event.key === 'End' ? list.length - 1 : index + (ARROW_STEPS[event.key] ?? Number.NaN);
+ if (Number.isNaN(target)) return;
+ event.preventDefault();
+ const next = list[(target + list.length) % list.length];
+ if (!next) return;
+ list.forEach((option) => (option.tabIndex = option === next ? 0 : -1));
+ next.focus();
+ });
+
+ let optionsKey = '';
+ let indicatorKey = '';
+ const render = (list: PanelOption[], selected: string[]): boolean => {
+ const key = JSON.stringify(list.map(({ id, name }) => [id, name]));
+ const changed = key !== optionsKey;
+ if (changed) {
+ optionsKey = key;
+ const keepFocus = element.contains(focused());
+ options().forEach((option) => option.remove());
+ element.append(
+ ...list.map(({ id, name }) =>
+ h(
+ 'button',
+ { type: 'button', className: 'option', 'data-id': id, onClick: () => onToggle(id) },
+ h('span', { className: 'option-name', textContent: name }),
+ h('span', { className: 'option-count', 'aria-hidden': 'true' }),
+ h('span', { className: 'visually-hidden option-count-label' }),
+ ),
+ ),
+ );
+ if (keepFocus) requestAnimationFrame(() => element.querySelector('[aria-pressed="true"]')?.focus());
+ }
+
+ const current = focused()?.classList.contains('option') ? focused() : null;
+ for (const option of options()) {
+ const count = list.find((item) => item.id === option.dataset.id)?.count;
+ const countElement = option.querySelector('.option-count');
+ const countLabel = option.querySelector('.option-count-label');
+ if (countElement && countLabel && count !== undefined) {
+ const text = format(count);
+ if (countElement.textContent !== text) countElement.textContent = text;
+ countLabel.textContent = ` ${i18n.t('fileCount', count, [text])}`;
+ option.classList.toggle('empty', count === 0);
+ }
+ const pressed = selected.includes(option.dataset.id ?? '');
+ if (option.getAttribute('aria-pressed') !== String(pressed)) option.setAttribute('aria-pressed', String(pressed));
+ option.tabIndex = (current ? option === current : option.dataset.id === selected[0]) ? 0 : -1;
+ }
+ // Measuring the chips forces a layout, so it only happens when the selection changes; a ResizeObserver covers the rest.
+ const pressedKey = `${optionsKey} ${selected.join()}`;
+ if (pressedKey !== indicatorKey) {
+ indicatorKey = pressedKey;
+ moveIndicator();
+ }
+ return changed;
+ };
+
+ return { element, render, moveIndicator };
+};
diff --git a/src/components/icons.ts b/src/components/icons.ts
new file mode 100644
index 0000000..5c59941
--- /dev/null
+++ b/src/components/icons.ts
@@ -0,0 +1,34 @@
+import { icon } from '@/utils/dom';
+import type { ThreadState } from '@/utils/github';
+
+export const settingsIcon = () =>
+ icon([
+ ['path', { d: 'M2 4h7M13 4h1M2 8h1M7 8h7M2 12h5M11 12h3' }],
+ ['circle', { cx: '11', cy: '4', r: '2' }],
+ ['circle', { cx: '5', cy: '8', r: '2' }],
+ ['circle', { cx: '9', cy: '12', r: '2' }],
+ ]);
+export const breakdownIcon = () => icon('M2 13.5h12M4 11V7M8 11V3M12 11V8');
+export const infoIcon = () =>
+ icon(
+ [
+ ['circle', { cx: '8', cy: '8', r: '6.25' }],
+ ['path', { d: 'M8 7.25v3.75M8 5v.25' }],
+ ],
+ 14,
+ );
+export const checkIcon = () => icon('M3.5 8.5 6.5 11.5 12.5 4.5');
+export const commentIcon = () => icon('M3 3.5h10a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H8l-3 2.5V11.5H3a1 1 0 0 1-1-1v-6a1 1 0 0 1 1-1z');
+export const closeIcon = () => icon('M4.5 4.5l7 7M11.5 4.5l-7 7', 14);
+export const chevronIcon = (direction: 'left' | 'right') => icon(direction === 'left' ? 'M10 4.5 6.5 8 10 11.5' : 'M6 4.5 9.5 8 6 11.5');
+
+/** A conversation waiting on the reader (a dot), answered (a reply arrow) or resolved (a check). */
+export const threadStateIcons: Record SVGSVGElement> = {
+ waiting: () => icon([['circle', { cx: '8', cy: '8', r: '3.5', fill: 'currentColor', stroke: 'none' }]]),
+ answered: () => icon('M6.5 4 3 7.5 6.5 11M3 7.5h6a4 4 0 0 1 4 4v.5'),
+ resolved: () =>
+ icon([
+ ['circle', { cx: '8', cy: '8', r: '6.25' }],
+ ['path', { d: 'M5.5 8.25 7.25 10l3.25-3.5' }],
+ ]),
+};
diff --git a/src/components/panel/index.ts b/src/components/panel/index.ts
deleted file mode 100644
index f14875a..0000000
--- a/src/components/panel/index.ts
+++ /dev/null
@@ -1,358 +0,0 @@
-import { i18n } from '#i18n';
-import { counter } from '@/components/panel/counter';
-import { pointAt } from '@/components/panel/popover';
-import { h, icon } from '@/utils/dom';
-import { formatDuration, formatNumber as format } from '@/utils/format';
-import { LINES_PER_HOUR } from '@/utils/review-time';
-
-export interface Totals {
- visible: number;
- total: number;
- additions: number;
- deletions: number;
- pending: number;
- /** Estimated review time, see utils/review-time.ts. */
- minutes: number;
-}
-
-export interface PanelOption {
- id: string;
- name: string;
-}
-
-export interface BreakdownRow extends PanelOption, Totals {}
-
-export interface PanelActions {
- onSelect: (id: string) => void;
- onToggle: (id: string) => void;
- onSettings: () => void;
-}
-
-export interface Panel {
- renderOptions: (options: PanelOption[], selected: string[]) => void;
- renderStats: (totals: Totals) => void;
- /** Rows are only computed while the breakdown is open. */
- renderBreakdown: (rows: () => BreakdownRow[], selected: string[]) => void;
- announce: (summary: Totals & { name: string }) => void;
- setVisible: (visible: boolean) => void;
- reset: () => void;
-}
-
-const ARROW_STEPS: Record = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 };
-
-const settingsIcon = () =>
- icon([
- ['path', { d: 'M2 4h7M13 4h1M2 8h1M7 8h7M2 12h5M11 12h3' }],
- ['circle', { cx: '11', cy: '4', r: '2' }],
- ['circle', { cx: '5', cy: '8', r: '2' }],
- ['circle', { cx: '9', cy: '12', r: '2' }],
- ]);
-
-const breakdownIcon = () => icon('M2 13.5h12M4 11V7M8 11V3M12 11V8');
-
-interface PanelMount {
- root: ShadowRoot;
- container: HTMLElement;
- host: HTMLElement;
- signal: AbortSignal;
-}
-
-export const createPanel = ({ root, container, host, signal }: PanelMount, { onSelect, onToggle, onSettings }: PanelActions): Panel => {
- const focused = () => root.activeElement as HTMLElement | null;
-
- const indicator = h('span', { className: 'indicator', 'aria-hidden': 'true' });
- const group = h('div', { className: 'filters', role: 'group', 'aria-label': i18n.t('panelShowFiles') }, indicator);
- const visibleFiles = counter('visible', format);
- const totalFiles = counter('total', format);
- const additions = counter('additions', (n) => `+${format(n)}`);
- const deletions = counter('deletions', (n) => `−${format(n)}`);
- const time = counter('time', (seconds) => formatDuration(seconds / 60));
- const pending = h('span', { className: 'pending', 'aria-hidden': 'true' });
- const pendingText = h('span', { className: 'visually-hidden' });
- const stats = h(
- 'span',
- { className: 'stats' },
- h(
- 'span',
- { className: 'files' },
- visibleFiles.element,
- h('span', { className: 'number', textContent: '/' }),
- totalFiles.element,
- h('span', { className: 'files-label', textContent: ` ${i18n.t('panelFilesLabel')}` }),
- ),
- additions.element,
- h('span', { className: 'visually-hidden', textContent: ` ${i18n.t('panelLinesAdded')}` }),
- deletions.element,
- h('span', { className: 'visually-hidden', textContent: ` ${i18n.t('panelLinesRemoved')}` }),
- h(
- 'span',
- { className: 'time-wrap', title: i18n.t('timeHint', [LINES_PER_HOUR]) },
- time.element,
- h('span', { className: 'visually-hidden', textContent: ` ${i18n.t('panelTimeLabel')}` }),
- ),
- pending,
- pendingText,
- );
-
- const breakdownRows = h('div', { className: 'rows' });
- const breakdown = h(
- 'div',
- {
- className: 'breakdown popover',
- id: 'focus-diff-breakdown',
- role: 'dialog',
- hidden: true,
- 'aria-labelledby': 'focus-diff-breakdown-heading',
- },
- h('h2', { id: 'focus-diff-breakdown-heading', textContent: i18n.t('panelBreakdownHeading') }),
- breakdownRows,
- h('p', { className: 'hint' }, i18n.t('panelCombineHint'), h('br'), i18n.t('timeHint', [LINES_PER_HOUR])),
- );
- const breakdownToggle = h(
- 'button',
- {
- type: 'button',
- className: 'icon-button breakdown-toggle',
- title: i18n.t('panelBreakdown'),
- 'aria-label': i18n.t('panelBreakdown'),
- 'aria-expanded': 'false',
- 'aria-controls': 'focus-diff-breakdown',
- },
- breakdownIcon(),
- );
- const settings = h('button', {
- type: 'button',
- className: 'settings icon-button',
- title: i18n.t('panelSettings'),
- onClick: () => onSettings(),
- });
- const status = h('span', { className: 'visually-hidden', role: 'status' });
- // The breakdown follows its toggle so Tab moves straight into it; it's positioned against the host.
- const panel = h('div', { className: 'panel' }, group, stats, breakdownToggle, breakdown, settings, status);
- container.append(panel);
-
- const options = () => [...group.querySelectorAll('.option')];
- const pick = (id: string, event: MouseEvent | KeyboardEvent) => (event.shiftKey ? onToggle(id) : onSelect(id));
-
- const moveIndicator = () => {
- const pressed = group.querySelectorAll('[aria-pressed="true"]');
- group.classList.toggle('combined', pressed.length > 1);
- const target = pressed[0];
- if (pressed.length !== 1 || !target?.offsetWidth) return;
- const right = group.clientWidth - target.offsetLeft - target.offsetWidth;
- const bottom = group.clientHeight - target.offsetTop - target.offsetHeight;
- indicator.style.clipPath = `inset(${target.offsetTop}px ${right}px ${bottom}px ${target.offsetLeft}px round 8px)`;
- if (!indicator.classList.contains('ready')) requestAnimationFrame(() => indicator.classList.add('ready'));
- };
- const resizeObserver = new ResizeObserver(() => {
- moveIndicator();
- pointAt(breakdown, breakdownToggle);
- });
- resizeObserver.observe(group);
- resizeObserver.observe(breakdown);
- signal.addEventListener('abort', () => resizeObserver.disconnect());
-
- group.addEventListener('keydown', (event) => {
- const list = options();
- const index = list.indexOf(focused() as HTMLButtonElement);
- if (index === -1) return;
- const target = event.key === 'Home' ? 0 : event.key === 'End' ? list.length - 1 : index + (ARROW_STEPS[event.key] ?? Number.NaN);
- if (Number.isNaN(target)) return;
- event.preventDefault();
- const next = list[(target + list.length) % list.length];
- if (!next) return;
- list.forEach((option) => (option.tabIndex = option === next ? 0 : -1));
- next.focus();
- if (!event.shiftKey && next.dataset.id) onSelect(next.dataset.id);
- });
-
- let breakdownSource: { rows: () => BreakdownRow[]; selected: string[] } = { rows: () => [], selected: [] };
- let drawnKey = '';
- const checkIcon = () => icon('M3.5 8.5 6.5 11.5 12.5 4.5');
-
- /** GitHub's five-square diffstat: the share of added and removed lines. */
- const diffstat = (additions: number, deletions: number) => {
- const total = additions + deletions;
- const added = total ? Math.round((additions / total) * 5) : 0;
- const removed = total ? Math.min(5 - added, Math.round((deletions / total) * 5)) : 0;
- return h(
- 'span',
- { className: 'diffstat', 'aria-hidden': 'true' },
- ...Array.from({ length: 5 }, (_, index) => h('span', { className: index < added ? 'add' : index < added + removed ? 'del' : '' })),
- );
- };
-
- const columns = () =>
- h(
- 'div',
- { className: 'columns', 'aria-hidden': 'true' },
- h('span'),
- h('span', { textContent: i18n.t('panelColumnFilter') }),
- h('span', { textContent: i18n.t('panelColumnFiles') }),
- h('span', { textContent: i18n.t('panelColumnLines') }),
- h('span', { textContent: i18n.t('panelColumnTime') }),
- );
-
- const breakdownRow = (row: BreakdownRow, selected: boolean) => {
- const time = formatDuration(row.minutes);
- return h(
- 'button',
- {
- type: 'button',
- className: 'row',
- 'data-row': row.id,
- 'aria-pressed': String(selected),
- 'aria-label': i18n.t('panelBreakdownRow', [
- row.name,
- i18n.t('fileCount', row.visible, [format(row.visible)]),
- format(row.additions),
- format(row.deletions),
- time,
- ]),
- onClick: (event: MouseEvent) => pick(row.id, event),
- },
- h('span', { className: 'row-check' }, selected ? checkIcon() : null),
- h('span', { className: 'row-name', textContent: row.name }),
- h('span', { className: 'row-files', textContent: format(row.visible) }),
- h(
- 'span',
- { className: 'row-lines' },
- h('span', { className: 'additions', textContent: `+${format(row.additions)}` }),
- h('span', { className: 'deletions', textContent: `−${format(row.deletions)}` }),
- diffstat(row.additions, row.deletions),
- ),
- h('span', { className: 'row-time', textContent: time }),
- );
- };
-
- const drawBreakdown = () => {
- if (breakdown.hidden) return;
- const rows = breakdownSource.rows();
- const { selected } = breakdownSource;
- const key = JSON.stringify([rows, selected]);
- if (key === drawnKey) return;
- drawnKey = key;
- const keepFocus = focused()?.dataset.row;
- breakdownRows.replaceChildren(columns(), ...rows.map((row) => breakdownRow(row, selected.includes(row.id))));
- if (keepFocus) breakdownRows.querySelector(`[data-row="${CSS.escape(keepFocus)}"]`)?.focus();
- };
-
- const setBreakdownOpen = (open: boolean) => {
- breakdown.hidden = !open;
- breakdownToggle.setAttribute('aria-expanded', String(open));
- if (open) {
- drawBreakdown();
- pointAt(breakdown, breakdownToggle);
- } else drawnKey = '';
- };
- breakdownToggle.addEventListener('click', () => setBreakdownOpen(breakdown.hidden));
- document.addEventListener(
- 'keydown',
- (event) => {
- if (event.key !== 'Escape' || breakdown.hidden) return;
- const focusInside = breakdown.contains(focused());
- setBreakdownOpen(false);
- if (focusInside) breakdownToggle.focus();
- },
- { signal },
- );
- document.addEventListener(
- 'pointerdown',
- (event) => {
- if (!breakdown.hidden && !event.composedPath().includes(host)) setBreakdownOpen(false);
- },
- { signal },
- );
-
- let optionsKey = '';
- const renderOptions = (list: PanelOption[], selected: string[]) => {
- const key = JSON.stringify(list.map(({ id, name }) => [id, name]));
- if (key !== optionsKey) {
- optionsKey = key;
- const keepFocus = group.contains(focused());
- options().forEach((option) => option.remove());
- group.append(
- ...list.map(({ id, name }) =>
- h('button', {
- type: 'button',
- className: 'option',
- textContent: name,
- 'data-id': id,
- onClick: (event: MouseEvent) => pick(id, event),
- }),
- ),
- );
- if (keepFocus) requestAnimationFrame(() => group.querySelector('[aria-pressed="true"]')?.focus());
-
- const configured = list.length > 1;
- settings.classList.toggle('labelled', !configured);
- breakdownToggle.hidden = !configured;
- if (configured) {
- settings.replaceChildren(settingsIcon());
- settings.setAttribute('aria-label', i18n.t('panelSettings'));
- } else {
- settings.textContent = i18n.t('panelSetUp');
- settings.removeAttribute('aria-label');
- setBreakdownOpen(false);
- }
- }
-
- const current = focused()?.classList.contains('option') ? focused() : null;
- for (const option of options()) {
- const pressed = selected.includes(option.dataset.id ?? '');
- if (option.getAttribute('aria-pressed') !== String(pressed)) option.setAttribute('aria-pressed', String(pressed));
- option.tabIndex = (current ? option === current : option.dataset.id === selected[0]) ? 0 : -1;
- }
- moveIndicator();
- };
-
- const renderBreakdown = (rows: () => BreakdownRow[], selected: string[]) => {
- breakdownSource = { rows, selected };
- drawBreakdown();
- };
-
- const renderStats = (totals: Totals) => {
- visibleFiles.set(totals.visible);
- totalFiles.set(totals.total);
- additions.set(totals.additions);
- deletions.set(totals.deletions);
- time.set(Math.round(totals.minutes * 60));
- pending.classList.toggle('active', totals.pending > 0);
- pending.title = totals.pending > 0 ? i18n.t('panelNotLoaded', [format(totals.pending)]) : '';
- stats.title = pending.title;
- pendingText.textContent = pending.title ? ` ${pending.title}` : '';
- };
-
- const announce = ({
- name,
- visible,
- total,
- additions: added,
- deletions: removed,
- pending: waiting,
- minutes,
- }: Totals & { name: string }) => {
- const parts = [
- i18n.t('panelAnnounce', [name, format(visible), format(total), format(added), format(removed)]),
- i18n.t('panelAnnounceTime', [formatDuration(minutes)]),
- ];
- if (waiting > 0) parts.push(i18n.t('panelAnnouncePartial', [format(waiting)]));
- status.textContent = parts.join(' ');
- };
-
- const setVisible = (visible: boolean) => {
- const display = visible ? '' : 'none';
- if (host.style.display === display) return;
- host.style.display = display;
- if (visible) requestAnimationFrame(moveIndicator);
- else setBreakdownOpen(false);
- };
-
- const reset = () => {
- optionsKey = '';
- drawnKey = '';
- };
-
- setVisible(false);
- return { renderOptions, renderStats, renderBreakdown, announce, setVisible, reset };
-};
diff --git a/src/components/panel/panel.css b/src/components/panel/panel.css
index 6c55661..485651e 100644
--- a/src/components/panel/panel.css
+++ b/src/components/panel/panel.css
@@ -35,7 +35,7 @@
flex-wrap: wrap;
align-items: center;
gap: 8px;
- max-width: min(760px, calc(100vw - 32px));
+ max-width: min(1040px, calc(100vw - 32px));
padding: 6px;
border: 1px solid var(--fd-border);
border-radius: 12px;
@@ -44,22 +44,6 @@
color: var(--fd-fg);
font: 500 13px/20px var(--fd-font);
}
-
-.filters {
- position: relative;
- display: flex;
- flex-wrap: wrap;
- gap: 2px;
- isolation: isolate;
-}
-.indicator {
- position: absolute;
- inset: 0;
- z-index: -1;
- clip-path: inset(0 100% 100% 0 round 8px);
- background: var(--fd-selected-bg);
- pointer-events: none;
-}
button {
min-height: var(--fd-target);
padding: 0 12px;
@@ -73,20 +57,24 @@ button {
button:hover {
background: var(--fd-hover);
}
-button:focus-visible {
+button:focus-visible,
+a:focus-visible {
outline: 2px solid var(--fd-focus);
outline-offset: 1px;
}
-.option[aria-pressed='true'],
-.option[aria-pressed='true']:hover {
- background: transparent;
- color: var(--fd-selected-fg);
-}
-.filters.combined .indicator {
- opacity: 0;
+.option-count {
+ box-sizing: border-box;
+ min-width: 20px;
+ padding: 0 6px;
+ border-radius: 999px;
+ background: var(--fd-track);
+ color: var(--fd-muted);
+ font: 600 11px/18px var(--fd-font);
+ font-variant-numeric: tabular-nums;
+ text-align: center;
}
-.filters.combined .option[aria-pressed='true'] {
- background: var(--fd-selected-bg);
+.option-count:empty {
+ display: none;
}
.icon-button {
@@ -101,7 +89,7 @@ button:focus-visible {
color: var(--fd-fg);
}
-.breakdown {
+.popover {
position: absolute;
right: 0;
bottom: calc(100% + 8px);
@@ -116,149 +104,15 @@ button:focus-visible {
font: 400 14px/20px var(--fd-font);
font-variant-numeric: tabular-nums;
}
-.breakdown[hidden] {
+.popover[hidden] {
display: none;
}
-.breakdown h2 {
- margin: 0;
- padding: 12px 16px 8px;
- font: 600 14px/20px var(--fd-font);
-}
-.rows {
- display: grid;
- grid-template-columns: 16px minmax(96px, max-content) repeat(3, max-content);
- column-gap: 16px;
- max-height: min(52vh, 420px);
- padding: 0 8px;
- overflow-y: auto;
- overscroll-behavior: contain;
-}
-.columns,
-.row {
- display: grid;
- grid-column: 1 / -1;
- grid-template-columns: subgrid;
- align-items: center;
- padding: 0 8px;
-}
-.columns {
- padding-bottom: 6px;
- border-bottom: 1px solid var(--fd-border-muted);
- font-size: 12px;
- line-height: 16px;
- color: var(--fd-muted);
-}
-.columns > :nth-child(n + 3),
-.row > :nth-child(n + 3) {
- justify-self: end;
-}
-.row {
- min-height: 36px;
- border-radius: 6px;
- text-align: left;
- font-weight: 400;
-}
-.row + .row::before {
- content: '';
- position: absolute;
- inset: -1px 8px auto;
- border-top: 1px solid var(--fd-border-muted);
-}
-.row {
- position: relative;
-}
-.row:hover::before,
-.row:hover + .row::before {
- border-color: transparent;
-}
-.row-check {
- display: grid;
- color: var(--fd-accent);
-}
-.row[aria-pressed='true'] .row-name {
- font-weight: 600;
-}
-.row-lines {
- display: inline-flex;
- align-items: center;
- gap: 6px;
-}
-.diffstat {
- display: inline-flex;
- gap: 1px;
-}
-.diffstat span {
- width: 8px;
- height: 8px;
- border-radius: 1px;
- background: var(--fd-track);
-}
-.diffstat .add {
- background: var(--fd-add);
-}
-.diffstat .del {
- background: var(--fd-del);
-}
-.row-time {
- font-weight: 600;
-}
-.hint {
- margin: 0;
- padding: 8px 16px 12px;
- border-top: 1px solid var(--fd-border-muted);
- font-size: 12px;
- line-height: 18px;
- color: var(--fd-muted);
-}
-
-.stats {
- display: inline-flex;
- align-items: center;
- gap: 8px;
- padding: 0 4px 0 10px;
- border-left: 1px solid var(--fd-border);
- color: var(--fd-muted);
- font-variant-numeric: tabular-nums;
- white-space: nowrap;
-}
-.files {
- display: inline-flex;
- align-items: baseline;
-}
-.files-label {
- margin-left: 0.35em;
-}
-.number {
- display: inline-grid;
- font: 500 12px/20px var(--fd-mono);
- letter-spacing: -0.01em;
-}
-.number > * {
- grid-area: 1 / 1;
-}
-.ghost {
- visibility: hidden;
-}
-.visible {
- justify-items: end;
-}
.additions {
color: var(--fd-add);
}
.deletions {
color: var(--fd-del);
}
-.pending {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: var(--fd-attention);
- opacity: 0;
- transition: opacity 200ms ease-out;
-}
-.pending.active {
- opacity: 1;
-}
.settings.labelled {
padding: 0 12px;
@@ -276,12 +130,12 @@ button:focus-visible {
.panel {
transition: opacity 200ms ease-out;
}
-.breakdown {
+.popover {
transition: opacity 160ms ease-out;
}
@starting-style {
.panel,
- .breakdown {
+ .popover {
opacity: 0;
}
}
@@ -301,39 +155,22 @@ button {
translate: 0 8px;
}
}
- .breakdown {
+ .popover {
transition:
opacity 160ms ease-out,
translate 220ms var(--fd-ease);
}
@starting-style {
- .breakdown {
+ .popover {
translate: 0 6px;
}
}
- .indicator.ready {
- transition: clip-path 260ms var(--fd-ease);
- }
}
+
@media (forced-colors: active) {
.panel {
border-color: CanvasText;
}
- .indicator {
- background: Highlight;
- }
- .option[aria-pressed='true'] {
- color: HighlightText;
- }
- .row[aria-pressed='true'] {
- outline: 2px solid Highlight;
- }
- .diffstat span {
- border: 1px solid CanvasText;
- }
- .filters.combined .option[aria-pressed='true'] {
- background: Highlight;
- }
}
/* An arrow under a popover, pointing at the button that opened it. */
diff --git a/src/components/panel/panel.ts b/src/components/panel/panel.ts
new file mode 100644
index 0000000..efb36a3
--- /dev/null
+++ b/src/components/panel/panel.ts
@@ -0,0 +1,158 @@
+import './panel.css';
+import { i18n } from '#i18n';
+import { settingsIcon } from '@/components/icons';
+import { h } from '@/utils/dom';
+import { formatDuration, formatNumber as format } from '@/utils/format';
+import { createBreakdown, type BreakdownRow } from '@/components/breakdown/breakdown';
+import { createConversations, type Conversations } from '@/components/conversations/conversations';
+import { createFilters, type PanelOption } from '@/components/filters/filters';
+import { createStats, type Totals } from '@/components/stats/stats';
+import { createUpdateNotice } from '@/components/update-notice/update-notice';
+
+/** What every part of the panel shares: where it's mounted, when it's torn down, and what has focus inside it. */
+export interface PanelContext {
+ host: HTMLElement;
+ signal: AbortSignal;
+ focused: () => HTMLElement | null;
+}
+
+export interface PanelActions {
+ onToggle: (id: string) => void;
+ onSettings: () => void;
+ onComment: (step: 1 | -1) => void;
+ /** The reader opened or dismissed the update notice. */
+ onUpdateSeen: () => void;
+}
+
+export interface Panel {
+ renderOptions: (options: PanelOption[], selected: string[]) => void;
+ renderStats: (totals: Totals) => void;
+ renderConversations: (conversations: Conversations) => void;
+ /** Rows are only computed while the breakdown is open. */
+ renderBreakdown: (rows: () => BreakdownRow[], selected: string[]) => void;
+ announce: (summary: Totals & { name: string }) => void;
+ /** Reads out a short message, like where a jump landed. */
+ announceText: (text: string) => void;
+ setVisible: (visible: boolean) => void;
+ /** Points to the notes of a release the reader hasn't seen, or hides the notice with `null`. */
+ showUpdate: (version: string | null) => void;
+}
+
+interface PanelMount {
+ root: ShadowRoot;
+ container: HTMLElement;
+ host: HTMLElement;
+ signal: AbortSignal;
+}
+
+/**
+ * The floating bar: filters, stats, the breakdown, conversations, the update notice and settings, in that order. Each
+ * part lives in its own module; this one lays them out and settles what they share, like which popover is open.
+ */
+export const createPanel = (
+ { root, container, host, signal }: PanelMount,
+ { onToggle, onSettings, onComment, onUpdateSeen }: PanelActions,
+): Panel => {
+ const context: PanelContext = { host, signal, focused: () => root.activeElement as HTMLElement | null };
+
+ const settings = h('button', {
+ type: 'button',
+ className: 'settings icon-button',
+ title: i18n.t('panelSettings'),
+ onClick: () => onSettings(),
+ });
+ const filters = createFilters(context, onToggle);
+ const stats = createStats(context);
+ const breakdown = createBreakdown(context, { onToggle, onOpen: () => conversations.setOpen(false), fallback: settings });
+ const conversations = createConversations(context, { onStep: onComment, onOpen: () => breakdown.setOpen(false), fallback: settings });
+ const update = createUpdateNotice(context, { onSeen: onUpdateSeen, fallback: settings });
+ const status = h('span', { className: 'visually-hidden', role: 'status' });
+ // The breakdown follows its toggle so Tab moves straight into it; it's positioned against the host.
+ container.append(
+ h(
+ 'div',
+ { className: 'panel' },
+ filters.element,
+ stats.element,
+ breakdown.toggle,
+ breakdown.popover,
+ conversations.element,
+ update.element,
+ settings,
+ status,
+ ),
+ );
+
+ const resizeObserver = new ResizeObserver(() => {
+ filters.moveIndicator();
+ breakdown.reposition();
+ conversations.reposition();
+ });
+ for (const element of [filters.element, breakdown.popover, conversations.popover]) resizeObserver.observe(element);
+ signal.addEventListener('abort', () => resizeObserver.disconnect());
+
+ document.addEventListener(
+ 'keydown',
+ (event) => {
+ if (event.key !== 'Escape' || breakdown.dismiss()) return;
+ // Escape elsewhere on the page belongs to GitHub, like cancelling a reply.
+ if (conversations.isOpen() && context.focused()) conversations.setOpen(false);
+ },
+ { signal },
+ );
+ document.addEventListener(
+ 'pointerdown',
+ (event) => {
+ if (breakdown.isOpen() && !event.composedPath().includes(host)) breakdown.setOpen(false);
+ },
+ { signal },
+ );
+
+ /** Without filters there's nothing to break down, and settings becomes a labelled call to set them up. */
+ const renderOptions = (list: PanelOption[], selected: string[]) => {
+ if (!filters.render(list, selected)) return;
+ const configured = list.length > 1;
+ settings.classList.toggle('labelled', !configured);
+ breakdown.toggle.hidden = !configured;
+ if (configured) {
+ settings.replaceChildren(settingsIcon());
+ settings.setAttribute('aria-label', i18n.t('panelSettings'));
+ } else {
+ settings.textContent = i18n.t('panelSetUp');
+ settings.removeAttribute('aria-label');
+ breakdown.setOpen(false);
+ }
+ };
+
+ const announce = ({ name, visible, total, additions, deletions, pending, minutes }: Totals & { name: string }) => {
+ const parts = [
+ i18n.t('panelAnnounce', [name, format(visible), format(total), format(additions), format(deletions)]),
+ i18n.t('panelAnnounceTime', [formatDuration(minutes)]),
+ ];
+ if (pending > 0) parts.push(i18n.t('panelAnnouncePartial', pending, [format(pending)]));
+ status.textContent = parts.join(' ');
+ };
+
+ const setVisible = (visible: boolean) => {
+ const display = visible ? '' : 'none';
+ if (host.style.display === display) return;
+ host.style.display = display;
+ if (visible) requestAnimationFrame(filters.moveIndicator);
+ else {
+ breakdown.setOpen(false);
+ conversations.setOpen(false);
+ }
+ };
+ setVisible(false);
+
+ return {
+ renderOptions,
+ renderStats: stats.render,
+ renderConversations: conversations.render,
+ renderBreakdown: breakdown.render,
+ announce,
+ announceText: (text) => (status.textContent = text),
+ setVisible,
+ showUpdate: update.show,
+ };
+};
diff --git a/src/components/panel/popover.ts b/src/components/panel/popover.ts
deleted file mode 100644
index b1d5a47..0000000
--- a/src/components/panel/popover.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-const EDGE = 18;
-
-/** Points a popover's arrow at the centre of the button that opened it, kept clear of the rounded corners. */
-export const pointAt = (popover: HTMLElement, trigger: HTMLElement): void => {
- if (popover.hidden || !trigger.offsetWidth) return;
- const box = popover.getBoundingClientRect();
- const target = trigger.getBoundingClientRect();
- const x = Math.min(Math.max(target.left + target.width / 2 - box.left, EDGE), box.width - EDGE);
- popover.style.setProperty('--fd-arrow-x', `${Math.round(x)}px`);
-};
diff --git a/src/components/popover.ts b/src/components/popover.ts
new file mode 100644
index 0000000..7d27218
--- /dev/null
+++ b/src/components/popover.ts
@@ -0,0 +1,33 @@
+const EDGE = 18;
+
+/** Points a popover's arrow at the centre of the button that opened it, kept clear of the rounded corners. */
+export const pointAt = (popover: HTMLElement, trigger: HTMLElement): void => {
+ if (popover.hidden || !trigger.offsetWidth) return;
+ const box = popover.getBoundingClientRect();
+ const target = trigger.getBoundingClientRect();
+ const x = Math.min(Math.max(target.left + target.width / 2 - box.left, EDGE), box.width - EDGE);
+ popover.style.setProperty('--fd-arrow-x', `${Math.round(x)}px`);
+};
+
+const SCREEN_MARGIN = 16;
+
+/**
+ * Centres an absolutely positioned popover over the button that opened it, kept on screen, then points its arrow at
+ * the button. `offsetParent` is the element the popover is positioned against.
+ */
+export const centreOver = (popover: HTMLElement, trigger: HTMLElement, offsetParent: HTMLElement): void => {
+ if (popover.hidden || !trigger.offsetWidth) return;
+ const parent = offsetParent.getBoundingClientRect();
+ const target = trigger.getBoundingClientRect();
+ const width = popover.getBoundingClientRect().width;
+ const ideal = target.left + target.width / 2 - width / 2;
+ const left = Math.min(Math.max(ideal, SCREEN_MARGIN), innerWidth - SCREEN_MARGIN - width);
+ popover.style.left = `${Math.round(left - parent.left)}px`;
+ popover.style.right = 'auto';
+ pointAt(popover, trigger);
+};
+
+/** Focus inside a closing popover goes back to its toggle, or to `fallback` when the toggle is gone too. */
+export const returnFocus = (popover: HTMLElement, toggle: HTMLElement, fallback: HTMLElement, focused: HTMLElement | null): void => {
+ if (popover.contains(focused)) (toggle.hidden ? fallback : toggle).focus();
+};
diff --git a/src/components/panel/counter.ts b/src/components/stats/counter.ts
similarity index 74%
rename from src/components/panel/counter.ts
rename to src/components/stats/counter.ts
index 08baa9d..4106c50 100644
--- a/src/components/panel/counter.ts
+++ b/src/components/stats/counter.ts
@@ -5,23 +5,18 @@ const COUNT_MS = 300;
const prefersReducedMotion = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
const easeOutExpo = (progress: number) => (progress === 1 ? 1 : 1 - 2 ** (-10 * progress));
-export interface Counter {
+interface Counter {
element: HTMLSpanElement;
set: (value: number) => void;
}
-/** A number that counts towards new values and reserves the width of the longest one, so the panel never shifts. */
+/** A number that counts towards new values. The stats block around it reserves width, so the panel never shifts. */
export const counter = (className: string, render: (value: number) => string): Counter => {
- const ghost = h('span', { className: 'ghost', 'aria-hidden': 'true' });
const output = h('span');
- const element = h('span', { className: `number ${className}` }, ghost, output);
+ const element = h('span', { className: `number ${className}` }, output);
let value: number | null = null;
let frame = 0;
- const reserve = (text: string) => {
- if (text.length > (ghost.textContent ?? '').length) ghost.textContent = text;
- };
-
const paint = (current: number) => {
output.textContent = render(current);
};
@@ -31,7 +26,6 @@ export const counter = (className: string, render: (value: number) => string): C
cancelAnimationFrame(frame);
const from = value;
value = next;
- reserve(render(Math.max(from ?? 0, next)));
if (from === null || prefersReducedMotion()) return paint(next);
const start = performance.now();
const step = (now: number) => {
diff --git a/src/components/stats/stats.css b/src/components/stats/stats.css
new file mode 100644
index 0000000..fc7087d
--- /dev/null
+++ b/src/components/stats/stats.css
@@ -0,0 +1,44 @@
+.stats {
+ box-sizing: border-box;
+ display: inline-flex;
+ align-items: center;
+ gap: 14px;
+ padding: 0 4px 0 10px;
+ border-left: 1px solid var(--fd-border);
+ color: var(--fd-muted);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+.files {
+ display: inline-flex;
+ align-items: baseline;
+}
+.files-label {
+ margin-left: 0.35em;
+}
+.files .pending {
+ align-self: center;
+ margin-left: 6px;
+}
+.lines {
+ display: inline-flex;
+ gap: 6px;
+}
+.number {
+ display: inline-grid;
+ font-weight: 500;
+ font-variant-numeric: tabular-nums;
+}
+.visible {
+ justify-items: end;
+}
+.pending {
+ display: none;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--fd-attention);
+}
+.pending.active {
+ display: block;
+}
diff --git a/src/components/stats/stats.ts b/src/components/stats/stats.ts
new file mode 100644
index 0000000..0f8bc10
--- /dev/null
+++ b/src/components/stats/stats.ts
@@ -0,0 +1,84 @@
+import './stats.css';
+import { i18n } from '#i18n';
+import { h } from '@/utils/dom';
+import { formatDuration, formatNumber as format } from '@/utils/format';
+import { LINES_PER_HOUR } from '@/utils/review-time';
+import { counter } from './counter';
+import type { PanelContext } from '@/components/panel/panel';
+
+export interface Totals {
+ visible: number;
+ total: number;
+ additions: number;
+ deletions: number;
+ pending: number;
+ /** Estimated review time, see utils/review-time.ts. */
+ minutes: number;
+ /** Files marked as viewed on GitHub, and the estimate for the rest. */
+ viewed: number;
+ minutesLeft: number;
+}
+
+/** Shown files out of all, lines added and removed, and the time left to review them. */
+export const createStats = ({ host, signal }: PanelContext) => {
+ const visibleFiles = counter('visible', format);
+ const totalFiles = counter('total', format);
+ const additions = counter('additions', (n) => `+${format(n)}`);
+ const deletions = counter('deletions', (n) => `−${format(n)}`);
+ const time = counter('time', (seconds) => (seconds === 0 ? i18n.t('timeDone') : formatDuration(seconds / 60)));
+ const timeLabel = h('span', { className: 'visually-hidden', textContent: ` ${i18n.t('panelTimeLabel')}` });
+ const pending = h('span', { className: 'pending', 'aria-hidden': 'true' });
+ const pendingText = h('span', { className: 'visually-hidden' });
+ const element = h(
+ 'span',
+ { className: 'stats' },
+ h(
+ 'span',
+ { className: 'files' },
+ visibleFiles.element,
+ h('span', { className: 'number', textContent: '/' }),
+ totalFiles.element,
+ h('span', { className: 'files-label', textContent: ` ${i18n.t('panelFilesLabel')}` }),
+ pending,
+ pendingText,
+ ),
+ h(
+ 'span',
+ { className: 'lines' },
+ additions.element,
+ h('span', { className: 'visually-hidden', textContent: ` ${i18n.t('panelLinesAdded')}` }),
+ deletions.element,
+ h('span', { className: 'visually-hidden', textContent: ` ${i18n.t('panelLinesRemoved')}` }),
+ ),
+ h('span', { className: 'time-wrap', title: i18n.t('timeHint', [LINES_PER_HOUR]) }, time.element, timeLabel),
+ );
+
+ /** The block only grows: shorter numbers leave room at its end instead of shifting the buttons after it. */
+ let reservedWidth = 0;
+ const reserveWidth = () => {
+ // WXT injects the stylesheet asynchronously; before it lands, hidden labels are inline and inflate the width.
+ if (host.style.display === 'none' || getComputedStyle(pendingText).position !== 'absolute') return;
+ const width = Math.ceil(element.getBoundingClientRect().width);
+ if (width <= reservedWidth) return;
+ reservedWidth = width;
+ element.style.minWidth = `${width}px`;
+ };
+ const resizeObserver = new ResizeObserver(reserveWidth);
+ resizeObserver.observe(element);
+ signal.addEventListener('abort', () => resizeObserver.disconnect());
+
+ const render = (totals: Totals) => {
+ visibleFiles.set(totals.visible);
+ totalFiles.set(totals.total);
+ additions.set(totals.additions);
+ deletions.set(totals.deletions);
+ time.set(Math.round(totals.minutesLeft * 60));
+ timeLabel.textContent = totals.minutesLeft === 0 ? '' : ` ${i18n.t('panelTimeLabel')}`;
+ pending.classList.toggle('active', totals.pending > 0);
+ pending.title = totals.pending > 0 ? i18n.t('panelNotLoaded', totals.pending, [format(totals.pending)]) : '';
+ element.title = pending.title;
+ pendingText.textContent = pending.title ? ` ${pending.title}` : '';
+ };
+
+ return { element, render };
+};
diff --git a/src/components/toast/toast.css b/src/components/toast/toast.css
new file mode 100644
index 0000000..04b9a30
--- /dev/null
+++ b/src/components/toast/toast.css
@@ -0,0 +1,34 @@
+.toast {
+ position: fixed;
+ left: 50%;
+ bottom: calc(72px + env(safe-area-inset-bottom, 0px));
+ max-width: calc(100vw - 32px);
+ padding: 10px 16px;
+ border-radius: 8px;
+ background: var(--fg);
+ color: var(--bg);
+ font-weight: 600;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
+ transform: translate(-50%, 8px);
+ opacity: 0;
+ pointer-events: none;
+}
+.toast.visible {
+ transform: translate(-50%, 0);
+ opacity: 1;
+}
+.toast:empty {
+ display: none;
+}
+@media (prefers-reduced-motion: no-preference) {
+ .toast {
+ transition:
+ opacity 160ms ease-out,
+ transform 200ms cubic-bezier(0.16, 1, 0.3, 1);
+ }
+}
+@media (forced-colors: active) {
+ .toast {
+ border: 1px solid CanvasText;
+ }
+}
diff --git a/src/components/toast/toast.ts b/src/components/toast/toast.ts
new file mode 100644
index 0000000..35a12e6
--- /dev/null
+++ b/src/components/toast/toast.ts
@@ -0,0 +1,14 @@
+import './toast.css';
+
+const TOAST_MS = 2800;
+
+/** A short message at the bottom of the page that fades out on its own. `toast` is the page's `.toast` live region. */
+export const toaster = (toast: HTMLElement) => {
+ let timer: ReturnType | undefined;
+ return (text: string) => {
+ clearTimeout(timer);
+ toast.textContent = text;
+ toast.classList.add('visible');
+ timer = setTimeout(() => toast.classList.remove('visible'), TOAST_MS);
+ };
+};
diff --git a/src/components/update-notice/update-notice.css b/src/components/update-notice/update-notice.css
new file mode 100644
index 0000000..a5fd8f6
--- /dev/null
+++ b/src/components/update-notice/update-notice.css
@@ -0,0 +1,33 @@
+.update {
+ display: inline-flex;
+ align-items: center;
+ border-radius: 6px;
+ background: color-mix(in srgb, var(--fd-accent) 10%, transparent);
+}
+.update[hidden] {
+ display: none;
+}
+.update-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ min-height: var(--fd-target);
+ padding: 0 4px 0 10px;
+ border-radius: 6px;
+ color: var(--fd-accent);
+ font-weight: 600;
+ text-decoration: none;
+ white-space: nowrap;
+}
+.update-link:hover {
+ text-decoration: underline;
+}
+.update-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+}
+.update-dismiss {
+ min-width: 28px;
+}
diff --git a/src/components/update-notice/update-notice.ts b/src/components/update-notice/update-notice.ts
new file mode 100644
index 0000000..48d4c91
--- /dev/null
+++ b/src/components/update-notice/update-notice.ts
@@ -0,0 +1,47 @@
+import './update-notice.css';
+import { i18n } from '#i18n';
+import { closeIcon } from '@/components/icons';
+import { h } from '@/utils/dom';
+import type { PanelContext } from '@/components/panel/panel';
+
+const RELEASES = 'https://github.com/hyanmandian/focus-diff/releases/tag/v';
+
+/** "New in 1.1", linking to that release's notes, until the reader opens or dismisses it. */
+export const createUpdateNotice = ({ focused }: PanelContext, { onSeen, fallback }: { onSeen: () => void; fallback: HTMLElement }) => {
+ const text = h('span');
+ const link = h(
+ 'a',
+ { className: 'update-link', target: '_blank', rel: 'noopener', onClick: () => onSeen() },
+ h('span', { className: 'update-dot', 'aria-hidden': 'true' }),
+ text,
+ );
+ const element = h(
+ 'div',
+ { className: 'update', hidden: true },
+ link,
+ h(
+ 'button',
+ {
+ type: 'button',
+ className: 'icon-button update-dismiss',
+ 'aria-label': i18n.t('panelUpdateDismiss'),
+ title: i18n.t('panelUpdateDismiss'),
+ onClick: () => onSeen(),
+ },
+ closeIcon(),
+ ),
+ );
+
+ const show = (version: string | null) => {
+ if (!version) {
+ if (element.contains(focused())) fallback.focus();
+ element.hidden = true;
+ return;
+ }
+ link.href = `${RELEASES}${version}`;
+ text.textContent = i18n.t('panelUpdate', [version.split('.').slice(0, 2).join('.')]);
+ element.hidden = false;
+ };
+
+ return { element, show };
+};
diff --git a/src/entrypoints/github.content/controller.ts b/src/content/controller.ts
similarity index 60%
rename from src/entrypoints/github.content/controller.ts
rename to src/content/controller.ts
index 3908727..25c0888 100644
--- a/src/entrypoints/github.content/controller.ts
+++ b/src/content/controller.ts
@@ -1,13 +1,14 @@
import { i18n } from '#i18n';
import type { ContentScriptContext } from '#imports';
import { browser } from 'wxt/browser';
-import type { Panel, Totals } from '@/components/panel';
+import type { Panel } from '@/components/panel/panel';
+import type { Totals } from '@/components/stats/stats';
import { ALL, filtersFor, normalize, toMatcher, type Config, type Matcher } from '@/utils/filters';
import * as page from '@/utils/github';
-import type { FileStats } from '@/utils/github';
-import { reviewMinutes } from '@/utils/review-time';
import type { Message } from '@/utils/messages';
-import { configItem, loadConfig, selectionsItem, type Selections } from '@/utils/storage';
+import { configItem, loadConfig, selectionsItem, updateItem, type Selections } from '@/utils/storage';
+import { collectFiles, everything, totalsFor, type FileInfo, type Files } from '@/content/files';
+import { createNavigation } from '@/content/navigation';
interface Option {
id: string;
@@ -15,21 +16,25 @@ interface Option {
matches?: Matcher;
}
-interface LoadedDiff {
- path: string;
- container: HTMLElement;
- stats: FileStats | null;
-}
-
/** GitHub keeps rendering while a pull request loads; page changes wait for idle time, at most this long. */
const IDLE_TIMEOUT_MS = 200;
-const everything: Matcher = () => true;
+const DIMMED_OPACITY = '0.35';
const show = (element: HTMLElement, visible: boolean) => {
const display = visible ? '' : 'none';
if (element.style.display !== display) element.style.display = display;
};
+/**
+ * Filters a rendered diff. In the newer, virtualized list every file keeps an absolute position, so hiding one would
+ * leave a gap the size of the file; there, files outside the filter are dimmed instead.
+ */
+const present = (element: HTMLElement, visible: boolean, virtualized: boolean) => {
+ show(element, visible || virtualized);
+ const opacity = visible || !virtualized ? '' : DIMMED_OPACITY;
+ if (element.style.opacity !== opacity) element.style.opacity = opacity;
+};
+
const COUNTER_COPY = 'data-focus-diff-counter';
/** GitHub's counters use English number formatting whatever the browser language. */
@@ -61,35 +66,20 @@ const mirror = (original: HTMLElement | null, value: string | null) => {
show(original, false);
};
-const totalsFor = (diffs: LoadedDiff[], matches: Matcher, reported: number): Totals => {
- const total = Math.max(diffs.length, reported);
- const totals = { visible: 0, total, additions: 0, deletions: 0, pending: 0, minutes: 0 };
- for (const diff of diffs) {
- if (!matches(diff.path)) continue;
- totals.visible++;
- if (!diff.stats) totals.pending++;
- totals.additions += diff.stats?.additions ?? 0;
- totals.deletions += diff.stats?.deletions ?? 0;
- totals.minutes += reviewMinutes(diff.path, diff.stats);
- }
- if (matches === everything) {
- totals.pending += total - diffs.length;
- totals.visible = total;
- }
- return totals;
-};
-
-const filterTree = (matches: Matcher, filtering: boolean, visiblePaths: string[]) => {
- const files = page.treeFiles();
- for (const file of files) show(file.element, matches(file.path));
- const shown = [...visiblePaths, ...files.filter((file) => file.element.style.display !== 'none').map((file) => file.path)];
- for (const folder of page.treeFolders()) {
- const prefix = `${folder.path}/`;
- show(
- folder.element,
- !filtering || shown.some((path) => path.startsWith(prefix)) || folder.files.some((file) => file.style.display !== 'none'),
- );
+/** Hides tree files outside the filter, and folders left with nothing to show. */
+const filterTree = (matches: Matcher, filtering: boolean, shownPaths: string[], pathByDigest: Map) => {
+ const folders = new Set();
+ const keepFoldersOf = (path: string) => {
+ for (let end = path.lastIndexOf('/'); end > 0; end = path.lastIndexOf('/', end - 1)) folders.add(path.slice(0, end));
+ };
+ shownPaths.forEach(keepFoldersOf);
+ for (const file of page.treeFiles()) {
+ const path = pathByDigest.get(file.digest) ?? page.treePathOf(file.element);
+ const visible = matches(path);
+ show(file.element, visible);
+ if (visible) keepFoldersOf(path);
}
+ for (const folder of page.treeFolders()) show(folder.element, !filtering || folders.has(folder.path));
};
const updatePageCounters = (totals: Totals, filtering: boolean) => {
@@ -106,7 +96,7 @@ const updatePageCounters = (totals: Totals, filtering: boolean) => {
/** Puts the page back the way GitHub drew it. */
const restorePage = () => {
- for (const diff of page.diffs()) show(diff.container, true);
+ for (const diff of page.diffs()) present(diff.container, true, false);
for (const item of page.treeFiles()) show(item.element, true);
for (const folder of page.treeFolders()) show(folder.element, true);
Object.values(page.pageCounters()).forEach((counter) => mirror(counter, null));
@@ -126,9 +116,10 @@ const memoize = (matches: Matcher): Matcher => {
};
export interface Controller {
- select: (id: string) => void;
toggle: (id: string) => void;
openSettings: () => void;
+ comment: (step: 1 | -1) => void;
+ dismissUpdate: () => void;
}
export const startController = async (ctx: ContentScriptContext, panel: Panel): Promise => {
@@ -186,6 +177,11 @@ export const startController = async (ctx: ContentScriptContext, panel: Panel):
};
let pageChanged = false;
+ let selectionKey = '';
+ let files: Files = { list: [], complete: false };
+ let shown: FileInfo[] = [];
+ const navigation = createNavigation(panel, () => schedule());
+ const comment = (step: 1 | -1) => void navigation.comment(shown, files.complete, step);
const apply = () => {
pending = null;
@@ -206,22 +202,37 @@ export const startController = async (ctx: ContentScriptContext, panel: Panel):
const matches: Matcher = filtering ? (path) => selected.some((option) => option.matches?.(path)) : everything;
const selection = filtering ? selected.map((option) => option.id) : [ALL];
- const diffs: LoadedDiff[] = page.diffs().map((diff) => ({ path: diff.path, container: diff.container, stats: diff.stats() }));
+ files = collectFiles();
const reported = page.reportedFileCount();
- const totals = totalsFor(diffs, matches, reported);
- panel.renderOptions(options, selection);
- const shown: string[] = [];
- for (const diff of diffs) {
- const visible = matches(diff.path);
- show(diff.container, visible);
- if (visible) shown.push(diff.path);
+ const totals = totalsFor(files, matches, reported);
+ panel.renderOptions(
+ options.map((option) => ({
+ ...option,
+ count: option.matches ? files.list.filter((file) => option.matches?.(file.path)).length : totals.total,
+ })),
+ selection,
+ );
+ const virtualized = page.isVirtualized();
+ shown = files.list.filter((file) => matches(file.path));
+ for (const file of files.list) if (file.diff) present(file.diff.container, matches(file.path), virtualized);
+ filterTree(
+ matches,
+ filtering,
+ shown.map((file) => file.path),
+ new Map(files.list.filter((file) => file.digest).map((file) => [file.digest, file.path])),
+ );
+ // A new pull request or a new selection starts the conversation count over.
+ const key = `${location.pathname} ${selection.join()}`;
+ if (selectionKey !== key) {
+ selectionKey = key;
+ navigation.reset();
}
- filterTree(matches, filtering, shown);
+ panel.renderConversations(navigation.position(shown, files.complete));
updatePageCounters(totals, filtering);
pageChanged = filtering;
panel.renderStats(totals);
panel.renderBreakdown(
- () => options.map((option) => ({ id: option.id, name: option.name, ...totalsFor(diffs, option.matches ?? everything, reported) })),
+ () => options.map((option) => ({ id: option.id, name: option.name, ...totalsFor(files, option.matches ?? everything, reported) })),
selection,
);
@@ -232,24 +243,44 @@ export const startController = async (ctx: ContentScriptContext, panel: Panel):
}
};
- /** Reader actions apply on the next frame; changes GitHub makes to the page wait for idle time. */
+ /**
+ * Reader actions apply on the next frame; changes GitHub makes to the page wait for idle time. A frame request
+ * overtakes a pending idle one, which then does nothing.
+ */
+ let ticket = 0;
const schedule = (when: 'frame' | 'idle' = 'frame') => {
if (ctx.isInvalid || pending === 'frame' || pending === when) return;
pending = when;
- if (when === 'frame') ctx.requestAnimationFrame(apply);
- else ctx.requestIdleCallback(apply, { timeout: IDLE_TIMEOUT_MS });
+ const mine = ++ticket;
+ const run = () => mine === ticket && apply();
+ if (when === 'frame') ctx.requestAnimationFrame(run);
+ // Safari has no requestIdleCallback; a short timeout keeps page changes batched there.
+ else if ('requestIdleCallback' in window) ctx.requestIdleCallback(run, { timeout: IDLE_TIMEOUT_MS });
+ else ctx.setTimeout(run, IDLE_TIMEOUT_MS / 4);
};
- /** New diffs get filtered on the next frame so they never flash in; any other change waits for idle time. */
+ const changesElements = (record: MutationRecord) =>
+ record.type === 'attributes' || [...record.addedNodes, ...record.removedNodes].some((node) => node instanceof Element);
+
+ /** New diffs get filtered on the next frame so they never flash in; text-only changes, like ticking timestamps, are ignored. */
const pageObserver = new MutationObserver((records) => {
- const addsDiff = records.some((record) => [...record.addedNodes].some(page.containsDiff));
- schedule(addsDiff ? 'frame' : 'idle');
+ const relevant = records.filter(changesElements);
+ if (!relevant.length) return;
+ const urgent = relevant.some((record) => record.type === 'attributes' || [...record.addedNodes].some(page.containsDiff));
+ schedule(urgent ? 'frame' : 'idle');
});
let observing = false;
const observe = (active: boolean) => {
if (active === observing) return;
observing = active;
- if (active) pageObserver.observe(document.documentElement, { childList: true, subtree: true });
+ // Viewed toggles flip aria-pressed in the new diff view; the classic one only fires `change` (below).
+ if (active)
+ pageObserver.observe(document.documentElement, {
+ childList: true,
+ subtree: true,
+ attributes: true,
+ attributeFilter: ['aria-pressed'],
+ });
else pageObserver.disconnect();
};
@@ -258,20 +289,25 @@ export const startController = async (ctx: ContentScriptContext, panel: Panel):
if (message.command === 'next-filter') step(1);
if (message.command === 'previous-filter') step(-1);
if (message.command === 'show-all') select(ALL);
+ if (message.command === 'next-comment') comment(1);
+ if (message.command === 'previous-comment') comment(-1);
};
browser.runtime.onMessage.addListener(onMessage);
const unwatchConfig = configItem.watch((value) => {
config = normalize(value);
- panel.reset();
schedule();
});
const unwatchSelections = selectionsItem.watch((value) => {
selections = value;
schedule();
});
+ panel.showUpdate(await updateItem.getValue());
+ const unwatchUpdate = updateItem.watch((version) => panel.showUpdate(version));
+ const dismissUpdate = () => void updateItem.setValue(null);
ctx.addEventListener(window, 'wxt:locationchange', () => schedule());
+ ctx.addEventListener(document, 'change', () => schedule(), { capture: true });
ctx.onInvalidated(() => {
pageObserver.disconnect();
if (pageChanged) restorePage();
@@ -279,8 +315,9 @@ export const startController = async (ctx: ContentScriptContext, panel: Panel):
browser.runtime.onMessage.removeListener(onMessage);
unwatchConfig();
unwatchSelections();
+ unwatchUpdate();
});
schedule();
- return { select, toggle, openSettings };
+ return { toggle, openSettings, comment, dismissUpdate };
};
diff --git a/src/content/files.ts b/src/content/files.ts
new file mode 100644
index 0000000..9c049ee
--- /dev/null
+++ b/src/content/files.ts
@@ -0,0 +1,91 @@
+import type { Totals } from '@/components/stats/stats';
+import type { Matcher } from '@/utils/filters';
+import * as page from '@/utils/github';
+import { reviewMinutes } from '@/utils/review-time';
+
+export interface FileInfo {
+ path: string;
+ /** Used for `#diff-` anchors; empty when unknown. */
+ digest: string;
+ stats: page.FileStats | null;
+ viewed: boolean;
+ /** The rendered diff, when GitHub has it in the page. */
+ diff: { element: HTMLElement; container: HTMLElement } | null;
+ /** Review threads, known up front only in the newer diff view. */
+ threads: page.ThreadSummary[];
+}
+
+export interface Files {
+ list: FileInfo[];
+ /** True when the list covers every changed file, not only the rendered ones. */
+ complete: boolean;
+}
+
+const viewedByPath = new Map();
+let viewedPathname = '';
+
+/**
+ * Every changed file the page knows about. GitHub's embedded data lists them all, with exact line counts; the classic
+ * view only has what it rendered. Viewed toggles seen in the page win over the embedded data, which is fixed at load.
+ */
+export const collectFiles = (): Files => {
+ if (viewedPathname !== location.pathname) {
+ viewedByPath.clear();
+ viewedPathname = location.pathname;
+ }
+ const rendered = new Map(
+ page.diffs().map((diff) => {
+ const viewed = page.viewed(diff);
+ if (viewed !== null) viewedByPath.set(diff.path, viewed);
+ return [diff.path, { element: diff.element as HTMLElement, container: diff.container, stats: diff.stats }] as const;
+ }),
+ );
+ const data = page.pullRequestData();
+ if (data) {
+ const list = data.files.map((file) => ({
+ path: file.path,
+ digest: file.digest,
+ stats: { additions: file.additions, deletions: file.deletions },
+ viewed: viewedByPath.get(file.path) ?? file.viewed,
+ diff: rendered.get(file.path) ?? null,
+ threads: file.threads,
+ }));
+ return { list, complete: true };
+ }
+ const list = [...rendered].map(([path, diff]) => ({
+ path,
+ digest: diff.element.id.startsWith('diff-') ? diff.element.id.slice('diff-'.length) : '',
+ // Only the classic view reads line counts from the page; the embedded data already has them.
+ stats: diff.stats(),
+ viewed: viewedByPath.get(path) ?? false,
+ diff: { element: diff.element, container: diff.container },
+ threads: [],
+ }));
+ return { list, complete: false };
+};
+
+export const everything: Matcher = () => true;
+
+export const totalsFor = ({ list, complete }: Files, matches: Matcher, reported: number): Totals => {
+ const total = complete ? list.length : Math.max(list.length, reported);
+ const totals = { visible: 0, total, additions: 0, deletions: 0, pending: 0, minutes: 0, viewed: 0, minutesLeft: 0 };
+ for (const file of list) {
+ if (!matches(file.path)) continue;
+ totals.visible++;
+ if (!file.stats) totals.pending++;
+ totals.additions += file.stats?.additions ?? 0;
+ totals.deletions += file.stats?.deletions ?? 0;
+ const minutes = reviewMinutes(file.path, file.stats);
+ totals.minutes += minutes;
+ if (file.viewed) totals.viewed++;
+ else totals.minutesLeft += minutes;
+ }
+ if (matches === everything && !complete) {
+ const unrendered = total - list.length;
+ totals.pending += unrendered;
+ totals.visible = total;
+ totals.minutes += unrendered * reviewMinutes('', null);
+ totals.minutesLeft += unrendered * reviewMinutes('', null);
+ }
+ return totals;
+};
diff --git a/src/content/navigation.ts b/src/content/navigation.ts
new file mode 100644
index 0000000..38d0ade
--- /dev/null
+++ b/src/content/navigation.ts
@@ -0,0 +1,149 @@
+import { i18n } from '#i18n';
+import type { Conversation } from '@/components/conversations/conversations';
+import type { Panel } from '@/components/panel/panel';
+import * as page from '@/utils/github';
+import type { FileInfo } from '@/content/files';
+
+const RENDER_TIMEOUT_MS = 2000;
+const EXPAND_TIMEOUT_MS = 1500;
+
+const waitFor = (find: () => T | null, timeout = RENDER_TIMEOUT_MS): Promise =>
+ new Promise((resolve) => {
+ const started = performance.now();
+ const check = () => {
+ const found = find();
+ if (found || performance.now() - started > timeout) return resolve(found);
+ requestAnimationFrame(check);
+ };
+ check();
+ });
+
+/** Brings a file to the top of the screen. Files GitHub hasn't rendered are opened through the file tree's link. */
+const goToFile = async (file: FileInfo): Promise => {
+ if (file.diff?.element.isConnected) {
+ page.scrollToTop(file.diff.container);
+ return file.diff.element;
+ }
+ if (!file.digest) return null;
+ // With its folder collapsed or the tree closed, the file's anchor still takes GitHub there.
+ const link = page.treeLink(file.digest);
+ if (link) link.click();
+ else location.hash = `diff-${file.digest}`;
+ return waitFor(() => page.diffByDigest(file.digest));
+};
+
+export const createNavigation = (panel: Panel, schedule: () => void) => {
+ /** 1-based position of the conversation the reader last jumped to, 0 before any jump. */
+ let commentIndex = 0;
+
+ /** Conversations known from GitHub's data, in file order then line order. */
+ const knownThreads = (shown: FileInfo[]) => shown.flatMap((file) => file.threads.map((thread) => ({ file, thread })));
+
+ /** Classic view: the conversations GitHub rendered, with their file. */
+ const renderedThreads = (shown: FileInfo[]) =>
+ shown.flatMap((file) => (file.diff ? page.threads(file.diff.element).map((thread) => ({ file, thread })) : []));
+
+ const conversations = (shown: FileInfo[], complete: boolean): Conversation[] =>
+ complete
+ ? knownThreads(shown).map(({ file, thread }) => ({
+ path: file.path,
+ line: Number(thread.line.slice(1)) || null,
+ state: thread.state,
+ }))
+ : renderedThreads(shown).map(({ file, thread }) => ({ path: file.path, line: thread.line, state: thread.state }));
+
+ const announce = (total: number, path: string) => {
+ panel.announceText(i18n.t('panelJumpedToComment', [String(commentIndex), String(total), path]));
+ schedule();
+ };
+
+ /** The open, expanded conversation for a thread, once GitHub has rendered its comments. */
+ const openThread = (id: string) => {
+ const thread = page.threadById(id);
+ return thread && page.threadComments(thread).length ? thread : null;
+ };
+
+ /**
+ * Newer view: GitHub's own comment link opens the thread, resolved or not, wherever it is in the diff. Replacing the
+ * hash keeps these jumps out of the reader's history. Without it, the file is opened and its marker centred.
+ */
+ const goToKnown = async (shown: FileInfo[], index: number) => {
+ const threads = knownThreads(shown);
+ const target = threads[index];
+ if (!target) return;
+ commentIndex = index + 1;
+ const { thread, file } = target;
+ let found: HTMLElement | null = null;
+ if (thread.comment) {
+ history.replaceState(history.state, '', `#r${thread.comment}`);
+ dispatchEvent(new HashChangeEvent('hashchange'));
+ found = await waitFor(() => openThread(thread.id), EXPAND_TIMEOUT_MS);
+ }
+ if (!found) {
+ const element = await goToFile(file);
+ const lines = [...new Set(file.threads.map(({ line }) => line))];
+ found = element ? ((await waitFor(() => page.commentIndicators(element)[lines.indexOf(thread.line)] ?? null, 600)) ?? element) : null;
+ }
+ if (found) {
+ page.scrollToCenter(found);
+ page.flash(found);
+ }
+ announce(threads.length, file.path);
+ };
+
+ /** Classic view: every conversation is already on the page; collapsed ones are opened so they can be read. */
+ const goToRendered = async (shown: FileInfo[], index: number) => {
+ const threads = renderedThreads(shown);
+ const target = threads[index];
+ if (!target) return;
+ commentIndex = index + 1;
+ const { element } = target.thread;
+ const toggle = page.collapsedThreadToggle(element);
+ if (toggle) {
+ toggle.click();
+ // GitHub loads a resolved thread's comments on demand; centre it once they're in.
+ await waitFor(() => (page.threadComments(element).length ? element : null), EXPAND_TIMEOUT_MS);
+ }
+ page.scrollToCenter(element);
+ page.flash(target.thread.element);
+ announce(threads.length, target.file.path);
+ };
+
+ /** Before the first jump, starts from the top (or the end); in the classic view, from the middle of the screen. */
+ const step = (shown: FileInfo[], complete: boolean, direction: 1 | -1) => {
+ if (complete) {
+ const total = knownThreads(shown).length;
+ if (!total) return;
+ const index = commentIndex === 0 ? (direction > 0 ? 0 : total - 1) : (commentIndex - 1 + direction + total) % total;
+ return goToKnown(shown, index);
+ }
+ const threads = renderedThreads(shown);
+ if (!threads.length) return;
+ const middle = innerHeight / 2;
+ const centre = ({ thread }: { thread: page.Thread }) => {
+ const box = thread.element.getBoundingClientRect();
+ return box.top + box.height / 2;
+ };
+ const index =
+ commentIndex === 0 && scrollY < 10
+ ? direction > 0
+ ? 0
+ : threads.length - 1
+ : direction > 0
+ ? threads.findIndex((thread) => centre(thread) > middle + 1)
+ : threads.findLastIndex((thread) => centre(thread) < middle - 1);
+ return goToRendered(shown, index === -1 ? (direction > 0 ? 0 : threads.length - 1) : index);
+ };
+
+ return {
+ comment: step,
+ position: (shown: FileInfo[], complete: boolean) => {
+ const list = conversations(shown, complete);
+ if (commentIndex > list.length) commentIndex = 0;
+ return { current: commentIndex, list };
+ },
+ reset: () => {
+ commentIndex = 0;
+ },
+ };
+};
diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts
index 3e99ab9..0f9bbbf 100644
--- a/src/entrypoints/background.ts
+++ b/src/entrypoints/background.ts
@@ -1,6 +1,11 @@
import { defineBackground } from '#imports';
import { browser } from 'wxt/browser';
import type { Command, Message } from '@/utils/messages';
+import { updateItem } from '@/utils/storage';
+
+/** Patch releases only fix things, so only a new minor or major version is worth telling the reader about. */
+const isNotable = (previous: string, current: string) =>
+ previous.split('.').slice(0, 2).join('.') !== current.split('.').slice(0, 2).join('.');
/** Reuses an open settings tab instead of piling up copies that could overwrite each other. */
const openOptions = async (repo?: string | null) => {
@@ -14,8 +19,10 @@ const openOptions = async (repo?: string | null) => {
const openWelcome = () => browser.tabs.create({ url: browser.runtime.getURL('/welcome.html') });
export default defineBackground(() => {
- browser.runtime.onInstalled.addListener(({ reason }) => {
+ browser.runtime.onInstalled.addListener(({ reason, previousVersion }) => {
if (reason === 'install') void openWelcome();
+ const { version } = browser.runtime.getManifest();
+ if (reason === 'update' && previousVersion && isNotable(previousVersion, version)) void updateItem.setValue(version);
});
browser.action.onClicked.addListener(() => void openOptions());
diff --git a/src/entrypoints/github.content/index.ts b/src/entrypoints/github.content.ts
similarity index 76%
rename from src/entrypoints/github.content/index.ts
rename to src/entrypoints/github.content.ts
index 1028b6e..092c230 100644
--- a/src/entrypoints/github.content/index.ts
+++ b/src/entrypoints/github.content.ts
@@ -1,7 +1,6 @@
-import '@/components/panel/panel.css';
import { createShadowRootUi, defineContentScript } from '#imports';
-import { createPanel } from '@/components/panel';
-import { startController, type Controller } from './controller';
+import { createPanel } from '@/components/panel/panel';
+import { startController, type Controller } from '@/content/controller';
export default defineContentScript({
matches: ['https://github.com/*'],
@@ -19,9 +18,10 @@ export default defineContentScript({
createPanel(
{ root, container, host, signal: ctx.signal },
{
- onSelect: (id) => controller?.select(id),
onToggle: (id) => controller?.toggle(id),
onSettings: () => controller?.openSettings(),
+ onComment: (step) => controller?.comment(step),
+ onUpdateSeen: () => controller?.dismissUpdate(),
},
),
});
diff --git a/src/entrypoints/options/index.html b/src/entrypoints/options/index.html
index 5d01b8b..20b5030 100644
--- a/src/entrypoints/options/index.html
+++ b/src/entrypoints/options/index.html
@@ -114,6 +114,41 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -161,6 +196,6 @@
-
+