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.

+ +
+ + + +
+ +
+

Chrome, Edge, Brave, Arc, Opera and every other Chromium browser.

+
    +
  1. Unzip focus-diff-chromium.zip.
  2. +
  3. + Open chrome://extensions (edge://extensions in Edge) and turn on Developer mode. +
  4. +
  5. Click Load unpacked and pick the unzipped folder.
  6. +
+ Download for Chromium +
+ + + + + + +
+ + +
+ + 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 @@

- + diff --git a/src/assets/page.css b/src/entrypoints/options/options.css similarity index 50% rename from src/assets/page.css rename to src/entrypoints/options/options.css index 7834b94..c837b63 100644 --- a/src/assets/page.css +++ b/src/entrypoints/options/options.css @@ -1,138 +1,3 @@ -/* 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; -} - .intro { display: flex; align-items: center; @@ -340,54 +205,6 @@ input[aria-invalid='true'] { } } -.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); -} - .try { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); @@ -406,13 +223,6 @@ input[aria-invalid='true'] { font-weight: 600; } -.row { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px; -} - .savebar { position: fixed; inset: auto 0 0 0; @@ -439,38 +249,29 @@ input[aria-invalid='true'] { color: var(--danger); } -@media (pointer: coarse) { - :root { - --target: 44px; - } -} - -@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, - input, - textarea { - border: 1px solid ButtonText; - } - input[aria-invalid='true'] { - border: 2px solid Mark; - } +.project { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 8px 24px; + padding-top: 16px; + border-top: 1px solid var(--line); + color: var(--muted); + font-size: 13px; } - -h3 { +.project ul { + display: flex; + flex-wrap: wrap; + gap: 4px 16px; margin: 0; - font-size: 14px; - line-height: 1.4; - font-weight: 600; + padding: 0; + list-style: none; +} +.project a { + color: var(--focus); + text-decoration-thickness: 1px; + text-underline-offset: 2px; } .share { @@ -493,45 +294,12 @@ h3 { background: var(--surface-muted); } -.btn:disabled, -.btn:disabled:hover { - cursor: not-allowed; - color: var(--muted); - background: var(--surface-muted); - border-color: var(--line); -} - -.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; + input, + textarea { + border: 1px solid ButtonText; + } + input[aria-invalid='true'] { + border: 2px solid Mark; } } diff --git a/src/entrypoints/options/main.ts b/src/entrypoints/options/options.ts similarity index 96% rename from src/entrypoints/options/main.ts rename to src/entrypoints/options/options.ts index 8305de2..91cfca3 100644 --- a/src/entrypoints/options/main.ts +++ b/src/entrypoints/options/options.ts @@ -1,5 +1,8 @@ -import '@/assets/page.css'; +import '@/assets/global.css'; +import '@/components/button.css'; +import './options.css'; import { i18n } from '#i18n'; +import { browser } from 'wxt/browser'; import { compile, filtersFor, @@ -13,7 +16,8 @@ import { type Filter, type RepoFilters, } from '@/utils/filters'; -import { $, reveal, toaster, translate, translateDocument } from '@/utils/page'; +import { toaster } from '@/components/toast/toast'; +import { $, reveal, translate, translateDocument } from '@/utils/page'; import { configItem, loadConfig, saveConfig } from '@/utils/storage'; type Field = 'name' | 'include' | 'exclude'; @@ -322,6 +326,7 @@ window.addEventListener('beforeunload', (event) => { }); translateDocument(i18n.t('optionsTitle')); +$('#version').textContent = i18n.t('optionsVersion', [browser.runtime.getManifest().version]); void loadConfig().then((loaded) => { config = loaded; diff --git a/src/entrypoints/welcome/index.html b/src/entrypoints/welcome/index.html index 7539bd2..8972d5f 100644 --- a/src/entrypoints/welcome/index.html +++ b/src/entrypoints/welcome/index.html @@ -79,6 +79,6 @@

- + diff --git a/src/entrypoints/welcome/style.css b/src/entrypoints/welcome/welcome.css similarity index 99% rename from src/entrypoints/welcome/style.css rename to src/entrypoints/welcome/welcome.css index 4b942e2..ee67b76 100644 --- a/src/entrypoints/welcome/style.css +++ b/src/entrypoints/welcome/welcome.css @@ -155,6 +155,7 @@ section > h2 { } .mono { font-family: var(--mono); + font-size: 13px; } .add { color: var(--ok); diff --git a/src/entrypoints/welcome/main.ts b/src/entrypoints/welcome/welcome.ts similarity index 95% rename from src/entrypoints/welcome/main.ts rename to src/entrypoints/welcome/welcome.ts index d269c9c..0cd796c 100644 --- a/src/entrypoints/welcome/main.ts +++ b/src/entrypoints/welcome/welcome.ts @@ -1,10 +1,12 @@ -import '@/assets/page.css'; -import './style.css'; +import '@/assets/global.css'; +import '@/components/button.css'; +import './welcome.css'; import { i18n } from '#i18n'; import { browser } from 'wxt/browser'; import { normalize, type Config } from '@/utils/filters'; import { message } from '@/utils/i18n'; -import { $, reveal, toaster, translateDocument } from '@/utils/page'; +import { toaster } from '@/components/toast/toast'; +import { $, reveal, translateDocument } from '@/utils/page'; import { RECIPES, type Recipe } from '@/utils/recipes'; import { configItem, loadConfig, saveConfig } from '@/utils/storage'; diff --git a/src/locales/en.json b/src/locales/en.json index df03c01..8dca8a9 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -10,10 +10,18 @@ "panelLinesAdded": "lines added,", "panelLinesRemoved": "lines removed", "panelSettings": "Focus Diff settings", + "panelUpdate": "New in $1", + "panelUpdateDismiss": "Dismiss what's new", "panelSetUp": "Set up filters", - "panelNotLoaded": "$1 files not loaded yet, so line counts are partial", + "panelNotLoaded": { + "1": "1 file not loaded yet, so line counts are partial", + "n": "$1 files not loaded yet, so line counts are partial" + }, "panelAnnounce": "$1: $2 of $3 files, $4 lines added, $5 removed.", - "panelAnnouncePartial": "$1 of them haven't loaded yet, so line counts are partial.", + "panelAnnouncePartial": { + "1": "1 of them hasn't loaded yet, so line counts are partial.", + "n": "$1 of them haven't loaded yet, so line counts are partial." + }, "optionsTitle": "Focus Diff settings", "skipToFilters": "Skip to filters", "optionsTagline": "Each filter becomes a button on the Files changed tab of a pull request.", @@ -118,19 +126,24 @@ "welcomeNextHeading": "Next", "welcomeOpenSettings": "Open settings", "welcomeShortcuts": "Switch filters without the mouse: $1 next, $2 previous, $3 all files.", + "optionsVersion": "Focus Diff $1, open source under the MIT license", + "projectLinks": "Focus Diff project", + "linkSource": "Source code", + "linkReleases": "Release notes", + "linkBug": "Report a bug", + "linkIdea": "Suggest an idea", + "linkPrivacy": "Privacy", "welcomeLink": "Examples and how it works", "filtersEmptyWithExamples": "No filters yet. Add one, or start from an example.", "browseExamples": "Browse examples", "panelBreakdown": "Breakdown by filter", - "panelBreakdownHeading": "Where the changes are", - "panelCombineHint": "Shift-click to combine filters", "timeUnderMinute": "<1 min", "timeMinutes": "~$1 min", "timeHours": "~$1 h", "timeHoursMinutes": "~$1 h $2 min", "timeHint": "Review time assumes about $1 changed lines an hour. Removed lines count a quarter, each file adds half a minute, and generated or lock files only add that half minute.", - "panelAnnounceTime": "About $1 to review.", - "panelTimeLabel": "to review", + "panelAnnounceTime": "About $1 left to review.", + "panelTimeLabel": "left to review", "copied": { "1": "Copied 1 filter to the clipboard", "n": "Copied $1 filters to the clipboard" @@ -144,9 +157,26 @@ "n": "$1 files" }, "panelColumnFilter": "Filter", - "panelColumnFiles": "Files", "panelColumnLines": "Lines", - "panelColumnTime": "Review time", - "panelBreakdownRow": "$1: $2, $3 lines added, $4 removed, $5 to review", - "statusChangedElsewhere": "Filters changed in another window. Save to keep your edits, or reload to see the new filters." + "panelColumnTime": "Time left", + "panelBreakdownRow": "$1: $2, $3 viewed, $4 lines added, $5 removed. $6", + "statusChangedElsewhere": "Filters changed in another window. Save to keep your edits, or reload to see the new filters.", + "panelColumnViewed": "Viewed", + "timeDone": "Done", + "timeLeft": "$1 left to review", + "panelComments": "Conversations", + "panelPreviousComment": "Previous conversation", + "panelNextComment": "Next conversation", + "panelCommentPosition": "Conversation $1 of $2", + "panelCommentCount": { + "1": "1 conversation", + "n": "$1 conversations" + }, + "commandNextComment": "Go to the next conversation", + "commandPreviousComment": "Go to the previous conversation", + "panelJumpedToComment": "Conversation $1 of $2, in $3.", + "panelTimeInfo": "How review time is estimated", + "panelStateWaiting": "Waiting on you", + "panelStateAnswered": "Answered", + "panelStateResolved": "Resolved" } diff --git a/src/locales/pt_BR.json b/src/locales/pt_BR.json index db2096b..ebf9e76 100644 --- a/src/locales/pt_BR.json +++ b/src/locales/pt_BR.json @@ -10,10 +10,18 @@ "panelLinesAdded": "linhas adicionadas,", "panelLinesRemoved": "linhas removidas", "panelSettings": "Configurações do Focus Diff", + "panelUpdate": "Novidades da $1", + "panelUpdateDismiss": "Dispensar novidades", "panelSetUp": "Configurar filtros", - "panelNotLoaded": "$1 arquivos ainda não carregaram, então a contagem de linhas é parcial", + "panelNotLoaded": { + "1": "1 arquivo ainda não carregou, então a contagem de linhas é parcial", + "n": "$1 arquivos ainda não carregaram, então a contagem de linhas é parcial" + }, "panelAnnounce": "$1: $2 de $3 arquivos, $4 linhas adicionadas, $5 removidas.", - "panelAnnouncePartial": "$1 deles ainda não carregaram, então a contagem de linhas é parcial.", + "panelAnnouncePartial": { + "1": "1 deles ainda não carregou, então a contagem de linhas é parcial.", + "n": "$1 deles ainda não carregaram, então a contagem de linhas é parcial." + }, "optionsTitle": "Configurações do Focus Diff", "skipToFilters": "Pular para os filtros", "optionsTagline": "Cada filtro vira um botão na aba Files changed de um pull request.", @@ -118,19 +126,24 @@ "welcomeNextHeading": "Próximos passos", "welcomeOpenSettings": "Abrir configurações", "welcomeShortcuts": "Troque de filtro sem o mouse: $1 próximo, $2 anterior, $3 todos os arquivos.", + "optionsVersion": "Focus Diff $1, código aberto sob a licença MIT", + "projectLinks": "Projeto Focus Diff", + "linkSource": "Código-fonte", + "linkReleases": "Notas de versão", + "linkBug": "Relatar um bug", + "linkIdea": "Sugerir uma ideia", + "linkPrivacy": "Privacidade", "welcomeLink": "Exemplos e como funciona", "filtersEmptyWithExamples": "Nenhum filtro ainda. Adicione um ou comece com um exemplo.", "browseExamples": "Ver exemplos", "panelBreakdown": "Distribuição por filtro", - "panelBreakdownHeading": "Onde estão as mudanças", - "panelCombineHint": "Shift+clique para combinar filtros", "timeUnderMinute": "<1 min", "timeMinutes": "~$1 min", "timeHours": "~$1 h", "timeHoursMinutes": "~$1 h $2 min", "timeHint": "O tempo de revisão considera cerca de $1 linhas alteradas por hora. Linhas removidas contam um quarto, cada arquivo soma meio minuto, e arquivos gerados ou de lock somam só esse meio minuto.", - "panelAnnounceTime": "Cerca de $1 para revisar.", - "panelTimeLabel": "para revisar", + "panelAnnounceTime": "Cerca de $1 para terminar a revisão.", + "panelTimeLabel": "para terminar", "copied": { "1": "1 filtro copiado", "n": "$1 filtros copiados" @@ -144,9 +157,26 @@ "n": "$1 arquivos" }, "panelColumnFilter": "Filtro", - "panelColumnFiles": "Arquivos", "panelColumnLines": "Linhas", - "panelColumnTime": "Revisão", - "panelBreakdownRow": "$1: $2, $3 linhas adicionadas, $4 removidas, $5 de revisão", - "statusChangedElsewhere": "Os filtros mudaram em outra janela. Salve para manter suas edições ou recarregue para ver os novos filtros." + "panelColumnTime": "Falta", + "panelBreakdownRow": "$1: $2, $3 vistos, $4 linhas adicionadas, $5 removidas. $6", + "statusChangedElsewhere": "Os filtros mudaram em outra janela. Salve para manter suas edições ou recarregue para ver os novos filtros.", + "panelColumnViewed": "Vistos", + "timeDone": "Pronto", + "timeLeft": "Faltam $1 de revisão", + "panelComments": "Conversas", + "panelPreviousComment": "Conversa anterior", + "panelNextComment": "Próxima conversa", + "panelCommentPosition": "Conversa $1 de $2", + "panelCommentCount": { + "1": "1 conversa", + "n": "$1 conversas" + }, + "commandNextComment": "Ir para a próxima conversa", + "commandPreviousComment": "Ir para a conversa anterior", + "panelJumpedToComment": "Conversa $1 de $2, em $3.", + "panelTimeInfo": "Como o tempo de revisão é estimado", + "panelStateWaiting": "Aguardando você", + "panelStateAnswered": "Respondida", + "panelStateResolved": "Resolvida" } diff --git a/src/utils/github.ts b/src/utils/github.ts index e34d135..447df6a 100644 --- a/src/utils/github.ts +++ b/src/utils/github.ts @@ -10,24 +10,24 @@ export interface Diff { stats: () => FileStats | null; } -export interface TreeFile { +interface TreeFile { element: HTMLElement; - path: string; + /** From the item's `#diff-` link, when the tree has one. */ + digest: string; } -export interface TreeFolder { +interface TreeFolder { element: HTMLElement; path: string; - files: HTMLElement[]; } -export interface PageCounters { +interface PageCounters { files: HTMLElement | null; additions: HTMLElement | null; deletions: HTMLElement | null; } -const DIFF = '[role="region"][id^="diff-"], .file[data-tagsearch-path]'; +const DIFF = '[role="region"][id^="diff-"], [data-tagsearch-path][id^="diff-"]'; const TREE_ITEM = '[role="treeitem"]'; const PAGE_LAYOUT = 'main, [role="main"], #diff-comparison-viewer-container, #files'; const PULL_REQUEST_FILES = /^\/([^/]+)\/([^/]+)\/pull\/\d+\/(?:changes|files)(?:\/|$)/; @@ -60,7 +60,7 @@ const pathOf = (diff: HTMLElement) => diff.querySelector('[data-file-path]')?.dataset.filePath || diff.dataset.tagsearchPath || document.getElementById(diff.getAttribute('aria-labelledby') ?? '')?.textContent || - diff.querySelector('h3, [class*="file-name"], a[href^="#diff-"]')?.textContent || + diff.querySelector('h3, a[href^="#diff-"]')?.textContent || '', ); @@ -141,7 +141,8 @@ const folderPathOf = (item: Element | null | undefined): string => { return parts.join('/'); }; -const treePathOf = (item: Element) => { +/** A file's path as the tree spells it out, for trees without `#diff-` links. */ +export const treePathOf = (item: Element): string => { const label = labelOf(item); const name = label.match(/[^\s/]+\.[A-Za-z0-9]+/)?.[0] ?? label; const folder = folderPathOf(item.parentElement?.closest(TREE_ITEM)); @@ -151,14 +152,13 @@ const treePathOf = (item: Element) => { export const treeFiles = (): TreeFile[] => [...document.querySelectorAll(`${TREE_ITEM}:not([aria-expanded])`)].map((element) => ({ element, - path: treePathOf(element), + digest: element.querySelector('a[href^="#diff-"]')?.getAttribute('href')?.slice('#diff-'.length) ?? '', })); export const treeFolders = (): TreeFolder[] => [...document.querySelectorAll(`${TREE_ITEM}[aria-expanded]`)].map((element) => ({ element, path: folderPathOf(element), - files: [...element.querySelectorAll(`${TREE_ITEM}:not([aria-expanded])`)], })); const findFilesCounter = (): HTMLElement | null => @@ -209,3 +209,248 @@ export const reportedFileCount = (): number => { const text = pageCounters().files?.textContent ?? ''; return Number(text.replace(/[^\d]/g, '')) || 0; }; + +/** The classic view's checkbox, or the newer view's toggle button, found by its label rather than its styling. */ +const VIEWED = 'input.js-reviewed-checkbox, button[aria-pressed], [data-diff-header-wrapper] input[type="checkbox"]'; +const VIEWED_LABEL = /^(not )?viewed$/i; + +/** Whether the reviewer marked a file as viewed on GitHub, or `null` when its toggle isn't rendered. */ +export const viewed = (diff: Pick): boolean | null => { + for (const control of diff.element.querySelectorAll(VIEWED)) { + if (control instanceof HTMLInputElement) { + if (control.classList.contains('js-reviewed-checkbox') || VIEWED_LABEL.test(control.closest('label')?.textContent?.trim() ?? '')) + return control.checked; + } else if (VIEWED_LABEL.test((control.getAttribute('aria-label') ?? control.textContent ?? '').trim())) { + return control.getAttribute('aria-pressed') === 'true'; + } + } + return null; +}; + +/** The newer diff view positions each file absolutely in a tall list and only renders the ones near the screen. */ +export const isVirtualized = (): boolean => document.querySelector('[data-index][data-path-digest]') !== null; + +export const diffByDigest = (digest: string): HTMLElement | null => (digest ? document.getElementById(`diff-${digest}`) : null); + +/** The file tree's link to a file, which makes GitHub scroll to it and render it. */ +export const treeLink = (digest: string): HTMLAnchorElement | null => + digest ? document.querySelector(`[role="treeitem"] a[href="#diff-${CSS.escape(digest)}"]`) : null; + +/** Conversation markers beside the lines of a rendered diff in the newer view, top to bottom. */ +export const commentIndicators = (diff: Element): HTMLElement[] => + [...diff.querySelectorAll('[class*="CommentIndicator-module__commentIn"]')] + .map((element) => ({ element, top: element.getBoundingClientRect().top })) + .toSorted((a, b) => a.top - b.top) + .map(({ element }) => element); + +/** Review conversations in the classic view; the class is a fallback for the custom element. */ +const THREAD = 'review-thread-collapsible, .js-resolvable-timeline-thread-container'; + +/** Waiting on the reader, answered by them (they wrote or reacted to the last comment), or resolved. */ +export type ThreadState = 'waiting' | 'answered' | 'resolved'; + +export interface Thread { + element: HTMLElement; + state: ThreadState; + line: number | null; +} + +const viewer = (): string | null => document.querySelector('meta[name="user-login"]')?.content || null; + +/** A thread's comments, each wrapped in an element with its id, like `r3727922886`. */ +export const threadComments = (thread: HTMLElement): HTMLElement[] => + [...thread.querySelectorAll('[id^="r"]')].filter((element) => /^r\d+$/.test(element.id)); + +const threadState = (thread: HTMLElement, login: string | null): ThreadState => { + if (thread.dataset.resolved === 'true') return 'resolved'; + const last = threadComments(thread).at(-1); + if (!login || !last) return 'waiting'; + const author = [...last.querySelectorAll('a[data-hovercard-type="user"]')].find((link) => link.textContent?.trim()); + const reacted = last.querySelector('button[data-reaction-content][aria-pressed="true"]') !== null; + return author?.textContent?.trim() === login || reacted ? 'answered' : 'waiting'; +}; + +/** The new-version line a classic thread hangs under: the last line number on the row above it. */ +const threadLine = (thread: HTMLElement): number | null => { + const numbers = thread.closest('tr')?.previousElementSibling?.querySelectorAll('[data-line-number]'); + return Number(numbers?.[numbers.length - 1]?.getAttribute('data-line-number')) || null; +}; + +/** A conversation open in the newer view, found by its thread id. */ +export const threadById = (id: string): HTMLElement | null => document.querySelector(`[data-marker-id="${CSS.escape(id)}"]`); + +/** The control that expands a collapsed classic thread, like a resolved one, or `null` when it's already open. */ +export const collapsedThreadToggle = (thread: HTMLElement): HTMLButtonElement | null => { + const toggle = thread.querySelector('button[aria-expanded="false"][data-target$=".button"]'); + return toggle?.closest(THREAD) === thread ? toggle : null; +}; + +/** Conversations inside a diff, in page order. */ +export const threads = (diff: HTMLElement): Thread[] => { + const login = viewer(); + return [...diff.querySelectorAll(THREAD)] + .filter((element) => !element.parentElement?.closest(THREAD)) + .map((element) => ({ element, state: threadState(element, login), line: threadLine(element) })); +}; + +const STICKY_OFFSET_PX = 80; +const FLASH_MS = 2000; + +const scrollBehavior = (): ScrollBehavior => (matchMedia('(prefers-reduced-motion: no-preference)').matches ? 'smooth' : 'auto'); + +/** Scrolls an element to the top of the page, below GitHub's sticky headers. */ +export const scrollToTop = (element: Element): void => { + const top = element.getBoundingClientRect().top + scrollY - STICKY_OFFSET_PX; + scrollTo({ top, behavior: scrollBehavior() }); +}; + +/** Scrolls an element to the middle of the screen. */ +export const scrollToCenter = (element: Element): void => { + const box = element.getBoundingClientRect(); + scrollTo({ top: box.top + scrollY - Math.max(STICKY_OFFSET_PX, (innerHeight - box.height) / 2), behavior: scrollBehavior() }); +}; + +const FLASH_ID = 'focus-diff-flash'; + +const ACCENT = 'var(--fgColor-accent, var(--color-accent-fg, #0969da))'; +const ring = (alpha: number, glow: number) => + `0 0 0 2px color-mix(in srgb, ${ACCENT} ${alpha}%, transparent), 0 0 0 ${glow}px color-mix(in srgb, ${ACCENT} ${alpha / 4}%, transparent)`; + +/** + * Lights a ring around an element after a jump so the eye finds it: it glows in, holds, and fades out. It's an + * animation, so it leaves nothing behind on GitHub's markup. + */ +export const flash = (element: HTMLElement): void => { + for (const animation of element.getAnimations()) if (animation.id === FLASH_ID) animation.cancel(); + const animation = element.animate( + [ + { boxShadow: ring(0, 0), offset: 0 }, + { boxShadow: ring(100, 8), offset: 0.12 }, + { boxShadow: ring(100, 6), offset: 0.7 }, + { boxShadow: ring(0, 0), offset: 1 }, + ], + { duration: FLASH_MS, easing: 'ease-out' }, + ); + animation.id = FLASH_ID; +}; + +/** + * GitHub's newer diff view (shown to signed-in reviewers) embeds the whole pull request as JSON in + * `script[data-target="react-app.embeddedData"]`: every changed file with its line counts and Viewed state, and every + * review thread with its line. The diff list itself is virtualized, so this is the only complete picture of a large + * pull request. The classic view has no such data; callers fall back to reading the DOM. + */ + +interface FileSummary { + path: string; + /** SHA-256 of the path, as used in `#diff-` anchors and region ids. */ + digest: string; + additions: number; + deletions: number; + viewed: boolean; + /** Thread anchors in this file, like `R550` for line 550 of the new version. */ + threads: ThreadSummary[]; +} + +export interface ThreadSummary { + id: string; + line: string; + state: ThreadState; + /** The first comment's id; `#r` is GitHub's own link that opens the thread. */ + comment: string; +} + +export interface PullRequestData { + files: FileSummary[]; +} + +const EMBEDDED = 'script[data-target="react-app.embeddedData"]'; + +type Json = Record; +const isObject = (value: unknown): value is Json => typeof value === 'object' && value !== null; + +/** Finds the object that holds `key`, wherever GitHub nests it. */ +const holderOf = (value: unknown, key: string, depth = 0): Json | null => { + if (!isObject(value) || depth > 8) return null; + if (key in value) return value; + for (const child of Object.values(value)) { + const found = holderOf(child, key, depth + 1); + if (found) return found; + } + return null; +}; + +const commentsOf = (detail: unknown): Json[] => + isObject(detail) && isObject(detail.commentsData) && Array.isArray(detail.commentsData.comments) + ? detail.commentsData.comments.filter(isObject) + : []; + +/** Same rule as the classic view: answered when the reader wrote or reacted to the last comment. */ +const stateOf = (detail: unknown): ThreadState => { + if (!isObject(detail)) return 'waiting'; + if (detail.isResolved === true) return 'resolved'; + const last = commentsOf(detail).at(-1); + if (!last) return 'waiting'; + const reacted = + Array.isArray(last.reactionGroups) && + last.reactionGroups.some((group) => isObject(group) && isObject(group.reaction) && group.reaction.viewerHasReacted === true); + return last.viewerDidAuthor === true || reacted ? 'answered' : 'waiting'; +}; + +const lineNumber = (anchor: string) => Number(anchor.slice(1)) || 0; + +const parse = (text: string): PullRequestData | null => { + let json: unknown; + try { + json = JSON.parse(text); + } catch { + return null; + } + const holder = holderOf(json, 'diffSummaries'); + if (!holder || !Array.isArray(holder.diffSummaries)) return null; + const threadDetails = isObject(holder.markers) && isObject(holder.markers.threads) ? holder.markers.threads : {}; + const files: FileSummary[] = holder.diffSummaries.filter(isObject).flatMap((summary) => { + if (typeof summary.path !== 'string') return []; + const markers = isObject(summary.markersMap) ? summary.markersMap : {}; + const threads = Object.entries(markers) + .flatMap(([line, marker]) => + isObject(marker) && Array.isArray(marker.threads) + ? marker.threads.filter(isObject).map((thread) => { + const id = String(thread.id ?? ''); + const detail = threadDetails[id]; + return { id, line, state: stateOf(detail), comment: String(commentsOf(detail)[0]?.databaseId ?? '') }; + }) + : [], + ) + .toSorted((a, b) => lineNumber(a.line) - lineNumber(b.line)); + return [ + { + path: summary.path, + digest: typeof summary.pathDigest === 'string' ? summary.pathDigest : '', + additions: Number(summary.linesAdded) || 0, + deletions: Number(summary.linesDeleted) || 0, + viewed: summary.markedAsViewed === true, + threads, + }, + ]; + }); + return { files }; +}; + +const parsed = new WeakMap(); + +/** + * The current pull request's embedded data, or `null` on the classic view. GitHub swaps the script element on + * navigation, so each one is parsed once. A page can embed data for other apps too; the first with diffs wins. + */ +export const pullRequestData = (): PullRequestData | null => { + for (const script of document.querySelectorAll(EMBEDDED)) { + let data = parsed.get(script); + if (data === undefined) { + data = parse(script.textContent ?? ''); + parsed.set(script, data); + } + if (data) return data; + } + return null; +}; diff --git a/src/utils/messages.ts b/src/utils/messages.ts index a24aa4c..ce1a18a 100644 --- a/src/utils/messages.ts +++ b/src/utils/messages.ts @@ -1,3 +1,3 @@ -export type Command = 'next-filter' | 'previous-filter' | 'show-all'; +export type Command = 'next-filter' | 'previous-filter' | 'show-all' | 'next-comment' | 'previous-comment'; export type Message = { type: 'open-options'; repo?: string | null } | { type: 'open-welcome' } | { type: 'command'; command: Command }; diff --git a/src/utils/page.ts b/src/utils/page.ts index ae1aab8..d116091 100644 --- a/src/utils/page.ts +++ b/src/utils/page.ts @@ -38,19 +38,7 @@ export const translateDocument = (title: string): void => { translate(document.body); }; -/** Shows a page once its first render is complete; see `.pending` in page.css. */ +/** Shows a page once its first render is complete; see `.pending` in global.css. */ export const reveal = (): void => { document.documentElement.classList.remove('pending'); }; - -const TOAST_MS = 2800; - -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/utils/review-time.ts b/src/utils/review-time.ts index c8022d3..69eeab5 100644 --- a/src/utils/review-time.ts +++ b/src/utils/review-time.ts @@ -6,7 +6,7 @@ import type { FileStats } from '@/utils/github'; */ export const LINES_PER_HOUR = 400; /** Our assumption, not measured: removed code is read, not studied. */ -export const REMOVED_LINE_WEIGHT = 0.25; +const REMOVED_LINE_WEIGHT = 0.25; /** Our assumption, not measured: opening a file and getting oriented in it. */ export const MINUTES_PER_FILE = 0.5; diff --git a/src/utils/storage.ts b/src/utils/storage.ts index fa2ecff..6a80a08 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -17,6 +17,9 @@ export const selectionsItem = storage.defineItem('local:active', { }, }); +/** The release whose notes the reader hasn't seen yet, set when the extension updates to a new minor or major version. */ +export const updateItem = storage.defineItem('local:update', { fallback: null }); + export const loadConfig = async (): Promise => normalize(await configItem.getValue()); export const saveConfig = (config: Config): Promise => configItem.setValue(normalize(config)); diff --git a/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts index 6ae7581..d64c492 100644 --- a/tests/e2e/fixtures.ts +++ b/tests/e2e/fixtures.ts @@ -6,8 +6,11 @@ import type { Config } from '../../src/utils/filters'; const extensionPath = path.resolve('.output/chrome-mv3'); const pullRequestHtml = readFileSync(path.resolve('tests/e2e/fixtures/pull-request.html'), 'utf8'); +const virtualizedHtml = readFileSync(path.resolve('tests/e2e/fixtures/pull-request-virtualized.html'), 'utf8'); export const PULL_REQUEST = 'https://github.com/octo/web/pull/1/changes'; +/** GitHub's newer, virtualized diff view with its embedded data. */ +export const VIRTUALIZED_PULL_REQUEST = 'https://github.com/octo/web/pull/2/changes'; export const DEFAULT_CONFIG: Config = { global: [ @@ -41,7 +44,7 @@ export const test = base.extend({ env: { ...process.env, LANGUAGE: language.replace('-', '_') }, }); await context.route(/^https:\/\/github\.com\/octo\/web\/pull\/\d+\/(changes|files)$/, (route) => - route.fulfill({ contentType: 'text/html', body: pullRequestHtml }), + route.fulfill({ contentType: 'text/html', body: route.request().url().includes('/pull/2/') ? virtualizedHtml : pullRequestHtml }), ); await context.route(/^https:\/\/github\.com\/octo\/web\/pull\/\d+$/, (route) => route.fulfill({ contentType: 'text/html', body: 'Conversation' }), @@ -76,7 +79,7 @@ export const test = base.extend({ page.on('pageerror', (error) => pageErrors.push(error.message)); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(url); - await page.locator('focus-diff-panel .panel').waitFor({ state: url === PULL_REQUEST ? 'visible' : 'attached' }); + await page.locator('focus-diff-panel .panel').waitFor({ state: url.endsWith('/changes') ? 'visible' : 'attached' }); return page; }); }, diff --git a/tests/e2e/fixtures/pull-request-virtualized.html b/tests/e2e/fixtures/pull-request-virtualized.html new file mode 100644 index 0000000..5ffa49e --- /dev/null +++ b/tests/e2e/fixtures/pull-request-virtualized.html @@ -0,0 +1,200 @@ + + + + + Add bookshelf · Pull Request #2 + + + + +
+
    +
    +
    + + + diff --git a/tests/e2e/fixtures/pull-request.html b/tests/e2e/fixtures/pull-request.html index df89c99..d665421 100644 --- a/tests/e2e/fixtures/pull-request.html +++ b/tests/e2e/fixtures/pull-request.html @@ -2,6 +2,7 @@ + Add bookshelf · Pull Request #1