Files

1353 lines
36 KiB
JavaScript

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