Ver código fonte

[gnome] Add clipboard manager extension

Colin Powell 8 horas atrás
pai
commit
c0179c8a87
34 arquivos alterados com 3104 adições e 0 exclusões
  1. 25 0
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/LICENSE
  2. 68 0
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/README.md
  3. 77 0
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/confirmDialog.js
  4. 417 0
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/dataStructures.js
  5. 1352 0
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/extension.js
  6. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ar/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  7. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ca/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  8. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/cs/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  9. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/de/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  10. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/el/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  11. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/es/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  12. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/eu/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  13. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/fa/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  14. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/fi/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  15. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/fr/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  16. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/hu/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  17. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/it/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  18. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ja/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  19. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/nl/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  20. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/oc/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  21. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/pl/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  22. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/pt_BR/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  23. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ru/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  24. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/sk/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  25. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/tr/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  26. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/uk/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  27. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/zh_CN/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo
  28. 16 0
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/metadata.json
  29. 444 0
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/prefs.js
  30. BIN
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/schemas/gschemas.compiled
  31. 140 0
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/schemas/org.gnome.shell.extensions.clipboard-indicator.gschema.xml
  32. 19 0
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/settingsFields.js
  33. 512 0
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/store.js
  34. 34 0
      gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/stylesheet.css

+ 25 - 0
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.**

+ 68 - 0
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
+
+[<img src="https://raw.githubusercontent.com/andyholmes/gnome-shell-extensions-badge/eb9af9a1c6f04eb060cb01de6aeb5c84232cd8c0/get-it-on-ego.svg?sanitize=true" alt="Get it on GNOME Extensions" height="100" align="middle">](https://extensions.gnome.org/extension/4839/clipboard-history/)
+
+## Tips
+
+![Tutorial screenshot](tutorial-screenshot.png)
+
+- Open the panel from anywhere with <kbd>Super</kbd> + <kbd>Shift</kbd> + <kbd>V</kbd>.
+- 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:
+  - <kbd>Ctrl</kbd> + <kbd>N</kbd> where `N` is a number from 1 to 9 to select the Nth
+    non-favorited entry.
+  - <kbd>Super</kbd> + <kbd>Ctrl</kbd> + <kbd>N</kbd> where `N` is a number from 1 to 9 to select
+    the Nth favorited entry.
+  - <kbd>Ctrl</kbd> + <kbd>p/n</kbd> to navigate to the previous/next page.
+  - <kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>S</kbd> to open settings.
+  - <kbd>/</kbd> to search.
+  - <kbd>F</kbd> 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
+
+<kbd>Alt</kbd> + <kbd>F2</kbd> then type `r`.
+
+### Install
+
+```shell
+gnome-extensions enable clipboard-history@alexsaveau.dev
+```

+ 77 - 0
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;
+          },
+        },
+      ]);
+    }
+  },
+);

+ 417 - 0
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;
+}

+ 1352 - 0
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();
+  }
+}

BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ar/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ca/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/cs/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/de/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/el/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/es/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/eu/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/fa/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/fi/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/fr/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/hu/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/it/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ja/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/nl/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/oc/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/pl/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/pt_BR/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/ru/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/sk/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/tr/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/uk/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/locale/zh_CN/LC_MESSAGES/clipboard-history@alexsaveau.dev.mo


+ 16 - 0
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
+}

+ 444 - 0
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;
+}

BIN
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/schemas/gschemas.compiled


+ 140 - 0
gnome/.local/share/gnome-shell/extensions/clipboard-history@alexsaveau.dev/schemas/org.gnome.shell.extensions.clipboard-indicator.gschema.xml

