diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/LICENSE b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/LICENSE
new file mode 100644
index 0000000..a2d684f
--- /dev/null
+++ b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/LICENSE
@@ -0,0 +1,25 @@
+======================
+The MIT License (MIT)
+======================
+
+Copyright (c) 2022, Alex Saveau
+Copyright (c) 2014, Yotam Bar-On
+-----------------------------------------
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+*The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.*
+
+**THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.**
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/README.md b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/README.md
new file mode 100644
index 0000000..e2edeb1
--- /dev/null
+++ b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/README.md
@@ -0,0 +1,68 @@
+# Gnome Clipboard History
+
+[Gnome Clipboard History](https://extensions.gnome.org/extension/4839/clipboard-history/) is a
+clipboard manager GNOME extension that saves what you've copied into an easily accessible,
+searchable history panel.
+
+The extension is a rewrite of
+[Clipboard Indicator](https://github.com/Tudmotu/gnome-shell-extension-clipboard-indicator) with
+vastly improved performance, new features, and
+[bug fixes](https://github.com/Tudmotu/gnome-shell-extension-clipboard-indicator/pull/338).
+
+A technical overview is available at https://alexsaveau.dev/blog/gch.
+
+## Project status: replaced by Ringboard
+
+Gnome Clipboard History is now in maintenance mode as it is being replaced by
+[Ringboard](https://github.com/SUPERCILEX/clipboard-history). I'm still accepting PRs for small
+improvements and bug fixes (such as supporting the latest Gnome version), but no new development
+will take place.
+
+## Download
+
+[
](https://extensions.gnome.org/extension/4839/clipboard-history/)
+
+## Tips
+
+
+
+- Open the panel from anywhere with Super + Shift + V.
+- Modify shortcuts in settings or delete them by hitting backspace while editing a shortcut.
+- Use the `Only save favorites to disk` feature to wipe your non-favorited items on shutdown.
+- Use `Private mode` to temporarily stop processing copied items.
+- Use keyboard shortcuts while the panel is open:
+ - Ctrl + N where `N` is a number from 1 to 9 to select the Nth
+ non-favorited entry.
+ - Super + Ctrl + N where `N` is a number from 1 to 9 to select
+ the Nth favorited entry.
+ - Ctrl + p/n to navigate to the previous/next page.
+ - Ctrl + Alt + S to open settings.
+ - / to search.
+ - F to (un)favorite a highlighted item.
+- Search uses case-insensitive [regex](https://regex101.com/?flavor=javascript).
+
+## Install from source
+
+A note on versioning:
+
+- The `master` branch and `1.4.x` tags support GNOME 45.
+- The `pre-45` branch and `1.3.x` (or earlier) tags support GNOME 40-44.
+
+### Build
+
+```shell
+cd ~/.local/share/gnome-shell/extensions/ && \
+ git clone https://github.com/SUPERCILEX/gnome-clipboard-history.git clipboard-history@alexsaveau.dev && \
+ cd clipboard-history@alexsaveau.dev && \
+ make
+```
+
+### Restart GNOME
+
+Alt + F2 then type `r`.
+
+### Install
+
+```shell
+gnome-extensions enable clipboard-history@alexsaveau.dev
+```
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/confirmDialog.js b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/confirmDialog.js
new file mode 100644
index 0000000..1b06006
--- /dev/null
+++ b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/confirmDialog.js
@@ -0,0 +1,77 @@
+import Clutter from 'gi://Clutter';
+import St from 'gi://St';
+import GObject from 'gi://GObject';
+
+import * as ModalDialog from 'resource:///org/gnome/shell/ui/modalDialog.js';
+
+let _openDialog;
+
+export function openConfirmDialog(
+ title,
+ message,
+ sub_message,
+ ok_label,
+ cancel_label,
+ callback,
+) {
+ if (!_openDialog) {
+ _openDialog = new ConfirmDialog(
+ title,
+ message + '\n' + sub_message,
+ ok_label,
+ cancel_label,
+ callback,
+ ).open();
+ }
+}
+
+const ConfirmDialog = GObject.registerClass(
+ class ConfirmDialog extends ModalDialog.ModalDialog {
+ _init(title, desc, ok_label, cancel_label, callback) {
+ super._init();
+
+ let main_box = new St.BoxLayout({
+ vertical: false,
+ });
+ this.contentLayout.add_child(main_box);
+
+ let message_box = new St.BoxLayout({
+ vertical: true,
+ });
+ main_box.add_child(message_box);
+
+ let subject_label = new St.Label({
+ style: 'font-weight: bold',
+ x_align: Clutter.ActorAlign.CENTER,
+ text: title,
+ });
+ message_box.add_child(subject_label);
+
+ let desc_label = new St.Label({
+ style: 'padding-top: 12px',
+ x_align: Clutter.ActorAlign.CENTER,
+ text: desc,
+ });
+ message_box.add_child(desc_label);
+
+ this.setButtons([
+ {
+ label: cancel_label,
+ action: () => {
+ this.close();
+ _openDialog = null;
+ },
+ key: Clutter.Escape,
+ },
+ {
+ label: ok_label,
+ action: () => {
+ this.close();
+ callback();
+ _openDialog = null;
+ },
+ },
+ ]);
+ }
+ },
+);
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/dataStructures.js b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/dataStructures.js
new file mode 100644
index 0000000..4ea4bb9
--- /dev/null
+++ b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/dataStructures.js
@@ -0,0 +1,417 @@
+// Derived from
+// https://github.com/wooorm/linked-list/blob/d2390fe1cab9f780cfd34fa31c8fa8ede4ad674d/index.js
+
+export const TYPE_TEXT = 'text';
+
+// Creates a new `Iterator` for looping over the `List`.
+class Iterator {
+ constructor(item) {
+ this.item = item;
+ }
+
+ // Move the `Iterator` to the next item.
+ next() {
+ this.value = this.item;
+ this.done = !this.item;
+ this.item = this.item ? this.item.next : undefined;
+ return this;
+ }
+}
+
+// Creates a new `Item`:
+// An item is a bit like DOM node: It knows only about its "parent" (`list`),
+// the item before it (`prev`), and the item after it (`next`).
+export class LLNode {
+ // Prepends the given item *before* the item operated on.
+ prepend(item) {
+ const list = this.list;
+
+ if (!item || !item.append || !item.prepend || !item.detach) {
+ throw new Error(
+ 'An argument without append, prepend, or detach methods was given to `Item#prepend`.',
+ );
+ }
+
+ // If self is detached, return false.
+ if (!list) {
+ return false;
+ }
+ if (this === item) {
+ return false;
+ }
+
+ // Detach the prependee.
+ const transient = this.list === item.list;
+ item.detach(transient);
+
+ // If self has a previous item...
+ if (this.prev) {
+ item.prev = this.prev;
+ this.prev.next = item;
+ }
+
+ // Connect the prependee.
+ item.next = this;
+ item.list = list;
+
+ // Set the previous item of self to the prependee.
+ this.prev = item;
+
+ // If self is the first item in the parent list, link the lists first item to
+ // the prependee.
+ if (this === list.head) {
+ list.head = item;
+ }
+
+ // If the the parent list has no last item, link the lists last item to self.
+ if (!list.tail) {
+ list.tail = this;
+ }
+
+ list.length++;
+ if (!transient) {
+ item._addToIndex();
+ }
+
+ return item;
+ }
+
+ // Appends the given item *after* the item operated on.
+ append(item) {
+ const list = this.list;
+
+ if (!item || !item.append || !item.prepend || !item.detach) {
+ throw new Error(
+ 'An argument without append, prepend, or detach methods was given to `Item#append`.',
+ );
+ }
+
+ if (!list) {
+ return false;
+ }
+ if (this === item) {
+ return false;
+ }
+
+ // Detach the appendee.
+ const transient = this.list === item.list;
+ item.detach(transient);
+
+ // If self has a next item...
+ if (this.next) {
+ item.next = this.next;
+ this.next.prev = item;
+ }
+
+ // Connect the appendee.
+ item.prev = this;
+ item.list = list;
+
+ // Set the next item of self to the appendee.
+ this.next = item;
+
+ // If the the parent list has no last item or if self is the parent lists last
+ // item, link the lists last item to the appendee.
+ if (this === list.tail || !list.tail) {
+ list.tail = item;
+ }
+
+ list.length++;
+ if (!transient) {
+ item._addToIndex();
+ }
+
+ return item;
+ }
+
+ // Detaches the item operated on from its parent list.
+ detach(transient) {
+ const list = this.list;
+
+ if (!list) {
+ return this;
+ }
+ if (!transient) {
+ this._removeFromIndex();
+ }
+
+ // If self is the last item in the parent list, link the lists last item to
+ // the previous item.
+ if (list.tail === this) {
+ list.tail = this.prev;
+ }
+
+ // If self is the first item in the parent list, link the lists first item to
+ // the next item.
+ if (list.head === this) {
+ list.head = this.next;
+ }
+
+ // If both the last and first items in the parent list are the same, remove
+ // the link to the last item.
+ if (list.tail === list.head) {
+ list.tail = null;
+ }
+
+ // If a previous item exists, link its next item to selfs next item.
+ if (this.prev) {
+ this.prev.next = this.next;
+ }
+
+ // If a next item exists, link its previous item to selfs previous item.
+ if (this.next) {
+ this.next.prev = this.prev;
+ }
+
+ // Remove links from self to both the next and previous items, and to the
+ // parent list.
+ this.prev = this.next = this.list = null;
+
+ list.length--;
+
+ return this;
+ }
+
+ nextCyclic() {
+ return this.next || this.list.head;
+ }
+
+ prevCyclic() {
+ return this.prev || this.list.last();
+ }
+
+ _addToIndex() {
+ const hash = this._hash();
+ if (hash === undefined || hash === null) {
+ return;
+ }
+
+ if (this.type === TYPE_TEXT) {
+ this.list.bytes += this.text.length;
+ }
+
+ let entries = this.list.invertedIndex[hash];
+ if (!entries) {
+ entries = [];
+ this.list.invertedIndex[hash] = entries;
+ }
+ entries.push(this.id);
+ this.list.idsToItems[this.id] = this;
+ }
+
+ _removeFromIndex() {
+ const hash = this._hash();
+ if (hash === undefined || hash === null) {
+ return;
+ }
+
+ if (this.type === TYPE_TEXT) {
+ this.list.bytes -= this.text.length;
+ }
+
+ const entries = this.list.invertedIndex[hash];
+ if (entries.length === 1) {
+ delete this.list.invertedIndex[hash];
+ } else {
+ entries.splice(entries.indexOf(this.id), 1);
+ }
+ delete this.list.idsToItems[this.id];
+ }
+
+ _hash() {
+ if (this.type === TYPE_TEXT) {
+ return _hashText(this.text);
+ } else {
+ return null;
+ }
+ }
+}
+
+LLNode.prototype.next = LLNode.prototype.prev = LLNode.prototype.list = null;
+
+// Creates a new List: A linked list is a bit like an Array, but knows nothing
+// about how many items are in it, and knows only about its first (`head`) and
+// last (`tail`) items.
+// Each item (e.g. `head`, `tail`, &c.) knows which item comes before or after
+// it (its more like the implementation of the DOM in JavaScript).
+export class LinkedList {
+ // Creates a new list from the arguments (each a list item) passed in.
+ static of(...items) {
+ return appendAll(new this(), items);
+ }
+
+ // Creates a new list from the given array-like object (each a list item) passed
+ // in.
+ static from(items) {
+ return appendAll(new this(), items);
+ }
+
+ constructor(...items) {
+ appendAll(this, items);
+ this.idsToItems = {};
+ this.invertedIndex = {};
+ /** Note: this isn't an accurate count because of UTF encoding and other JS mumbo jumbo. */
+ this.bytes = 0;
+ }
+
+ // Returns the list's items as an array.
+ // This does *not* detach the items.
+ toArray() {
+ let item = this.head;
+ const result = [];
+
+ while (item) {
+ result.push(item);
+ item = item.next;
+ }
+
+ return result;
+ }
+
+ // Prepends the given item to the list.
+ // `item` will be the new first item (`head`).
+ prepend(item) {
+ if (!item) {
+ return false;
+ }
+
+ if (!item.append || !item.prepend || !item.detach) {
+ throw new Error(
+ 'An argument without append, prepend, or detach methods was given to `List#prepend`.',
+ );
+ }
+
+ if (this.head) {
+ return this.head.prepend(item);
+ }
+
+ item.detach();
+ item.list = this;
+ this.head = item;
+ this.length++;
+
+ item._addToIndex();
+
+ return item;
+ }
+
+ // Appends the given item to the list.
+ // `item` will be the new last item (`tail`) if the list had a first item, and
+ // its first item (`head`) otherwise.
+ append(item) {
+ if (!item) {
+ return false;
+ }
+
+ if (!item.append || !item.prepend || !item.detach) {
+ throw new Error(
+ 'An argument without append, prepend, or detach methods was given to `List#append`.',
+ );
+ }
+
+ // If self has a last item, defer appending to the last items append method,
+ // and return the result.
+ if (this.tail) {
+ return this.tail.append(item);
+ }
+
+ // If self has a first item, defer appending to the first items append method,
+ // and return the result.
+ if (this.head) {
+ return this.head.append(item);
+ }
+
+ // ...otherwise, there is no `tail` or `head` item yet.
+ item.detach();
+ item.list = this;
+ this.head = item;
+ this.length++;
+
+ item._addToIndex();
+
+ return item;
+ }
+
+ last() {
+ return this.tail || this.head;
+ }
+
+ findById(id) {
+ return this.idsToItems[id];
+ }
+
+ findTextItem(text) {
+ const entries = this.invertedIndex[_hashText(text)];
+ if (!entries) {
+ return null;
+ }
+
+ for (let i = entries.length - 1; i >= 0; i--) {
+ const item = this.idsToItems[entries[i]];
+ if (item.type === TYPE_TEXT && item.text === text) {
+ return item;
+ }
+ }
+ return null;
+ }
+
+ // Creates an iterator from the list.
+ [Symbol.iterator]() {
+ return new Iterator(this.head);
+ }
+}
+
+LinkedList.prototype.length = 0;
+LinkedList.prototype.tail = LinkedList.prototype.head = null;
+
+// Creates a new list from the items passed in.
+export function appendAll(list, items) {
+ let index;
+ let item;
+ let iterator;
+
+ if (!items) {
+ return list;
+ }
+
+ if (items[Symbol.iterator]) {
+ iterator = items[Symbol.iterator]();
+ item = {};
+
+ while (!item.done) {
+ item = iterator.next();
+ list.append(item && item.value);
+ }
+ } else {
+ index = -1;
+
+ while (++index < items.length) {
+ list.append(items[index]);
+ }
+ }
+
+ return list;
+}
+
+function _hashText(text) {
+ // The goal of this hash function is to be extremely fast while minimizing collisions. To do
+ // this, we make an assumption about our data. If users copy text, the guess is that there is
+ // a very low likelihood of collisions when the text is very long. For example, why would
+ // someone copy two different pieces of text that are exactly 29047 characters long? However, for
+ // smaller pieces of text, it's very easy to get length collisions. For example, I can copy "the"
+ // and "123" to cause a collision. Thus, our hash function returns the string length for longer
+ // strings while using an ok-ish hash for short strings.
+
+ if (text.length > 500) {
+ return text.length;
+ }
+
+ // Copied from https://stackoverflow.com/a/7616484/4548500
+ let hash = 0;
+ for (let i = 0; i < text.length; i++) {
+ let chr = text.charCodeAt(i);
+ hash = (hash << 5) - hash + chr;
+ hash |= 0; // Convert to integer
+ }
+ return hash;
+}
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/extension.js b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/extension.js
new file mode 100644
index 0000000..9a3a1e6
--- /dev/null
+++ b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/extension.js
@@ -0,0 +1,1352 @@
+import Clutter from 'gi://Clutter';
+import GObject from 'gi://GObject';
+import GLib from 'gi://GLib';
+import Meta from 'gi://Meta';
+import Shell from 'gi://Shell';
+import St from 'gi://St';
+
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+import * as MessageTray from 'resource:///org/gnome/shell/ui/messageTray.js';
+import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
+import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
+
+import {
+ Extension,
+ gettext as _,
+} from 'resource:///org/gnome/shell/extensions/extension.js';
+
+import { ensureActorVisibleInScrollView } from 'resource:///org/gnome/shell/misc/animationUtils.js';
+
+import * as Store from './store.js';
+import * as DS from './dataStructures.js';
+import { openConfirmDialog } from './confirmDialog.js';
+import SettingsFields from './settingsFields.js';
+
+const Clipboard = St.Clipboard.get_default();
+const VirtualKeyboard = (() => {
+ let VirtualKeyboard;
+ return () => {
+ if (!VirtualKeyboard) {
+ VirtualKeyboard = Clutter.get_default_backend()
+ .get_default_seat()
+ .create_virtual_device(Clutter.InputDeviceType.KEYBOARD_DEVICE);
+ }
+ return VirtualKeyboard;
+ };
+})();
+
+const SETTING_KEY_CLEAR_HISTORY = 'clear-history';
+const SETTING_KEY_PREV_ENTRY = 'prev-entry';
+const SETTING_KEY_NEXT_ENTRY = 'next-entry';
+const SETTING_KEY_TOGGLE_MENU = 'toggle-menu';
+const SETTING_KEY_PRIVATE_MODE = 'toggle-private-mode';
+const INDICATOR_ICON = 'edit-paste-symbolic';
+
+const PAGE_SIZE = 50;
+const MAX_VISIBLE_CHARS = 200;
+
+let MAX_REGISTRY_LENGTH;
+let MAX_BYTES;
+let WINDOW_WIDTH_PERCENTAGE;
+let CACHE_ONLY_FAVORITES;
+let MOVE_ITEM_FIRST;
+let ENABLE_KEYBINDING;
+let PRIVATE_MODE;
+let NOTIFY_ON_COPY;
+let CONFIRM_ON_CLEAR;
+let MAX_TOPBAR_LENGTH;
+let TOPBAR_DISPLAY_MODE; // 0 - only icon, 1 - only clipboard content, 2 - both, 3 - none
+let DISABLE_DOWN_ARROW;
+let STRIP_TEXT;
+let PASTE_ON_SELECTION;
+let PROCESS_PRIMARY_SELECTION;
+let IGNORE_PASSWORD_MIMES;
+
+class ClipboardIndicator extends PanelMenu.Button {
+ _init(extension) {
+ super._init(0, extension.indicatorName, false);
+
+ this.extension = extension;
+ this.settings = extension.getSettings();
+
+ this._shortcutsBindingIds = [];
+
+ const hbox = new St.BoxLayout({
+ style_class: 'panel-status-menu-box clipboard-indicator-hbox',
+ });
+ this.icon = new St.Icon({
+ icon_name: INDICATOR_ICON,
+ style_class: 'system-status-icon clipboard-indicator-icon',
+ });
+ hbox.add_child(this.icon);
+ this._buttonText = new St.Label({
+ text: '',
+ y_align: Clutter.ActorAlign.CENTER,
+ });
+ hbox.add_child(this._buttonText);
+ this._downArrow = PopupMenu.arrowIcon(St.Side.BOTTOM);
+ hbox.add_child(this._downArrow);
+ this.add_child(hbox);
+
+ this._fetchSettings();
+ this._buildMenu();
+ this._updateTopbarLayout();
+ }
+
+ destroy() {
+ this._disconnectSettings();
+ this._unbindShortcuts();
+ this._disconnectSelectionListener();
+
+ if (this._searchFocusHackCallbackId) {
+ GLib.Source.source_remove(this._searchFocusHackCallbackId);
+ this._searchFocusHackCallbackId = undefined;
+ }
+ if (this._pasteHackCallbackId) {
+ GLib.Source.source_remove(this._pasteHackCallbackId);
+ this._pasteHackCallbackId = undefined;
+ }
+
+ super.destroy();
+ }
+
+ _buildMenu() {
+ this.searchEntry = new St.Entry({
+ name: 'searchEntry',
+ style_class: 'search-entry ci-history-search-entry',
+ can_focus: true,
+ hint_text: _('Search clipboard history…'),
+ track_hover: true,
+ x_expand: true,
+ y_expand: true,
+ });
+
+ const entryItem = new PopupMenu.PopupBaseMenuItem({
+ style_class: 'ci-history-search-section',
+ reactive: false,
+ can_focus: false,
+ });
+ entryItem.add_child(this.searchEntry);
+ this.menu.addMenuItem(entryItem);
+
+ this.menu.connect('open-state-changed', (self, open) => {
+ if (open) {
+ this._setMenuWidth();
+ this.searchEntry.set_text('');
+ this._searchFocusHackCallbackId = GLib.timeout_add(
+ GLib.PRIORITY_DEFAULT,
+ 1,
+ () => {
+ global.stage.set_key_focus(this.searchEntry);
+ this._searchFocusHackCallbackId = undefined;
+ return false;
+ },
+ );
+ }
+ });
+
+ // Create menu sections for items
+ // Favorites
+ this.favoritesSection = new PopupMenu.PopupMenuSection();
+
+ this.scrollViewFavoritesMenuSection = new PopupMenu.PopupMenuSection();
+ const favoritesScrollView = new St.ScrollView({
+ style_class: 'ci-history-menu-section',
+ overlay_scrollbars: true,
+ });
+ favoritesScrollView.add_child(this.favoritesSection.actor);
+
+ this.scrollViewFavoritesMenuSection.actor.add_child(favoritesScrollView);
+ this.menu.addMenuItem(this.scrollViewFavoritesMenuSection);
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ // History
+ this.historySection = new PopupMenu.PopupMenuSection();
+
+ this.scrollViewMenuSection = new PopupMenu.PopupMenuSection();
+ this.historyScrollView = new St.ScrollView({
+ style_class: 'ci-history-menu-section',
+ overlay_scrollbars: true,
+ });
+ this.historyScrollView.add_child(this.historySection.actor);
+
+ this.scrollViewMenuSection.actor.add_child(this.historyScrollView);
+
+ this.menu.addMenuItem(this.scrollViewMenuSection);
+
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ const actionsSection = new PopupMenu.PopupMenuSection();
+ const actionsBox = new St.BoxLayout({
+ style_class: 'ci-history-actions-section',
+ vertical: false,
+ });
+
+ actionsSection.actor.add_child(actionsBox);
+ this.menu.addMenuItem(actionsSection);
+
+ const prevPage = new PopupMenu.PopupBaseMenuItem();
+ prevPage.add_child(
+ new St.Icon({
+ icon_name: 'go-previous-symbolic',
+ style_class: 'popup-menu-icon',
+ }),
+ );
+ prevPage.connect('activate', this._navigatePrevPage.bind(this));
+ actionsBox.add_child(prevPage);
+
+ const nextPage = new PopupMenu.PopupBaseMenuItem();
+ nextPage.add_child(
+ new St.Icon({
+ icon_name: 'go-next-symbolic',
+ style_class: 'popup-menu-icon',
+ }),
+ );
+ nextPage.connect('activate', this._navigateNextPage.bind(this));
+ actionsBox.add_child(nextPage);
+
+ actionsBox.add_child(new St.BoxLayout({ x_expand: true }));
+
+ this.privateModeMenuItem = new PopupMenu.PopupSwitchMenuItem(
+ _('Private mode'),
+ PRIVATE_MODE,
+ { reactive: true },
+ );
+ this.privateModeMenuItem.connect('toggled', () => {
+ this.settings.set_boolean(
+ SettingsFields.PRIVATE_MODE,
+ this.privateModeMenuItem.state,
+ );
+ });
+ actionsBox.add_child(this.privateModeMenuItem);
+ this._updatePrivateModeState();
+
+ const clearMenuItem = new PopupMenu.PopupBaseMenuItem();
+ clearMenuItem.add_child(
+ new St.Icon({
+ icon_name: 'edit-delete-symbolic',
+ style_class: 'popup-menu-icon',
+ }),
+ );
+ actionsBox.add_child(clearMenuItem);
+
+ const settingsMenuItem = new PopupMenu.PopupBaseMenuItem();
+ settingsMenuItem.add_child(
+ new St.Icon({
+ icon_name: 'emblem-system-symbolic',
+ style_class: 'popup-menu-icon',
+ }),
+ );
+ settingsMenuItem.connect('activate', this._openSettings.bind(this));
+ actionsBox.add_child(settingsMenuItem);
+
+ if (ENABLE_KEYBINDING) {
+ this._bindShortcuts();
+ }
+ this.menu.actor.connect('key-press-event', (_, event) =>
+ this._handleGlobalKeyEvent(event),
+ );
+
+ Store.buildClipboardStateFromLog(
+ (entries, favoriteEntries, nextId, nextDiskId) => {
+ /**
+ * This field stores the number of items in the historySection to avoid calling _getMenuItems
+ * since that method is slow.
+ */
+ this.activeHistoryMenuItems = 0;
+ /**
+ * These two IDs are extremely important: making a mistake with either one breaks the
+ * extension. Both IDs are globally unique within compaction intervals. The normal ID is
+ * *always* present and valid -- it allows us to build an inverted index so we can find
+ * previously copied items in O(1) time. The Disk ID is only present when we cache all
+ * entries. This additional complexity is needed to know what the ID of an item is on disk as
+ * compared to in memory when we're only caching favorites.
+ */
+ this.nextId = nextId;
+ this.nextDiskId = nextDiskId || nextId;
+ /**
+ * DS.LinkedList is the actual clipboard history and source of truth. Never use historySection
+ * or favoritesSection as the source of truth as these may get outdated during pagination.
+ *
+ * Entries *may* have a menuItem attached, meaning they are currently visible. On the other
+ * hand, menu items must always have an entry attached.
+ */
+ this.entries = entries;
+ this.favoriteEntries = favoriteEntries;
+
+ this.currentlySelectedEntry = entries.last();
+ this._restoreFavoritedEntries();
+ this._maybeRestoreMenuPages();
+
+ this._settingsChangedId = this.settings.connect(
+ 'changed',
+ this._onSettingsChange.bind(this),
+ );
+
+ this.searchEntry
+ .get_clutter_text()
+ .connect('text-changed', this._onSearchTextChanged.bind(this));
+ clearMenuItem.connect('activate', this._removeAll.bind(this));
+
+ this._setupSelectionChangeListener();
+ },
+ );
+ }
+
+ _setMenuWidth() {
+ const display = global.display;
+ const screen_width = display.get_monitor_geometry(
+ display.get_primary_monitor(),
+ ).width;
+
+ this.menu.actor.width = screen_width * (WINDOW_WIDTH_PERCENTAGE / 100);
+ }
+
+ _handleGlobalKeyEvent(event) {
+ this._handleCtrlSelectKeyEvent(event);
+ this._handleSettingsKeyEvent(event);
+ this._handleNavigationKeyEvent(event);
+ this._handleFocusSearchKeyEvent(event);
+ }
+
+ _handleCtrlSelectKeyEvent(event) {
+ if (!event.has_control_modifier()) {
+ return;
+ }
+
+ const index = parseInt(event.get_key_unicode()); // Starts at 1
+ if (isNaN(index) || index <= 0) {
+ return;
+ }
+
+ const items =
+ event.get_state() === 68 // Ctrl + Super
+ ? this.favoritesSection._getMenuItems()
+ : this.historySection._getMenuItems();
+ if (index > items.length) {
+ return;
+ }
+
+ this._onMenuItemSelectedAndMenuClose(items[index - 1]);
+ }
+
+ _handleSettingsKeyEvent(event) {
+ if (event.get_state() !== 12 || event.get_key_unicode() !== 's') {
+ return;
+ }
+
+ this._openSettings();
+ }
+
+ _handleNavigationKeyEvent(event) {
+ if (!event.has_control_modifier()) {
+ return;
+ }
+
+ if (event.get_key_unicode() === 'n') {
+ this._navigateNextPage();
+ } else if (event.get_key_unicode() === 'p') {
+ this._navigatePrevPage();
+ }
+ }
+
+ _handleFocusSearchKeyEvent(event) {
+ if (event.get_key_unicode() !== '/') {
+ return;
+ }
+
+ global.stage.set_key_focus(this.searchEntry);
+ }
+
+ _addEntry(entry, selectEntry, updateClipboard, insertIndex) {
+ if (!entry.favorite && this.activeHistoryMenuItems >= PAGE_SIZE) {
+ const items = this.historySection._getMenuItems();
+ const item = items[items.length - 1];
+ this._rewriteMenuItem(item, entry);
+ this.historySection.moveMenuItem(item, 0);
+
+ if (selectEntry) {
+ this._selectEntry(entry, updateClipboard);
+ }
+ return;
+ }
+
+ const menuItem = new PopupMenu.PopupMenuItem('', { hover: false });
+ menuItem.setOrnament(PopupMenu.Ornament.NONE);
+
+ menuItem.entry = entry;
+ entry.menuItem = menuItem;
+
+ menuItem.connect(
+ 'activate',
+ this._onMenuItemSelectedAndMenuClose.bind(this),
+ );
+ menuItem.connect('key-press-event', (_, event) =>
+ this._handleMenuItemKeyEvent(event, menuItem),
+ );
+
+ this._setEntryLabel(menuItem);
+
+ // Favorite button
+ const icon_name = entry.favorite
+ ? 'starred-symbolic'
+ : 'non-starred-symbolic';
+ const iconfav = new St.Icon({
+ icon_name: icon_name,
+ style_class: 'system-status-icon',
+ });
+
+ const icofavBtn = new St.Button({
+ style_class: 'ci-action-btn',
+ can_focus: true,
+ child: iconfav,
+ x_align: Clutter.ActorAlign.END,
+ x_expand: true,
+ y_expand: true,
+ });
+
+ menuItem.actor.add_child(icofavBtn);
+ icofavBtn.connect('clicked', () => {
+ this._favoriteToggle(menuItem);
+ });
+
+ // Delete button
+ const icon = new St.Icon({
+ icon_name: 'edit-delete-symbolic',
+ style_class: 'system-status-icon',
+ });
+
+ const icoBtn = new St.Button({
+ style_class: 'ci-action-btn',
+ can_focus: true,
+ child: icon,
+ x_align: Clutter.ActorAlign.END,
+ x_expand: false,
+ y_expand: true,
+ });
+
+ menuItem.actor.add_child(icoBtn);
+ icoBtn.connect('clicked', () => {
+ this._deleteEntryAndRestoreLatest(menuItem.entry);
+ });
+
+ menuItem.connect('destroy', () => {
+ delete menuItem.entry.menuItem;
+ if (!menuItem.entry.favorite) {
+ this.activeHistoryMenuItems--;
+ }
+ });
+ menuItem.connect('key-focus-in', () => {
+ if (!menuItem.entry.favorite) {
+ ensureActorVisibleInScrollView(this.historyScrollView, menuItem);
+ }
+ });
+
+ if (entry.favorite) {
+ this.favoritesSection.addMenuItem(menuItem, insertIndex);
+ } else {
+ this.historySection.addMenuItem(menuItem, insertIndex);
+
+ this.activeHistoryMenuItems++;
+ }
+
+ if (selectEntry) {
+ this._selectEntry(entry, updateClipboard);
+ }
+ }
+
+ _handleMenuItemKeyEvent(event, menuItem) {
+ if (event.get_key_unicode() === 'f') {
+ this._favoriteToggle(menuItem);
+ }
+ if (event.get_key_code() === 119) {
+ const next = menuItem.entry.prev || menuItem.entry.next;
+ if (next?.menuItem) {
+ global.stage.set_key_focus(next.menuItem);
+ }
+ this._deleteEntryAndRestoreLatest(menuItem.entry);
+ }
+ }
+
+ _updateButtonText(entry) {
+ if (
+ !(TOPBAR_DISPLAY_MODE === 1 || TOPBAR_DISPLAY_MODE === 2) ||
+ (entry && entry.type !== DS.TYPE_TEXT)
+ ) {
+ return;
+ }
+
+ if (PRIVATE_MODE) {
+ this._buttonText.set_text('…');
+ } else if (entry) {
+ this._buttonText.set_text(this._truncated(entry.text, MAX_TOPBAR_LENGTH));
+ } else {
+ this._buttonText.set_text('');
+ }
+ }
+
+ _setEntryLabel(menuItem) {
+ const entry = menuItem.entry;
+ if (entry.type === DS.TYPE_TEXT) {
+ menuItem.label.set_text(this._truncated(entry.text, MAX_VISIBLE_CHARS));
+ } else {
+ throw new TypeError('Unknown type: ' + entry.type);
+ }
+ }
+
+ _favoriteToggle(menuItem) {
+ const entry = menuItem.entry;
+ const wasSelected = this.currentlySelectedEntry?.id === entry.id;
+
+ // Move to front (end of list)
+ (entry.favorite ? this.entries : this.favoriteEntries).append(entry);
+ this._removeEntry(entry);
+ entry.favorite = !entry.favorite;
+ this._addEntry(entry, wasSelected, false, 0);
+ this._maybeRestoreMenuPages();
+ global.stage.set_key_focus(entry.menuItem);
+
+ if (CACHE_ONLY_FAVORITES && !entry.favorite) {
+ if (entry.diskId) {
+ Store.deleteTextEntry(entry.diskId, true);
+ delete entry.diskId;
+ }
+ return;
+ }
+
+ if (entry.diskId) {
+ Store.updateFavoriteStatus(entry.diskId, entry.favorite);
+ } else {
+ entry.diskId = this.nextDiskId++;
+
+ Store.storeTextEntry(entry.text);
+ Store.updateFavoriteStatus(entry.diskId, true);
+ }
+ }
+
+ _removeAll() {
+ if (CONFIRM_ON_CLEAR) {
+ this._confirmRemoveAll();
+ } else {
+ this._clearHistory();
+ }
+ }
+
+ _confirmRemoveAll() {
+ const title = _('Clear all?');
+ const message = _('Are you sure you want to delete all clipboard items?');
+ const sub_message = _('This operation cannot be undone.');
+
+ openConfirmDialog(
+ title,
+ message,
+ sub_message,
+ _('Clear'),
+ _('Cancel'),
+ () => {
+ this._clearHistory();
+ },
+ );
+ }
+
+ _clearHistory() {
+ if (this.currentlySelectedEntry && !this.currentlySelectedEntry.favorite) {
+ this._resetSelectedMenuItem(true);
+ }
+
+ // Favorites aren't touched when clearing history
+ this.entries = new DS.LinkedList();
+ this.historySection.removeAll();
+
+ Store.resetDatabase(this._currentStateBuilder.bind(this));
+ }
+
+ _removeEntry(entry, fullyDelete, humanGenerated) {
+ if (fullyDelete) {
+ entry.detach();
+
+ if (entry.diskId) {
+ Store.deleteTextEntry(entry.diskId, entry.favorite);
+ }
+ }
+
+ if (entry.id === this.currentlySelectedEntry?.id) {
+ this._resetSelectedMenuItem(humanGenerated);
+ }
+ entry.menuItem?.destroy();
+ if (fullyDelete) {
+ this._maybeRestoreMenuPages();
+ }
+ }
+
+ _pruneOldestEntries() {
+ let entry = this.entries.head;
+ while (
+ entry &&
+ (this.entries.length > MAX_REGISTRY_LENGTH ||
+ this.entries.bytes > MAX_BYTES)
+ ) {
+ const next = entry.next;
+ this._removeEntry(entry, true);
+ entry = next;
+ }
+
+ Store.maybePerformLogCompaction(this._currentStateBuilder.bind(this));
+ }
+
+ _selectEntry(entry, updateClipboard, triggerPaste) {
+ this.currentlySelectedEntry?.menuItem?.setOrnament(PopupMenu.Ornament.NONE);
+ this.currentlySelectedEntry = entry;
+
+ entry.menuItem?.setOrnament(PopupMenu.Ornament.DOT);
+ this._updateButtonText(entry);
+ if (updateClipboard !== false) {
+ if (entry.type === DS.TYPE_TEXT) {
+ this._setClipboardText(entry.text);
+ } else {
+ throw new TypeError('Unknown type: ' + entry.type);
+ }
+
+ if (PASTE_ON_SELECTION && triggerPaste) {
+ this._triggerPasteHack();
+ }
+ }
+ }
+
+ _setClipboardText(text) {
+ if (this._debouncing !== undefined) {
+ this._debouncing++;
+ }
+
+ Clipboard.set_text(St.ClipboardType.CLIPBOARD, text);
+ Clipboard.set_text(St.ClipboardType.PRIMARY, text);
+ }
+
+ _triggerPasteHack() {
+ this._pasteHackCallbackId = GLib.timeout_add(
+ GLib.PRIORITY_DEFAULT,
+ 1, // Just post to the end of the event loop
+ () => {
+ const SHIFT_L = 42;
+ const INSERT = 110;
+
+ const eventTime = Clutter.get_current_event_time() * 1000;
+ VirtualKeyboard().notify_key(
+ eventTime,
+ SHIFT_L,
+ Clutter.KeyState.PRESSED,
+ );
+ VirtualKeyboard().notify_key(
+ eventTime,
+ INSERT,
+ Clutter.KeyState.PRESSED,
+ );
+ VirtualKeyboard().notify_key(
+ eventTime,
+ INSERT,
+ Clutter.KeyState.RELEASED,
+ );
+ VirtualKeyboard().notify_key(
+ eventTime,
+ SHIFT_L,
+ Clutter.KeyState.RELEASED,
+ );
+
+ this._pasteHackCallbackId = undefined;
+ return false;
+ },
+ );
+ }
+
+ _onMenuItemSelectedAndMenuClose(menuItem) {
+ this._moveEntryFirst(menuItem.entry);
+ this._selectEntry(menuItem.entry, true, true);
+ this.menu.close();
+ }
+
+ _resetSelectedMenuItem(resetClipboard) {
+ this.currentlySelectedEntry = undefined;
+ this._updateButtonText();
+ if (resetClipboard) {
+ this._setClipboardText('');
+ }
+ }
+
+ _restoreFavoritedEntries() {
+ for (let entry = this.favoriteEntries.last(); entry; entry = entry.prev) {
+ this._addEntry(entry);
+ }
+ }
+
+ _maybeRestoreMenuPages() {
+ if (this.activeHistoryMenuItems > 0) {
+ return;
+ }
+
+ for (
+ let entry = this.entries.last();
+ entry && this.activeHistoryMenuItems < PAGE_SIZE;
+ entry = entry.prev
+ ) {
+ this._addEntry(entry, this.currentlySelectedEntry === entry);
+ }
+ }
+
+ /**
+ * Our pagination implementation is purposefully "broken." The idea is simply to do no unnecessary
+ * work. As a consequence, if a user navigates to some page and then starts copying/moving items,
+ * those items will appear on the currently visible page even though they don't belong there. This
+ * could kind of be considered a feature since it means you can go back to some cluster of copied
+ * items and start copying stuff from the same cluster and have it all show up together.
+ *
+ * Note that over time (as the user copies items), the page reclamation process will morph the
+ * current page into the first page. This is the only way to make the user-visible state match our
+ * backing store after changing pages.
+ *
+ * Also note that the use of `last` and `next` is correct. Menu items are ordered from latest to
+ * oldest whereas `entries` is ordered from oldest to latest.
+ */
+ _navigatePrevPage() {
+ if (this.searchEntryFront) {
+ this.populateSearchResults(this.searchEntry.get_text(), false);
+ return;
+ }
+
+ const items = this.historySection._getMenuItems();
+ if (items.length === 0) {
+ return;
+ }
+
+ const start = items[0].entry;
+ for (
+ let entry = start.nextCyclic(), i = items.length - 1;
+ entry !== start && i >= 0;
+ entry = entry.nextCyclic()
+ ) {
+ this._rewriteMenuItem(items[i--], entry);
+ }
+ }
+
+ _navigateNextPage() {
+ if (this.searchEntryFront) {
+ this.populateSearchResults(this.searchEntry.get_text(), true);
+ return;
+ }
+
+ const items = this.historySection._getMenuItems();
+ if (items.length === 0) {
+ return;
+ }
+
+ const start = items[items.length - 1].entry;
+ for (
+ let entry = start.prevCyclic(), i = 0;
+ entry !== start && i < items.length;
+ entry = entry.prevCyclic()
+ ) {
+ this._rewriteMenuItem(items[i++], entry);
+ }
+ }
+
+ _rewriteMenuItem(item, entry) {
+ if (item.entry.id === this.currentlySelectedEntry?.id) {
+ item.setOrnament(PopupMenu.Ornament.NONE);
+ }
+
+ item.entry = entry;
+ entry.menuItem = item;
+
+ this._setEntryLabel(item);
+ if (entry.id === this.currentlySelectedEntry?.id) {
+ item.setOrnament(PopupMenu.Ornament.DOT);
+ }
+ }
+
+ _onSearchTextChanged() {
+ const query = this.searchEntry.get_text();
+
+ if (!query) {
+ this.historySection.removeAll();
+ this.favoritesSection.removeAll();
+
+ this.searchEntryFront = this.searchEntryBack = undefined;
+ this._restoreFavoritedEntries();
+ this._maybeRestoreMenuPages();
+ return;
+ }
+
+ this.searchEntryFront = this.searchEntryBack = this.entries.last();
+ this.populateSearchResults(query);
+ }
+
+ populateSearchResults(query, forward) {
+ if (!this.searchEntryFront) {
+ return;
+ }
+
+ this.historySection.removeAll();
+ this.favoritesSection.removeAll();
+
+ if (typeof forward !== 'boolean') {
+ forward = true;
+ }
+
+ query = query.toLowerCase();
+ let searchExp;
+ try {
+ searchExp = new RegExp(query, 'i');
+ } catch {}
+ const start = forward ? this.searchEntryFront : this.searchEntryBack;
+ let entry = start;
+
+ while (this.activeHistoryMenuItems < PAGE_SIZE) {
+ if (entry.type === DS.TYPE_TEXT) {
+ let match = entry.text.toLowerCase().indexOf(query);
+ if (searchExp && match < 0) {
+ match = entry.text.search(searchExp);
+ }
+ if (match >= 0) {
+ this._addEntry(
+ entry,
+ entry === this.currentlySelectedEntry,
+ false,
+ forward ? undefined : 0,
+ );
+ entry.menuItem.label.set_text(
+ this._truncated(
+ entry.text,
+ match - 40,
+ match + MAX_VISIBLE_CHARS - 40,
+ ),
+ );
+ }
+ } else {
+ throw new TypeError('Unknown type: ' + entry.type);
+ }
+
+ entry = forward ? entry.prevCyclic() : entry.nextCyclic();
+ if (entry === start) {
+ break;
+ }
+ }
+
+ if (forward) {
+ this.searchEntryBack = this.searchEntryFront.nextCyclic();
+ this.searchEntryFront = entry;
+ } else {
+ this.searchEntryFront = this.searchEntryBack.prevCyclic();
+ this.searchEntryBack = entry;
+ }
+ }
+
+ _shouldAbortClipboardQuery(kind) {
+ if (PRIVATE_MODE) {
+ return true;
+ }
+
+ if (
+ IGNORE_PASSWORD_MIMES &&
+ Clipboard.get_mimetypes(kind).includes(
+ // Note that we should check for the value "secret" but there don't appear to be any other
+ // values so it's not worth the trouble right now.
+ 'x-kde-passwordManagerHint',
+ )
+ ) {
+ console.log(this.uuid, 'Ignoring password entry.');
+ return true;
+ }
+
+ return false;
+ }
+
+ _queryClipboard() {
+ if (this._shouldAbortClipboardQuery(St.Clipboard.CLIPBOARD)) {
+ return;
+ }
+
+ Clipboard.get_text(St.ClipboardType.CLIPBOARD, (_, text) => {
+ this._processClipboardContent(text, true);
+ });
+ }
+
+ _queryPrimaryClipboard() {
+ if (this._shouldAbortClipboardQuery(St.Clipboard.PRIMARY)) {
+ return;
+ }
+
+ Clipboard.get_text(St.ClipboardType.PRIMARY, (_, text) => {
+ const last = this.entries.last();
+ text = this._processClipboardContent(text, false);
+ if (
+ last &&
+ text &&
+ text.length !== last.text.length &&
+ (text.endsWith(last.text) ||
+ text.startsWith(last.text) ||
+ last.text.endsWith(text) ||
+ last.text.startsWith(text))
+ ) {
+ this._removeEntry(last, true);
+ }
+ });
+ }
+
+ _processClipboardContent(text, selectEntry) {
+ if (this._debouncing > 0) {
+ this._debouncing--;
+ return;
+ }
+
+ if (STRIP_TEXT && text) {
+ text = text.trim();
+ }
+ if (!text) {
+ return;
+ }
+
+ let entry =
+ this.entries.findTextItem(text) ||
+ this.favoriteEntries.findTextItem(text);
+ if (entry) {
+ const isFirst =
+ entry === this.entries.last() || entry === this.favoriteEntries.last();
+ if (!isFirst) {
+ this._moveEntryFirst(entry);
+ }
+ if (selectEntry && (!isFirst || entry !== this.currentlySelectedEntry)) {
+ this._selectEntry(entry, false);
+ }
+ } else {
+ entry = new DS.LLNode();
+ entry.id = this.nextId++;
+ entry.diskId = CACHE_ONLY_FAVORITES ? undefined : this.nextDiskId++;
+ entry.type = DS.TYPE_TEXT;
+ entry.text = text;
+ entry.favorite = false;
+ this.entries.append(entry);
+ this._addEntry(entry, selectEntry, false, 0);
+
+ if (!CACHE_ONLY_FAVORITES) {
+ Store.storeTextEntry(text);
+ }
+ this._pruneOldestEntries();
+ }
+
+ if (NOTIFY_ON_COPY) {
+ this._showNotification(_('Copied to clipboard'), null, (notif) => {
+ notif.addAction(_('Cancel'), () =>
+ this._deleteEntryAndRestoreLatest(this.currentlySelectedEntry),
+ );
+ });
+ }
+
+ return text;
+ }
+
+ _moveEntryFirst(entry) {
+ if (!MOVE_ITEM_FIRST) {
+ return;
+ }
+
+ let menu;
+ let entries;
+ if (entry.favorite) {
+ menu = this.favoritesSection;
+ entries = this.favoriteEntries;
+ } else {
+ menu = this.historySection;
+ entries = this.entries;
+ }
+
+ if (entry.menuItem) {
+ menu.moveMenuItem(entry.menuItem, 0);
+ } else {
+ this._addEntry(entry, false, false, 0);
+ }
+
+ entries.append(entry);
+ if (entry.diskId) {
+ Store.moveEntryToEnd(entry.diskId);
+ }
+ }
+
+ _currentStateBuilder() {
+ const state = [];
+
+ this.nextDiskId = 1;
+ for (const entry of this.favoriteEntries) {
+ entry.diskId = this.nextDiskId++;
+ state.push(entry);
+ }
+ for (const entry of this.entries) {
+ if (CACHE_ONLY_FAVORITES) {
+ delete entry.diskId;
+ } else {
+ entry.diskId = this.nextDiskId++;
+ state.push(entry);
+ }
+ }
+
+ return state;
+ }
+
+ _setupSelectionChangeListener() {
+ this._debouncing = 0;
+
+ this.selection = Shell.Global.get().get_display().get_selection();
+ this._selectionOwnerChangedId = this.selection.connect(
+ 'owner-changed',
+ (_, selectionType) => {
+ if (selectionType === Meta.SelectionType.SELECTION_CLIPBOARD) {
+ this._queryClipboard();
+ } else if (
+ PROCESS_PRIMARY_SELECTION &&
+ selectionType === Meta.SelectionType.SELECTION_PRIMARY
+ ) {
+ this._queryPrimaryClipboard();
+ }
+ },
+ );
+ }
+
+ _disconnectSelectionListener() {
+ if (!this._selectionOwnerChangedId) {
+ return;
+ }
+
+ this.selection.disconnect(this._selectionOwnerChangedId);
+ this.selection = undefined;
+ this._selectionOwnerChangedId = undefined;
+ }
+
+ _deleteEntryAndRestoreLatest(entry) {
+ this._removeEntry(entry, true, true);
+
+ if (!this.currentlySelectedEntry) {
+ const nextEntry = this.entries.last();
+ if (nextEntry) {
+ this._selectEntry(nextEntry, true);
+ }
+ }
+ }
+
+ _initNotifSource() {
+ if (this._notifSource) {
+ return;
+ }
+
+ this._notifSource = new MessageTray.Source({
+ title: this.extension.indicatorName,
+ iconName: INDICATOR_ICON,
+ });
+ this._notifSource.connect('destroy', () => {
+ this._notifSource = undefined;
+ });
+ Main.messageTray.add(this._notifSource);
+ }
+
+ _showNotification(title, message, transformFn) {
+ const dndOn = () =>
+ !Main.panel.statusArea.dateMenu._indicator._settings.get_boolean(
+ 'show-banners',
+ );
+ if (PRIVATE_MODE || dndOn()) {
+ return;
+ }
+
+ this._initNotifSource();
+
+ let notification;
+ if (this._notifSource.count === 0) {
+ notification = new MessageTray.Notification({
+ source: this._notifSource,
+ title,
+ body: message,
+ isTransient: true,
+ });
+ } else {
+ notification = this._notifSource.notifications[0];
+ notification.set({
+ title,
+ body: message,
+ });
+ notification.clearActions();
+ }
+
+ if (typeof transformFn === 'function') {
+ transformFn(notification);
+ }
+
+ this._notifSource.addNotification(notification);
+ }
+
+ _updatePrivateModeState() {
+ // We hide the history in private mode because it will be out of sync
+ // (selected item will not reflect clipboard)
+ this.scrollViewMenuSection.actor.visible = !PRIVATE_MODE;
+ this.scrollViewFavoritesMenuSection.actor.visible = !PRIVATE_MODE;
+
+ if (PRIVATE_MODE) {
+ this.icon.add_style_class_name('private-mode');
+ this._updateButtonText();
+ } else {
+ this.icon.remove_style_class_name('private-mode');
+ if (this.currentlySelectedEntry) {
+ this._selectEntry(this.currentlySelectedEntry, true);
+ } else {
+ this._resetSelectedMenuItem(true);
+ }
+ }
+ }
+
+ _fetchSettings() {
+ MAX_REGISTRY_LENGTH = this.settings.get_int(SettingsFields.HISTORY_SIZE);
+ MAX_BYTES =
+ (1 << 20) * this.settings.get_int(SettingsFields.CACHE_FILE_SIZE);
+ WINDOW_WIDTH_PERCENTAGE = this.settings.get_int(
+ SettingsFields.WINDOW_WIDTH_PERCENTAGE,
+ );
+ CACHE_ONLY_FAVORITES = this.settings.get_boolean(
+ SettingsFields.CACHE_ONLY_FAVORITES,
+ );
+ MOVE_ITEM_FIRST = this.settings.get_boolean(SettingsFields.MOVE_ITEM_FIRST);
+ NOTIFY_ON_COPY = this.settings.get_boolean(SettingsFields.NOTIFY_ON_COPY);
+ CONFIRM_ON_CLEAR = this.settings.get_boolean(
+ SettingsFields.CONFIRM_ON_CLEAR,
+ );
+ ENABLE_KEYBINDING = this.settings.get_boolean(
+ SettingsFields.ENABLE_KEYBINDING,
+ );
+ MAX_TOPBAR_LENGTH = this.settings.get_int(
+ SettingsFields.TOPBAR_PREVIEW_SIZE,
+ );
+ TOPBAR_DISPLAY_MODE = this.settings.get_int(
+ SettingsFields.TOPBAR_DISPLAY_MODE_ID,
+ );
+ DISABLE_DOWN_ARROW = this.settings.get_boolean(
+ SettingsFields.DISABLE_DOWN_ARROW,
+ );
+ STRIP_TEXT = this.settings.get_boolean(SettingsFields.STRIP_TEXT);
+ PRIVATE_MODE = this.settings.get_boolean(SettingsFields.PRIVATE_MODE);
+ PASTE_ON_SELECTION = this.settings.get_boolean(
+ SettingsFields.PASTE_ON_SELECTION,
+ );
+ PROCESS_PRIMARY_SELECTION = this.settings.get_boolean(
+ SettingsFields.PROCESS_PRIMARY_SELECTION,
+ );
+ IGNORE_PASSWORD_MIMES = this.settings.get_boolean(
+ SettingsFields.IGNORE_PASSWORD_MIMES,
+ );
+ }
+
+ _onSettingsChange() {
+ const prevCacheOnlyFavorites = CACHE_ONLY_FAVORITES;
+ const prevPrivateMode = PRIVATE_MODE;
+
+ this._fetchSettings();
+
+ if (
+ prevCacheOnlyFavorites !== undefined &&
+ CACHE_ONLY_FAVORITES !== prevCacheOnlyFavorites
+ ) {
+ if (CACHE_ONLY_FAVORITES) {
+ Store.resetDatabase(this._currentStateBuilder.bind(this));
+ } else {
+ for (const entry of this.entries) {
+ entry.diskId = this.nextDiskId++;
+ Store.storeTextEntry(entry.text);
+ }
+ }
+ }
+
+ if (prevPrivateMode !== undefined && PRIVATE_MODE !== prevPrivateMode) {
+ this._updatePrivateModeState();
+ }
+
+ // Remove old entries in case the registry size changed
+ this._pruneOldestEntries();
+
+ // Re-set menu-items labels in case preview size changed
+ const resetLabel = (item) => this._setEntryLabel(item);
+ this.favoritesSection._getMenuItems().forEach(resetLabel);
+ this.historySection._getMenuItems().forEach(resetLabel);
+
+ this._updateTopbarLayout();
+ if (this.currentlySelectedEntry) {
+ this._updateButtonText(this.currentlySelectedEntry);
+ }
+ this._setMenuWidth();
+
+ if (ENABLE_KEYBINDING) {
+ this._bindShortcuts();
+ } else {
+ this._unbindShortcuts();
+ }
+ }
+
+ _bindShortcuts() {
+ this._unbindShortcuts();
+ this._bindShortcut(SETTING_KEY_CLEAR_HISTORY, () => {
+ if (this.entries) {
+ this._removeAll();
+ }
+ });
+ this._bindShortcut(SETTING_KEY_PREV_ENTRY, () => {
+ if (this.entries) {
+ this._previousEntry();
+ }
+ });
+ this._bindShortcut(SETTING_KEY_NEXT_ENTRY, () => {
+ if (this.entries) {
+ this._nextEntry();
+ }
+ });
+ this._bindShortcut(SETTING_KEY_TOGGLE_MENU, () => this.menu.toggle());
+ this._bindShortcut(SETTING_KEY_PRIVATE_MODE, () =>
+ this.privateModeMenuItem.toggle(),
+ );
+ }
+
+ _unbindShortcuts() {
+ this._shortcutsBindingIds.forEach((id) => Main.wm.removeKeybinding(id));
+
+ this._shortcutsBindingIds = [];
+ }
+
+ _bindShortcut(name, cb) {
+ const ModeType = Shell.hasOwnProperty('ActionMode')
+ ? Shell.ActionMode
+ : Shell.KeyBindingMode;
+
+ Main.wm.addKeybinding(
+ name,
+ this.settings,
+ Meta.KeyBindingFlags.NONE,
+ ModeType.ALL,
+ cb.bind(this),
+ );
+
+ this._shortcutsBindingIds.push(name);
+ }
+
+ _updateTopbarLayout() {
+ if (TOPBAR_DISPLAY_MODE === 3) {
+ this.icon.visible = false;
+ this._buttonText.visible = false;
+
+ this._style_class = this.style_class;
+ this.style_class = '';
+ } else if (this._style_class) {
+ this.style_class = this._style_class;
+ }
+
+ if (TOPBAR_DISPLAY_MODE === 0) {
+ this.icon.visible = true;
+ this._buttonText.visible = false;
+ }
+ if (TOPBAR_DISPLAY_MODE === 1) {
+ this.icon.visible = false;
+ this._buttonText.visible = true;
+ }
+ if (TOPBAR_DISPLAY_MODE === 2) {
+ this.icon.visible = true;
+ this._buttonText.visible = true;
+ }
+ this._downArrow.visible = !DISABLE_DOWN_ARROW;
+ }
+
+ _disconnectSettings() {
+ if (!this._settingsChangedId) {
+ return;
+ }
+
+ this.settings.disconnect(this._settingsChangedId);
+ this._settingsChangedId = undefined;
+ }
+
+ _openSettings() {
+ this.extension.openPreferences();
+ this.menu.close();
+ }
+
+ _previousEntry() {
+ this._selectNextPrevEntry(
+ this.currentlySelectedEntry.nextCyclic() || this.entries.head,
+ );
+ }
+
+ _nextEntry() {
+ this._selectNextPrevEntry(
+ this.currentlySelectedEntry.prevCyclic() || this.entries.last(),
+ );
+ }
+
+ _selectNextPrevEntry(entry) {
+ if (!entry) {
+ return;
+ }
+
+ this._selectEntry(entry, true);
+ if (entry.type === DS.TYPE_TEXT) {
+ this._showNotification(_('Copied'), entry.text);
+ }
+ }
+
+ _truncated(s, start, end) {
+ if (start < 0) {
+ start = 0;
+ }
+ if (!end) {
+ end = start;
+ start = 0;
+ }
+ if (end > s.length) {
+ end = s.length;
+ }
+
+ const includesStart = start === 0;
+ const includesEnd = end === s.length;
+ const isMiddle = !includesStart && !includesEnd;
+ const length = end - start;
+ const overflow = s.length > length;
+
+ // Reduce regex search space. If the string is mostly whitespace,
+ // we might end up removing too many characters, but oh well.
+ s = s.substring(start, end + 100);
+
+ // Remove new lines and extra spaces so the text fits nicely on one line
+ s = s.replace(/\s+/g, ' ').trim();
+
+ if (includesStart && overflow) {
+ s = s.substring(0, length - 1) + '…';
+ }
+ if (includesEnd && overflow) {
+ s = '…' + s.substring(1, length);
+ }
+ if (isMiddle) {
+ s = '…' + s.substring(1, length - 1) + '…';
+ }
+
+ return s;
+ }
+}
+
+const ClipboardIndicatorObj = GObject.registerClass(ClipboardIndicator);
+
+export default class ClipboardHistoryExtension extends Extension {
+ enable() {
+ this.indicatorName = `${this.metadata.name} Indicator`;
+
+ Store.init(this.uuid);
+
+ this.clipboardIndicator = new ClipboardIndicatorObj(this);
+ Main.panel.addToStatusArea(this.indicatorName, this.clipboardIndicator, 1);
+ }
+
+ disable() {
+ this.clipboardIndicator.destroy();
+ this.clipboardIndicator = undefined;
+
+ Store.destroy();
+ }
+}
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ar/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ar/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..75db88b
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ar/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ca/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ca/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..c1e42c2
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ca/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/cs/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/cs/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..0849a18
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/cs/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/de/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/de/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..c79031f
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/de/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/el/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/el/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..9ef3647
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/el/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/es/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/es/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..b052702
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/es/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/eu/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/eu/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..c6d0697
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/eu/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/fa/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/fa/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..6c6f242
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/fa/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/fi/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/fi/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..61be0a9
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/fi/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/fr/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/fr/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..5975d31
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/fr/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/hu/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/hu/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..8aaedc7
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/hu/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/it/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/it/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..50712b8
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/it/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ja/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ja/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..e78cac6
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ja/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/nl/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/nl/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..f80e11b
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/nl/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/oc/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/oc/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..29f8476
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/oc/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/pl/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/pl/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..ea75784
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/pl/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/pt_BR/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/pt_BR/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..b1e6f99
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/pt_BR/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ru/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ru/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..8c4c350
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ru/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/sk/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/sk/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..c351ec2
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/sk/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/tr/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/tr/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..af82a06
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/tr/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/uk/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/uk/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..f42e7cf
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/uk/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/zh_CN/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/zh_CN/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
new file mode 100644
index 0000000..ee405d9
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/zh_CN/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/metadata.json b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/metadata.json
new file mode 100644
index 0000000..fa25d4f
--- /dev/null
+++ b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/metadata.json
@@ -0,0 +1,16 @@
+{
+ "_generated": "Generated by SweetTooth, do not edit",
+ "description": "Gnome Clipboard History is a clipboard manager GNOME extension that saves items you've copied into an easily accessible, searchable history panel.",
+ "gettext-domain": "clipboard-history@alexsaveau.dev",
+ "name": "Clipboard History",
+ "settings-schema": "org.gnome.shell.extensions.clipboard-history",
+ "shell-version": [
+ "46",
+ "47",
+ "48",
+ "49"
+ ],
+ "url": "https://github.com/SUPERCILEX/gnome-clipboard-history",
+ "uuid": "clipboard-history@alexsaveau.dev",
+ "version": 47
+}
\ No newline at end of file
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/prefs.js b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/prefs.js
new file mode 100644
index 0000000..5868498
--- /dev/null
+++ b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/prefs.js
@@ -0,0 +1,444 @@
+import GObject from 'gi://GObject';
+import Gtk from 'gi://Gtk';
+import Gio from 'gi://Gio';
+import Adw from 'gi://Adw';
+
+import {
+ ExtensionPreferences,
+ gettext as _,
+} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
+
+import Fields from './settingsFields.js';
+
+export default class ClipboardHistoryPrefs extends ExtensionPreferences {
+ // fillPreferencesWindow() is passed a Adw.PreferencesWindow,
+ // we need to wrap our widget in a Adw.PreferencesPage and Adw.PreferencesGroup
+ // ourselves.
+ // It would be great to port the preferences to standard Adw widgets.
+ // https://gjs.guide/extensions/development/preferences.html#prefs-js
+ fillPreferencesWindow(window) {
+ const settings = this.getSettings();
+
+ const main = new Gtk.Grid({
+ margin_top: 10,
+ margin_bottom: 10,
+ margin_start: 10,
+ margin_end: 10,
+ row_spacing: 12,
+ column_spacing: 18,
+ column_homogeneous: false,
+ row_homogeneous: false,
+ });
+ const field_size = new Gtk.SpinButton({
+ adjustment: new Gtk.Adjustment({
+ lower: 1,
+ upper: 100_000,
+ step_increment: 100,
+ }),
+ });
+ const window_width_percentage = new Gtk.SpinButton({
+ adjustment: new Gtk.Adjustment({
+ lower: 0,
+ upper: 100,
+ step_increment: 5,
+ }),
+ });
+ const field_cache_size = new Gtk.SpinButton({
+ adjustment: new Gtk.Adjustment({
+ lower: 1,
+ upper: 1024,
+ step_increment: 5,
+ }),
+ });
+ const field_topbar_preview_size = new Gtk.SpinButton({
+ adjustment: new Gtk.Adjustment({
+ lower: 1,
+ upper: 100,
+ step_increment: 10,
+ }),
+ });
+ const field_display_mode = new Gtk.ComboBox({
+ model: this._create_display_mode_options(),
+ });
+
+ const rendererText = new Gtk.CellRendererText();
+ field_display_mode.pack_start(rendererText, false);
+ field_display_mode.add_attribute(rendererText, 'text', 0);
+ const field_disable_down_arrow = new Gtk.Switch();
+ const field_cache_disable = new Gtk.Switch();
+ const field_notification_toggle = new Gtk.Switch();
+ const field_confirm_clear_toggle = new Gtk.Switch();
+ const field_strip_text = new Gtk.Switch();
+ const field_paste_on_selection = new Gtk.Switch();
+ const field_process_primary_selection = new Gtk.Switch();
+ const field_ignore_password_mimes = new Gtk.Switch();
+ const field_move_item_first = new Gtk.Switch();
+ const field_keybinding = createKeybindingWidget(settings);
+ addKeybinding(
+ field_keybinding.model,
+ settings,
+ 'toggle-menu',
+ _('Toggle the menu'),
+ );
+ addKeybinding(
+ field_keybinding.model,
+ settings,
+ 'clear-history',
+ _('Clear history'),
+ );
+ addKeybinding(
+ field_keybinding.model,
+ settings,
+ 'prev-entry',
+ _('Previous entry'),
+ );
+ addKeybinding(
+ field_keybinding.model,
+ settings,
+ 'next-entry',
+ _('Next entry'),
+ );
+ addKeybinding(
+ field_keybinding.model,
+ settings,
+ 'toggle-private-mode',
+ _('Toggle private mode'),
+ );
+
+ const field_keybinding_activation = new Gtk.Switch();
+ field_keybinding_activation.connect('notify::active', (widget) => {
+ field_keybinding.set_sensitive(widget.active);
+ });
+
+ const sizeLabel = new Gtk.Label({
+ label: _('Max number of items'),
+ hexpand: true,
+ halign: Gtk.Align.START,
+ });
+ const windowWidthPercentageLabel = new Gtk.Label({
+ label: _('Window width (%)'),
+ hexpand: true,
+ halign: Gtk.Align.START,
+ });
+ const cacheSizeLabel = new Gtk.Label({
+ label: _('Max clipboard history size (MiB)'),
+ hexpand: true,
+ halign: Gtk.Align.START,
+ });
+ const cacheDisableLabel = new Gtk.Label({
+ label: _('Only save favorites to disk'),
+ hexpand: true,
+ halign: Gtk.Align.START,
+ });
+ const notificationLabel = new Gtk.Label({
+ label: _('Show notification on copy'),
+ hexpand: true,
+ halign: Gtk.Align.START,
+ });
+ const confirmClearLabel = new Gtk.Label({
+ label: _('Ask for confirmation before clearing history'),
+ hexpand: true,
+ halign: Gtk.Align.START,
+ });
+ const moveFirstLabel = new Gtk.Label({
+ label: _('Move previously copied items to the top'),
+ hexpand: true,
+ halign: Gtk.Align.START,
+ });
+ const keybindingLabel = new Gtk.Label({
+ label: _('Keyboard shortcuts'),
+ hexpand: true,
+ halign: Gtk.Align.START,
+ });
+ const topbarPreviewLabel = new Gtk.Label({
+ label: _('Number of characters in status bar'),
+ hexpand: true,
+ halign: Gtk.Align.START,
+ });
+ const displayModeLabel = new Gtk.Label({
+ label: _('What to show in status bar'),
+ hexpand: true,
+ halign: Gtk.Align.START,
+ });
+ const disableDownArrowLabel = new Gtk.Label({
+ label: _('Remove down arrow in status bar'),
+ hexpand: true,
+ halign: Gtk.Align.START,
+ });
+ const stripTextLabel = new Gtk.Label({
+ label: _('Remove whitespace around text'),
+ hexpand: true,
+ halign: Gtk.Align.START,
+ });
+ const pasteOnSelectionLabel = new Gtk.Label({
+ label: _('Paste on selection'),
+ hexpand: true,
+ halign: Gtk.Align.START,
+ });
+ const processPrimarySelection = new Gtk.Label({
+ label: _('Save selected text to history'),
+ hexpand: true,
+ halign: Gtk.Align.START,
+ });
+ const ignorePasswordMimes = new Gtk.Label({
+ label: _('Try to avoid copying passwords (known potentially buggy)'),
+ hexpand: true,
+ halign: Gtk.Align.START,
+ });
+
+ const addRow = ((main) => {
+ let row = 0;
+ return (label, input) => {
+ let inputWidget = input;
+
+ if (input instanceof Gtk.Switch) {
+ inputWidget = new Gtk.Box({
+ orientation: Gtk.Orientation.HORIZONTAL,
+ });
+ inputWidget.append(input);
+ }
+
+ if (label) {
+ main.attach(label, 0, row, 1, 1);
+ main.attach(inputWidget, 1, row, 1, 1);
+ } else {
+ main.attach(inputWidget, 0, row, 2, 1);
+ }
+
+ row++;
+ };
+ })(main);
+
+ addRow(windowWidthPercentageLabel, window_width_percentage);
+ addRow(sizeLabel, field_size);
+ addRow(cacheSizeLabel, field_cache_size);
+ addRow(cacheDisableLabel, field_cache_disable);
+ addRow(moveFirstLabel, field_move_item_first);
+ addRow(stripTextLabel, field_strip_text);
+ addRow(pasteOnSelectionLabel, field_paste_on_selection);
+ addRow(processPrimarySelection, field_process_primary_selection);
+ addRow(ignorePasswordMimes, field_ignore_password_mimes);
+ addRow(displayModeLabel, field_display_mode);
+ addRow(disableDownArrowLabel, field_disable_down_arrow);
+ addRow(topbarPreviewLabel, field_topbar_preview_size);
+ addRow(notificationLabel, field_notification_toggle);
+ addRow(confirmClearLabel, field_confirm_clear_toggle);
+ addRow(keybindingLabel, field_keybinding_activation);
+ addRow(null, field_keybinding);
+
+ settings.bind(
+ Fields.HISTORY_SIZE,
+ field_size,
+ 'value',
+ Gio.SettingsBindFlags.DEFAULT,
+ );
+ settings.bind(
+ Fields.WINDOW_WIDTH_PERCENTAGE,
+ window_width_percentage,
+ 'value',
+ Gio.SettingsBindFlags.DEFAULT,
+ );
+ settings.bind(
+ Fields.CACHE_FILE_SIZE,
+ field_cache_size,
+ 'value',
+ Gio.SettingsBindFlags.DEFAULT,
+ );
+ settings.bind(
+ Fields.CACHE_ONLY_FAVORITES,
+ field_cache_disable,
+ 'active',
+ Gio.SettingsBindFlags.DEFAULT,
+ );
+ settings.bind(
+ Fields.NOTIFY_ON_COPY,
+ field_notification_toggle,
+ 'active',
+ Gio.SettingsBindFlags.DEFAULT,
+ );
+ settings.bind(
+ Fields.CONFIRM_ON_CLEAR,
+ field_confirm_clear_toggle,
+ 'active',
+ Gio.SettingsBindFlags.DEFAULT,
+ );
+ settings.bind(
+ Fields.MOVE_ITEM_FIRST,
+ field_move_item_first,
+ 'active',
+ Gio.SettingsBindFlags.DEFAULT,
+ );
+ settings.bind(
+ Fields.TOPBAR_DISPLAY_MODE_ID,
+ field_display_mode,
+ 'active',
+ Gio.SettingsBindFlags.DEFAULT,
+ );
+ settings.bind(
+ Fields.DISABLE_DOWN_ARROW,
+ field_disable_down_arrow,
+ 'active',
+ Gio.SettingsBindFlags.DEFAULT,
+ );
+ settings.bind(
+ Fields.TOPBAR_PREVIEW_SIZE,
+ field_topbar_preview_size,
+ 'value',
+ Gio.SettingsBindFlags.DEFAULT,
+ );
+ settings.bind(
+ Fields.STRIP_TEXT,
+ field_strip_text,
+ 'active',
+ Gio.SettingsBindFlags.DEFAULT,
+ );
+ settings.bind(
+ Fields.PASTE_ON_SELECTION,
+ field_paste_on_selection,
+ 'active',
+ Gio.SettingsBindFlags.DEFAULT,
+ );
+ settings.bind(
+ Fields.PROCESS_PRIMARY_SELECTION,
+ field_process_primary_selection,
+ 'active',
+ Gio.SettingsBindFlags.DEFAULT,
+ );
+ settings.bind(
+ Fields.IGNORE_PASSWORD_MIMES,
+ field_ignore_password_mimes,
+ 'active',
+ Gio.SettingsBindFlags.DEFAULT,
+ );
+ settings.bind(
+ Fields.ENABLE_KEYBINDING,
+ field_keybinding_activation,
+ 'active',
+ Gio.SettingsBindFlags.DEFAULT,
+ );
+
+ const group = new Adw.PreferencesGroup();
+ group.add(main);
+
+ const page = new Adw.PreferencesPage();
+ page.add(group);
+
+ window.add(page);
+ }
+
+ _create_display_mode_options() {
+ const options = [
+ { name: _('Icon') },
+ { name: _('Clipboard contents') },
+ { name: _('Both') },
+ { name: _('Neither') },
+ ];
+ const liststore = new Gtk.ListStore();
+ liststore.set_column_types([GObject.TYPE_STRING]);
+ for (let i = 0; i < options.length; i++) {
+ const option = options[i];
+ const iter = liststore.append();
+ liststore.set(iter, [0], [option.name]);
+ }
+ return liststore;
+ }
+}
+
+//binding widgets
+//////////////////////////////////
+const COLUMN_ID = 0;
+const COLUMN_DESCRIPTION = 1;
+const COLUMN_KEY = 2;
+const COLUMN_MODS = 3;
+
+function addKeybinding(model, settings, id, description) {
+ // Get the current accelerator.
+ const accelerator = settings.get_strv(id)[0];
+ let key, mods;
+ if (accelerator == null) {
+ [key, mods] = [0, 0];
+ } else {
+ [, key, mods] = Gtk.accelerator_parse(settings.get_strv(id)[0]);
+ }
+
+ // Add a row for the keybinding.
+ const row = model.insert(100); // Erm...
+ model.set(
+ row,
+ [COLUMN_ID, COLUMN_DESCRIPTION, COLUMN_KEY, COLUMN_MODS],
+ [id, description, key, mods],
+ );
+}
+
+function createKeybindingWidget(Settings) {
+ const model = new Gtk.ListStore();
+
+ model.set_column_types([
+ GObject.TYPE_STRING, // COLUMN_ID
+ GObject.TYPE_STRING, // COLUMN_DESCRIPTION
+ GObject.TYPE_INT, // COLUMN_KEY
+ GObject.TYPE_INT,
+ ]); // COLUMN_MODS
+
+ const treeView = new Gtk.TreeView();
+ treeView.model = model;
+ treeView.headers_visible = false;
+
+ let column, renderer;
+
+ // Description column.
+ renderer = new Gtk.CellRendererText();
+
+ column = new Gtk.TreeViewColumn();
+ column.expand = true;
+ column.pack_start(renderer, true);
+ column.add_attribute(renderer, 'text', COLUMN_DESCRIPTION);
+
+ treeView.append_column(column);
+
+ // Key binding column.
+ renderer = new Gtk.CellRendererAccel();
+ renderer.accel_mode = Gtk.CellRendererAccelMode.GTK;
+ renderer.editable = true;
+
+ renderer.connect(
+ 'accel-edited',
+ function (renderer, path, key, mods, hwCode) {
+ const [ok, iter] = model.get_iter_from_string(path);
+ if (!ok) {
+ return;
+ }
+
+ // Update the UI.
+ model.set(iter, [COLUMN_KEY, COLUMN_MODS], [key, mods]);
+
+ // Update the stored setting.
+ const id = model.get_value(iter, COLUMN_ID);
+ const accelString = Gtk.accelerator_name(key, mods);
+ Settings.set_strv(id, [accelString]);
+ },
+ );
+
+ renderer.connect('accel-cleared', function (renderer, path) {
+ const [ok, iter] = model.get_iter_from_string(path);
+ if (!ok) {
+ return;
+ }
+
+ // Update the UI.
+ model.set(iter, [COLUMN_KEY, COLUMN_MODS], [0, 0]);
+
+ // Update the stored setting.
+ const id = model.get_value(iter, COLUMN_ID);
+ Settings.set_strv(id, []);
+ });
+
+ column = new Gtk.TreeViewColumn();
+ column.pack_end(renderer, false);
+ column.add_attribute(renderer, 'accel-key', COLUMN_KEY);
+ column.add_attribute(renderer, 'accel-mods', COLUMN_MODS);
+
+ treeView.append_column(column);
+
+ return treeView;
+}
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/schemas/gschemas.compiled b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/schemas/gschemas.compiled
new file mode 100644
index 0000000..95071e6
Binary files /dev/null and b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/schemas/gschemas.compiled differ
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/schemas/org.gnome.shell.extensions.clipboard-indicator.gschema.xml b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/schemas/org.gnome.shell.extensions.clipboard-indicator.gschema.xml
new file mode 100644
index 0000000..9484485
--- /dev/null
+++ b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/schemas/org.gnome.shell.extensions.clipboard-indicator.gschema.xml
@@ -0,0 +1,140 @@
+
+
+
+
+ 1000
+ The maximum number of items to remember
+
+
+
+
+ 0
+ What to display in top bar
+
+
+
+
+ true
+ Remove down arrow in top bar
+
+
+
+ 33
+ Window width (%)
+
+ The width of the clipboard panel as a percentage of screen width.
+
+
+
+
+
+ 10
+ Number of visible characters in top bar
+
+ The number of characters to display for the current clipboard item in the top bar.
+
+
+
+
+
+ 100
+ The maximum clipboard history size (MiB)
+
+ Note that this is the maximum number of clipboard item bytes to store, not the maximum file
+ size on disk. The clipboard history on disk may be larger than this limit due to storage
+ inefficiencies.
+
+
+
+
+
+ false
+ Only save favorites to disk
+
+ Non-favorite items will still be saved, but only in-memory. Restarting the Gnome Shell will
+ result in the loss of those items.
+
+
+
+
+ false
+ Show a notification on copy
+
+ If true, a notification is shown when content is copied to clipboard with an undo button.
+
+
+
+
+ true
+ Show confirmation dialog on Clear History
+
+ If true, a confirmation dialog is shown when attempting to Clear History.
+
+
+
+
+ false
+ Remove whitespace around copied plaintext items
+
+
+
+ true
+ Paste selected items into the previously active window
+
+
+
+ false
+ Save the currently selected text to the clipboard history
+
+ If true, both the contents from the "CLIPBOARD" clipboard and the "PRIMARY" clipboard are added to the history.
+ For more info, see https://wiki.archlinux.org/title/clipboard#Selections.
+
+
+
+
+ true
+ Move previously copied items to the top of the list
+
+
+
+ false
+ Enable private mode
+
+ If true, copied items are not saved in the clipboard history (be that in memory or on disk).
+
+
+
+
+ true
+ Enable keyboard shortcuts
+
+
+
+ Shortcut to clear history
+
+
+
+ Shortcut to cycle to the previous clipboard entry
+
+
+
+
+
+ Shortcut to cycle to the next clipboard entry
+
+
+ V']]]>
+ Shortcut to open the clipboard history
+
+
+ P']]]>
+ Toggle private mode
+
+
+ true
+ Ignore selections containing the x-kde-passwordManagerHint mime type.
+
+
+
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/settingsFields.js b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/settingsFields.js
new file mode 100644
index 0000000..de7a063
--- /dev/null
+++ b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/settingsFields.js
@@ -0,0 +1,19 @@
+const SettingsFields = {
+ HISTORY_SIZE: 'history-size',
+ WINDOW_WIDTH_PERCENTAGE: 'window-width-percentage',
+ CACHE_FILE_SIZE: 'cache-size',
+ CACHE_ONLY_FAVORITES: 'cache-only-favorites',
+ NOTIFY_ON_COPY: 'notify-on-copy',
+ CONFIRM_ON_CLEAR: 'confirm-clear',
+ MOVE_ITEM_FIRST: 'move-item-first',
+ ENABLE_KEYBINDING: 'enable-keybindings',
+ TOPBAR_PREVIEW_SIZE: 'topbar-preview-size',
+ TOPBAR_DISPLAY_MODE_ID: 'display-mode',
+ DISABLE_DOWN_ARROW: 'disable-down-arrow',
+ STRIP_TEXT: 'strip-text',
+ PRIVATE_MODE: 'private-mode',
+ PASTE_ON_SELECTION: 'paste-on-selection',
+ PROCESS_PRIMARY_SELECTION: 'process-primary-selection',
+ IGNORE_PASSWORD_MIMES: 'ignore-password-mimes',
+};
+export default SettingsFields;
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/store.js b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/store.js
new file mode 100644
index 0000000..0cc9c77
--- /dev/null
+++ b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/store.js
@@ -0,0 +1,512 @@
+import GLib from 'gi://GLib';
+import Gio from 'gi://Gio';
+import * as DS from './dataStructures.js';
+
+let EXTENSION_UUID;
+let CACHE_DIR;
+const OLD_REGISTRY_FILE = GLib.build_filenamev([
+ GLib.get_user_cache_dir(),
+ 'clipboard-indicator@tudmotu.com',
+ 'registry.txt',
+]);
+
+/**
+ * Stores our compacting log implementation. Here are its key ideas:
+ * - We only ever append to the log.
+ * - This means there will be operations that cancel each other out. These are wasted/useless ops
+ * that must be occasionally pruned. MAX_WASTED_OPS limits the number of useless ops.
+ * - The available operations are listed in the OP_TYPE_* constants.
+ * - An add op never moves (until compaction), allowing us to derive globally unique entry IDs based
+ * on the order in which these add ops are discovered.
+ */
+let DATABASE_FILE;
+const BYTE_ORDER = Gio.DataStreamByteOrder.LITTLE_ENDIAN;
+
+// Don't use zero b/c DataInputStream uses 0 as its error value
+const OP_TYPE_SAVE_TEXT = 1;
+const OP_TYPE_DELETE_TEXT = 2;
+const OP_TYPE_FAVORITE_ITEM = 3;
+const OP_TYPE_UNFAVORITE_ITEM = 4;
+const OP_TYPE_MOVE_ITEM_TO_END = 5;
+
+const MAX_WASTED_OPS = 500;
+let uselessOpCount;
+
+let opQueue = new DS.LinkedList();
+let opInProgress = false;
+let writeStream;
+
+export function init(uuid) {
+ EXTENSION_UUID = uuid;
+ CACHE_DIR = GLib.build_filenamev([GLib.get_user_cache_dir(), EXTENSION_UUID]);
+ DATABASE_FILE = GLib.build_filenamev([CACHE_DIR, 'database.log']);
+
+ if (GLib.mkdir_with_parents(CACHE_DIR, 0o775) !== 0) {
+ console.log(
+ EXTENSION_UUID,
+ "Failed to create cache dir, extension likely won't work",
+ CACHE_DIR,
+ );
+ }
+}
+
+export function destroy() {
+ _pushToOpQueue((resolve) => {
+ if (writeStream) {
+ writeStream.close_async(0, null, (src, res) => {
+ src.close_finish(res);
+ resolve();
+ });
+ writeStream = undefined;
+ } else {
+ resolve();
+ }
+ });
+}
+
+export function buildClipboardStateFromLog(callback) {
+ if (typeof callback !== 'function') {
+ throw TypeError('`callback` must be a function');
+ }
+ uselessOpCount = 0;
+
+ Gio.File.new_for_path(DATABASE_FILE).read_async(0, null, (src, res) => {
+ try {
+ _parseLog(src.read_finish(res), callback);
+ } catch (e) {
+ if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) {
+ _readAndConsumeOldFormat(callback);
+ } else {
+ throw e;
+ }
+ }
+ });
+}
+
+function _parseLog(stream, callback) {
+ stream = Gio.DataInputStream.new(stream);
+ stream.set_byte_order(BYTE_ORDER);
+
+ const state = {
+ entries: new DS.LinkedList(),
+ favorites: new DS.LinkedList(),
+ nextId: 1,
+ };
+ _consumeStream(stream, state, callback);
+}
+
+function _consumeStream(stream, state, callback) {
+ const finish = () => {
+ callback(state.entries, state.favorites, state.nextId);
+ };
+ const forceFill = (minBytes, fillCallback) => {
+ stream.fill_async(/*count=*/ -1, 0, null, (src, res) => {
+ if (src.fill_finish(res) < minBytes) {
+ finish();
+ } else {
+ fillCallback();
+ }
+ });
+ };
+
+ let parseAvailableAware;
+
+ function loop() {
+ if (stream.get_available() === 0) {
+ forceFill(1, loop);
+ return;
+ }
+
+ const opType = stream.read_byte(null);
+ if (opType === OP_TYPE_SAVE_TEXT) {
+ stream.read_upto_async(
+ /*stop_chars=*/ '\0',
+ /*stop_chars_len=*/ 1,
+ 0,
+ null,
+ (src, res) => {
+ const [text] = src.read_upto_finish(res);
+ src.read_byte(null);
+
+ const node = new DS.LLNode();
+ node.diskId = node.id = state.nextId++;
+ node.type = DS.TYPE_TEXT;
+ node.text = text || '';
+ node.favorite = false;
+ state.entries.append(node);
+
+ loop();
+ },
+ );
+ } else if (opType === OP_TYPE_DELETE_TEXT) {
+ uselessOpCount += 2;
+ parseAvailableAware(4, () => {
+ const id = stream.read_uint32(null);
+ (state.entries.findById(id) || state.favorites.findById(id)).detach();
+ });
+ } else if (opType === OP_TYPE_FAVORITE_ITEM) {
+ parseAvailableAware(4, () => {
+ const id = stream.read_uint32(null);
+ const entry = state.entries.findById(id);
+
+ entry.favorite = true;
+ state.favorites.append(entry);
+ });
+ } else if (opType === OP_TYPE_UNFAVORITE_ITEM) {
+ uselessOpCount += 2;
+ parseAvailableAware(4, () => {
+ const id = stream.read_uint32(null);
+ const entry = state.favorites.findById(id);
+
+ entry.favorite = false;
+ state.entries.append(entry);
+ });
+ } else if (opType === OP_TYPE_MOVE_ITEM_TO_END) {
+ uselessOpCount++;
+ parseAvailableAware(4, () => {
+ const id = stream.read_uint32(null);
+ const entry =
+ state.entries.findById(id) || state.favorites.findById(id);
+
+ if (entry.favorite) {
+ state.favorites.append(entry);
+ } else {
+ state.entries.append(entry);
+ }
+ });
+ } else {
+ console.log(EXTENSION_UUID, 'Unknown op type, aborting load.', opType);
+ finish();
+ }
+ }
+
+ parseAvailableAware = (minBytes, parse) => {
+ const safeParse = (cont) => {
+ try {
+ parse();
+ cont();
+ } catch (e) {
+ console.log(EXTENSION_UUID, 'Parsing error');
+ console.error(e);
+
+ const entries = new DS.LinkedList();
+ let nextId = 1;
+ const addEntry = (text) => {
+ const node = new DS.LLNode();
+ node.id = nextId++;
+ node.type = DS.TYPE_TEXT;
+ node.text = text;
+ node.favorite = false;
+ entries.prepend(node);
+ };
+
+ addEntry('Your clipboard data has been corrupted and was moved to:');
+ addEntry('~/.cache/clipboard-history@alexsaveau.dev/corrupted.log');
+ addEntry('Please file a bug report at:');
+ addEntry(
+ 'https://github.com/SUPERCILEX/gnome-clipboard-history/issues/new?assignees=&labels=bug&template=1-bug.md',
+ );
+
+ try {
+ if (
+ !Gio.File.new_for_path(DATABASE_FILE).move(
+ Gio.File.new_for_path(
+ GLib.build_filenamev([CACHE_DIR, 'corrupted.log']),
+ ),
+ Gio.FileCopyFlags.OVERWRITE,
+ null,
+ null,
+ )
+ ) {
+ console.log(EXTENSION_UUID, 'Failed to move database file');
+ }
+ } catch (e) {
+ console.log(EXTENSION_UUID, 'Crash moving database file');
+ console.error(e);
+ }
+ callback(entries, new DS.LinkedList(), nextId, 1);
+ }
+ };
+
+ if (stream.get_available() < minBytes) {
+ forceFill(minBytes, () => {
+ safeParse(loop);
+ });
+ } else {
+ safeParse(loop);
+ }
+ };
+
+ loop();
+}
+
+function _readAndConsumeOldFormat(callback) {
+ Gio.File.new_for_path(OLD_REGISTRY_FILE).load_contents_async(
+ null,
+ (src, res) => {
+ const entries = new DS.LinkedList();
+ const favorites = new DS.LinkedList();
+ let id = 1;
+
+ let contents;
+ try {
+ [, contents] = src.load_contents_finish(res);
+ } catch (e) {
+ if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND)) {
+ callback(entries, favorites, id);
+ return;
+ } else {
+ throw e;
+ }
+ }
+
+ let registry = [];
+ try {
+ registry = JSON.parse(GLib.ByteArray.toString(contents));
+ } catch (e) {
+ console.error(e);
+ }
+
+ for (const entry of registry) {
+ const node = new DS.LLNode();
+
+ node.diskId = node.id = id;
+ node.type = DS.TYPE_TEXT;
+ if (typeof entry === 'string') {
+ node.text = entry;
+ node.favorite = false;
+
+ entries.append(node);
+ } else {
+ node.text = entry.contents;
+ node.favorite = entry.favorite;
+
+ favorites.append(node);
+ }
+
+ id++;
+ }
+
+ resetDatabase(() => entries.toArray().concat(favorites.toArray()));
+ Gio.File.new_for_path(OLD_REGISTRY_FILE).trash_async(
+ 0,
+ null,
+ (src, res) => {
+ src.trash_finish(res);
+ },
+ );
+
+ callback(entries, favorites, id);
+ },
+ );
+}
+
+export function maybePerformLogCompaction(currentStateBuilder) {
+ if (uselessOpCount >= MAX_WASTED_OPS) {
+ resetDatabase(currentStateBuilder);
+ }
+}
+
+export function resetDatabase(currentStateBuilder) {
+ uselessOpCount = 0;
+
+ const state = currentStateBuilder();
+ _pushToOpQueue((resolve) => {
+ // Sigh, can't use truncate because it doesn't have an async variant. Instead, nuke the stream
+ // and let the next append re-create it. Note that we can't use this stream because it tries to
+ // apply our operations atomically and therefore writes to a temporary file instead of the one
+ // we asked for.
+ writeStream = undefined;
+
+ const priority = -10;
+ Gio.File.new_for_path(DATABASE_FILE).replace_async(
+ /*etag=*/ null,
+ /*make_backup=*/ false,
+ Gio.FileCreateFlags.PRIVATE,
+ priority,
+ null,
+ (src, res) => {
+ const stream = _intoDataStream(src.replace_finish(res));
+ const finish = () => {
+ stream.close_async(priority, null, (src, res) => {
+ src.close_finish(res);
+ resolve();
+ });
+ };
+
+ if (state.length === 0) {
+ finish();
+ return;
+ }
+
+ let i = 0;
+ _writeToStream(stream, priority, finish, (dataStream) => {
+ do {
+ const entry = state[i];
+
+ if (entry.type === DS.TYPE_TEXT) {
+ _storeTextOp(entry.text)(dataStream);
+ } else {
+ throw new TypeError('Unknown type: ' + entry.type);
+ }
+ if (entry.favorite) {
+ _updateFavoriteStatusOp(entry.diskId, true)(dataStream);
+ }
+
+ i++;
+ } while (i % 1000 !== 0 && i < state.length);
+
+ // Flush the buffer every 1000 entries
+ return i >= state.length;
+ });
+ },
+ );
+ });
+}
+
+export function storeTextEntry(text) {
+ _appendBytesToLog(_storeTextOp(text), -5);
+}
+
+function _storeTextOp(text) {
+ return (dataStream) => {
+ dataStream.put_byte(OP_TYPE_SAVE_TEXT, null);
+ dataStream.put_string(text, null);
+ dataStream.put_byte(0, null); // NUL terminator
+ return true;
+ };
+}
+
+export function deleteTextEntry(id, isFavorite) {
+ _appendBytesToLog(_deleteTextOp(id), 5);
+ uselessOpCount += 2;
+ if (isFavorite) {
+ uselessOpCount++;
+ }
+}
+
+function _deleteTextOp(id) {
+ return (dataStream) => {
+ dataStream.put_byte(OP_TYPE_DELETE_TEXT, null);
+ dataStream.put_uint32(id, null);
+ return true;
+ };
+}
+
+export function updateFavoriteStatus(id, favorite) {
+ _appendBytesToLog(_updateFavoriteStatusOp(id, favorite));
+
+ if (!favorite) {
+ uselessOpCount += 2;
+ }
+}
+
+function _updateFavoriteStatusOp(id, favorite) {
+ return (dataStream) => {
+ dataStream.put_byte(
+ favorite ? OP_TYPE_FAVORITE_ITEM : OP_TYPE_UNFAVORITE_ITEM,
+ null,
+ );
+ dataStream.put_uint32(id, null);
+ return true;
+ };
+}
+
+export function moveEntryToEnd(id) {
+ _appendBytesToLog(_moveToEndOp(id));
+ uselessOpCount++;
+}
+
+function _moveToEndOp(id) {
+ return (dataStream) => {
+ dataStream.put_byte(OP_TYPE_MOVE_ITEM_TO_END, null);
+ dataStream.put_uint32(id, null);
+ return true;
+ };
+}
+
+function _appendBytesToLog(callback, priority) {
+ priority = priority || 0;
+ _pushToOpQueue((resolve) => {
+ const runUnsafe = () => {
+ _writeToStream(writeStream, priority, resolve, callback);
+ };
+
+ if (writeStream === undefined) {
+ Gio.File.new_for_path(DATABASE_FILE).append_to_async(
+ Gio.FileCreateFlags.PRIVATE,
+ priority,
+ null,
+ (src, res) => {
+ writeStream = _intoDataStream(src.append_to_finish(res));
+ runUnsafe();
+ },
+ );
+ } else {
+ runUnsafe();
+ }
+ });
+}
+
+function _writeToStream(stream, priority, resolve, callback) {
+ _writeCallbackBytesAsyncHack(callback, stream, priority, () => {
+ stream.flush_async(priority, null, (src, res) => {
+ src.flush_finish(res);
+ resolve();
+ });
+ });
+}
+
+/**
+ * This garbage code is here to keep disk writes off the main thread. DataOutputStream doesn't have
+ * async method variants, so we write to a memory buffer and then flush it asynchronously. We're
+ * basically trying to balance memory allocations with disk writes.
+ */
+function _writeCallbackBytesAsyncHack(
+ dataCallback,
+ stream,
+ priority,
+ callback,
+) {
+ if (dataCallback(stream)) {
+ callback();
+ } else {
+ stream.flush_async(priority, null, (src, res) => {
+ src.flush_finish(res);
+ _writeCallbackBytesAsyncHack(dataCallback, stream, priority, callback);
+ });
+ }
+}
+
+function _intoDataStream(stream) {
+ const bufStream = Gio.BufferedOutputStream.new(stream);
+ bufStream.set_auto_grow(true); // Blocks flushing, needed for hack
+ const ioStream = Gio.DataOutputStream.new(bufStream);
+ ioStream.set_byte_order(BYTE_ORDER);
+ return ioStream;
+}
+
+function _pushToOpQueue(op) {
+ const consumeOp = () => {
+ const resolve = () => {
+ opInProgress = false;
+
+ const next = opQueue.head;
+ if (next) {
+ next.detach();
+ next.op();
+ }
+ };
+
+ opInProgress = true;
+ op(resolve);
+ };
+
+ if (opInProgress) {
+ const node = new DS.LLNode();
+ node.op = consumeOp;
+ opQueue.append(node);
+ } else {
+ consumeOp();
+ }
+}
diff --git a/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/stylesheet.css b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/stylesheet.css
new file mode 100644
index 0000000..824aee7
--- /dev/null
+++ b/gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/stylesheet.css
@@ -0,0 +1,34 @@
+.clipboard-indicator-icon.private-mode {
+ color: rgba(255, 255, 255, 0.3);
+}
+
+.ci-notification-label {
+ font-weight: bold;
+ color: #ffffff;
+ background-color: rgba(10, 10, 10, 0.7);
+ border-radius: 6px;
+ font-size: 2em;
+ padding: 0.5em;
+ width: 400px;
+}
+
+.popup-menu-item .ci-action-btn StIcon {
+ icon-size: 16px;
+}
+
+.ci-history-menu-section {
+ max-height: 450px;
+}
+
+.ci-history-search-section {
+ padding-top: 0;
+}
+
+.ci-history-search-section .popup-menu-ornament,
+.ci-history-actions-section .popup-menu-ornament {
+ width: auto;
+}
+
+.ci-history-search-entry {
+ width: 5em;
+}