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); + }); +});