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