fix moving of browser groups and allow to store groups into session

This commit is contained in:
2026-04-09 23:38:00 +02:00
parent eaa86e3f3d
commit c5a4218da0
3 changed files with 226 additions and 21 deletions
+114 -20
View File
@@ -444,22 +444,31 @@ async function groupMove({ group, forward, backward }) {
const allTabs = await chrome.tabs.query({ windowId: groupInfo.windowId }); const allTabs = await chrome.tabs.query({ windowId: groupInfo.windowId });
allTabs.sort((a, b) => a.index - b.index); allTabs.sort((a, b) => a.index - b.index);
const groupTabs = allTabs.filter(t => t.groupId === groupId); const blocks = buildTabBlocks(allTabs);
if (!groupTabs.length) throw new Error(`No tabs found in group '${group}'`); 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 currentBlock = blocks[currentIdx];
const lastIdx = groupTabs[groupTabs.length - 1].index; const currentLength = currentBlock.tabIds.length;
if (forward) { if (forward) {
const tabAfter = allTabs.find(t => t.index > lastIdx && t.groupId !== groupId); const nextBlock = blocks[currentIdx + 1];
if (!tabAfter) return { groupId, moved: false }; if (!nextBlock) return { groupId, moved: false };
await chrome.tabs.move(groupTabs.map(t => t.id), { index: tabAfter.index + 1 }); const targetIndex =
nextBlock.groupId === null
? currentBlock.startIndex + 1
: nextBlock.endIndex - currentLength + 1;
await chrome.tabGroups.move(groupId, { index: targetIndex });
} else if (backward) { } else if (backward) {
const tabsBefore = allTabs.filter(t => t.index < firstIdx && t.groupId !== groupId); const previousBlock = blocks[currentIdx - 1];
const tabBefore = tabsBefore[tabsBefore.length - 1]; if (!previousBlock) return { groupId, moved: false };
if (!tabBefore) return { groupId, moved: false }; const targetIndex =
await chrome.tabs.move(groupTabs.map(t => t.id), { index: tabBefore.index }); previousBlock.groupId === null
? currentBlock.startIndex - 1
: previousBlock.startIndex;
await chrome.tabGroups.move(groupId, { index: targetIndex });
} }
return { groupId, moved: true }; return { groupId, moved: true };
} }
@@ -597,28 +606,74 @@ function contentDispatch(funcName, args) {
async function sessionSave({ name }) { async function sessionSave({ name }) {
const tabs = await chrome.tabs.query({}); 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(); 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 }); await chrome.storage.local.set({ sessions });
return { name, tabs: urls.length }; return { name, tabs: sessionTabs.length };
} }
async function sessionLoad({ name }) { async function sessionLoad({ name }) {
const sessions = await getSessions(); const sessions = await getSessions();
const session = sessions[name]; const session = sessions[name];
if (!session) throw new Error(`Session '${name}' not found`); 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() { async function sessionList() {
const sessions = await getSessions(); const sessions = await getSessions();
return Object.entries(sessions).map(([name, s]) => ({ return Object.entries(sessions).map(([name, s]) => ({
name, name,
tabs: (s.urls || []).length, tabs: getSessionTabs(s).length,
savedAt: s.savedAt || null, savedAt: s.savedAt || null,
})); }));
} }
@@ -633,8 +688,8 @@ async function sessionRemove({ name }) {
async function sessionDiff({ nameA, nameB }) { async function sessionDiff({ nameA, nameB }) {
const sessions = await getSessions(); const sessions = await getSessions();
const a = new Set((sessions[nameA]?.urls) || []); const a = new Set(getSessionTabs(sessions[nameA]).map(tab => tab.url));
const b = new Set((sessions[nameB]?.urls) || []); const b = new Set(getSessionTabs(sessions[nameB]).map(tab => tab.url));
return { return {
added: [...b].filter(u => !a.has(u)), added: [...b].filter(u => !a.has(u)),
removed: [...a].filter(u => !b.has(u)), removed: [...a].filter(u => !b.has(u)),
@@ -692,6 +747,45 @@ async function resolveGroupId(nameOrId) {
return match.id; 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() { async function getAliases() {
const { windowAliases } = await chrome.storage.local.get("windowAliases"); const { windowAliases } = await chrome.storage.local.get("windowAliases");
return windowAliases || {}; return windowAliases || {};
+58
View File
@@ -131,3 +131,61 @@ def test_group_tabs_returns_list(browser):
browser("tabs.close", {"tabId": t["id"]}) browser("tabs.close", {"tabId": t["id"]})
except Exception: except Exception:
pass 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
+54 -1
View File
@@ -38,9 +38,62 @@ def test_session_remove(browser):
assert SESSION_NAME + "_remove" not in names 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): def teardown_module(module):
"""Clean up test sessions after all tests run.""" """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: try:
send_command("session.remove", {"name": name}) send_command("session.remove", {"name": name})
except Exception: except Exception: