fix moving of browser groups and allow to store groups into session
This commit is contained in:
+114
-20
@@ -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 || {};
|
||||
|
||||
@@ -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
|
||||
|
||||
+54
-1
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user