Skip to content

Commit acad1be

Browse files
joaogktjoaoktorresleandromqrs
authored
Feat(clipboard): Adds a new Clipboard History module (Super+Shift+V) … (#40)
* Feat(clipboard): Adds a new Clipboard History module (Super+Shift+V) with searchable history panel, keyboard navigation, drag-to-reposition, and JSON persistence across sessions. * chore: remove outdated DEVELOPMENT.md file --------- Co-authored-by: joaogkt <jgabriel.ktorres@gmail.com> Co-authored-by: Leandro Rodrigues <leandromqrs@hotmail.com>
1 parent 1da6a47 commit acad1be

14 files changed

Lines changed: 1173 additions & 0 deletions

data/schemas/org.gnome.shell.extensions.aurora-shell.gschema.xml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,5 +133,27 @@
133133
<summary>Recolor symbolic SNI pixmaps</summary>
134134
<description>Automatically recolor monochrome SNI tray pixmaps to match the current panel theme</description>
135135
</key>
136+
<key name="module-clipboard-history" type="b">
137+
<default>true</default>
138+
<summary>Enable Clipboard History module</summary>
139+
<description>Searchable clipboard history with pinning and keyboard navigation</description>
140+
</key>
141+
<key name="clipboard-history-shortcut" type="as">
142+
<default>['&lt;Super&gt;&lt;Shift&gt;v']</default>
143+
<summary>Clipboard History shortcut</summary>
144+
<description>Keyboard shortcut to open the clipboard history panel</description>
145+
</key>
146+
<key name="clipboard-history-max-items" type="i">
147+
<range min="10" max="200"/>
148+
<default>50</default>
149+
<summary>Maximum clipboard history items</summary>
150+
<description>Number of non-pinned entries to retain (oldest are dropped when the limit is reached)</description>
151+
</key>
152+
<key name="clipboard-history-poll-interval" type="i">
153+
<range min="250" max="5000"/>
154+
<default>1000</default>
155+
<summary>Clipboard poll interval (ms)</summary>
156+
<description>How often to check the clipboard for new content in milliseconds</description>
157+
</key>
136158
</schema>
137159
</schemalist>
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import '@girs/gjs';
2+
import { gettext as _ } from 'gettext';
3+
4+
import GLib from '@girs/glib-2.0';
5+
import St from '@girs/st-18';
6+
import Meta from '@girs/meta-18';
7+
import Shell from '@girs/shell-18';
8+
import * as Main from '@girs/gnome-shell/ui/main';
9+
10+
import type { ExtensionContext } from '~/core/context.ts';
11+
import { logger } from '~/core/logger.ts';
12+
import { Module } from '~/module.ts';
13+
import type { ModuleDefinition } from '~/module.ts';
14+
15+
import type { ClipboardEntry } from '~/modules/clipboardHistory/clipboardStore.ts';
16+
import { ClipboardStore } from '~/modules/clipboardHistory/clipboardStore.ts';
17+
import { ClipboardMonitor } from '~/modules/clipboardHistory/clipboardMonitor.ts';
18+
import { ClipboardPanel } from '~/modules/clipboardHistory/clipboardPanel.ts';
19+
20+
const KEYBINDING_KEY = 'clipboard-history-shortcut';
21+
const LOG_PREFIX = 'ClipboardHistory';
22+
23+
export class ClipboardHistory extends Module {
24+
private _store: ClipboardStore | null = null;
25+
private _monitor: ClipboardMonitor | null = null;
26+
private _panel: ClipboardPanel | null = null;
27+
private _settingsIds: number[] = [];
28+
private _startupIdleId: number = 0;
29+
30+
constructor(context: ExtensionContext) {
31+
super(context);
32+
}
33+
34+
override enable(): void {
35+
const configDir = GLib.get_user_config_dir() + '/aurora-shell';
36+
const filePath = configDir + '/clipboard-history.json';
37+
const rawSettings = this.context.settings.getRawSettings();
38+
const maxItems = rawSettings.get_int('clipboard-history-max-items');
39+
const pollMs = rawSettings.get_int('clipboard-history-poll-interval');
40+
41+
this._store = new ClipboardStore(filePath, maxItems);
42+
this._store.load();
43+
44+
this._panel = new (ClipboardPanel as unknown as new (
45+
store: ClipboardStore,
46+
callbacks: {
47+
onActivate: (entry: ClipboardEntry) => void;
48+
onRemove: (id: string) => void;
49+
onTogglePin: (id: string) => void;
50+
},
51+
) => ClipboardPanel)(this._store, {
52+
onActivate: (entry) => this._onActivate(entry),
53+
onRemove: (id) => this._onRemove(id),
54+
onTogglePin: (id) => this._onTogglePin(id),
55+
});
56+
57+
this._monitor = new ClipboardMonitor(pollMs, (text) => {
58+
this._store?.addText(text);
59+
});
60+
61+
this._startupIdleId = GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
62+
this._startupIdleId = 0;
63+
this._monitor?.start();
64+
return GLib.SOURCE_REMOVE;
65+
});
66+
67+
try {
68+
Main.wm.addKeybinding(
69+
KEYBINDING_KEY,
70+
rawSettings,
71+
Meta.KeyBindingFlags.IGNORE_AUTOREPEAT,
72+
Shell.ActionMode.ALL,
73+
() => this._togglePanel(),
74+
);
75+
} catch (e) {
76+
logger.error('Failed to register keybinding:', { prefix: LOG_PREFIX }, e as Error);
77+
}
78+
79+
this._settingsIds = [
80+
rawSettings.connect('changed::clipboard-history-max-items', () => {
81+
this._store?.setMaxItems(rawSettings.get_int('clipboard-history-max-items'));
82+
}),
83+
rawSettings.connect('changed::clipboard-history-poll-interval', () => {
84+
this._monitor?.setInterval(rawSettings.get_int('clipboard-history-poll-interval'));
85+
}),
86+
];
87+
}
88+
89+
override disable(): void {
90+
if (this._startupIdleId !== 0) {
91+
GLib.source_remove(this._startupIdleId);
92+
this._startupIdleId = 0;
93+
}
94+
95+
try {
96+
Main.wm.removeKeybinding(KEYBINDING_KEY);
97+
} catch (_e) {
98+
// ignore if not registered
99+
}
100+
101+
this._panel?.close();
102+
this._panel?.destroy();
103+
this._panel = null;
104+
105+
this._monitor?.stop();
106+
this._monitor = null;
107+
108+
this._store?.save();
109+
this._store = null;
110+
111+
const rawSettings = this.context.settings.getRawSettings();
112+
for (const id of this._settingsIds) {
113+
rawSettings.disconnect(id);
114+
}
115+
this._settingsIds = [];
116+
}
117+
118+
private _togglePanel(): void {
119+
if (this._panel?.isOpen) {
120+
this._panel.close();
121+
} else {
122+
this._panel?.open();
123+
}
124+
}
125+
126+
private _onActivate(entry: ClipboardEntry): void {
127+
St.Clipboard.get_default().set_text(St.ClipboardType.CLIPBOARD, entry.text);
128+
this._panel?.close();
129+
logger.debug(`Restored clipboard entry: ${entry.text.slice(0, 40)}`, { prefix: LOG_PREFIX });
130+
}
131+
132+
private _onRemove(id: string): void {
133+
this._store?.remove(id);
134+
this._panel?.refresh();
135+
}
136+
137+
private _onTogglePin(id: string): void {
138+
if (!this._store) return;
139+
const pinned = this._store.getPinned();
140+
if (pinned.find((e) => e.id === id)) {
141+
this._store.unpin(id);
142+
} else {
143+
this._store.pin(id);
144+
}
145+
this._panel?.refresh();
146+
}
147+
}
148+
149+
export const definition: ModuleDefinition = {
150+
key: 'clipboard-history',
151+
settingsKey: 'module-clipboard-history',
152+
title: _('Clipboard History'),
153+
subtitle: _('Searchable clipboard history with pinning and keyboard navigation'),
154+
options: [
155+
{
156+
key: 'clipboard-history-max-items',
157+
title: _('Max History Items'),
158+
subtitle: _('Number of non-pinned entries to retain'),
159+
type: 'spin',
160+
min: 10,
161+
max: 200,
162+
},
163+
{
164+
key: 'clipboard-history-poll-interval',
165+
title: _('Poll Interval (ms)'),
166+
subtitle: _('How often to check the clipboard for changes'),
167+
type: 'spin',
168+
min: 250,
169+
max: 5000,
170+
},
171+
],
172+
factory: (ctx) => new ClipboardHistory(ctx),
173+
};
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import '@girs/gjs';
2+
3+
import St from '@girs/st-18';
4+
import GObject from '@girs/gobject-2.0';
5+
import Clutter from '@girs/clutter-18';
6+
7+
import type { ClipboardEntry } from '~/modules/clipboardHistory/clipboardStore.ts';
8+
9+
const MAX_LABEL_CHARS = 80;
10+
11+
@GObject.registerClass
12+
export class ClipboardItem extends St.Button {
13+
declare private _entry: ClipboardEntry;
14+
declare private _label: St.Label;
15+
declare private _pinIcon: St.Icon;
16+
17+
override _init(entry: ClipboardEntry): void {
18+
super._init({
19+
style_class: 'aurora-clipboard-item',
20+
can_focus: true,
21+
x_expand: true,
22+
reactive: true,
23+
x_align: Clutter.ActorAlign.FILL,
24+
});
25+
26+
this._entry = entry;
27+
28+
const box = new St.BoxLayout({
29+
orientation: Clutter.Orientation.HORIZONTAL,
30+
x_expand: true,
31+
});
32+
33+
this._label = new St.Label({
34+
text: _truncate(entry.text, MAX_LABEL_CHARS),
35+
style_class: 'aurora-clipboard-item-label',
36+
x_expand: true,
37+
y_align: Clutter.ActorAlign.CENTER,
38+
});
39+
this._label.clutter_text.ellipsize = 3; // Pango.EllipsizeMode.END
40+
41+
this._pinIcon = new St.Icon({
42+
icon_name: 'view-pin-symbolic',
43+
icon_size: 14,
44+
style_class: 'aurora-clipboard-pin-icon',
45+
visible: entry.pinned,
46+
y_align: Clutter.ActorAlign.CENTER,
47+
});
48+
49+
box.add_child(this._label);
50+
box.add_child(this._pinIcon);
51+
this.set_child(box);
52+
}
53+
54+
get entry(): ClipboardEntry {
55+
return this._entry;
56+
}
57+
}
58+
59+
function _truncate(text: string, maxChars: number): string {
60+
const single = text.replace(/\s+/g, ' ').trim();
61+
return single.length > maxChars ? single.slice(0, maxChars) + '…' : single;
62+
}

0 commit comments

Comments
 (0)