Keep item pane, quick search, and column settings per window

Each window keeps its own item pane section order, pinned and collapsed
sections, quick search mode, and item tree columns, so changing them in
one window doesn't affect others. The settings are saved and restored
with the window and copied to windows opened from it, and the most
recent change is saved globally for windows that start without them.
This commit is contained in:
Dan Stillman 2026-11-05 22:52:32 -05:00
parent 45833ae86c
commit ec8b6beb01
11 changed files with 295 additions and 37 deletions

View file

@ -350,7 +350,9 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider {
let rowChanged;
switch (type) {
case 'search':
rowChanged = collectionTreeRow.setSearch(data, Zotero.Prefs.get('search.quicksearch-mode'));
rowChanged = collectionTreeRow.setSearch(
data, Zotero.Prefs.getForWindow(window, 'search.quicksearch-mode')
);
break;
case 'citation-search':
rowChanged = collectionTreeRow.setSearch(data, 'fields');

View file

@ -200,7 +200,9 @@
this.render();
});
this._prefsObserverID = Zotero.Prefs.registerObserver(`panes.${this.dataset.pane}.open`, this._restoreOpenState.bind(this));
this._unobserveOpenState = Zotero.Prefs.observeForWindow(
window, `panes.${this.dataset.pane}.open`, this._restoreOpenState.bind(this)
);
if (this.hasAttribute('data-l10n-id') && !this.hasAttribute('data-l10n-args')) {
this.setAttribute('data-l10n-args', JSON.stringify({ count: 0 }));
@ -358,12 +360,13 @@
this._contextMenu?.remove();
Zotero.Prefs.unregisterObserver(this._prefsObserverID);
this._unobserveOpenState?.();
this._unobserveOpenState = null;
}
_saveOpenState() {
if (this._disableSavingOpenState || this._skipSaveOpenState) return;
Zotero.Prefs.set(`panes.${this.dataset.pane}.open`, this.open);
Zotero.Prefs.setForWindow(window, `panes.${this.dataset.pane}.open`, this.open);
}
_restoreOpenState() {
@ -372,7 +375,7 @@
return;
}
this._restoringOpenState = true;
this.open = Zotero.Prefs.get(`panes.${this.dataset.pane}.open`) ?? true;
this.open = Zotero.Prefs.getForWindow(window, `panes.${this.dataset.pane}.open`) ?? true;
this._restoringOpenState = false;
}

View file

