perf: reduce main-thread work in the extension

- Clear the thumbnail cache asynchronously in batches instead of a
  synchronous enumerate + delete on the compositor thread
- Prune the thumbnail path cache on item delete and clear-all
- Compute the image/url/color row flags once per row instead of
  recomputing isUrl/isColor several times
- Lower-case the excluded-app list once instead of on every read
This commit is contained in:
Eduard Tolosa 2026-07-02 15:19:21 -05:00
parent 6c4fa04221
commit 98307b8a2e
3 changed files with 62 additions and 26 deletions

View file

@ -59,10 +59,10 @@ export default class StrataExtension extends Extension {
this._daemonRestartAttempts = 0;
this._settings = this.getSettings();
this._excludedApps = this._settings.get_strv('excluded-apps');
this._excludedApps = this._readExcluded();
this._readSizeLimits();
this._settings.connectObject(
'changed::excluded-apps', () => { this._excludedApps = this._settings.get_strv('excluded-apps'); },
'changed::excluded-apps', () => { this._excludedApps = this._readExcluded(); },
'changed::max-history', () => { this._readSizeLimits(); this._pushConfig(); },
'changed::max-text-mb', () => { this._readSizeLimits(); this._pushConfig(); },
'changed::max-image-mb', () => { this._readSizeLimits(); this._pushConfig(); },
@ -392,23 +392,50 @@ export default class StrataExtension extends Extension {
this._historyClearedId = this._proxy.connectSignal('HistoryCleared',
() => {
try {
const dir = `${GLib.get_user_cache_dir()}/strata/thumbnails`;
const d = Gio.File.new_for_path(dir);
if (d.query_exists(null)) {
const en = d.enumerate_children(
'standard::name', Gio.FileQueryInfoFlags.NONE, null);
let info;
while ((info = en.next_file(null))) {
try { d.get_child(info.get_name()).delete(null); } catch { /* best-effort cleanup */ }
}
en.close(null);
}
} catch (e) { logError('cache clear failed', e); }
this._clearThumbnailCache();
this._addIdleSource(() => this._panel?.clearItems());
});
}
/** Delete every cached thumbnail without blocking the compositor:
* enumerate and unlink asynchronously in batches. */
_clearThumbnailCache() {
const dir = Gio.File.new_for_path(
`${GLib.get_user_cache_dir()}/strata/thumbnails`);
dir.enumerate_children_async(
'standard::name', Gio.FileQueryInfoFlags.NONE,
GLib.PRIORITY_DEFAULT, null,
(d, res) => {
let en;
try {
en = d.enumerate_children_finish(res);
} catch {
return; // dir missing or unreadable
}
const step = () => {
en.next_files_async(32, GLib.PRIORITY_DEFAULT, null, (e, r) => {
let infos;
try {
infos = e.next_files_finish(r);
} catch {
infos = [];
}
if (infos.length === 0) {
e.close_async(GLib.PRIORITY_DEFAULT, null, () => {});
return;
}
for (const info of infos) {
e.get_child(info).delete_async(
GLib.PRIORITY_DEFAULT, null,
(f, rr) => { try { f.delete_finish(rr); } catch { /* best-effort */ } });
}
step();
});
};
step();
});
}
_disconnectSignals() {
if (this._itemAddedId && this._proxy) {
this._proxy.disconnectSignal(this._itemAddedId);
@ -459,9 +486,13 @@ export default class StrataExtension extends Extension {
});
}
_readExcluded() {
return this._settings.get_strv('excluded-apps').map(s => s.toLowerCase());
}
_isExcluded(appClass) {
if (!appClass) return false;
return this._excludedApps.some(ex => appClass.includes(ex.toLowerCase()));
return this._excludedApps.some(ex => appClass.includes(ex));
}

View file

@ -57,8 +57,11 @@ export const ClipboardItem = GObject.registerClass({
x_expand: true,
});
row.add_child(this._buildLeading(mimeType, preview));
row.add_child(this._buildContent(mimeType, preview));
const isImage = mimeType.startsWith('image/');
const urlLike = !isImage && isUrl(preview ?? '');
const colorLike = !isImage && !urlLike && isColor(preview ?? '');
row.add_child(this._buildLeading(mimeType, preview, isImage, colorLike));
row.add_child(this._buildContent(preview, isImage, urlLike, colorLike));
const deleteBtn = new St.Button({
style_class: 'strata-item-delete',
@ -88,11 +91,11 @@ export const ClipboardItem = GObject.registerClass({
});
}
_buildLeading(mimeType, preview) {
if (mimeType.startsWith('image/')) {
_buildLeading(mimeType, preview, isImage, colorLike) {
if (isImage) {
return this._buildThumbnail(this._id);
}
if (isColor(preview)) {
if (colorLike) {
return this._buildColorSwatch(preview);
}
const icon = new St.Icon({
@ -197,7 +200,7 @@ export const ClipboardItem = GObject.registerClass({
});
}
_buildContent(mimeType, preview) {
_buildContent(preview, isImage, urlLike, colorLike) {
preview = preview ?? '';
const box = new St.BoxLayout({
vertical: true,
@ -208,17 +211,17 @@ export const ClipboardItem = GObject.registerClass({
let mainText;
let subText = '';
if (mimeType.startsWith('image/')) {
if (isImage) {
// Generic label - the thumbnail identifies the image, and the
// on-clipboard format (often PNG even for a copied GIF/WebP) is an
// implementation detail that misleads more than it informs.
mainText = 'Image';
} else if (isUrl(preview)) {
} else if (urlLike) {
mainText = preview.trim();
try {
subText = GLib.Uri.parse(preview.trim(), GLib.UriFlags.NONE).get_host() ?? '';
} catch { /* not a parseable URI */ }
} else if (isColor(preview)) {
} else if (colorLike) {
mainText = preview.trim().toUpperCase();
subText = 'Color';
} else {
@ -230,7 +233,7 @@ export const ClipboardItem = GObject.registerClass({
const labelMain = new St.Label({
text: mainText || '(empty)',
style_class: `strata-item-text${isUrl(preview) ? ' strata-item-url' : ''}`,
style_class: `strata-item-text${urlLike ? ' strata-item-url' : ''}`,
x_expand: true,
y_align: Clutter.ActorAlign.CENTER,
});

View file

@ -278,6 +278,7 @@ export class StrataPanel {
removeItem(id) {
this._items = this._items.filter(i => i.id !== id);
this._thumbCache.delete(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
@ -310,6 +311,7 @@ export class StrataPanel {
this._hoveredWidget = null;
this._activeWidget = null;
this._widgets.clear();
this._thumbCache.clear();
this._itemList.destroy_all_children();
}