extension.js 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352
  1. import Clutter from 'gi://Clutter';
  2. import GObject from 'gi://GObject';
  3. import GLib from 'gi://GLib';
  4. import Meta from 'gi://Meta';
  5. import Shell from 'gi://Shell';
  6. import St from 'gi://St';
  7. import * as Main from 'resource:///org/gnome/shell/ui/main.js';
  8. import * as MessageTray from 'resource:///org/gnome/shell/ui/messageTray.js';
  9. import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
  10. import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
  11. import {
  12. Extension,
  13. gettext as _,
  14. } from 'resource:///org/gnome/shell/extensions/extension.js';
  15. import { ensureActorVisibleInScrollView } from 'resource:///org/gnome/shell/misc/animationUtils.js';
  16. import * as Store from './store.js';
  17. import * as DS from './dataStructures.js';
  18. import { openConfirmDialog } from './confirmDialog.js';
  19. import SettingsFields from './settingsFields.js';
  20. const Clipboard = St.Clipboard.get_default();
  21. const VirtualKeyboard = (() => {
  22. let VirtualKeyboard;
  23. return () => {
  24. if (!VirtualKeyboard) {
  25. VirtualKeyboard = Clutter.get_default_backend()
  26. .get_default_seat()
  27. .create_virtual_device(Clutter.InputDeviceType.KEYBOARD_DEVICE);
  28. }
  29. return VirtualKeyboard;
  30. };
  31. })();
  32. const SETTING_KEY_CLEAR_HISTORY = 'clear-history';
  33. const SETTING_KEY_PREV_ENTRY = 'prev-entry';
  34. const SETTING_KEY_NEXT_ENTRY = 'next-entry';
  35. const SETTING_KEY_TOGGLE_MENU = 'toggle-menu';
  36. const SETTING_KEY_PRIVATE_MODE = 'toggle-private-mode';
  37. const INDICATOR_ICON = 'edit-paste-symbolic';
  38. const PAGE_SIZE = 50;
  39. const MAX_VISIBLE_CHARS = 200;
  40. let MAX_REGISTRY_LENGTH;
  41. let MAX_BYTES;
  42. let WINDOW_WIDTH_PERCENTAGE;
  43. let CACHE_ONLY_FAVORITES;
  44. let MOVE_ITEM_FIRST;
  45. let ENABLE_KEYBINDING;
  46. let PRIVATE_MODE;
  47. let NOTIFY_ON_COPY;
  48. let CONFIRM_ON_CLEAR;
  49. let MAX_TOPBAR_LENGTH;
  50. let TOPBAR_DISPLAY_MODE; // 0 - only icon, 1 - only clipboard content, 2 - both, 3 - none
  51. let DISABLE_DOWN_ARROW;
  52. let STRIP_TEXT;
  53. let PASTE_ON_SELECTION;
  54. let PROCESS_PRIMARY_SELECTION;
  55. let IGNORE_PASSWORD_MIMES;
  56. class ClipboardIndicator extends PanelMenu.Button {
  57. _init(extension) {
  58. super._init(0, extension.indicatorName, false);
  59. this.extension = extension;
  60. this.settings = extension.getSettings();
  61. this._shortcutsBindingIds = [];
  62. const hbox = new St.BoxLayout({
  63. style_class: 'panel-status-menu-box clipboard-indicator-hbox',
  64. });
  65. this.icon = new St.Icon({
  66. icon_name: INDICATOR_ICON,
  67. style_class: 'system-status-icon clipboard-indicator-icon',
  68. });
  69. hbox.add_child(this.icon);
  70. this._buttonText = new St.Label({
  71. text: '',
  72. y_align: Clutter.ActorAlign.CENTER,
  73. });
  74. hbox.add_child(this._buttonText);
  75. this._downArrow = PopupMenu.arrowIcon(St.Side.BOTTOM);
  76. hbox.add_child(this._downArrow);
  77. this.add_child(hbox);
  78. this._fetchSettings();
  79. this._buildMenu();
  80. this._updateTopbarLayout();
  81. }
  82. destroy() {
  83. this._disconnectSettings();
  84. this._unbindShortcuts();
  85. this._disconnectSelectionListener();
  86. if (this._searchFocusHackCallbackId) {
  87. GLib.Source.source_remove(this._searchFocusHackCallbackId);
  88. this._searchFocusHackCallbackId = undefined;
  89. }
  90. if (this._pasteHackCallbackId) {
  91. GLib.Source.source_remove(this._pasteHackCallbackId);
  92. this._pasteHackCallbackId = undefined;
  93. }
  94. super.destroy();
  95. }
  96. _buildMenu() {
  97. this.searchEntry = new St.Entry({
  98. name: 'searchEntry',
  99. style_class: 'search-entry ci-history-search-entry',
  100. can_focus: true,
  101. hint_text: _('Search clipboard history…'),
  102. track_hover: true,
  103. x_expand: true,
  104. y_expand: true,
  105. });
  106. const entryItem = new PopupMenu.PopupBaseMenuItem({
  107. style_class: 'ci-history-search-section',
  108. reactive: false,
  109. can_focus: false,
  110. });
  111. entryItem.add_child(this.searchEntry);
  112. this.menu.addMenuItem(entryItem);
  113. this.menu.connect('open-state-changed', (self, open) => {
  114. if (open) {
  115. this._setMenuWidth();
  116. this.searchEntry.set_text('');
  117. this._searchFocusHackCallbackId = GLib.timeout_add(
  118. GLib.PRIORITY_DEFAULT,
  119. 1,
  120. () => {
  121. global.stage.set_key_focus(this.searchEntry);
  122. this._searchFocusHackCallbackId = undefined;
  123. return false;
  124. },
  125. );
  126. }
  127. });
  128. // Create menu sections for items
  129. // Favorites
  130. this.favoritesSection = new PopupMenu.PopupMenuSection();
  131. this.scrollViewFavoritesMenuSection = new PopupMenu.PopupMenuSection();
  132. const favoritesScrollView = new St.ScrollView({
  133. style_class: 'ci-history-menu-section',
  134. overlay_scrollbars: true,
  135. });
  136. favoritesScrollView.add_child(this.favoritesSection.actor);
  137. this.scrollViewFavoritesMenuSection.actor.add_child(favoritesScrollView);
  138. this.menu.addMenuItem(this.scrollViewFavoritesMenuSection);
  139. this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
  140. // History
  141. this.historySection = new PopupMenu.PopupMenuSection();
  142. this.scrollViewMenuSection = new PopupMenu.PopupMenuSection();
  143. this.historyScrollView = new St.ScrollView({
  144. style_class: 'ci-history-menu-section',
  145. overlay_scrollbars: true,
  146. });
  147. this.historyScrollView.add_child(this.historySection.actor);
  148. this.scrollViewMenuSection.actor.add_child(this.historyScrollView);
  149. this.menu.addMenuItem(this.scrollViewMenuSection);
  150. this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
  151. const actionsSection = new PopupMenu.PopupMenuSection();
  152. const actionsBox = new St.BoxLayout({
  153. style_class: 'ci-history-actions-section',
  154. vertical: false,
  155. });
  156. actionsSection.actor.add_child(actionsBox);
  157. this.menu.addMenuItem(actionsSection);
  158. const prevPage = new PopupMenu.PopupBaseMenuItem();
  159. prevPage.add_child(
  160. new St.Icon({
  161. icon_name: 'go-previous-symbolic',
  162. style_class: 'popup-menu-icon',
  163. }),
  164. );
  165. prevPage.connect('activate', this._navigatePrevPage.bind(this));
  166. actionsBox.add_child(prevPage);
  167. const nextPage = new PopupMenu.PopupBaseMenuItem();
  168. nextPage.add_child(
  169. new St.Icon({
  170. icon_name: 'go-next-symbolic',
  171. style_class: 'popup-menu-icon',
  172. }),
  173. );
  174. nextPage.connect('activate', this._navigateNextPage.bind(this));
  175. actionsBox.add_child(nextPage);
  176. actionsBox.add_child(new St.BoxLayout({ x_expand: true }));
  177. this.privateModeMenuItem = new PopupMenu.PopupSwitchMenuItem(
  178. _('Private mode'),
  179. PRIVATE_MODE,
  180. { reactive: true },
  181. );
  182. this.privateModeMenuItem.connect('toggled', () => {
  183. this.settings.set_boolean(
  184. SettingsFields.PRIVATE_MODE,
  185. this.privateModeMenuItem.state,
  186. );
  187. });
  188. actionsBox.add_child(this.privateModeMenuItem);
  189. this._updatePrivateModeState();
  190. const clearMenuItem = new PopupMenu.PopupBaseMenuItem();
  191. clearMenuItem.add_child(
  192. new St.Icon({
  193. icon_name: 'edit-delete-symbolic',
  194. style_class: 'popup-menu-icon',
  195. }),
  196. );
  197. actionsBox.add_child(clearMenuItem);
  198. const settingsMenuItem = new PopupMenu.PopupBaseMenuItem();
  199. settingsMenuItem.add_child(
  200. new St.Icon({
  201. icon_name: 'emblem-system-symbolic',
  202. style_class: 'popup-menu-icon',
  203. }),
  204. );
  205. settingsMenuItem.connect('activate', this._openSettings.bind(this));
  206. actionsBox.add_child(settingsMenuItem);
  207. if (ENABLE_KEYBINDING) {
  208. this._bindShortcuts();
  209. }
  210. this.menu.actor.connect('key-press-event', (_, event) =>
  211. this._handleGlobalKeyEvent(event),
  212. );
  213. Store.buildClipboardStateFromLog(
  214. (entries, favoriteEntries, nextId, nextDiskId) => {
  215. /**
  216. * This field stores the number of items in the historySection to avoid calling _getMenuItems
  217. * since that method is slow.
  218. */
  219. this.activeHistoryMenuItems = 0;
  220. /**
  221. * These two IDs are extremely important: making a mistake with either one breaks the
  222. * extension. Both IDs are globally unique within compaction intervals. The normal ID is
  223. * *always* present and valid -- it allows us to build an inverted index so we can find
  224. * previously copied items in O(1) time. The Disk ID is only present when we cache all
  225. * entries. This additional complexity is needed to know what the ID of an item is on disk as
  226. * compared to in memory when we're only caching favorites.
  227. */
  228. this.nextId = nextId;
  229. this.nextDiskId = nextDiskId || nextId;
  230. /**
  231. * DS.LinkedList is the actual clipboard history and source of truth. Never use historySection
  232. * or favoritesSection as the source of truth as these may get outdated during pagination.
  233. *
  234. * Entries *may* have a menuItem attached, meaning they are currently visible. On the other
  235. * hand, menu items must always have an entry attached.
  236. */
  237. this.entries = entries;
  238. this.favoriteEntries = favoriteEntries;
  239. this.currentlySelectedEntry = entries.last();
  240. this._restoreFavoritedEntries();
  241. this._maybeRestoreMenuPages();
  242. this._settingsChangedId = this.settings.connect(
  243. 'changed',
  244. this._onSettingsChange.bind(this),
  245. );
  246. this.searchEntry
  247. .get_clutter_text()
  248. .connect('text-changed', this._onSearchTextChanged.bind(this));
  249. clearMenuItem.connect('activate', this._removeAll.bind(this));
  250. this._setupSelectionChangeListener();
  251. },
  252. );
  253. }
  254. _setMenuWidth() {
  255. const display = global.display;
  256. const screen_width = display.get_monitor_geometry(
  257. display.get_primary_monitor(),
  258. ).width;
  259. this.menu.actor.width = screen_width * (WINDOW_WIDTH_PERCENTAGE / 100);
  260. }
  261. _handleGlobalKeyEvent(event) {
  262. this._handleCtrlSelectKeyEvent(event);
  263. this._handleSettingsKeyEvent(event);
  264. this._handleNavigationKeyEvent(event);
  265. this._handleFocusSearchKeyEvent(event);
  266. }
  267. _handleCtrlSelectKeyEvent(event) {
  268. if (!event.has_control_modifier()) {
  269. return;
  270. }
  271. const index = parseInt(event.get_key_unicode()); // Starts at 1
  272. if (isNaN(index) || index <= 0) {
  273. return;
  274. }
  275. const items =
  276. event.get_state() === 68 // Ctrl + Super
  277. ? this.favoritesSection._getMenuItems()
  278. : this.historySection._getMenuItems();
  279. if (index > items.length) {
  280. return;
  281. }
  282. this._onMenuItemSelectedAndMenuClose(items[index - 1]);
  283. }
  284. _handleSettingsKeyEvent(event) {
  285. if (event.get_state() !== 12 || event.get_key_unicode() !== 's') {
  286. return;
  287. }
  288. this._openSettings();
  289. }
  290. _handleNavigationKeyEvent(event) {
  291. if (!event.has_control_modifier()) {
  292. return;
  293. }
  294. if (event.get_key_unicode() === 'n') {
  295. this._navigateNextPage();
  296. } else if (event.get_key_unicode() === 'p') {
  297. this._navigatePrevPage();
  298. }
  299. }
  300. _handleFocusSearchKeyEvent(event) {
  301. if (event.get_key_unicode() !== '/') {
  302. return;
  303. }
  304. global.stage.set_key_focus(this.searchEntry);
  305. }
  306. _addEntry(entry, selectEntry, updateClipboard, insertIndex) {
  307. if (!entry.favorite && this.activeHistoryMenuItems >= PAGE_SIZE) {
  308. const items = this.historySection._getMenuItems();
  309. const item = items[items.length - 1];
  310. this._rewriteMenuItem(item, entry);
  311. this.historySection.moveMenuItem(item, 0);
  312. if (selectEntry) {
  313. this._selectEntry(entry, updateClipboard);
  314. }
  315. return;
  316. }
  317. const menuItem = new PopupMenu.PopupMenuItem('', { hover: false });
  318. menuItem.setOrnament(PopupMenu.Ornament.NONE);
  319. menuItem.entry = entry;
  320. entry.menuItem = menuItem;
  321. menuItem.connect(
  322. 'activate',
  323. this._onMenuItemSelectedAndMenuClose.bind(this),
  324. );
  325. menuItem.connect('key-press-event', (_, event) =>
  326. this._handleMenuItemKeyEvent(event, menuItem),
  327. );
  328. this._setEntryLabel(menuItem);
  329. // Favorite button
  330. const icon_name = entry.favorite
  331. ? 'starred-symbolic'
  332. : 'non-starred-symbolic';
  333. const iconfav = new St.Icon({
  334. icon_name: icon_name,
  335. style_class: 'system-status-icon',
  336. });
  337. const icofavBtn = new St.Button({
  338. style_class: 'ci-action-btn',
  339. can_focus: true,
  340. child: iconfav,
  341. x_align: Clutter.ActorAlign.END,
  342. x_expand: true,
  343. y_expand: true,
  344. });
  345. menuItem.actor.add_child(icofavBtn);
  346. icofavBtn.connect('clicked', () => {
  347. this._favoriteToggle(menuItem);
  348. });
  349. // Delete button
  350. const icon = new St.Icon({
  351. icon_name: 'edit-delete-symbolic',
  352. style_class: 'system-status-icon',
  353. });
  354. const icoBtn = new St.Button({
  355. style_class: 'ci-action-btn',
  356. can_focus: true,
  357. child: icon,
  358. x_align: Clutter.ActorAlign.END,
  359. x_expand: false,
  360. y_expand: true,
  361. });
  362. menuItem.actor.add_child(icoBtn);
  363. icoBtn.connect('clicked', () => {
  364. this._deleteEntryAndRestoreLatest(menuItem.entry);
  365. });
  366. menuItem.connect('destroy', () => {
  367. delete menuItem.entry.menuItem;
  368. if (!menuItem.entry.favorite) {
  369. this.activeHistoryMenuItems--;
  370. }
  371. });
  372. menuItem.connect('key-focus-in', () => {
  373. if (!menuItem.entry.favorite) {
  374. ensureActorVisibleInScrollView(this.historyScrollView, menuItem);
  375. }
  376. });
  377. if (entry.favorite) {
  378. this.favoritesSection.addMenuItem(menuItem, insertIndex);
  379. } else {
  380. this.historySection.addMenuItem(menuItem, insertIndex);
  381. this.activeHistoryMenuItems++;
  382. }
  383. if (selectEntry) {
  384. this._selectEntry(entry, updateClipboard);
  385. }
  386. }
  387. _handleMenuItemKeyEvent(event, menuItem) {
  388. if (event.get_key_unicode() === 'f') {
  389. this._favoriteToggle(menuItem);
  390. }
  391. if (event.get_key_code() === 119) {
  392. const next = menuItem.entry.prev || menuItem.entry.next;
  393. if (next?.menuItem) {
  394. global.stage.set_key_focus(next.menuItem);
  395. }
  396. this._deleteEntryAndRestoreLatest(menuItem.entry);
  397. }
  398. }
  399. _updateButtonText(entry) {
  400. if (
  401. !(TOPBAR_DISPLAY_MODE === 1 || TOPBAR_DISPLAY_MODE === 2) ||
  402. (entry && entry.type !== DS.TYPE_TEXT)
  403. ) {
  404. return;
  405. }
  406. if (PRIVATE_MODE) {
  407. this._buttonText.set_text('…');
  408. } else if (entry) {
  409. this._buttonText.set_text(this._truncated(entry.text, MAX_TOPBAR_LENGTH));
  410. } else {
  411. this._buttonText.set_text('');
  412. }
  413. }
  414. _setEntryLabel(menuItem) {
  415. const entry = menuItem.entry;
  416. if (entry.type === DS.TYPE_TEXT) {
  417. menuItem.label.set_text(this._truncated(entry.text, MAX_VISIBLE_CHARS));
  418. } else {
  419. throw new TypeError('Unknown type: ' + entry.type);
  420. }
  421. }
  422. _favoriteToggle(menuItem) {
  423. const entry = menuItem.entry;
  424. const wasSelected = this.currentlySelectedEntry?.id === entry.id;
  425. // Move to front (end of list)
  426. (entry.favorite ? this.entries : this.favoriteEntries).append(entry);
  427. this._removeEntry(entry);
  428. entry.favorite = !entry.favorite;
  429. this._addEntry(entry, wasSelected, false, 0);
  430. this._maybeRestoreMenuPages();
  431. global.stage.set_key_focus(entry.menuItem);
  432. if (CACHE_ONLY_FAVORITES && !entry.favorite) {
  433. if (entry.diskId) {
  434. Store.deleteTextEntry(entry.diskId, true);
  435. delete entry.diskId;
  436. }
  437. return;
  438. }
  439. if (entry.diskId) {
  440. Store.updateFavoriteStatus(entry.diskId, entry.favorite);
  441. } else {
  442. entry.diskId = this.nextDiskId++;
  443. Store.storeTextEntry(entry.text);
  444. Store.updateFavoriteStatus(entry.diskId, true);
  445. }
  446. }
  447. _removeAll() {
  448. if (CONFIRM_ON_CLEAR) {
  449. this._confirmRemoveAll();
  450. } else {
  451. this._clearHistory();
  452. }
  453. }
  454. _confirmRemoveAll() {
  455. const title = _('Clear all?');
  456. const message = _('Are you sure you want to delete all clipboard items?');
  457. const sub_message = _('This operation cannot be undone.');
  458. openConfirmDialog(
  459. title,
  460. message,
  461. sub_message,
  462. _('Clear'),
  463. _('Cancel'),
  464. () => {
  465. this._clearHistory();
  466. },
  467. );
  468. }
  469. _clearHistory() {
  470. if (this.currentlySelectedEntry && !this.currentlySelectedEntry.favorite) {
  471. this._resetSelectedMenuItem(true);
  472. }
  473. // Favorites aren't touched when clearing history
  474. this.entries = new DS.LinkedList();
  475. this.historySection.removeAll();
  476. Store.resetDatabase(this._currentStateBuilder.bind(this));
  477. }
  478. _removeEntry(entry, fullyDelete, humanGenerated) {
  479. if (fullyDelete) {
  480. entry.detach();
  481. if (entry.diskId) {
  482. Store.deleteTextEntry(entry.diskId, entry.favorite);
  483. }
  484. }
  485. if (entry.id === this.currentlySelectedEntry?.id) {
  486. this._resetSelectedMenuItem(humanGenerated);
  487. }
  488. entry.menuItem?.destroy();
  489. if (fullyDelete) {
  490. this._maybeRestoreMenuPages();
  491. }
  492. }
  493. _pruneOldestEntries() {
  494. let entry = this.entries.head;
  495. while (
  496. entry &&
  497. (this.entries.length > MAX_REGISTRY_LENGTH ||
  498. this.entries.bytes > MAX_BYTES)
  499. ) {
  500. const next = entry.next;
  501. this._removeEntry(entry, true);
  502. entry = next;
  503. }
  504. Store.maybePerformLogCompaction(this._currentStateBuilder.bind(this));
  505. }
  506. _selectEntry(entry, updateClipboard, triggerPaste) {
  507. this.currentlySelectedEntry?.menuItem?.setOrnament(PopupMenu.Ornament.NONE);
  508. this.currentlySelectedEntry = entry;
  509. entry.menuItem?.setOrnament(PopupMenu.Ornament.DOT);
  510. this._updateButtonText(entry);
  511. if (updateClipboard !== false) {
  512. if (entry.type === DS.TYPE_TEXT) {
  513. this._setClipboardText(entry.text);
  514. } else {
  515. throw new TypeError('Unknown type: ' + entry.type);
  516. }
  517. if (PASTE_ON_SELECTION && triggerPaste) {
  518. this._triggerPasteHack();
  519. }
  520. }
  521. }
  522. _setClipboardText(text) {
  523. if (this._debouncing !== undefined) {
  524. this._debouncing++;
  525. }
  526. Clipboard.set_text(St.ClipboardType.CLIPBOARD, text);
  527. Clipboard.set_text(St.ClipboardType.PRIMARY, text);
  528. }
  529. _triggerPasteHack() {
  530. this._pasteHackCallbackId = GLib.timeout_add(
  531. GLib.PRIORITY_DEFAULT,
  532. 1, // Just post to the end of the event loop
  533. () => {
  534. const SHIFT_L = 42;
  535. const INSERT = 110;
  536. const eventTime = Clutter.get_current_event_time() * 1000;
  537. VirtualKeyboard().notify_key(
  538. eventTime,
  539. SHIFT_L,
  540. Clutter.KeyState.PRESSED,
  541. );
  542. VirtualKeyboard().notify_key(
  543. eventTime,
  544. INSERT,
  545. Clutter.KeyState.PRESSED,
  546. );
  547. VirtualKeyboard().notify_key(
  548. eventTime,
  549. INSERT,
  550. Clutter.KeyState.RELEASED,
  551. );
  552. VirtualKeyboard().notify_key(
  553. eventTime,
  554. SHIFT_L,
  555. Clutter.KeyState.RELEASED,
  556. );
  557. this._pasteHackCallbackId = undefined;
  558. return false;
  559. },
  560. );
  561. }
  562. _onMenuItemSelectedAndMenuClose(menuItem) {
  563. this._moveEntryFirst(menuItem.entry);
  564. this._selectEntry(menuItem.entry, true, true);
  565. this.menu.close();
  566. }
  567. _resetSelectedMenuItem(resetClipboard) {
  568. this.currentlySelectedEntry = undefined;
  569. this._updateButtonText();
  570. if (resetClipboard) {
  571. this._setClipboardText('');
  572. }
  573. }
  574. _restoreFavoritedEntries() {
  575. for (let entry = this.favoriteEntries.last(); entry; entry = entry.prev) {
  576. this._addEntry(entry);
  577. }
  578. }
  579. _maybeRestoreMenuPages() {
  580. if (this.activeHistoryMenuItems > 0) {
  581. return;
  582. }
  583. for (
  584. let entry = this.entries.last();
  585. entry && this.activeHistoryMenuItems < PAGE_SIZE;
  586. entry = entry.prev
  587. ) {
  588. this._addEntry(entry, this.currentlySelectedEntry === entry);
  589. }
  590. }
  591. /**
  592. * Our pagination implementation is purposefully "broken." The idea is simply to do no unnecessary
  593. * work. As a consequence, if a user navigates to some page and then starts copying/moving items,
  594. * those items will appear on the currently visible page even though they don't belong there. This
  595. * could kind of be considered a feature since it means you can go back to some cluster of copied
  596. * items and start copying stuff from the same cluster and have it all show up together.
  597. *
  598. * Note that over time (as the user copies items), the page reclamation process will morph the
  599. * current page into the first page. This is the only way to make the user-visible state match our
  600. * backing store after changing pages.
  601. *
  602. * Also note that the use of `last` and `next` is correct. Menu items are ordered from latest to
  603. * oldest whereas `entries` is ordered from oldest to latest.
  604. */
  605. _navigatePrevPage() {
  606. if (this.searchEntryFront) {
  607. this.populateSearchResults(this.searchEntry.get_text(), false);
  608. return;
  609. }
  610. const items = this.historySection._getMenuItems();
  611. if (items.length === 0) {
  612. return;
  613. }
  614. const start = items[0].entry;
  615. for (
  616. let entry = start.nextCyclic(), i = items.length - 1;
  617. entry !== start && i >= 0;
  618. entry = entry.nextCyclic()
  619. ) {
  620. this._rewriteMenuItem(items[i--], entry);
  621. }
  622. }
  623. _navigateNextPage() {
  624. if (this.searchEntryFront) {
  625. this.populateSearchResults(this.searchEntry.get_text(), true);
  626. return;
  627. }
  628. const items = this.historySection._getMenuItems();
  629. if (items.length === 0) {
  630. return;
  631. }
  632. const start = items[items.length - 1].entry;
  633. for (
  634. let entry = start.prevCyclic(), i = 0;
  635. entry !== start && i < items.length;
  636. entry = entry.prevCyclic()
  637. ) {
  638. this._rewriteMenuItem(items[i++], entry);
  639. }
  640. }
  641. _rewriteMenuItem(item, entry) {
  642. if (item.entry.id === this.currentlySelectedEntry?.id) {
  643. item.setOrnament(PopupMenu.Ornament.NONE);
  644. }
  645. item.entry = entry;
  646. entry.menuItem = item;
  647. this._setEntryLabel(item);
  648. if (entry.id === this.currentlySelectedEntry?.id) {
  649. item.setOrnament(PopupMenu.Ornament.DOT);
  650. }
  651. }
  652. _onSearchTextChanged() {
  653. const query = this.searchEntry.get_text();
  654. if (!query) {
  655. this.historySection.removeAll();
  656. this.favoritesSection.removeAll();
  657. this.searchEntryFront = this.searchEntryBack = undefined;
  658. this._restoreFavoritedEntries();
  659. this._maybeRestoreMenuPages();
  660. return;
  661. }
  662. this.searchEntryFront = this.searchEntryBack = this.entries.last();
  663. this.populateSearchResults(query);
  664. }
  665. populateSearchResults(query, forward) {
  666. if (!this.searchEntryFront) {
  667. return;
  668. }
  669. this.historySection.removeAll();
  670. this.favoritesSection.removeAll();
  671. if (typeof forward !== 'boolean') {
  672. forward = true;
  673. }
  674. query = query.toLowerCase();
  675. let searchExp;
  676. try {
  677. searchExp = new RegExp(query, 'i');
  678. } catch {}
  679. const start = forward ? this.searchEntryFront : this.searchEntryBack;
  680. let entry = start;
  681. while (this.activeHistoryMenuItems < PAGE_SIZE) {
  682. if (entry.type === DS.TYPE_TEXT) {
  683. let match = entry.text.toLowerCase().indexOf(query);
  684. if (searchExp && match < 0) {
  685. match = entry.text.search(searchExp);
  686. }
  687. if (match >= 0) {
  688. this._addEntry(
  689. entry,
  690. entry === this.currentlySelectedEntry,
  691. false,
  692. forward ? undefined : 0,
  693. );
  694. entry.menuItem.label.set_text(
  695. this._truncated(
  696. entry.text,
  697. match - 40,
  698. match + MAX_VISIBLE_CHARS - 40,
  699. ),
  700. );
  701. }
  702. } else {
  703. throw new TypeError('Unknown type: ' + entry.type);
  704. }
  705. entry = forward ? entry.prevCyclic() : entry.nextCyclic();
  706. if (entry === start) {
  707. break;
  708. }
  709. }
  710. if (forward) {
  711. this.searchEntryBack = this.searchEntryFront.nextCyclic();
  712. this.searchEntryFront = entry;
  713. } else {
  714. this.searchEntryFront = this.searchEntryBack.prevCyclic();
  715. this.searchEntryBack = entry;
  716. }
  717. }
  718. _shouldAbortClipboardQuery(kind) {
  719. if (PRIVATE_MODE) {
  720. return true;
  721. }
  722. if (
  723. IGNORE_PASSWORD_MIMES &&
  724. Clipboard.get_mimetypes(kind).includes(
  725. // Note that we should check for the value "secret" but there don't appear to be any other
  726. // values so it's not worth the trouble right now.
  727. 'x-kde-passwordManagerHint',
  728. )
  729. ) {
  730. console.log(this.uuid, 'Ignoring password entry.');
  731. return true;
  732. }
  733. return false;
  734. }
  735. _queryClipboard() {
  736. if (this._shouldAbortClipboardQuery(St.Clipboard.CLIPBOARD)) {
  737. return;
  738. }
  739. Clipboard.get_text(St.ClipboardType.CLIPBOARD, (_, text) => {
  740. this._processClipboardContent(text, true);
  741. });
  742. }
  743. _queryPrimaryClipboard() {
  744. if (this._shouldAbortClipboardQuery(St.Clipboard.PRIMARY)) {
  745. return;
  746. }
  747. Clipboard.get_text(St.ClipboardType.PRIMARY, (_, text) => {
  748. const last = this.entries.last();
  749. text = this._processClipboardContent(text, false);
  750. if (
  751. last &&
  752. text &&
  753. text.length !== last.text.length &&
  754. (text.endsWith(last.text) ||
  755. text.startsWith(last.text) ||
  756. last.text.endsWith(text) ||
  757. last.text.startsWith(text))
  758. ) {
  759. this._removeEntry(last, true);
  760. }
  761. });
  762. }
  763. _processClipboardContent(text, selectEntry) {
  764. if (this._debouncing > 0) {
  765. this._debouncing--;
  766. return;
  767. }
  768. if (STRIP_TEXT && text) {
  769. text = text.trim();
  770. }
  771. if (!text) {
  772. return;
  773. }
  774. let entry =
  775. this.entries.findTextItem(text) ||
  776. this.favoriteEntries.findTextItem(text);
  777. if (entry) {
  778. const isFirst =
  779. entry === this.entries.last() || entry === this.favoriteEntries.last();
  780. if (!isFirst) {
  781. this._moveEntryFirst(entry);
  782. }
  783. if (selectEntry && (!isFirst || entry !== this.currentlySelectedEntry)) {
  784. this._selectEntry(entry, false);
  785. }
  786. } else {
  787. entry = new DS.LLNode();
  788. entry.id = this.nextId++;
  789. entry.diskId = CACHE_ONLY_FAVORITES ? undefined : this.nextDiskId++;
  790. entry.type = DS.TYPE_TEXT;
  791. entry.text = text;
  792. entry.favorite = false;
  793. this.entries.append(entry);
  794. this._addEntry(entry, selectEntry, false, 0);
  795. if (!CACHE_ONLY_FAVORITES) {
  796. Store.storeTextEntry(text);
  797. }
  798. this._pruneOldestEntries();
  799. }
  800. if (NOTIFY_ON_COPY) {
  801. this._showNotification(_('Copied to clipboard'), null, (notif) => {
  802. notif.addAction(_('Cancel'), () =>
  803. this._deleteEntryAndRestoreLatest(this.currentlySelectedEntry),
  804. );
  805. });
  806. }
  807. return text;
  808. }
  809. _moveEntryFirst(entry) {
  810. if (!MOVE_ITEM_FIRST) {
  811. return;
  812. }
  813. let menu;
  814. let entries;
  815. if (entry.favorite) {
  816. menu = this.favoritesSection;
  817. entries = this.favoriteEntries;
  818. } else {
  819. menu = this.historySection;
  820. entries = this.entries;
  821. }
  822. if (entry.menuItem) {
  823. menu.moveMenuItem(entry.menuItem, 0);
  824. } else {
  825. this._addEntry(entry, false, false, 0);
  826. }
  827. entries.append(entry);
  828. if (entry.diskId) {
  829. Store.moveEntryToEnd(entry.diskId);
  830. }
  831. }
  832. _currentStateBuilder() {
  833. const state = [];
  834. this.nextDiskId = 1;
  835. for (const entry of this.favoriteEntries) {
  836. entry.diskId = this.nextDiskId++;
  837. state.push(entry);
  838. }
  839. for (const entry of this.entries) {
  840. if (CACHE_ONLY_FAVORITES) {
  841. delete entry.diskId;
  842. } else {
  843. entry.diskId = this.nextDiskId++;
  844. state.push(entry);
  845. }
  846. }
  847. return state;
  848. }
  849. _setupSelectionChangeListener() {
  850. this._debouncing = 0;
  851. this.selection = Shell.Global.get().get_display().get_selection();
  852. this._selectionOwnerChangedId = this.selection.connect(
  853. 'owner-changed',
  854. (_, selectionType) => {
  855. if (selectionType === Meta.SelectionType.SELECTION_CLIPBOARD) {
  856. this._queryClipboard();
  857. } else if (
  858. PROCESS_PRIMARY_SELECTION &&
  859. selectionType === Meta.SelectionType.SELECTION_PRIMARY
  860. ) {
  861. this._queryPrimaryClipboard();
  862. }
  863. },
  864. );
  865. }
  866. _disconnectSelectionListener() {
  867. if (!this._selectionOwnerChangedId) {
  868. return;
  869. }
  870. this.selection.disconnect(this._selectionOwnerChangedId);
  871. this.selection = undefined;
  872. this._selectionOwnerChangedId = undefined;
  873. }
  874. _deleteEntryAndRestoreLatest(entry) {
  875. this._removeEntry(entry, true, true);
  876. if (!this.currentlySelectedEntry) {
  877. const nextEntry = this.entries.last();
  878. if (nextEntry) {
  879. this._selectEntry(nextEntry, true);
  880. }
  881. }
  882. }
  883. _initNotifSource() {
  884. if (this._notifSource) {
  885. return;
  886. }
  887. this._notifSource = new MessageTray.Source({
  888. title: this.extension.indicatorName,
  889. iconName: INDICATOR_ICON,
  890. });
  891. this._notifSource.connect('destroy', () => {
  892. this._notifSource = undefined;
  893. });
  894. Main.messageTray.add(this._notifSource);
  895. }
  896. _showNotification(title, message, transformFn) {
  897. const dndOn = () =>
  898. !Main.panel.statusArea.dateMenu._indicator._settings.get_boolean(
  899. 'show-banners',
  900. );
  901. if (PRIVATE_MODE || dndOn()) {
  902. return;
  903. }
  904. this._initNotifSource();
  905. let notification;
  906. if (this._notifSource.count === 0) {
  907. notification = new MessageTray.Notification({
  908. source: this._notifSource,
  909. title,
  910. body: message,
  911. isTransient: true,
  912. });
  913. } else {
  914. notification = this._notifSource.notifications[0];
  915. notification.set({
  916. title,
  917. body: message,
  918. });
  919. notification.clearActions();
  920. }
  921. if (typeof transformFn === 'function') {
  922. transformFn(notification);
  923. }
  924. this._notifSource.addNotification(notification);
  925. }
  926. _updatePrivateModeState() {
  927. // We hide the history in private mode because it will be out of sync
  928. // (selected item will not reflect clipboard)
  929. this.scrollViewMenuSection.actor.visible = !PRIVATE_MODE;
  930. this.scrollViewFavoritesMenuSection.actor.visible = !PRIVATE_MODE;
  931. if (PRIVATE_MODE) {
  932. this.icon.add_style_class_name('private-mode');
  933. this._updateButtonText();
  934. } else {
  935. this.icon.remove_style_class_name('private-mode');
  936. if (this.currentlySelectedEntry) {
  937. this._selectEntry(this.currentlySelectedEntry, true);
  938. } else {
  939. this._resetSelectedMenuItem(true);
  940. }
  941. }
  942. }
  943. _fetchSettings() {
  944. MAX_REGISTRY_LENGTH = this.settings.get_int(SettingsFields.HISTORY_SIZE);
  945. MAX_BYTES =
  946. (1 << 20) * this.settings.get_int(SettingsFields.CACHE_FILE_SIZE);
  947. WINDOW_WIDTH_PERCENTAGE = this.settings.get_int(
  948. SettingsFields.WINDOW_WIDTH_PERCENTAGE,
  949. );
  950. CACHE_ONLY_FAVORITES = this.settings.get_boolean(
  951. SettingsFields.CACHE_ONLY_FAVORITES,
  952. );
  953. MOVE_ITEM_FIRST = this.settings.get_boolean(SettingsFields.MOVE_ITEM_FIRST);
  954. NOTIFY_ON_COPY = this.settings.get_boolean(SettingsFields.NOTIFY_ON_COPY);
  955. CONFIRM_ON_CLEAR = this.settings.get_boolean(
  956. SettingsFields.CONFIRM_ON_CLEAR,
  957. );
  958. ENABLE_KEYBINDING = this.settings.get_boolean(
  959. SettingsFields.ENABLE_KEYBINDING,
  960. );
  961. MAX_TOPBAR_LENGTH = this.settings.get_int(
  962. SettingsFields.TOPBAR_PREVIEW_SIZE,
  963. );
  964. TOPBAR_DISPLAY_MODE = this.settings.get_int(
  965. SettingsFields.TOPBAR_DISPLAY_MODE_ID,
  966. );
  967. DISABLE_DOWN_ARROW = this.settings.get_boolean(
  968. SettingsFields.DISABLE_DOWN_ARROW,
  969. );
  970. STRIP_TEXT = this.settings.get_boolean(SettingsFields.STRIP_TEXT);
  971. PRIVATE_MODE = this.settings.get_boolean(SettingsFields.PRIVATE_MODE);
  972. PASTE_ON_SELECTION = this.settings.get_boolean(
  973. SettingsFields.PASTE_ON_SELECTION,
  974. );
  975. PROCESS_PRIMARY_SELECTION = this.settings.get_boolean(
  976. SettingsFields.PROCESS_PRIMARY_SELECTION,
  977. );
  978. IGNORE_PASSWORD_MIMES = this.settings.get_boolean(
  979. SettingsFields.IGNORE_PASSWORD_MIMES,
  980. );
  981. }
  982. _onSettingsChange() {
  983. const prevCacheOnlyFavorites = CACHE_ONLY_FAVORITES;
  984. const prevPrivateMode = PRIVATE_MODE;
  985. this._fetchSettings();
  986. if (
  987. prevCacheOnlyFavorites !== undefined &&
  988. CACHE_ONLY_FAVORITES !== prevCacheOnlyFavorites
  989. ) {
  990. if (CACHE_ONLY_FAVORITES) {
  991. Store.resetDatabase(this._currentStateBuilder.bind(this));
  992. } else {
  993. for (const entry of this.entries) {
  994. entry.diskId = this.nextDiskId++;
  995. Store.storeTextEntry(entry.text);
  996. }
  997. }
  998. }
  999. if (prevPrivateMode !== undefined && PRIVATE_MODE !== prevPrivateMode) {
  1000. this._updatePrivateModeState();
  1001. }
  1002. // Remove old entries in case the registry size changed
  1003. this._pruneOldestEntries();
  1004. // Re-set menu-items labels in case preview size changed
  1005. const resetLabel = (item) => this._setEntryLabel(item);
  1006. this.favoritesSection._getMenuItems().forEach(resetLabel);
  1007. this.historySection._getMenuItems().forEach(resetLabel);
  1008. this._updateTopbarLayout();
  1009. if (this.currentlySelectedEntry) {
  1010. this._updateButtonText(this.currentlySelectedEntry);
  1011. }
  1012. this._setMenuWidth();
  1013. if (ENABLE_KEYBINDING) {
  1014. this._bindShortcuts();
  1015. } else {
  1016. this._unbindShortcuts();
  1017. }
  1018. }
  1019. _bindShortcuts() {
  1020. this._unbindShortcuts();
  1021. this._bindShortcut(SETTING_KEY_CLEAR_HISTORY, () => {
  1022. if (this.entries) {
  1023. this._removeAll();
  1024. }
  1025. });
  1026. this._bindShortcut(SETTING_KEY_PREV_ENTRY, () => {
  1027. if (this.entries) {
  1028. this._previousEntry();
  1029. }
  1030. });
  1031. this._bindShortcut(SETTING_KEY_NEXT_ENTRY, () => {
  1032. if (this.entries) {
  1033. this._nextEntry();
  1034. }
  1035. });
  1036. this._bindShortcut(SETTING_KEY_TOGGLE_MENU, () => this.menu.toggle());
  1037. this._bindShortcut(SETTING_KEY_PRIVATE_MODE, () =>
  1038. this.privateModeMenuItem.toggle(),
  1039. );
  1040. }
  1041. _unbindShortcuts() {
  1042. this._shortcutsBindingIds.forEach((id) => Main.wm.removeKeybinding(id));
  1043. this._shortcutsBindingIds = [];
  1044. }
  1045. _bindShortcut(name, cb) {
  1046. const ModeType = Shell.hasOwnProperty('ActionMode')
  1047. ? Shell.ActionMode
  1048. : Shell.KeyBindingMode;
  1049. Main.wm.addKeybinding(
  1050. name,
  1051. this.settings,
  1052. Meta.KeyBindingFlags.NONE,
  1053. ModeType.ALL,
  1054. cb.bind(this),
  1055. );
  1056. this._shortcutsBindingIds.push(name);
  1057. }
  1058. _updateTopbarLayout() {
  1059. if (TOPBAR_DISPLAY_MODE === 3) {
  1060. this.icon.visible = false;
  1061. this._buttonText.visible = false;
  1062. this._style_class = this.style_class;
  1063. this.style_class = '';
  1064. } else if (this._style_class) {
  1065. this.style_class = this._style_class;
  1066. }
  1067. if (TOPBAR_DISPLAY_MODE === 0) {
  1068. this.icon.visible = true;
  1069. this._buttonText.visible = false;
  1070. }
  1071. if (TOPBAR_DISPLAY_MODE === 1) {
  1072. this.icon.visible = false;
  1073. this._buttonText.visible = true;
  1074. }
  1075. if (TOPBAR_DISPLAY_MODE === 2) {
  1076. this.icon.visible = true;
  1077. this._buttonText.visible = true;
  1078. }
  1079. this._downArrow.visible = !DISABLE_DOWN_ARROW;
  1080. }
  1081. _disconnectSettings() {
  1082. if (!this._settingsChangedId) {
  1083. return;
  1084. }
  1085. this.settings.disconnect(this._settingsChangedId);
  1086. this._settingsChangedId = undefined;
  1087. }
  1088. _openSettings() {
  1089. this.extension.openPreferences();
  1090. this.menu.close();
  1091. }
  1092. _previousEntry() {
  1093. this._selectNextPrevEntry(
  1094. this.currentlySelectedEntry.nextCyclic() || this.entries.head,
  1095. );
  1096. }
  1097. _nextEntry() {
  1098. this._selectNextPrevEntry(
  1099. this.currentlySelectedEntry.prevCyclic() || this.entries.last(),
  1100. );
  1101. }
  1102. _selectNextPrevEntry(entry) {
  1103. if (!entry) {
  1104. return;
  1105. }
  1106. this._selectEntry(entry, true);
  1107. if (entry.type === DS.TYPE_TEXT) {
  1108. this._showNotification(_('Copied'), entry.text);
  1109. }
  1110. }
  1111. _truncated(s, start, end) {
  1112. if (start < 0) {
  1113. start = 0;
  1114. }
  1115. if (!end) {
  1116. end = start;
  1117. start = 0;
  1118. }
  1119. if (end > s.length) {
  1120. end = s.length;
  1121. }
  1122. const includesStart = start === 0;
  1123. const includesEnd = end === s.length;
  1124. const isMiddle = !includesStart && !includesEnd;
  1125. const length = end - start;
  1126. const overflow = s.length > length;
  1127. // Reduce regex search space. If the string is mostly whitespace,
  1128. // we might end up removing too many characters, but oh well.
  1129. s = s.substring(start, end + 100);
  1130. // Remove new lines and extra spaces so the text fits nicely on one line
  1131. s = s.replace(/\s+/g, ' ').trim();
  1132. if (includesStart && overflow) {
  1133. s = s.substring(0, length - 1) + '…';
  1134. }
  1135. if (includesEnd && overflow) {
  1136. s = '…' + s.substring(1, length);
  1137. }
  1138. if (isMiddle) {
  1139. s = '…' + s.substring(1, length - 1) + '…';
  1140. }
  1141. return s;
  1142. }
  1143. }
  1144. const ClipboardIndicatorObj = GObject.registerClass(ClipboardIndicator);
  1145. export default class ClipboardHistoryExtension extends Extension {
  1146. enable() {
  1147. this.indicatorName = `${this.metadata.name} Indicator`;
  1148. Store.init(this.uuid);
  1149. this.clipboardIndicator = new ClipboardIndicatorObj(this);
  1150. Main.panel.addToStatusArea(this.indicatorName, this.clipboardIndicator, 1);
  1151. }
  1152. disable() {
  1153. this.clipboardIndicator.destroy();
  1154. this.clipboardIndicator = undefined;
  1155. Store.destroy();
  1156. }
  1157. }