Compare commits

..

15 commits
v0.9.0 ... main

Author SHA1 Message Date
1294ba741a docs: use JustPerfection's EGO badge for the extensions.gnome.org link
The "Download from extensions.gnome.org" badge is by Javad Rahmatzadeh
(JustPerfection), licensed GPL-3.0. Source:
https://gitlab.gnome.org/jrahmatzadeh/just-perfection/-/blob/main/data/imgs/ego.svg
2026-07-05 08:22:29 -05:00
6ea44ac60c docs: move the screenshots section lower 2026-07-02 16:16:29 -05:00
56399996f5 docs: note Rust/Cargo is needed to build the daemon 2026-07-02 16:12:54 -05:00
2b1f67b1ea docs: replace the screenshot 2026-07-02 16:07:23 -05:00
71f942ff36 docs: add EGO badge and a daemon-install section 2026-07-02 15:50:04 -05:00
7968270aa4 chore: bump versions to 0.11.0 2026-07-02 15:38:22 -05:00
b406970b93 docs: describe the shell-driven light/dark theming 2026-07-02 15:38:22 -05:00
d75ba7fff6 fix: drop the needless try/catch around send_signal
Gio.Subprocess.send_signal returns void and is race-free, so it never throws.
2026-07-02 15:38:22 -05:00
98307b8a2e 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
2026-07-02 15:19:21 -05:00
6c4fa04221 fix: address EGO review 72215 feedback
- Use GNOME's stylesheet-dark.css/stylesheet-light.css for theming
  instead of loading light.css into the theme context by hand; the panel
  now follows the shell style variant
- Track signals with connectObject/disconnectObject
- Clear the daemon-restart timeout before scheduling a new one
- Stop the daemon synchronously in disable(), no deferred kill timer
- Drop the unused theme setting and its prefs combo
- Bump the metadata version to 11 for resubmission
2026-07-02 13:43:52 -05:00
b6fe86f418 chore: bump versions to 0.10.0 2026-06-25 20:37:56 -05:00
3d41f39ad9 fix: bundle dbus.js in the pack and share logError via util.js
extension.js imports ./dbus.js, but gnome-extensions pack never bundled it,
so the packed extension (the EGO zip) was missing the module and would fail
to load. Add dbus.js and the new util.js to the pack sources.

util.js holds the shared logError helper, replacing the per-file copies in
extension.js and panel.js and the raw console.error calls in clipboardItem.js.
2026-06-25 20:29:02 -05:00
c9f3b6b1ad ci: add ESLint workflow 2026-06-25 20:08:08 -05:00
bb792cee95 style: conform the extension to the GJS ESLint config
- clipboardItem: convert _init() to constructor()/super() and give the class
  an explicit GTypeName (no-restricted-syntax, no-shadow).
- clipboardItem: replace new URL() with GLib.Uri.parse().get_host(). GJS has
  no global URL, so the old call always threw and the URL hostname subtitle
  never rendered; this makes it work.
- Use optional catch bindings (catch {}); logError guards with
  instanceof GLib.Error instead of a try/catch.
- prefs: drop an unused GLib import.
- panel: block-body promise executors, and a no-await-in-loop disable on the
  batched renders (the per-batch yield is intentional).
- Drop the remaining restate-the-code comments.
2026-06-25 19:43:47 -05:00
376c6442f9 build: add the GJS ESLint flat config and lint script 2026-06-25 19:43:46 -05:00
24 changed files with 772 additions and 456 deletions

View file

@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install system dependencies
run: |
@ -28,7 +28,7 @@ jobs:
components: rustfmt, clippy
- name: Cache cargo registry
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: |
~/.cargo/registry
@ -54,7 +54,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install glib-compile-schemas
run: sudo apt-get update -q && sudo apt-get install -y libglib2.0-bin

40
.github/workflows/eslint.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: ESLint
on:
push:
branches: [ 'main' ]
pull_request:
branches: [ 'main' ]
schedule:
- cron: '33 14 * * 5'
jobs:
eslint:
name: Run eslint scanning
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
# Required for private repositories by github/codeql-action/upload-sarif
actions: read
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Install
run: |
npm install eslint@^10.0.0 @eslint/js@^10.0.0
npm install @microsoft/eslint-formatter-sarif@2.1.7
- name: Lint
run: >
npx eslint .
--format @microsoft/eslint-formatter-sarif
--output-file eslint-results.sarif
continue-on-error: true
- name: Report
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: eslint-results.sarif
wait-for-processing: true

8
.gitignore vendored
View file

