feat: Store and restore split view layout tree, p=#11862

* feat: Store and restore split view layout tree

* fix: add backwards compatibility

* fix: Formatting

* fix: Don't activate split view when restoring session

* feat: Add id to unsynced tabs

* fix: Formatting

* refactor: Streamline unsynced window event handling

* feat: Make sure to duplicate pinend tabs when splitting, b=no-bug, c=split-view

---------

Co-authored-by: mr. m <mr.m@tuta.com>
This commit is contained in:
Andrey Bochkarev
2026-01-15 13:14:52 +03:00
committed by GitHub
parent 30ed19d540
commit 40986668de
2 changed files with 110 additions and 28 deletions

View File

@@ -22,8 +22,8 @@ XPCOMUtils.defineLazyPreferenceGetter(lazy, "gShouldLog", "zen.window-sync.log",
const OBSERVING = ["browser-window-before-show"];
const INSTANT_EVENTS = ["SSWindowClosing"];
const UNSYNCED_WINDOW_EVENTS = ["TabOpen"];
const EVENTS = [
"TabOpen",
"TabClose",
"ZenTabIconChanged",
@@ -47,6 +47,7 @@ const EVENTS = [
"focus",
...INSTANT_EVENTS,
...UNSYNCED_WINDOW_EVENTS,
];
// Flags acting as an enum for sync types.
@@ -163,6 +164,9 @@ class nsZenWindowSync {
) {
this.log("Not syncing new window due to unsynced argument or existing synced windows");
aWindow.document.documentElement.setAttribute("zen-unsynced-window", "true");
for (let eventName of UNSYNCED_WINDOW_EVENTS) {
aWindow.addEventListener(eventName, this, true);
}
return;
}
aWindow.gZenWindowSync = this;
@@ -254,7 +258,7 @@ class nsZenWindowSync {
const window = aEvent.currentTarget.ownerGlobal;
if (
!window.gZenStartup.isReady ||
window.gZenWorkspaces?.privateWindowOrDisabled ||
!window.gZenWorkspaces?.shouldHaveWorkspaces ||
window._zenClosingWindow
) {
return;
@@ -944,12 +948,17 @@ class nsZenWindowSync {
on_TabOpen(aEvent) {
const tab = aEvent.target;
const window = tab.ownerGlobal;
const isUnsyncedWindow = window.document.documentElement.hasAttribute("zen-unsynced-window");
if (tab.id) {
// This tab was opened as part of a sync operation.
return;
}
tab._zenContentsVisible = true;
tab.id = this.#newTabSyncId;
if (isUnsyncedWindow) {
return;
}
this.#runOnAllWindows(window, (win) => {
const newTab = win.gBrowser.addTrustedTab("about:blank", {
animate: true,

View File

@@ -250,6 +250,7 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature {
return element;
}
// eslint-disable-next-line complexity
onBrowserDragOverToSplit(event) {
if (this.fakeBrowser) {
return;
@@ -275,7 +276,8 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature {
!this._lastOpenedTab ||
(this._lastOpenedTab.getAttribute("zen-workspace-id") !==
draggedTab.getAttribute("zen-workspace-id") &&
!this._lastOpenedTab.hasAttribute("zen-essential"))
!this._lastOpenedTab.hasAttribute("zen-essential") &&
!draggedTab.hasAttribute("zen-essential"))
) {
this._lastOpenedTab = gBrowser.selectedTab;
}
@@ -1229,7 +1231,19 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature {
gridType ??= "grid";
// Add tabs to the split view group
let splitGroup = this._getSplitViewGroup(tabs);
const groupId = splitGroup?.id;
if (splitGroup) {
for (const tab of tabs) {
if (!tab.group || tab.group !== splitGroup) {
gBrowser.moveTabToExistingGroup(tab, splitGroup);
}
}
}
const splitData = {
groupId,
tabs,
gridType,
layoutTree: this.calculateLayoutTree(tabs, gridType),
@@ -1239,20 +1253,10 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature {
window.gBrowser.selectedTab = tabs[tabIndexToUse] ?? tabs[0];
}
// Add tabs to the split view group
let splitGroup = this._getSplitViewGroup(tabs);
if (splitGroup) {
for (const tab of tabs) {
if (!tab.group || tab.group !== splitGroup) {
gBrowser.moveTabToExistingGroup(tab, splitGroup);
}
}
if (!this._sessionRestoring) {
this.activateSplitView(splitData);
}
if (this._sessionRestoring) {
return;
}
this.activateSplitView(splitData);
this.#dispatchItemEvent("ZenSplitViewTabsSplit", splitGroup);
// eslint-disable-next-line consistent-return
return splitData;
@@ -1827,7 +1831,7 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature {
return false;
}
const droppedOnTab = gZenGlanceManager.getTabOrGlanceParent(gBrowser.getTabForBrowser(browser));
let droppedOnTab = gZenGlanceManager.getTabOrGlanceParent(gBrowser.getTabForBrowser(browser));
if (droppedOnTab === this._draggingTab) {
this.createEmptySplit(dropSide == "right");
return true;
@@ -1846,6 +1850,25 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature {
// const hoverSide = this.calculateHoverSide(event.clientX, event.clientY, browserRect);
const hoverSide = dropSide;
// We are here if none of the tabs have been previously split
// If there's ANY pinned tab on the list, we clone the pinned tab
// state to all the tabs
let tempTabs = [draggedTab, droppedOnTab];
const allArePinned = tempTabs.every((tab) => tab.pinned);
const thereIsOnePinned = tempTabs.some((tab) => tab.pinned);
const thereIsOneEssential = tempTabs.some((tab) => tab.hasAttribute("zen-essential"));
if (thereIsOneEssential || (thereIsOnePinned && !allArePinned)) {
for (let i = 0; i < tempTabs.length; i++) {
const tab = tempTabs[i];
if (tab.pinned) {
tempTabs[i] = gBrowser.duplicateTab(tab, true);
}
}
}
[draggedTab, droppedOnTab] = tempTabs;
if (droppedOnTab.splitView) {
// Add to existing split view
const groupIndex = this._data.findIndex((group) => group.tabs.includes(droppedOnTab));
@@ -1908,6 +1931,8 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature {
dropSide == "left" ? 0 : 1
);
}
gBrowser.selectedTab = draggedTab;
}
if (this._finishAllAnimatingPromise) {
this._finishAllAnimatingPromise.then(() => {
@@ -2007,31 +2032,79 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature {
}
storeDataForSessionStore() {
// We cant store any tab or browser elements in the session store
// so we need to store the tab indexes and group indexes
const data = this._data.map((group) => {
const serializeNode = (node) => {
if (node.tab) {
return {
type: "leaf",
tabId: node.tab.id,
sizeInParent: node.sizeInParent,
};
}
return {
groupId: group.tabs[0].group?.id,
type: "splitter",
direction: node.direction,
sizeInParent: node.sizeInParent,
children: node._children.map((child) => serializeNode(child)),
};
};
return this._data.map((group) => {
const serializedTree = serializeNode(group.layoutTree);
return {
groupId: group.groupId,
gridType: group.gridType,
layoutTree: serializedTree,
tabs: group.tabs.map((tab) => tab.id),
};
});
return data;
}
restoreDataFromSessionStore(data) {
if (!data) {
return;
}
this._sessionRestoring = true;
// We can just get the tab group with document.getElementById(group.groupId)
// and add the tabs to it
for (const group of data) {
const groupElement = document.getElementById(group.groupId);
if (groupElement) {
const tabs = groupElement.tabs;
this.splitTabs(tabs, group.gridType);
for (const groupData of data) {
const group = document.getElementById(groupData.groupId);
// Backwards compatibility
if (!groupData?.layoutTree) {
this.splitTabs(group.tabs, group.gridType);
delete this._sessionRestoring;
return;
}
const deserializeNode = (nodeData) => {
if (nodeData.type === "leaf") {
const tab = document.getElementById(nodeData.tabId);
if (!tab) {
return null;
}
return new nsSplitLeafNode(tab, nodeData.sizeInParent);
}
const splitter = new nsSplitNode(nodeData.direction, nodeData.sizeInParent);
splitter._children = [];
for (const childData of nodeData.children) {
const childNode = deserializeNode(childData);
if (childNode) {
childNode.parent = splitter;
splitter._children.push(childNode);
}
}
return splitter;
};
const layout = deserializeNode(groupData.layoutTree);
const splitData = this.splitTabs(group.tabs, groupData.gridType, -1);
splitData.layoutTree = layout;
}
delete this._sessionRestoring;
}