Branch: lean (cut from main at ef40348).
Started: 2026-04-14.
A small, fast, maintainable content script that correctly detects repository
navigation on SPA forges (especially GitHub), without jQuery, without a
subtree-wide MutationObserver, without navigator.locks, and without
setInterval-based completion polling.
refactordid structural work (split intosrc/*, added manifest generator, added caching/permissions modules) but kept the fundamentally wrong architecture for SPA navigation: a MutationObserver on<html>subtree, serialized bynavigator.locks, with 300 ms debounce and a 350 ms trailing sleep. It does not work correctly on GitHub (tested).- It also introduced a
ForgeHandlerclass hierarchy (one subclass per forge) that replaces the flat, five-row forge table documented inCONTRIBUTING.md. Over-engineered for the problem.
| Item | Reason |
|---|---|
build/manifest-generator.js + src/manifest-base.json |
Single source for MV2 + MV3 manifests |
vite.config.js, jest.config.js, babel.config.js |
Build + test infra |
package.json npm scripts (build, dev, test, lint) |
Already-wired toolchain |
extension/background.js — SWH proxy via FETCH_SWH_API |
Only way past SWH's CORS |
tests/unit/dateUtils.test.js, forgeHandlers.test.js |
Keep green through the rewrite |
Everything else from refactor is discarded.
- Drop jQuery entirely. ~80 KB of dependency for ~15 vanilla lines.
- Gitignore
extension/updateswh.js. It's a Vite build artifact; theMakefilerunsnpm run buildbefore zipping, so the release process is unaffected. Main commits it; we won't. - Keep MV2 for Firefox. Dual-manifest generator stays.
- Delete orphan experimental branches after
leanmerges:checkevents,popstate,debugtriggers,skipredundant-drops,weblocks,lock,csrf,help,replacebutton,esling,eslint,barais-main,chrome-v3,fix/cors-proxy,refactor. - Playwright integration tests: deferred. Current
tests/integration/extension.test.jsis a 22-line placeholder — drop it now, add real tests in a follow-up PR (see Future work below).
- No jQuery. Vanilla DOM +
fetch. - Kill the MutationObserver + locks + sleep stack. Replace with a ~25-line
navigation detector:
popstate+turbo:load+turbo:render+setInterval(check, 500)pollinglocation.href. Cheap and cross-browser. Promise.all+ inflight-dedup Map instead of thesetInterval/isCompletepolling loop from main.- Flat forge table (back to main's
forgehandlersarray style, one file, ~90 lines). No base class, no subclasses. This restores the contract documented inCONTRIBUTING.md. - Split API paths by CORS reality. Forges (GitHub/GitLab/Bitbucket/Gitea)
allow CORS → direct
fetchfrom content script. SWH does not → proxied via backgroundFETCH_SWH_APImessage.
extension/
background.js ~40 lines (SWH proxy + createtab + onInstalled)
popup.html unchanged from main
popup.js unchanged from main
options.html unchanged from main
options.js unchanged from main
css/ img/ unchanged
updateswh.js (Vite build output, gitignored)
src/
manifest-base.json
constants.js COLOR_CODES, DRIFT_MS, CACHE_TTL_MS
forges.js flat table (~90 lines)
content/
main.js ~70 lines (orchestration)
navigation.js ~25 lines (locationchange detector)
ui.js ~130 lines (vanilla-DOM button, click, tooltip)
api/
forge.js ~25 lines (fetch + status→errorType mapping)
swh.js ~40 lines (background-proxy client)
build/
manifest-generator.js
tests/unit/ kept from refactor
Target total: ~500 lines of new source replacing 640 lines of monolith.
- A. Scaffold. Create branch, bring in the four items from
refactor, delete the scratch/planning files listed under Hygiene. Done 2026-04-14 session 2. - B. Background script. Port the
FETCH_SWH_APIproxy (minus thefix/cors-proxycruft), wirecreatetab+onInstalled. Done 2026-04-14 session 2 (fefc7ea). Also fixed latentdata.type = "createtab"assignment bug inherited from main. - C. Forges + constants. Flat table, regex pattern/reject/setup per
forge,
updategitlabhandlers/updategiteahandlersfor custom instances. No class hierarchy. Done 2026-04-14 session 2 (9f1e5c3). 19/19 tests pass. Also rewrote forgeHandlers.test.js against the flat-table API (the refactor-era version assumed classes). - D. API clients.
src/api/forge.js(direct fetch, status→errorType) andsrc/api/swh.js(background-proxy, same error shape). Done 2026-04-14 session 2 (8445092). - E. UI. Vanilla-DOM button + tooltip + click handler, ported from
main's
insertSaveIcon. No jQuery. Done 2026-04-14 session 2 (58ef010). Extendedsrc/api/swh.jswithrequestSwhSavewhile here. - F. Navigation detector.
src/content/navigation.jsas described. Done 2026-04-14 session 2 (af72dff). - G. Orchestration.
src/content/main.jsties it together:Promise.allfor forge + SWH, inflight-dedup Map, cache with TTL, navigation callback. Done 2026-04-14 session 2 (5cd6a82). Also vendoredsrc/utils/dateUtils.jsfrom refactor; 29/29 unit tests pass. - H. Manifests. Update
src/manifest-base.json: drop jQuery fromcontent_scripts.jslist, declare optional host permissions for forge APIs, keep SWH as required. Done 2026-04-14 session 2 (7876065). Also untracked build artifacts (extension/updateswh.js,extension/manifest*.json) and deleted jquery shim. - I. Tests. Run
npm test— both unit test files must pass as-is. Add tests for inflight-dedup cache and navigation detector. Done 2026-04-14 session 2 (e30c939). Extracted cache tosrc/utils/cache.js; 42/42 tests pass (6 cache, 7 nav, 19 forges, 10 dateUtils). - J. Manual smoke. Load unpacked in Firefox + Chrome, walk through
GitHub SPA navigation, GitLab, Bitbucket, Codeberg, custom Gitea
instance. Log icon states. Done 2026-04-14 session 2. Surfaced
one regression (red button on GitHub caused by optional host
permissions without runtime grant); fixed in
77f077d. - K. Docs. Rewrite
CONTRIBUTING.mdto match the flat-table style (it still describes the old API). UpdateREADME.mddeveloper section. UpdateCLAUDE.md. Done 2026-04-14 session 2 (b13d8b8). Also wiredmaketonpm run buildand extendedmake cleanto wipe build artefacts. - L. Merge to main, delete orphan branches (the 15 listed above).
extension-2/,extension-3/,tmp/todo,extension/TODO,extension/popup.new.js,extension/0001-Add-support-for-POST-queries-to-forges.patchREFACTOR_LOG.md,CODEBASE_SUMMARY.md,TESTING_GUIDE.md,MANUAL_TESTING_CHECKLIST.md,PACKAGE_READY.md,CORS_FIX_README.mdtests/integration/,playwright.config.js.eslintrc.js~,.#known-instances- Committed build artifacts (
Chrome.zip,Edge.zip,FireFox.zip) if any
Kept at root: README.md, CONTRIBUTING.md (rewritten), HOWTO-RELEASE,
LICENSE, LEAN_REWRITE_PLAN.md, JOURNAL.md, CLAUDE.md.
- Real Playwright integration tests. Scope: launch Chromium with the
unpacked extension via
launchPersistentContext+--load-extension, mount static HTML fixtures that mimic forge repo pages, usepage.route()to stub forge + SWH responses deterministically for each colour code, drive SPA navigation withhistory.pushState, assert button state. Expected size: 200–400 lines including fixtures and CI wiring. Own branch / own PR. - Navigation API (
window.navigation.addEventListener('navigate', …)). Chrome 102+ has it natively; Firefox doesn't yet. Once Firefox ships, it can replace the 500 ms polling tick entirely. - Options page: add/remove custom forge instances with per-domain runtime
permission prompt. Ties in with the MV3 optional-host-permission story.
In progress on branch
feature/runtime-host-permissions— see next section.
Goal: remove <all_urls> from host_permissions and from
content_scripts.matches. Store reviewers flag broad permissions; with
optional-origin + dynamic content-script registration we can ship a
minimal baseline and ask for each forge origin on demand.
Scope confirmed 2026-04-16: both MV2 and MV3; lazy-grant UX with an install-time batch prompt for the five built-in forge origins; custom forges request the origin at options save-time; a distinct button shape + tooltip represents "permission missing, click to grant".
-
RP-A. Manifest: make forge origins optional. In
src/manifest-base.json: - Replace<all_urls>inhost_permissionswith an explicit list covering onlyhttps://archive.softwareheritage.org/*(stays required — SWH proxy). - Addoptional_host_permissions(MV3) /optional_permissions(MV2) with the five built-in forge origins (https://github.com/*,https://bitbucket.org/*,https://gitlab.com/*, plusGITLAB_KNOWN/GITEA_KNOWNdomains enumerated individually). - Replace<all_urls>incontent_scripts.matcheswith the same five-forge list so the content script only auto-injects on known forges. Custom Gitea/GitLab instances are handled by dynamic registration in RP-D. - Updatebuild/manifest-generator.jsif the generator's MV2/MV3 split needs the new fields. Verify both emitted manifests. -
RP-B. Permission helpers module. New
src/permissions.js:hasOrigin(origin),requestOrigins(origins),removeOrigin(origin),listGrantedOrigins(). Thin wrappers overchrome.permissionswith Firefox/Chrome parity (both implementchrome.permissionsunder different globals). Unit tests with the existing jsdom setup + a stubchrome.permissionsdouble. -
RP-C. Install-time batch prompt. In
extension/background.jsonInstalledhandler (install reason only), open the existing welcome tab AND either (a) callchrome.permissions.requestfrom a brief onboarding page bound to a user gesture, or (b) defer entirely to the options page with a prominent "Grant built-in forges" button. Decision: (b) —permissions.requestrequires a user gesture, andonInstalledhas none. The welcome page (external) stays as-is; add a one-click "Grant access to built-in forges" row at the top of the options page that batch-requests the five built-in origins. -
RP-D. Custom-forge save-time grant.
extension/options.js: when user edits thegitlabs/giteastextareas, diff the domain list on save and callchrome.permissions.request({origins: […]})for each newly added domain (translatingframagit.org→https://framagit.org/*). If the user denies, remove that domain from the textarea and show a status message. On removal of a domain, callchrome.permissions.remove. Also register/unregister a dynamic content script for the granted origin: MV3 →chrome.scripting.registerContentScripts; MV2 Firefox →browser.contentScripts.register. A small shim insrc/permissions.jshandles the split. -
RP-E. Content-script / UI fallback when permission missing. A forge page where the user revoked permission (or visits before granting during a mid-session edge case) must not silently fail.
src/content/main.js: whenfindMatchingForge(url)returns a match butchrome.permissions.containsis false for that origin, skip the fetch pipeline and call a newinsertGrantButton(...)insrc/content/ui.js. Distinct shape per decision 3 — e.g., same SVG inside a dashed-outline circle (vs. current filled square) — with tooltip "UpdateSWH needs permission for this forge. Click to grant." Click handler callschrome.permissions.requestfrom within the user gesture (content scripts CAN callpermissions.requestwhen it originates from a page click). On grant, reload the flow. Note: the content script only reaches this branch for domains in the basecontent_scripts.matcheslist — which by RP-A already covers all built-ins. For post-install revocations this is the correct fallback. -
RP-F. Tests + smoke. - Unit tests for
src/permissions.js. - Extend options.js / options.html with minimal DOM tests if the existing harness supports it (otherwise manual smoke only; options.js is currently untested). - Manual smoke on Firefox + Chrome + Edge: fresh install → options grant button → visit each built-in forge → revoke one origin → verify fallback grant-button renders → click → verify button re-renders correctly. Record results in the journal. -
RP-G. Docs — full review. The runtime-permission model changes the extension's UX and architecture significantly. Both
README.mdandCONTRIBUTING.mdneed a thorough review and rewrite, not just a patch: -README.md: document the new permission model (what users see on install, how to grant/revoke per-forge permissions, custom forge flow via options page). Remove any language implying the extension silently accesses all sites. -CONTRIBUTING.md: update the forge-record contract (theBUILTIN_FORGE_DOMAINSexport, the manifest match-pattern list,optional_host_permissions). Document the dynamic content-script registration path for custom forges. Describe the options-page save flow. -CLAUDE.md: replace the<all_urls>"Gotchas" entry with a description of the new optional-permission architecture. Update the Architecture section to coversrc/permissions.js, the options-page permission flow, and the background-script re-registration. Mark this section complete.
- RP-A:
GITLAB_KNOWN/GITEA_KNOWNare regex alternations. For manifest match patterns we must enumerate them explicitly (https://framagit.org/*,https://codeberg.org/*, …). Keep that list insrc/forges.jsas a derived constant so the manifest generator and content-script matches stay in sync. - RP-D: the existing options.js has no save button — it saves on
every
inputevent. For permission requests we need a user-gesture-bound control; add an explicit "Save" button for the forge-domain textareas (checkboxes/tokens can keep auto-save).
Builds on runtime-host-permissions. User feedback: the single "Grant all built-in forges" button is blunt; users want selective, per-origin control, and the gitlabs/giteas textareas are clumsy.
- Per-forge toggle slider for every forge (built-in + custom) in one
unified list. Initial state mirrors
permissions.contains(granted→ON, missing→OFF). Click OFF→ON triggerspermissions.requestwithin the gesture; deny reverts. Click ON→OFF triggerspermissions.remove. "Grant all built-in forges" bulk button stays. - Drop the
gitlabs/giteastextareas. Custom forges can only be added via the popup "add as GitLab/Gitea" flow; each appears as a row with a slider + type badge + × delete button. - Import/Export forge whitelist (JSON). Import shows a pending list +
"Grant and import" button so the user gesture survives the async
FileReaderread.
customForges: [{domain: "framagit.org", type: "gitlab"}].
One-shot idempotent migration (called from both background.js and
options.js on load): if customForges exists, use it; else read
legacy gitlabs + giteas text, parse, build the array, write
customForges, delete legacy keys. This collapses the dual storage
(textarea + customForgeOrigins) that produced earlier bugs.
customForgeOrigins stays as a derived cache (set by options.js
whenever customForges changes) so that background's content-script
injector doesn't need to re-parse.
- PFS-A. Storage migration + plumbing (options, background, src/content/main.js).
- PFS-B. Options UI: unified row list + Import/Export buttons; drop textareas.
- PFS-C. Slider + import/export behavior: per-row grant/revoke; bulk import via "Grant and import" confirm button; JSON export as a downloaded file.
- PFS-D. Popup: write directly to
customForges. - PFS-E. Tests + Firefox ESR smoke.
Append one line per meaningful change. Keep terse.
- 2026-04-14 — branch
leancreated frommain@ef40348; plan + journal committed (f155297). No phases started yet. - 2026-04-14 (session 2) — Phase A complete (
0cbc55d). A.1 vendored build/test infra (manifest generator, vite/jest/babel configs, unit tests, package.json, .gitignore); dropped Playwright from package.json. A.2 was a no-op (lean was cut clean from main — no scratch files to delete). A.3 baseline:npm testfails as expected because tests importsrc/utils/dateUtils.jsandsrc/forges/*.jswhich land in Phases C–E. Next: Phase B (background.js with SWH proxy). - 2026-04-14 (session 2 cont.) — Phase B complete (
fefc7ea). Rewroteextension/background.jswith FETCH_SWH_API proxy, fixed thedata.type = "createtab"assignment bug from main. - 2026-04-14 (session 2 cont.) — Phase C complete (
9f1e5c3). Flatsrc/forges.jstable +src/constants.js. Rewrotetests/unit/forgeHandlers.test.jsagainst the flat-table API; 19/19 pass. - 2026-04-14 (session 2 cont.) — Phase D complete (
8445092).src/api/forge.jsandsrc/api/swh.jswith uniform{ok, data|errorType, status}shape. - 2026-04-14 (session 2 cont.) — Phase E complete (
58ef010). Vanilla DOMinsertSaveIconinsrc/content/ui.js;requestSwhSaveadded to the SWH client. - 2026-04-14 (session 2 cont.) — Phase F complete (
af72dff).src/content/navigation.js: popstate + turbo:load + turbo:render + 500ms poll. - 2026-04-14 (session 2 cont.) — Phase G complete (
5cd6a82). Orchestration insrc/content/main.js; dateUtils vendored; 29/29 tests pass. - 2026-04-14 (session 2 cont.) — Phase H complete (
7876065). Manifests drop jQuery; build artifacts untracked. Bundle size: 18.74 KB (vs. ~85 KB jQuery + 20 KB old monolith). - 2026-04-14 (session 2 cont.) — Phase I complete (
e30c939). Cache extracted tosrc/utils/cache.js; tests for cache and navigation added; 42/42 pass. - 2026-04-14 (session 2 cont.) — Phase J complete. Smoke test on
GitHub surfaced red-button regression from Phase H's optional host
permissions; fixed in
77f077dby restoring<all_urls>as required (runtime-grant UI is Future Work). Retest green across GitHub SPA nav, GitLab, Codeberg. Next: Phase K (docs rewrite). - 2026-04-17 — PFS-A through PFS-D implemented in one session
(uncommitted): storage migration to
customForges: [{domain, type}]; options page gains per-forge sliders + Import/Export buttons + custom row delete; popup writes the new shape; background migrates on startup; content script derives gitlabs/giteas at runtime from the array. 72/72 unit tests pass (4 new incustomForges.test.js); build green at 23.53 KB. Next (PFS-E): Firefox ESR + Chrome smoke; if green, commit + journal entry. No commits yet on this batch.
Git state
- Current branch:
lean@f155297(contains onlyLEAN_REWRITE_PLAN.md+JOURNAL.mdon top ofmain@ef40348). - Working tree clean; untracked:
Chrome.zip,Edge.zip,FireFox.zip,node_modules/(build artifacts, ignore). - Stash
stash@{0}= "On refactor: untracked-from-refactor" contains the loose files that were untracked while exploringrefactor(CLAUDE.md,CODEBASE_SUMMARY.md,REFACTOR_LOG.md,TESTING_GUIDE.md,MANUAL_TESTING_CHECKLIST.md,PACKAGE_READY.md,.eslintrc.js[~],.eslintrc.yml,package-lock.json,src/utils/sleep.js,tmp/,extension/0001-*.patch,extension/TODO,extension/popup.new.js,extension-2/,extension-3/,todo,.#known-instances). All of this is on the delete list of Phase A exceptCLAUDE.mdandpackage-lock.json. Simplest next-session action: do not restore this stash; instead copy just the two useful files (CLAUDE.md,package-lock.json) from therefactorbranch when needed, then drop the stash.
How to resume cleanly next session
cd /home/dicosmo/code/updateswh
git checkout lean # should already be here
git status # confirm clean
cat LEAN_REWRITE_PLAN.md # decisions + phase list
cat JOURNAL.md # lessons from session 1Then start Phase A. Concrete first commits planned:
-
Phase A.1 — copy from
refactor:build/manifest-generator.js,src/manifest-base.json,vite.config.js,jest.config.js,babel.config.js, thetests/unit/*.test.jsfiles, and the relevant additions topackage.json(scripts.build,dev,test,lint; devDepsvite,jest,@babel/preset-env,babel-jest). Leavepackage-lock.jsonalone for now. Commit: "Phase A.1: vendor build and test infra from refactor". -
Phase A.2 — delete the scratch files listed under Hygiene in this plan. Commit: "Phase A.2: remove refactor-era scratch and planning docs".
-
Phase A.3 — run
npm install,npm test, confirm unit tests pass before any rewrite begins (they'll likely fail or need trivial adaptation because they import fromsrc/utils/dateUtils.jsandsrc/forges/*.jswhich don't exist yet onlean). If they fail, temporarily skip — we'll re-enable them as the matching modules come in during Phases C–E.
Memory system state
Saved at /home/dicosmo/.claude/projects/-home-dicosmo-code-updateswh/memory/:
user_role.md— Roberto's profilefeedback_collaboration.md— four rules (plan-before-code, keep progress+journal, avoid .md sprawl, prefer flat over layered)project_lean_rewrite.md— full context for this rewrite initiativeMEMORY.md— index pointing to the above
Key facts worth re-reading first thing next session
- GitHub SPA navigation needs
popstate+turbo:load/turbo:render+ a 500 mslocation.hrefpoll. Not a MutationObserver subtree. - Forge APIs allow CORS; only SWH needs the background proxy.
updategitlabhandlers/updategiteahandlersfrom main'supdateswh.jsmust be preserved in the flat forge table — they handle user-defined custom instances fromsettings.gitlabs/settings.giteas.- The
isCompletepolling loop inmain'supdateswh.js(line ~333) has a latent cache bug:lastresultsis written synchronously before the async chain completes, so a second call on the same URL returns an still-in-flight result. The inflight-dedup Map in the rewrite fixes this.