@ -4,12 +4,18 @@ strata-daemon/target/
# Bundled binary (no longer shipped - daemon must be installed separately)
strata@edu4rdshl.dev/bin/
# Screenshots / scratch images
# Screenshots / scratch images (project assets under assets/ are kept)
*.png
!assets/*.png
# External reference code (not part of this project)
external_sources/
# Node / lint tooling
node_modules/
package-lock.json
*.shell-extension.zip
# Editor / OS
.vscode/
.idea/

View file

@ -295,38 +295,24 @@ image.
## Theming
St's CSS engine has no custom properties (`var()`) and no reliable
`!important`, so the light theme is not a runtime-generated stylesheet.
Instead `stylesheet.css` is the dark theme (default, auto-loaded by GNOME),
and `light.css` carries light overrides with every rule scoped under a
`.strata-theme-light` ancestor class -- e.g. dark `.strata-panel { ... }` is
overridden by `.strata-panel.strata-theme-light { ... }`. That scoping makes
each light rule strictly more specific than its dark counterpart, so it wins
deterministically regardless of stylesheet load order.
Light and dark are handled by GNOME Shell's built-in per-variant stylesheet
loading, not by any code in the extension. When it enables an extension the
Shell loads `stylesheet-<variant>.css`, where the variant comes from
`Main.getStyleVariant()` (the shell's own light/dark), and falls back to
`stylesheet.css`; it reloads that sheet when the color scheme changes.
The switch between themes is a single class. `extension.js` loads `light.css`
into the St theme context once at `enable()` (and unloads it on `disable()`),
but loading it changes nothing on screen: its rules are present in the engine
yet match no actors, because nothing carries `.strata-theme-light` yet. The
panel resolves the effective theme from the `theme` setting (`auto` consults
`org.gnome.desktop.interface color-scheme`) and adds or removes that one class
on its root box. With the class present the more-specific light rules win and
the panel is light; with it absent only the base dark rules apply. So
switching is one class toggle on an existing subtree -- instant, off the
ingest/render hot paths, and needing no reload.
Strata ships two sheets and no `stylesheet.css`:
`light.css` is loaded exactly once and the theme context's `changed` signal is
deliberately not used: `load_stylesheet` itself emits `changed`, so reloading
on it feeds back into itself and hits "too much recursion" (it fired on screen
unlock, which restyles widgets). The one caveat of loading once is a full
GNOME Shell *theme* switch (the User Themes extension swapping the whole Shell
theme), which replaces the theme object and drops every dynamically loaded
sheet, including `light.css`. After that, light mode falls back to the dark
base rules until the extension is re-enabled; dark mode is unaffected because
GNOME re-loads `stylesheet.css` itself. This is rare and recoverable, and far
preferable to re-subscribing to `changed`. The ordinary light/dark switch
(including the system Settings light/dark that `auto` follows) is just the
class toggle and is unaffected.
- `stylesheet-dark.css` is the full dark theme (the base).
- `stylesheet-light.css` does `@import url("stylesheet-dark.css")` and then
overrides the colors for a light panel. Its rules follow the import, so they
win by load order.
This is the structure the built-in `window-list` extension uses. Because the
variant tracks the *shell* style, the panel matches the shell chrome: a normal
session prefers dark, so the panel is dark, and it turns light only when the
shell itself is light (a `prefer-light` color scheme, the Classic session, or
high contrast). There is no theme setting and no manual stylesheet loading.
## Wayland clipboard monitor

View file

@ -6,6 +6,63 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
---
## [0.11.0] - 2026-07-02
The daemon is unchanged from 0.10.0; all changes are in the GNOME Shell extension.
### Changed
- Light and dark styling follows the GNOME Shell style variant via
`stylesheet-dark.css` and `stylesheet-light.css`, loaded automatically by the
Shell, instead of loading `light.css` into the theme context by hand. The
manual theme setting and its preferences row are gone; the panel matches the
shell.
- Signal connections use `connectObject`/`disconnectObject`.
### Fixed
- `disable()` stops the daemon synchronously with `SIGTERM` instead of deferring
the kill to a timeout.
- The daemon-restart timeout is cleared before a new one is scheduled.
- Removed the `try`/`catch` around `send_signal`, which does not throw.
### Performance
- The thumbnail cache is cleared asynchronously in batches instead of a
synchronous enumerate and delete on the compositor thread.
- The thumbnail path cache is pruned on delete and clear-all.
- Each row computes its type (image, URL, color) once.
---
## [0.10.0] - 2026-06-25
The daemon is unchanged from 0.9.0; all changes are in the GNOME Shell extension.
### Fixed
- URL items now show their hostname as a subtitle. The old code used the WHATWG
`URL` API, which GJS does not provide, so it always threw and the subtitle
never appeared. It now uses `GLib.Uri`.
- The packaged extension was missing `dbus.js`. The extension imports it, but
`make pack` did not bundle it, so installing from the packaged zip would
fail to load. It is now included.
### Changed
- `ClipboardItem` uses a `constructor()` with an explicit `GTypeName` instead of
`_init()`.
- The error-logging helper lives in a single `util.js` module shared by the
extension, panel, and item widget.
### Internal
- Adopted the GJS ESLint style guide: an `eslint.config.js` flat config, an
`npm run lint` script, and a GitHub Actions workflow that lints on push and
pull requests. The extension passes with no warnings.
---
## [0.9.0] - 2026-06-25
The daemon is unchanged from 0.8.0; all changes are in the GNOME Shell extension.

View file

@ -37,7 +37,8 @@ install: schemas
pack: schemas
gnome-extensions pack $(EXTENSION_UUID) \
--extra-source=ui \
--extra-source=light.css \
--extra-source=dbus.js \
--extra-source=util.js \
--force
@echo "Packed: $(EXTENSION_UUID).shell-extension.zip"

View file

@ -10,6 +10,15 @@ All heavy work (hashing, decoding, storage, search, thumbnails) lives in a Rust
daemon. The GNOME Shell extension only renders UI and forwards events over
D-Bus, so the compositor is never blocked, even with thousands of items.
<p align="center">
<a href="https://extensions.gnome.org/extension/10291/strata/">
<img src="assets/ego.svg" width="200" alt="Download from extensions.gnome.org">
</a>
</p>
> **Requires a companion daemon.** Installing from GNOME Extensions is only half
> the setup. See [Installing the daemon](#installing-the-daemon).
The motivation behind this is explained in the technical blog post [Rethinking the GNOME clipboard issues](https://edu4rdshl.dev/posts/rethinking-the-gnome-clipboard-issues/)
## Features
@ -34,8 +43,8 @@ Unknown MIME types are ignored (a strict allowlist).
**Appearance.**
- Automatic light/dark theme: `Auto` follows the system color scheme; `Light`
and `Dark` force one.
- Light and dark styling follows the GNOME Shell automatically, matching the
rest of the shell UI.
- Configurable panel position (top/center/bottom by left/center/right), width,
and maximum height.
- Optional "move an item to the top" when you paste it.
@ -78,12 +87,6 @@ Unknown MIME types are ignored (a strict allowlist).
- Keyboard navigation (arrow keys, `Esc` to close), click-outside to dismiss,
per-row delete, and "Clear all".
## How it looks
The idea is to have a simple but responsive UI that fits into the project's goals. Actually, Strata looks like it:
<img width="1470" height="1227" alt="image" src="https://github.com/user-attachments/assets/07a17d08-4d7f-4afb-bafc-9346814e376a" />
## Architecture
Strata is **two components**, and you need **both** for it to work:
@ -104,6 +107,32 @@ GNOME Shell (GJS) ──D-Bus──▶ strata-daemon ──▶ SQLite (~/.lo
└──▶ thumbnails (~/.cache/strata)
```
## Installing the daemon
Strata is two parts: the GNOME Shell extension and a small background daemon
(`strata-daemon`) that does the storage, search, and thumbnailing. Installing
the extension from [GNOME Extensions](https://extensions.gnome.org/extension/10291/strata/)
is only half the setup; **without the daemon the extension does nothing.**
Install the daemon with whichever fits your system:
- **Arch (AUR):** `paru -S strata-daemon` (or `strata-daemon-git` for the latest `main`).
- **From source** (needs Rust/Cargo 1.74+):
```sh
git clone https://github.com/Edu4rdSHL/Strata.git
cd Strata
make install-daemon # builds and installs to ~/.local/bin
```
Make sure `~/.local/bin` is in your `$PATH`.
Then log out and back in (Wayland) or `Alt+F2``r` (X11). The extension finds
`strata-daemon` in `$PATH` and starts it automatically; no separate service is
needed.
For distro packages and the systemd user service, see [Install](#install).
## Requirements
- GNOME Shell 50. Older releases may work if built from source but are not tested or supported.
@ -197,6 +226,19 @@ the wire protocol and a `busctl` example.
The shipped UI is a GNOME Shell extension. Ports to other desktops only
need a new front-end against the same D-Bus interface.
## How it looks
A responsive panel that drops down from the top bar and follows the GNOME
shell's look.
<p align="center">
<img src="assets/panel-desktop.png" alt="Strata's clipboard panel open on the GNOME desktop" width="100%">
</p>
<p align="center">
<img src="assets/panel.png" alt="Close-up of the Strata panel: a URL with its hostname, an image thumbnail, color swatches, and text entries" width="400">
</p>
## Deeper reading
- [`ARCHITECTURE.md`](ARCHITECTURE.md): design goals, process model,

170
assets/ego.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

BIN
assets/panel-desktop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

BIN
assets/panel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

149
eslint.config.js Normal file
View file

@ -0,0 +1,149 @@
// SPDX-License-Identifier: CC0-1.0
// SPDX-FileCopyrightText: No rights reserved
import js from '@eslint/js';
export default [
js.configs.recommended,
{
languageOptions: {
globals: {
ARGV: 'readonly',
Debugger: 'readonly',
GIRepositoryGType: 'readonly',
globalThis: 'readonly',
imports: 'readonly',
Intl: 'readonly',
log: 'readonly',
logError: 'readonly',
pkg: 'readonly',
print: 'readonly',
printerr: 'readonly',
window: 'readonly',
TextEncoder: 'readonly',
TextDecoder: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
setInterval: 'readonly',
clearTimeout: 'readonly',
clearInterval: 'readonly',
// GNOME Shell Only
global: 'readonly',
_: 'readonly',
C_: 'readonly',
N_: 'readonly',
ngettext: 'readonly',
},
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
},
rules: {
// See: https://eslint.org/docs/latest/rules/#possible-problems
'array-callback-return': 'error',
'no-await-in-loop': 'error',
'no-constant-binary-expression': 'error',
'no-constructor-return': 'error',
'no-new-native-nonconstructor': 'error',
'no-promise-executor-return': 'error',
'no-self-compare': 'error',
'no-template-curly-in-string': 'error',
'no-unmodified-loop-condition': 'error',
'no-unreachable-loop': 'error',
'no-unused-private-class-members': 'error',
'no-use-before-define': [
'error',
{
functions: false,
classes: true,
variables: true,
allowNamedExports: true,
},
],
// See: https://eslint.org/docs/latest/rules/#suggestions
'block-scoped-var': 'error',
'complexity': 'warn',
'consistent-return': 'error',
'default-param-last': 'error',
'eqeqeq': 'error',
'no-array-constructor': 'error',
'no-caller': 'error',
'no-extend-native': 'error',
'no-extra-bind': 'error',
'no-extra-label': 'error',
'no-iterator': 'error',
'no-label-var': 'error',
'no-loop-func': 'error',
'no-multi-assign': 'warn',
'no-new-object': 'error',
'no-new-wrappers': 'error',
'no-proto': 'error',
'no-shadow': 'warn',
'no-unused-vars': [
'error',
{
varsIgnorePattern: '^_',
argsIgnorePattern: '^_',
},
],
'no-var': 'warn',
'unicode-bom': 'error',
// GJS Restrictions
'no-restricted-globals': [
'error',
{
name: 'Debugger',
message: 'Internal use only',
},
{
name: 'GIRepositoryGType',
message: 'Internal use only',
},
{
name: 'log',
message: 'Use console.log()',
},
{
name: 'logError',
message: 'Use console.warn() or console.error()',
},
],
'no-restricted-properties': [
'error',
{
object: 'imports',
property: 'format',
message: 'Use template strings',
},
{
object: 'pkg',
property: 'initFormat',
message: 'Use template strings',
},
{
object: 'Lang',
property: 'copyProperties',
message: 'Use Object.assign()',
},
{
object: 'Lang',
property: 'bind',
message: 'Use arrow notation or Function.prototype.bind()',
},
{
object: 'Lang',
property: 'Class',
message: 'Use ES6 classes',
},
],
'no-restricted-syntax': [
'error',
{
selector: 'MethodDefinition[key.name="_init"] CallExpression[arguments.length<=1][callee.object.type="Super"][callee.property.name="_init"]',
message: 'Use constructor() and super()',
},
],
},
},
];

14
package.json Normal file
View file

@ -0,0 +1,14 @@
{
"name": "strata-extension",
"version": "0.9.0",
"description": "Lint tooling for the Strata GNOME Shell extension",
"private": true,
"type": "module",
"scripts": {
"lint": "eslint 'strata@edu4rdshl.dev'"
},
"devDependencies": {
"@eslint/js": "^10.0.0",
"eslint": "^10.0.0"
}
}

View file

@ -1205,7 +1205,7 @@ dependencies = [
[[package]]
name = "strata-daemon"
version = "0.9.0"
version = "0.11.0"
dependencies = [
"anyhow",
"blake3",

View file

@ -1,6 +1,6 @@
[package]
name = "strata-daemon"
version = "0.9.0"
version = "0.11.0"
edition = "2021"
description = "Strata clipboard manager backend daemon"

View file

@ -13,14 +13,7 @@ import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
import { StrataProxy, BUS_NAME, OBJECT_PATH } from './dbus.js';
import { StrataPanel } from './ui/panel.js';
function logError(label, err) {
if (err !== undefined) {
try { Gio.DBusError.strip_remote_error(err); } catch (_) {}
}
const tail = err !== undefined ? `: ${err?.message ?? err}` : '';
console.error(`[Strata] ${label}${tail}`);
}
import { logError } from './util.js';
export default class StrataExtension extends Extension {
/** @type {Gio.Subprocess | null} */
@ -29,7 +22,6 @@ export default class StrataExtension extends Extension {
_daemonRestartAttempts = 0;
_shuttingDown = false;
_daemonRestartTimerId = null;
_daemonKillTimerId = null;
/** True while a GetNameOwner check before spawning is in flight, so an
* overlapping respawn attempt can't spawn a second daemon. */
_spawnPending = false;
@ -43,18 +35,12 @@ export default class StrataExtension extends Extension {
_itemDeletedId = null;
_historyClearedId = null;
/** @type {number | null} focused-window signal connection ID */
_focusSignalId = null;
/** @type {string} WM class of the currently focused app (lower-cased) */
_currentFocusedApp = '';
/** @type {object | null} PanelMenu.Button indicator */
_indicator = null;
/** @type {number | null} Meta.Selection owner-changed signal ID */
_selectionChangedId = null;
/** @type {number | null} Debounce timer for clipboard reads */
_clipboardDebounceId = null;
@ -71,29 +57,16 @@ export default class StrataExtension extends Extension {
enable() {
this._shuttingDown = false;
this._daemonRestartAttempts = 0;
// Cancel any pending force-kill timer left over from a previous disable
// (enable→disable→enable within 1.5s) so it can't outlive this cycle.
if (this._daemonKillTimerId) {
GLib.Source.remove(this._daemonKillTimerId);
this._daemonKillTimerId = null;
}
this._settings = this.getSettings();
this._excludedApps = this._settings.get_strv('excluded-apps');
this._excludedAppsChangedId = this._settings.connect('changed::excluded-apps', () => {
this._excludedApps = this._settings.get_strv('excluded-apps');
});
this._excludedApps = this._readExcluded();
this._readSizeLimits();
this._configChangedIds = [
this._settings.connect('changed::max-history', () => { this._readSizeLimits(); this._pushConfig(); }),
this._settings.connect('changed::max-text-mb', () => { this._readSizeLimits(); this._pushConfig(); }),
this._settings.connect('changed::max-image-mb', () => { this._readSizeLimits(); this._pushConfig(); }),
];
// Load the light theme overrides into the Shell theme context. They stay
// inert until the panel toggles the `.strata-theme-light` class (panel.js).
this._loadThemeStylesheet();
this._settings.connectObject(
'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(); },
this);
this._addIndicator();
this._spawnDaemon();
@ -110,33 +83,18 @@ export default class StrataExtension extends Extension {
GLib.Source.remove(this._daemonRestartTimerId);
this._daemonRestartTimerId = null;
}
// Clean up in reverse order.
this._unregisterShortcut();
this._disconnectClipboardMonitor();
this._disconnectFocusTracking();
this._disconnectSignals();
this._clearIdleSources();
if (this._proxyOwnerId && this._proxy) {
this._proxy.disconnect(this._proxyOwnerId);
this._proxyOwnerId = 0;
}
if (this._configChangedIds && this._settings) {
for (const id of this._configChangedIds) this._settings.disconnect(id);
this._configChangedIds = null;
}
if (this._excludedAppsChangedId && this._settings) {
this._settings.disconnect(this._excludedAppsChangedId);
this._excludedAppsChangedId = null;
}
this._proxy?.disconnectObject(this);
this._settings?.disconnectObject(this);
this._panel?.destroy();
this._panel = null;
if (this._indicatorClickId && this._indicator) {
this._indicator.disconnect(this._indicatorClickId);
this._indicatorClickId = null;
}
this._indicator?.disconnectObject(this);
this._indicator?.destroy();
this._indicator = null;
this._unloadThemeStylesheet();
this._stopDaemon();
this._proxy = null;
this._settings = null;
@ -151,20 +109,19 @@ export default class StrataExtension extends Extension {
style_class: 'system-status-icon',
});
this._indicator.add_child(icon);
this._indicatorClickId = this._indicator.connect('button-press-event', () => {
this._indicator.connectObject('button-press-event', () => {
this._panel?.toggle();
return false; // EVENT_PROPAGATE
});
}, this);
Main.panel.addToStatusArea('strata', this._indicator);
}
_connectClipboardMonitor() {
const selection = global.display.get_selection();
this._selectionChangedId = selection.connect('owner-changed', (_sel, type) => {
global.display.get_selection().connectObject('owner-changed', (_sel, type) => {
if (type !== Meta.SelectionType.SELECTION_CLIPBOARD) return;
this._scheduleClipboardRead();
});
}, this);
}
_disconnectClipboardMonitor() {
@ -175,10 +132,7 @@ export default class StrataExtension extends Extension {
this._clipboardDebounceId = null;
}
this._clipboardTransferPending = false;
if (this._selectionChangedId !== null) {
global.display.get_selection().disconnect(this._selectionChangedId);
this._selectionChangedId = null;
}
global.display.get_selection().disconnectObject(this);
}
/** Debounce entry point - coalesces rapid clipboard changes (e.g. from
@ -235,7 +189,6 @@ export default class StrataExtension extends Extension {
/** Pick the best MIME type to store from the offered list (mirrors Rust pick_mime). */
_pickMime(mimes) {
const PREFERRED = [
// Raster images (size-capped at MAX_IMAGE).
'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
'image/bmp', 'image/tiff', 'image/x-icon',
// Plain text (UTF-8 preferred, then locale, then X11 legacy aliases).
@ -281,7 +234,7 @@ export default class StrataExtension extends Extension {
try {
_conn.call_finish(result);
// Name already owned (e.g. systemd user service) - don't spawn a second instance.
} catch (_) {
} catch {
this._doSpawnDaemon();
}
}
@ -324,7 +277,7 @@ export default class StrataExtension extends Extension {
urgency: MessageTray.Urgency.HIGH,
});
source.addNotification(notification);
} catch (_) {
} catch {
// Message tray may not be available (e.g. during early startup) - already logged above.
}
}
@ -356,6 +309,10 @@ export default class StrataExtension extends Extension {
_scheduleDaemonRestart() {
if (this._shuttingDown) return;
if (this._daemonRestartTimerId !== null) {
GLib.Source.remove(this._daemonRestartTimerId);
this._daemonRestartTimerId = null;
}
// Exponential backoff: 1s, 2s, 4s, 8s, 16s
const backoffMs = 1000 * Math.pow(2, Math.max(0, this._daemonRestartAttempts - 1));
this._daemonRestartTimerId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, backoffMs, () => {
@ -367,20 +324,9 @@ export default class StrataExtension extends Extension {
_stopDaemon() {
if (!this._daemon) return;
// Capture reference immediately so the timer doesn't kill a newly-spawned daemon.
const daemonToStop = this._daemon;
const daemon = this._daemon;
this._daemon = null;
try {
// Graceful shutdown via D-Bus first.
this._proxy?.ShutdownRemote(() => {});
} catch (_) {}
// Give it 1.5s then force-terminate. Track the source so a re-enable
// within that window can cancel it (see enable()).
this._daemonKillTimerId = GLib.timeout_add(GLib.PRIORITY_LOW, 1500, () => {
this._daemonKillTimerId = null;
try { daemonToStop.send_signal(15); } catch (_) {} // SIGTERM
return GLib.SOURCE_REMOVE;
});
daemon.send_signal(15); // SIGTERM
}
@ -402,8 +348,8 @@ export default class StrataExtension extends Extension {
// again on every owner transition so a respawned
// daemon picks up the latest values.
this._pushConfig();
this._proxyOwnerId = proxy.connect('notify::g-name-owner',
() => { if (proxy.g_name_owner) this._pushConfig(); });
proxy.connectObject('notify::g-name-owner',
() => { if (proxy.g_name_owner) this._pushConfig(); }, this);
}
);
} catch (e) {
@ -440,29 +386,56 @@ export default class StrataExtension extends Extension {
const cachePath =
`${GLib.get_user_cache_dir()}/strata/thumbnails/${id}.png`;
GLib.unlink(cachePath);
} catch (_) {}
} catch { /* no cached thumbnail for this id */ }
this._addIdleSource(() => this._panel?.removeItem(id));
});
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 (_) {}
}
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);
@ -513,24 +486,25 @@ 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));
}
_connectFocusTracking() {
this._focusSignalId = global.display.connect('notify::focus-window', () => {
global.display.connectObject('notify::focus-window', () => {
const win = global.display.focus_window;
this._currentFocusedApp = (win?.get_wm_class() ?? '').toLowerCase();
});
}, this);
}
_disconnectFocusTracking() {
if (this._focusSignalId !== null) {
global.display.disconnect(this._focusSignalId);
this._focusSignalId = null;
}
global.display.disconnectObject(this);
}
@ -551,29 +525,4 @@ export default class StrataExtension extends Extension {
_unregisterShortcut() {
Main.wm.removeKeybinding('keyboard-shortcut');
}
/** Load light.css once. It is scoped under `.strata-theme-light` and stays
* inert until the panel adds that class. We do not subscribe to the theme
* context's 'changed' signal because load_stylesheet itself emits it. */
_loadThemeStylesheet() {
try {
const themeContext = St.ThemeContext.get_for_stage(global.stage);
this._lightCssFile = this.dir.get_child('light.css');
this._stTheme = themeContext.get_theme();
this._stTheme.load_stylesheet(this._lightCssFile);
} catch (e) {
logError('Failed to load light.css', e);
}
}
_unloadThemeStylesheet() {
try {
this._stTheme?.unload_stylesheet(this._lightCssFile);
} catch (e) {
logError('Failed to unload light.css', e);
}
this._stTheme = null;
this._lightCssFile = null;
}
}