@@ -0,0 +1,140 @@
+<schemalist gettext-domain="gnome-shell-extensions">
+  <schema
+      id="org.gnome.shell.extensions.clipboard-history"
+      path="/org/gnome/shell/extensions/clipboard-history/">
+
+    <key type="i" name="history-size">
+      <default>1000</default>
+      <summary>The maximum number of items to remember</summary>
+      <range min="1" max="100000" />
+    </key>
+
+    <key type="i" name="display-mode">
+      <default>0</default>
+      <summary>What to display in top bar</summary>
+      <range min="0" max="3" />
+    </key>
+
+    <key name="disable-down-arrow" type="b">
+      <default>true</default>
+      <summary>Remove down arrow in top bar</summary>
+    </key>
+
+    <key type="i" name="window-width-percentage">
+      <default>33</default>
+      <summary>Window width (%)</summary>
+      <description>
+        The width of the clipboard panel as a percentage of screen width.
+      </description>
+      <range min="0" max="100" />
+    </key>
+
+    <key type="i" name="topbar-preview-size">
+      <default>10</default>
+      <summary>Number of visible characters in top bar</summary>
+      <description>
+        The number of characters to display for the current clipboard item in the top bar.
+      </description>
+      <range min="1" max="100" />
+    </key>
+
+    <key type="i" name="cache-size">
+      <default>100</default>
+      <summary>The maximum clipboard history size (MiB)</summary>
+      <description>
+        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.
+      </description>
+      <range min="1" max="1024" />
+    </key>
+
+    <key name="cache-only-favorites" type="b">
+      <default>false</default>
+      <summary>Only save favorites to disk</summary>
+      <description>
+        Non-favorite items will still be saved, but only in-memory. Restarting the Gnome Shell will
+        result in the loss of those items.
+      </description>
+    </key>
+
+    <key name="notify-on-copy" type="b">
+      <default>false</default>
+      <summary>Show a notification on copy</summary>
+      <description>
+        If true, a notification is shown when content is copied to clipboard with an undo button.
+      </description>
+    </key>
+
+    <key name="confirm-clear" type="b">
+      <default>true</default>
+      <summary>Show confirmation dialog on Clear History</summary>
+      <description>
+        If true, a confirmation dialog is shown when attempting to Clear History.
+      </description>
+    </key>
+
+    <key name="strip-text" type="b">
+      <default>false</default>
+      <summary>Remove whitespace around copied plaintext items</summary>
+    </key>
+
+    <key name="paste-on-selection" type="b">
+      <default>true</default>
+      <summary>Paste selected items into the previously active window</summary>
+    </key>
+
+    <key name="process-primary-selection" type="b">
+      <default>false</default>
+      <summary>Save the currently selected text to the clipboard history</summary>
+      <description>
+        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.
+      </description>
+    </key>
+
+    <key name="move-item-first" type="b">
+      <default>true</default>
+      <summary>Move previously copied items to the top of the list</summary>
+    </key>
+
+    <key name="private-mode" type="b">
+      <default>false</default>
+      <summary>Enable private mode</summary>
+      <description>
+        If true, copied items are not saved in the clipboard history (be that in memory or on disk).
+      </description>
+    </key>
+
+    <key name="enable-keybindings" type="b">
+      <default>true</default>
+      <summary>Enable keyboard shortcuts</summary>
+    </key>
+    <key name="clear-history" type="as">
+      <default><![CDATA[[]]]></default>
+      <summary>Shortcut to clear history</summary>
+    </key>
+    <key name="prev-entry" type="as">
+      <default><![CDATA[[]]]></default>
+      <summary>Shortcut to cycle to the previous clipboard entry</summary>
+      <description>
+      </description>
+    </key>
+    <key name="next-entry" type="as">
+      <default><![CDATA[[]]]></default>
+      <summary>Shortcut to cycle to the next clipboard entry</summary>
+    </key>
+    <key name="toggle-menu" type="as">
+      <default><![CDATA[['<Super><Shift>V']]]></default>
+      <summary>Shortcut to open the clipboard history</summary>
+    </key>
+    <key name="toggle-private-mode" type="as">
+      <default><![CDATA[['<Super><Shift>P']]]></default>
+      <summary>Toggle private mode</summary>
+    </key>
+    <key name="ignore-password-mimes" type="b">
+      <default>true</default>
+      <summary>Ignore selections containing the x-kde-passwordManagerHint mime type.</summary>
+    </key>
+  </schema>
+</schemalist>

+ 19 - 0
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;

+ 512 - 0
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();
+  }
+}

+ 34 - 0
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;
+}