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