From 8255203e04fc8fa488793ac40a355205bb79770d Mon Sep 17 00:00:00 2001 From: Philip Langdale Date: Wed, 11 Oct 2017 21:12:52 -0700 Subject: [PATCH 01/10] dash: Add Trash Icon This change introduces a basic trash icon that can open and empty the trash. I did not attempt to implement any sort of drag and drop to put something in the trash. The implementation follows the path of least resistence and creates a DesktopAppInfo so that it can be represented by a regular AppIcon. That has particular implications - most significantly being that any actions offered by the icon have to be Execs of external programs, rather than code. In this case, we care about opening the trash and emptying it. We can use the 'gio' utility to open the trash using the default file manager, but emptying it is trickier. You can use gio to empty the trash but there is no confirmation dialog if you do this. Rather than implement such a dialog, I decided to use the Nautilus EmptyTrash dbus call; this triggers confirmation from Nautilus, but is obviously nautilus specific. Finally, I did not attempt to pin the icon to the bottom of the dash as Unity does. Given how elaborate the icon allocation logic is, I couldn't bring myself to tackle it. --- Makefile | 4 +-- dash.js | 10 +++++++ locations.js | 85 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 locations.js diff --git a/Makefile b/Makefile index aedaf1c91..4fede11b4 100644 --- a/Makefile +++ b/Makefile @@ -2,9 +2,9 @@ UUID = dash-to-dock@micxgx.gmail.com BASE_MODULES = extension.js stylesheet.css metadata.json COPYING README.md -EXTRA_MODULES = dash.js docking.js appIcons.js appIconIndicators.js launcherAPI.js windowPreview.js intellihide.js prefs.js theming.js utils.js Settings.ui +EXTRA_MODULES = dash.js docking.js appIcons.js appIconIndicators.js launcherAPI.js locations.js windowPreview.js intellihide.js prefs.js theming.js utils.js Settings.ui EXTRA_MEDIA = logo.svg glossy.svg highlight_stacked_bg.svg highlight_stacked_bg_h.svg -TOLOCALIZE = prefs.js appIcons.js +TOLOCALIZE = prefs.js appIcons.js locations.js MSGSRC = $(wildcard po/*.po) ifeq ($(strip $(DESTDIR)),) INSTALLTYPE = local diff --git a/dash.js b/dash.js index a646256bf..1dc8769b2 100644 --- a/dash.js +++ b/dash.js @@ -25,6 +25,7 @@ const Workspace = imports.ui.workspace; const Me = imports.misc.extensionUtils.getCurrentExtension(); const Utils = Me.imports.utils; const AppIcons = Me.imports.appIcons; +const Locations = Me.imports.locations; let DASH_ANIMATION_TIME = Dash.DASH_ANIMATION_TIME; let DASH_ITEM_LABEL_HIDE_TIME = Dash.DASH_ITEM_LABEL_HIDE_TIME; @@ -273,6 +274,9 @@ var MyDash = class DashToDock_MyDash { this._appSystem = Shell.AppSystem.get_default(); + // Trash Icon + this._trash = new Locations.Trash(); + this._signalsHandler.add([ this._appSystem, 'installed-changed', @@ -300,6 +304,10 @@ var MyDash = class DashToDock_MyDash { Main.overview, 'item-drag-cancelled', this._onDragCancelled.bind(this) + ], [ + this._trash, + 'changed', + this._queueRedisplay.bind(this) ]); } @@ -743,6 +751,8 @@ var MyDash = class DashToDock_MyDash { } } + newApps.push(this._trash.getApp()); + // Figure out the actual changes to the list of items; we iterate // over both the list of items currently in the dash and the list // of items expected there, and collect additions and removals. diff --git a/locations.js b/locations.js new file mode 100644 index 000000000..71853868c --- /dev/null +++ b/locations.js @@ -0,0 +1,85 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const Gio = imports.gi.Gio; +const GLib = imports.gi.GLib; +const Shell = imports.gi.Shell; +const Signals = imports.signals; + +// Use __ () and N__() for the extension gettext domain, and reuse +// the shell domain with the default _() and N_() +const Gettext = imports.gettext.domain('dashtodock'); +const __ = Gettext.gettext; +const N__ = function(e) { return e }; + +/** + * This class maintains a Shell.App representing the Trash and keeps it + * up-to-date as the trash fills and is emptied over time. + */ +var Trash = class DashToDock_Trash { + + constructor() { + this._file = Gio.file_new_for_uri('trash://'); + try { + this._monitor = this._file.monitor_directory(0, null); + this._signalId = this._monitor.connect( + 'changed', + this._onTrashChange.bind(this)); + } catch (e) { + log(`Unable to monitor trash: ${e}`); + } + this._lastEmpty = true; + this._empty = true; + this._onTrashChange(); + } + + destroy() { + if (this._monitor) { + this._monitor.disconnect(this._signalId); + this._monitor.run_dispose(); + } + this._file.run_dispose(); + } + + _onTrashChange() { + try { + let children = this._file.enumerate_children('*', 0, null); + this._empty = children.next_file(null) == null; + children.close(null); + } catch (e) { + log(`Unable to enumerate trash children: ${e}`); + } + + this._ensureApp(); + } + + _ensureApp() { + if (this._trashApp == null || + this._lastEmpty != this._empty) { + let trashKeys = new GLib.KeyFile(); + trashKeys.set_string('Desktop Entry', 'Name', __('Trash')); + trashKeys.set_string('Desktop Entry', 'Icon', + this._empty ? 'user-trash' : 'user-trash-full'); + trashKeys.set_string('Desktop Entry', 'Type', 'Application'); + trashKeys.set_string('Desktop Entry', 'Exec', 'gio open trash:///'); + trashKeys.set_string('Desktop Entry', 'StartupNotify', 'false'); + trashKeys.set_string('Desktop Entry', 'XdtdUri', 'trash:///'); + if (!this._empty) { + trashKeys.set_string('Desktop Entry', 'Actions', 'empty-trash;'); + trashKeys.set_string('Desktop Action empty-trash', 'Name', __('Empty Trash')); + trashKeys.set_string('Desktop Action empty-trash', 'Exec', + 'dbus-send --print-reply --dest=org.gnome.Nautilus /org/gnome/Nautilus org.gnome.Nautilus.FileOperations.EmptyTrash'); + } + + let trashAppInfo = Gio.DesktopAppInfo.new_from_keyfile(trashKeys); + this._trashApp = new Shell.App({appInfo: trashAppInfo}); + this._lastEmpty = this._empty; + + this.emit('changed'); + } + } + + getApp() { + return this._trashApp; + } +} +Signals.addSignalMethods(Trash.prototype); From bf6e7f535baba56caa2746865910d7c625cc93f7 Mon Sep 17 00:00:00 2001 From: Philip Langdale Date: Wed, 11 Oct 2017 21:15:52 -0700 Subject: [PATCH 02/10] dash: Add Removable drive/device icons Another Unity dock capability is showing icons for removable drives and devices. This change introduces such icons for these entities. As with the Trash, we back these with DesktopAppInfo, and implement the open/unmount/eject actions with the 'gio' utility. In Unity, icons are shown for both mounted and unmounted entities, and this behaviour is retained. Also retained is the practice of not exposing an unmount operation for ejectable entities. We also cannot show an unmounted icon if the entity has no activation root, but I believe that most entities of this type are ejectable so it's not a real problem. This limitation arises because the activation root is how we know where to mount the entity. Unlike the trash, the natural icon placement matches the behaviour in Unity. --- dash.js | 8 +++ locations.js | 179 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) diff --git a/dash.js b/dash.js index 1dc8769b2..24a7b5879 100644 --- a/dash.js +++ b/dash.js @@ -274,6 +274,9 @@ var MyDash = class DashToDock_MyDash { this._appSystem = Shell.AppSystem.get_default(); + // Remove Drive Icons + this._removables = new Locations.Removables(); + // Trash Icon this._trash = new Locations.Trash(); @@ -308,6 +311,10 @@ var MyDash = class DashToDock_MyDash { this._trash, 'changed', this._queueRedisplay.bind(this) + ], [ + this._removables, + 'changed', + this._queueRedisplay.bind(this) ]); } @@ -751,6 +758,7 @@ var MyDash = class DashToDock_MyDash { } } + Array.prototype.push.apply(newApps, this._removables.getApps()); newApps.push(this._trash.getApp()); // Figure out the actual changes to the list of items; we iterate diff --git a/locations.js b/locations.js index 71853868c..2010a1a3b 100644 --- a/locations.js +++ b/locations.js @@ -2,6 +2,7 @@ const Gio = imports.gi.Gio; const GLib = imports.gi.GLib; +const Gtk = imports.gi.Gtk; const Shell = imports.gi.Shell; const Signals = imports.signals; @@ -11,6 +12,9 @@ const Gettext = imports.gettext.domain('dashtodock'); const __ = Gettext.gettext; const N__ = function(e) { return e }; +const Me = imports.misc.extensionUtils.getCurrentExtension(); +const Utils = Me.imports.utils; + /** * This class maintains a Shell.App representing the Trash and keeps it * up-to-date as the trash fills and is emptied over time. @@ -83,3 +87,178 @@ var Trash = class DashToDock_Trash { } } Signals.addSignalMethods(Trash.prototype); + +/** + * This class maintains Shell.App representations for removable devices + * plugged into the system, and keeps the list of Apps up-to-date as + * devices come and go and are mounted and unmounted. + */ +var Removables = class DashToDock_Removables { + + constructor() { + this._signalsHandler = new Utils.GlobalSignalsHandler(); + + this._monitor = Gio.VolumeMonitor.get(); + this._volumeApps = [] + this._mountApps = [] + + this._monitor.get_volumes().forEach( + (volume) => { + this._onVolumeAdded(this._monitor, volume); + } + ); + + this._monitor.get_mounts().forEach( + (mount) => { + this._onMountAdded(this._monitor, mount); + } + ); + + this._signalsHandler.add([ + this._monitor, + 'mount-added', + this._onMountAdded.bind(this) + ], [ + this._monitor, + 'mount-removed', + this._onMountRemoved.bind(this) + ], [ + this._monitor, + 'volume-added', + this._onVolumeAdded.bind(this) + ], [ + this._monitor, + 'volume-removed', + this._onVolumeRemoved.bind(this) + ]); + } + + destroy() { + this._signalsHandler.destroy(); + } + + _getWorkingIconName(icon) { + if (icon instanceof Gio.ThemedIcon) { + let iconTheme = Gtk.IconTheme.get_default(); + let names = icon.get_names(); + for (let i = 0; i < names.length; i++) { + let iconName = names[i]; + if (iconTheme.has_icon(iconName)) { + return iconName; + } + } + return ''; + } else { + return icon.to_string(); + } + } + + _onVolumeAdded(monitor, volume) { + if (!volume.can_mount()) { + return; + } + + let activationRoot = volume.get_activation_root(); + if (!activationRoot) { + // Can't offer to mount a device if we don't know + // where to mount it. + // These devices are usually ejectable so you + // don't normally unmount them anyway. + return; + } + let uri = GLib.uri_unescape_string(activationRoot.get_uri(), null); + + let volumeKeys = new GLib.KeyFile(); + volumeKeys.set_string('Desktop Entry', 'Name', volume.get_name()); + volumeKeys.set_string('Desktop Entry', 'Icon', this._getWorkingIconName(volume.get_icon())); + volumeKeys.set_string('Desktop Entry', 'Type', 'Application'); + volumeKeys.set_string('Desktop Entry', 'Exec', 'gio open "' + uri + '"'); + volumeKeys.set_string('Desktop Entry', 'StartupNotify', 'false'); + volumeKeys.set_string('Desktop Entry', 'Actions', 'mount;'); + volumeKeys.set_string('Desktop Action mount', 'Name', __('Mount')); + volumeKeys.set_string('Desktop Action mount', 'Exec', 'gio mount "' + uri + '"'); + let volumeAppInfo = Gio.DesktopAppInfo.new_from_keyfile(volumeKeys); + let volumeApp = new Shell.App({appInfo: volumeAppInfo}); + this._volumeApps.push(volumeApp); + this.emit('changed'); + } + + _onVolumeRemoved(monitor, volume) { + for (let i = 0; i < this._volumeApps.length; i++) { + let app = this._volumeApps[i]; + if (app.get_name() == volume.get_name()) { + this._volumeApps.splice(i, 1); + } + } + this.emit('changed'); + } + + _onMountAdded(monitor, mount) { + // Filter out uninteresting mounts + if (!mount.can_eject() && !mount.can_unmount()) + return; + if (mount.is_shadowed()) + return; + + let volume = mount.get_volume(); + if (!volume || volume.get_identifier('class') == 'network') { + return; + } + + let escapedUri = mount.get_root().get_uri() + let uri = GLib.uri_unescape_string(escapedUri, null); + + let mountKeys = new GLib.KeyFile(); + mountKeys.set_string('Desktop Entry', 'Name', mount.get_name()); + mountKeys.set_string('Desktop Entry', 'Icon', + this._getWorkingIconName(volume.get_icon())); + mountKeys.set_string('Desktop Entry', 'Type', 'Application'); + mountKeys.set_string('Desktop Entry', 'Exec', 'gio open "' + uri + '"'); + mountKeys.set_string('Desktop Entry', 'StartupNotify', 'false'); + mountKeys.set_string('Desktop Entry', 'XdtdUri', escapedUri); + mountKeys.set_string('Desktop Entry', 'Actions', 'unmount;'); + if (mount.can_eject()) { + mountKeys.set_string('Desktop Action unmount', 'Name', __('Eject')); + mountKeys.set_string('Desktop Action unmount', 'Exec', + 'gio mount -e "' + uri + '"'); + } else { + mountKeys.set_string('Desktop Entry', 'Actions', 'unmount;'); + mountKeys.set_string('Desktop Action unmount', 'Name', __('Unmount')); + mountKeys.set_string('Desktop Action unmount', 'Exec', + 'gio mount -u "' + uri + '"'); + } + let mountAppInfo = Gio.DesktopAppInfo.new_from_keyfile(mountKeys); + let mountApp = new Shell.App({appInfo: mountAppInfo}); + this._mountApps.push(mountApp); + this.emit('changed'); + } + + _onMountRemoved(monitor, mount) { + for (let i = 0; i < this._mountApps.length; i++) { + let app = this._mountApps[i]; + if (app.get_name() == mount.get_name()) { + this._mountApps.splice(i, 1); + } + } + this.emit('changed'); + } + + getApps() { + // When we have both a volume app and a mount app, we prefer + // the mount app. + let apps = new Map(); + this._volumeApps.map(function(app) { + apps.set(app.get_name(), app); + }); + this._mountApps.map(function(app) { + apps.set(app.get_name(), app); + }); + + let ret = []; + for (let app of apps.values()) { + ret.push(app); + } + return ret; + } +} +Signals.addSignalMethods(Removables.prototype); From d1e63c9ffb710086b94794d749e548acea4649ef Mon Sep 17 00:00:00 2001 From: Philip Langdale Date: Mon, 8 Jan 2018 19:59:27 -0800 Subject: [PATCH 03/10] prefs: Add prefs to toggle showing Trash and Mounted Volumes Make showing of these items optional. --- Settings.ui | 84 +++++++++++++++++++ dash.js | 43 ++++++---- docking.js | 8 ++ prefs.js | 8 ++ ....shell.extensions.dash-to-dock.gschema.xml | 10 +++ 5 files changed, 137 insertions(+), 16 deletions(-) diff --git a/Settings.ui b/Settings.ui index a49f53104..240c2f447 100644 --- a/Settings.ui +++ b/Settings.ui @@ -1338,6 +1338,90 @@ + + + True + True + + + True + False + 12 + 12 + 12 + 12 + 32 + + + True + True + end + center + + + 1 + 0 + + + + + True + False + True + Show trash can + 0 + + + 0 + 0 + + + + + + + + + True + True + + + True + False + 12 + 12 + 12 + 12 + 32 + + + True + True + end + center + + + 1 + 0 + + + + + True + False + True + Show mounted volumes and devices + 0 + + + 0 + 0 + + + + + + diff --git a/dash.js b/dash.js index 24a7b5879..d38119a18 100644 --- a/dash.js +++ b/dash.js @@ -274,12 +274,6 @@ var MyDash = class DashToDock_MyDash { this._appSystem = Shell.AppSystem.get_default(); - // Remove Drive Icons - this._removables = new Locations.Removables(); - - // Trash Icon - this._trash = new Locations.Trash(); - this._signalsHandler.add([ this._appSystem, 'installed-changed', @@ -307,14 +301,6 @@ var MyDash = class DashToDock_MyDash { Main.overview, 'item-drag-cancelled', this._onDragCancelled.bind(this) - ], [ - this._trash, - 'changed', - this._queueRedisplay.bind(this) - ], [ - this._removables, - 'changed', - this._queueRedisplay.bind(this) ]); } @@ -758,8 +744,33 @@ var MyDash = class DashToDock_MyDash { } } - Array.prototype.push.apply(newApps, this._removables.getApps()); - newApps.push(this._trash.getApp()); + if (this._dtdSettings.get_boolean('show-mounts')) { + if (!this._removables) { + this._removables = new Locations.Removables(); + this._signalsHandler.addWithLabel('show-mounts', + [ this._removables, + 'changed', + this._queueRedisplay.bind(this) ]); + } + Array.prototype.push.apply(newApps, this._removables.getApps()); + } else if (this._removables) { + this._signalsHandler.removeWithLabel('show-mounts'); + this._removables = null; + } + + if (this._dtdSettings.get_boolean('show-trash')) { + if (!this._trash) { + this._trash = new Locations.Trash(); + this._signalsHandler.addWithLabel('show-trash', + [ this._trash, + 'changed', + this._queueRedisplay.bind(this) ]); + } + newApps.push(this._trash.getApp()); + } else if (this._trash) { + this._signalsHandler.removeWithLabel('show-trash'); + this._trash = null; + } // Figure out the actual changes to the list of items; we iterate // over both the list of items currently in the dash and the list diff --git a/docking.js b/docking.js index d35094b5c..4ff9a1c81 100644 --- a/docking.js +++ b/docking.js @@ -481,6 +481,14 @@ var DockedDash = class DashToDock { this._settings, 'changed::show-favorites', () => { this.dash.resetAppIcons(); } + ], [ + this._settings, + 'changed::show-trash', + () => { this.dash.resetAppIcons(); } + ], [ + this._settings, + 'changed::show-mounts', + () => { this.dash.resetAppIcons(); } ], [ this._settings, 'changed::show-running', diff --git a/prefs.js b/prefs.js index f8f792139..552302987 100644 --- a/prefs.js +++ b/prefs.js @@ -481,6 +481,14 @@ var Settings = class DashToDock_Settings { this._builder.get_object('show_favorite_switch'), 'active', Gio.SettingsBindFlags.DEFAULT); + this._settings.bind('show-trash', + this._builder.get_object('show_trash_switch'), + 'active', + Gio.SettingsBindFlags.DEFAULT); + this._settings.bind('show-mounts', + this._builder.get_object('show_mounts_switch'), + 'active', + Gio.SettingsBindFlags.DEFAULT); this._settings.bind('show-show-apps-button', this._builder.get_object('show_applications_button_switch'), 'active', diff --git a/schemas/org.gnome.shell.extensions.dash-to-dock.gschema.xml b/schemas/org.gnome.shell.extensions.dash-to-dock.gschema.xml index 9cf371ba4..65a0c10f9 100644 --- a/schemas/org.gnome.shell.extensions.dash-to-dock.gschema.xml +++ b/schemas/org.gnome.shell.extensions.dash-to-dock.gschema.xml @@ -215,6 +215,16 @@ Show favorites apps Show or hide favorite appplications icons in the dash + + true + Show trash can + Show or hide the trash can icon in the dash + + + true + Show mounted volumes and devices + Show or hide mounted volume and device icons in the dash + true Show applications button From 60ed9ca77186b697251b76bf2a67e2fb33d1b2dc Mon Sep 17 00:00:00 2001 From: Philip Langdale Date: Wed, 11 Oct 2017 17:35:42 -0700 Subject: [PATCH 04/10] appIcons: Implement window tracking for removable devices and trash In Unity, special logic is present that will map a Nautilus window the removable device or trash icon in the dock. The key to making this possible is a special dbus property that was patched into Nautilus that allows us to find the window where a location is open. Once we have access to this Nautilus information, we can then jump through a bunch of hoops to map the locations to MetaWindows and then a little special-casing logic, link our dock icons to those windows. Now, the special icons will have a running process highlight and window counts, and all the usual features of a running app. We support both the Ubuntu-specific patched Xid based window matching as well as the upstream GtkApplication based matching that I added to Nautilus. In a difference from Unity, I made no attempt to subtract the special location windows from the Nautilus app; that would be a bunch of work and the benefit is unclear. When run with a Nautilus that supports neither method, we will simply never see any linked windows, and the behaviour will be the same as without this change. --- Makefile | 2 +- appIconIndicators.js | 2 +- appIcons.js | 44 +++++++-- fileManager1API.js | 218 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 256 insertions(+), 10 deletions(-) create mode 100644 fileManager1API.js diff --git a/Makefile b/Makefile index 4fede11b4..3b493fa27 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ UUID = dash-to-dock@micxgx.gmail.com BASE_MODULES = extension.js stylesheet.css metadata.json COPYING README.md -EXTRA_MODULES = dash.js docking.js appIcons.js appIconIndicators.js launcherAPI.js locations.js windowPreview.js intellihide.js prefs.js theming.js utils.js Settings.ui +EXTRA_MODULES = dash.js docking.js appIcons.js appIconIndicators.js fileManager1API.js launcherAPI.js locations.js windowPreview.js intellihide.js prefs.js theming.js utils.js Settings.ui EXTRA_MEDIA = logo.svg glossy.svg highlight_stacked_bg.svg highlight_stacked_bg_h.svg TOLOCALIZE = prefs.js appIcons.js locations.js MSGSRC = $(wildcard po/*.po) diff --git a/appIconIndicators.js b/appIconIndicators.js index f4912352d..28b28891b 100644 --- a/appIconIndicators.js +++ b/appIconIndicators.js @@ -165,7 +165,7 @@ var RunningIndicatorBase = class DashToDock_RunningIndicatorBase extends Indicat // In the case of workspace isolation, we need to hide the dots of apps with // no windows in the current workspace - if (this._source.app.state != Shell.AppState.STOPPED && this._nWindows > 0) + if ((this._source.app.state != Shell.AppState.STOPPED || this._source.isLocation()) && this._nWindows > 0) this._isRunning = true; else this._isRunning = false; diff --git a/appIcons.js b/appIcons.js index 3b28304d4..35e095dff 100644 --- a/appIcons.js +++ b/appIcons.js @@ -29,6 +29,7 @@ const Util = imports.misc.util; const Workspace = imports.ui.workspace; const Me = imports.misc.extensionUtils.getCurrentExtension(); +const FileManager1API = Me.imports.fileManager1API; const Utils = Me.imports.utils; const WindowPreview = Me.imports.windowPreview; const AppIconIndicators = Me.imports.appIconIndicators; @@ -87,6 +88,9 @@ var MyAppIcon = class DashToDock_AppIcon extends AppDisplay.AppIcon { this.remoteModel = remoteModel; this._indicator = null; + let appInfo = app.get_app_info(); + this._location = appInfo ? appInfo.get_string('XdtdUri') : null; + this._updateIndicatorStyle(); // Monitor windows-changes instead of app state. @@ -130,6 +134,11 @@ var MyAppIcon = class DashToDock_AppIcon extends AppDisplay.AppIcon { this._updateIndicatorStyle.bind(this) ]); }, this); + this._signalsHandler.add([ + FileManager1API.fm1Client, + 'windows-changed', + this.onWindowsChanged.bind(this) + ]); this._dtdSettings.connect('changed::scroll-action', () => { this._optionalScrollCycleWindows(); @@ -277,7 +286,7 @@ var MyAppIcon = class DashToDock_AppIcon extends AppDisplay.AppIcon { [rect.x, rect.y] = this.actor.get_transformed_position(); [rect.width, rect.height] = this.actor.get_transformed_size(); - let windows = this.app.get_windows(); + let windows = this.getWindows(); if (this._dtdSettings.get_boolean('multi-monitor')){ let monitorIndex = this.monitorIndex; windows = windows.filter(function(w) { @@ -386,7 +395,7 @@ var MyAppIcon = class DashToDock_AppIcon extends AppDisplay.AppIcon { // We check if the app is running, and that the # of windows is > 0 in // case we use workspace isolation. let windows = this.getInterestingWindows(); - let appIsRunning = this.app.state == Shell.AppState.RUNNING + let appIsRunning = (this.app.state == Shell.AppState.RUNNING || this.isLocation()) && windows.length > 0; // Some action modes (e.g. MINIMIZE_OR_OVERVIEW) require overview to remain open @@ -591,7 +600,7 @@ var MyAppIcon = class DashToDock_AppIcon extends AppDisplay.AppIcon { // Try to manually activate the first window. Otherwise, when the app is activated by // switching to a different workspace, a launch spinning icon is shown and disappers only // after a timeout. - let windows = this.app.get_windows(); + let windows = this.getWindows(); if (windows.length > 0) Main.activateWindow(windows[0]) else @@ -746,10 +755,19 @@ var MyAppIcon = class DashToDock_AppIcon extends AppDisplay.AppIcon { return false; } + getWindows() { + return getWindows(this.app, this._location); + } + // Filter out unnecessary windows, for instance // nautilus desktop window. getInterestingWindows() { - return getInterestingWindows(this.app, this._dtdSettings, this.monitorIndex); + return getInterestingWindows(this.app, this._dtdSettings, this.monitorIndex, this._location); + } + + // Does the Icon represent a location rather than an App + isLocation() { + return this._location != null; } }; /** @@ -832,7 +850,8 @@ const MyAppIconMenu = class DashToDock_MyAppIconMenu extends AppDisplay.AppIconM }); } - let canFavorite = global.settings.is_writable('favorite-apps'); + let canFavorite = global.settings.is_writable('favorite-apps') && + !this._source.isLocation(); if (canFavorite) { this._appendSeparator(); @@ -854,7 +873,8 @@ const MyAppIconMenu = class DashToDock_MyAppIconMenu extends AppDisplay.AppIconM } } - if (Shell.AppSystem.get_default().lookup_app('org.gnome.Software.desktop')) { + if (Shell.AppSystem.get_default().lookup_app('org.gnome.Software.desktop') && + !this._source.isLocation()) { this._appendSeparator(); let item = this._appendMenuItem(_("Show Details")); item.connect('activate', () => { @@ -976,10 +996,18 @@ const MyAppIconMenu = class DashToDock_MyAppIconMenu extends AppDisplay.AppIconM }; Signals.addSignalMethods(MyAppIconMenu.prototype); +function getWindows(app, location) { + if (location != null) { + return FileManager1API.fm1Client.getWindows(location); + } else { + return app.get_windows(); + } +} + // Filter out unnecessary windows, for instance // nautilus desktop window. -function getInterestingWindows(app, settings, monitorIndex) { - let windows = app.get_windows().filter(function(w) { +function getInterestingWindows(app, settings, monitorIndex, location) { + let windows = getWindows(app, location).filter(function(w) { return !w.skip_taskbar; }); diff --git a/fileManager1API.js b/fileManager1API.js new file mode 100644 index 000000000..8f7ca3a30 --- /dev/null +++ b/fileManager1API.js @@ -0,0 +1,218 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const Gio = imports.gi.Gio; +const Signals = imports.signals; + +const Me = imports.misc.extensionUtils.getCurrentExtension(); +const Utils = Me.imports.utils; + +const FileManager1Iface = '\ + \ + \ + '; + +const FileManager1Proxy = Gio.DBusProxy.makeProxyWrapper(FileManager1Iface); + +/** + * This class implements a client for the org.freedesktop.FileManager1 dbus + * interface, and specifically for the OpenWindowsWithLocations property + * which is published by Nautilus, but is not an official part of the interface. + * + * The property is a map from window identifiers to a list of locations open in + * the window. + * + * While OpeWindowsWithLocations is part of upstream Nautilus, for many years + * prior, Ubuntu patched Nautilus to publish XUbuntuOpenLocationsXids, which is + * similar but uses Xids as the window identifiers instead of gtk window paths. + * + * When an old or unpatched Nautilus is running, we will observe the properties + * to always be empty arrays, but there will not be any correctness issues. + */ +var FileManager1Client = class DashToDock_FileManager1Client { + + constructor() { + this._signalsHandler = new Utils.GlobalSignalsHandler(); + + this._locationMap = new Map(); + this._proxy = new FileManager1Proxy(Gio.DBus.session, + "org.freedesktop.FileManager1", + "/org/freedesktop/FileManager1", + (initable, error) => { + // Use async construction to avoid blocking on errors. + if (error) { + global.log(error); + } else { + this._updateLocationMap(); + } + }); + + this._signalsHandler.add([ + this._proxy, + 'g-properties-changed', + this._onPropertyChanged.bind(this) + ], [ + // We must additionally listen for Screen events to know when to + // rebuild our location map when the set of available windows changes. + global.workspace_manager, + 'workspace-switched', + this._updateLocationMap.bind(this) + ], [ + global.display, + 'window-entered-monitor', + this._updateLocationMap.bind(this) + ], [ + global.display, + 'window-left-monitor', + this._updateLocationMap.bind(this) + ]); + } + + destroy() { + this._signalsHandler.destroy(); + } + + /** + * Return an array of windows that are showing a location or + * sub-directories of that location. + */ + getWindows(location) { + let ret = []; + for (let [k,v] of this._locationMap) { + if (k.startsWith(location)) { + for (let l of v) { + ret.push(l); + } + } + } + return ret; + } + + _onPropertyChanged(proxy, changed, invalidated) { + let property = changed.unpack(); + if (property && + ('XUbuntuOpenLocationsXids' in property || + 'OpenWindowsWithLocations' in property)) { + this._updateLocationMap(); + } + } + + _updateLocationMap() { + let properties = this._proxy.get_cached_property_names(); + if (properties == null) { + // Nothing to check yet. + return; + } + + if (properties.includes('OpenWindowsWithLocations')) { + this._updateFromPaths(); + } else if (properties.includes('XUbuntuOpenLocationsXids')) { + this._updateFromXids(); + } + } + + _updateFromPaths() { + let pathToLocations = this._proxy.OpenWindowsWithLocations; + let pathToWindow = getPathToWindow(); + + let locationToWindow = new Map(); + for (let path in pathToLocations) { + let locations = pathToLocations[path]; + for (let i = 0; i < locations.length; i++) { + let l = locations[i]; + // Use a set to deduplicate when a window has a + // location open in multiple tabs. + if (!locationToWindow.has(l)) { + locationToWindow.set(l, new Set()); + } + let window = pathToWindow.get(path); + if (window != null) { + locationToWindow.get(l).add(window); + } + } + } + this._locationMap = locationToWindow; + this.emit('windows-changed'); + } + + _updateFromXids() { + let xidToLocations = this._proxy.XUbuntuOpenLocationsXids; + let xidToWindow = getXidToWindow(); + + let locationToWindow = new Map(); + for (let xid in xidToLocations) { + let locations = xidToLocations[xid]; + for (let i = 0; i < locations.length; i++) { + let l = locations[i]; + // Use a set to deduplicate when a window has a + // location open in multiple tabs. + if (!locationToWindow.has(l)) { + locationToWindow.set(l, new Set()); + } + let window = xidToWindow.get(parseInt(xid)); + if (window != null) { + locationToWindow.get(l).add(window); + } + } + } + this._locationMap = locationToWindow; + this.emit('windows-changed'); + } +} +Signals.addSignalMethods(FileManager1Client.prototype); + +/** + * Construct a map of gtk application window object paths to MetaWindows. + */ +function getPathToWindow() { + let pathToWindow = new Map(); + + for (let i = 0; i < global.workspace_manager.n_workspaces; i++) { + let ws = global.workspace_manager.get_workspace_by_index(i); + ws.list_windows().map(function(w) { + let path = w.get_gtk_window_object_path(); + if (path != null) { + pathToWindow.set(path, w); + } + }); + } + return pathToWindow; +} + +/** + * Construct a map of XIDs to MetaWindows. + * + * This is somewhat annoying as you cannot lookup a window by + * XID in any way, and must iterate through all of them looking + * for a match. + */ +function getXidToWindow() { + let xidToWindow = new Map(); + + for (let i = 0; i < global.workspace_manager.n_workspaces; i++) { + let ws = global.workspace_manager.get_workspace_by_index(i); + ws.list_windows().map(function(w) { + let xid = guessWindowXID(w); + if (xid != null) { + xidToWindow.set(parseInt(xid), w); + } + }); + } + return xidToWindow; +} + +/** + * Guesses the X ID of a window. + * + * This is the basic implementation that is sufficient for Nautilus + * windows. The pixel-saver extension has a much more complex + * implementation if we ever need it. + */ +function guessWindowXID(win) { + try { + return win.get_description().match(/0x[0-9a-f]+/)[0]; + } catch (err) { + return null; + } +} + +var fm1Client = new FileManager1Client(); From ff604a6226e576a2951eb67234aec27e0c0afc75 Mon Sep 17 00:00:00 2001 From: Philip Langdale Date: Sat, 20 Apr 2019 21:35:39 -0700 Subject: [PATCH 05/10] fileManager1API: Deduplicate window with related locations in tabs I was already deduplicating if a window had the identical location open in tabs but if the locations were different but still under the same removable device, we'd get two separate entries created for that same window. This is now fixed. --- fileManager1API.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fileManager1API.js b/fileManager1API.js index 8f7ca3a30..b8a671571 100644 --- a/fileManager1API.js +++ b/fileManager1API.js @@ -76,15 +76,15 @@ var FileManager1Client = class DashToDock_FileManager1Client { * sub-directories of that location. */ getWindows(location) { - let ret = []; + let ret = new Set(); for (let [k,v] of this._locationMap) { if (k.startsWith(location)) { for (let l of v) { - ret.push(l); + ret.add(l); } } } - return ret; + return Array.from(ret); } _onPropertyChanged(proxy, changed, invalidated) { From f7624842a07924bcdaabb26e8464c86a0f329e0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Trevisan=20=28Trevi=C3=B1o=29?= Date: Mon, 19 Aug 2019 14:52:24 +0200 Subject: [PATCH 06/10] locations: Ensure volume monitor is destroyed on destruction (cherry picked from commit 7b58ceb597eca8893018eba0275840b719a5e9d3) --- locations.js | 1 + 1 file changed, 1 insertion(+) diff --git a/locations.js b/locations.js index 2010a1a3b..9ef94a657 100644 --- a/locations.js +++ b/locations.js @@ -135,6 +135,7 @@ var Removables = class DashToDock_Removables { destroy() { this._signalsHandler.destroy(); + this._monitor.run_dispose(); } _getWorkingIconName(icon) { From 01746fbde8605df5e96d56aef6445ec0d97f073a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Trevisan=20=28Trevi=C3=B1o=29?= Date: Mon, 19 Aug 2019 14:54:56 +0200 Subject: [PATCH 07/10] dash: Destroy Removables and Trash if disabled (cherry picked from commit 921b68d68200e7615bbd92926bdb63c00ae4c11f) --- dash.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dash.js b/dash.js index d38119a18..d9c1e5ee8 100644 --- a/dash.js +++ b/dash.js @@ -755,6 +755,7 @@ var MyDash = class DashToDock_MyDash { Array.prototype.push.apply(newApps, this._removables.getApps()); } else if (this._removables) { this._signalsHandler.removeWithLabel('show-mounts'); + this._removables.destroy(); this._removables = null; } @@ -769,6 +770,7 @@ var MyDash = class DashToDock_MyDash { newApps.push(this._trash.getApp()); } else if (this._trash) { this._signalsHandler.removeWithLabel('show-trash'); + this._trash.destroy(); this._trash = null; } From 605f0e18fcf5e58b67f0b4d2cc4affe9c58a28a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Trevisan=20=28Trevi=C3=B1o=29?= Date: Mon, 19 Aug 2019 14:55:31 +0200 Subject: [PATCH 08/10] DockManager: Keep a fm1Client reference and destroy it when unneeded Instead of keeping the FileManager1 proxy around all the times, only create it when needed and destroy it on extension destruction. (cherry picked from commit 6bddbc31943d76ad683071eee35ee5be664a9d00) --- appIcons.js | 19 +++++++++++-------- docking.js | 43 ++++++++++++++++++++++++++++++++++++++++++- fileManager1API.js | 3 +-- 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/appIcons.js b/appIcons.js index 35e095dff..8f915e300 100644 --- a/appIcons.js +++ b/appIcons.js @@ -29,7 +29,7 @@ const Util = imports.misc.util; const Workspace = imports.ui.workspace; const Me = imports.misc.extensionUtils.getCurrentExtension(); -const FileManager1API = Me.imports.fileManager1API; +const Docking = Me.imports.docking; const Utils = Me.imports.utils; const WindowPreview = Me.imports.windowPreview; const AppIconIndicators = Me.imports.appIconIndicators; @@ -134,11 +134,14 @@ var MyAppIcon = class DashToDock_AppIcon extends AppDisplay.AppIcon { this._updateIndicatorStyle.bind(this) ]); }, this); - this._signalsHandler.add([ - FileManager1API.fm1Client, - 'windows-changed', - this.onWindowsChanged.bind(this) - ]); + + if (this._location) { + this._signalsHandler.add([ + Docking.DockManager.getDefault().fm1Client, + 'windows-changed', + this.onWindowsChanged.bind(this) + ]); + } this._dtdSettings.connect('changed::scroll-action', () => { this._optionalScrollCycleWindows(); @@ -997,8 +1000,8 @@ const MyAppIconMenu = class DashToDock_MyAppIconMenu extends AppDisplay.AppIconM Signals.addSignalMethods(MyAppIconMenu.prototype); function getWindows(app, location) { - if (location != null) { - return FileManager1API.fm1Client.getWindows(location); + if (location != null && Docking.DockManager.getDefault().fm1Client) { + return Docking.DockManager.getDefault().fm1Client.getWindows(location); } else { return app.get_windows(); } diff --git a/docking.js b/docking.js index 4ff9a1c81..36cde01b2 100644 --- a/docking.js +++ b/docking.js @@ -30,6 +30,7 @@ const Intellihide = Me.imports.intellihide; const Theming = Me.imports.theming; const MyDash = Me.imports.dash; const LauncherAPI = Me.imports.launcherAPI; +const FileManager1API = Me.imports.fileManager1API; const DOCK_DWELL_CHECK_INTERVAL = 100; @@ -1574,6 +1575,8 @@ var DockManager = class DashToDock_DockManager { this._remoteModel = new LauncherAPI.LauncherEntryRemoteModel(); this._settings = ExtensionUtils.getSettings('org.gnome.shell.extensions.dash-to-dock'); this._oldDash = Main.overview._dash; + this._ensureFileManagerClient(); + /* Array of all the docks created */ this._allDocks = []; this._createDocks(); @@ -1586,6 +1589,32 @@ var DockManager = class DashToDock_DockManager { this._bindSettingsChanges(); } + static getDefault() { + return Me.imports.extension.dockManager + } + + static get settings() { + return DockManager.getDefault()._settings; + } + + get fm1Client() { + return this._fm1Client; + } + + _ensureFileManagerClient() { + let supportsLocations = ['show-trash', 'show-mounts'].some((s) => { + return this._settings.get_boolean(s); + }); + + if (supportsLocations) { + if (!this._fm1Client) + this._fm1Client = new FileManager1API.FileManager1Client(); + } else if (this._fm1Client) { + this._fm1Client.destroy(); + this._fm1Client = null; + } + } + _toggle() { this._deleteDocks(); this._createDocks(); @@ -1619,7 +1648,15 @@ var DockManager = class DashToDock_DockManager { this._settings, 'changed::dock-fixed', this._adjustPanelCorners.bind(this) - ]); + ], [ + this._settings, + 'changed::show-trash', + () => this._ensureFileManagerClient() + ], [ + this._settings, + 'changed::show-mounts', + () => this._ensureFileManagerClient() + ], ); } _createDocks() { @@ -1831,6 +1868,10 @@ var DockManager = class DashToDock_DockManager { this._deleteDocks(); this._revertPanelCorners(); this._restoreDash(); + if (this._fm1Client) { + this._fm1Client.destroy(); + this._fm1Client = null; + } this._remoteModel.destroy(); } diff --git a/fileManager1API.js b/fileManager1API.js index b8a671571..2b9bbe889 100644 --- a/fileManager1API.js +++ b/fileManager1API.js @@ -69,6 +69,7 @@ var FileManager1Client = class DashToDock_FileManager1Client { destroy() { this._signalsHandler.destroy(); + this._proxy.run_dispose(); } /** @@ -214,5 +215,3 @@ function guessWindowXID(win) { return null; } } - -var fm1Client = new FileManager1Client(); From dba7b646b43f4616fbc24a8ecc0ea114cb6eb815 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Trevisan=20=28Trevi=C3=B1o=29?= Date: Mon, 16 Sep 2019 17:55:09 +0200 Subject: [PATCH 09/10] utils: Allow to pass flags to SignalsHandler's Add SignalsHandlerFlags which allows to connect to a signal using flags, the only defined for now is CONNECT_AFTER, that allows to use `connect_after()` method when the parent object supports it (so when it is inheriting from GObject). (cherry picked from commit 3ff622dedc9193fba0e933a58b3765761d894de1) --- utils.js | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/utils.js b/utils.js index d315bd903..496039b5d 100644 --- a/utils.js +++ b/utils.js @@ -1,6 +1,13 @@ const Clutter = imports.gi.Clutter; const Meta = imports.gi.Meta; const St = imports.gi.St; +const Me = imports.misc.extensionUtils.getCurrentExtension(); +const Docking = Me.imports.docking; + +var SignalsHandlerFlags = { + NONE: 0, + CONNECT_AFTER: 1 +}; /** * Simplify global signals and function injections handling @@ -31,7 +38,11 @@ const BasicHandler = class DashToDock_BasicHandler { // Skip first element of the arguments for (let i = 1; i < arguments.length; i++) { let item = this._storage[label]; - item.push(this._create(arguments[i])); + try { + item.push(this._create(arguments[i])); + } catch (e) { + logError(e); + } } } @@ -70,7 +81,21 @@ var GlobalSignalsHandler = class DashToDock_GlobalSignalHandler extends BasicHan let object = item[0]; let event = item[1]; let callback = item[2] - let id = object.connect(event, callback); + let flags = item.length > 3 ? item[3] : SignalsHandlerFlags.NONE; + + if (!object) + throw new Error('Impossible to connect to an invalid object'); + + let after = flags == SignalsHandlerFlags.CONNECT_AFTER; + let connector = after ? object.connect_after : object.connect; + + if (!connector) { + throw new Error(`Requested to connect to signal '${event}', ` + + `but no implementation for 'connect${after ? '_after' : ''}' `+ + `found in ${object.constructor.name}`); + } + + let id = connector.call(object, event, callback); return [object, id]; } From 053e1c569fd83b0a32d42747aa14491da1bbf7ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Trevisan=20=28Trevi=C3=B1o=29?= Date: Mon, 16 Sep 2019 18:18:56 +0200 Subject: [PATCH 10/10] docking: Use CONNNECT_AFTER for mounts changes in docked dash Changing show-trash/show-mounts causes the file-manager proxy object to be destroyed, in order to make sure this happens before that the icons might consume this, connect to show-mounts and show-trash changes after. (cherry picked from commit cbb7ce75737ccd226c16ad0c18b6cedcd134b865) --- docking.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docking.js b/docking.js index 36cde01b2..389d8cd62 100644 --- a/docking.js +++ b/docking.js @@ -485,11 +485,13 @@ var DockedDash = class DashToDock { ], [ this._settings, 'changed::show-trash', - () => { this.dash.resetAppIcons(); } + () => { this.dash.resetAppIcons(); }, + Utils.SignalsHandlerFlags.CONNECT_AFTER, ], [ this._settings, 'changed::show-mounts', - () => { this.dash.resetAppIcons(); } + () => { this.dash.resetAppIcons(); }, + Utils.SignalsHandlerFlags.CONNECT_AFTER ], [ this._settings, 'changed::show-running',