diff --git a/extension/background.js b/extension/background.js index 65fa179..0313634 100644 --- a/extension/background.js +++ b/extension/background.js @@ -444,22 +444,31 @@ async function groupMove({ group, forward, backward }) { const allTabs = await chrome.tabs.query({ windowId: groupInfo.windowId }); allTabs.sort((a, b) => a.index - b.index); - const groupTabs = allTabs.filter(t => t.groupId === groupId); - if (!groupTabs.length) throw new Error(`No tabs found in group '${group}'`); + const blocks = buildTabBlocks(allTabs); + const currentIdx = blocks.findIndex(block => block.groupId === groupId); + if (currentIdx === -1) throw new Error(`No tabs found in group '${group}'`); - const firstIdx = groupTabs[0].index; - const lastIdx = groupTabs[groupTabs.length - 1].index; + const currentBlock = blocks[currentIdx]; + const currentLength = currentBlock.tabIds.length; if (forward) { - const tabAfter = allTabs.find(t => t.index > lastIdx && t.groupId !== groupId); - if (!tabAfter) return { groupId, moved: false }; - await chrome.tabs.move(groupTabs.map(t => t.id), { index: tabAfter.index + 1 }); + const nextBlock = blocks[currentIdx + 1]; + if (!nextBlock) return { groupId, moved: false }; + const targetIndex = + nextBlock.groupId === null + ? currentBlock.startIndex + 1 + : nextBlock.endIndex - currentLength + 1; + await chrome.tabGroups.move(groupId, { index: targetIndex }); } else if (backward) { - const tabsBefore = allTabs.filter(t => t.index < firstIdx && t.groupId !== groupId); - const tabBefore = tabsBefore[tabsBefore.length - 1]; - if (!tabBefore) return { groupId, moved: false }; - await chrome.tabs.move(groupTabs.map(t => t.id), { index: tabBefore.index }); + const previousBlock = blocks[currentIdx - 1]; + if (!previousBlock) return { groupId, moved: false }; + const targetIndex = + previousBlock.groupId === null + ? currentBlock.startIndex - 1 + : previousBlock.startIndex; + await chrome.tabGroups.move(groupId, { index: targetIndex }); } + return { groupId, moved: true }; } @@ -597,28 +606,74 @@ function contentDispatch(funcName, args) { async function sessionSave({ name }) { const tabs = await chrome.tabs.query({}); - const urls = tabs.map(t => t.url).filter(Boolean); + const groups = await chrome.tabGroups.query({}); + const groupById = new Map(groups.map(group => [group.id, group])); + const sessionTabs = tabs + .filter(tab => Boolean(tab.url)) + .sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index)) + .map(tab => { + const entry = { url: tab.url }; + if (tab.groupId >= 0) { + const group = groupById.get(tab.groupId); + entry.group = { + key: `${tab.windowId}:${tab.groupId}`, + title: group?.title || "", + color: normalizeGroupColor(group?.color), + collapsed: Boolean(group?.collapsed), + }; + } + return entry; + }); const sessions = await getSessions(); - sessions[name] = { urls, savedAt: Date.now() }; + sessions[name] = { + tabs: sessionTabs, + urls: sessionTabs.map(tab => tab.url), + savedAt: Date.now(), + }; await chrome.storage.local.set({ sessions }); - return { name, tabs: urls.length }; + return { name, tabs: sessionTabs.length }; } async function sessionLoad({ name }) { const sessions = await getSessions(); const session = sessions[name]; if (!session) throw new Error(`Session '${name}' not found`); - for (const url of session.urls) { - await chrome.tabs.create({ url, active: false }); + + const sessionTabs = getSessionTabs(session); + const createdTabs = []; + + for (const entry of sessionTabs) { + const tab = await chrome.tabs.create({ url: entry.url, active: false }); + createdTabs.push({ tabId: tab.id, entry }); } - return { name, tabs: session.urls.length }; + + const groups = new Map(); + for (const { tabId, entry } of createdTabs) { + if (!entry.group) continue; + const key = entry.group.key || `${entry.group.title || "group"}:${groups.size}`; + if (!groups.has(key)) { + groups.set(key, { meta: entry.group, tabIds: [] }); + } + groups.get(key).tabIds.push(tabId); + } + + for (const { meta, tabIds } of groups.values()) { + const restoredGroupId = await chrome.tabs.group({ tabIds }); + await chrome.tabGroups.update(restoredGroupId, { + title: meta.title || "", + color: normalizeGroupColor(meta.color), + collapsed: Boolean(meta.collapsed), + }); + } + + return { name, tabs: sessionTabs.length }; } async function sessionList() { const sessions = await getSessions(); return Object.entries(sessions).map(([name, s]) => ({ name, - tabs: (s.urls || []).length, + tabs: getSessionTabs(s).length, savedAt: s.savedAt || null, })); } @@ -633,8 +688,8 @@ async function sessionRemove({ name }) { async function sessionDiff({ nameA, nameB }) { const sessions = await getSessions(); - const a = new Set((sessions[nameA]?.urls) || []); - const b = new Set((sessions[nameB]?.urls) || []); + const a = new Set(getSessionTabs(sessions[nameA]).map(tab => tab.url)); + const b = new Set(getSessionTabs(sessions[nameB]).map(tab => tab.url)); return { added: [...b].filter(u => !a.has(u)), removed: [...a].filter(u => !b.has(u)), @@ -692,6 +747,45 @@ async function resolveGroupId(nameOrId) { return match.id; } +function buildTabBlocks(tabs) { + const blocks = []; + for (const tab of tabs) { + const normalizedGroupId = tab.groupId >= 0 ? tab.groupId : null; + const lastBlock = blocks[blocks.length - 1]; + if (lastBlock?.groupId === normalizedGroupId) { + lastBlock.tabIds.push(tab.id); + lastBlock.endIndex = tab.index; + continue; + } + + blocks.push({ + groupId: normalizedGroupId, + startIndex: tab.index, + endIndex: tab.index, + tabIds: [tab.id], + }); + } + return blocks; +} + +function getSessionTabs(session) { + if (!session) return []; + if (Array.isArray(session.tabs)) { + return session.tabs + .map(entry => typeof entry === "string" ? { url: entry } : entry) + .filter(entry => entry?.url); + } + if (Array.isArray(session.urls)) { + return session.urls.filter(Boolean).map(url => ({ url })); + } + return []; +} + +function normalizeGroupColor(color) { + const allowed = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]); + return allowed.has(color) ? color : "grey"; +} + async function getAliases() { const { windowAliases } = await chrome.storage.local.get("windowAliases"); return windowAliases || {}; diff --git a/tests/test_groups.py b/tests/test_groups.py index 7a3acd6..eba25f1 100644 --- a/tests/test_groups.py +++ b/tests/test_groups.py @@ -131,3 +131,61 @@ def test_group_tabs_returns_list(browser): browser("tabs.close", {"tabId": t["id"]}) except Exception: pass + + +def test_group_move_forward_swaps_adjacent_group_blocks(browser): + group_a = browser("group.open", {"name": "__move_group_a__"}) + group_b = browser("group.open", {"name": "__move_group_b__"}) + gid_a = group_a["id"] + gid_b = group_b["id"] + + created_tab_ids = set() + + try: + extra_a = browser("group.add_tab", {"group": str(gid_a), "url": "https://example.com/?group=a"}) + extra_b = browser("group.add_tab", {"group": str(gid_b), "url": "https://example.com/?group=b"}) + created_tab_ids.update([extra_a["tabId"], extra_b["tabId"]]) + + tabs_before = browser("tabs.list") + block_order_before = [ + t["groupId"] for t in tabs_before + if t.get("groupId") in {gid_a, gid_b} + ] + assert block_order_before + assert block_order_before[0] == gid_a + + browser("group.move", {"group": str(gid_a), "forward": True}) + + tabs_after = browser("tabs.list") + grouped_after = [ + t for t in tabs_after + if t.get("groupId") in {gid_a, gid_b} + ] + + assert grouped_after + assert grouped_after[0]["groupId"] == gid_b + assert grouped_after[-1]["groupId"] == gid_a + + group_ids_after = {t["id"]: t["groupId"] for t in grouped_after} + for t in browser("group.tabs", {"groupId": gid_a}): + assert group_ids_after[t["id"]] == gid_a + for t in browser("group.tabs", {"groupId": gid_b}): + assert group_ids_after[t["id"]] == gid_b + finally: + for gid in (gid_a, gid_b): + try: + group_tabs = browser("group.tabs", {"groupId": gid}) + except Exception: + group_tabs = [] + try: + browser("group.close", {"groupId": gid}) + except Exception: + pass + for t in group_tabs: + created_tab_ids.add(t["id"]) + + for tab_id in created_tab_ids: + try: + browser("tabs.close", {"tabId": tab_id}) + except Exception: + pass diff --git a/tests/test_session.py b/tests/test_session.py index 88cc243..d9528a0 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -38,9 +38,62 @@ def test_session_remove(browser): assert SESSION_NAME + "_remove" not in names +def test_session_load_restores_group_metadata(browser): + session_name = SESSION_NAME + "_groups" + group_name = "__session_group_restore__" + group_url = "https://example.com/?session-group=1" + + group = browser("group.open", {"name": group_name}) + gid = group["id"] + created_ids = set() + + try: + added = browser("group.add_tab", {"group": str(gid), "url": group_url}) + created_ids.add(added["tabId"]) + + browser("session.save", {"name": session_name}) + + original_tabs = browser("group.tabs", {"groupId": gid}) + created_ids.update(t["id"] for t in original_tabs) + browser("group.close", {"groupId": gid}) + for tab_id in list(created_ids): + try: + browser("tabs.close", {"tabId": tab_id}) + except Exception: + pass + + baseline_ids = {t["id"] for t in browser("tabs.list")} + browser("session.load", {"name": session_name}) + + tabs_after = browser("tabs.list") + loaded_ids = {t["id"] for t in tabs_after} - baseline_ids + + restored_groups = browser("group.query", {"search": group_name}) + assert restored_groups, "Expected saved group to be restored" + + restored = next((g for g in restored_groups if g.get("title") == group_name), None) + assert restored is not None + + restored_tabs = browser("group.tabs", {"groupId": restored["id"]}) + restored_urls = {t["url"] for t in restored_tabs} + assert group_url in restored_urls + + browser("group.close", {"groupId": restored["id"]}) + for tab_id in loaded_ids: + try: + browser("tabs.close", {"tabId": tab_id}) + except Exception: + pass + finally: + try: + browser("session.remove", {"name": session_name}) + except Exception: + pass + + def teardown_module(module): """Clean up test sessions after all tests run.""" - for name in [SESSION_NAME, SESSION_NAME + "_a", SESSION_NAME + "_b"]: + for name in [SESSION_NAME, SESSION_NAME + "_a", SESSION_NAME + "_b", SESSION_NAME + "_groups"]: try: send_command("session.remove", {"name": name}) except Exception: