- Python 57%
- JavaScript 43%
| bin | ||
| extension | ||
| tests | ||
| .gitignore | ||
| README.md | ||
tg — one Chrome tab group per project
tg makes Chrome follow your project the way an editor or terminal does: every project
(a wd workdir, say LPS-123) gets a tab group
of that name, and switching projects expands that group and collapses the others. All
tabs of a ticket live inside its group; the top level stays small.
tg focus [LPS-123] bring the group to the front with its last used tab
(created if missing), collapse the rest; NAME defaults
to the project of $PWD
tg open -a LPS-123 URL... open urls in the group, reusing tabs that show them
tg adopt LPS-123 move the active tab into the group
tg close LPS-123 close the group's tabs
tg route URL show / take over the tab the url belongs to (browser-open
calls this first); exit 3: no such tab
tg main [--show|--unset] make the last focused window the main one
tg open-main URL new tab in the main window, shown (browser-open uses
this when no tab has the url yet)
tg tabs [NAME] the group's tabs (NAME defaults to the project of $PWD)
tg go [-n NAME] [QUERY...] select the tab matching the words (title or url);
several matches: pick with fzf
tg groups | tg list [--json] what is where
tg status is the extension connected?
Under niri focus and open -a also raise the Chrome
window (--no-raise to switch quietly, e.g. while another window should keep focus).
Why an extension
Tab groups are only reachable through the extension APIs (chrome.tabGroups); Chrome
ignores --remote-debugging-port on the default profile, and CDP has no tab groups
anyway. So three small parts, all in this repo:
tg --unix socket--> tg-host --native messaging--> extension/
extension/— MV3 extension doing the work (tabs,tabGroups,nativeMessaging). Itsmanifest.jsonpins akey, so its id is stable:odkcmdcpmkiifoakjbjdaomboncmhgga.bin/tg-host— native messaging host, started by Chrome; relays between the socket$XDG_RUNTIME_DIR/tg.sockand the extension.bin/tg— the command line tool. Python 3 stdlib only; no dependencies.
open reuses a tab that shows the same page anywhere, or a subpage of it inside the group
(/pull/1/files for /pull/1, ?focusedCommentId=… for a ticket) rather than opening
a duplicate; reload the extension after updating it.
Which tab a url belongs to is decided in extension/match.js (tests: node --test tests/match.test.mjs): the same page or a subpage of it (GitHub PR/issue/run/commit by
repo and number, Jira by ticket key) is reused; a CI run or commit URL navigates the most
recently used PR/run tab of the same repo (history keeps the PR one Back away); other PRs
and issues are peers and open their own tab.
A group is identified by its title; renaming one by hand detaches it from its project. Chrome syncs tab groups, so ticket names show up on your other devices too.
Install
Chrome 138+ disables unpacked extensions whenever developer mode is off, so the lasting install is a packed, force-installed one (works on a managed profile as long as no org policy blocks it):
tg installwrites the native messaging manifest (~/.config/google-chrome/NativeMessagingHosts/tg_host.json, pointing atbin/tg-host).tg packbuildsdist/tg.crxanddist/update.xml, signed with the private key from gopass (tg/extension-key; its public half is thekeyinmanifest.json, so the extension idodkcmdcpmkiifoakjbjdaomboncmhggais fixed).tg policy > /tmp/tg.json; sudo install -Dm644 /tmp/tg.json /etc/opt/chrome/policies/managed/tg.jsonforce-installs it and keeps it updated from that update manifest (afile://URL, so it must be the machine's final clone path). It usesExtensionSettings: the simplerExtensionInstallForcelistonly uses its update URL for the first install. Restart Chrome;tg statusshould sayok.
Updating (verified: 0.2.2 → 0.3.0): bump version in extension/manifest.json, commit, push,
then on the machine git -C ~/.local/share/tg pull && tg pack. Chrome fetches the new
build from dist/update.xml on its own schedule (hours); to get it now, open
chrome://extensions and press Update (developer mode on), no restart needed.
This works because tg pack writes an update_url into the packed manifest: the update
URL in the policy only serves the first install, and a build without one is never updated
(it needs a one-time remove-and-reinstall via the policy file). For development, Load
unpacked extension/ with developer mode on; remove it again before the packed one runs
(same id).
With chezmoi, .chezmoiexternal.toml keeps the clone at ~/.local/share/tg and a
run_onchange_after_ script calls tg install; pack and policy stay manual.
The main window
Links from outside (Slack, mail: browser-open) that no tab shows yet go into one main
window, so the other windows stay whatever you made them. tg main makes the last focused
normal window the main one. Chrome gives its windows new ids on every start, so the
choice lasts until Chrome quits (or that window closes); until it is made again, links go
to the last focused normal window, as Chrome itself would do, minus the new window Chrome
opens when the last focused one is a PWA.
Limits
- Chrome only (
google-chrome); Chromium, Vivaldi etc. would need their own manifest dir. - One Chrome window per project set is assumed: groups are looked up in the last focused normal window first, otherwise anywhere.
- The policy file makes Chrome report "managed by your organization" at browser level.
Tests
python3 -I -m unittest discover -s tests
Covers the relay (CLI ⇄ host); the extension itself has to be tried in Chrome.