From a98d276d6c8a1c06427f55d934586cfb7e1a225c Mon Sep 17 00:00:00 2001 From: "tushar.muralidharan" Date: Mon, 2 Mar 2026 17:29:00 +1100 Subject: [PATCH 01/17] 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 d26e786a91c0c415a35a72013a8ed1d280c9eed2 Mon Sep 17 00:00:00 2001 From: "tushar.muralidharan" Date: Mon, 4 May 2026 02:47:55 +1000 Subject: [PATCH 02/17] 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 51644483baa8bdcb6001bb7bf5aca7853eb092ab Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Sun, 14 Jun 2026 17:24:09 +0200 Subject: [PATCH 03/17] 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 d96534c53ff6b7b1b6ea5e7b0767440bf7fed1da Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Sun, 14 Jun 2026 18:04:18 +0200 Subject: [PATCH 04/17] 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 24e46e5a81842c59b7dc8f2dde57a4bc9c66791c Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Sun, 14 Jun 2026 18:39:47 +0200 Subject: [PATCH 05/17] 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 592a31270d4c03748bc75381d2a659d84854eca8 Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Sun, 14 Jun 2026 18:43:31 +0200 Subject: [PATCH 06/17] 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 8a23e302dc8496fd29c2a281eaefc67bf236dd3d Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Sun, 14 Jun 2026 18:57:01 +0200 Subject: [PATCH 07/17] 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 cca3b6bc316d853a90962719b34016995b650e96 Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Sun, 14 Jun 2026 19:18:43 +0200 Subject: [PATCH 08/17] 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 61d5deb5af7ba031f0c879ac10a090c969af4b8f Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Sun, 14 Jun 2026 20:11:30 +0200 Subject: [PATCH 09/17] 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 d63e2858e2b8643266c64c00075395254cc5b9c5 Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Sun, 14 Jun 2026 23:55:04 +0200 Subject: [PATCH 10/17] 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 c347772a8182b2cf645d898ca8be522f64318b98 Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Mon, 15 Jun 2026 00:26:04 +0200 Subject: [PATCH 11/17] 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 6e7383998f30263d293c92111d7caf16179f4ded Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Mon, 15 Jun 2026 01:08:27 +0200 Subject: [PATCH 12/17] 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); + }); }); From 9e2bed961eb84fabaec1043cf1aee1fac853fe96 Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Mon, 15 Jun 2026 01:42:12 +0200 Subject: [PATCH 13/17] feat: move tab selection block with >> / << MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When multiple tabs are highlighted (via zz/ZZ), pressing >> or << now moves the entire selection block together as a unit. Moves the adjacent outside tab to the opposite side of the block — one swap per step, count supported. Extract all selection logic (selectNextTabForGroup, selectPreviousTabForGroup, moveTabSelection) into a new tab_selection.js module. tab_groups.js stays focused on group-aware single-tab movement and delegates to moveTabSelection when multiple non-pinned tabs are highlighted. Co-Authored-By: Claude Sonnet 4.6 --- background_scripts/main.js | 5 +- background_scripts/tab_groups.js | 75 +-------- background_scripts/tab_selection.js | 104 ++++++++++++ tests/unit_tests/tab_groups_test.js | 131 +-------------- tests/unit_tests/tab_selection_test.js | 218 +++++++++++++++++++++++++ 5 files changed, 333 insertions(+), 200 deletions(-) create mode 100644 background_scripts/tab_selection.js create mode 100644 tests/unit_tests/tab_selection_test.js diff --git a/background_scripts/main.js b/background_scripts/main.js index d778477c9..5d1b87bf2 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/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, @@ -345,8 +346,8 @@ const BackgroundCommands = { collapseTabGroup: tabGroups.collapseTabGroup, previousTabGroup: tabGroups.previousTabGroup, nextTabGroup: tabGroups.nextTabGroup, - selectNextTabForGroup: tabGroups.selectNextTabForGroup, - selectPreviousTabForGroup: tabGroups.selectPreviousTabForGroup, + selectNextTabForGroup: tabSelection.selectNextTabForGroup, + selectPreviousTabForGroup: tabSelection.selectPreviousTabForGroup, moveTabLeft: tabGroups.moveTab, moveTabRight: tabGroups.moveTab, diff --git a/background_scripts/tab_groups.js b/background_scripts/tab_groups.js index 82b6cd5bc..6e361b13f 100644 --- a/background_scripts/tab_groups.js +++ b/background_scripts/tab_groups.js @@ -1,4 +1,5 @@ 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; @@ -20,76 +21,14 @@ export function nextTabGroup({ tab }) { return goToTabGroup(tab, 1); } -// 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 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 }); diff --git a/background_scripts/tab_selection.js b/background_scripts/tab_selection.js new file mode 100644 index 000000000..7e5c84910 --- /dev/null +++ b/background_scripts/tab_selection.js @@ -0,0 +1,104 @@ +// 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)], + }); +} + +// Move the entire highlighted tab block one slot in the given direction, count times. +// Uses a swap strategy: instead of moving all selected tabs, move the single adjacent +// non-selected tab to the opposite side of the block. +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); + if (direction > 0) { + // Move right: take the tab just right of the block and place it left of the block. + const rightEdge = selected[selected.length - 1]; + const rightPos = nonPinned.findIndex((t) => t.id === rightEdge.id); + const neighbor = nonPinned[rightPos + 1]; + if (!neighbor) return; + await chrome.tabs.move(neighbor.id, { index: selected[0].index }); + } else { + // Move left: take the tab just left of the block and place it right of the block. + const leftEdge = selected[0]; + const leftPos = nonPinned.findIndex((t) => t.id === leftEdge.id); + const neighbor = nonPinned[leftPos - 1]; + if (!neighbor) return; + await chrome.tabs.move(neighbor.id, { index: selected[selected.length - 1].index }); + } +} diff --git a/tests/unit_tests/tab_groups_test.js b/tests/unit_tests/tab_groups_test.js index d30e5a527..a7f5d4bef 100644 --- a/tests/unit_tests/tab_groups_test.js +++ b/tests/unit_tests/tab_groups_test.js @@ -1,20 +1,6 @@ import "./test_helper.js"; import "../../background_scripts/bg_utils.js"; -import { - moveTab, - 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, - })); -} +import { moveTab } from "../../background_scripts/tab_groups.js"; context("moveTab (>> / <<) past a collapsed group at the window edge", () => { let movedTo; @@ -50,118 +36,3 @@ context("moveTab (>> / <<) past a collapsed group at the window edge", () => { }); }); -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); - }); -}); diff --git a/tests/unit_tests/tab_selection_test.js b/tests/unit_tests/tab_selection_test.js new file mode 100644 index 000000000..bdbdeb885 --- /dev/null +++ b/tests/unit_tests/tab_selection_test.js @@ -0,0 +1,218 @@ +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("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); + }); +}); From 4be933088ed2210307535166050095ace5cad37b Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Mon, 15 Jun 2026 02:04:37 +0200 Subject: [PATCH 14/17] fix: moveSelectionOneStep respects tab groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the selection block's neighbor belongs to a tab group, the previous swap approach ripped just one tab out of the group. Now: - Collapsed group → treat it as a single unit; move the entire selection block past the whole group in one step (chrome.tabs.move with an array of selected IDs, targeting groupTabs[0].index). - Open group → add all selected tabs to the group without ripping it apart (chrome.tabs.group). - No group (existing behavior) → simple neighbor swap, unchanged. Co-Authored-By: Claude Sonnet 4.6 --- background_scripts/tab_selection.js | 39 ++++++++-- tests/unit_tests/tab_selection_test.js | 98 ++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 4 deletions(-) diff --git a/background_scripts/tab_selection.js b/background_scripts/tab_selection.js index 7e5c84910..58cdbec4b 100644 --- a/background_scripts/tab_selection.js +++ b/background_scripts/tab_selection.js @@ -86,19 +86,50 @@ async function getSelectedNonPinned(windowId) { 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); + if (direction > 0) { - // Move right: take the tab just right of the block and place it left of the block. + // Move right: find the tab just right of the block. const rightEdge = selected[selected.length - 1]; const rightPos = nonPinned.findIndex((t) => t.id === rightEdge.id); const neighbor = nonPinned[rightPos + 1]; if (!neighbor) return; - await chrome.tabs.move(neighbor.id, { index: selected[0].index }); + + if (neighbor.groupId === -1) { + // Simple swap: move the neighbor to the left of the block. + 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) { + // Skip the whole collapsed group: move the block past it to groupTabs[0].index. + // chrome.tabs.move removes selected tabs first (remaining tabs shift left), then + // inserts them starting at the given index — placing them after the group. ✓ + await chrome.tabs.move(selected.map((t) => t.id), { index: groupTabs[0].index }); + } else { + // Enter the open group without ripping it apart. + await chrome.tabs.group({ tabIds: selected.map((t) => t.id), groupId: neighbor.groupId }); + } + } } else { - // Move left: take the tab just left of the block and place it right of the block. + // Move left: find the tab just left of the block. const leftEdge = selected[0]; const leftPos = nonPinned.findIndex((t) => t.id === leftEdge.id); const neighbor = nonPinned[leftPos - 1]; if (!neighbor) return; - await chrome.tabs.move(neighbor.id, { index: selected[selected.length - 1].index }); + + if (neighbor.groupId === -1) { + // Simple swap: move the neighbor to the right of the block. + 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) { + // Skip the whole collapsed group: move the block past it to groupTabs[0].index. + await chrome.tabs.move(selected.map((t) => t.id), { index: groupTabs[0].index }); + } else { + // Enter the open group without ripping it apart. + await chrome.tabs.group({ tabIds: selected.map((t) => t.id), groupId: neighbor.groupId }); + } + } } } diff --git a/tests/unit_tests/tab_selection_test.js b/tests/unit_tests/tab_selection_test.js index bdbdeb885..f785619cb 100644 --- a/tests/unit_tests/tab_selection_test.js +++ b/tests/unit_tests/tab_selection_test.js @@ -199,6 +199,104 @@ context("moveTabSelection (>> / << with multi-tab selection)", () => { 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 }) }); + await moveTabSelection({ + count: 1, + tab: tabs[2], + registryEntry: { command: "moveTabRight" }, + }); + // Selection block [ids 2,3,4] should move to groupTabs[0].index=4, skipping the group. + assert.equal([2, 3, 4], movedId); + assert.equal(4, movedToIndex); + }); + + 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 }) }); + await moveTabSelection({ + count: 1, + tab: tabs[4], + registryEntry: { command: "moveTabLeft" }, + }); + // Selection block [ids 4,5,6] should move to groupTabs[0].index=1, skipping the group. + assert.equal([4, 5, 6], movedId); + assert.equal(1, movedToIndex); + }); + + 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) => { From 4889a34112e82c1e3f03df45fc8299c248e8dd58 Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Mon, 15 Jun 2026 02:16:26 +0200 Subject: [PATCH 15/17] fix: move group instead of selection when skipping collapsed group The previous approach moved the selected tabs to groupTabs[0].index (inside the group range), which caused Chrome to unfold the collapsed group and insert the selection into it. Fix: move the ENTIRE collapsed group to selected[0].index instead. Chrome slides the group to the other side of the selection block while keeping it collapsed and intact. This is safe for both directions since { index: selected[0].index } always targets a position outside the group. Also fix intra-group movement: when the neighbor shares a groupId with any selected tab, fall through to a simple swap so tabs inside a group can be reordered normally. Co-Authored-By: Claude Sonnet 4.6 --- background_scripts/tab_selection.js | 22 ++++++++++++---------- tests/unit_tests/tab_selection_test.js | 12 ++++++------ 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/background_scripts/tab_selection.js b/background_scripts/tab_selection.js index 58cdbec4b..8eaf6f5e7 100644 --- a/background_scripts/tab_selection.js +++ b/background_scripts/tab_selection.js @@ -94,17 +94,17 @@ async function moveSelectionOneStep(selected, direction) { const neighbor = nonPinned[rightPos + 1]; if (!neighbor) return; - if (neighbor.groupId === -1) { - // Simple swap: move the neighbor to the left of the block. + const sameGroup = neighbor.groupId !== -1 && selected.some((t) => t.groupId === neighbor.groupId); + if (neighbor.groupId === -1 || sameGroup) { + // Ungrouped neighbor or intra-group movement: simple swap. 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) { - // Skip the whole collapsed group: move the block past it to groupTabs[0].index. - // chrome.tabs.move removes selected tabs first (remaining tabs shift left), then - // inserts them starting at the given index — placing them after the group. ✓ - await chrome.tabs.move(selected.map((t) => t.id), { index: groupTabs[0].index }); + // Move the ENTIRE collapsed group to start at selected[0].index. Chrome slides it to + // the left of the selection block while keeping it collapsed and intact. + await chrome.tabs.move(groupTabs.map((t) => t.id), { index: selected[0].index }); } else { // Enter the open group without ripping it apart. await chrome.tabs.group({ tabIds: selected.map((t) => t.id), groupId: neighbor.groupId }); @@ -117,15 +117,17 @@ async function moveSelectionOneStep(selected, direction) { const neighbor = nonPinned[leftPos - 1]; if (!neighbor) return; - if (neighbor.groupId === -1) { - // Simple swap: move the neighbor to the right of the block. + const sameGroup = neighbor.groupId !== -1 && selected.some((t) => t.groupId === neighbor.groupId); + if (neighbor.groupId === -1 || sameGroup) { + // Ungrouped neighbor or intra-group movement: simple swap. 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) { - // Skip the whole collapsed group: move the block past it to groupTabs[0].index. - await chrome.tabs.move(selected.map((t) => t.id), { index: groupTabs[0].index }); + // Move the ENTIRE collapsed group to start at selected[0].index. Chrome slides it to + // the right of the selection block while keeping it collapsed and intact. + await chrome.tabs.move(groupTabs.map((t) => t.id), { index: selected[0].index }); } else { // Enter the open group without ripping it apart. await chrome.tabs.group({ tabIds: selected.map((t) => t.id), groupId: neighbor.groupId }); diff --git a/tests/unit_tests/tab_selection_test.js b/tests/unit_tests/tab_selection_test.js index f785619cb..89735289a 100644 --- a/tests/unit_tests/tab_selection_test.js +++ b/tests/unit_tests/tab_selection_test.js @@ -217,9 +217,9 @@ context("moveTabSelection (>> / << with multi-tab selection)", () => { tab: tabs[2], registryEntry: { command: "moveTabRight" }, }); - // Selection block [ids 2,3,4] should move to groupTabs[0].index=4, skipping the group. - assert.equal([2, 3, 4], movedId); - assert.equal(4, movedToIndex); + // The GROUP [ids 5,6] should be moved to selected[0].index=1, sliding it left of the block. + assert.equal([5, 6], movedId); + assert.equal(1, movedToIndex); }); should("skip over a collapsed group to the left in one step", async () => { @@ -240,9 +240,9 @@ context("moveTabSelection (>> / << with multi-tab selection)", () => { tab: tabs[4], registryEntry: { command: "moveTabLeft" }, }); - // Selection block [ids 4,5,6] should move to groupTabs[0].index=1, skipping the group. - assert.equal([4, 5, 6], movedId); - assert.equal(1, movedToIndex); + // The GROUP [ids 2,3] should be moved to selected[0].index=3, sliding it right of the block. + assert.equal([2, 3], movedId); + assert.equal(3, movedToIndex); }); should("join an open group when moving the block right into it", async () => { From fbfcc4f2a38f55c7b2f405561686b59eacded8e4 Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Mon, 15 Jun 2026 10:29:15 +0200 Subject: [PATCH 16/17] fix: group-aware multi-selection movement for >> / << MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites moveSelectionOneStep to correctly handle all tab group interactions when moving a multi-tab selection with >> / <<. Previous behaviour had three bugs: 1. Skipping a collapsed group moved the GROUP tabs, not the selected tabs. This ripped the group apart: chrome.tabs.move processes an array sequentially, so the first group tab would land between remaining group members, causing Chrome to ungroup it. 2. The sameGroup check used selected.some() instead of checking whether the ENTIRE selection shared one group, so a selection that happened to touch a group member incorrectly triggered the intra-group path. 3. The selection had no special handling when it was already inside a group, so pressing >> / << from inside a group would pull in an outside tab rather than moving within or exiting the group. New behaviour: Selection NOT in a group: - Ungrouped neighbor → swap as before - Collapsed group neighbor → move the SELECTION past the group using sequential individual moves (rightmost-first for >>, leftmost-first for <<). Moving the selection rather than the group keeps the group fully intact; the direction-dependent order ensures each tab always lands just outside the group rather than between two group members. - Open group neighbor → join the selection into the group via chrome.tabs.group, leaving existing group members untouched. Selection entirely inside one group: - Next neighbor is in the same group → swap within the group (move the neighbor to the opposite edge of the selection). - Next neighbor is outside the group → exit the group one step: - Exiting right (>>): ungroup rightmost-first. Chrome places each ungrouped tab just after the last remaining group member; going rightmost-first means every tab is already that last member, so Chrome leaves it in place and order is preserved. - Exiting left (<<): ungroup leftmost-first. Symmetric reasoning — Chrome places each ungrouped tab just before the first remaining group member; going leftmost-first keeps each tab in place. - Non-selected group members are never ungrouped or repositioned. Co-Authored-By: Claude Sonnet 4.6 --- background_scripts/tab_selection.js | 59 +++++++++++++++++--------- tests/unit_tests/tab_selection_test.js | 14 +++--- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/background_scripts/tab_selection.js b/background_scripts/tab_selection.js index 8eaf6f5e7..91ac7ae94 100644 --- a/background_scripts/tab_selection.js +++ b/background_scripts/tab_selection.js @@ -66,9 +66,6 @@ async function selectPreviousTabOnce(tab) { }); } -// Move the entire highlighted tab block one slot in the given direction, count times. -// Uses a swap strategy: instead of moving all selected tabs, move the single adjacent -// non-selected tab to the opposite side of the block. export async function moveTabSelection({ count, tab, registryEntry }) { const direction = registryEntry.command === "moveTabLeft" ? -1 : 1; let selected = await getSelectedNonPinned(tab.windowId); @@ -87,49 +84,73 @@ 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) { - // Move right: find the tab just right of the block. 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; - const sameGroup = neighbor.groupId !== -1 && selected.some((t) => t.groupId === neighbor.groupId); - if (neighbor.groupId === -1 || sameGroup) { - // Ungrouped neighbor or intra-group movement: simple swap. + 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) { - // Move the ENTIRE collapsed group to start at selected[0].index. Chrome slides it to - // the left of the selection block while keeping it collapsed and intact. - await chrome.tabs.move(groupTabs.map((t) => t.id), { index: selected[0].index }); + // 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 { - // Enter the open group without ripping it apart. await chrome.tabs.group({ tabIds: selected.map((t) => t.id), groupId: neighbor.groupId }); } } } else { - // Move left: find the tab just left of the block. 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; - const sameGroup = neighbor.groupId !== -1 && selected.some((t) => t.groupId === neighbor.groupId); - if (neighbor.groupId === -1 || sameGroup) { - // Ungrouped neighbor or intra-group movement: simple swap. + 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) { - // Move the ENTIRE collapsed group to start at selected[0].index. Chrome slides it to - // the right of the selection block while keeping it collapsed and intact. - await chrome.tabs.move(groupTabs.map((t) => t.id), { index: selected[0].index }); + // 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 { - // Enter the open group without ripping it apart. await chrome.tabs.group({ tabIds: selected.map((t) => t.id), groupId: neighbor.groupId }); } } diff --git a/tests/unit_tests/tab_selection_test.js b/tests/unit_tests/tab_selection_test.js index 89735289a..9c42bb3c8 100644 --- a/tests/unit_tests/tab_selection_test.js +++ b/tests/unit_tests/tab_selection_test.js @@ -212,14 +212,15 @@ context("moveTabSelection (>> / << with multi-tab selection)", () => { ]; 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" }, }); - // The GROUP [ids 5,6] should be moved to selected[0].index=1, sliding it left of the block. - assert.equal([5, 6], movedId); - assert.equal(1, movedToIndex); + // 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 () => { @@ -235,14 +236,15 @@ context("moveTabSelection (>> / << with multi-tab selection)", () => { ]; 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" }, }); - // The GROUP [ids 2,3] should be moved to selected[0].index=3, sliding it right of the block. - assert.equal([2, 3], movedId); - assert.equal(3, movedToIndex); + // 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 () => { From 191d0e623956cdfe2a703dc799cbbbed34200831 Mon Sep 17 00:00:00 2001 From: PfoRsten Date: Sun, 5 Jul 2026 14:33:51 +0200 Subject: [PATCH 17/17] feat: add zA to collapse all tab groups, wire za/zA to tab recency history za and zA previously picked the tab to land on after collapsing a group by nearest index, which often dropped you somewhere arbitrary rather than back where you actually came from. - Add bgUtils.getLastActiveTab({ windowId, excludeTabId, isValid }) in background_scripts/bg_utils.js. It walks the existing TabRecency history (bgUtils.tabRecency, already used by ^ / visitPreviousTab) filtered by window and a caller-supplied predicate, returning the most recently active matching tab or null. - Rewire collapseTabGroup (za) to prefer the last-active tab outside the group being collapsed, falling back to the previous index-proximity search, then to opening a new tab, if no candidate is found via recency. - Add collapseAllTabGroups (zA): collapses every currently-expanded group in the window, then jumps to the last-active ungrouped tab using the same recency -> index-proximity -> new-tab fallback chain. Wired up via background_scripts/commands.js (key mapping), all_commands.js (registry entry), and main.js (dispatch). - za/zA activate the target tab directly via chrome.tabs.update rather than reusing selectSpecificTab, since that helper re-expands the target's group if grouped -- which would undo the collapse we just did. - Add unit tests in tests/unit_tests/bg_utils_test.js and tests/unit_tests/tab_groups_test.js covering the recency / index / new-tab fallback chain for both commands. - Update TODO.md: the previously-proposed "tab history" feature turned out to already exist as TabRecency/visitPreviousTab, just not documented under that name; the doc now points at the real implementation instead of describing a redundant per-window stack, and records the za/zA work above as implemented. Co-Authored-By: Claude Sonnet 5 --- TODO.md | 38 +++++++++ background_scripts/all_commands.js | 8 ++ background_scripts/bg_utils.js | 15 ++++ background_scripts/commands.js | 1 + background_scripts/main.js | 1 + background_scripts/tab_groups.js | 41 ++++++++- tests/unit_tests/bg_utils_test.js | 51 +++++++++++- tests/unit_tests/tab_groups_test.js | 125 +++++++++++++++++++++++++++- 8 files changed, 273 insertions(+), 7 deletions(-) create mode 100644 TODO.md 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 4b37c7681..95e81f0f5 100644 --- a/background_scripts/all_commands.js +++ b/background_scripts/all_commands.js @@ -543,6 +543,14 @@ const allCommands = [ 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", 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 a813d118d..48dc5f32a 100644 --- a/background_scripts/commands.js +++ b/background_scripts/commands.js @@ -486,6 +486,7 @@ const defaultKeyMappings = { "zo": "zoomOut", "z0": "zoomReset", "za": "collapseTabGroup", + "zA": "collapseAllTabGroups", "zN": "previousTabGroup", "zn": "nextTabGroup", "zz": "selectNextTabForGroup", diff --git a/background_scripts/main.js b/background_scripts/main.js index 5d1b87bf2..551d80dc9 100644 --- a/background_scripts/main.js +++ b/background_scripts/main.js @@ -344,6 +344,7 @@ const BackgroundCommands = { }, toggleMuteTab, collapseTabGroup: tabGroups.collapseTabGroup, + collapseAllTabGroups: tabGroups.collapseAllTabGroups, previousTabGroup: tabGroups.previousTabGroup, nextTabGroup: tabGroups.nextTabGroup, selectNextTabForGroup: tabSelection.selectNextTabForGroup, diff --git a/background_scripts/tab_groups.js b/background_scripts/tab_groups.js index 6e361b13f..58309a0e2 100644 --- a/background_scripts/tab_groups.js +++ b/background_scripts/tab_groups.js @@ -3,14 +3,47 @@ import { moveTabSelection } from "./tab_selection.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); + 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 }); - chrome.tabGroups.update(tab.groupId, { collapsed: true }); } export function previousTabGroup({ tab }) { 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/tab_groups_test.js b/tests/unit_tests/tab_groups_test.js index a7f5d4bef..e4d8f83ea 100644 --- a/tests/unit_tests/tab_groups_test.js +++ b/tests/unit_tests/tab_groups_test.js @@ -1,6 +1,10 @@ import "./test_helper.js"; -import "../../background_scripts/bg_utils.js"; -import { moveTab } from "../../background_scripts/tab_groups.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; @@ -36,3 +40,120 @@ context("moveTab (>> / <<) past a collapsed group at the window edge", () => { }); }); +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); + }); +});