- JavaScript 57%
- CSS 28.3%
- HTML 9.4%
- Go Template 5.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| debian | ||
| screenshots | ||
| scripts | ||
| src | ||
| .gitignore | ||
| AGENTS.md | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
Forgejo Sidetree
A browser extension that adds a GitHub-style file tree sidebar to Forgejo repository pages. Supports both Chrome and Firefox (Manifest V3).
Screenshots
| Dark theme | Light theme |
|---|---|
![]() |
![]() |
Features
- Inline sidebar — file tree injected into the page layout as a flex child, no floating overlay, no fixed positioning
- Custom Forgejo instance — configure any Forgejo hostname via the options page (default: empty, must be set before first use)
- API token support — optional token for private repository access, stored in
storage.sync - Directory-aware tree — shows all top-level directories via non-recursive
git/trees/{sha}(no 1000-entry truncation) - Lazy subdirectory loading — expanding a directory fetches its contents via
repos/{owner}/{repo}/contents/{path}(60s cache) - Auto-expand current path — on page load and SPA navigation, the current directory's ancestor chain is automatically expanded
- File page support — when viewing a file, the sidebar shows its parent directory with the file highlighted (detected from API response, not DOM class)
- Resizable sidebar — drag the divider between sidebar and content (16px hot zone,
col-resizecursor on hover,::beforeseparator line) - Collapsible sidebar — toggle button in the repo toolbar (near branch selector), keyboard shortcut: Ctrl+Shift+E, toolbar icon click
- Sticky sidebar —
position: sticky; top: 0keeps the sidebar visible while scrolling; parentoverflow: hiddenis auto-cleared - No install-time site access — the extension requests no host permissions up front; the options page asks for the one Forgejo host you name, and the sidebar is injected on demand into that origin only
- Forgejo theme integration — uses Forgejo's CSS variables for seamless visual blending
- Octicon SVG icons — folder (closed/open), file, and chevron icons via inline SVG data URIs (no emoji dependency)
- Custom application icon — a node-and-branch tree (one root node branching to two children) drawn in Forgejo's own logo palette (
#f60root and trunk,#d40000branches and child nodes), kept asicons/icon.svgand rasterised at 16/48/128 px with every edge on a whole pixel so it stays crisp in the toolbar
Design History
| Iteration | Approach | Problem |
|---|---|---|
| v1 | Floating sidebar panel, toggle button | Floating overlay, not inline |
| v2 | DOM-injected inline sidebar (flex wrapper) | Content pushed to far right |
| v3 | Inner flex wrapper, per-directory API | Root URL not handled |
| v4 | git/trees/{sha}?recursive=1 full tree |
<1000 files: instant; >=1000: truncated |
| v5 | Root tree + per-dir lazy (unified) | Every expand needs a network call (rejected) |
| v6 | Smart truncation detection | Can't detect completely missing dirs |
| v7 (current) | Non-recursive root tree + per-dir lazy on expand | Root always complete, expand is 1 network call |
Why v7 won
git/trees/{sha}(no?recursive=1) returns the root-level tree entries with no truncation — all top-level directories are always visible- Each directory expansion triggers one
contents/{path}call, cached for 60s - Small repos (<1000 files) would have been faster with recursive, but the UX consistency of "all root dirs always visible" was preferred over the truncation headache
Build
Built with a minimal toolchain — one dev dependency:
npm install
npm run build # both Firefox + Chrome
One dev dependency: web-ext (Firefox packaging). Manifest templates are rendered by scripts/render-manifest.js (stdlib node, no Mustache). Chrome zipping uses python3 -m zipfile (stdlib, no zip/7z).
Output
dist/
├── firefox.zip # Signed Firefox extension (web-ext build)
└── chrome.zip # Chrome extension (zip of src/)
Both zips are committed to the repo for easy sideloading.
Architecture
Cross-Browser Compatibility
| Aspect | Chrome | Firefox |
|---|---|---|
| API namespace | chrome.* |
browser.* (native) |
| Shim | if (typeof browser === 'undefined') { var browser = chrome; } in each JS file |
N/A |
| Background | service_worker |
scripts (array) |
| Manifest | manifest.chrome.json.tmpl |
manifest.firefox.json.tmpl (with browser_specific_settings.gecko) |
| Scrollbar | ::-webkit-scrollbar |
scrollbar-width: thin |
| Packaging | cp + python3 -m zipfile |
web-ext build |
Dependencies
- web-ext — Firefox extension building
- Everything else: stdlib (node.js, python3 -m zipfile, bash)
File Structure
forgejo-sidetree/
├── screenshots/ # Extension screenshots
├── scripts/
│ └── render-manifest.js # Manifest template renderer (node stdlib, 17 lines)
├── src/
│ ├── background.js # action.onClicked + commands.onCommand handlers
│ ├── content.css # Sidebar styles (inline flex, sticky, Octicon SVG icons)
│ ├── content.js # All extension logic (URL parsing, API, tree rendering)
│ ├── icons/ # icon.svg source + 16/48/128 PNG
│ ├── manifest.chrome.json.tmpl # → dist/chrome/manifest.json (built)
│ ├── manifest.firefox.json.tmpl # → src/manifest.json (built)
│ ├── options.html # Settings page (hostname + API token)
│ └── options.js # Settings logic
├── package.json # One devDependency: web-ext
├── .gitignore
├── AGENTS.md -> README.md
└── README.md
content.js — Key Design Decisions
Settings (options page)
forgejoHost— hostname of the Forgejo instance (e.g.git.example.com). Default: empty.forgejoToken— optional API token for private repo access.- Both stored in
browser.storage.sync. Read once at startup vialoadSettings(). - Saving the options page also requests access:
permissions.request()forhttps://<host>/*andhttp://<host>/*, thenpermissions.remove()for a previous host so a changed hostname leaves no standing grant behind. - The sidebar is injected by
background.jsfromtabs.onUpdated, not by a staticcontent_scriptsentry — the hostname is unknown at install time.content.jssetsglobalThis.__fstLoadedon first run and throws on a second, so reloads cannot register every listener twice. - If unconfigured, a banner appears in the bottom-right corner with a link to the options page.
browser.runtime.onInstalledopens the options page automatically on first install.
API Strategy
init()
└─ loadSettings() ── Read host + token from storage.sync
├─ (unconfigured) ── showConfigBanner(), return early
└─ parseURL() ────── Currently: hardcoded FORGEJO_HOST.
│ Options page → storage.sync for dynamic host (wip)
└─ fetchTreeRoot() ── GET /repos/{owner}/{repo}/git/trees/{sha}
│ Returns ALL root-level entries (no limit)
├─ renderItems() ── Render root items in #fst-tree
└─ expandChain() ── Walk currentPath from root, for each ancestor:
└─ fetchDir() ──── GET /repos/{owner}/{repo}/contents/{path}?ref={branch}
Fetches subdirectory contents on demand
API calls include Authorization: token <api_token> when configured.
File vs Directory Detection
The API response from contents/{path} returns an array for directories and a single object for files. The content script checks Array.isArray(items) to distinguish. If it's a single file object, the parent directory is extracted and re-fetched, with the file name highlighted in the tree.
Drag Resize
- 16px hot zone between sidebar and content
#fst-drag-handleelement —position: relativein flex flow::beforepseudo-element draws the 1px separator line (--color-secondary)cursor: col-resizeon the handle — visible on hover across the full 16px zonemousedownon element →mousemove/mouseupondocument- Range: 180px–600px, default 280px
Sidebar Toggle
- Toggle button — injected into
.repo-button-rowbefore.js-branch-tag-selector, uses Forgejo'sui basic small compact buttonclass with Octiconsidebar-expand/sidebar-collapseSVG icons - Toolbar icon —
browser.action.onClickedsends toggle message to content script - Keyboard shortcut —
Ctrl+Shift+Eviacommands.onCommand('toggle-sidebar')+keydownfallback .fst-wrapper-collapsedclass: sidebar width → 0, drag handle hidden, content gets 14px left padding- Button removed on navigation away from tree pages
Keyboard shortcut fallback mechanism: The content script queries browser.commands.getAll() via the background at startup. If the toggle-sidebar command has a shortcut assigned (user set one in browser settings), the keydown fallback is skipped — the commands API handles it. If no shortcut is assigned (common on Edge unpacked), the keydown fallback registers Ctrl+Shift+E directly. A 200ms debounce prevents double-triggering.
Sticky Sidebar
position: sticky; top: 0; height: 100vh- At page top: sidebar occupies its natural position (below the Code tab, inside
.page-content) - When scrolling past header: sticky kicks in, sidebar sticks flush to viewport top (no gap)
- Parent
overflow: hiddenis cleared via JS walk-up to prevent sticky breakage - Sidebar is in normal flex flow (not
position: fixed), so footer and Code tab are never covered
SPA Navigation
MutationObserverwatcheswindow.location.hrefchanges- Same repo+branch: only update expand state + highlight (no API calls, no DOM rebuild)
- Different repo/branch: full re-init (fetch new root tree, rebuild sidebar)
- Non-tree page: remove sidebar entirely, restore
.ui.containerinline styles
content.css — Key Design Decisions
| Property | Value | Why |
|---|---|---|
| Sidebar bg | var(--color-body) |
Matches page background, not a separate panel |
| Node height | 28px | Compact, matches Forgejo row density |
| Depth spacer | 14px per level | Tighter indentation than default |
| Font size | 13px | Matches Forgejo body text |
| Drag handle | 16px | Wide enough for easy mouse targeting |
| Separator line | 1px ::before |
Merged with drag handle, no duplicate border |
| Position | sticky; top: 0 |
Follows page flow, doesn't cover footer/Code tab |
| Icons | Octicon SVG masks | No emoji dependency, theme-adapting color |
Usage
- Install the extension (load unpacked in Chrome, or install the XPI in Firefox)
- Right-click the extension icon → Options → set your Forgejo hostname (required before first use; opens automatically on first install)
- Navigate to
https://{your-forgejo-instance}/{owner}/{repo}/src/branch/{branch}/... - The file tree sidebar appears on the left side of the page
- Click directories to expand/collapse
- Click files to navigate
- Drag the divider to resize the sidebar
- Click the sidebar toggle button in the toolbar (near branch selector) to collapse/expand
Keyboard Shortcuts
- Ctrl+Shift+E — toggle sidebar. Customize or disable at
chrome://extensions/shortcuts,edge://extensions/shortcuts, orabout:addons(Firefox). - Toolbar icon click — also toggles the sidebar.
Ponytail Simplifications
The project was simplified using the ponytail (lazy senior dev) approach:
| Before | After | Savings |
|---|---|---|
| 9 devDependencies | 1 (web-ext) | 8 removed, 132M→90M node_modules |
| mustache (template) | scripts/render-manifest.js (17 lines, node stdlib) |
No external dep |
| npm-run-all | && shell chaining |
No external dep |
| addons-linter, eslint, stylelint | Removed | No config files, no lint deps |
background.js (sidebar router, 23 lines) |
Simplified to 18-line action.onClicked + commands.onCommand |
Smaller background |
sidebar.html + sidebar.js |
Removed | Firefox sidebar_action dropped |
Known Limitations
- Branch switching within the same repo triggers a full re-init (root tree re-fetch)
- File tree doesn't show at repo root URL (
/{owner}/{repo}) — only on/src/branch/...pages - Large subdirectories (>1000 files at a single level) are fully loaded on expand — no internal pagination
- Forgejo host is configured via the options page (default: empty, must be set before use). Saving also grants access to that one host — the extension requests no site access at install time.
- Edge unpacked extensions: the
commandsAPI may not auto-registersuggested_key. Akeydownfallback listener fills this gap. If you customize the shortcut inedge://extensions/shortcuts, the fallback automatically disables itself.
Changelog
Version history, written for AMO / CWS review. Versions match package.json and the manifest version field.
0.2.1 — 2026-10-01
Changed
- The manifest no longer declares
browser_specific_settings.gecko_android. That sub-key is how an extension tells AMO it supports Firefox for Android; with it present, AMO listed this extension as Available on Firefox for Android and offered an Android QR code. It was added in 0.1.1 to silence a linter warning — Android needsdata_collection_permissions, which landed in Android 142 while desktop got it in 140 — but the cost was advertising a platform this extension does not support: the sidebar is injected into Forgejo's desktop page layout, which is not what Firefox for Android renders. Dropping the key is the only way to undo that, because AMO locks its Android compatibility controls for any version whose manifest carriesgecko_android. - The desktop
strict_min_versionrises from 140 to 142. That is what keeps the lint run clean without claiming Android support: the warning compares the desktop floor against the Android version that introduceddata_collection_permissions, so a floor below 142 recreates it (verified — keeping 140 alongside the removed key reproducesKEY_FIREFOX_ANDROID_UNSUPPORTED_BY_MIN_VERSION). 142 is well behind current releases, so the practical effect is nil.
Notes for reviewers
- No permission changes in this release: still
storage+scripting, with host access granted per-host from the options page. - Not offering the extension on Firefox for Android is deliberate. The content script targets
.page-content.repository.file.listand the options page carries no viewport handling, so neither is usable on mobile — listing it there only invites installs that cannot work. - Verified with
web-ext linton the packaged xpi: 0 errors, 0 notices, 0 warnings.
0.2.0 — 2026-10-01
Changed
- The extension no longer asks for access to every site at install time. It declared
content_scriptsmatchinghttps://*/*andhttp://*/*plus the same patterns ashost_permissions, so installing it produced the browser's "Access your data for all websites" prompt. Nothing needed that breadth: the Forgejo hostname is chosen by the user at setup, so it was there only because a manifest cannot know the hostname in advance. The manifest now declares those same patterns asoptional_host_permissions, declares nocontent_scriptsat all, andbackground.jsinjects the sidebar on demand into the single origin the user has actually granted, throughscripting.executeScriptandscripting.insertCSS. - The options page asks for access when you save.
permissions.request()is called from the Save button's click handler — it only works from a user gesture, so it is the first thing awaited there — forhttps://<your-host>/*andhttp://<your-host>/*. Declining leaves the hostname unsaved and the sidebar off. Changing the hostname callspermissions.remove()for the previous host, so a site you no longer use does not keep standing access. - The options page reports "No access to
<host>yet — save to grant it" on load when the stored hostname has no matching grant. That is the state an existing install lands in after this update. tabsis no longer requested.tabs.onUpdatedandtabs.sendMessageneed no permission, and the only URL the background reads is one it already holds host access to. The permission set is nowstorage+scripting.- The token field's placeholder no longer implies a
ghp_prefix, which is GitHub's format rather than Forgejo's.
Notes for reviewers
- This release strictly reduces what the extension can reach. Removed: the
https://*/*andhttp://*/*host permissions, thecontent_scriptsentry, andtabs. Added:scripting, which on-demand injection requires, andoptional_host_permissionscarrying the same broad patterns — a user can grant at most one specific host, and only from the options page. - The broad patterns in
optional_host_permissionsare unavoidable rather than careless: a self-hosted Forgejo can live on any domain and the hostname is not known until the user types it. Nothing is requested before that point, andpermissions.request()is always called with one concrete origin (https://git.example.com/*), never with the whole pattern. - Injection is idempotent by two means:
background.jsprobes the page forglobalThis.__fstLoadedbefore injecting, andcontent.jssets that flag and throws if it runs a second time, so a reload cannot register every listener twice. Verified against a live Forgejo page by reloading it three times: wrapper, toggle button and tree-node counts held at 1 / 1 / 17 throughout. - Verification used the packaged build unpacked from
dist/chrome.zip, not the source directory. With no host grant, a real Forgejo directory page shows no#fst-wrapperand no.fst-node. After the grant, the sidebar renders 17 nodes and the toggle button. The options Save flow reports success and stores the hostname, and the stored grant is confirmed withpermissions.contains(). - Upgrade note for existing installs: the old broad grant disappears with the update, so the sidebar stays off until the options page is opened and Save is pressed once. The options page states this on load rather than failing silently.
- Still no remote code — no
eval, no script loaded over the network.data_collection_permissionsremains{"required": ["none"]}.
0.1.1 — 2026-10-01
Fixed
- Sidebars broke on repositories containing a path with a quote or backslash. The path was interpolated raw into a
querySelectorattribute selector. Measured in Chromium:[data-path="weird"name"]throwsSyntaxError(the sidebar's whole tree is replaced by the error message, because the throw happens insideinit()'s try block), and[data-path="back\slash"]silently matches nothing (the auto-expand chain stops at that level — the sidebar renders but never expands to the page you are on). All three selector sites now pass the path throughCSS.escape().
Changed
- Internal refactor of
content.js, no behavior change: oneexpandNode()now backs directory expand, the auto-expand chain, and SPA re-expansion, replacing three near-identical copies; the five module-scope variables collapsed into a singlestateobject; node depth is read fromdata-depthrather than parsed back out of the inline--fst-depthcustom property; the content container's style overrides are driven by one map shared by inject and teardown. - Removed dead code: an unused
isFileheuristic in the SPA branch, and afst-loadingclass toggle that had no matching CSS rule (only the#fst-loadingplaceholder exists). - The options reminder banner's styles moved from JavaScript into
content.css. - New application icon. The placeholder green triangle PNG is replaced by an SVG-derived mark — a node-and-branch tree (one root node branching to two children), kept as
icons/icon.svgand rasterised at 16/48/128 px. The palette is taken from Forgejo's own logo SVG (public/assets/img/logo.svg): orange#f60for the root node and trunk, red#d40000for the branches and their child nodes. That logo draws a commit graph — three nodes and two branching paths — in the same stroke language, so the mark reads as part of the same family. Measured against real toolbar backgrounds, the two colours are complementary: red is 4.92:1 on the light toolbar while orange is 5.02:1 on the dark one, so at least one half of the mark stays clearly visible in either theme. The mark is drawn as a tree rather than as a container: at 16px a filled panel with rows inside reads as a document or a list, while a node-and-branch shape reads as a hierarchy — the same visual language Octotree uses. Edges sit on whole pixels at 16px, so the toolbar size stays sharp instead of greying out from antialiasing. package.jsonauthor is nowtianheg; the retiredtianheg.xyzURL is gone.
Notes for reviewers
- No new permissions: still
storage+tabs, withhttps://*/*andhttp://*/*host permissions. - No remote code — no
eval, no script loaded from the network. Everything runs from the packaged files. data_collection_permissionsremains{"required": ["none"]}.- Verifying this release: the build was loaded into Chromium next to 0.1.0, and live DOM snapshots were diffed across eight interaction steps (initial render, expand, collapse, toolbar toggle, SPA navigation between paths, navigation to a non-tree page). Zero differences; viewport screenshots of both builds were byte-identical.
0.1.0 — 2026-06-19
First release.
Added
- GitHub-style file tree sidebar on Forgejo repository pages, injected inline into the page layout as a flex child — no floating overlay, no fixed positioning
- Directory-aware tree: non-recursive
git/trees/{sha}for the root level (sidesteps the 1000-entry truncation that silently drops whole directories), lazy per-directorycontents/{path}on expand, 60-second cache - Auto-expand of the current path's ancestor chain on load and on SPA navigation; file pages show the parent directory with the file highlighted
- Resizable sidebar (drag divider, 180–600px) and collapsible via the toolbar button, the extension icon, or
Ctrl+Shift+E - Sticky sidebar that follows the page while scrolling, clearing the parent
overflow: hiddenchain that would otherwise breakposition: sticky - Options page for any Forgejo hostname, plus an optional API token for private repositories (stored in
storage.sync); a reminder banner appears until the hostname is set - Octicon SVG icons rendered through CSS masks — no emoji, no icon font, no network request
- One source tree building both Chrome and Firefox packages (Manifest V3,
browser/chromeshim,web-extpackaging)
Fixed during 0.1.0 development
- Drag handle invisible and untargetable — an empty flex child collapses to zero height under
align-items: flex-start position: stickysilently broken by an ancestoroverflow: hidden- Sidebar DOM discarded on SPA navigation; manual expand/collapse state not preserved
- Toggle double-firing when both the
commandsAPI and the keydown fallback are live (200 ms debounce) - Toolbar icon dead on unpacked Edge/Chrome builds (added
action.onClicked, plus the keydown fallback) - Firefox manifest validation:
data_collection_permissionsplacement andstrict_min_versionformat - Chrome build shipped without
background.js
License
MPL-2.0