@ -187,7 +187,9 @@
this._notifierID = Zotero.Notifier.registerObserver(
this, ['item', 'itempane', 'tab'], 'ItemDetails');
this._prefsObserverID = Zotero.Prefs.registerObserver('pinnedPane', this._restorePinnedPane.bind(this));
this._unobservePinnedPane = Zotero.Prefs.observeForWindow(
window, 'pinnedPane', this._restorePinnedPane.bind(this)
);
this._disableScrollHandler = false;
this._pinnedPaneMinScrollHeight = 0;
@ -211,7 +213,8 @@
this._intersectionOb.disconnect();
Zotero.Notifier.unregisterObserver(this._notifierID);
Zotero.Prefs.unregisterObserver(this._prefsObserverID);
this._unobservePinnedPane?.();
this._unobservePinnedPane = null;
}
async render() {
@ -666,19 +669,14 @@
return;
}
let pinnedPane = this.pinnedPane;
if (pinnedPane) {
Zotero.Prefs.set('pinnedPane', pinnedPane);
}
else {
Zotero.Prefs.clear('pinnedPane');
}
Zotero.Prefs.setForWindow(window, 'pinnedPane', pinnedPane || undefined);
}
_restorePinnedPane() {
if (this.tabType !== 'library') {
return;
}
let pinnedPane = Zotero.Prefs.get('pinnedPane') || '';
let pinnedPane = Zotero.Prefs.getForWindow(window, 'pinnedPane') || '';
if (this.pinnedPane !== pinnedPane) {
this.pinnedPane = pinnedPane;
}

View file

@ -87,7 +87,7 @@
_dropIndicator = null;
_prefObserverID = null;
_unobservePaneOrder = null;
get observedAttributes() {
return ["no-context-notes"];
@ -341,7 +341,9 @@
});
this.setAttribute("role", "tablist");
this._prefObserverID = Zotero.Prefs.registerObserver("sidenav.order", this.handlePaneOrderChange);
this._unobservePaneOrder = Zotero.Prefs.observeForWindow(
window, "sidenav.order", this.handlePaneOrderChange
);
this._initialized = true;
}
@ -357,7 +359,8 @@
this._buttonContainer.removeEventListener('dragend', this.handleButtonDragEnd);
this._buttonContainer.removeEventListener('dragleave', this.handleButtonDragLeave);
Zotero.Prefs.unregisterObserver(this._prefObserverID);
this._unobservePaneOrder?.();
this._unobservePaneOrder = null;
this._initialized = false;
}
@ -456,13 +459,13 @@
currentOrder.push(paneID);
}
Zotero.Prefs.set("sidenav.order", currentOrder.join(","));
Zotero.Prefs.setForWindow(window, "sidenav.order", currentOrder.join(","));
}
getPersistedOrder(value = null) {
if (value === null) {
value = Zotero.Prefs.get("sidenav.order");
value = Zotero.Prefs.getForWindow(window, "sidenav.order");
}
if (!value) return this._builtInPanes;
try {
@ -991,10 +994,10 @@
}
};
handlePaneOrderChange = async (value) => {
handlePaneOrderChange = async () => {
// If no container, wait until it's set
if (!this.container) return;
let order = this.getPersistedOrder(value);
let order = this.getPersistedOrder();
let hasChange = false;
for (let i = 0; i < order.length; i++) {
let paneID = order[i];

View file

@ -1131,7 +1131,7 @@
// items list refreshes once
let searchText = state.quickSearch || '';
let tags = new Set(state.tags?.selected || []);
let mode = Zotero.Prefs.get('search.quicksearch-mode');
let mode = Zotero.Prefs.getForWindow(window, 'search.quicksearch-mode');
// A quick search with conditions in it is run as a search, as search() does
let query = Zotero.SearchQuery.getSearch(searchText, { mode });
this._quickSearchIsQuery = !!query;
@ -1551,7 +1551,7 @@
// carries none of its own -- with one, a multi-library selection would
// match only in that library
let query = Zotero.SearchQuery.getSearch(searchVal, {
mode: Zotero.Prefs.get('search.quicksearch-mode')
mode: Zotero.Prefs.getForWindow(window, 'search.quicksearch-mode')
});
if (query) {
this._quickSearchIsQuery = true;

View file

@ -113,7 +113,7 @@
event.stopPropagation();
// If there's text in the field, seed the Advanced Search with it,
// reproducing the current quick search mode as editable conditions.
let mode = Zotero.Prefs.get('search.quicksearch-mode');
let mode = Zotero.Prefs.getForWindow(window, 'search.quicksearch-mode');
// Shift-click opens the Advanced Search in a new window, and
// Cmd/Ctrl-click in a new tab
if (event.shiftKey) {
@ -173,7 +173,7 @@
item.value = mode;
item.addEventListener('command', () => {
Zotero.Prefs.set("search.quicksearch-mode", mode);
Zotero.Prefs.setForWindow(window, "search.quicksearch-mode", mode);
this.updateMode();
if (this.value) {
@ -193,10 +193,10 @@
}
updateMode() {
let mode = Zotero.Prefs.get("search.quicksearch-mode");
let mode = Zotero.Prefs.getForWindow(window, "search.quicksearch-mode");
if (!this._searchModes[mode]) {
Zotero.Prefs.set("search.quicksearch-mode", "fields");
Zotero.Prefs.setForWindow(window, "search.quicksearch-mode", "fields");
mode = 'fields';
}

View file

@ -2504,6 +2504,7 @@ var ItemTree = class ItemTree extends LibraryTree {
}
}
this._columnPrefs = prefs;
window.ZoteroPane?.setItemTreeColumnPrefs?.(this.prefsId, prefs);
// Invalidate the cached _columns array; next _getColumns() call will
// rebuild canonical state from COLUMNS defs + _columnPrefs + viewType.
this._columnsId = null;
@ -2519,8 +2520,16 @@ var ItemTree = class ItemTree extends LibraryTree {
_loadColumnPrefsFromFile = async () => {
if (!this.props.columnPicker) return;
// A main window keeps its own column settings, starting with the ones saved most
// recently in any window
let windowPrefs = window.ZoteroPane?.getItemTreeColumnPrefs?.(this.prefsId);
if (windowPrefs) {
this._columnPrefs = windowPrefs;
return;
}
await _migrateTreePrefsFile();
this._columnPrefs = await TreePrefs.get(this.prefsId);
window.ZoteroPane?.setItemTreeColumnPrefs?.(this.prefsId, this._columnPrefs);
}
/**

View file

@ -410,16 +410,10 @@ Zotero.Prefs = new function () {
Zotero.logError(e);
}
}],
// Main windows keep their own mode (see ZoteroPane.getWindowSetting())
[ "search.quicksearch-mode", function (val) {
var wm = Components.classes["@mozilla.org/appshell/window-mediator;1"]
.getService(Components.interfaces.nsIWindowMediator);
var enumerator = wm.getEnumerator("navigator:browser");
while (enumerator.hasMoreElements()) {
var win = enumerator.getNext();
if (!win.ZoteroPane) continue;
Zotero.updateQuickSearchBox(win.ZoteroPane.document);
}
var enumerator = wm.getEnumerator("zotero:item-selector");
while (enumerator.hasMoreElements()) {
var win = enumerator.getNext();
@ -477,6 +471,64 @@ Zotero.Prefs = new function () {
var _observers = {};
var _observersBySymbol = {};
// Whether a pref is being set as a main window's own setting, which other windows ignore
this.settingForWindow = false;
/**
* Get a setting that each main window keeps for itself (see ZoteroPane.getWindowSetting()),
* or the pref of the same name outside of a main window
*
* @param {Window} win
* @param {String} name
* @return {*}
*/
this.getForWindow = function (win, name) {
if (win.ZoteroPane?.getWindowSetting) {
return win.ZoteroPane.getWindowSetting(name);
}
return this.get(name);
};
/**
* @param {Window} win
* @param {String} name
* @param {*} value - undefined to clear
*/
this.setForWindow = function (win, name, value) {
if (win.ZoteroPane?.setWindowSetting) {
win.ZoteroPane.setWindowSetting(name, value);
}
else if (value === undefined) {
this.clear(name);
}
else {
this.set(name, value);
}
};
/**
* Observe a setting that each main window keeps for itself, or the pref of the same name
* outside of a main window
*
* @param {Window} win
* @param {String} name
* @param {Function} handler
* @return {Function} - Call to stop observing
*/
this.observeForWindow = function (win, name, handler) {
if (win.ZoteroPane?.getWindowSetting) {
let listener = (event) => {
if (event.detail.name == name) {
handler();
}
};
win.addEventListener('zotero-window-setting-change', listener);
return () => win.removeEventListener('zotero-window-setting-change', listener);
}
let symbol = this.registerObserver(name, handler);
return () => this.unregisterObserver(symbol);
};
/**
* @param {String} name - Preference name; if not global, this is on the extensions.zotero branch
* @param {Function} handler

View file

@ -1175,6 +1175,8 @@ const { CommandLineOptions } = ChromeUtils.importESModule("chrome://zotero/conte
* @param {Object} [args.movedTab] - { windowID, tabID } of a tab in another window to move
* into this one as its only tab
* @param {Object} [args.geometry] - { screenX, screenY, width, height } to open the window at
* @param {Object} [args.windowSettings] - The window's own settings, as returned by
* ZoteroPane.getWindowSettings() -- by default, those of the most recent window
* @return {ChromeWindow}
*/
this.openMainWindow = function (args) {
@ -1182,8 +1184,12 @@ const { CommandLineOptions } = ChromeUtils.importESModule("chrome://zotero/conte
var flags = "chrome,all,dialog=no,resizable=yes";
var ww = Components.classes['@mozilla.org/embedcomp/window-watcher;1']
.getService(Components.interfaces.nsIWindowWatcher);
// A new window takes its pane layout from the window it's opened from
Services.wm.getMostRecentWindow("navigator:browser")?.ZoteroPane?.serializePersist();
// A new window takes its pane layout and settings from the window it's opened from
let sourcePane = Services.wm.getMostRecentWindow("navigator:browser")?.ZoteroPane;
sourcePane?.serializePersist();
if (sourcePane?.getWindowSettings && !args?.sessionState && !args?.windowSettings) {
args = Object.assign({}, args, { windowSettings: sourcePane.getWindowSettings() });
}
// Box the args so they reliably cross openWindow()'s nsISupports argument
return ww.openWindow(null, chromeURI, '_blank', flags, args ? { wrappedJSObject: args } : null);
}

View file

@ -244,6 +244,10 @@ var ZoteroPane = new function () {
var _initialReaderTabState = null;
var _initialNoteTabState = null;
var _initialMovedTab = null;
// Settings this window keeps for itself (see getWindowSetting())
var _windowSettings = normalizeWindowSettings();
// Observers of the prefs behind this window's settings, by pref name
var _windowSettingObservers = new Map();
var lastFocusedElement = null;
this.lastKeyPress = null;
// When this window was last activated, for ordering saved window states
@ -606,6 +610,7 @@ var ZoteroPane = new function () {
*/
function takeWindowState() {
let args = getWindowArgs();
_windowSettings = normalizeWindowSettings(args.windowSettings);
// Where a tab torn off from another window was dropped
if (args.geometry) {
restoreWindowGeometry(args.geometry);
@ -629,6 +634,7 @@ var ZoteroPane = new function () {
}
if (args.sessionState) {
_sessionPaneState = args.sessionState;
_windowSettings = normalizeWindowSettings(_sessionPaneState.settings);
restoreWindowGeometry(_sessionPaneState.geometry);
return;
}
@ -638,6 +644,9 @@ var ZoteroPane = new function () {
let states = Zotero.Session.getPendingPaneStates();
_sessionPaneState = states[0] || null;
_otherSessionPaneStates = states.slice(1);
if (_sessionPaneState?.settings) {
_windowSettings = normalizeWindowSettings(_sessionPaneState.settings);
}
// The window opens with the persisted geometry, which is shared by all windows and
// holds the position of whichever closed last, so when there are other windows to
// restore, use its own saved geometry instead
@ -798,6 +807,12 @@ var ZoteroPane = new function () {
Zotero.Keys.windowInit(document);
window.addEventListener('zotero-window-setting-change', (event) => {
if (event.detail.name == 'search.quicksearch-mode') {
Zotero.updateQuickSearchBox(document);
}
});
if (Zotero.restoreFromServer) {
Zotero.restoreFromServer = false;
@ -1057,6 +1072,10 @@ var ZoteroPane = new function () {
ZoteroContextPane.destroy();
Zotero.Plugins.notifyMainWindowUnload(window);
for (let symbol of _windowSettingObservers.values()) {
Zotero.Prefs.unregisterObserver(symbol);
}
_windowSettingObservers.clear();
// A window closed before it restored its saved state shouldn't come back in the next
// session, unless it's the last one
@ -7100,10 +7119,110 @@ var ZoteroPane = new function () {
height: window.outerHeight,
sizemode: document.documentElement.getAttribute('sizemode')
},
tabs: Zotero_Tabs.getState()
tabs: Zotero_Tabs.getState(),
settings: this.getWindowSettings()
};
};
/**
* Get a setting that this window keeps for itself, such as an item pane or quick search
* setting
*
* A window's settings are saved with its state and copied to windows opened from it, so
* changing one in a window doesn't affect other windows. A setting the window hasn't
* changed or been opened with comes from the pref of the same name, which holds the value
* set most recently in any window, and a change to the pref from outside of a main window
* applies to all windows.
*
* @param {String} name - Pref name
* @return {*}
*/
this.getWindowSetting = function (name) {
observeWindowSettingPref(name);
if (!(name in _windowSettings.prefs)) {
_windowSettings.prefs[name] = Zotero.Prefs.get(name);
}
return structuredClone(_windowSettings.prefs[name]);
};
/**
* Set a setting for this window, and save it to the pref of the same name for windows that
* open without one
*
* @param {String} name - Pref name
* @param {*} value - undefined to clear
*/
this.setWindowSetting = function (name, value) {
observeWindowSettingPref(name);
_windowSettings.prefs[name] = structuredClone(value);
Zotero.Prefs.settingForWindow = true;
try {
if (value === undefined) {
Zotero.Prefs.clear(name);
}
else {
Zotero.Prefs.set(name, value);
}
}
finally {
Zotero.Prefs.settingForWindow = false;
}
dispatchWindowSettingChange(name);
};
/**
* Get the column settings this window keeps for an item tree, if it has any
*
* @param {String} id - The tree's prefsId
* @return {Object|null}
*/
this.getItemTreeColumnPrefs = function (id) {
let prefs = _windowSettings.itemTreeColumns[id];
return prefs ? structuredClone(prefs) : null;
};
/**
* @param {String} id - The tree's prefsId
* @param {Object} prefs
*/
this.setItemTreeColumnPrefs = function (id, prefs) {
_windowSettings.itemTreeColumns[id] = structuredClone(prefs);
};
/**
* @return {Object} - The settings this window keeps, for its state
*/
this.getWindowSettings = function () {
return structuredClone(_windowSettings);
};
function normalizeWindowSettings(settings) {
return {
prefs: structuredClone(settings?.prefs || {}),
itemTreeColumns: structuredClone(settings?.itemTreeColumns || {})
};
}
function observeWindowSettingPref(name) {
if (_windowSettingObservers.has(name)) {
return;
}
_windowSettingObservers.set(name, Zotero.Prefs.registerObserver(name, () => {
// A change made as a window's own setting applies only to that window
if (Zotero.Prefs.settingForWindow) {
return;
}
_windowSettings.prefs[name] = Zotero.Prefs.get(name);
dispatchWindowSettingChange(name);
}));
}
function dispatchWindowSettingChange(name) {
window.dispatchEvent(
new CustomEvent('zotero-window-setting-change', { detail: { name } })
);
}
/**
* Unserializes zotero-persist elements from preferences
*

View file

@ -741,6 +741,72 @@ describe("Zotero_Tabs", function() {
}
});
it("should keep item pane and quick search settings to each window", async function () {
await selectCollection(win, collection);
await win.ZoteroPane.selectItem(item.id);
await selectCollection(win2, collection);
await zp2.selectItem(item.id);
let getSection = w => w.ZoteroPane.itemPane._itemDetails
.querySelector('collapsible-section[data-pane="info"]');
let open = getSection(win).open;
let mode = Zotero.Prefs.getForWindow(win, 'search.quicksearch-mode');
let otherMode = mode == 'fields' ? 'titleCreatorYear' : 'fields';
try {
getSection(win2).open = !open;
assert.equal(getSection(win).open, open);
// The pref holds the most recent change, for windows that open without one
assert.equal(Zotero.Prefs.get('panes.info.open'), !open);
zp2.libraryView.getElement('zotero-tb-search').searchModePopup
.querySelector(`menuitem[value="${otherMode}"]`).doCommand();
assert.equal(Zotero.Prefs.getForWindow(win2, 'search.quicksearch-mode'), otherMode);
assert.equal(Zotero.Prefs.getForWindow(win, 'search.quicksearch-mode'), mode);
}
finally {
getSection(win2).open = open;
Zotero.Prefs.setForWindow(win2, 'search.quicksearch-mode', mode);
}
});
it("should reopen a window with its own settings", async function () {
let mode = Zotero.Prefs.getForWindow(win, 'search.quicksearch-mode');
let otherMode = mode == 'fields' ? 'titleCreatorYear' : 'fields';
let isDateAddedHidden = view => view.itemsView._getColumns()
.find(column => column.dataKey == 'dateAdded').hidden;
let dateAddedHidden = isDateAddedHidden(zp.libraryView);
let win3 = await loadZoteroPane();
Zotero.Prefs.setForWindow(win3, 'search.quicksearch-mode', otherMode);
let itemsView = win3.ZoteroPane.itemsView;
itemsView.tree._columns.toggleHidden(
itemsView._getColumns().findIndex(column => column.dataKey == 'dateAdded')
);
let openWindows = Zotero.getAllMainWindows();
win3.close();
await waitForCallback(() => win3.closed, 50, 20);
await tabs.undoClose();
let newWin;
try {
await waitForCallback(
() => (newWin = Zotero.getAllMainWindows().find(x => !openWindows.includes(x)))
&& newWin.ZoteroPane.libraryView?.loaded,
100,
20
);
assert.equal(Zotero.Prefs.getForWindow(newWin, 'search.quicksearch-mode'), otherMode);
assert.equal(isDateAddedHidden(newWin.ZoteroPane.libraryView), !dateAddedHidden);
assert.equal(Zotero.Prefs.getForWindow(win, 'search.quicksearch-mode'), mode);
await zp.itemsView.reloadColumnPrefs();
assert.equal(isDateAddedHidden(zp.libraryView), dateAddedHidden);
}
finally {
newWin?.close();
Zotero.Prefs.set('search.quicksearch-mode', mode);
}
});
it("should open each selected collection in a tab of its own in a new window", async function () {
let otherCollection = await createDataObject('collection');
await zp2.collectionsView.restoreSelection([collection.treeViewID, otherCollection.treeViewID]);