Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a98d276
feat: add tab group support (skip collapsed groups, zc/zj/zk navigation)
Mar 2, 2026
d26e786
feat: enable tab group commands on Firefox via feature detection
May 3, 2026
5164448
feat: remap tab group shortcuts with circular navigation
MarvinHauke Jun 14, 2026
d96534c
feat: make << and >> group-aware for tab movement
MarvinHauke Jun 14, 2026
24e46e5
refactor: extract tab group logic into tab_groups.js + add group_comp…
MarvinHauke Jun 14, 2026
592a312
feat: add multi-tab selection via ctrl+shift+j / ctrl+shift+k
MarvinHauke Jun 14, 2026
8a23e30
fix: change multi-tab selection shortcuts to <a-J> / <a-K>
MarvinHauke Jun 14, 2026
cca3b6b
fix: rework multi-tab selection shortcuts and logic
MarvinHauke Jun 14, 2026
61d5deb
feat: add tab group Vomnibar commands (zg / ZG) with color swatches
MarvinHauke Jun 14, 2026
d63e285
test: add unit tests for tab group completers and multi-tab selection
MarvinHauke Jun 14, 2026
c347772
fix: allow >> / << to jump past a collapsed group at the window edge
MarvinHauke Jun 14, 2026
6e73839
feat: add count support to zz/ZZ tab selection commands
MarvinHauke Jun 14, 2026
9e2bed9
feat: move tab selection block with >> / <<
MarvinHauke Jun 14, 2026
4be9330
fix: moveSelectionOneStep respects tab groups
MarvinHauke Jun 15, 2026
4889a34
fix: move group instead of selection when skipping collapsed group
MarvinHauke Jun 15, 2026
fbfcc4f
fix: group-aware multi-selection movement for >> / <<
MarvinHauke Jun 15, 2026
191d0e6
feat: add zA to collapse all tab groups, wire za/zA to tab recency hi…
MarvinHauke Jul 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
dist
dist
.serena
38 changes: 38 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# TODO

## Tab history — jump to last selected tab

**Status: already implemented, just not under this name.** This doc originally proposed
building a new per-window in-memory history stack, but that duplicates functionality that
already exists in this codebase:

- `background_scripts/tab_recency.js` (`TabRecency` class) tracks tab-activation order
globally via `chrome.tabs.onActivated` / `chrome.windows.onFocusChanged`, persisted to
`chrome.storage.session` (survives service-worker restarts — the original in-memory
proposal below would not have).
- It's exposed as the `bgUtils.tabRecency` singleton (`background_scripts/bg_utils.js`).
- `visitPreviousTab` (`background_scripts/main.js`, bound to `^` via
`background_scripts/commands.js`) already jumps back through this history, and supports
a count prefix to cycle further back.

One real difference from the original proposal: `TabRecency` is global across all
windows, not scoped per-window. If per-window-only `^` navigation is ever wanted, that
would mean filtering `getTabsByRecency()` by `windowId` in `visitPreviousTab` — not
implemented, no current need for it.

`za`/`zA` (see below) now also build on `TabRecency` via a new
`bgUtils.getLastActiveTab({ windowId, excludeTabId, isValid })` helper, so this is the
shared mechanism going forward — don't add a second, competing history store.

## `za` / `zA` — collapse tab group(s) and jump to a sensible tab

**Status: implemented.**

- `za` (`collapseTabGroup`, `background_scripts/tab_groups.js`) collapses the current
tab's group and jumps to the last-active tab outside that group (via
`bgUtils.getLastActiveTab`), falling back to the previous index-proximity search, then
to opening a new tab, if no candidate is found.
- `zA` (`collapseAllTabGroups`, same file) collapses every expanded group in the window,
then jumps to the last-active *ungrouped* tab, with the same index-proximity → new-tab
fallback chain.
- Tests: `tests/unit_tests/tab_groups_test.js`, `tests/unit_tests/bg_utils_test.js`.
62 changes: 62 additions & 0 deletions background_scripts/all_commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,22 @@ const allCommands = [
noRepeat: true,
},

{
name: "Vomnibar.activateTabGroupSelection",
desc: "Search through your tab groups",
group: "vomnibar",
topFrame: true,
noRepeat: true,
},

