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/all_commands.js b/background_scripts/all_commands.js
index 96f9e0a38..4b37c7681 100644
--- a/background_scripts/all_commands.js
+++ b/background_scripts/all_commands.js
@@ -365,6 +365,22 @@ const allCommands = [
noRepeat: true,
},
+ {
+ name: "Vomnibar.activateTabGroupSelection",
+ desc: "Search through your tab groups",
+ group: "vomnibar",
+ topFrame: true,
+ noRepeat: true,
+ },
+
+ {
+ name: "Vomnibar.activateGroupAssign",
+ desc: "Assign tab(s) to a group",
+ group: "vomnibar",
+ topFrame: true,
+ noRepeat: true,
+ },
+
{
name: "Vomnibar.activateEditUrl",
desc: "Edit the current URL",
@@ -519,6 +535,44 @@ 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: "selectNextTabForGroup",
+ desc: "Select next tab",
+ group: "tabs",
+ background: true,
+ },
+
+ {
+ name: "selectPreviousTabForGroup",
+ desc: "Select previous tab",
+ group: "tabs",
+ background: true,
+ },
+
{
name: "removeTab",
desc: "Close current tab",
diff --git a/background_scripts/commands.js b/background_scripts/commands.js
index cdd8b42bc..a813d118d 100644
--- a/background_scripts/commands.js
+++ b/background_scripts/commands.js
@@ -453,6 +453,8 @@ const defaultKeyMappings = {
"o": "Vomnibar.activate",
"O": "Vomnibar.activateInNewTab",
"T": "Vomnibar.activateTabSelection",
+ "ZG": "Vomnibar.activateTabGroupSelection",
+ "zg": "Vomnibar.activateGroupAssign",
"b": "Vomnibar.activateBookmarks",
"B": "Vomnibar.activateBookmarksInNewTab",
":": "Vomnibar.activateCommandSelection",
@@ -483,6 +485,11 @@ const defaultKeyMappings = {
"zi": "zoomIn",
"zo": "zoomOut",
"z0": "zoomReset",
+ "za": "collapseTabGroup",
+ "zN": "previousTabGroup",
+ "zn": "nextTabGroup",
+ "zz": "selectNextTabForGroup",
+ "ZZ": "selectPreviousTabForGroup",
// Marks
"m": "Marks.activateCreateMode",
diff --git a/background_scripts/completion/completers.js b/background_scripts/completion/completers.js
index 63e99146f..efa350f56 100644
--- a/background_scripts/completion/completers.js
+++ b/background_scripts/completion/completers.js
@@ -58,6 +58,7 @@ export class Suggestion {
// The generated HTML string for showing this suggestion in the Vomnibar.
html;
searchUrl;
+ groupData;
constructor(options) {
Object.seal(this);
@@ -741,10 +742,12 @@ export class MultiCompleter {
const queryTerms = request.queryTerms;
// The only UX where we support showing results when there are no query terms is via
- // Vomnibar.activateTabSelection, where we show the list of open tabs by recency.
- const isTabCompleter = this.completers.length == 1 &&
- this.completers[0] instanceof TabCompleter;
- if (queryTerms.length == 0 && !isTabCompleter) {
+ // Vomnibar.activateTabSelection, and completers that explicitly opt in via showResultsWithNoQuery.
+ const showsEmptyResults = this.completers.length == 1 && (
+ this.completers[0] instanceof TabCompleter ||
+ this.completers[0].showResultsWithNoQuery
+ );
+ if (queryTerms.length == 0 && !showsEmptyResults) {
return [];
}
diff --git a/background_scripts/completion/group_completer.js b/background_scripts/completion/group_completer.js
new file mode 100644
index 000000000..4a5016fe0
--- /dev/null
+++ b/background_scripts/completion/group_completer.js
@@ -0,0 +1,141 @@
+import * as ranking from "./ranking.js";
+import { Suggestion } from "./completers.js";
+
+const GROUP_COLORS = ["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"];
+
+const COLOR_CSS = {
+ grey: "#5F6368", blue: "#1A73E8", red: "#D93025", yellow: "#F9AB00",
+ green: "#1E8E3E", pink: "#D01884", purple: "#8430CE", cyan: "#007B83", orange: "#E37400",
+};
+
+async function queryCurrentWindow() {
+ const win = await chrome.windows.getLastFocused({ populate: false });
+ const [groups, tabs] = await Promise.all([
+ chrome.tabGroups.query({ windowId: win.id }),
+ chrome.tabs.query({ windowId: win.id }),
+ ]);
+ return { groups, tabs };
+}
+
+function buildFirstTabMap(tabs) {
+ const map = new Map();
+ for (const tab of tabs.sort((a, b) => a.index - b.index)) {
+ if (tab.groupId !== -1 && !map.has(tab.groupId)) {
+ map.set(tab.groupId, tab);
+ }
+ }
+ return map;
+}
+
+function esc(s) {
+ return String(s).replace(/&/g, "&").replace(//g, ">");
+}
+
+function colorSwatch(color) {
+ const bg = COLOR_CSS[color] ?? "#888";
+ return ``;
+}
+
+function groupHtml(group, url) {
+ const source = `${colorSwatch(group.color)}${esc(group.color)}`;
+ const name = esc(group.title || `(${group.color})`);
+ return `
${source}${name}
` +
+ `${esc(url)}
`;
+}
+
+// ZG: navigate to a tab group by name.
+export class TabGroupCompleter {
+ showResultsWithNoQuery = true;
+
+ async filter({ queryTerms }) {
+ if (!chrome.tabGroups) return [];
+ const { groups, tabs } = await queryCurrentWindow();
+ const firstTabByGroup = buildFirstTabMap(tabs);
+ return groups
+ .filter((g) => queryTerms.length === 0 || ranking.matches(queryTerms, g.title ?? "", g.color))
+ .map((group) => {
+ const firstTab = firstTabByGroup.get(group.id);
+ const url = firstTab?.url ?? "";
+ const s = new Suggestion({
+ queryTerms,
+ description: group.color || "tab group",
+ url,
+ title: group.title || `(${group.color})`,
+ tabId: firstTab?.id,
+ deDuplicate: false,
+ });
+ s.html = groupHtml(group, url);
+ s.relevancy = 1;
+ return s;
+ });
+ }
+}
+
+// zg step 1: assign highlighted tabs to an existing group, or start creating a new one.
+export class TabGroupAssignCompleter {
+ showResultsWithNoQuery = true;
+
+ async filter({ queryTerms, query }) {
+ if (!chrome.tabGroups) return [];
+ const { groups, tabs } = await queryCurrentWindow();
+ const firstTabByGroup = buildFirstTabMap(tabs);
+ const existingMatches = groups
+ .filter((g) => queryTerms.length === 0 || ranking.matches(queryTerms, g.title ?? "", g.color))
+ .map((group) => {
+ const firstTab = firstTabByGroup.get(group.id);
+ const url = firstTab?.url ?? "";
+ const s = new Suggestion({
+ queryTerms,
+ description: group.color || "tab group",
+ url,
+ title: group.title || `(${group.color})`,
+ deDuplicate: false,
+ groupData: { action: "addToGroup", groupId: group.id },
+ });
+ s.html = groupHtml(group, url);
+ s.relevancy = 1;
+ return s;
+ });
+
+ const name = query.trim();
+ if (name) {
+ const create = new Suggestion({
+ queryTerms: [],
+ description: "new group",
+ url: "",
+ title: `Create "${name}"`,
+ deDuplicate: false,
+ groupData: { action: "createGroup", name },
+ });
+ create.html = `new group` +
+ `${esc(`Create "${name}"`)}
`;
+ create.relevancy = 0;
+ return [...existingMatches, create];
+ }
+ return existingMatches;
+ }
+}
+
+// zg step 2: pick a color for a new group.
+export class TabGroupColorCompleter {
+ showResultsWithNoQuery = true;
+
+ async filter({ queryTerms }) {
+ return GROUP_COLORS
+ .filter((c) => queryTerms.length === 0 || c.includes(queryTerms[0]?.toLowerCase() ?? ""))
+ .map((color, i) => {
+ const s = new Suggestion({
+ queryTerms,
+ description: "color",
+ url: "",
+ title: color,
+ deDuplicate: false,
+ groupData: { action: "setColor", color },
+ });
+ s.html = `${colorSwatch(color)}` +
+ `${esc(color)}
`;
+ s.relevancy = GROUP_COLORS.length - i;
+ return s;
+ });
+ }
+}
diff --git a/background_scripts/main.js b/background_scripts/main.js
index c4d3870ce..d778477c9 100644
--- a/background_scripts/main.js
+++ b/background_scripts/main.js
@@ -8,9 +8,9 @@ import { Commands } from "../background_scripts/commands.js";
import * as exclusions from "../background_scripts/exclusions.js";
import "../background_scripts/completion/search_engines.js";
import "../background_scripts/completion/search_wrapper.js";
-import "../background_scripts/completion/completers.js";
import "../background_scripts/tab_operations.js";
import * as marks from "../background_scripts/marks.js";
+import * as tabGroups from "./tab_groups.js";
import {
BookmarkCompleter,
@@ -21,6 +21,11 @@ import {
SearchEngineCompleter,
TabCompleter,
} from "./completion/completers.js";
+import {
+ TabGroupCompleter,
+ TabGroupAssignCompleter,
+ TabGroupColorCompleter,
+} from "./completion/group_completer.js";
// NOTE(philc): This file has many superfluous return statements in its functions, as a result of
// converting from coffeescript to es6. Many can be removed, but I didn't take the time to
@@ -49,6 +54,9 @@ const completionSources = {
domains: new DomainCompleter(),
tabs: new TabCompleter(),
searchEngines: new SearchEngineCompleter(),
+ tabGroups: new TabGroupCompleter(),
+ tabGroupAssign: new TabGroupAssignCompleter(),
+ groupColors: new TabGroupColorCompleter(),
};
const completers = {
@@ -62,6 +70,9 @@ const completers = {
bookmarks: new MultiCompleter([completionSources.bookmarks]),
commands: new MultiCompleter([completionSources.commands]),
tabs: new MultiCompleter([completionSources.tabs]),
+ tabGroups: new MultiCompleter([completionSources.tabGroups]),
+ tabGroupAssign: new MultiCompleter([completionSources.tabGroupAssign]),
+ groupColors: new MultiCompleter([completionSources.groupColors]),
};
// A query dictionary for `chrome.tabs.query` that will return only the visible tabs.
@@ -175,6 +186,9 @@ function getTabIndex(tab, tabs) {
//
async function selectSpecificTab(request) {
const tab = await chrome.tabs.get(request.id);
+ if (tab.groupId !== -1 && chrome.tabGroups) {
+ await chrome.tabGroups.update(tab.groupId, { collapsed: false });
+ }
// Focus the tab's window. TODO(philc): Why are we null-checking chrome.windows here?
if (chrome.windows != null) {
await chrome.windows.update(tab.windowId, { focused: true });
@@ -182,21 +196,6 @@ async function selectSpecificTab(request) {
await chrome.tabs.update(request.id, { active: true });
}
-function moveTab({ count, tab, registryEntry }) {
- if (registryEntry.command === "moveTabLeft") {
- count = -count;
- }
- return chrome.tabs.query(visibleTabsQueryArgs, function (tabs) {
- const pinnedCount = (tabs.filter((tab) => tab.pinned)).length;
- const minIndex = tab.pinned ? 0 : pinnedCount;
- const maxIndex = (tab.pinned ? pinnedCount : tabs.length) - 1;
- // The tabs array index of the new position.
- const moveIndex = Math.max(minIndex, Math.min(maxIndex, getTabIndex(tab, tabs) + count));
- return chrome.tabs.move(tab.id, {
- index: tabs[moveIndex].index,
- });
- });
-}
function createRepeatCommand(command) {
return async function (request) {
@@ -343,8 +342,13 @@ const BackgroundCommands = {
});
},
toggleMuteTab,
- moveTabLeft: moveTab,
- moveTabRight: moveTab,
+ collapseTabGroup: tabGroups.collapseTabGroup,
+ previousTabGroup: tabGroups.previousTabGroup,
+ nextTabGroup: tabGroups.nextTabGroup,
+ selectNextTabForGroup: tabGroups.selectNextTabForGroup,
+ selectPreviousTabForGroup: tabGroups.selectPreviousTabForGroup,
+ moveTabLeft: tabGroups.moveTab,
+ moveTabRight: tabGroups.moveTab,
async setZoom({ tabId, registryEntry }) {
const level = registryEntry.options?.["level"] ?? "1";
@@ -470,8 +474,16 @@ async function removeTabsRelative(direction, { count, tab }) {
// Selects a tab before or after the currently selected tab.
// - direction: "next", "previous", "first" or "last".
function selectTab(direction, { count, tab }) {
- chrome.tabs.query(visibleTabsQueryArgs, function (tabs) {
+ chrome.tabs.query(visibleTabsQueryArgs, async function (tabs) {
if (tabs.length > 1) {
+ // Skip tabs in collapsed tab groups.
+ if (chrome.tabGroups) {
+ const groups = await chrome.tabGroups.query({ windowId: tab.windowId, collapsed: true });
+ const collapsedGroupIds = new Set(groups.map((g) => g.id));
+ tabs = tabs.filter((t) => t.id === tab.id || !collapsedGroupIds.has(t.groupId));
+ if (tabs.length <= 1) return;
+ }
+
const toSelect = (() => {
switch (direction) {
case "next":
@@ -651,6 +663,23 @@ const sendRequestHandlers = {
nextFrame: BackgroundCommands.nextFrame,
selectSpecificTab,
+
+ async addTabsToGroup(request) {
+ const win = await chrome.windows.getLastFocused();
+ const tabs = await chrome.tabs.query({ windowId: win.id });
+ const tabIds = tabs.filter((t) => t.highlighted).map((t) => t.id);
+ if (tabIds.length === 0) return;
+ await chrome.tabs.group({ tabIds, groupId: request.groupId });
+ },
+
+ async createTabGroupWithColor(request) {
+ const win = await chrome.windows.getLastFocused();
+ const tabs = await chrome.tabs.query({ windowId: win.id });
+ const tabIds = tabs.filter((t) => t.highlighted).map((t) => t.id);
+ if (tabIds.length === 0) return;
+ const groupId = await chrome.tabs.group({ tabIds });
+ await chrome.tabGroups.update(groupId, { title: request.name, color: request.color });
+ },
createMark: marks.create,
gotoMark: marks.goto,
// Send a message to all frames in the current tab. If request.frameId is provided, then send
diff --git a/background_scripts/tab_groups.js b/background_scripts/tab_groups.js
new file mode 100644
index 000000000..82b6cd5bc
--- /dev/null
+++ b/background_scripts/tab_groups.js
@@ -0,0 +1,182 @@
+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);
+}
+
+// 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;
+ // Pinned tabs and environments without tabGroups API use the original simple logic.
+ if (tab.pinned || !chrome.tabGroups) {
+ const tabs = await chrome.tabs.query({ currentWindow: true });
+ const pinnedCount = tabs.filter((t) => t.pinned).length;
+ const minIndex = tab.pinned ? 0 : pinnedCount;
+ const maxIndex = (tab.pinned ? pinnedCount : tabs.length) - 1;
+ const pos = tabs.findIndex((t) => t.id === tab.id);
+ const moveIndex = Math.max(minIndex, Math.min(maxIndex, pos + direction * count));
+ return chrome.tabs.move(tab.id, { index: tabs[moveIndex].index });
+ }
+ for (let i = 0; i < count; i++) {
+ await moveTabOneStep(tab, direction);
+ tab = await chrome.tabs.get(tab.id);
+ }
+}
+
+// Jump to the next (direction=1) or previous (direction=-1) tab group, wrapping circularly.
+async function goToTabGroup(tab, direction) {
+ if (!chrome.tabGroups) return;
+ const tabs = await chrome.tabs.query({ currentWindow: true });
+ const inDifferentGroup = (t) => t.groupId != -1 && t.groupId != tab.groupId;
+ let target = direction > 0
+ ? tabs.find((t) => t.index > tab.index && inDifferentGroup(t))
+ : tabs.findLast((t) => t.index < tab.index && inDifferentGroup(t));
+ if (!target) {
+ target = direction > 0
+ ? tabs.find((t) => inDifferentGroup(t))
+ : tabs.findLast((t) => inDifferentGroup(t));
+ }
+ if (target) {
+ await chrome.tabGroups.update(target.groupId, { collapsed: false });
+ chrome.tabs.update(target.id, { active: true });
+ }
+}
+
+// Move a non-pinned tab one step in the given direction, respecting tab group boundaries.
+async function moveTabOneStep(tab, direction) {
+ const tabs = await chrome.tabs.query({ currentWindow: true });
+ const nonPinned = tabs.filter((t) => !t.pinned).sort((a, b) => a.index - b.index);
+ const pos = nonPinned.findIndex((t) => t.id === tab.id);
+ if (pos === -1) return;
+
+ // Case 1: tab is inside a group — check if it's at the boundary.
+ if (tab.groupId !== -1) {
+ const groupTabs = nonPinned.filter((t) => t.groupId === tab.groupId);
+ const atEdge = direction > 0
+ ? tab.id === groupTabs.at(-1).id
+ : tab.id === groupTabs[0].id;
+ if (atEdge) {
+ // Exit the group without moving — ungroup keeps the tab at its current index.
+ await chrome.tabs.ungroup([tab.id]);
+ } else {
+ const neighbor = nonPinned[pos + direction];
+ if (neighbor) await chrome.tabs.move(tab.id, { index: neighbor.index });
+ }
+ return;
+ }
+
+ // Case 2: tab is not in any group.
+ const neighbor = nonPinned[pos + direction];
+ if (!neighbor) return; // at window edge
+
+ if (neighbor.groupId === -1) {
+ await chrome.tabs.move(tab.id, { index: neighbor.index });
+ return;
+ }
+
+ // Neighbor belongs to a group.
+ const group = await chrome.tabGroups.get(neighbor.groupId);
+ const groupTabs = nonPinned.filter((t) => t.groupId === neighbor.groupId);
+
+ if (group.collapsed) {
+ // Skip over the entire collapsed group: land on the far side of it.
+ const edgeTab = direction > 0 ? groupTabs.at(-1) : groupTabs[0];
+ const edgePos = nonPinned.findIndex((t) => t.id === edgeTab.id);
+ const afterGroup = nonPinned[edgePos + direction];
+ if (!afterGroup) {
+ // Group is flush against the window edge — land just past it.
+ await chrome.tabs.move(tab.id, { index: edgeTab.index });
+ return;
+ }
+ // afterGroup.index - direction places the tab immediately next to afterGroup
+ // on the group's side, accounting for the index shift caused by the move.
+ await chrome.tabs.move(tab.id, { index: afterGroup.index - direction });
+ } else {
+ // Enter the open group. Chrome keeps the tab at its current adjacent position
+ // and adds it to the group, so no explicit move is needed.
+ await chrome.tabs.group({ tabIds: [tab.id], groupId: neighbor.groupId });
+ }
+}
diff --git a/content_scripts/mode_normal.js b/content_scripts/mode_normal.js
index cab46599c..053ae3f92 100644
--- a/content_scripts/mode_normal.js
+++ b/content_scripts/mode_normal.js
@@ -373,6 +373,8 @@ const NormalModeCommands = {
"Vomnibar.activateCommandSelection": Vomnibar.activateCommandSelection.bind(Vomnibar),
"Vomnibar.activateEditUrl": Vomnibar.activateEditUrl.bind(Vomnibar),
"Vomnibar.activateEditUrlInNewTab": Vomnibar.activateEditUrlInNewTab.bind(Vomnibar),
+ "Vomnibar.activateTabGroupSelection": Vomnibar.activateTabGroupSelection.bind(Vomnibar),
+ "Vomnibar.activateGroupAssign": Vomnibar.activateGroupAssign.bind(Vomnibar),
"Marks.activateCreateMode": Marks.activateCreateMode.bind(Marks),
"Marks.activateGotoMode": Marks.activateGotoMode.bind(Marks),
diff --git a/content_scripts/vomnibar.js b/content_scripts/vomnibar.js
index 162936725..e9b599177 100644
--- a/content_scripts/vomnibar.js
+++ b/content_scripts/vomnibar.js
@@ -40,6 +40,20 @@ const Vomnibar = {
this.open(sourceFrameId, options);
},
+ activateTabGroupSelection(sourceFrameId) {
+ this.open(sourceFrameId, {
+ completer: "tabGroups",
+ selectFirst: true,
+ });
+ },
+
+ activateGroupAssign(sourceFrameId) {
+ this.open(sourceFrameId, {
+ completer: "tabGroupAssign",
+ selectFirst: true,
+ });
+ },
+
activateBookmarksInNewTab(sourceFrameId, registryEntry) {
const options = Object.assign({}, registryEntry.options, {
completer: "bookmarks",
diff --git a/manifest.json b/manifest.json
index 783852cbb..e21f45fa8 100644
--- a/manifest.json
+++ b/manifest.json
@@ -1,6 +1,6 @@
{
"manifest_version": 3,
- "name": "Vimium",
+ "name": "Vimium Dev",
"version": "2.4.2",
"description": "The Hacker's Browser. Vimium provides keyboard shortcuts for navigation and control in the spirit of Vim.",
"icons": {
@@ -20,11 +20,10 @@
"browser_style": false,
"open_in_tab": true
},
- "host_permissions": [
- ""
- ],
+ "host_permissions": [""],
"permissions": [
"tabs",
+ "tabGroups",
"bookmarks",
"history",
"storage",
@@ -41,9 +40,7 @@
],
"content_scripts": [
{
- "matches": [
- ""
- ],
+ "matches": [""],
"js": [
"lib/types.js",
"lib/utils.js",
@@ -67,21 +64,14 @@
"content_scripts/mode_normal.js",
"content_scripts/vimium_frontend.js"
],
- "css": [
- "content_scripts/vimium.css"
- ],
+ "css": ["content_scripts/vimium.css"],
"run_at": "document_start",
"all_frames": true,
"match_about_blank": true
},
{
- "matches": [
- "file:///",
- "file:///*/"
- ],
- "css": [
- "content_scripts/file_urls.css"
- ],
+ "matches": ["file:///", "file:///*/"],
+ "css": ["content_scripts/file_urls.css"],
"run_at": "document_start",
"all_frames": true
}
@@ -118,9 +108,7 @@
// "pages/reload.html",
"_favicon/*"
],
- "matches": [
- ""
- ]
+ "matches": [""]
}
]
}
diff --git a/pages/vomnibar_page.css b/pages/vomnibar_page.css
index 1c5efdf16..c141533b6 100644
--- a/pages/vomnibar_page.css
+++ b/pages/vomnibar_page.css
@@ -83,6 +83,15 @@ ul {
color: #777;
margin-right: 4px;
}
+
+#vomnibar li .group-color-swatch {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+ vertical-align: middle;
+ margin-right: 5px;
+}
#vomnibar li .relevancy {
position: absolute;
right: 0;
diff --git a/pages/vomnibar_page.js b/pages/vomnibar_page.js
index f838fa130..d8ba7dcbe 100644
--- a/pages/vomnibar_page.js
+++ b/pages/vomnibar_page.js
@@ -59,6 +59,7 @@ class VomnibarUI {
this.onInput = this.onInput.bind(this);
this.update = this.update.bind(this);
this.onHiddenCallback = null;
+ this.pendingGroupName = null;
this.initDom();
// The user's custom search engine, if they have prefixed their query with the keyword for one
// of their search engines.
@@ -299,6 +300,23 @@ class VomnibarUI {
return;
}
+ // Smart Enter for tabGroupAssign: exact match assigns, anything else creates a new group.
+ if (this.completerName === "tabGroupAssign") {
+ const exactMatch = this.completions.find(
+ (c) => c.groupData?.action === "addToGroup" &&
+ c.title.split(" · ")[0].toLowerCase() === query.toLowerCase(),
+ );
+ if (exactMatch) {
+ this.hide(() => this.openCompletion(exactMatch, false));
+ } else {
+ this.pendingGroupName = query;
+ this.initialSelectionValue = 0;
+ this.setCompleterName("groupColors");
+ await this.update();
+ }
+ return;
+ }
+
// with no selection on a completer other than "omni" is a no-op.
if (this.completerName != "omni") return;
@@ -335,6 +353,12 @@ class VomnibarUI {
count: this.prefixCount,
});
});
+ } else if (completion.groupData?.action === "createGroup") {
+ // Stay open — switch to color picker in-place for step 2.
+ this.pendingGroupName = completion.groupData.name;
+ this.initialSelectionValue = 0;
+ this.setCompleterName("groupColors");
+ await this.update();
} else {
this.hide(() => this.openCompletion(completion, openInNewTab));
}
@@ -437,7 +461,15 @@ class VomnibarUI {
}
openCompletion(completion, openInNewTab) {
- if (completion.description == "tab") {
+ if (completion.groupData?.action === "addToGroup") {
+ chrome.runtime.sendMessage({ handler: "addTabsToGroup", groupId: completion.groupData.groupId });
+ } else if (completion.groupData?.action === "setColor") {
+ chrome.runtime.sendMessage({
+ handler: "createTabGroupWithColor",
+ name: this.pendingGroupName,
+ color: completion.groupData.color,
+ });
+ } else if (completion.tabId != null) {
chrome.runtime.sendMessage({ handler: "selectSpecificTab", id: completion.tabId });
} else {
this.launchUrl(completion.url, openInNewTab);
diff --git a/tests/unit_tests/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..d30e5a527
--- /dev/null
+++ b/tests/unit_tests/tab_groups_test.js
@@ -0,0 +1,167 @@
+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,
+ }));
+}
+
+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;
+
+ 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);
+ });
+});