diff --git a/.gitignore b/.gitignore
index 53c37a166..92b6ae495 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
-dist
\ No newline at end of file
+dist
+.serena
\ No newline at end of file
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 000000000..6b34255ac
--- /dev/null
+++ b/TODO.md
@@ -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`.
diff --git a/background_scripts/all_commands.js b/background_scripts/all_commands.js
index 96f9e0a38..95e81f0f5 100644
--- a/background_scripts/all_commands.js
+++ b/background_scripts/all_commands.js
@@ -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",
@@ -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",
diff --git a/background_scripts/bg_utils.js b/background_scripts/bg_utils.js
index 37b58aa80..61459c58e 100644
--- a/background_scripts/bg_utils.js
+++ b/background_scripts/bg_utils.js
@@ -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;
+}
diff --git a/background_scripts/commands.js b/background_scripts/commands.js
index cdd8b42bc..48dc5f32a 100644
--- a/background_scripts/commands.js
+++ b/background_scripts/commands.js
@@ -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",
@@ -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",
diff --git a/background_scripts/completion/completers.js b/background_scripts/completion/completers.js
index 63e99146f..efa350f56 100644
--- a/background_scripts/completion/completers.js
+++ b/background_scripts/completion/completers.js
@@ -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);
@@ -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 [];
}
diff --git a/background_scripts/completion/group_completer.js b/background_scripts/completion/group_completer.js
new file mode 100644
index 000000000..4a5016fe0
--- /dev/null
+++ b/background_scripts/completion/group_completer.js
@@ -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, "&").replace(//g, ">");
+}
+
+function colorSwatch(color) {
+ const bg = COLOR_CSS[color] ?? "#888";
+ return ``;
+}
+
+function groupHtml(group, url) {
+ const source = `${colorSwatch(group.color)}${esc(group.color)}`;
+ const name = esc(group.title || `(${group.color})`);
+ return `
${source}${name}
` +
+ `${esc(url)}
`;
+}
+
+// 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 = `new group` +
+ `${esc(`Create "${name}"`)}
`;
+ 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 = `${colorSwatch(color)}` +
+ `${esc(color)}
`;
+ s.relevancy = GROUP_COLORS.length - i;
+ return s;
+ });
+ }
+}
diff --git a/background_scripts/main.js b/background_scripts/main.js
index c4d3870ce..551d80dc9 100644
--- a/background_scripts/main.js
+++ b/background_scripts/main.js
@@ -8,9 +8,10 @@ import { Commands } from "../background_scripts/commands.js";
import * as exclusions from "../background_scripts/exclusions.js";
import "../background_scripts/completion/search_engines.js";
import "../background_scripts/completion/search_wrapper.js";
-import "../background_scripts/completion/completers.js";
import "../background_scripts/tab_operations.js";
import * as marks from "../background_scripts/marks.js";
+import * as tabGroups from "./tab_groups.js";
+import * as tabSelection from "./tab_selection.js";
import {
BookmarkCompleter,
@@ -21,6 +22,11 @@ import {
SearchEngineCompleter,
TabCompleter,
} from "./completion/completers.js";
+import {
+ TabGroupCompleter,
+ TabGroupAssignCompleter,
+ TabGroupColorCompleter,
+} from "./completion/group_completer.js";
// NOTE(philc): This file has many superfluous return statements in its functions, as a result of
// converting from coffeescript to es6. Many can be removed, but I didn't take the time to
@@ -49,6 +55,9 @@ const completionSources = {
domains: new DomainCompleter(),
tabs: new TabCompleter(),
searchEngines: new SearchEngineCompleter(),
+ tabGroups: new TabGroupCompleter(),
+ tabGroupAssign: new TabGroupAssignCompleter(),
+ groupColors: new TabGroupColorCompleter(),
};
const completers = {
@@ -62,6 +71,9 @@ const completers = {
bookmarks: new MultiCompleter([completionSources.bookmarks]),
commands: new MultiCompleter([completionSources.commands]),
tabs: new MultiCompleter([completionSources.tabs]),
+ tabGroups: new MultiCompleter([completionSources.tabGroups]),
+ tabGroupAssign: new MultiCompleter([completionSources.tabGroupAssign]),
+ groupColors: new MultiCompleter([completionSources.groupColors]),
};
// A query dictionary for `chrome.tabs.query` that will return only the visible tabs.
@@ -175,6 +187,9 @@ function getTabIndex(tab, tabs) {
//
async function selectSpecificTab(request) {
const tab = await chrome.tabs.get(request.id);
+ if (tab.groupId !== -1 && chrome.tabGroups) {
+ await chrome.tabGroups.update(tab.groupId, { collapsed: false });
+ }
// Focus the tab's window. TODO(philc): Why are we null-checking chrome.windows here?
if (chrome.windows != null) {
await chrome.windows.update(tab.windowId, { focused: true });
@@ -182,21 +197,6 @@ async function selectSpecificTab(request) {
await chrome.tabs.update(request.id, { active: true });
}
-function moveTab({ count, tab, registryEntry }) {
- if (registryEntry.command === "moveTabLeft") {
- count = -count;
- }
- return chrome.tabs.query(visibleTabsQueryArgs, function (tabs) {
- const pinnedCount = (tabs.filter((tab) => tab.pinned)).length;
- const minIndex = tab.pinned ? 0 : pinnedCount;
- const maxIndex = (tab.pinned ? pinnedCount : tabs.length) - 1;
- // The tabs array index of the new position.
- const moveIndex = Math.max(minIndex, Math.min(maxIndex, getTabIndex(tab, tabs) + count));
- return chrome.tabs.move(tab.id, {
- index: tabs[moveIndex].index,
- });
- });
-}
function createRepeatCommand(command) {
return async function (request) {
@@ -343,8 +343,14 @@ const BackgroundCommands = {
});
},
toggleMuteTab,
- moveTabLeft: moveTab,
- moveTabRight: moveTab,
+ collapseTabGroup: tabGroups.collapseTabGroup,
+ collapseAllTabGroups: tabGroups.collapseAllTabGroups,
+ previousTabGroup: tabGroups.previousTabGroup,
+ nextTabGroup: tabGroups.nextTabGroup,
+ selectNextTabForGroup: tabSelection.selectNextTabForGroup,
+ selectPreviousTabForGroup: tabSelection.selectPreviousTabForGroup,
+ moveTabLeft: tabGroups.moveTab,
+ moveTabRight: tabGroups.moveTab,
async setZoom({ tabId, registryEntry }) {
const level = registryEntry.options?.["level"] ?? "1";
@@ -470,8 +476,16 @@ async function removeTabsRelative(direction, { count, tab }) {
// Selects a tab before or after the currently selected tab.
// - direction: "next", "previous", "first" or "last".
function selectTab(direction, { count, tab }) {
- chrome.tabs.query(visibleTabsQueryArgs, function (tabs) {
+ chrome.tabs.query(visibleTabsQueryArgs, async function (tabs) {
if (tabs.length > 1) {
+ // Skip tabs in collapsed tab groups.
+ if (chrome.tabGroups) {
+ const groups = await chrome.tabGroups.query({ windowId: tab.windowId, collapsed: true });
+ const collapsedGroupIds = new Set(groups.map((g) => g.id));
+ tabs = tabs.filter((t) => t.id === tab.id || !collapsedGroupIds.has(t.groupId));
+ if (tabs.length <= 1) return;
+ }
+
const toSelect = (() => {
switch (direction) {
case "next":
@@ -651,6 +665,23 @@ const sendRequestHandlers = {
nextFrame: BackgroundCommands.nextFrame,
selectSpecificTab,
+
+ async addTabsToGroup(request) {
+ const win = await chrome.windows.getLastFocused();
+ const tabs = await chrome.tabs.query({ windowId: win.id });
+ const tabIds = tabs.filter((t) => t.highlighted).map((t) => t.id);
+ if (tabIds.length === 0) return;
+ await chrome.tabs.group({ tabIds, groupId: request.groupId });
+ },
+
+ async createTabGroupWithColor(request) {
+ const win = await chrome.windows.getLastFocused();
+ const tabs = await chrome.tabs.query({ windowId: win.id });
+ const tabIds = tabs.filter((t) => t.highlighted).map((t) => t.id);
+ if (tabIds.length === 0) return;
+ const groupId = await chrome.tabs.group({ tabIds });
+ await chrome.tabGroups.update(groupId, { title: request.name, color: request.color });
+ },
createMark: marks.create,
gotoMark: marks.goto,
// Send a message to all frames in the current tab. If request.frameId is provided, then send
diff --git a/background_scripts/tab_groups.js b/background_scripts/tab_groups.js
new file mode 100644
index 000000000..58309a0e2
--- /dev/null
+++ b/background_scripts/tab_groups.js
@@ -0,0 +1,154 @@
+import * as bgUtils from "./bg_utils.js";
+import { moveTabSelection } from "./tab_selection.js";
+
+export async function collapseTabGroup({ tab }) {
+ if (!chrome.tabGroups || tab.groupId == -1) return;
+ const groupId = tab.groupId;
+
+ let nextTab = await bgUtils.getLastActiveTab({
+ windowId: tab.windowId,
+ excludeTabId: tab.id,
+ isValid: (t) => t.groupId !== groupId,
+ });
+ if (!nextTab) {
+ const tabs = await chrome.tabs.query({ currentWindow: true });
+ nextTab = tabs.find((t) => t.index > tab.index && t.groupId != groupId) ||
+ tabs.findLast((t) => t.index < tab.index && t.groupId != groupId);
+ }
+ if (!nextTab && !bgUtils.isFirefox()) {
+ nextTab = await chrome.tabs.create({});
+ }
+ if (nextTab) await chrome.tabs.update(nextTab.id, { active: true });
+ chrome.tabGroups.update(groupId, { collapsed: true });
+}
+
+// Collapse every expanded tab group in the window, then jump to the last active
+// ungrouped tab (or open a new one if none exists).
+export async function collapseAllTabGroups({ tab }) {
+ if (!chrome.tabGroups) return;
+
+ const groups = await chrome.tabGroups.query({ windowId: tab.windowId, collapsed: false });
+ await Promise.all(groups.map((g) => chrome.tabGroups.update(g.id, { collapsed: true })));
+
+ let nextTab = await bgUtils.getLastActiveTab({
+ windowId: tab.windowId,
+ excludeTabId: tab.id,
+ isValid: (t) => t.groupId === -1,
+ });
+ if (!nextTab) {
+ const tabs = await chrome.tabs.query({ currentWindow: true });
+ nextTab = tabs.find((t) => t.index > tab.index && t.groupId === -1) ||
+ tabs.findLast((t) => t.index < tab.index && t.groupId === -1);
+ }
+ if (!nextTab && !bgUtils.isFirefox()) {
+ nextTab = await chrome.tabs.create({});
+ }
+ if (nextTab) await chrome.tabs.update(nextTab.id, { active: true });
+}
+
+export function previousTabGroup({ tab }) {
+ return goToTabGroup(tab, -1);
+}
+
+export function nextTabGroup({ tab }) {
+ return goToTabGroup(tab, 1);
+}
+
+export async function moveTab({ count, tab, registryEntry }) {
+ const direction = registryEntry.command === "moveTabLeft" ? -1 : 1;
+
+ // Delegate to selection mover when multiple non-pinned tabs are highlighted.
+ const allTabs = await chrome.tabs.query({ windowId: tab.windowId });
+ const selectedCount = allTabs.filter((t) => t.highlighted && !t.pinned).length;
+ if (selectedCount > 1) return moveTabSelection({ count, tab, registryEntry });
+
+ // Pinned tabs and environments without tabGroups API use the original simple logic.
+ if (tab.pinned || !chrome.tabGroups) {
+ const tabs = await chrome.tabs.query({ currentWindow: true });
+ const pinnedCount = tabs.filter((t) => t.pinned).length;
+ const minIndex = tab.pinned ? 0 : pinnedCount;
+ const maxIndex = (tab.pinned ? pinnedCount : tabs.length) - 1;
+ const pos = tabs.findIndex((t) => t.id === tab.id);
+ const moveIndex = Math.max(minIndex, Math.min(maxIndex, pos + direction * count));
+ return chrome.tabs.move(tab.id, { index: tabs[moveIndex].index });
+ }
+ for (let i = 0; i < count; i++) {
+ await moveTabOneStep(tab, direction);
+ tab = await chrome.tabs.get(tab.id);
+ }
+}
+
+// Jump to the next (direction=1) or previous (direction=-1) tab group, wrapping circularly.
+async function goToTabGroup(tab, direction) {
+ if (!chrome.tabGroups) return;
+ const tabs = await chrome.tabs.query({ currentWindow: true });
+ const inDifferentGroup = (t) => t.groupId != -1 && t.groupId != tab.groupId;
+ let target = direction > 0
+ ? tabs.find((t) => t.index > tab.index && inDifferentGroup(t))
+ : tabs.findLast((t) => t.index < tab.index && inDifferentGroup(t));
+ if (!target) {
+ target = direction > 0
+ ? tabs.find((t) => inDifferentGroup(t))
+ : tabs.findLast((t) => inDifferentGroup(t));
+ }
+ if (target) {
+ await chrome.tabGroups.update(target.groupId, { collapsed: false });
+ chrome.tabs.update(target.id, { active: true });
+ }
+}
+
+// Move a non-pinned tab one step in the given direction, respecting tab group boundaries.
+async function moveTabOneStep(tab, direction) {
+ const tabs = await chrome.tabs.query({ currentWindow: true });
+ const nonPinned = tabs.filter((t) => !t.pinned).sort((a, b) => a.index - b.index);
+ const pos = nonPinned.findIndex((t) => t.id === tab.id);
+ if (pos === -1) return;
+
+ // Case 1: tab is inside a group — check if it's at the boundary.
+ if (tab.groupId !== -1) {
+ const groupTabs = nonPinned.filter((t) => t.groupId === tab.groupId);
+ const atEdge = direction > 0
+ ? tab.id === groupTabs.at(-1).id
+ : tab.id === groupTabs[0].id;
+ if (atEdge) {
+ // Exit the group without moving — ungroup keeps the tab at its current index.
+ await chrome.tabs.ungroup([tab.id]);
+ } else {
+ const neighbor = nonPinned[pos + direction];
+ if (neighbor) await chrome.tabs.move(tab.id, { index: neighbor.index });
+ }
+ return;
+ }
+
+ // Case 2: tab is not in any group.
+ const neighbor = nonPinned[pos + direction];
+ if (!neighbor) return; // at window edge
+
+ if (neighbor.groupId === -1) {
+ await chrome.tabs.move(tab.id, { index: neighbor.index });
+ return;
+ }
+
+ // Neighbor belongs to a group.
+ const group = await chrome.tabGroups.get(neighbor.groupId);
+ const groupTabs = nonPinned.filter((t) => t.groupId === neighbor.groupId);
+
+ if (group.collapsed) {
+ // Skip over the entire collapsed group: land on the far side of it.
+ const edgeTab = direction > 0 ? groupTabs.at(-1) : groupTabs[0];
+ const edgePos = nonPinned.findIndex((t) => t.id === edgeTab.id);
+ const afterGroup = nonPinned[edgePos + direction];
+ if (!afterGroup) {
+ // Group is flush against the window edge — land just past it.
+ await chrome.tabs.move(tab.id, { index: edgeTab.index });
+ return;
+ }
+ // afterGroup.index - direction places the tab immediately next to afterGroup
+ // on the group's side, accounting for the index shift caused by the move.
+ await chrome.tabs.move(tab.id, { index: afterGroup.index - direction });
+ } else {
+ // Enter the open group. Chrome keeps the tab at its current adjacent position
+ // and adds it to the group, so no explicit move is needed.
+ await chrome.tabs.group({ tabIds: [tab.id], groupId: neighbor.groupId });
+ }
+}
diff --git a/background_scripts/tab_selection.js b/background_scripts/tab_selection.js
new file mode 100644
index 000000000..91ac7ae94
--- /dev/null
+++ b/background_scripts/tab_selection.js
@@ -0,0 +1,158 @@
+// Extend or shrink the tab selection (zz). Vim visual-mode semantics: tab.index is the anchor.
+// - Right side extended? → extend further right.
+// - Left side extended? → shrink from the left.
+// - Nothing extended yet? → start extending right.
+export async function selectNextTabForGroup({ tab, count }) {
+ for (let i = 0; i < count; i++) {
+ await selectNextTabOnce(tab);
+ }
+}
+
+async function selectNextTabOnce(tab) {
+ const tabs = await chrome.tabs.query({ windowId: tab.windowId });
+ const highlighted = tabs.filter((t) => t.highlighted).map((t) => t.index);
+ const anchor = tab.index;
+
+ let next;
+ if (highlighted.some((i) => i > anchor)) {
+ const max = Math.max(...highlighted);
+ if (max + 1 >= tabs.length) return;
+ next = [...new Set([...highlighted, max + 1])];
+ } else if (highlighted.some((i) => i < anchor)) {
+ const min = Math.min(...highlighted);
+ next = highlighted.filter((i) => i !== min);
+ } else {
+ if (anchor + 1 >= tabs.length) return;
+ next = [anchor, anchor + 1];
+ }
+
+ await chrome.tabs.highlight({
+ windowId: tab.windowId,
+ tabs: [anchor, ...next.filter((i) => i !== anchor)],
+ });
+}
+
+// Extend or shrink the tab selection (ZZ). Vim visual-mode semantics: tab.index is the anchor.
+// - Left side extended? → extend further left.
+// - Right side extended? → shrink from the right.
+// - Nothing extended yet? → start extending left.
+export async function selectPreviousTabForGroup({ tab, count }) {
+ for (let i = 0; i < count; i++) {
+ await selectPreviousTabOnce(tab);
+ }
+}
+
+async function selectPreviousTabOnce(tab) {
+ const tabs = await chrome.tabs.query({ windowId: tab.windowId });
+ const highlighted = tabs.filter((t) => t.highlighted).map((t) => t.index);
+ const anchor = tab.index;
+
+ let next;
+ if (highlighted.some((i) => i < anchor)) {
+ const min = Math.min(...highlighted);
+ if (min - 1 < 0) return;
+ next = [...new Set([...highlighted, min - 1])];
+ } else if (highlighted.some((i) => i > anchor)) {
+ const max = Math.max(...highlighted);
+ next = highlighted.filter((i) => i !== max);
+ } else {
+ if (anchor - 1 < 0) return;
+ next = [anchor, anchor - 1];
+ }
+
+ await chrome.tabs.highlight({
+ windowId: tab.windowId,
+ tabs: [anchor, ...next.filter((i) => i !== anchor)],
+ });
+}
+
+export async function moveTabSelection({ count, tab, registryEntry }) {
+ const direction = registryEntry.command === "moveTabLeft" ? -1 : 1;
+ let selected = await getSelectedNonPinned(tab.windowId);
+ for (let i = 0; i < count; i++) {
+ await moveSelectionOneStep(selected, direction);
+ selected = await getSelectedNonPinned(tab.windowId);
+ }
+}
+
+async function getSelectedNonPinned(windowId) {
+ const tabs = await chrome.tabs.query({ windowId });
+ return tabs.filter((t) => t.highlighted && !t.pinned).sort((a, b) => a.index - b.index);
+}
+
+async function moveSelectionOneStep(selected, direction) {
+ const allTabs = await chrome.tabs.query({ windowId: selected[0].windowId });
+ const nonPinned = allTabs.filter((t) => !t.pinned).sort((a, b) => a.index - b.index);
+
+ const selGroupId = selected[0].groupId;
+ const selectionInGroup = selGroupId !== -1 && selected.every((t) => t.groupId === selGroupId);
+
+ if (direction > 0) {
+ const rightEdge = selected[selected.length - 1];
+ const rightPos = nonPinned.findIndex((t) => t.id === rightEdge.id);
+ const neighbor = nonPinned[rightPos + 1];
+
+ if (selectionInGroup) {
+ if (neighbor && neighbor.groupId === selGroupId) {
+ await chrome.tabs.move(neighbor.id, { index: selected[0].index });
+ } else if (neighbor) {
+ // Rightmost-first: each tab is the last remaining group member when ungrouped,
+ // so Chrome places it just after itself — no reposition needed.
+ for (let i = selected.length - 1; i >= 0; i--) await chrome.tabs.ungroup([selected[i].id]);
+ }
+ return;
+ }
+
+ if (!neighbor) return;
+
+ if (neighbor.groupId === -1) {
+ await chrome.tabs.move(neighbor.id, { index: selected[0].index });
+ } else if (chrome.tabGroups) {
+ const group = await chrome.tabGroups.get(neighbor.groupId);
+ const groupTabs = nonPinned.filter((t) => t.groupId === neighbor.groupId);
+ if (group.collapsed) {
+ // Individual moves rightmost-first: a batch move would drop the first tab between
+ // group members, causing Chrome to absorb it into the group and uncollapse it.
+ const groupLastIndex = groupTabs[groupTabs.length - 1].index;
+ for (let i = 0; i < selected.length; i++) {
+ await chrome.tabs.move(selected[selected.length - 1 - i].id, { index: groupLastIndex - i });
+ }
+ } else {
+ await chrome.tabs.group({ tabIds: selected.map((t) => t.id), groupId: neighbor.groupId });
+ }
+ }
+ } else {
+ const leftEdge = selected[0];
+ const leftPos = nonPinned.findIndex((t) => t.id === leftEdge.id);
+ const neighbor = nonPinned[leftPos - 1];
+
+ if (selectionInGroup) {
+ if (neighbor && neighbor.groupId === selGroupId) {
+ await chrome.tabs.move(neighbor.id, { index: selected[selected.length - 1].index });
+ } else if (neighbor) {
+ // Leftmost-first: each tab is the first remaining group member when ungrouped,
+ // so Chrome places it just before itself — no reposition needed.
+ for (const t of selected) await chrome.tabs.ungroup([t.id]);
+ }
+ return;
+ }
+
+ if (!neighbor) return;
+
+ if (neighbor.groupId === -1) {
+ await chrome.tabs.move(neighbor.id, { index: selected[selected.length - 1].index });
+ } else if (chrome.tabGroups) {
+ const group = await chrome.tabGroups.get(neighbor.groupId);
+ const groupTabs = nonPinned.filter((t) => t.groupId === neighbor.groupId);
+ if (group.collapsed) {
+ // Individual moves leftmost-first: same reason as the right case above.
+ const groupFirstIndex = groupTabs[0].index;
+ for (let i = 0; i < selected.length; i++) {
+ await chrome.tabs.move(selected[i].id, { index: groupFirstIndex + i });
+ }
+ } else {
+ await chrome.tabs.group({ tabIds: selected.map((t) => t.id), groupId: neighbor.groupId });
+ }
+ }
+ }
+}
diff --git a/content_scripts/mode_normal.js b/content_scripts/mode_normal.js
index cab46599c..053ae3f92 100644
--- a/content_scripts/mode_normal.js
+++ b/content_scripts/mode_normal.js
@@ -373,6 +373,8 @@ const NormalModeCommands = {
"Vomnibar.activateCommandSelection": Vomnibar.activateCommandSelection.bind(Vomnibar),
"Vomnibar.activateEditUrl": Vomnibar.activateEditUrl.bind(Vomnibar),
"Vomnibar.activateEditUrlInNewTab": Vomnibar.activateEditUrlInNewTab.bind(Vomnibar),
+ "Vomnibar.activateTabGroupSelection": Vomnibar.activateTabGroupSelection.bind(Vomnibar),
+ "Vomnibar.activateGroupAssign": Vomnibar.activateGroupAssign.bind(Vomnibar),
"Marks.activateCreateMode": Marks.activateCreateMode.bind(Marks),
"Marks.activateGotoMode": Marks.activateGotoMode.bind(Marks),
diff --git a/content_scripts/vomnibar.js b/content_scripts/vomnibar.js
index 162936725..e9b599177 100644
--- a/content_scripts/vomnibar.js
+++ b/content_scripts/vomnibar.js
@@ -40,6 +40,20 @@ const Vomnibar = {
this.open(sourceFrameId, options);
},
+ activateTabGroupSelection(sourceFrameId) {
+ this.open(sourceFrameId, {
+ completer: "tabGroups",
+ selectFirst: true,
+ });
+ },
+
+ activateGroupAssign(sourceFrameId) {
+ this.open(sourceFrameId, {
+ completer: "tabGroupAssign",
+ selectFirst: true,
+ });
+ },
+
activateBookmarksInNewTab(sourceFrameId, registryEntry) {
const options = Object.assign({}, registryEntry.options, {
completer: "bookmarks",
diff --git a/manifest.json b/manifest.json
index 783852cbb..e21f45fa8 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,6 +1,6 @@
{
"manifest_version": 3,
- "name": "Vimium",
+ "name": "Vimium Dev",
"version": "2.4.2",
"description": "The Hacker's Browser. Vimium provides keyboard shortcuts for navigation and control in the spirit of Vim.",
"icons": {
@@ -20,11 +20,10 @@
"browser_style": false,
"open_in_tab": true
},
- "host_permissions": [
- ""
- ],
+ "host_permissions": [""],
"permissions": [
"tabs",
+ "tabGroups",
"bookmarks",
"history",
"storage",
@@ -41,9 +40,7 @@
],
"content_scripts": [
{
- "matches": [
- ""
- ],
+ "matches": [""],
"js": [
"lib/types.js",
"lib/utils.js",
@@ -67,21 +64,14 @@
"content_scripts/mode_normal.js",
"content_scripts/vimium_frontend.js"
],
- "css": [
- "content_scripts/vimium.css"
- ],
+ "css": ["content_scripts/vimium.css"],
"run_at": "document_start",
"all_frames": true,
"match_about_blank": true
},
{
- "matches": [
- "file:///",
- "file:///*/"
- ],
- "css": [
- "content_scripts/file_urls.css"
- ],
+ "matches": ["file:///", "file:///*/"],
+ "css": ["content_scripts/file_urls.css"],
"run_at": "document_start",
"all_frames": true
}
@@ -118,9 +108,7 @@
// "pages/reload.html",
"_favicon/*"
],
- "matches": [
- ""
- ]
+ "matches": [""]
}
]
}
diff --git a/pages/vomnibar_page.css b/pages/vomnibar_page.css
index 1c5efdf16..c141533b6 100644
--- a/pages/vomnibar_page.css
+++ b/pages/vomnibar_page.css
@@ -83,6 +83,15 @@ ul {
color: #777;
margin-right: 4px;
}
+
+#vomnibar li .group-color-swatch {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+ vertical-align: middle;
+ margin-right: 5px;
+}
#vomnibar li .relevancy {
position: absolute;
right: 0;
diff --git a/pages/vomnibar_page.js b/pages/vomnibar_page.js
index f838fa130..d8ba7dcbe 100644
--- a/pages/vomnibar_page.js
+++ b/pages/vomnibar_page.js
@@ -59,6 +59,7 @@ class VomnibarUI {
this.onInput = this.onInput.bind(this);
this.update = this.update.bind(this);
this.onHiddenCallback = null;
+ this.pendingGroupName = null;
this.initDom();
// The user's custom search engine, if they have prefixed their query with the keyword for one
// of their search engines.
@@ -299,6 +300,23 @@ class VomnibarUI {
return;
}
+ // Smart Enter for tabGroupAssign: exact match assigns, anything else creates a new group.
+ if (this.completerName === "tabGroupAssign") {
+ const exactMatch = this.completions.find(
+ (c) => c.groupData?.action === "addToGroup" &&
+ c.title.split(" · ")[0].toLowerCase() === query.toLowerCase(),
+ );
+ if (exactMatch) {
+ this.hide(() => this.openCompletion(exactMatch, false));
+ } else {
+ this.pendingGroupName = query;
+ this.initialSelectionValue = 0;
+ this.setCompleterName("groupColors");
+ await this.update();
+ }
+ return;
+ }
+
// with no selection on a completer other than "omni" is a no-op.
if (this.completerName != "omni") return;
@@ -335,6 +353,12 @@ class VomnibarUI {
count: this.prefixCount,
});
});
+ } else if (completion.groupData?.action === "createGroup") {
+ // Stay open — switch to color picker in-place for step 2.
+ this.pendingGroupName = completion.groupData.name;
+ this.initialSelectionValue = 0;
+ this.setCompleterName("groupColors");
+ await this.update();
} else {
this.hide(() => this.openCompletion(completion, openInNewTab));
}
@@ -437,7 +461,15 @@ class VomnibarUI {
}
openCompletion(completion, openInNewTab) {
- if (completion.description == "tab") {
+ if (completion.groupData?.action === "addToGroup") {
+ chrome.runtime.sendMessage({ handler: "addTabsToGroup", groupId: completion.groupData.groupId });
+ } else if (completion.groupData?.action === "setColor") {
+ chrome.runtime.sendMessage({
+ handler: "createTabGroupWithColor",
+ name: this.pendingGroupName,
+ color: completion.groupData.color,
+ });
+ } else if (completion.tabId != null) {
chrome.runtime.sendMessage({ handler: "selectSpecificTab", id: completion.tabId });
} else {
this.launchUrl(completion.url, openInNewTab);
diff --git a/tests/unit_tests/bg_utils_test.js b/tests/unit_tests/bg_utils_test.js
index 341f946fe..85ee9bd7e 100644
--- a/tests/unit_tests/bg_utils_test.js
+++ b/tests/unit_tests/bg_utils_test.js
@@ -1,4 +1,53 @@
import "./test_helper.js";
import "../../lib/url_utils.js";
import "../../background_scripts/tab_recency.js";
-import "../../background_scripts/bg_utils.js";
+import * as bgUtils from "../../background_scripts/bg_utils.js";
+
+context("getLastActiveTab", () => {
+ setup(() => {
+ stub(bgUtils.tabRecency, "init", () => Promise.resolve());
+ });
+
+ should("return the most recent valid tab, excluding excludeTabId", async () => {
+ stub(bgUtils.tabRecency, "getTabsByRecency", () => [1, 2, 3]);
+ stub(chrome.tabs, "query", () =>
+ Promise.resolve([
+ { id: 1, groupId: -1 },
+ { id: 2, groupId: -1 },
+ { id: 3, groupId: -1 },
+ ]));
+ const tab = await bgUtils.getLastActiveTab({ windowId: 1, excludeTabId: 1 });
+ assert.equal(2, tab.id);
+ });
+
+ should("skip candidates that fail isValid", async () => {
+ stub(bgUtils.tabRecency, "getTabsByRecency", () => [1, 2, 3]);
+ stub(chrome.tabs, "query", () =>
+ Promise.resolve([
+ { id: 1, groupId: 5 },
+ { id: 2, groupId: 5 },
+ { id: 3, groupId: -1 },
+ ]));
+ const tab = await bgUtils.getLastActiveTab({
+ windowId: 1,
+ excludeTabId: 1,
+ isValid: (t) => t.groupId === -1,
+ });
+ assert.equal(3, tab.id);
+ });
+
+ should("return null when no candidate matches", async () => {
+ stub(bgUtils.tabRecency, "getTabsByRecency", () => [1, 2]);
+ stub(chrome.tabs, "query", () =>
+ Promise.resolve([
+ { id: 1, groupId: 5 },
+ { id: 2, groupId: 5 },
+ ]));
+ const tab = await bgUtils.getLastActiveTab({
+ windowId: 1,
+ excludeTabId: 1,
+ isValid: (t) => t.groupId === -1,
+ });
+ assert.equal(null, tab);
+ });
+});
diff --git a/tests/unit_tests/group_completer_test.js b/tests/unit_tests/group_completer_test.js
new file mode 100644
index 000000000..f18c1ba70
--- /dev/null
+++ b/tests/unit_tests/group_completer_test.js
@@ -0,0 +1,151 @@
+import "./test_helper.js";
+import "../../background_scripts/tab_recency.js";
+import "../../background_scripts/bg_utils.js";
+import {
+ TabGroupCompleter,
+ TabGroupAssignCompleter,
+ TabGroupColorCompleter,
+} from "../../background_scripts/completion/group_completer.js";
+
+async function filterCompleter(completer, queryTerms) {
+ return await completer.filter({ queryTerms, query: queryTerms.join(" ") });
+}
+
+const testGroups = [
+ { id: 10, title: "Work", color: "blue" },
+ { id: 20, title: "", color: "orange" },
+];
+const testTabs = [
+ { id: 1, index: 0, groupId: 10, url: "http://work.com" },
+ { id: 2, index: 1, groupId: 20, url: "http://test.com" },
+];
+
+context("TabGroupCompleter", () => {
+ setup(() => {
+ stub(chrome.windows, "getLastFocused", () => ({ id: 1 }));
+ stub(chrome, "tabGroups", { query: () => testGroups });
+ stub(chrome.tabs, "query", () => testTabs);
+ });
+
+ should("return all groups when query is empty", async () => {
+ const results = await filterCompleter(new TabGroupCompleter(), []);
+ assert.equal(2, results.length);
+ });
+
+ should("filter groups by title", async () => {
+ const results = await filterCompleter(new TabGroupCompleter(), ["work"]);
+ assert.equal(1, results.length);
+ assert.equal("Work", results[0].title);
+ });
+
+ should("filter groups by color", async () => {
+ const results = await filterCompleter(new TabGroupCompleter(), ["blue"]);
+ assert.equal(1, results.length);
+ assert.equal("blue", results[0].description);
+ });
+
+ should("return empty when no groups match", async () => {
+ const results = await filterCompleter(new TabGroupCompleter(), ["nonexistent"]);
+ assert.equal(0, results.length);
+ });
+
+ should("set tabId to the first tab in the group", async () => {
+ const results = await filterCompleter(new TabGroupCompleter(), []);
+ assert.equal(1, results[0].tabId);
+ assert.equal(2, results[1].tabId);
+ });
+
+ should("include a color swatch in the html", async () => {
+ const results = await filterCompleter(new TabGroupCompleter(), []);
+ assert.isTrue(results[0].html.includes("group-color-swatch"));
+ assert.isTrue(results[0].html.includes("background:#1A73E8")); // blue
+ assert.isTrue(results[1].html.includes("background:#E37400")); // orange
+ });
+
+ should("show group name in title and color name in source", async () => {
+ const results = await filterCompleter(new TabGroupCompleter(), []);
+ assert.isTrue(results[0].html.includes(">Work<")); // named group title
+ assert.isTrue(results[1].html.includes(">(orange)<")); // unnamed group falls back to (color)
+ });
+});
+
+context("TabGroupAssignCompleter", () => {
+ setup(() => {
+ stub(chrome.windows, "getLastFocused", () => ({ id: 1 }));
+ stub(chrome, "tabGroups", { query: () => testGroups });
+ stub(chrome.tabs, "query", () => testTabs);
+ });
+
+ should("return existing groups when query is empty", async () => {
+ const results = await filterCompleter(new TabGroupAssignCompleter(), []);
+ assert.equal(2, results.length);
+ assert.isTrue(results.every((r) => r.groupData?.action === "addToGroup"));
+ });
+
+ should("include a Create entry when query does not empty", async () => {
+ const results = await filterCompleter(new TabGroupAssignCompleter(), ["newgroup"]);
+ const create = results.find((r) => r.groupData?.action === "createGroup");
+ assert.isTrue(create != null);
+ assert.equal("newgroup", create.groupData.name);
+ });
+
+ should("place the Create entry after existing matches", async () => {
+ const results = await filterCompleter(new TabGroupAssignCompleter(), ["work"]);
+ assert.equal("createGroup", results[results.length - 1].groupData.action);
+ });
+
+ should("set groupId correctly on addToGroup entries", async () => {
+ const results = await filterCompleter(new TabGroupAssignCompleter(), []);
+ assert.equal(10, results[0].groupData.groupId);
+ assert.equal(20, results[1].groupData.groupId);
+ });
+
+ should("include color swatches for existing group entries", async () => {
+ const results = await filterCompleter(new TabGroupAssignCompleter(), []);
+ assert.isTrue(results[0].html.includes("group-color-swatch"));
+ });
+
+ should("filter existing groups by title when query matches", async () => {
+ const results = await filterCompleter(new TabGroupAssignCompleter(), ["work"]);
+ const existing = results.filter((r) => r.groupData?.action === "addToGroup");
+ assert.equal(1, existing.length);
+ assert.equal("Work", existing[0].title);
+ });
+});
+
+context("TabGroupColorCompleter", () => {
+ should("return all 9 colors when query is empty", async () => {
+ const results = await filterCompleter(new TabGroupColorCompleter(), []);
+ assert.equal(9, results.length);
+ });
+
+ should("filter colors by name prefix", async () => {
+ const results = await filterCompleter(new TabGroupColorCompleter(), ["bl"]);
+ assert.equal(1, results.length);
+ assert.equal("blue", results[0].title);
+ });
+
+ should("return empty when no color matches", async () => {
+ const results = await filterCompleter(new TabGroupColorCompleter(), ["xyz"]);
+ assert.equal(0, results.length);
+ });
+
+ should("set a setColor action on every suggestion", async () => {
+ const results = await filterCompleter(new TabGroupColorCompleter(), []);
+ assert.isTrue(results.every((r) => r.groupData?.action === "setColor"));
+ assert.isTrue(results.every((r) => typeof r.groupData.color === "string"));
+ });
+
+ should("preserve the GROUP_COLORS order", async () => {
+ const results = await filterCompleter(new TabGroupColorCompleter(), []);
+ assert.equal("grey", results[0].groupData.color);
+ assert.equal("orange", results[results.length - 1].groupData.color);
+ });
+
+ should("include a color swatch in the html", async () => {
+ const results = await filterCompleter(new TabGroupColorCompleter(), []);
+ const grey = results.find((r) => r.groupData.color === "grey");
+ assert.isTrue(grey.html.includes("group-color-swatch"));
+ assert.isTrue(grey.html.includes("background:#5F6368"));
+ });
+});
diff --git a/tests/unit_tests/tab_groups_test.js b/tests/unit_tests/tab_groups_test.js
new file mode 100644
index 000000000..e4d8f83ea
--- /dev/null
+++ b/tests/unit_tests/tab_groups_test.js
@@ -0,0 +1,159 @@
+import "./test_helper.js";
+import * as bgUtils from "../../background_scripts/bg_utils.js";
+import {
+ collapseAllTabGroups,
+ collapseTabGroup,
+ moveTab,
+} from "../../background_scripts/tab_groups.js";
+
+context("moveTab (>> / <<) past a collapsed group at the window edge", () => {
+ let movedTo;
+
+ setup(() => {
+ movedTo = null;
+ stub(chrome.tabs, "move", (_id, args) => {
+ movedTo = args.index;
+ });
+ stub(chrome, "tabGroups", { get: () => ({ collapsed: true }) });
+ });
+
+ should("jump past a collapsed group flush against the right edge", async () => {
+ const tabs = [
+ { id: 1, index: 0, groupId: -1, pinned: false },
+ { id: 2, index: 1, groupId: 99, pinned: false },
+ { id: 3, index: 2, groupId: 99, pinned: false },
+ ];
+ stub(chrome.tabs, "query", () => tabs);
+ await moveTab({ count: 1, tab: tabs[0], registryEntry: { command: "moveTabRight" } });
+ assert.equal(2, movedTo); // lands at the last group tab's index, past the group
+ });
+
+ should("jump past a collapsed group flush against the left edge", async () => {
+ const tabs = [
+ { id: 1, index: 0, groupId: 99, pinned: false },
+ { id: 2, index: 1, groupId: 99, pinned: false },
+ { id: 3, index: 2, groupId: -1, pinned: false },
+ ];
+ stub(chrome.tabs, "query", () => tabs);
+ await moveTab({ count: 1, tab: tabs[2], registryEntry: { command: "moveTabLeft" } });
+ assert.equal(0, movedTo); // lands at the first group tab's index, before the group
+ });
+});
+
+context("collapseTabGroup (za)", () => {
+ let activatedId, collapsedGroupId;
+
+ setup(() => {
+ activatedId = null;
+ collapsedGroupId = null;
+ stub(bgUtils.tabRecency, "init", () => Promise.resolve());
+ stub(chrome.tabs, "update", (id, _args) => {
+ activatedId = id;
+ });
+ stub(chrome, "tabGroups", {
+ update: (id, _args) => {
+ collapsedGroupId = id;
+ },
+ });
+ });
+
+ should("prefer the last active tab outside the group over index proximity", async () => {
+ const tab = { id: 2, index: 1, windowId: 1, groupId: 99 };
+ const tabs = [
+ { id: 1, index: 0, groupId: -1 },
+ { id: 2, index: 1, groupId: 99 },
+ { id: 3, index: 2, groupId: -1 },
+ ];
+ stub(chrome.tabs, "query", () => tabs);
+ stub(bgUtils.tabRecency, "getTabsByRecency", () => [2, 1, 3]);
+ await collapseTabGroup({ tab });
+ assert.equal(1, activatedId); // recency prefers tab 1 over the nearer-by-index tab 3
+ assert.equal(99, collapsedGroupId);
+ });
+
+ should("fall back to index proximity when recency has no valid candidate", async () => {
+ const tab = { id: 2, index: 1, windowId: 1, groupId: 99 };
+ const tabs = [
+ { id: 1, index: 0, groupId: 99 },
+ { id: 2, index: 1, groupId: 99 },
+ { id: 3, index: 2, groupId: -1 },
+ ];
+ stub(chrome.tabs, "query", () => tabs);
+ stub(bgUtils.tabRecency, "getTabsByRecency", () => []);
+ await collapseTabGroup({ tab });
+ assert.equal(3, activatedId);
+ });
+
+ should("create a new tab when neither recency nor index proximity find a candidate", async () => {
+ const tab = { id: 1, index: 0, windowId: 1, groupId: 99 };
+ const tabs = [{ id: 1, index: 0, groupId: 99 }];
+ stub(chrome.tabs, "query", () => tabs);
+ stub(bgUtils.tabRecency, "getTabsByRecency", () => []);
+ stub(chrome.tabs, "create", () => Promise.resolve({ id: 42 }));
+ await collapseTabGroup({ tab });
+ assert.equal(42, activatedId);
+ });
+});
+
+context("collapseAllTabGroups (zA)", () => {
+ let activatedId, collapsedGroupIds;
+
+ setup(() => {
+ activatedId = null;
+ collapsedGroupIds = [];
+ stub(bgUtils.tabRecency, "init", () => Promise.resolve());
+ stub(chrome.tabs, "update", (id, _args) => {
+ activatedId = id;
+ });
+ });
+
+ should("collapse every expanded group and land on the last active ungrouped tab", async () => {
+ const tab = { id: 3, index: 2, windowId: 1, groupId: -1 };
+ stub(chrome, "tabGroups", {
+ query: () => [{ id: 10 }, { id: 20 }],
+ update: (id, _args) => collapsedGroupIds.push(id),
+ });
+ const tabs = [
+ { id: 1, index: 0, groupId: -1 },
+ { id: 2, index: 1, groupId: 10 },
+ { id: 3, index: 2, groupId: -1 },
+ { id: 4, index: 3, groupId: -1 },
+ ];
+ stub(chrome.tabs, "query", () => tabs);
+ stub(bgUtils.tabRecency, "getTabsByRecency", () => [3, 1, 4, 2]);
+ await collapseAllTabGroups({ tab });
+ assert.equal([10, 20], collapsedGroupIds);
+ assert.equal(1, activatedId); // recency prefers tab 1 over the nearer-by-index tab 4
+ });
+
+ should("skip already-collapsed groups, and fall back to creating a new tab", async () => {
+ const tab = { id: 1, index: 0, windowId: 1, groupId: -1 };
+ stub(chrome, "tabGroups", {
+ query: (args) => {
+ assert.equal(false, args.collapsed);
+ return [];
+ },
+ update: (id, _args) => collapsedGroupIds.push(id),
+ });
+ stub(chrome.tabs, "query", () => [{ id: 1, index: 0, groupId: -1 }]);
+ stub(bgUtils.tabRecency, "getTabsByRecency", () => []);
+ stub(chrome.tabs, "create", () => Promise.resolve({ id: 99 }));
+ await collapseAllTabGroups({ tab });
+ assert.equal([], collapsedGroupIds);
+ assert.equal(99, activatedId);
+ });
+
+ should("fall back to index proximity when recency has no valid candidate", async () => {
+ const tab = { id: 2, index: 1, windowId: 1, groupId: -1 };
+ stub(chrome, "tabGroups", { query: () => [], update: () => {} });
+ const tabs = [
+ { id: 1, index: 0, groupId: 10 },
+ { id: 2, index: 1, groupId: -1 },
+ { id: 3, index: 2, groupId: -1 },
+ ];
+ stub(chrome.tabs, "query", () => tabs);
+ stub(bgUtils.tabRecency, "getTabsByRecency", () => []);
+ await collapseAllTabGroups({ tab });
+ assert.equal(3, activatedId);
+ });
+});
diff --git a/tests/unit_tests/tab_selection_test.js b/tests/unit_tests/tab_selection_test.js
new file mode 100644
index 000000000..9c42bb3c8
--- /dev/null
+++ b/tests/unit_tests/tab_selection_test.js
@@ -0,0 +1,318 @@
+import "./test_helper.js";
+import "../../background_scripts/bg_utils.js";
+import {
+ moveTabSelection,
+ selectNextTabForGroup,
+ selectPreviousTabForGroup,
+} from "../../background_scripts/tab_selection.js";
+
+function makeTabs(count, highlighted = []) {
+ return Array.from({ length: count }, (_, i) => ({
+ id: i + 1,
+ index: i,
+ windowId: 1,
+ highlighted: highlighted.includes(i),
+ pinned: false,
+ groupId: -1,
+ }));
+}
+
+context("selectNextTabForGroup (zz)", () => {
+ let capturedHighlight;
+
+ setup(() => {
+ capturedHighlight = null;
+ stub(chrome.tabs, "highlight", (args) => {
+ capturedHighlight = args.tabs;
+ });
+ });
+
+ should("extend right when only the anchor is selected", async () => {
+ const tabs = makeTabs(5, [2]);
+ stub(chrome.tabs, "query", () => tabs);
+ await selectNextTabForGroup({ tab: tabs[2], count: 1 });
+ assert.equal([2, 3], capturedHighlight);
+ });
+
+ should("extend further right when right side is already extended", async () => {
+ const tabs = makeTabs(5, [2, 3]);
+ stub(chrome.tabs, "query", () => tabs);
+ await selectNextTabForGroup({ tab: tabs[2], count: 1 });
+ assert.equal([2, 3, 4], capturedHighlight);
+ });
+
+ should("shrink from left when left side is extended", async () => {
+ const tabs = makeTabs(5, [1, 2]);
+ stub(chrome.tabs, "query", () => tabs);
+ await selectNextTabForGroup({ tab: tabs[2], count: 1 });
+ assert.equal([2], capturedHighlight);
+ });
+
+ should("do nothing when anchor is at the last tab", async () => {
+ const tabs = makeTabs(3, [2]);
+ stub(chrome.tabs, "query", () => tabs);
+ await selectNextTabForGroup({ tab: tabs[2], count: 1 });
+ assert.equal(null, capturedHighlight);
+ });
+
+ should("do nothing when right extension already reaches the last tab", async () => {
+ const tabs = makeTabs(3, [1, 2]);
+ stub(chrome.tabs, "query", () => tabs);
+ await selectNextTabForGroup({ tab: tabs[1], count: 1 });
+ assert.equal(null, capturedHighlight);
+ });
+
+ should("extend right 3 tabs with count: 3", async () => {
+ const tabs = makeTabs(6, [2]);
+ let callCount = 0;
+ stub(chrome.tabs, "query", () => {
+ const highlighted = [2, ...Array.from({ length: callCount }, (_, i) => 3 + i)];
+ callCount++;
+ return makeTabs(6, highlighted);
+ });
+ await selectNextTabForGroup({ tab: tabs[2], count: 3 });
+ assert.equal([2, 3, 4, 5], capturedHighlight);
+ });
+});
+
+context("selectPreviousTabForGroup (ZZ)", () => {
+ let capturedHighlight;
+
+ setup(() => {
+ capturedHighlight = null;
+ stub(chrome.tabs, "highlight", (args) => {
+ capturedHighlight = args.tabs;
+ });
+ });
+
+ should("extend left when only the anchor is selected", async () => {
+ const tabs = makeTabs(5, [2]);
+ stub(chrome.tabs, "query", () => tabs);
+ await selectPreviousTabForGroup({ tab: tabs[2], count: 1 });
+ assert.equal([2, 1], capturedHighlight);
+ });
+
+ should("extend further left when left side is already extended", async () => {
+ const tabs = makeTabs(5, [1, 2]);
+ stub(chrome.tabs, "query", () => tabs);
+ await selectPreviousTabForGroup({ tab: tabs[2], count: 1 });
+ assert.equal([2, 1, 0], capturedHighlight);
+ });
+
+ should("shrink from right when right side is extended", async () => {
+ const tabs = makeTabs(5, [2, 3]);
+ stub(chrome.tabs, "query", () => tabs);
+ await selectPreviousTabForGroup({ tab: tabs[2], count: 1 });
+ assert.equal([2], capturedHighlight);
+ });
+
+ should("do nothing when anchor is at the first tab", async () => {
+ const tabs = makeTabs(3, [0]);
+ stub(chrome.tabs, "query", () => tabs);
+ await selectPreviousTabForGroup({ tab: tabs[0], count: 1 });
+ assert.equal(null, capturedHighlight);
+ });
+
+ should("do nothing when left extension already reaches the first tab", async () => {
+ const tabs = makeTabs(3, [0, 1]);
+ stub(chrome.tabs, "query", () => tabs);
+ await selectPreviousTabForGroup({ tab: tabs[1], count: 1 });
+ assert.equal(null, capturedHighlight);
+ });
+
+ should("extend left 3 tabs with count: 3", async () => {
+ const tabs = makeTabs(6, [3]);
+ let callCount = 0;
+ stub(chrome.tabs, "query", () => {
+ const highlighted = [3, ...Array.from({ length: callCount }, (_, i) => 2 - i)];
+ callCount++;
+ return makeTabs(6, highlighted);
+ });
+ await selectPreviousTabForGroup({ tab: tabs[3], count: 3 });
+ assert.equal([3, 1, 2, 0], capturedHighlight);
+ });
+});
+
+context("moveTabSelection (>> / << with multi-tab selection)", () => {
+ let movedId, movedToIndex;
+
+ setup(() => {
+ movedId = null;
+ movedToIndex = null;
+ stub(chrome.tabs, "move", (id, args) => {
+ movedId = id;
+ movedToIndex = args.index;
+ });
+ });
+
+ should("move block right by placing the right neighbor before the block", async () => {
+ // Tabs: [0, 1(sel), 2(sel), 3(sel), 4]
+ const tabs = makeTabs(5, [1, 2, 3]);
+ stub(chrome.tabs, "query", () => tabs);
+ await moveTabSelection({
+ count: 1,
+ tab: tabs[2],
+ registryEntry: { command: "moveTabRight" },
+ });
+ // Neighbor to the right of the block is tab id=5 (index 4).
+ // It should be moved to index 1 (the left edge of the block).
+ assert.equal(5, movedId);
+ assert.equal(1, movedToIndex);
+ });
+
+ should("move block left by placing the left neighbor after the block", async () => {
+ // Tabs: [0, 1(sel), 2(sel), 3(sel), 4]
+ const tabs = makeTabs(5, [1, 2, 3]);
+ stub(chrome.tabs, "query", () => tabs);
+ await moveTabSelection({
+ count: 1,
+ tab: tabs[2],
+ registryEntry: { command: "moveTabLeft" },
+ });
+ // Neighbor to the left of the block is tab id=1 (index 0).
+ // It should be moved to index 3 (the right edge of the block).
+ assert.equal(1, movedId);
+ assert.equal(3, movedToIndex);
+ });
+
+ should("do nothing when block is at the right window edge", async () => {
+ // Tabs: [0, 1(sel), 2(sel), 3(sel)] — block already at the right edge
+ const tabs = makeTabs(4, [1, 2, 3]);
+ stub(chrome.tabs, "query", () => tabs);
+ await moveTabSelection({
+ count: 1,
+ tab: tabs[2],
+ registryEntry: { command: "moveTabRight" },
+ });
+ assert.equal(null, movedId);
+ });
+
+ should("do nothing when block is at the left window edge", async () => {
+ // Tabs: [0(sel), 1(sel), 2(sel), 3] — block already at the left edge
+ const tabs = makeTabs(4, [0, 1, 2]);
+ stub(chrome.tabs, "query", () => tabs);
+ await moveTabSelection({
+ count: 1,
+ tab: tabs[1],
+ registryEntry: { command: "moveTabLeft" },
+ });
+ assert.equal(null, movedId);
+ });
+
+ should("skip over a collapsed group to the right in one step", async () => {
+ // Tabs: [0, 1(sel), 2(sel), 3(sel), 4(grp99), 5(grp99), 6]
+ const tabs = [
+ { id: 1, index: 0, windowId: 1, highlighted: false, pinned: false, groupId: -1 },
+ { id: 2, index: 1, windowId: 1, highlighted: true, pinned: false, groupId: -1 },
+ { id: 3, index: 2, windowId: 1, highlighted: true, pinned: false, groupId: -1 },
+ { id: 4, index: 3, windowId: 1, highlighted: true, pinned: false, groupId: -1 },
+ { id: 5, index: 4, windowId: 1, highlighted: false, pinned: false, groupId: 99 },
+ { id: 6, index: 5, windowId: 1, highlighted: false, pinned: false, groupId: 99 },
+ { id: 7, index: 6, windowId: 1, highlighted: false, pinned: false, groupId: -1 },
+ ];
+ stub(chrome.tabs, "query", () => tabs);
+ stub(chrome, "tabGroups", { get: () => ({ collapsed: true }) });
+ const moves = [];
+ stub(chrome.tabs, "move", (id, args) => moves.push({ id, index: args.index }));
+ await moveTabSelection({
+ count: 1,
+ tab: tabs[2],
+ registryEntry: { command: "moveTabRight" },
+ });
+ // Rightmost first: id4→groupLast(5)-0=5, id3→5-1=4, id2→5-2=3. Group never touched.
+ assert.equal([{ id: 4, index: 5 }, { id: 3, index: 4 }, { id: 2, index: 3 }], moves);
+ });
+
+ should("skip over a collapsed group to the left in one step", async () => {
+ // Tabs: [0, 1(grp99), 2(grp99), 3(sel), 4(sel), 5(sel), 6]
+ const tabs = [
+ { id: 1, index: 0, windowId: 1, highlighted: false, pinned: false, groupId: -1 },
+ { id: 2, index: 1, windowId: 1, highlighted: false, pinned: false, groupId: 99 },
+ { id: 3, index: 2, windowId: 1, highlighted: false, pinned: false, groupId: 99 },
+ { id: 4, index: 3, windowId: 1, highlighted: true, pinned: false, groupId: -1 },
+ { id: 5, index: 4, windowId: 1, highlighted: true, pinned: false, groupId: -1 },
+ { id: 6, index: 5, windowId: 1, highlighted: true, pinned: false, groupId: -1 },
+ { id: 7, index: 6, windowId: 1, highlighted: false, pinned: false, groupId: -1 },
+ ];
+ stub(chrome.tabs, "query", () => tabs);
+ stub(chrome, "tabGroups", { get: () => ({ collapsed: true }) });
+ const moves = [];
+ stub(chrome.tabs, "move", (id, args) => moves.push({ id, index: args.index }));
+ await moveTabSelection({
+ count: 1,
+ tab: tabs[4],
+ registryEntry: { command: "moveTabLeft" },
+ });
+ // Leftmost first: id4→groupFirst(1)+0=1, id5→1+1=2, id6→1+2=3. Group never touched.
+ assert.equal([{ id: 4, index: 1 }, { id: 5, index: 2 }, { id: 6, index: 3 }], moves);
+ });
+
+ should("join an open group when moving the block right into it", async () => {
+ let joinedTabIds = null;
+ let joinedGroupId = null;
+ stub(chrome.tabs, "group", ({ tabIds, groupId }) => {
+ joinedTabIds = tabIds;
+ joinedGroupId = groupId;
+ });
+ // Tabs: [0(sel), 1(sel), 2(sel), 3(grp99), 4(grp99)]
+ const tabs = [
+ { id: 1, index: 0, windowId: 1, highlighted: true, pinned: false, groupId: -1 },
+ { id: 2, index: 1, windowId: 1, highlighted: true, pinned: false, groupId: -1 },
+ { id: 3, index: 2, windowId: 1, highlighted: true, pinned: false, groupId: -1 },
+ { id: 4, index: 3, windowId: 1, highlighted: false, pinned: false, groupId: 99 },
+ { id: 5, index: 4, windowId: 1, highlighted: false, pinned: false, groupId: 99 },
+ ];
+ stub(chrome.tabs, "query", () => tabs);
+ stub(chrome, "tabGroups", { get: () => ({ collapsed: false }) });
+ await moveTabSelection({
+ count: 1,
+ tab: tabs[1],
+ registryEntry: { command: "moveTabRight" },
+ });
+ assert.equal([1, 2, 3], joinedTabIds);
+ assert.equal(99, joinedGroupId);
+ });
+
+ should("join an open group when moving the block left into it", async () => {
+ let joinedTabIds = null;
+ let joinedGroupId = null;
+ stub(chrome.tabs, "group", ({ tabIds, groupId }) => {
+ joinedTabIds = tabIds;
+ joinedGroupId = groupId;
+ });
+ // Tabs: [0(grp99), 1(grp99), 2(sel), 3(sel), 4(sel)]
+ const tabs = [
+ { id: 1, index: 0, windowId: 1, highlighted: false, pinned: false, groupId: 99 },
+ { id: 2, index: 1, windowId: 1, highlighted: false, pinned: false, groupId: 99 },
+ { id: 3, index: 2, windowId: 1, highlighted: true, pinned: false, groupId: -1 },
+ { id: 4, index: 3, windowId: 1, highlighted: true, pinned: false, groupId: -1 },
+ { id: 5, index: 4, windowId: 1, highlighted: true, pinned: false, groupId: -1 },
+ ];
+ stub(chrome.tabs, "query", () => tabs);
+ stub(chrome, "tabGroups", { get: () => ({ collapsed: false }) });
+ await moveTabSelection({
+ count: 1,
+ tab: tabs[3],
+ registryEntry: { command: "moveTabLeft" },
+ });
+ assert.equal([3, 4, 5], joinedTabIds);
+ assert.equal(99, joinedGroupId);
+ });
+
+ should("move block right N times with count: N", async () => {
+ const moves = [];
+ stub(chrome.tabs, "move", (id, args) => {
+ moves.push({ id, index: args.index });
+ });
+ // Block at [2,3,4] in a 9-tab window — always enough room on the right.
+ // Return the same state on every query call; we just verify move is called 3 times.
+ const tabs = makeTabs(9, [2, 3, 4]);
+ stub(chrome.tabs, "query", () => tabs);
+ await moveTabSelection({
+ count: 3,
+ tab: tabs[3],
+ registryEntry: { command: "moveTabRight" },
+ });
+ assert.equal(3, moves.length);
+ });
+});