From 092ce6ba42edb5ab92f6671312506a078572444c Mon Sep 17 00:00:00 2001 From: "tushar.muralidharan" Date: Mon, 2 Mar 2026 17:29:00 +1100 Subject: [PATCH 01/12] feat: add tab group support (skip collapsed groups, zc/zj/zk navigation) --- background_scripts/all_commands.js | 24 ++++++++++++++++++ background_scripts/commands.js | 3 +++ background_scripts/main.js | 40 +++++++++++++++++++++++++++++- manifest.json | 1 + 4 files changed, 67 insertions(+), 1 deletion(-) diff --git a/background_scripts/all_commands.js b/background_scripts/all_commands.js index 96f9e0a38..b506e49ba 100644 --- a/background_scripts/all_commands.js +++ b/background_scripts/all_commands.js @@ -519,6 +519,30 @@ const allCommands = [ noRepeat: true, }, + { + name: "collapseTabGroup", + desc: "Collapse current tab's group", + 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: "removeTab", desc: "Close current tab", diff --git a/background_scripts/commands.js b/background_scripts/commands.js index cdd8b42bc..bb2aa19ea 100644 --- a/background_scripts/commands.js +++ b/background_scripts/commands.js @@ -483,6 +483,9 @@ const defaultKeyMappings = { "zi": "zoomIn", "zo": "zoomOut", "z0": "zoomReset", + "zc": "collapseTabGroup", + "zj": "previousTabGroup", + "zk": "nextTabGroup", // Marks "m": "Marks.activateCreateMode", diff --git a/background_scripts/main.js b/background_scripts/main.js index c4d3870ce..c7b847814 100644 --- a/background_scripts/main.js +++ b/background_scripts/main.js @@ -343,6 +343,22 @@ const BackgroundCommands = { }); }, toggleMuteTab, + // Tab group commands (Chrome only; no-op on Firefox). + async collapseTabGroup({ tab }) { + if (bgUtils.isFirefox() || !chrome.tabGroups || tab.groupId == -1) return; + const tabs = await chrome.tabs.query({ currentWindow: true }); + let nextTab = tabs.find((t) => t.index > tab.index && t.groupId != tab.groupId) || + tabs.findLast((t) => t.index < tab.index && t.groupId != tab.groupId); + if (!nextTab) nextTab = await chrome.tabs.create({}); // All tabs are in this group. + await chrome.tabs.update(nextTab.id, { active: true }); + chrome.tabGroups.update(tab.groupId, { collapsed: true }); + }, + previousTabGroup({ tab }) { + return goToTabGroup(tab, -1); + }, + nextTabGroup({ tab }) { + return goToTabGroup(tab, 1); + }, moveTabLeft: moveTab, moveTabRight: moveTab, @@ -467,11 +483,33 @@ async function removeTabsRelative(direction, { count, tab }) { await chrome.tabs.remove(toRemove.map((t) => t.id)); } +// Jump to the next (direction=1) or previous (direction=-1) tab group. +async function goToTabGroup(tab, direction) { + if (bgUtils.isFirefox() || !chrome.tabGroups) return; + const tabs = await chrome.tabs.query({ currentWindow: true }); + const inDifferentGroup = (t) => t.groupId != -1 && t.groupId != tab.groupId; + const target = direction > 0 + ? tabs.find((t) => t.index > tab.index && inDifferentGroup(t)) + : tabs.findLast((t) => t.index < tab.index && inDifferentGroup(t)); + if (target) { + await chrome.tabGroups.update(target.groupId, { collapsed: false }); + chrome.tabs.update(target.id, { active: true }); + } +} + // 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) { + // On Chrome, skip tabs in collapsed tab groups. + if (!bgUtils.isFirefox() && 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": diff --git a/manifest.json b/manifest.json index 783852cbb..754d7a3f3 100644 --- a/manifest.json +++ b/manifest.json @@ -25,6 +25,7 @@ ], "permissions": [ "tabs", + "tabGroups", "bookmarks", "history", "storage", From f1ad15169bbc9fa1d4bce2942c1aa49ec46c937b Mon Sep 17 00:00:00 2001 From: "tushar.muralidharan" Date: Mon, 4 May 2026 02:47:55 +1000 Subject: [PATCH 02/12] feat: enable tab group commands on Firefox via feature detection --- background_scripts/main.js | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/background_scripts/main.js b/background_scripts/main.js index c7b847814..9c65db77f 100644 --- a/background_scripts/main.js +++ b/background_scripts/main.js @@ -343,14 +343,15 @@ const BackgroundCommands = { }); }, toggleMuteTab, - // Tab group commands (Chrome only; no-op on Firefox). async collapseTabGroup({ tab }) { - if (bgUtils.isFirefox() || !chrome.tabGroups || tab.groupId == -1) return; + if (!chrome.tabGroups || tab.groupId == -1) return; const tabs = await chrome.tabs.query({ currentWindow: true }); let nextTab = tabs.find((t) => t.index > tab.index && t.groupId != tab.groupId) || tabs.findLast((t) => t.index < tab.index && t.groupId != tab.groupId); - if (!nextTab) nextTab = await chrome.tabs.create({}); // All tabs are in this group. - await chrome.tabs.update(nextTab.id, { active: true }); + if (!nextTab && !bgUtils.isFirefox()) { + nextTab = await chrome.tabs.create({}); + } + if (nextTab) await chrome.tabs.update(nextTab.id, { active: true }); chrome.tabGroups.update(tab.groupId, { collapsed: true }); }, previousTabGroup({ tab }) { @@ -485,7 +486,7 @@ async function removeTabsRelative(direction, { count, tab }) { // Jump to the next (direction=1) or previous (direction=-1) tab group. async function goToTabGroup(tab, direction) { - if (bgUtils.isFirefox() || !chrome.tabGroups) return; + if (!chrome.tabGroups) return; const tabs = await chrome.tabs.query({ currentWindow: true }); const inDifferentGroup = (t) => t.groupId != -1 && t.groupId != tab.groupId; const target = direction > 0 @@ -502,8 +503,8 @@ async function goToTabGroup(tab, direction) { function selectTab(direction, { count, tab }) { chrome.tabs.query(visibleTabsQueryArgs, async function (tabs) { if (tabs.length > 1) { - // On Chrome, skip tabs in collapsed tab groups. - if (!bgUtils.isFirefox() && chrome.tabGroups) { + // 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)); From 2dbf711721f9f87d50366d565a6faca13a4dba86 Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Sun, 14 Jun 2026 17:24:09 +0200 Subject: [PATCH 03/12] feat: remap tab group shortcuts with circular navigation - za: collapse current tab group (was zc) - zn/zN: next/previous tab group with circular wrap (were zk/zj) - goToTabGroup now wraps around to the first/last group when no target exists in the current direction - add .serena to .gitignore Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 3 ++- background_scripts/commands.js | 6 +++--- background_scripts/main.js | 9 +++++++-- manifest.json | 27 +++++++-------------------- 4 files changed, 19 insertions(+), 26 deletions(-) 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/background_scripts/commands.js b/background_scripts/commands.js index bb2aa19ea..8056bb776 100644 --- a/background_scripts/commands.js +++ b/background_scripts/commands.js @@ -483,9 +483,9 @@ const defaultKeyMappings = { "zi": "zoomIn", "zo": "zoomOut", "z0": "zoomReset", - "zc": "collapseTabGroup", - "zj": "previousTabGroup", - "zk": "nextTabGroup", + "za": "collapseTabGroup", + "zN": "previousTabGroup", + "zn": "nextTabGroup", // Marks "m": "Marks.activateCreateMode", diff --git a/background_scripts/main.js b/background_scripts/main.js index 9c65db77f..d8cae940a 100644 --- a/background_scripts/main.js +++ b/background_scripts/main.js @@ -484,14 +484,19 @@ async function removeTabsRelative(direction, { count, tab }) { await chrome.tabs.remove(toRemove.map((t) => t.id)); } -// Jump to the next (direction=1) or previous (direction=-1) tab group. +// 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; - const target = direction > 0 + 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 }); diff --git a/manifest.json b/manifest.json index 754d7a3f3..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,9 +20,7 @@ "browser_style": false, "open_in_tab": true }, - "host_permissions": [ - "" - ], + "host_permissions": [""], "permissions": [ "tabs", "tabGroups", @@ -42,9 +40,7 @@ ], "content_scripts": [ { - "matches": [ - "" - ], + "matches": [""], "js": [ "lib/types.js", "lib/utils.js", @@ -68,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 } @@ -119,9 +108,7 @@ // "pages/reload.html", "_favicon/*" ], - "matches": [ - "" - ] + "matches": [""] } ] } From af44e7944dd07061bf07502a36fe566cde5deb3c Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Sun, 14 Jun 2026 18:04:18 +0200 Subject: [PATCH 04/12] feat: make << and >> group-aware for tab movement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, << and >> (moveTabLeft/moveTabRight) moved a tab by a fixed number of positions in the tab strip, ignoring tab group structure entirely. This rewrite introduces group-aware movement where tab groups behave as atomic blocks: - Moving INTO an open group: if the neighboring tab belongs to an expanded group, the tab joins that group via chrome.tabs.group(). Chrome keeps the tab at its current adjacent position and adds it as a member, so no explicit repositioning is needed. - Moving past a collapsed group: if the neighboring tab belongs to a collapsed group, the tab skips over the entire group and lands immediately on the far side. The target index is computed as afterGroup.index - direction to account for the index shift that occurs when Chrome removes the tab from its original position before reinserting it. - Exiting a group at its boundary: if the active tab is the first/last member of a group and the user presses << or >> toward the boundary, chrome.tabs.ungroup() removes it from the group without any additional move call — the tab stays at its current index, which is already outside the remaining group members. - Moving within a group (not at a boundary): behaves identically to the previous implementation, shifting one position at a time. For counts > 1 (e.g. 3>>) the logic is applied iteratively, re-fetching the tab after each step to get the updated index. Fallback: pinned tabs and environments where chrome.tabGroups is unavailable (e.g. older browsers) retain the original flat-index logic unchanged. Co-Authored-By: Claude Sonnet 4.6 --- background_scripts/main.js | 76 ++++++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 12 deletions(-) diff --git a/background_scripts/main.js b/background_scripts/main.js index d8cae940a..aedfb9ac7 100644 --- a/background_scripts/main.js +++ b/background_scripts/main.js @@ -182,20 +182,72 @@ 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; +async function moveTab({ count, tab, registryEntry }) { + const direction = registryEntry.command === "moveTabLeft" ? -1 : 1; + // Pinned tabs and environments without tabGroups API use the original simple logic. + if (tab.pinned || !chrome.tabGroups) { + const tabs = await chrome.tabs.query(visibleTabsQueryArgs); + const pinnedCount = tabs.filter((t) => t.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, - }); - }); + const moveIndex = Math.max(minIndex, Math.min(maxIndex, getTabIndex(tab, tabs) + 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); + } +} + +// 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 edgePos = nonPinned.findIndex((t) => t.id === (direction > 0 ? groupTabs.at(-1) : groupTabs[0]).id); + const afterGroup = nonPinned[edgePos + direction]; + if (!afterGroup) return; // group is flush against the window edge + // 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 }); + } } function createRepeatCommand(command) { From bba68a8a7e244fc273587dd92b415ef9cff643ee Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Sun, 14 Jun 2026 18:39:47 +0200 Subject: [PATCH 05/12] refactor: extract tab group logic into tab_groups.js + add group_completer.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation main.js was growing too large as tab group features accumulated. All group-related background logic is now consolidated in a dedicated module, following the same pattern used by marks.js, exclusions.js, and tab_operations.js. ## Changes ### background_scripts/tab_groups.js (new) Exports four public BackgroundCommand handlers: - collapseTabGroup: collapse the current tab's group, moving focus to an adjacent tab - previousTabGroup / nextTabGroup: circular navigation between groups - moveTab: group-aware implementation of << and >> (replaces the inline version in main.js) Two private helpers (goToTabGroup, moveTabOneStep) remain internal to the module. The only external dependency is bg_utils.js (for isFirefox()). Circular imports are avoided by inlining the two main.js helpers that were previously needed: - visibleTabsQueryArgs → { currentWindow: true } (pinned tabs are never hidden) - getTabIndex(tab, tabs) → tabs.findIndex((t) => t.id === tab.id) (id-based, safer) ### background_scripts/completion/group_completer.js (new) Implements TabGroupCompleter following the Completer contract from completers.js (filter({ queryTerms }) → Suggestion[]). Queries chrome.tabGroups for the current window, maps each group to a Suggestion with tabId set to the first tab in the group so Vomnibar can jump to it on selection. Falls back gracefully when chrome.tabGroups is unavailable (Firefox without tab group support). ### background_scripts/main.js - Added imports for tab_groups.js and group_completer.js - Added tabGroups entry to completionSources and completers (key: "tabGroups") - Delegated collapseTabGroup, previousTabGroup, nextTabGroup, moveTabLeft, moveTabRight to the tabGroups module - Removed the now-redundant inline implementations of moveTab, moveTabOneStep, collapseTabGroup, previousTabGroup, nextTabGroup, goToTabGroup (~80 lines removed) ### content_scripts/vomnibar.js Added activateTabGroupSelection() method with completer: "tabGroups", selectFirst: true. ### background_scripts/all_commands.js Added Vomnibar.activateTabGroupSelection command definition. ### background_scripts/commands.js Added "ZG" key binding for Vomnibar.activateTabGroupSelection. Note: ZG is a stub — the full group management UI (creating groups, adding selected tabs to groups) will be wired up in a follow-up commit. Co-Authored-By: Claude Sonnet 4.6 --- background_scripts/all_commands.js | 8 ++ background_scripts/commands.js | 1 + .../completion/group_completer.js | 35 ++++++ background_scripts/main.js | 114 ++---------------- background_scripts/tab_groups.js | 109 +++++++++++++++++ content_scripts/vomnibar.js | 7 ++ 6 files changed, 169 insertions(+), 105 deletions(-) create mode 100644 background_scripts/completion/group_completer.js create mode 100644 background_scripts/tab_groups.js diff --git a/background_scripts/all_commands.js b/background_scripts/all_commands.js index b506e49ba..26c784d62 100644 --- a/background_scripts/all_commands.js +++ b/background_scripts/all_commands.js @@ -365,6 +365,14 @@ const allCommands = [ noRepeat: true, }, + { + name: "Vomnibar.activateTabGroupSelection", + desc: "Search through your tab groups", + group: "vomnibar", + topFrame: true, + noRepeat: true, + }, + { name: "Vomnibar.activateEditUrl", desc: "Edit the current URL", diff --git a/background_scripts/commands.js b/background_scripts/commands.js index 8056bb776..3df3808b6 100644 --- a/background_scripts/commands.js +++ b/background_scripts/commands.js @@ -453,6 +453,7 @@ const defaultKeyMappings = { "o": "Vomnibar.activate", "O": "Vomnibar.activateInNewTab", "T": "Vomnibar.activateTabSelection", + "ZG": "Vomnibar.activateTabGroupSelection", "b": "Vomnibar.activateBookmarks", "B": "Vomnibar.activateBookmarksInNewTab", ":": "Vomnibar.activateCommandSelection", diff --git a/background_scripts/completion/group_completer.js b/background_scripts/completion/group_completer.js new file mode 100644 index 000000000..67a5cbdb9 --- /dev/null +++ b/background_scripts/completion/group_completer.js @@ -0,0 +1,35 @@ +import * as ranking from "./ranking.js"; +import { Suggestion } from "./completers.js"; + +export class TabGroupCompleter { + async filter({ queryTerms }) { + if (!chrome.tabGroups) return []; + const [groups, tabs] = await Promise.all([ + chrome.tabGroups.query({ windowId: chrome.windows.WINDOW_ID_CURRENT }), + chrome.tabs.query({ currentWindow: true }), + ]); + // Build a map from groupId → first tab in that group (by tab index). + const firstTabByGroup = new Map(); + for (const tab of tabs.sort((a, b) => a.index - b.index)) { + if (tab.groupId !== -1 && !firstTabByGroup.has(tab.groupId)) { + firstTabByGroup.set(tab.groupId, tab); + } + } + return groups + .filter((g) => queryTerms.length === 0 || ranking.matches(queryTerms, g.title ?? "", g.color)) + .map((group) => { + const firstTab = firstTabByGroup.get(group.id); + const label = group.title || `(${group.color})`; + const suggestion = new Suggestion({ + queryTerms, + description: "tab group", + url: firstTab?.url ?? "", + title: label, + tabId: firstTab?.id, + deDuplicate: false, + }); + suggestion.relevancy = 1; + return suggestion; + }); + } +} diff --git a/background_scripts/main.js b/background_scripts/main.js index aedfb9ac7..2fec63e14 100644 --- a/background_scripts/main.js +++ b/background_scripts/main.js @@ -11,6 +11,7 @@ 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 { BookmarkCompleter, @@ -21,6 +22,7 @@ import { SearchEngineCompleter, TabCompleter, } from "./completion/completers.js"; +import { TabGroupCompleter } 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 +51,7 @@ const completionSources = { domains: new DomainCompleter(), tabs: new TabCompleter(), searchEngines: new SearchEngineCompleter(), + tabGroups: new TabGroupCompleter(), }; const completers = { @@ -62,6 +65,7 @@ const completers = { bookmarks: new MultiCompleter([completionSources.bookmarks]), commands: new MultiCompleter([completionSources.commands]), tabs: new MultiCompleter([completionSources.tabs]), + tabGroups: new MultiCompleter([completionSources.tabGroups]), }; // A query dictionary for `chrome.tabs.query` that will return only the visible tabs. @@ -182,73 +186,6 @@ async function selectSpecificTab(request) { await chrome.tabs.update(request.id, { active: true }); } -async function moveTab({ count, tab, registryEntry }) { - const direction = registryEntry.command === "moveTabLeft" ? -1 : 1; - // Pinned tabs and environments without tabGroups API use the original simple logic. - if (tab.pinned || !chrome.tabGroups) { - const tabs = await chrome.tabs.query(visibleTabsQueryArgs); - const pinnedCount = tabs.filter((t) => t.pinned).length; - const minIndex = tab.pinned ? 0 : pinnedCount; - const maxIndex = (tab.pinned ? pinnedCount : tabs.length) - 1; - const moveIndex = Math.max(minIndex, Math.min(maxIndex, getTabIndex(tab, tabs) + 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); - } -} - -// 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 edgePos = nonPinned.findIndex((t) => t.id === (direction > 0 ? groupTabs.at(-1) : groupTabs[0]).id); - const afterGroup = nonPinned[edgePos + direction]; - if (!afterGroup) return; // group is flush against the window edge - // 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 }); - } -} function createRepeatCommand(command) { return async function (request) { @@ -395,25 +332,11 @@ const BackgroundCommands = { }); }, toggleMuteTab, - async collapseTabGroup({ tab }) { - if (!chrome.tabGroups || tab.groupId == -1) return; - const tabs = await chrome.tabs.query({ currentWindow: true }); - let nextTab = tabs.find((t) => t.index > tab.index && t.groupId != tab.groupId) || - tabs.findLast((t) => t.index < tab.index && t.groupId != tab.groupId); - if (!nextTab && !bgUtils.isFirefox()) { - nextTab = await chrome.tabs.create({}); - } - if (nextTab) await chrome.tabs.update(nextTab.id, { active: true }); - chrome.tabGroups.update(tab.groupId, { collapsed: true }); - }, - previousTabGroup({ tab }) { - return goToTabGroup(tab, -1); - }, - nextTabGroup({ tab }) { - return goToTabGroup(tab, 1); - }, - moveTabLeft: moveTab, - moveTabRight: moveTab, + collapseTabGroup: tabGroups.collapseTabGroup, + previousTabGroup: tabGroups.previousTabGroup, + nextTabGroup: tabGroups.nextTabGroup, + moveTabLeft: tabGroups.moveTab, + moveTabRight: tabGroups.moveTab, async setZoom({ tabId, registryEntry }) { const level = registryEntry.options?.["level"] ?? "1"; @@ -536,25 +459,6 @@ async function removeTabsRelative(direction, { count, tab }) { await chrome.tabs.remove(toRemove.map((t) => t.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 }); - } -} - // Selects a tab before or after the currently selected tab. // - direction: "next", "previous", "first" or "last". function selectTab(direction, { count, tab }) { diff --git a/background_scripts/tab_groups.js b/background_scripts/tab_groups.js new file mode 100644 index 000000000..29ec96735 --- /dev/null +++ b/background_scripts/tab_groups.js @@ -0,0 +1,109 @@ +import * as bgUtils from "./bg_utils.js"; + +export async function collapseTabGroup({ tab }) { + if (!chrome.tabGroups || tab.groupId == -1) return; + const tabs = await chrome.tabs.query({ currentWindow: true }); + let nextTab = tabs.find((t) => t.index > tab.index && t.groupId != tab.groupId) || + tabs.findLast((t) => t.index < tab.index && t.groupId != tab.groupId); + if (!nextTab && !bgUtils.isFirefox()) { + nextTab = await chrome.tabs.create({}); + } + if (nextTab) await chrome.tabs.update(nextTab.id, { active: true }); + chrome.tabGroups.update(tab.groupId, { collapsed: 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; + // 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 edgePos = nonPinned.findIndex((t) => t.id === (direction > 0 ? groupTabs.at(-1) : groupTabs[0]).id); + const afterGroup = nonPinned[edgePos + direction]; + if (!afterGroup) return; // group is flush against the window edge + // 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/content_scripts/vomnibar.js b/content_scripts/vomnibar.js index 162936725..51de07208 100644 --- a/content_scripts/vomnibar.js +++ b/content_scripts/vomnibar.js @@ -40,6 +40,13 @@ const Vomnibar = { this.open(sourceFrameId, options); }, + activateTabGroupSelection(sourceFrameId) { + this.open(sourceFrameId, { + completer: "tabGroups", + selectFirst: true, + }); + }, + activateBookmarksInNewTab(sourceFrameId, registryEntry) { const options = Object.assign({}, registryEntry.options, { completer: "bookmarks", From d1226f02514a7eccbcd9e7b148603df2e2d6412f Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Sun, 14 Jun 2026 18:43:31 +0200 Subject: [PATCH 06/12] feat: add multi-tab selection via ctrl+shift+j / ctrl+shift+k MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces two new background commands that extend Chrome's native tab highlighting to allow selecting multiple tabs at once — a prerequisite for the upcoming "group selected tabs" feature (zg). ## How it works Chrome maintains a "highlighted" state on tabs independently of the active tab. chrome.tabs.highlight({ windowId, tabs: [indices] }) replaces the highlighted set while keeping the specified active tab as the first entry. The highlighted tabs appear visually selected (blue) in Chrome's native tab strip, with no content-script UI changes needed. ## Behavior ctrl+shift+j (bound as ): adds the tab immediately after the highest currently-highlighted index to the selection. Stops at the last tab. ctrl+shift+k (bound as ): adds the tab immediately before the lowest currently-highlighted index to the selection. Stops at the first tab. The active tab index is always kept in the highlighted set as the first element (Chrome requirement — removing it from the set would change the active tab). ## Files - background_scripts/tab_groups.js: selectNextTabForGroup, selectPreviousTabForGroup exported and implemented - background_scripts/main.js: two new BackgroundCommands entries - background_scripts/all_commands.js: two new command definitions - background_scripts/commands.js: and key bindings Co-Authored-By: Claude Sonnet 4.6 --- background_scripts/all_commands.js | 16 ++++++++++++++++ background_scripts/commands.js | 2 ++ background_scripts/main.js | 2 ++ background_scripts/tab_groups.js | 24 ++++++++++++++++++++++++ 4 files changed, 44 insertions(+) diff --git a/background_scripts/all_commands.js b/background_scripts/all_commands.js index 26c784d62..57783c0d2 100644 --- a/background_scripts/all_commands.js +++ b/background_scripts/all_commands.js @@ -551,6 +551,22 @@ const allCommands = [ noRepeat: true, }, + { + name: "selectNextTabForGroup", + desc: "Extend tab selection downward (for group creation)", + group: "tabs", + background: true, + noRepeat: true, + }, + + { + name: "selectPreviousTabForGroup", + desc: "Extend tab selection upward (for group creation)", + group: "tabs", + background: true, + noRepeat: true, + }, + { name: "removeTab", desc: "Close current tab", diff --git a/background_scripts/commands.js b/background_scripts/commands.js index 3df3808b6..d7eb01594 100644 --- a/background_scripts/commands.js +++ b/background_scripts/commands.js @@ -487,6 +487,8 @@ const defaultKeyMappings = { "za": "collapseTabGroup", "zN": "previousTabGroup", "zn": "nextTabGroup", + "": "selectNextTabForGroup", + "": "selectPreviousTabForGroup", // Marks "m": "Marks.activateCreateMode", diff --git a/background_scripts/main.js b/background_scripts/main.js index 2fec63e14..89c4b8c47 100644 --- a/background_scripts/main.js +++ b/background_scripts/main.js @@ -335,6 +335,8 @@ const BackgroundCommands = { collapseTabGroup: tabGroups.collapseTabGroup, previousTabGroup: tabGroups.previousTabGroup, nextTabGroup: tabGroups.nextTabGroup, + selectNextTabForGroup: tabGroups.selectNextTabForGroup, + selectPreviousTabForGroup: tabGroups.selectPreviousTabForGroup, moveTabLeft: tabGroups.moveTab, moveTabRight: tabGroups.moveTab, diff --git a/background_scripts/tab_groups.js b/background_scripts/tab_groups.js index 29ec96735..7f33e199b 100644 --- a/background_scripts/tab_groups.js +++ b/background_scripts/tab_groups.js @@ -20,6 +20,30 @@ export function nextTabGroup({ tab }) { return goToTabGroup(tab, 1); } +// Extend the highlighted tab selection to the next tab (ctrl+shift+j). +export async function selectNextTabForGroup({ tab }) { + const tabs = await chrome.tabs.query({ currentWindow: true }); + const highlighted = tabs.filter((t) => t.highlighted).map((t) => t.index); + const nextIndex = Math.max(...highlighted) + 1; + if (nextIndex >= tabs.length) return; + await chrome.tabs.highlight({ + windowId: tab.windowId, + tabs: [...new Set([tab.index, ...highlighted, nextIndex])], + }); +} + +// Extend the highlighted tab selection to the previous tab (ctrl+shift+k). +export async function selectPreviousTabForGroup({ tab }) { + const tabs = await chrome.tabs.query({ currentWindow: true }); + const highlighted = tabs.filter((t) => t.highlighted).map((t) => t.index); + const prevIndex = Math.min(...highlighted) - 1; + if (prevIndex < 0) return; + await chrome.tabs.highlight({ + windowId: tab.windowId, + tabs: [...new Set([tab.index, ...highlighted, prevIndex])], + }); +} + export async function moveTab({ count, tab, registryEntry }) { const direction = registryEntry.command === "moveTabLeft" ? -1 : 1; // Pinned tabs and environments without tabGroups API use the original simple logic. From 87b25131a21d62a03a11575891f93ea1eeb29975 Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Sun, 14 Jun 2026 18:57:01 +0200 Subject: [PATCH 07/12] fix: change multi-tab selection shortcuts to / MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ctrl+shift+j () is intercepted by Chrome on Windows and Linux as the DevTools Console shortcut, preventing Vimium's content script from ever seeing the keydown event. alt+shift+j/k ( / ) is not reserved by Chrome on any platform and reliably reaches the content script. Key encoding in Vimium: shift on a single-char key uppercases the char (j → J), and alt adds the "a" modifier prefix → . Co-Authored-By: Claude Sonnet 4.6 --- background_scripts/commands.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/background_scripts/commands.js b/background_scripts/commands.js index d7eb01594..c33d78e8c 100644 --- a/background_scripts/commands.js +++ b/background_scripts/commands.js @@ -487,8 +487,8 @@ const defaultKeyMappings = { "za": "collapseTabGroup", "zN": "previousTabGroup", "zn": "nextTabGroup", - "": "selectNextTabForGroup", - "": "selectPreviousTabForGroup", + "": "selectNextTabForGroup", + "": "selectPreviousTabForGroup", // Marks "m": "Marks.activateCreateMode", From ef955312979851895542800694fc12742c3267bd Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Sun, 14 Jun 2026 19:18:43 +0200 Subject: [PATCH 08/12] fix: rework multi-tab selection shortcuts and logic Rebind selectNextTabForGroup to `zz` and selectPreviousTabForGroup to `ZZ` (was /, which Karabiner Elements intercepts on macOS). Both shortcuts now sit cleanly in the z-prefix group alongside za/zn/zN. Rewrite both functions with vim visual-mode semantics: tab.index is the fixed anchor; the selection cursor extends or shrinks relative to it. - zz: extends right if right side is selected; shrinks left if left side is selected; starts extending right if nothing is selected yet. - ZZ: extends left if left side is selected; shrinks right if right side is selected; starts extending left if nothing is selected yet. Also switches chrome.tabs.query from currentWindow:true to windowId:tab.windowId for reliable results from MV3 service workers, and shortens the command descriptions to "Select next/previous tab". Co-Authored-By: Claude Sonnet 4.6 --- background_scripts/all_commands.js | 4 +-- background_scripts/commands.js | 4 +-- background_scripts/tab_groups.js | 52 ++++++++++++++++++++++++------ 3 files changed, 46 insertions(+), 14 deletions(-) diff --git a/background_scripts/all_commands.js b/background_scripts/all_commands.js index 57783c0d2..96f95b280 100644 --- a/background_scripts/all_commands.js +++ b/background_scripts/all_commands.js @@ -553,7 +553,7 @@ const allCommands = [ { name: "selectNextTabForGroup", - desc: "Extend tab selection downward (for group creation)", + desc: "Select next tab", group: "tabs", background: true, noRepeat: true, @@ -561,7 +561,7 @@ const allCommands = [ { name: "selectPreviousTabForGroup", - desc: "Extend tab selection upward (for group creation)", + desc: "Select previous tab", group: "tabs", background: true, noRepeat: true, diff --git a/background_scripts/commands.js b/background_scripts/commands.js index c33d78e8c..1101c67a7 100644 --- a/background_scripts/commands.js +++ b/background_scripts/commands.js @@ -487,8 +487,8 @@ const defaultKeyMappings = { "za": "collapseTabGroup", "zN": "previousTabGroup", "zn": "nextTabGroup", - "": "selectNextTabForGroup", - "": "selectPreviousTabForGroup", + "zz": "selectNextTabForGroup", + "ZZ": "selectPreviousTabForGroup", // Marks "m": "Marks.activateCreateMode", diff --git a/background_scripts/tab_groups.js b/background_scripts/tab_groups.js index 7f33e199b..5b26a7141 100644 --- a/background_scripts/tab_groups.js +++ b/background_scripts/tab_groups.js @@ -20,27 +20,59 @@ export function nextTabGroup({ tab }) { return goToTabGroup(tab, 1); } -// Extend the highlighted tab selection to the next tab (ctrl+shift+j). +// 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 }) { - const tabs = await chrome.tabs.query({ currentWindow: true }); + const tabs = await chrome.tabs.query({ windowId: tab.windowId }); const highlighted = tabs.filter((t) => t.highlighted).map((t) => t.index); - const nextIndex = Math.max(...highlighted) + 1; - if (nextIndex >= tabs.length) return; + 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: [...new Set([tab.index, ...highlighted, nextIndex])], + tabs: [anchor, ...next.filter((i) => i !== anchor)], }); } -// Extend the highlighted tab selection to the previous tab (ctrl+shift+k). +// 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 }) { - const tabs = await chrome.tabs.query({ currentWindow: true }); + const tabs = await chrome.tabs.query({ windowId: tab.windowId }); const highlighted = tabs.filter((t) => t.highlighted).map((t) => t.index); - const prevIndex = Math.min(...highlighted) - 1; - if (prevIndex < 0) return; + 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: [...new Set([tab.index, ...highlighted, prevIndex])], + tabs: [anchor, ...next.filter((i) => i !== anchor)], }); } From f3cba94172b993fc8d86d3c0d7a6a26d66f10839 Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Sun, 14 Jun 2026 20:11:30 +0200 Subject: [PATCH 09/12] feat: add tab group Vomnibar commands (zg / ZG) with color swatches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## New commands - `ZG` — navigate to a tab group: opens Vomnibar listing all groups in the current window; selecting one uncollapses the group and switches to its first tab. - `zg` — assign highlighted tabs to a group: two-step Vomnibar flow: 1. Shows all existing groups; type to filter or enter a name. - Enter on an exact name match → assigns highlighted tabs to that group. - Enter on a non-matching name → moves to step 2 (color picker). 2. Shows the 9 Chrome tab group colors; selecting one creates a new named, colored group containing the highlighted tabs. Selecting an existing group directly from the dropdown assigns tabs immediately without opening step 2. ## Completers (group_completer.js) - `TabGroupCompleter` (ZG): lists groups with `showResultsWithNoQuery` so they appear as soon as the Vomnibar opens. - `TabGroupAssignCompleter` (zg step 1): same, plus emits a trailing "Create…" suggestion when the query is non-empty. - `TabGroupColorCompleter` (zg step 2): lists all 9 Chrome colors with `showResultsWithNoQuery`. - All three use `chrome.windows.getLastFocused()` for the window ID — `WINDOW_ID_CURRENT` / `currentWindow: true` are unreliable in MV3 background service workers. - Every suggestion pre-sets `s.html` so `generateHtml()` returns it directly, embedding a colored swatch (``) inline-styled with the actual Chrome group color hex. ## Suggestion class (completers.js) - Declared `groupData` as a class field alongside the existing `html`, `searchUrl`, etc. — required because the constructor calls `Object.seal(this)`, preventing ad-hoc property additions. - Extended `MultiCompleter.filter`'s empty-query guard to also allow completers that set `showResultsWithNoQuery = true`, not just `TabCompleter`. ## Vomnibar page (vomnibar_page.js) - `VomnibarUI` constructor: added `this.pendingGroupName` to carry the group name across the two-step flow (survives `reset()`, which does not clear it). - `handleEnterKey`: smart Enter for `tabGroupAssign` — exact title match assigns; anything else enters step 2 (color picker). - `handleOpenRequested`: when the selected completion is a "createGroup" action, switches completer to `groupColors` in-place instead of closing the Vomnibar. - `openCompletion`: dispatches on `groupData.action` first (`addToGroup`, `setColor`), then falls back to `tabId != null` for tab switching (replacing the fragile `description == "tab"` check that broke ZG), then URL launch. ## Background (main.js) - Added `addTabsToGroup` handler: groups all highlighted tabs in the focused window into an existing group. - Added `createTabGroupWithColor` handler: groups highlighted tabs into a new group and sets its title and color. - `selectSpecificTab`: uncollapses the tab's group before switching to it, so the ZG destination is always visible. ## Wiring (mode_normal.js, vomnibar.js, commands.js, all_commands.js) - Added `Vomnibar.activateTabGroupSelection` and `Vomnibar.activateGroupAssign` to the manually maintained `NormalModeCommands` dispatch table — both were missing, causing `ZG` and `zg` to silently do nothing. - Registered both methods on the `Vomnibar` object in `vomnibar.js`. - Bound `zg` key in `commands.js` and declared both commands in `all_commands.js`. ## CSS (vomnibar_page.css) - Added `.group-color-swatch`: 10×10px rounded square, vertically centered, right margin 5px. Background is always set inline from the `COLOR_CSS` map in `group_completer.js`, so swatches work in both light and dark Vomnibar themes without any media query override. Co-Authored-By: Claude Sonnet 4.6 --- background_scripts/all_commands.js | 8 + background_scripts/commands.js | 1 + background_scripts/completion/completers.js | 11 +- .../completion/group_completer.js | 142 +++++++++++++++--- background_scripts/main.js | 26 +++- content_scripts/mode_normal.js | 2 + content_scripts/vomnibar.js | 7 + pages/vomnibar_page.css | 9 ++ pages/vomnibar_page.js | 34 ++++- 9 files changed, 216 insertions(+), 24 deletions(-) diff --git a/background_scripts/all_commands.js b/background_scripts/all_commands.js index 96f95b280..0308bd5fe 100644 --- a/background_scripts/all_commands.js +++ b/background_scripts/all_commands.js @@ -373,6 +373,14 @@ const allCommands = [ 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", diff --git a/background_scripts/commands.js b/background_scripts/commands.js index 1101c67a7..a813d118d 100644 --- a/background_scripts/commands.js +++ b/background_scripts/commands.js @@ -454,6 +454,7 @@ const defaultKeyMappings = { "O": "Vomnibar.activateInNewTab", "T": "Vomnibar.activateTabSelection", "ZG": "Vomnibar.activateTabGroupSelection", + "zg": "Vomnibar.activateGroupAssign", "b": "Vomnibar.activateBookmarks", "B": "Vomnibar.activateBookmarksInNewTab", ":": "Vomnibar.activateCommandSelection", 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 index 67a5cbdb9..4a5016fe0 100644 --- a/background_scripts/completion/group_completer.js +++ b/background_scripts/completion/group_completer.js @@ -1,35 +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 Promise.all([ - chrome.tabGroups.query({ windowId: chrome.windows.WINDOW_ID_CURRENT }), - chrome.tabs.query({ currentWindow: true }), - ]); - // Build a map from groupId → first tab in that group (by tab index). - const firstTabByGroup = new Map(); - for (const tab of tabs.sort((a, b) => a.index - b.index)) { - if (tab.groupId !== -1 && !firstTabByGroup.has(tab.groupId)) { - firstTabByGroup.set(tab.groupId, tab); - } - } + 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 label = group.title || `(${group.color})`; - const suggestion = new Suggestion({ + const url = firstTab?.url ?? ""; + const s = new Suggestion({ queryTerms, - description: "tab group", - url: firstTab?.url ?? "", - title: label, + description: group.color || "tab group", + url, + title: group.title || `(${group.color})`, tabId: firstTab?.id, deDuplicate: false, }); - suggestion.relevancy = 1; - return suggestion; + 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 89c4b8c47..b1c411adf 100644 --- a/background_scripts/main.js +++ b/background_scripts/main.js @@ -22,7 +22,7 @@ import { SearchEngineCompleter, TabCompleter, } from "./completion/completers.js"; -import { TabGroupCompleter } from "./completion/group_completer.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 @@ -52,6 +52,8 @@ const completionSources = { tabs: new TabCompleter(), searchEngines: new SearchEngineCompleter(), tabGroups: new TabGroupCompleter(), + tabGroupAssign: new TabGroupAssignCompleter(), + groupColors: new TabGroupColorCompleter(), }; const completers = { @@ -66,6 +68,8 @@ const completers = { 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. @@ -179,6 +183,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 }); @@ -653,6 +660,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/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 51de07208..e9b599177 100644 --- a/content_scripts/vomnibar.js +++ b/content_scripts/vomnibar.js @@ -47,6 +47,13 @@ const Vomnibar = { }); }, + activateGroupAssign(sourceFrameId) { + this.open(sourceFrameId, { + completer: "tabGroupAssign", + selectFirst: true, + }); + }, + activateBookmarksInNewTab(sourceFrameId, registryEntry) { const options = Object.assign({}, registryEntry.options, { completer: "bookmarks", 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); From b11ffcbad0fd8e2768e0156b34f6b85de9c1f0db Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Sun, 14 Jun 2026 23:55:04 +0200 Subject: [PATCH 10/12] test: add unit tests for tab group completers and multi-tab selection Covers the two most logic-heavy parts of the tab group feature: - tests/unit_tests/tab_groups_test.js (10 cases) selectNextTabForGroup (zz) and selectPreviousTabForGroup (ZZ): extend, shrink, and no-op behavior for all anchor/selection combos. - tests/unit_tests/group_completer_test.js (16 cases) TabGroupCompleter: empty-query results, title/color filtering, tabId assignment, color swatch hex in html, unnamed group fallback title. TabGroupAssignCompleter: no Create entry on empty query, Create entry appended on non-empty query, correct groupId, filtering by title. TabGroupColorCompleter: all 9 colors returned, prefix filtering, setColor action on every result, GROUP_COLORS order preserved, swatch hex in html. Also removes the redundant bare import of completers.js in main.js (already loaded by the named import below it) and expands the tab group completer import to multi-line style for consistency. Co-Authored-By: Claude Sonnet 4.6 --- background_scripts/main.js | 7 +- tests/unit_tests/group_completer_test.js | 151 +++++++++++++++++++++++ tests/unit_tests/tab_groups_test.js | 108 ++++++++++++++++ 3 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 tests/unit_tests/group_completer_test.js create mode 100644 tests/unit_tests/tab_groups_test.js diff --git a/background_scripts/main.js b/background_scripts/main.js index b1c411adf..d778477c9 100644 --- a/background_scripts/main.js +++ b/background_scripts/main.js @@ -8,7 +8,6 @@ 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"; @@ -22,7 +21,11 @@ import { SearchEngineCompleter, TabCompleter, } from "./completion/completers.js"; -import { TabGroupCompleter, TabGroupAssignCompleter, TabGroupColorCompleter } from "./completion/group_completer.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 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..7c7001497 --- /dev/null +++ b/tests/unit_tests/tab_groups_test.js @@ -0,0 +1,108 @@ +import "./test_helper.js"; +import "../../background_scripts/bg_utils.js"; +import { + selectNextTabForGroup, + selectPreviousTabForGroup, +} from "../../background_scripts/tab_groups.js"; + +function makeTabs(count, highlighted = []) { + return Array.from({ length: count }, (_, i) => ({ + id: i + 1, + index: i, + windowId: 1, + highlighted: highlighted.includes(i), + 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] }); + 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] }); + 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] }); + 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] }); + 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] }); + assert.equal(null, 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] }); + 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] }); + 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] }); + 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] }); + 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] }); + assert.equal(null, capturedHighlight); + }); +}); From 661d964f32155262ee477239ae22c5ae2352b504 Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Mon, 15 Jun 2026 00:26:04 +0200 Subject: [PATCH 11/12] fix: allow >> / << to jump past a collapsed group at the window edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, pressing >> on a free tab when a collapsed group was the last item in the tab bar silently did nothing (the function returned early because there was no tab after the group to anchor against). The user had to manually uncollapse the group, then shift through every tab inside it to reach the end — unintuitive. The fix: when afterGroup is undefined (group is flush against the window edge), move the tab to the edge group tab's index instead of returning. The index arithmetic is the same as the non-edge case; the group shifts by one to accommodate the incoming tab. Open-group behaviour (>> absorbs the tab into the group) is unchanged. Also adds two unit tests covering both the right-edge and left-edge cases via the public moveTab function. Co-Authored-By: Claude Sonnet 4.6 --- background_scripts/tab_groups.js | 9 ++++++-- tests/unit_tests/tab_groups_test.js | 35 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/background_scripts/tab_groups.js b/background_scripts/tab_groups.js index 5b26a7141..6e9665f79 100644 --- a/background_scripts/tab_groups.js +++ b/background_scripts/tab_groups.js @@ -151,9 +151,14 @@ async function moveTabOneStep(tab, direction) { if (group.collapsed) { // Skip over the entire collapsed group: land on the far side of it. - const edgePos = nonPinned.findIndex((t) => t.id === (direction > 0 ? groupTabs.at(-1) : groupTabs[0]).id); + 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) return; // group is flush against the window edge + 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 }); diff --git a/tests/unit_tests/tab_groups_test.js b/tests/unit_tests/tab_groups_test.js index 7c7001497..2781697da 100644 --- a/tests/unit_tests/tab_groups_test.js +++ b/tests/unit_tests/tab_groups_test.js @@ -1,6 +1,7 @@ import "./test_helper.js"; import "../../background_scripts/bg_utils.js"; import { + moveTab, selectNextTabForGroup, selectPreviousTabForGroup, } from "../../background_scripts/tab_groups.js"; @@ -15,6 +16,40 @@ function makeTabs(count, highlighted = []) { })); } +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("selectNextTabForGroup (zz)", () => { let capturedHighlight; From c931adaf95f56a957b132ade9926a7ae73d2f0dc Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Mon, 15 Jun 2026 01:08:27 +0200 Subject: [PATCH 12/12] feat: add count support to zz/ZZ tab selection commands 3zz / 3ZZ now extend or shrink the visual tab selection by N tabs in one keystroke, matching the existing count behavior of >> / <<. Remove noRepeat from both commands so the count prefix reaches the handler, then loop the single-step logic N times via private helper functions. Co-Authored-By: Claude Sonnet 4.6 --- background_scripts/all_commands.js | 2 -- background_scripts/tab_groups.js | 16 +++++++++-- tests/unit_tests/tab_groups_test.js | 44 ++++++++++++++++++++++------- 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/background_scripts/all_commands.js b/background_scripts/all_commands.js index 0308bd5fe..4b37c7681 100644 --- a/background_scripts/all_commands.js +++ b/background_scripts/all_commands.js @@ -564,7 +564,6 @@ const allCommands = [ desc: "Select next tab", group: "tabs", background: true, - noRepeat: true, }, { @@ -572,7 +571,6 @@ const allCommands = [ desc: "Select previous tab", group: "tabs", background: true, - noRepeat: true, }, { diff --git a/background_scripts/tab_groups.js b/background_scripts/tab_groups.js index 6e9665f79..82b6cd5bc 100644 --- a/background_scripts/tab_groups.js +++ b/background_scripts/tab_groups.js @@ -24,7 +24,13 @@ export function nextTabGroup({ tab }) { // - Right side extended? → extend further right. // - Left side extended? → shrink from the left. // - Nothing extended yet? → start extending right. -export async function selectNextTabForGroup({ tab }) { +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; @@ -52,7 +58,13 @@ export async function selectNextTabForGroup({ tab }) { // - Left side extended? → extend further left. // - Right side extended? → shrink from the right. // - Nothing extended yet? → start extending left. -export async function selectPreviousTabForGroup({ tab }) { +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; diff --git a/tests/unit_tests/tab_groups_test.js b/tests/unit_tests/tab_groups_test.js index 2781697da..d30e5a527 100644 --- a/tests/unit_tests/tab_groups_test.js +++ b/tests/unit_tests/tab_groups_test.js @@ -63,37 +63,49 @@ context("selectNextTabForGroup (zz)", () => { 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] }); + 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] }); + 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] }); + 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] }); + 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] }); + 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)", () => { @@ -109,35 +121,47 @@ context("selectPreviousTabForGroup (ZZ)", () => { 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] }); + 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] }); + 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] }); + 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] }); + 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] }); + 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); + }); });