diff --git a/chrome/content/zotero/collectionViewItemTree.jsx b/chrome/content/zotero/collectionViewItemTree.jsx index d8595b0494..35599f474a 100644 --- a/chrome/content/zotero/collectionViewItemTree.jsx +++ b/chrome/content/zotero/collectionViewItemTree.jsx @@ -171,6 +171,62 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider { return this._bestMatchBarFractions || new Map(); } + /** + * Embedding-index coverage while a best-match search is active, for the + * banner above the items list. Null when there's no active best-match + * search or every eligible item is indexed. + * + * @returns {Object|null} - { type: 'indexing'|'paused', indexed, total } + */ + getBestMatchIndexState() { + return this._bestMatchIndexState || null; + } + + /** + * Compute the current index coverage across the selected rows' libraries. + * Never throws -- the banner is informational and shouldn't break a + * refresh. + * + * @return {Promise} + */ + async _getBestMatchIndexState() { + try { + let status = Zotero.Embeddings.Indexing.getStatus(); + if (!status.enabled) { + return null; + } + // Counts aren't populated until the indexer runs in this session + if (!status.libraries.length) { + status = await Zotero.Embeddings.Indexing.refreshStatus(); + } + let libraryIDs = new Set( + this.collectionTreeRows + .map(row => row.ref?.libraryID) + .filter(id => id !== undefined) + ); + let libraries = status.libraries + .filter(lib => !libraryIDs.size || libraryIDs.has(lib.libraryID)); + let indexed = libraries.reduce((sum, lib) => sum + lib.indexed, 0); + let total = libraries.reduce((sum, lib) => sum + lib.eligible, 0); + if (indexed >= total) { + return null; + } + // Only an explicit pause reports as paused. Anything else -- + // between runs (startup, the pre-run debounce) or after an error + // (detailed in the preferences) -- reports as indexing, since the + // banner explains the incomplete coverage, not the indexer state + return { + type: status.paused ? 'paused' : 'indexing', + indexed, + total + }; + } + catch (e) { + Zotero.logError(e); + return null; + } + } + /** * The semantic stage of a best-match search: score the merged, * deduplicated results from all selected rows against the query in a @@ -235,6 +291,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider { Zotero.logError(e); } this._bestMatchRanks = new Map(); + this._bestMatchIndexState = await this._getBestMatchIndexState(); // A rank-only search's membership doesn't depend on the index, so // show its results unranked; anything else shows no results rather // than an unranked scope @@ -274,6 +331,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider { } this._bestMatchRanks = ranks; this._bestMatchBarFractions = fractions; + this._bestMatchIndexState = await this._getBestMatchIndexState(); return kept; } @@ -511,6 +569,7 @@ class CollectionViewItemTreeRowProvider extends ItemTreeRowProvider { this.collectionTreeRows.forEach(row => row.clearCache()); this._bestMatchRanks = null; this._bestMatchBarFractions = null; + this._bestMatchIndexState = null; // Get the full set of items we want to show, merged across all selected rows let newSearchItemSet = new Set(); for (let arr of await Promise.all(this.collectionTreeRows.map(row => row.getItems()))) { @@ -1354,6 +1413,30 @@ class CollectionViewItemTree extends ItemTree { return super.sort(itemIDs); } + /** + * While a best-match search runs against a partially built embeddings + * index, show a banner above the items list with the indexing progress, + * so incomplete results aren't mistaken for a complete ranking. Updates + * arrive with the periodic refreshes the indexer triggers as it fills + * the index. + */ + _renderTablePrologue() { + let state = this.rowProvider.getBestMatchIndexState(); + if (!state) { + return null; + } + return ( +
+ ); + } + render() { const showMessage = !this.collectionTreeRows.length || this._itemsPaneMessage; diff --git a/chrome/content/zotero/components/virtualized-table.jsx b/chrome/content/zotero/components/virtualized-table.jsx index 79dd6321e6..c93794319d 100644 --- a/chrome/content/zotero/components/virtualized-table.jsx +++ b/chrome/content/zotero/components/virtualized-table.jsx @@ -509,6 +509,9 @@ class VirtualizedTable extends React.Component { // Render with display: none hide: PropTypes.bool, + // An element rendered above the column header (e.g. a status banner) + prologue: PropTypes.element, + multiSelect: PropTypes.bool, requireSelection: PropTypes.bool, @@ -1420,6 +1423,7 @@ class VirtualizedTable extends React.Component { return (
{columnDragMarker} + {this.props.prologue} {header}
{/* Pinned copy of the current section's header. Lives inside the scrolling diff --git a/chrome/content/zotero/itemTree.jsx b/chrome/content/zotero/itemTree.jsx index f0f7975ea2..7fa0819f35 100644 --- a/chrome/content/zotero/itemTree.jsx +++ b/chrome/content/zotero/itemTree.jsx @@ -1322,14 +1322,18 @@ var ItemTree = class ItemTree extends LibraryTree { } // A refresh can change the derived column set (e.g. the forced - // Relevance column while a best-match search is active), and the - // header only picks that up through a render + // Relevance column while a best-match search is active) or the table + // prologue, and the header and prologue only pick those up through a + // render this._getColumns(); - if (this.tree && this._renderedColumnsId !== this._columnsId) { + let columnsChanged = this._renderedColumnsId !== this._columnsId; + if (this.tree && (columnsChanged || this._renderedPrologueId !== this._getPrologueId())) { await new Promise(resolve => this.forceUpdate(resolve)); - // The rows above were painted with the previous column set, and the - // render only rebuilds the header - this.tree.invalidate(); + if (columnsChanged) { + // The rows above were painted with the previous column set, and + // the render only rebuilds the header + this.tree.invalidate(); + } } const itemsViewInActiveWindow = Zotero.getActiveZoteroPane()?.itemsView == this; @@ -1478,13 +1482,37 @@ var ItemTree = class ItemTree extends LibraryTree {
); } + /** + * An element to render above the table's column header (e.g. a status + * banner). Subclasses override this; the base tree renders nothing. + * + * @return {React.Element|null} + */ + _renderTablePrologue() { + return null; + } + + /** + * Identity of the current prologue's content, for handleRowModelUpdate() + * to detect when a refresh changed the prologue and a render is needed + * + * @return {String|null} + */ + _getPrologueId() { + let prologue = this._renderTablePrologue(); + return prologue ? JSON.stringify(prologue.props) : null; + } + render() { const showMessage = !!this._itemsPaneMessage; const itemsPaneMessage = this._renderItemsPaneMessage(showMessage); let columns = this._getColumns(); - // The columns the header currently shows, for handleRowModelUpdate() + // The columns and prologue the table currently shows, for + // handleRowModelUpdate() this._renderedColumnsId = this._columnsId; + let prologue = this._renderTablePrologue(); + this._renderedPrologueId = prologue ? JSON.stringify(prologue.props) : null; let virtualizedTable = React.createElement(VirtualizedTree, { getRowCount: () => this.rowProvider.getRowCount(), @@ -1492,6 +1520,7 @@ var ItemTree = class ItemTree extends LibraryTree { ref: ref => this.tree = ref, treeboxRef: ref => this._treebox = ref, renderItem: this._renderItem.bind(this), + prologue, hide: showMessage, key: "virtualized-table", diff --git a/chrome/locale/en-US/zotero/zotero.ftl b/chrome/locale/en-US/zotero/zotero.ftl index fd83184fc3..4de369c597 100644 --- a/chrome/locale/en-US/zotero/zotero.ftl +++ b/chrome/locale/en-US/zotero/zotero.ftl @@ -458,6 +458,9 @@ items-column-last-read = Last Read items-column-relevance = Relevance items-column-relevance-rank = Rank { $rank } +items-best-match-indexing = Indexing in progress — { $indexed } of { $total } items indexed +items-best-match-indexing-paused = Indexing is paused — { $indexed } of { $total } items indexed + report-error = .label = Report Error… diff --git a/scss/components/_item-tree.scss b/scss/components/_item-tree.scss index f7f8daeca0..fbc6cdf22d 100644 --- a/scss/components/_item-tree.scss +++ b/scss/components/_item-tree.scss @@ -12,6 +12,17 @@ min-width: $min-width-items-pane; } +// Indexing-progress banner above the column header while a best-match search +// runs against a partially built embeddings index +.virtualized-table .best-match-index-banner { + flex-shrink: 0; + padding: 4px 8px; + background: var(--material-sidepane); + border-bottom: var(--material-panedivider); + color: var(--fill-secondary); + text-align: center; +} + #zotero-items-tree { .virtualized-table-body, .drag-image-container { padding: 4px 8px 8px; diff --git a/test/tests/collectionViewItemTreeTest.js b/test/tests/collectionViewItemTreeTest.js index 71d4682e9a..50aa264118 100644 --- a/test/tests/collectionViewItemTreeTest.js +++ b/test/tests/collectionViewItemTreeTest.js @@ -208,6 +208,13 @@ describe("CollectionViewItemTree", function () { beforeEach(function () { stubs.push(sinon.stub(Zotero.Embeddings, 'isEnabled').returns(true)); stubs.push(sinon.stub(Zotero.Embeddings, 'getScoreFraction').callsFake(score => score)); + // A fully built index by default, so no indexing banner appears + stubs.push(sinon.stub(Zotero.Embeddings.Indexing, 'getStatus').returns({ + enabled: true, + indexing: false, + paused: false, + libraries: [{ libraryID: Zotero.Libraries.userLibraryID, indexed: 0, eligible: 0 }] + })); Zotero.Prefs.set('search.quicksearch-mode', 'bestMatch'); }); @@ -330,6 +337,63 @@ describe("CollectionViewItemTree", function () { } }); + it("should show an indexing-progress banner while the index is incomplete", async function () { + let col = await createDataObject('collection'); + let item = await createDataObject('item', { title: "A", collections: [col.id] }); + stubs.push(sinon.stub(Zotero.Embeddings, 'scoreItemIDs') + .resolves(new Map([[item.id, 0.7]]))); + Zotero.Embeddings.Indexing.getStatus.returns({ + enabled: true, + indexing: true, + paused: false, + libraries: [{ libraryID: Zotero.Libraries.userLibraryID, indexed: 752, eligible: 9553 }] + }); + + await select(win, col); + itemsView = zp.itemsView; + await itemsView.setFilter('search', 'some query'); + + // Localization is async, so poll for the translated counts (the + // test times out on failure) + let banner = win.document.querySelector('.best-match-index-banner'); + assert.ok(banner); + while (!/752 of 9,553/.test(banner.textContent)) { + await Zotero.Promise.delay(10); + } + + // The paused wording requires the explicit paused flag -- the + // indexer merely not running at the moment (startup, the pre-run + // debounce) still reports indexing + Zotero.Embeddings.Indexing.getStatus.returns({ + enabled: true, + indexing: false, + paused: false, + libraries: [{ libraryID: Zotero.Libraries.userLibraryID, indexed: 752, eligible: 9553 }] + }); + await itemsView.setFilter('search', 'between runs query'); + banner = win.document.querySelector('.best-match-index-banner'); + assert.equal(banner.getAttribute('data-l10n-id'), 'items-best-match-indexing'); + Zotero.Embeddings.Indexing.getStatus.returns({ + enabled: true, + indexing: false, + paused: true, + libraries: [{ libraryID: Zotero.Libraries.userLibraryID, indexed: 752, eligible: 9553 }] + }); + await itemsView.setFilter('search', 'paused query'); + banner = win.document.querySelector('.best-match-index-banner'); + assert.equal(banner.getAttribute('data-l10n-id'), 'items-best-match-indexing-paused'); + + // A complete index shows no banner + Zotero.Embeddings.Indexing.getStatus.returns({ + enabled: true, + indexing: false, + paused: false, + libraries: [{ libraryID: Zotero.Libraries.userLibraryID, indexed: 9553, eligible: 9553 }] + }); + await itemsView.setFilter('search', 'another query'); + assert.notOk(win.document.querySelector('.best-match-index-banner')); + }); + it("should score once across a multi-collection selection", async function () { let col1 = await createDataObject('collection'); let col2 = await createDataObject('collection');