View file

@ -1,143 +0,0 @@
/* Strata light theme.
*
* Loaded into the Shell theme context by extension.js and activated by toggling
* the `.strata-theme-light` class on the panel root box (see ui/panel.js).
* Every rule is scoped under `.strata-theme-light` so it is strictly more
* specific than its counterpart in stylesheet.css and wins deterministically,
* regardless of stylesheet load order. The dark theme (stylesheet.css) is left
* untouched: with no class present, none of these rules apply.
*
* Colors follow GNOME Adwaita (blue #3584e4, link #1a5fb4, amber #b5830a,
* red #e01b24) and are picked for contrast on a light panel.
*
* Performance rule (same as stylesheet.css): NO `transition: all`, NO
* Clutter/CSS animations. Only background-color transitions on hover.
*/
/* ── Panel container ────────────────────────────────────────────────────── */
.strata-panel.strata-theme-light {
background-color: rgba(250, 250, 250, 0.98);
border-color: rgba(0, 0, 0, 0.14);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.28);
}
/* ── Header ─────────────────────────────────────────────────────────────── */
.strata-theme-light .strata-title {
color: rgba(0, 0, 0, 0.87);
}
.strata-theme-light .strata-clear-btn {
border-color: rgba(0, 0, 0, 0.18);
color: rgba(0, 0, 0, 0.55);
}
.strata-theme-light .strata-clear-btn:hover {
background-color: rgba(0, 0, 0, 0.07);
color: rgba(0, 0, 0, 0.85);
}
/* ── Search box ─────────────────────────────────────────────────────────── */
.strata-theme-light .strata-search {
background-color: rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.14);
color: rgba(0, 0, 0, 0.87);
caret-color: rgba(0, 0, 0, 0.8);
}
.strata-theme-light .strata-search:focus {
border-color: rgba(53, 132, 228, 0.8);
background-color: rgba(0, 0, 0, 0.035);
}
/* Placeholder ("Search...") text - legible dim gray on the light panel. */
.strata-theme-light .strata-search-hint {
color: rgba(0, 0, 0, 0.55);
}
/* ── Clipboard item: hover / focus (Adwaita blue) ───────────────────────── */
.strata-theme-light .strata-item:hover,
.strata-theme-light .strata-item-hovered {
background-color: rgba(53, 132, 228, 0.14);
border-color: rgba(53, 132, 228, 0.45);
}
.strata-theme-light .strata-item:focus,
.strata-theme-light .strata-item-focused {
background-color: rgba(53, 132, 228, 0.16);
border-color: rgba(53, 132, 228, 0.65);
}
.strata-theme-light .strata-item:hover:focus,
.strata-theme-light .strata-item-focused.strata-item-hovered {
background-color: rgba(53, 132, 228, 0.24);
border-color: rgba(53, 132, 228, 0.78);
}
.strata-theme-light .strata-item:focus .strata-item-text,
.strata-theme-light .strata-item-focused .strata-item-text {
color: rgba(0, 0, 0, 0.95);
}
/* ── Active item (warm amber, darkened for light bg) ────────────────────── */
.strata-theme-light .strata-item-active {
background-color: rgba(181, 131, 10, 0.16);
border-color: rgba(181, 131, 10, 0.50);
}
.strata-theme-light .strata-item-active .strata-item-text {
color: rgba(120, 82, 0, 1.0);
}
.strata-theme-light .strata-item-active.strata-item-hovered {
background-color: rgba(181, 131, 10, 0.24);
}
.strata-theme-light .strata-item-active.strata-item-focused,
.strata-theme-light .strata-item-active:focus {
background-color: rgba(53, 132, 228, 0.20);
border-color: rgba(53, 132, 228, 0.65);
}
.strata-theme-light .strata-item-active.strata-item-focused .strata-item-text,
.strata-theme-light .strata-item-active:focus .strata-item-text {
color: rgba(0, 0, 0, 0.95);
}
.strata-theme-light .strata-item:active {
background-color: rgba(0, 0, 0, 0.10);
}
/* ── Item icon / thumbnail ──────────────────────────────────────────────── */
.strata-theme-light .strata-item-icon {
color: rgba(0, 0, 0, 0.55);
}
.strata-theme-light .strata-item-thumb {
border-color: rgba(0, 0, 0, 0.15);
}
/* ── Text content ───────────────────────────────────────────────────────── */
.strata-theme-light .strata-item-text {
color: rgba(0, 0, 0, 0.85);
}
.strata-theme-light .strata-item-url {
color: rgba(26, 95, 180, 1.0);
}
.strata-theme-light .strata-item-subtext {
color: rgba(0, 0, 0, 0.55);
}
/* ── Delete button (Adwaita red) ────────────────────────────────────────── */
.strata-theme-light .strata-item:hover .strata-item-delete,
.strata-theme-light .strata-item:focus .strata-item-delete,
.strata-theme-light .strata-item-focused .strata-item-delete,
.strata-theme-light .strata-item-hovered .strata-item-delete {
color: rgba(224, 27, 36, 0.75);
}
.strata-theme-light .strata-item-delete:hover {
background-color: rgba(224, 27, 36, 0.13);
color: rgba(224, 27, 36, 1.0);
}