{
name: "Vomnibar.activateGroupAssign",
desc: "Assign tab(s) to a group",
group: "vomnibar",
topFrame: true,
noRepeat: true,
},

{
name: "Vomnibar.activateEditUrl",
desc: "Edit the current URL",
Expand Down Expand Up @@ -519,6 +535,52 @@ const allCommands = [
noRepeat: true,
},

{
name: "collapseTabGroup",
desc: "Collapse current tab's group",
group: "tabs",
background: true,
noRepeat: true,
},

{
name: "collapseAllTabGroups",
desc: "Collapse all tab groups and jump to last active ungrouped tab",
group: "tabs",
background: true,
noRepeat: true,
},

{
name: "previousTabGroup",
desc: "Switch to previous tab group",
group: "tabs",
background: true,
noRepeat: true,
},

{
name: "nextTabGroup",
desc: "Switch to next tab group",
group: "tabs",
background: true,
noRepeat: true,
},

{
name: "selectNextTabForGroup",
desc: "Select next tab",
group: "tabs",
background: true,
},

{
name: "selectPreviousTabForGroup",
desc: "Select previous tab",
group: "tabs",
background: true,
},

{
name: "removeTab",
desc: "Close current tab",
Expand Down
15 changes: 15 additions & 0 deletions background_scripts/bg_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,18 @@ export async function getFirefoxVersion() {
// TODO(philc): tabRecency imports bg_utils. We should resovle the cycle for the sake of clarity.
export const tabRecency = new TabRecency();
tabRecency.init();

// Returns the most-recently-active tab in `windowId` that satisfies `isValid`, or null.
// Excludes `excludeTabId` (typically the currently active tab) even if it would otherwise
// be the most recent.
export async function getLastActiveTab({ windowId, excludeTabId, isValid }) {
await tabRecency.init();
const tabs = await chrome.tabs.query({ windowId });
const tabsById = new Map(tabs.map((t) => [t.id, t]));
for (const id of tabRecency.getTabsByRecency()) {
if (id === excludeTabId) continue;
const candidate = tabsById.get(id);
if (candidate && (!isValid || isValid(candidate))) return candidate;
}
return null;
}
8 changes: 8 additions & 0 deletions background_scripts/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,8 @@ const defaultKeyMappings = {
"o": "Vomnibar.activate",
"O": "Vomnibar.activateInNewTab",
"T": "Vomnibar.activateTabSelection",
"ZG": "Vomnibar.activateTabGroupSelection",
"zg": "Vomnibar.activateGroupAssign",
"b": "Vomnibar.activateBookmarks",
"B": "Vomnibar.activateBookmarksInNewTab",
":": "Vomnibar.activateCommandSelection",
Expand Down Expand Up @@ -483,6 +485,12 @@ const defaultKeyMappings = {
"zi": "zoomIn",
"zo": "zoomOut",
"z0": "zoomReset",
"za": "collapseTabGroup",
"zA": "collapseAllTabGroups",
"zN": "previousTabGroup",
"zn": "nextTabGroup",
"zz": "selectNextTabForGroup",
"ZZ": "selectPreviousTabForGroup",

// Marks
"m": "Marks.activateCreateMode",
Expand Down
11 changes: 7 additions & 4 deletions background_scripts/completion/completers.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export class Suggestion {
// The generated HTML string for showing this suggestion in the Vomnibar.
html;
searchUrl;
groupData;

constructor(options) {
Object.seal(this);
Expand Down Expand Up @@ -741,10 +742,12 @@ export class MultiCompleter {
const queryTerms = request.queryTerms;

// The only UX where we support showing results when there are no query terms is via
// Vomnibar.activateTabSelection, where we show the list of open tabs by recency.
const isTabCompleter = this.completers.length == 1 &&
this.completers[0] instanceof TabCompleter;
if (queryTerms.length == 0 && !isTabCompleter) {
// Vomnibar.activateTabSelection, and completers that explicitly opt in via showResultsWithNoQuery.
const showsEmptyResults = this.completers.length == 1 && (
this.completers[0] instanceof TabCompleter ||
this.completers[0].showResultsWithNoQuery
);
if (queryTerms.length == 0 && !showsEmptyResults) {
return [];
}

Expand Down
141 changes: 141 additions & 0 deletions background_scripts/completion/group_completer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import * as ranking from "./ranking.js";
import { Suggestion } from "./completers.js";

const GROUP_COLORS = ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"];

const COLOR_CSS = {
grey: "#5F6368", blue: "#1A73E8", red: "#D93025", yellow: "#F9AB00",
green: "#1E8E3E", pink: "#D01884", purple: "#8430CE", cyan: "#007B83", orange: "#E37400",
};

async function queryCurrentWindow() {
const win = await chrome.windows.getLastFocused({ populate: false });
const [groups, tabs] = await Promise.all([
chrome.tabGroups.query({ windowId: win.id }),
chrome.tabs.query({ windowId: win.id }),
]);
return { groups, tabs };
}

function buildFirstTabMap(tabs) {
const map = new Map();
for (const tab of tabs.sort((a, b) => a.index - b.index)) {
if (tab.groupId !== -1 && !map.has(tab.groupId)) {
map.set(tab.groupId, tab);
}
}
return map;
}

function esc(s) {
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

function colorSwatch(color) {
const bg = COLOR_CSS[color] ?? "#888";
return `<span class="group-color-swatch" style="background:${bg}"></span>`;
}

function groupHtml(group, url) {
const source = `${colorSwatch(group.color)}${esc(group.color)}`;
const name = esc(group.title || `(${group.color})`);
return `<div class="top-half"><span class="source">${source}</span><span class="title">${name}</span></div>` +
`<div class="bottom-half"><span class="url">${esc(url)}</span></div>`;
}

// ZG: navigate to a tab group by name.
export class TabGroupCompleter {
showResultsWithNoQuery = true;

async filter({ queryTerms }) {
if (!chrome.tabGroups) return [];
const { groups, tabs } = await queryCurrentWindow();
const firstTabByGroup = buildFirstTabMap(tabs);
return groups
.filter((g) => queryTerms.length === 0 || ranking.matches(queryTerms, g.title ?? "", g.color))
.map((group) => {
const firstTab = firstTabByGroup.get(group.id);
const url = firstTab?.url ?? "";
const s = new Suggestion({
queryTerms,
description: group.color || "tab group",
url,
title: group.title || `(${group.color})`,
tabId: firstTab?.id,
deDuplicate: false,
});
s.html = groupHtml(group, url);
s.relevancy = 1;
return s;
});
}
}

// zg step 1: assign highlighted tabs to an existing group, or start creating a new one.
export class TabGroupAssignCompleter {
showResultsWithNoQuery = true;

async filter({ queryTerms, query }) {
if (!chrome.tabGroups) return [];
const { groups, tabs } = await queryCurrentWindow();
const firstTabByGroup = buildFirstTabMap(tabs);
const existingMatches = groups
.filter((g) => queryTerms.length === 0 || ranking.matches(queryTerms, g.title ?? "", g.color))
.map((group) => {
const firstTab = firstTabByGroup.get(group.id);
const url = firstTab?.url ?? "";
const s = new Suggestion({
queryTerms,
description: group.color || "tab group",
url,
title: group.title || `(${group.color})`,
deDuplicate: false,
groupData: { action: "addToGroup", groupId: group.id },
});
s.html = groupHtml(group, url);
s.relevancy = 1;
return s;
});

const name = query.trim();
if (name) {
const create = new Suggestion({
queryTerms: [],
description: "new group",
url: "",
title: `Create "${name}"`,
deDuplicate: false,
groupData: { action: "createGroup", name },
});
create.html = `<div class="top-half"><span class="source">new group</span>` +
`<span class="title">${esc(`Create "${name}"`)}</span></div>`;
create.relevancy = 0;
return [...existingMatches, create];
}
return existingMatches;
}
}

// zg step 2: pick a color for a new group.
export class TabGroupColorCompleter {
showResultsWithNoQuery = true;

async filter({ queryTerms }) {
return GROUP_COLORS
.filter((c) => queryTerms.length === 0 || c.includes(queryTerms[0]?.toLowerCase() ?? ""))
.map((color, i) => {
const s = new Suggestion({
queryTerms,
description: "color",
url: "",
title: color,
deDuplicate: false,
groupData: { action: "setColor", color },
});
s.html = `<div class="top-half"><span class="source">${colorSwatch(color)}</span>` +
`<span class="title">${esc(color)}</span></div>`;
s.relevancy = GROUP_COLORS.length - i;
return s;
});
}
}
Loading