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 });
|
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 || {};
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user