View file

@ -2,7 +2,7 @@
"name": "Strata",
"description": "A fast clipboard manager. Storage, search and image decoding run in a separate Rust daemon.",
"uuid": "strata@edu4rdshl.dev",
"version": 9,
"version": 11,
"shell-version": ["50"],
"settings-schema": "org.gnome.shell.extensions.strata",
"url": "https://github.com/Edu4rdSHL/Strata"

View file

@ -3,7 +3,6 @@
import Adw from 'gi://Adw';
import Gtk from 'gi://Gtk';
import Gdk from 'gi://Gdk';
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
@ -80,29 +79,6 @@ export default class StrataPreferences extends ExtensionPreferences {
const appearanceGroup = new Adw.PreferencesGroup({ title: 'Appearance' });
const themes = [
{ id: 'auto', label: 'Automatic' },
{ id: 'light', label: 'Light' },
{ id: 'dark', label: 'Dark' },
];
const themeRow = new Adw.ComboRow({
title: 'Theme',
subtitle: 'Automatic follows the system light/dark preference',
model: Gtk.StringList.new(themes.map(t => t.label)),
});
const currentTheme = settings.get_string('theme');
const currentThemeIdx = themes.findIndex(t => t.id === currentTheme);
themeRow.selected = currentThemeIdx >= 0 ? currentThemeIdx : 0;
themeRow.connect('notify::selected', () => {
settings.set_string('theme', themes[themeRow.selected].id);
});
settings.connect('changed::theme', () => {
const idx = themes.findIndex(t => t.id === settings.get_string('theme'));
if (idx >= 0 && themeRow.selected !== idx)
themeRow.selected = idx;
});
appearanceGroup.add(themeRow);
const panelWidthRow = new Adw.SpinRow({
title: 'Panel width',
subtitle: 'Width of the clipboard panel in pixels',
@ -211,7 +187,6 @@ export default class StrataPreferences extends ExtensionPreferences {
for (const app of currentApps)
model.append(app);
/** Sync the StringList back to GSettings. */
const saveModel = () => {
const apps = [];
for (let i = 0; i < model.get_n_items(); i++) {

View file

@ -52,17 +52,6 @@
<description>When enabled, clicking or pressing Enter on a history item moves it to position 1 in the list.</description>
</key>
<key name="theme" type="s">
<choices>
<choice value="auto"/>
<choice value="light"/>
<choice value="dark"/>
</choices>
<default>'auto'</default>
<summary>Panel color theme</summary>
<description>Auto follows the system light/dark color scheme (org.gnome.desktop.interface color-scheme). Light and Dark force a fixed theme.</description>
</key>
<key name="panel-position" type="s">
<choices>
<choice value="top-center"/>

View file

@ -1,4 +1,4 @@
/* Strata clipboard manager stylesheet.
/* Strata dark theme (the default; loaded when the system color scheme is dark).
*
* Performance rule: NO `transition: all`, NO Clutter/CSS animations.
* Only background-color transitions on hover (GPU-composited, zero JS).

View file

@ -0,0 +1,129 @@
/* Strata light theme.
*
* GNOME Shell loads this instead of stylesheet-dark.css when the system color
* scheme is light. It imports the dark base and overrides the colors below, so
* the overrides win by load order. Colors follow GNOME Adwaita (blue #3584e4,
* link #1a5fb4, amber, red #e01b24), picked for contrast on a light panel.
*/
@import url("stylesheet-dark.css");
.strata-panel {
background-color: rgba(250, 250, 250, 0.98);
border-color: rgba(0, 0, 0, 0.14);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.28);
}
.strata-title {
color: rgba(0, 0, 0, 0.87);
}
.strata-clear-btn {
border-color: rgba(0, 0, 0, 0.18);
color: rgba(0, 0, 0, 0.55);
}
.strata-clear-btn:hover {
background-color: rgba(0, 0, 0, 0.07);
color: rgba(0, 0, 0, 0.85);
}
.strata-search {
background-color: rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.14);
color: rgba(0, 0, 0, 0.87);
caret-color: rgba(0, 0, 0, 0.8);
}
.strata-search:focus {
border-color: rgba(53, 132, 228, 0.8);
background-color: rgba(0, 0, 0, 0.035);
}
/* Placeholder ("Search...") text - legible dim gray on the light panel. */
.strata-search-hint {
color: rgba(0, 0, 0, 0.55);
}
.strata-item:hover,
.strata-item-hovered {
background-color: rgba(53, 132, 228, 0.14);
border-color: rgba(53, 132, 228, 0.45);
}
.strata-item:focus,
.strata-item-focused {
background-color: rgba(53, 132, 228, 0.16);
border-color: rgba(53, 132, 228, 0.65);
}
.strata-item:hover:focus,
.strata-item-focused.strata-item-hovered {
background-color: rgba(53, 132, 228, 0.24);
border-color: rgba(53, 132, 228, 0.78);
}
.strata-item:focus .strata-item-text,
.strata-item-focused .strata-item-text {
color: rgba(0, 0, 0, 0.95);
}
.strata-item-active {
background-color: rgba(181, 131, 10, 0.16);
border-color: rgba(181, 131, 10, 0.50);
}
.strata-item-active .strata-item-text {
color: rgba(120, 82, 0, 1.0);
}
.strata-item-active.strata-item-hovered {
background-color: rgba(181, 131, 10, 0.24);
}
.strata-item-active.strata-item-focused,
.strata-item-active:focus {
background-color: rgba(53, 132, 228, 0.20);
border-color: rgba(53, 132, 228, 0.65);
}
.strata-item-active.strata-item-focused .strata-item-text,
.strata-item-active:focus .strata-item-text {
color: rgba(0, 0, 0, 0.95);
}
.strata-item:active {
background-color: rgba(0, 0, 0, 0.10);
}
.strata-item-icon {
color: rgba(0, 0, 0, 0.55);
}
.strata-item-thumb {
border-color: rgba(0, 0, 0, 0.15);
}
.strata-item-text {
color: rgba(0, 0, 0, 0.85);
}
.strata-item-url {
color: rgba(26, 95, 180, 1.0);
}
.strata-item-subtext {
color: rgba(0, 0, 0, 0.55);
}
.strata-item:hover .strata-item-delete,
.strata-item:focus .strata-item-delete,
.strata-item-focused .strata-item-delete,
.strata-item-hovered .strata-item-delete {
color: rgba(224, 27, 36, 0.75);
}
.strata-item-delete:hover {
background-color: rgba(224, 27, 36, 0.13);
color: rgba(224, 27, 36, 1.0);
}

View file

@ -6,6 +6,8 @@ import St from 'gi://St';
import Clutter from 'gi://Clutter';
import Gio from 'gi://Gio';
import { logError } from '../util.js';
const TEXT_PREVIEW_LEN = 140;
const THUMB_SIZE = 48;
@ -22,24 +24,23 @@ function iconForMime(mimeType) {
return 'edit-copy-symbolic';
}
/** Detect if a string looks like a URL. */
function isUrl(text) {
return /^https?:\/\/.+/i.test(text.trim());
}
/** Detect if a string looks like a CSS/hex color. */
function isColor(text) {
return /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(text.trim());
}
export const ClipboardItem = GObject.registerClass({
GTypeName: 'StrataClipboardItem',
Signals: {
'activate': {},
'delete': {},
},
}, class ClipboardItem extends St.Button {
_init(id, mimeType, preview, opts = {}) {
super._init({
}, class extends St.Button {
constructor(id, mimeType, preview, opts = {}) {
super({
style_class: 'strata-item',
x_expand: true,
can_focus: true,
@ -56,13 +57,12 @@ export const ClipboardItem = GObject.registerClass({
x_expand: true,
});
// Left: icon or image thumbnail
row.add_child(this._buildLeading(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));
// Center: text preview
row.add_child(this._buildContent(mimeType, preview));
// Right: delete button
const deleteBtn = new St.Button({
style_class: 'strata-item-delete',
icon_name: 'edit-delete-symbolic',
@ -91,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({
@ -122,7 +122,7 @@ export const ClipboardItem = GObject.registerClass({
const applyStyle = () => {
try {
container.style = `background-image: url("${fileUri}"); background-size: cover; background-repeat: no-repeat;`;
} catch (_) { /* container was destroyed mid-flight */ }
} catch { /* container was destroyed mid-flight */ }
};
try {
@ -160,18 +160,18 @@ export const ClipboardItem = GObject.registerClass({
this._thumbCache?.set(id, cachePath);
applyStyle();
} catch (e) {
console.error('[Strata] Thumbnail write error:', e);
logError('Thumbnail write error', e);
this._fallbackIcon(container);
}
}
);
} catch (e) {
console.error('[Strata] Thumbnail fetch handler error:', e);
logError('Thumbnail fetch handler error', e);
this._fallbackIcon(container);
}
});
} catch (e) {
console.error('[Strata] Thumbnail render error:', e);
logError('Thumbnail render error', e);
this._fallbackIcon(container);
}
return container;
@ -187,7 +187,7 @@ export const ClipboardItem = GObject.registerClass({
x_align: Clutter.ActorAlign.CENTER,
});
container.add_child(icon);
} catch (_) {}
} catch { /* container destroyed before the icon was added */ }
}
_buildColorSwatch(hex) {
@ -200,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,20 +208,20 @@ export const ClipboardItem = GObject.registerClass({
style_class: 'strata-item-content',
});
let mainText = '';
let subText = '';
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 = new URL(preview.trim()).hostname;
} catch (_) {}
} else if (isColor(preview)) {
subText = GLib.Uri.parse(preview.trim(), GLib.UriFlags.NONE).get_host() ?? '';
} catch { /* not a parseable URI */ }
} else if (colorLike) {
mainText = preview.trim().toUpperCase();
subText = 'Color';
} else {
@ -233,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

@ -1,7 +1,6 @@
/* panel.js - Strata clipboard popup panel. */
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import St from 'gi://St';
import Clutter from 'gi://Clutter';
import Meta from 'gi://Meta';
@ -9,25 +8,18 @@ import Shell from 'gi://Shell';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import { ClipboardItem } from './clipboardItem.js';
import { logError } from '../util.js';
const SEARCH_DEBOUNCE_MS = 150;
const LOAD_MORE_THRESHOLD = 200;
function logError(label, err) {
if (err !== undefined) {
try { Gio.DBusError.strip_remote_error(err); } catch (_) {}
}
const tail = err !== undefined ? `: ${err?.message ?? err}` : '';
console.error(`[Strata] ${label}${tail}`);
}
export class StrataPanel {
constructor(proxy, settings) {
this._proxy = proxy;
this._settings = settings;
this._pageSize = settings.get_int('page-size');
this._pageSizeChangedId = settings.connect('changed::page-size',
() => { this._pageSize = settings.get_int('page-size'); });
settings.connectObject('changed::page-size',
() => { this._pageSize = settings.get_int('page-size'); }, this);
/** @type {{ id: string, mimeType: string, preview: string }[]} */
this._items = [];
/** @type {Map<string, ClipboardItem>} id → widget */
@ -53,16 +45,6 @@ export class StrataPanel {
this._buildUI();
// Theme: apply light/dark to the panel box and react to changes.
// The light.css overrides (loaded by extension.js) only take effect
// while the `.strata-theme-light` class is present on `_box`.
this._interfaceSettings = new Gio.Settings({ schema_id: 'org.gnome.desktop.interface' });
this._applyTheme();
this._themeChangedId = this._settings.connect('changed::theme',
() => this._applyTheme());
this._colorSchemeChangedId = this._interfaceSettings.connect('changed::color-scheme',
() => this._applyTheme());
// Trigger the initial load as soon as the daemon owns its bus name.
// At extension boot the daemon may still be starting (its D-Bus name
// not yet owned); the proxy fires notify::g-name-owner when that
@ -70,26 +52,8 @@ export class StrataPanel {
// and only then issue GetHistory. If the daemon is already up, the
// owner is already set and we load immediately.
this._tryInitialLoad();
this._nameOwnerId = this._proxy.connect('notify::g-name-owner',
() => this._tryInitialLoad());
}
/** True when the effective theme is light. `auto` follows the system
* color-scheme (anything other than prefer-dark counts as light). */
_effectiveIsLight() {
const mode = this._settings.get_string('theme');
if (mode === 'light') return true;
if (mode === 'dark') return false;
return this._interfaceSettings.get_string('color-scheme') !== 'prefer-dark';
}
/** Toggle the light-theme class on the panel root box. */
_applyTheme() {
if (!this._box) return;
if (this._effectiveIsLight())
this._box.add_style_class_name('strata-theme-light');
else
this._box.remove_style_class_name('strata-theme-light');
this._proxy.connectObject('notify::g-name-owner',
() => this._tryInitialLoad(), this);
}
_tryInitialLoad() {
@ -103,7 +67,6 @@ export class StrataPanel {
_buildUI() {
// Overlay container - sits above all windows.
this._overlay = new St.Widget({
layout_manager: new Clutter.FixedLayout(),
visible: false,
@ -190,7 +153,6 @@ export class StrataPanel {
this._searchEntry.get_clutter_text().connect('text-changed', () => {
this._scheduleSearch(this._searchEntry.get_text());
});
// Down arrow from search box moves focus to the first item.
this._searchEntry.get_clutter_text().connect('key-press-event', (_actor, event) => {
if (event.get_key_symbol() === Clutter.KEY_Down) {
this._focusItem(0);
@ -212,11 +174,9 @@ export class StrataPanel {
});
this._scrollView.set_child(this._itemList);
// Load more items when scrolled near the bottom.
const vadj = this._scrollView.get_vadjustment();
if (vadj) {
vadj.connect('notify::value', () => this._maybeLoadMore());
// Also re-check when the list grows (a new page just appended).
vadj.connect('notify::upper', () => this._maybeLoadMore());
}
@ -271,7 +231,6 @@ export class StrataPanel {
close() {
if (!this._visible) return;
this._visible = false;
// Clear hover state since the panel is closing.
this._hoveredWidget?.remove_style_class_name('strata-item-hovered');
this._hoveredWidget = null;
if (this._grab) {
@ -293,7 +252,6 @@ export class StrataPanel {
this._widgets.delete(id);
}
// While searching, only render if the new item matches the active query.
if (this._searchQuery && !this._matchesSearch(preview, mimeType)) {
return;
}
@ -320,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
@ -337,7 +296,6 @@ export class StrataPanel {
if (wasHovered) this._hoveredWidget = null;
widget.destroy();
this._widgets.delete(id);
// Re-assign active to the new first visible item.
if (wasActive) {
const first = this._getVisibleItems()[0];
if (first) this._setActiveWidget(first);
@ -353,27 +311,13 @@ export class StrataPanel {
this._hoveredWidget = null;
this._activeWidget = null;
this._widgets.clear();
this._thumbCache.clear();
this._itemList.destroy_all_children();
}
destroy() {
if (this._pageSizeChangedId) {
this._settings.disconnect(this._pageSizeChangedId);
this._pageSizeChangedId = 0;
}
if (this._themeChangedId) {
this._settings.disconnect(this._themeChangedId);
this._themeChangedId = 0;
}
if (this._colorSchemeChangedId && this._interfaceSettings) {
this._interfaceSettings.disconnect(this._colorSchemeChangedId);
this._colorSchemeChangedId = 0;
}
this._interfaceSettings = null;
if (this._nameOwnerId) {
this._proxy.disconnect(this._nameOwnerId);
this._nameOwnerId = 0;
}
this._settings.disconnectObject(this);
this._proxy.disconnectObject(this);
if (this._searchDebounceId) {
GLib.Source.remove(this._searchDebounceId);
this._searchDebounceId = null;
@ -395,14 +339,15 @@ export class StrataPanel {
const BATCH = 20;
for (let i = 0; i < items.length; i += BATCH) {
if (!this._overlay) return false;
await new Promise(resolve =>
// eslint-disable-next-line no-await-in-loop -- batches deliberately yield to the main loop
await new Promise(resolve => {
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
const end = Math.min(i + BATCH, items.length);
for (let j = i; j < end; j++) this._appendItemFromMeta(items[j]);
resolve();
return GLib.SOURCE_REMOVE;
})
);
});
});
}
this._loadedOffset = offset + items.length;
this._hasMore = items.length >= limit;
@ -445,7 +390,6 @@ export class StrataPanel {
// 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._loadHistory(this._loadedOffset, this._pageSize)
@ -482,12 +426,14 @@ export class StrataPanel {
if (i === 0) {
renderChunk();
} else {
await new Promise(resolve =>
// eslint-disable-next-line no-await-in-loop -- batches deliberately yield to the main loop
await new Promise(resolve => {
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
renderChunk();
resolve();
return GLib.SOURCE_REMOVE;
}));
});
});
}
}
this._searchRendered = end;
@ -543,14 +489,12 @@ export class StrataPanel {
return widget;
}
/** Focus the item at visible-list index `idx` (clamped). */
_focusItem(idx) {
const items = this._getVisibleItems();
if (items.length === 0) return;
global.stage.set_key_focus(items[Math.max(0, Math.min(idx, items.length - 1))]);
}
/** All currently-visible item actors in order. */
_getVisibleItems() {
return this._itemList.get_children().filter(c => c.visible);
}
@ -717,13 +661,11 @@ export class StrataPanel {
}
_moveItemToTop(id, widget) {
// Move data model entry to front.
const idx = this._items.findIndex(i => i.id === id);
if (idx > 0) {
const [entry] = this._items.splice(idx, 1);
this._items.unshift(entry);
}
// Move actor to position 0 in the list.
this._itemList.set_child_at_index(widget, 0);
}
@ -750,7 +692,6 @@ export class StrataPanel {
const MARGIN = 16; // px gap from screen edge
let x, y;
// Horizontal
if (position.endsWith('left'))
x = monitor.x + MARGIN;
else if (position.endsWith('right'))

View file

@ -0,0 +1,11 @@
/* util.js - shared helpers for the Strata extension. */
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
export function logError(label, err) {
if (err instanceof GLib.Error)
Gio.DBusError.strip_remote_error(err);
const tail = err !== undefined ? `: ${err?.message ?? err}` : '';
console.error(`[Strata] ${label}${tail}`);
}