[gnome] Add clipboard manager extension
This commit is contained in:
@ -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.**
|
||||
@ -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
|
||||
|
||||

|
||||
|
||||
- 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
|
||||
```
|
||||
@ -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;
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
},
|
||||
);
|
||||
@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
Binary file not shown.
@ -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>
|
||||
@ -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;
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user