mirror of
https://github.com/edu4rdshl/Strata.git
synced 2026-07-17 23:24:46 +00:00
feat: lazy full-history search, rendered blink-free
Search now covers the entire stored history (bounded by max-history) instead of an arbitrary 500-result cap, and renders a page at a time as you scroll rather than building every match at once - so a broad query on a large history no longer builds thousands of row widgets up front. The full match set is snapshotted once (lightweight truncated metadata) and paged from memory, so scrolling never re-queries and cannot race the search. The first chunk renders synchronously in the same frame as the list clear, so the list never paints empty between keystrokes (no "blink"). Concurrency hardening for the render path: - epoch-ownership of the shared loading guard so a fast new query cannot be blocked by, or have its guard cleared by, a superseded render; - a results-epoch guard so a scroll during a query's fetch window cannot render the previous query's stale snapshot; - removeItem drops the id from the snapshot (adjusting the rendered index) so a pruned item in the unrendered tail cannot reappear as a phantom row. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
764911b32d
commit
46ce549b2f
1 changed files with 95 additions and 23 deletions
|
|
@ -10,7 +10,6 @@ import Shell from 'gi://Shell';
|
||||||
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||||
import { ClipboardItem } from './clipboardItem.js';
|
import { ClipboardItem } from './clipboardItem.js';
|
||||||
|
|
||||||
const SEARCH_LIMIT = 500;
|
|
||||||
const SEARCH_DEBOUNCE_MS = 150;
|
const SEARCH_DEBOUNCE_MS = 150;
|
||||||
const LOAD_MORE_THRESHOLD = 200;
|
const LOAD_MORE_THRESHOLD = 200;
|
||||||
|
|
||||||
|
|
@ -36,6 +35,9 @@ export class StrataPanel {
|
||||||
this._searchQuery = ''; // current search string ('' = no search)
|
this._searchQuery = ''; // current search string ('' = no search)
|
||||||
this._searchDebounceId = null; // pending GLib timeout for debounce
|
this._searchDebounceId = null; // pending GLib timeout for debounce
|
||||||
this._searchEpoch = 0; // monotonic counter to discard stale search responses
|
this._searchEpoch = 0; // monotonic counter to discard stale search responses
|
||||||
|
this._searchResults = []; // full match snapshot (metadata) for the active query
|
||||||
|
this._searchRendered = 0; // how many of _searchResults have widgets so far
|
||||||
|
this._resultsEpoch = -1; // epoch whose results currently fill _searchResults
|
||||||
|
|
||||||
this._visible = false;
|
this._visible = false;
|
||||||
this._grab = null; // Clutter.Grab from Main.pushModal
|
this._grab = null; // Clutter.Grab from Main.pushModal
|
||||||
|
|
@ -316,6 +318,15 @@ export class StrataPanel {
|
||||||
|
|
||||||
removeItem(id) {
|
removeItem(id) {
|
||||||
this._items = this._items.filter(i => i.id !== id);
|
this._items = this._items.filter(i => i.id !== id);
|
||||||
|
// Keep the search snapshot in sync so a deleted/pruned item sitting in
|
||||||
|
// the not-yet-rendered tail can't be re-created as a phantom row when
|
||||||
|
// scrolling appends the next page. Adjust _searchRendered if the removed
|
||||||
|
// item was within the already-rendered range so the index stays aligned.
|
||||||
|
const sIdx = this._searchResults.findIndex(r => r.id === id);
|
||||||
|
if (sIdx !== -1) {
|
||||||
|
this._searchResults.splice(sIdx, 1);
|
||||||
|
if (sIdx < this._searchRendered) this._searchRendered--;
|
||||||
|
}
|
||||||
const widget = this._widgets.get(id);
|
const widget = this._widgets.get(id);
|
||||||
if (widget) {
|
if (widget) {
|
||||||
const wasActive = widget === this._activeWidget;
|
const wasActive = widget === this._activeWidget;
|
||||||
|
|
@ -334,6 +345,8 @@ export class StrataPanel {
|
||||||
|
|
||||||
clearItems() {
|
clearItems() {
|
||||||
this._items = [];
|
this._items = [];
|
||||||
|
this._searchResults = [];
|
||||||
|
this._searchRendered = 0;
|
||||||
this._hoveredWidget = null;
|
this._hoveredWidget = null;
|
||||||
this._activeWidget = null;
|
this._activeWidget = null;
|
||||||
this._widgets.clear();
|
this._widgets.clear();
|
||||||
|
|
@ -358,10 +371,6 @@ export class StrataPanel {
|
||||||
this._proxy.disconnect(this._nameOwnerId);
|
this._proxy.disconnect(this._nameOwnerId);
|
||||||
this._nameOwnerId = 0;
|
this._nameOwnerId = 0;
|
||||||
}
|
}
|
||||||
if (this._initialLoadId) {
|
|
||||||
GLib.Source.remove(this._initialLoadId);
|
|
||||||
this._initialLoadId = null;
|
|
||||||
}
|
|
||||||
if (this._searchDebounceId) {
|
if (this._searchDebounceId) {
|
||||||
GLib.Source.remove(this._searchDebounceId);
|
GLib.Source.remove(this._searchDebounceId);
|
||||||
this._searchDebounceId = null;
|
this._searchDebounceId = null;
|
||||||
|
|
@ -422,17 +431,74 @@ export class StrataPanel {
|
||||||
}
|
}
|
||||||
|
|
||||||
_maybeLoadMore() {
|
_maybeLoadMore() {
|
||||||
if (this._searchQuery) return;
|
if (this._loadingMore) return;
|
||||||
if (!this._hasMore || this._loadingMore) return;
|
|
||||||
const adj = this._scrollView.get_vadjustment();
|
const adj = this._scrollView.get_vadjustment();
|
||||||
if (!adj || adj.upper <= adj.page_size) return;
|
if (!adj || adj.upper <= adj.page_size) return;
|
||||||
const distanceToBottom = adj.upper - (adj.value + adj.page_size);
|
const distanceToBottom = adj.upper - (adj.value + adj.page_size);
|
||||||
if (distanceToBottom > LOAD_MORE_THRESHOLD) return;
|
if (distanceToBottom > LOAD_MORE_THRESHOLD) return;
|
||||||
|
|
||||||
|
if (this._searchQuery) {
|
||||||
|
// Search mode: render the next page from the in-memory match
|
||||||
|
// snapshot (no re-query, so a scroll cannot race the search).
|
||||||
|
this._renderSearchPage(this._searchEpoch);
|
||||||
|
} else {
|
||||||
|
// Browse mode: pull the next page from the daemon.
|
||||||
|
if (!this._hasMore) return;
|
||||||
this._loadingMore = true;
|
this._loadingMore = true;
|
||||||
this._loadHistory(this._loadedOffset, this._pageSize)
|
this._loadHistory(this._loadedOffset, this._pageSize)
|
||||||
.finally(() => { this._loadingMore = false; });
|
.finally(() => { this._loadingMore = false; });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the next page-size slice of `_searchResults` into the list.
|
||||||
|
* Bounded per call so a broad query never builds every match at once;
|
||||||
|
* scrolling calls this again until the snapshot is exhausted. */
|
||||||
|
async _renderSearchPage(epoch) {
|
||||||
|
if (epoch !== this._searchEpoch || !this._overlay) return;
|
||||||
|
// The snapshot must belong to this epoch. During a new query's fetch the
|
||||||
|
// epoch is already bumped while _searchResults still holds the previous
|
||||||
|
// set; rendering then would paint stale rows (and race the real render).
|
||||||
|
if (this._resultsEpoch !== epoch) return;
|
||||||
|
if (this._loadingMore) return;
|
||||||
|
const start = this._searchRendered;
|
||||||
|
if (start >= this._searchResults.length) return;
|
||||||
|
|
||||||
|
this._loadingMore = true;
|
||||||
|
try {
|
||||||
|
const end = Math.min(start + this._pageSize, this._searchResults.length);
|
||||||
|
const BATCH = 20;
|
||||||
|
for (let i = start; i < end; i += BATCH) {
|
||||||
|
if (epoch !== this._searchEpoch || !this._overlay) return;
|
||||||
|
const renderChunk = () => {
|
||||||
|
const e = Math.min(i + BATCH, end);
|
||||||
|
for (let j = i; j < e; j++)
|
||||||
|
this._appendItemFromMeta(this._searchResults[j]);
|
||||||
|
};
|
||||||
|
// The very first chunk of a fresh render (start === 0, right after
|
||||||
|
// _clearListDom) is rendered synchronously, in the same frame as
|
||||||
|
// the clear, so the list never paints empty in between - that
|
||||||
|
// empty frame is the "blink" seen while typing a search. Later
|
||||||
|
// chunks (and scroll-driven pages) yield to idle to keep frames
|
||||||
|
// light.
|
||||||
|
if (i === 0) {
|
||||||
|
renderChunk();
|
||||||
|
} else {
|
||||||
|
await new Promise(resolve =>
|
||||||
|
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
|
||||||
|
renderChunk();
|
||||||
|
resolve();
|
||||||
|
return GLib.SOURCE_REMOVE;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this._searchRendered = end;
|
||||||
|
} finally {
|
||||||
|
// Only release the guard if we still own the current search. If a
|
||||||
|
// newer search superseded us mid-render, it now owns the guard and
|
||||||
|
// must not be cleared by this stale render.
|
||||||
|
if (epoch === this._searchEpoch) this._loadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
_makeItemWidget(id, mimeType, preview) {
|
_makeItemWidget(id, mimeType, preview) {
|
||||||
|
|
@ -571,8 +637,16 @@ export class StrataPanel {
|
||||||
* search cannot overwrite the results of a newer one. */
|
* search cannot overwrite the results of a newer one. */
|
||||||
async _runSearch(query) {
|
async _runSearch(query) {
|
||||||
const epoch = ++this._searchEpoch;
|
const epoch = ++this._searchEpoch;
|
||||||
|
// A new search (or reset) supersedes any in-flight page render. Release
|
||||||
|
// the shared loading guard so this fresh render is never blocked by a
|
||||||
|
// stale one; the stale render bails on its own epoch check and, per the
|
||||||
|
// epoch-ownership check in _renderSearchPage, won't clear the guard out
|
||||||
|
// from under us.
|
||||||
|
this._loadingMore = false;
|
||||||
if (!query) {
|
if (!query) {
|
||||||
this._searchQuery = '';
|
this._searchQuery = '';
|
||||||
|
this._searchResults = [];
|
||||||
|
this._searchRendered = 0;
|
||||||
this._clearListDom();
|
this._clearListDom();
|
||||||
this._items = [];
|
this._items = [];
|
||||||
this._loadedOffset = 0;
|
this._loadedOffset = 0;
|
||||||
|
|
@ -592,9 +666,12 @@ export class StrataPanel {
|
||||||
}
|
}
|
||||||
|
|
||||||
this._searchQuery = query;
|
this._searchQuery = query;
|
||||||
|
// Fetch the full match set, bounded by the configured history size, so
|
||||||
|
// search covers everything that is stored (not an arbitrary cap).
|
||||||
|
const limit = this._settings.get_int('max-history');
|
||||||
let json;
|
let json;
|
||||||
try {
|
try {
|
||||||
[json] = await this._proxy.SearchHistoryAsync(query, SEARCH_LIMIT);
|
[json] = await this._proxy.SearchHistoryAsync(query, limit);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Strata] SearchHistory D-Bus error:', e);
|
console.error('[Strata] SearchHistory D-Bus error:', e);
|
||||||
return;
|
return;
|
||||||
|
|
@ -611,22 +688,17 @@ export class StrataPanel {
|
||||||
|
|
||||||
this._clearListDom();
|
this._clearListDom();
|
||||||
this._items = [];
|
this._items = [];
|
||||||
// Disable pagination while searching - the search response is already
|
// Snapshot the full match set and render it lazily, a page at a time,
|
||||||
// bounded by SEARCH_LIMIT, scrolling shouldn't pull more.
|
// so a broad query on a large history never builds thousands of row
|
||||||
|
// widgets up front. Browse-mode pagination stays off; search paging is
|
||||||
|
// driven by _renderSearchPage over this snapshot (see _maybeLoadMore).
|
||||||
|
this._searchResults = results;
|
||||||
|
this._searchRendered = 0;
|
||||||
|
this._resultsEpoch = epoch;
|
||||||
this._hasMore = false;
|
this._hasMore = false;
|
||||||
this._loadedOffset = 0;
|
this._loadedOffset = 0;
|
||||||
|
|
||||||
const BATCH = 20;
|
await this._renderSearchPage(epoch);
|
||||||
for (let i = 0; i < results.length; i += BATCH) {
|
|
||||||
if (epoch !== this._searchEpoch || !this._overlay) return;
|
|
||||||
await new Promise(resolve =>
|
|
||||||
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
|
|
||||||
const end = Math.min(i + BATCH, results.length);
|
|
||||||
for (let j = i; j < end; j++) this._appendItemFromMeta(results[j]);
|
|
||||||
resolve();
|
|
||||||
return GLib.SOURCE_REMOVE;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tear down all rendered item widgets (without touching the data model). */
|
/** Tear down all rendered item widgets (without touching the data model). */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue