aboutsummaryrefslogtreecommitdiff
path: root/.local/share
diff options
context:
space:
mode:
Diffstat (limited to '.local/share')
-rw-r--r--.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/extension.js548
-rw-r--r--.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/metadata.json11
-rw-r--r--.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/prefs.js33
-rw-r--r--.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/schemas/org.gnome.shell.extensions.org-agenda-indicator.gschema.xml14
-rw-r--r--.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/stylesheet.css38
-rw-r--r--.local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/extension.js375
-rw-r--r--.local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/metadata.json10
-rw-r--r--.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/extension.js298
-rw-r--r--.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/metadata.json10
9 files changed, 1337 insertions, 0 deletions
diff --git a/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/extension.js b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/extension.js
new file mode 100644
index 0000000..b92048c
--- /dev/null
+++ b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/extension.js
@@ -0,0 +1,548 @@
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import GObject from 'gi://GObject';
+import St from 'gi://St';
+import Clutter from 'gi://Clutter';
+import Pango from 'gi://Pango';
+
+import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
+import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
+
+const SNAPSHOT_RELATIVE_PATH = '.cache/org-agenda-shell/today.json';
+const REFRESH_INTERVAL_SECONDS = 60;
+const RELOAD_DELAY_MS = 200;
+const PANEL_TITLE_MAX = 20;
+
+const InfoMenuItem = GObject.registerClass(
+class InfoMenuItem extends PopupMenu.PopupBaseMenuItem {
+ _init(primaryText, secondaryText = '', {overdue = false} = {}) {
+ super._init({reactive: false, can_focus: false});
+
+ const box = new St.BoxLayout({
+ vertical: true,
+ x_expand: true,
+ style_class: 'org-agenda-indicator-row',
+ });
+
+ this._primaryLabel = new St.Label({
+ text: primaryText,
+ x_expand: true,
+ style_class: 'org-agenda-indicator-row-title',
+ });
+ this._primaryLabel.clutter_text.ellipsize = Pango.EllipsizeMode.END;
+
+ if (overdue)
+ this._primaryLabel.add_style_class_name('org-agenda-indicator-overdue');
+
+ box.add_child(this._primaryLabel);
+
+ if (secondaryText) {
+ this._secondaryLabel = new St.Label({
+ text: secondaryText,
+ x_expand: true,
+ style_class: 'org-agenda-indicator-row-meta',
+ });
+ this._secondaryLabel.clutter_text.ellipsize = Pango.EllipsizeMode.END;
+ box.add_child(this._secondaryLabel);
+ }
+
+ this.add_child(box);
+ }
+});
+
+const SectionHeaderItem = GObject.registerClass(
+class SectionHeaderItem extends PopupMenu.PopupBaseMenuItem {
+ _init(text) {
+ super._init({reactive: false, can_focus: false});
+
+ const label = new St.Label({
+ text,
+ x_expand: true,
+ style_class: 'org-agenda-indicator-section-label',
+ });
+ const box = new St.BoxLayout({
+ x_expand: true,
+ style_class: 'org-agenda-indicator-section',
+ });
+
+ box.add_child(label);
+ this.add_child(box);
+ }
+});
+
+const Indicator = GObject.registerClass(
+class Indicator extends PanelMenu.Button {
+ _init(settings) {
+ super._init(0.0, 'Org Agenda Indicator');
+
+ this._settings = settings;
+ this._snapshotPath = GLib.build_filenamev([
+ GLib.get_home_dir(),
+ SNAPSHOT_RELATIVE_PATH,
+ ]);
+ this._snapshotDir = GLib.path_get_dirname(this._snapshotPath);
+ this._snapshotParentDir = GLib.path_get_dirname(this._snapshotDir);
+ this._snapshotDirBaseName = GLib.path_get_basename(this._snapshotDir);
+ this._snapshotBaseName = GLib.path_get_basename(this._snapshotPath);
+ this._directoryMonitor = null;
+ this._monitoredDirectory = null;
+ this._refreshSourceId = 0;
+ this._reloadSourceId = 0;
+ this._state = {
+ status: 'loading',
+ taskCount: 0,
+ todayCount: 0,
+ overdueCount: 0,
+ date: null,
+ generatedAt: null,
+ generatedEpoch: null,
+ tasks: [],
+ clockedIn: null,
+ error: null,
+ };
+
+ this._label = new St.Label({
+ text: '...',
+ y_align: Clutter.ActorAlign.CENTER,
+ name: 'org-agenda-indicator-label',
+ });
+ this._label.clutter_text.ellipsize = Pango.EllipsizeMode.END;
+ this.add_child(this._label);
+
+ this._showClockedInChangedId = this._settings.connect(
+ 'changed::show-clocked-in-task',
+ () => {
+ this._syncLabel();
+ this._rebuildMenu();
+ });
+
+ this._refreshSourceId = GLib.timeout_add_seconds(
+ GLib.PRIORITY_DEFAULT,
+ REFRESH_INTERVAL_SECONDS,
+ () => {
+ this._loadSnapshot();
+ return GLib.SOURCE_CONTINUE;
+ });
+
+ this._startMonitoring();
+ this._loadSnapshot();
+ }
+
+ destroy() {
+ if (this._reloadSourceId) {
+ GLib.source_remove(this._reloadSourceId);
+ this._reloadSourceId = 0;
+ }
+
+ if (this._refreshSourceId) {
+ GLib.source_remove(this._refreshSourceId);
+ this._refreshSourceId = 0;
+ }
+
+ this._directoryMonitor?.cancel();
+ this._directoryMonitor = null;
+ if (this._showClockedInChangedId) {
+ this._settings.disconnect(this._showClockedInChangedId);
+ this._showClockedInChangedId = 0;
+ }
+
+ super.destroy();
+ }
+
+ _startMonitoring() {
+ this._directoryMonitor?.cancel();
+ this._directoryMonitor = null;
+
+ const targetDirectory = GLib.file_test(this._snapshotDir, GLib.FileTest.IS_DIR)
+ ? this._snapshotDir
+ : this._snapshotParentDir;
+ const directory = Gio.File.new_for_path(targetDirectory);
+
+ try {
+ this._directoryMonitor = directory.monitor_directory(
+ Gio.FileMonitorFlags.WATCH_MOVES,
+ null);
+ this._monitoredDirectory = targetDirectory;
+ this._directoryMonitor.connect('changed', (_monitor, file, otherFile, eventType) => {
+ if (!this._eventTouchesSnapshot(file, otherFile, eventType))
+ return;
+
+ this._scheduleReload();
+ });
+ } catch (error) {
+ this._monitoredDirectory = null;
+ this._setState({
+ status: 'error',
+ taskCount: 0,
+ todayCount: 0,
+ overdueCount: 0,
+ date: null,
+ generatedAt: null,
+ generatedEpoch: null,
+ tasks: [],
+ clockedIn: null,
+ error: `Unable to monitor ${this._snapshotDir}: ${error.message}`,
+ });
+ }
+ }
+
+ _eventTouchesSnapshot(file, otherFile, eventType) {
+ const relevantEvents = new Set([
+ Gio.FileMonitorEvent.CHANGED,
+ Gio.FileMonitorEvent.CHANGES_DONE_HINT,
+ Gio.FileMonitorEvent.CREATED,
+ Gio.FileMonitorEvent.MOVED_IN,
+ Gio.FileMonitorEvent.MOVED_OUT,
+ Gio.FileMonitorEvent.DELETED,
+ Gio.FileMonitorEvent.ATTRIBUTE_CHANGED,
+ ]);
+
+ if (!relevantEvents.has(eventType))
+ return false;
+
+ const names = [
+ file?.get_basename(),
+ otherFile?.get_basename(),
+ ];
+
+ if (this._monitoredDirectory === this._snapshotParentDir)
+ return names.includes(this._snapshotDirBaseName);
+
+ return names.includes(this._snapshotBaseName);
+ }
+
+ _scheduleReload() {
+ if (this._reloadSourceId)
+ return;
+
+ this._reloadSourceId = GLib.timeout_add(
+ GLib.PRIORITY_DEFAULT,
+ RELOAD_DELAY_MS,
+ () => {
+ this._reloadSourceId = 0;
+ this._loadSnapshot();
+ return GLib.SOURCE_REMOVE;
+ });
+ }
+
+ _loadSnapshot() {
+ const snapshotFile = Gio.File.new_for_path(this._snapshotPath);
+
+ if ((this._monitoredDirectory === this._snapshotParentDir &&
+ GLib.file_test(this._snapshotDir, GLib.FileTest.IS_DIR)) ||
+ (this._monitoredDirectory === this._snapshotDir &&
+ !GLib.file_test(this._snapshotDir, GLib.FileTest.IS_DIR)))
+ this._startMonitoring();
+
+ try {
+ if (!snapshotFile.query_exists(null)) {
+ this._setState({
+ status: 'missing',
+ taskCount: 0,
+ todayCount: 0,
+ overdueCount: 0,
+ date: null,
+ generatedAt: null,
+ generatedEpoch: null,
+ tasks: [],
+ clockedIn: null,
+ error: null,
+ });
+ return;
+ }
+
+ const [, contents] = snapshotFile.load_contents(null);
+ const payload = JSON.parse(new TextDecoder().decode(contents));
+ const currentDate = GLib.DateTime.new_now_local().format('%F');
+
+ if (payload.date !== currentDate) {
+ this._setState({
+ status: 'stale',
+ taskCount: 0,
+ todayCount: 0,
+ overdueCount: 0,
+ date: payload.date ?? null,
+ generatedAt: payload.generated_at ?? null,
+ generatedEpoch: payload.generated_epoch ?? null,
+ tasks: [],
+ clockedIn: payload.clocked_in ?? null,
+ error: null,
+ });
+ return;
+ }
+
+ const tasks = Array.isArray(payload.today_tasks) ? payload.today_tasks : [];
+ const overdueCount = Number(payload.overdue_count ?? tasks.filter(task => task.is_overdue).length);
+ const todayCount = Number(payload.today_count ?? tasks.length - overdueCount);
+
+ this._setState({
+ status: 'ready',
+ taskCount: Number(payload.task_count ?? tasks.length),
+ todayCount,
+ overdueCount,
+ date: payload.date ?? null,
+ generatedAt: payload.generated_at ?? null,
+ generatedEpoch: payload.generated_epoch ?? null,
+ tasks,
+ clockedIn: payload.clocked_in ?? null,
+ error: null,
+ });
+ } catch (error) {
+ this._setState({
+ status: 'error',
+ taskCount: 0,
+ todayCount: 0,
+ overdueCount: 0,
+ date: null,
+ generatedAt: null,
+ generatedEpoch: null,
+ tasks: [],
+ clockedIn: null,
+ error: error.message,
+ });
+ }
+ }
+
+ _setState(nextState) {
+ this._state = nextState;
+ this._syncLabel();
+ this._rebuildMenu();
+ }
+
+ _syncLabel() {
+ switch (this._state.status) {
+ case 'ready':
+ this._label.set_text(this._panelText());
+ break;
+ case 'loading':
+ this._label.set_text('...');
+ break;
+ case 'missing':
+ this._label.set_text('-');
+ break;
+ default:
+ this._label.set_text('?');
+ break;
+ }
+ }
+
+ _rebuildMenu() {
+ this.menu.removeAll();
+
+ this.menu.addMenuItem(new InfoMenuItem(this._summaryText(), this._summaryMetaText()));
+
+ if (this._state.error) {
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ this.menu.addMenuItem(new InfoMenuItem('Unable to read agenda snapshot', this._state.error));
+ return;
+ }
+
+ if (this._state.status !== 'ready')
+ return;
+
+ const todayTasks = this._state.tasks.filter(task => !task.is_overdue);
+ const overdueTasks = this._state.tasks.filter(task => task.is_overdue);
+
+ if (this._showClockedInTask() && this._state.clockedIn) {
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ this.menu.addMenuItem(new SectionHeaderItem('Clocked In'));
+ this.menu.addMenuItem(this._clockedInItem(this._state.clockedIn));
+ }
+
+ if (todayTasks.length > 0 || overdueTasks.length > 0) {
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+ }
+
+ if (todayTasks.length > 0) {
+ this.menu.addMenuItem(new SectionHeaderItem('Today'));
+ for (const task of todayTasks)
+ this.menu.addMenuItem(this._taskItem(task));
+ }
+
+ if (overdueTasks.length > 0) {
+ if (todayTasks.length > 0)
+ this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
+
+ this.menu.addMenuItem(new SectionHeaderItem('Overdue'));
+ for (const task of overdueTasks)
+ this.menu.addMenuItem(this._taskItem(task));
+ }
+ }
+
+ _panelText() {
+ const compactCount = this._compactCountText();
+
+ if (this._showClockedInTask() && this._state.clockedIn?.title) {
+ const title = this._truncate(this._state.clockedIn.title, PANEL_TITLE_MAX);
+ return compactCount ? `● ${title} · ${compactCount}` : `● ${title}`;
+ }
+
+ return compactCount;
+ }
+
+ _compactCountText() {
+ const {todayCount, overdueCount} = this._state;
+
+ if (overdueCount > 0 && todayCount > 0)
+ return `${todayCount}+${overdueCount}!`;
+
+ if (overdueCount > 0)
+ return `${overdueCount}!`;
+
+ return String(todayCount);
+ }
+
+ _summaryText() {
+ switch (this._state.status) {
+ case 'ready':
+ if (this._state.taskCount === 0)
+ return this._state.clockedIn ? 'Clock is running' : 'No scheduled tasks';
+
+ return this._countPhrase(this._state.todayCount, 'today') +
+ (this._state.overdueCount > 0 ? `, ${this._countPhrase(this._state.overdueCount, 'overdue')}` : '');
+ case 'missing':
+ return 'Agenda snapshot not found';
+ case 'stale':
+ return `Agenda snapshot is stale (${this._state.date ?? 'unknown date'})`;
+ case 'loading':
+ return 'Loading org agenda snapshot';
+ default:
+ return 'Unable to read agenda snapshot';
+ }
+ }
+
+ _summaryMetaText() {
+ const parts = [];
+
+ if (this._showClockedInTask() && this._state.clockedIn)
+ parts.push(this._clockSummaryText(this._state.clockedIn));
+
+ if (this._state.generatedEpoch)
+ parts.push(`Updated ${this._timeOfDay(this._state.generatedEpoch)}`);
+ else if (this._state.generatedAt)
+ parts.push(`Updated ${this._state.generatedAt}`);
+
+ return parts.join(' ');
+ }
+
+ _showClockedInTask() {
+ return this._settings.get_boolean('show-clocked-in-task');
+ }
+
+ _countPhrase(count, label) {
+ if (count === 1)
+ return `1 ${label}`;
+
+ return `${count} ${label}`;
+ }
+
+ _clockedInItem(task) {
+ return new InfoMenuItem(
+ task.title ?? 'Clock running',
+ this._clockTaskMeta(task));
+ }
+
+ _taskItem(task) {
+ return new InfoMenuItem(
+ this._taskPrimaryText(task),
+ this._taskMetaText(task),
+ {overdue: task.is_overdue});
+ }
+
+ _taskPrimaryText(task) {
+ const pieces = [];
+
+ if (task.time)
+ pieces.push(task.time);
+
+ pieces.push(task.title ?? 'Untitled task');
+
+ return pieces.join(' ');
+ }
+
+ _taskMetaText(task) {
+ const pieces = [];
+
+ if (task.state)
+ pieces.push(task.state);
+
+ if (task.category)
+ pieces.push(task.category);
+
+ if (task.is_overdue && task.scheduled_for)
+ pieces.push(`scheduled ${this._friendlyDate(task.scheduled_for)}`);
+
+ return pieces.join(' · ');
+ }
+
+ _clockTaskMeta(task) {
+ const pieces = [];
+
+ if (task.started_epoch)
+ pieces.push(`started ${this._timeOfDay(task.started_epoch)}`);
+
+ const elapsed = this._elapsedClockText(task.started_epoch);
+ if (elapsed)
+ pieces.push(elapsed);
+
+ if (task.state)
+ pieces.push(task.state);
+
+ if (task.category)
+ pieces.push(task.category);
+
+ return pieces.join(' · ');
+ }
+
+ _clockSummaryText(task) {
+ const elapsed = this._elapsedClockText(task.started_epoch);
+ return elapsed ? `Clock ${elapsed}` : 'Clock running';
+ }
+
+ _elapsedClockText(startedEpoch) {
+ if (!startedEpoch)
+ return '';
+
+ const roundedMinutes = Math.max(0, Math.floor((Date.now() / 1000 - startedEpoch) / 60));
+ const hours = Math.floor(roundedMinutes / 60);
+ const remainder = roundedMinutes % 60;
+
+ if (hours > 0)
+ return `${hours}h ${remainder}m`;
+
+ return `${roundedMinutes}m`;
+ }
+
+ _friendlyDate(dateText) {
+ const date = GLib.DateTime.new_from_iso8601(
+ `${dateText}T00:00:00`,
+ GLib.TimeZone.new_local());
+ return date ? date.format('%b %-d') : dateText;
+ }
+
+ _timeOfDay(epoch) {
+ const date = GLib.DateTime.new_from_unix_local(epoch);
+ return date ? date.format('%H:%M') : '';
+ }
+
+ _truncate(text, maxLength) {
+ if (!text || text.length <= maxLength)
+ return text ?? '';
+
+ return `${text.slice(0, maxLength - 1)}…`;
+ }
+});
+
+export default class OrgAgendaIndicatorExtension extends Extension {
+ enable() {
+ this._indicator = new Indicator(this.getSettings());
+ Main.panel.addToStatusArea(this.uuid, this._indicator);
+ }
+
+ disable() {
+ this._indicator?.destroy();
+ this._indicator = null;
+ }
+}
diff --git a/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/metadata.json b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/metadata.json
new file mode 100644
index 0000000..d24f67c
--- /dev/null
+++ b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/metadata.json
@@ -0,0 +1,11 @@
+{
+ "uuid": "org-agenda-indicator@rbenencia.name",
+ "extension-id": "org-agenda-indicator",
+ "name": "Org Agenda Indicator",
+ "description": "Show today's org-agenda tasks in the top bar.",
+ "settings-schema": "org.gnome.shell.extensions.org-agenda-indicator",
+ "shell-version": ["46", "47", "48", "49"],
+ "session-modes": ["user"],
+ "url": "https://rbenencia.name",
+ "version": 1
+}
diff --git a/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/prefs.js b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/prefs.js
new file mode 100644
index 0000000..08db40d
--- /dev/null
+++ b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/prefs.js
@@ -0,0 +1,33 @@
+import Adw from 'gi://Adw';
+import Gtk from 'gi://Gtk';
+
+import {ExtensionPreferences} from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
+
+export default class OrgAgendaIndicatorPreferences extends ExtensionPreferences {
+ fillPreferencesWindow(window) {
+ const settings = this.getSettings('org.gnome.shell.extensions.org-agenda-indicator');
+ const page = new Adw.PreferencesPage();
+ const group = new Adw.PreferencesGroup({
+ title: 'Display',
+ description: 'Configure what the indicator shows.',
+ });
+ const row = new Adw.ActionRow({
+ title: 'Show clocked-in task',
+ subtitle: 'Display the active Org clock in the panel and menu when one is running.',
+ });
+ const toggle = new Gtk.Switch({
+ active: settings.get_boolean('show-clocked-in-task'),
+ valign: Gtk.Align.CENTER,
+ });
+
+ toggle.connect('notify::active', widget => {
+ settings.set_boolean('show-clocked-in-task', widget.get_active());
+ });
+
+ row.add_suffix(toggle);
+ row.activatable_widget = toggle;
+ group.add(row);
+ page.add(group);
+ window.add(page);
+ }
+}
diff --git a/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/schemas/org.gnome.shell.extensions.org-agenda-indicator.gschema.xml b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/schemas/org.gnome.shell.extensions.org-agenda-indicator.gschema.xml
new file mode 100644
index 0000000..317b9a1
--- /dev/null
+++ b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/schemas/org.gnome.shell.extensions.org-agenda-indicator.gschema.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<schemalist>
+ <schema id="org.gnome.shell.extensions.org-agenda-indicator"
+ path="/org/gnome/shell/extensions/org-agenda-indicator/">
+ <key name="show-clocked-in-task" type="b">
+ <default>true</default>
+ <summary>Show clocked-in task</summary>
+ <description>
+ When enabled, the indicator displays the active Org clock in the panel
+ and in a dedicated menu section.
+ </description>
+ </key>
+ </schema>
+</schemalist>
diff --git a/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/stylesheet.css b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/stylesheet.css
new file mode 100644
index 0000000..ed690bb
--- /dev/null
+++ b/.local/share/gnome-shell/extensions/org-agenda-indicator@rbenencia.name/stylesheet.css
@@ -0,0 +1,38 @@
+#org-agenda-indicator-label {
+ margin: 0 6px;
+ max-width: 24em;
+}
+
+.org-agenda-indicator-overdue {
+ font-weight: 600;
+}
+
+.org-agenda-indicator-section {
+ padding: 6px 12px 2px;
+}
+
+.org-agenda-indicator-section-label {
+ font-size: 0.82em;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ opacity: 0.7;
+ text-transform: uppercase;
+}
+
+.org-agenda-indicator-row {
+ min-width: 22em;
+ padding: 3px 0;
+}
+
+.org-agenda-indicator-row-title {
+ font-weight: 600;
+}
+
+.org-agenda-indicator-row-meta {
+ font-size: 0.9em;
+ opacity: 0.75;
+}
+
+.org-agenda-indicator-row-title.org-agenda-indicator-overdue {
+ color: #d97706;
+}
diff --git a/.local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/extension.js b/.local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/extension.js
new file mode 100644
index 0000000..8a80b8b
--- /dev/null
+++ b/.local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/extension.js
@@ -0,0 +1,375 @@
+import Gio from 'gi://Gio';
+import GLib from 'gi://GLib';
+import Meta from 'gi://Meta';
+import Shell from 'gi://Shell';
+
+import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+
+const BUS_NAME = 'name.rbenencia.WorkspaceRouter';
+const OBJECT_PATH = '/name/rbenencia/WorkspaceRouter';
+const INTERFACE_NAME = 'name.rbenencia.WorkspaceRouter';
+
+const DBUS_XML = `
+<node>
+ <interface name="${INTERFACE_NAME}">
+ <method name="RouteWindows">
+ <arg type="s" name="result" direction="out"/>
+ </method>
+ <method name="ListWindows">
+ <arg type="s" name="result" direction="out"/>
+ </method>
+ <method name="ActivateWindow">
+ <arg type="u" name="windowId" direction="in"/>
+ <arg type="s" name="result" direction="out"/>
+ </method>
+ </interface>
+</node>`;
+
+const ROUTABLE_WINDOW_TYPES = new Set([
+ Meta.WindowType.NORMAL,
+ Meta.WindowType.DIALOG,
+ Meta.WindowType.MODAL_DIALOG,
+]);
+
+const WINDOW_RULES = [
+ {
+ name: 'misc',
+ workspace: 4,
+ matchAny: [
+ {field: 'title', pattern: /(cisco secure client|secure client|anyconnect|vpnui|keepass)/i},
+ {field: 'appId', pattern: /(cisco|secureclient|anyconnect|vpnui|keepass)/i},
+ {field: 'wmClass', pattern: /(cisco|secureclient|anyconnect|vpnui|keepass)/i},
+ {field: 'wmClassInstance', pattern: /(cisco|secureclient|anyconnect|vpnui|keepass)/i},
+ ],
+ },
+ {
+ name: 'main-frame',
+ workspace: 0,
+ matchAll: [
+ {field: 'appId', pattern: /emacs/i},
+ {field: 'title', pattern: /^main$/i},
+ ],
+ },
+ {
+ name: 'browsers',
+ workspace: 1,
+ matchAny: [
+ {field: 'appId', pattern: /(firefox|chromium|chrome)/i},
+ {field: 'wmClass', pattern: /(firefox|chromium|chrome)/i},
+ {field: 'wmClassInstance', pattern: /(firefox|chromium|chrome)/i},
+ ],
+ },
+ {
+ name: 'communications-frame',
+ workspace: 2,
+ matchAll: [
+ {field: 'appId', pattern: /emacs/i},
+ {field: 'title', pattern: /^communications$/i},
+ ],
+ },
+ {
+ name: 'communications-apps',
+ workspace: 2,
+ matchAny: [
+ {field: 'title', pattern: /(webex|slack|communications|notmuch|outlook|elfeed|thunderbird)/i},
+ {field: 'appId', pattern: /(webex|slack|outlook|thunderbird)/i},
+ {field: 'wmClass', pattern: /(webex|slack|outlook|thunderbird)/i},
+ {field: 'wmClassInstance', pattern: /(webex|slack|outlook|thunderbird)/i},
+ ],
+ },
+ {
+ name: 'terminals-frame',
+ workspace: 3,
+ matchAll: [
+ {field: 'appId', pattern: /emacs/i},
+ {field: 'title', pattern: /^terminals$/i},
+ ],
+ },
+ {
+ name: 'terminals-apps',
+ workspace: 3,
+ matchAny: [
+ {field: 'title', pattern: /(alacritty|kitty|terminal)/i},
+ {field: 'appId', pattern: /(alacritty|kitty|console|terminal)/i},
+ {field: 'wmClass', pattern: /(alacritty|kitty|terminal)/i},
+ {field: 'wmClassInstance', pattern: /(alacritty|kitty|terminal)/i},
+ ],
+ },
+ {
+ name: 'teleport',
+ workspace: 5,
+ matchAny: [
+ {field: 'title', pattern: /teleport/i},
+ {field: 'appId', pattern: /teleport/i},
+ {field: 'wmClass', pattern: /teleport/i},
+ {field: 'wmClassInstance', pattern: /teleport/i},
+ ],
+ },
+ {
+ name: 'media',
+ workspace: 8,
+ matchAny: [
+ {field: 'title', pattern: /(youtube|spotify)/i},
+ {field: 'appId', pattern: /spotify/i},
+ {field: 'wmClass', pattern: /spotify/i},
+ {field: 'wmClassInstance', pattern: /spotify/i},
+ ],
+ },
+];
+
+function getWindowInfo(window, windowTracker) {
+ const app = windowTracker.get_window_app(window);
+
+ return {
+ title: window.get_title() ?? '',
+ wmClass: window.get_wm_class() ?? '',
+ wmClassInstance: window.get_wm_class_instance() ?? '',
+ appId: app?.get_id() ?? '',
+ appName: app?.get_name() ?? '',
+ role: window.get_role() ?? '',
+ monitor: window.get_monitor(),
+ workspace: window.get_workspace()?.index() ?? null,
+ };
+}
+
+function matchCondition(info, condition) {
+ const value = info[condition.field] ?? '';
+ return condition.pattern.test(value);
+}
+
+function ruleMatches(info, rule) {
+ const matchAll = rule.matchAll ?? [];
+ const matchAny = rule.matchAny ?? [];
+
+ if (!matchAll.every(condition => matchCondition(info, condition)))
+ return false;
+
+ if (matchAny.length === 0)
+ return true;
+
+ return matchAny.some(condition => matchCondition(info, condition));
+}
+
+function windowInfoForResult(window, info, rule) {
+ return {
+ id: window.get_stable_sequence(),
+ title: info.title,
+ appId: info.appId,
+ appName: info.appName,
+ wmClass: info.wmClass,
+ wmClassInstance: info.wmClassInstance,
+ role: info.role,
+ monitor: info.monitor,
+ workspace: info.workspace,
+ route: rule?.name ?? null,
+ targetWorkspace: rule?.workspace ?? null,
+ finalMonitor: window.get_monitor(),
+ finalWorkspace: window.get_workspace()?.index() ?? null,
+ };
+}
+
+function isSkipTaskbar(window) {
+ if (typeof window.is_skip_taskbar === 'function')
+ return window.is_skip_taskbar();
+
+ if (typeof window.skip_taskbar === 'function')
+ return window.skip_taskbar();
+
+ return Boolean(window.skip_taskbar);
+}
+
+export default class WorkspaceRouterCliExtension extends Extension {
+ enable() {
+ this._windowTracker = Shell.WindowTracker.get_default();
+ this._dbusImpl = Gio.DBusExportedObject.wrapJSObject(DBUS_XML, this);
+ this._dbusImpl.export(Gio.DBus.session, OBJECT_PATH);
+ this._ownName = Gio.DBus.session.own_name(
+ BUS_NAME,
+ Gio.BusNameOwnerFlags.REPLACE,
+ () => {},
+ () => log(`Workspace Router CLI lost D-Bus name ${BUS_NAME}`));
+ }
+
+ disable() {
+ if (this._ownName) {
+ Gio.DBus.session.unown_name(this._ownName);
+ this._ownName = null;
+ }
+
+ if (this._dbusImpl) {
+ this._dbusImpl.unexport();
+ this._dbusImpl.run_dispose();
+ this._dbusImpl = null;
+ }
+
+ this._windowTracker = null;
+ }
+
+ RouteWindowsAsync(_params, invocation) {
+ try {
+ invocation.return_value(GLib.Variant.new('(s)', [this._routeWindows()]));
+ } catch (e) {
+ logError(e, 'Workspace Router CLI failed to route windows');
+ invocation.return_dbus_error(`${INTERFACE_NAME}.Error`, e.message);
+ }
+ }
+
+ ListWindowsAsync(_params, invocation) {
+ try {
+ invocation.return_value(GLib.Variant.new('(s)', [this._listWindows()]));
+ } catch (e) {
+ logError(e, 'Workspace Router CLI failed to list windows');
+ invocation.return_dbus_error(`${INTERFACE_NAME}.Error`, e.message);
+ }
+ }
+
+ ActivateWindowAsync(params, invocation) {
+ try {
+ const [windowId] = params.deepUnpack();
+ invocation.return_value(GLib.Variant.new('(s)', [this._activateWindow(windowId)]));
+ } catch (e) {
+ logError(e, 'Workspace Router CLI failed to activate window');
+ invocation.return_dbus_error(`${INTERFACE_NAME}.Error`, e.message);
+ }
+ }
+
+ _routeWindows() {
+ const result = {
+ primaryMonitor: Main.layoutManager.primaryIndex,
+ movedToPrimary: 0,
+ routed: 0,
+ skipped: 0,
+ windows: [],
+ };
+
+ for (const window of this._collectWindows()) {
+ const routeResult = this._routeWindow(window);
+
+ if (!routeResult) {
+ result.skipped++;
+ continue;
+ }
+
+ if (routeResult.movedToPrimary)
+ result.movedToPrimary++;
+
+ if (routeResult.routed)
+ result.routed++;
+ else
+ result.skipped++;
+
+ result.windows.push(routeResult.window);
+ }
+
+ return JSON.stringify(result);
+ }
+
+ _listWindows() {
+ const windows = this._collectWindows().map(window => {
+ const info = getWindowInfo(window, this._windowTracker);
+ const rule = WINDOW_RULES.find(candidate => ruleMatches(info, candidate));
+
+ return windowInfoForResult(window, info, rule);
+ });
+
+ return JSON.stringify(windows);
+ }
+
+ _activateWindow(windowId) {
+ const window = this._collectWindows().find(candidate => candidate.get_stable_sequence() === windowId);
+
+ if (!window)
+ throw new Error(`Window not found: ${windowId}`);
+
+ Main.activateWindow(window);
+ return JSON.stringify({activated: true, id: windowId});
+ }
+
+ _collectWindows() {
+ const windows = [];
+ const seen = new Set();
+
+ for (const actor of global.get_window_actors()) {
+ const window = actor.meta_window;
+
+ if (!window || seen.has(window) || !this._isRoutableWindow(window))
+ continue;
+
+ seen.add(window);
+ windows.push(window);
+ }
+
+ return windows;
+ }
+
+ _routeWindow(window) {
+ if (!this._isRoutableWindow(window))
+ return null;
+
+ const info = getWindowInfo(window, this._windowTracker);
+ const rule = WINDOW_RULES.find(candidate => ruleMatches(info, candidate));
+ const movedToPrimary = this._moveWindowToMonitor(window, Main.layoutManager.primaryIndex);
+
+ if (rule)
+ this._moveWindowToWorkspace(window, rule.workspace);
+
+ return {
+ movedToPrimary,
+ routed: Boolean(rule),
+ window: windowInfoForResult(window, info, rule),
+ };
+ }
+
+ _moveWindowToMonitor(window, monitorIndex) {
+ if (monitorIndex === null || monitorIndex === undefined)
+ return false;
+
+ if (monitorIndex === window.get_monitor())
+ return false;
+
+ const wasFullscreen = window.is_fullscreen();
+ const maximized = window.get_maximized();
+
+ if (wasFullscreen)
+ window.unmake_fullscreen();
+
+ if (maximized)
+ window.unmaximize(maximized);
+
+ window.move_to_monitor(monitorIndex);
+
+ if (maximized)
+ window.maximize(maximized);
+
+ if (wasFullscreen)
+ window.make_fullscreen();
+
+ return true;
+ }
+
+ _moveWindowToWorkspace(window, workspaceIndex) {
+ this._ensureWorkspace(workspaceIndex);
+
+ const workspace = window.get_workspace();
+ if (!workspace || workspace.index() !== workspaceIndex)
+ window.change_workspace_by_index(workspaceIndex, false);
+ }
+
+ _ensureWorkspace(index) {
+ const workspaceManager = global.workspace_manager;
+
+ while (workspaceManager.n_workspaces <= index)
+ workspaceManager.append_new_workspace(false, global.get_current_time());
+ }
+
+ _isRoutableWindow(window) {
+ if (isSkipTaskbar(window) || window.is_override_redirect())
+ return false;
+
+ if (window.is_on_all_workspaces())
+ return false;
+
+ return ROUTABLE_WINDOW_TYPES.has(window.get_window_type());
+ }
+}
diff --git a/.local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/metadata.json b/.local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/metadata.json
new file mode 100644
index 0000000..c1b99b5
--- /dev/null
+++ b/.local/share/gnome-shell/extensions/workspace-router-cli@rbenencia.name/metadata.json
@@ -0,0 +1,10 @@
+{
+ "uuid": "workspace-router-cli@rbenencia.name",
+ "extension-id": "workspace-router-cli",
+ "name": "Workspace Router CLI",
+ "description": "Expose Raul's GNOME window routing workflow over D-Bus.",
+ "shell-version": ["46", "47", "48", "49"],
+ "url": "https://rbenencia.name",
+ "session-modes": ["user"],
+ "version": 1
+}
diff --git a/.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/extension.js b/.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/extension.js
new file mode 100644
index 0000000..a59c491
--- /dev/null
+++ b/.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/extension.js
@@ -0,0 +1,298 @@
+import GLib from 'gi://GLib';
+import Meta from 'gi://Meta';
+import Shell from 'gi://Shell';
+
+import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
+import * as Main from 'resource:///org/gnome/shell/ui/main.js';
+
+const EVALUATE_DELAY_MS = 150;
+const ROUTABLE_WINDOW_TYPES = new Set([
+ Meta.WindowType.NORMAL,
+ Meta.WindowType.DIALOG,
+ Meta.WindowType.MODAL_DIALOG,
+]);
+const WINDOW_RULES = [
+ {
+ name: 'main-frame',
+ workspace: 0,
+ monitor: 'primary',
+ matchAll: [
+ {field: 'appId', pattern: /emacs/i},
+ {field: 'title', pattern: /^main$/i},
+ ],
+ },
+ {
+ name: 'browsers',
+ workspace: 1,
+ monitor: 'primary',
+ matchAny: [
+ {field: 'appId', pattern: /(firefox|chromium|chrome)/i},
+ {field: 'wmClass', pattern: /(firefox|chromium|chrome)/i},
+ {field: 'wmClassInstance', pattern: /(firefox|chromium|chrome)/i},
+ ],
+ },
+ {
+ name: 'communications-frame',
+ workspace: 2,
+ monitor: 'primary',
+ matchAll: [
+ {field: 'appId', pattern: /emacs/i},
+ {field: 'title', pattern: /^communications$/i},
+ ],
+ },
+ {
+ name: 'communications-apps',
+ workspace: 2,
+ monitor: 'primary',
+ matchAny: [
+ {field: 'title', pattern: /(webex|slack|communications|notmuch|outlook|elfeed|thunderbird)/i},
+ {field: 'appId', pattern: /(webex|slack|outlook|thunderbird)/i},
+ {field: 'wmClass', pattern: /(webex|slack|outlook|thunderbird)/i},
+ {field: 'wmClassInstance', pattern: /(webex|slack|outlook|thunderbird)/i},
+ ],
+ },
+ {
+ name: 'terminals-frame',
+ workspace: 3,
+ monitor: 'primary',
+ matchAll: [
+ {field: 'appId', pattern: /emacs/i},
+ {field: 'title', pattern: /^terminals$/i},
+ ],
+ },
+ {
+ name: 'teleport',
+ workspace: 5,
+ monitor: 'primary',
+ matchAny: [
+ {field: 'title', pattern: /teleport/i},
+ {field: 'appId', pattern: /teleport/i},
+ {field: 'wmClass', pattern: /teleport/i},
+ {field: 'wmClassInstance', pattern: /teleport/i},
+ ],
+ },
+ {
+ name: 'terminals-apps',
+ workspace: 3,
+ monitor: 'primary',
+ matchAny: [
+ {field: 'title', pattern: /(alacritty|kitty|terminal)/i},
+ {field: 'appId', pattern: /(alacritty|kitty|console|terminal)/i},
+ {field: 'wmClass', pattern: /(alacritty|kitty|terminal)/i},
+ {field: 'wmClassInstance', pattern: /(alacritty|kitty|terminal)/i},
+ ],
+ },
+ {
+ name: 'misc',
+ workspace: 4,
+ monitor: 'primary',
+ matchAny: [
+ {field: 'title', pattern: /(cisco secure client|secure client|anyconnect|vpnui|keepass)/i},
+ {field: 'appId', pattern: /(cisco|secureclient|anyconnect|vpnui|keepass)/i},
+ {field: 'wmClass', pattern: /(cisco|secureclient|anyconnect|vpnui|keepass)/i},
+ {field: 'wmClassInstance', pattern: /(cisco|secureclient|anyconnect|vpnui|keepass)/i},
+ ],
+ },
+ {
+ name: 'media',
+ workspace: 8,
+ monitor: 'primary',
+ matchAny: [
+ {field: 'title', pattern: /(youtube|spotify)/i},
+ {field: 'appId', pattern: /spotify/i},
+ {field: 'wmClass', pattern: /spotify/i},
+ {field: 'wmClassInstance', pattern: /spotify/i},
+ ],
+ },
+];
+
+function getWindowInfo(window, windowTracker) {
+ const app = windowTracker.get_window_app(window);
+
+ return {
+ title: window.get_title() ?? '',
+ wmClass: window.get_wm_class() ?? '',
+ wmClassInstance: window.get_wm_class_instance() ?? '',
+ appId: app?.get_id() ?? '',
+ appName: app?.get_name() ?? '',
+ role: window.get_role() ?? '',
+ };
+}
+
+function matchCondition(info, condition) {
+ const value = info[condition.field] ?? '';
+ return condition.pattern.test(value);
+}
+
+function ruleMatches(info, rule) {
+ const matchAll = rule.matchAll ?? [];
+ const matchAny = rule.matchAny ?? [];
+
+ if (!matchAll.every(condition => matchCondition(info, condition)))
+ return false;
+
+ if (matchAny.length === 0)
+ return true;
+
+ return matchAny.some(condition => matchCondition(info, condition));
+}
+
+function resolveMonitor(monitor) {
+ if (monitor === 'primary')
+ return Main.layoutManager.primaryIndex;
+
+ if (Number.isInteger(monitor))
+ return monitor;
+
+ return null;
+}
+
+function isSkipTaskbar(window) {
+ if (typeof window.is_skip_taskbar === 'function')
+ return window.is_skip_taskbar();
+
+ if (typeof window.skip_taskbar === 'function')
+ return window.skip_taskbar();
+
+ return Boolean(window.skip_taskbar);
+}
+
+export default class WorkspaceRouterExtension extends Extension {
+ enable() {
+ this._windowTracker = Shell.WindowTracker.get_default();
+ this._trackedWindows = new Set();
+ this._routedWindows = new Set();
+ this._pendingEvaluations = new Map();
+
+ global.display.connectObject(
+ 'window-created', (_display, window) => this._trackWindow(window),
+ this);
+
+ this._windowTracker.connectObject(
+ 'tracked-windows-changed', () => this._queueUnmatchedWindows(),
+ this);
+
+ for (const actor of global.get_window_actors())
+ this._trackWindow(actor.meta_window);
+ }
+
+ disable() {
+ global.display.disconnectObject(this);
+ this._windowTracker?.disconnectObject(this);
+
+ for (const [window, sourceId] of this._pendingEvaluations) {
+ GLib.source_remove(sourceId);
+ window.disconnectObject(this);
+ }
+
+ for (const window of this._trackedWindows)
+ window.disconnectObject(this);
+
+ this._pendingEvaluations.clear();
+ this._trackedWindows.clear();
+ this._routedWindows.clear();
+ this._windowTracker = null;
+ }
+
+ _trackWindow(window) {
+ if (!window || this._trackedWindows.has(window))
+ return;
+
+ if (!this._isRoutableWindow(window))
+ return;
+
+ this._trackedWindows.add(window);
+
+ window.connectObject(
+ 'notify::title', () => this._queueWindow(window),
+ 'notify::wm-class', () => this._queueWindow(window),
+ 'unmanaged', () => this._cleanupWindow(window),
+ this);
+
+ this._queueWindow(window);
+ }
+
+ _queueUnmatchedWindows() {
+ for (const window of this._trackedWindows)
+ this._queueWindow(window);
+ }
+
+ _queueWindow(window) {
+ if (!this._trackedWindows.has(window) || this._routedWindows.has(window))
+ return;
+
+ this._clearPendingEvaluation(window);
+
+ const sourceId = GLib.timeout_add(
+ GLib.PRIORITY_DEFAULT,
+ EVALUATE_DELAY_MS,
+ () => {
+ this._pendingEvaluations.delete(window);
+ this._routeWindow(window);
+ return GLib.SOURCE_REMOVE;
+ });
+
+ this._pendingEvaluations.set(window, sourceId);
+ }
+
+ _routeWindow(window) {
+ if (!this._trackedWindows.has(window) || this._routedWindows.has(window))
+ return;
+
+ if (!this._isRoutableWindow(window))
+ return;
+
+ const info = getWindowInfo(window, this._windowTracker);
+ const rule = WINDOW_RULES.find(candidate => ruleMatches(info, candidate));
+
+ if (!rule)
+ return;
+
+ this._moveWindow(window, rule);
+ this._routedWindows.add(window);
+ }
+
+ _moveWindow(window, rule) {
+ this._ensureWorkspace(rule.workspace);
+
+ const monitorIndex = resolveMonitor(rule.monitor);
+ if (monitorIndex !== null && monitorIndex !== window.get_monitor())
+ window.move_to_monitor(monitorIndex);
+
+ const workspace = window.get_workspace();
+ if (!workspace || workspace.index() !== rule.workspace)
+ window.change_workspace_by_index(rule.workspace, false);
+ }
+
+ _ensureWorkspace(index) {
+ const workspaceManager = global.workspace_manager;
+
+ while (workspaceManager.n_workspaces <= index)
+ workspaceManager.append_new_workspace(false, global.get_current_time());
+ }
+
+ _isRoutableWindow(window) {
+ if (isSkipTaskbar(window) || window.is_override_redirect())
+ return false;
+
+ if (window.is_on_all_workspaces())
+ return false;
+
+ return ROUTABLE_WINDOW_TYPES.has(window.get_window_type());
+ }
+
+ _clearPendingEvaluation(window) {
+ const sourceId = this._pendingEvaluations.get(window);
+ if (sourceId) {
+ GLib.source_remove(sourceId);
+ this._pendingEvaluations.delete(window);
+ }
+ }
+
+ _cleanupWindow(window) {
+ this._clearPendingEvaluation(window);
+ this._trackedWindows.delete(window);
+ this._routedWindows.delete(window);
+ window.disconnectObject(this);
+ }
+}
diff --git a/.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/metadata.json b/.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/metadata.json
new file mode 100644
index 0000000..ecff8cc
--- /dev/null
+++ b/.local/share/gnome-shell/extensions/workspace-router@rbenencia.name/metadata.json
@@ -0,0 +1,10 @@
+{
+ "uuid": "workspace-router@rbenencia.name",
+ "extension-id": "workspace-router",
+ "name": "Workspace Router",
+ "description": "Route newly created windows to fixed workspaces based on title, app ID, or WM class.",
+ "shell-version": ["46", "47", "48", "49"],
+ "url": "https://rbenencia.name",
+ "session-modes": ["user"],
+ "version": 1
+}
nihil fit ex nihilo