From eda45e607247a13b38f99e6e6206d0df7d1f651d Mon Sep 17 00:00:00 2001 From: Giovanni Campagna Date: Thu, 6 Sep 2012 19:25:54 +0200 Subject: [PATCH] places-menu: rework to be more similar to the Files sidebar Let's go GNOME 3 style, and make a places menu that looks like the sidebar in Files, with for subsections corresponding to default places, devices, bookmarks and network mounts. Among other things, this should fix the duplicate home or duplicate desktop bookmark problem (as now we don't bookmarks that are same as the default locations), and it makes us future proof for the removal of PlacesManager in core shell. --- extensions/places-menu/Makefile.am | 2 + extensions/places-menu/extension.js | 158 +++++-------- extensions/places-menu/placeDisplay.js | 304 +++++++++++++++++++++++++ 3 files changed, 357 insertions(+), 107 deletions(-) create mode 100644 extensions/places-menu/placeDisplay.js diff --git a/extensions/places-menu/Makefile.am b/extensions/places-menu/Makefile.am index 19b537f0..7096386e 100644 --- a/extensions/places-menu/Makefile.am +++ b/extensions/places-menu/Makefile.am @@ -1,3 +1,5 @@ EXTENSION_ID = places-menu +EXTRA_MODULES = placeDisplay.js + include ../../extension.mk diff --git a/extensions/places-menu/extension.js b/extensions/places-menu/extension.js index 5121381a..27a0d2fe 100644 --- a/extensions/places-menu/extension.js +++ b/extensions/places-menu/extension.js @@ -10,46 +10,43 @@ const Main = imports.ui.main; const PanelMenu = imports.ui.panelMenu; const PopupMenu = imports.ui.popupMenu; const Panel = imports.ui.panel; -const PlaceDisplay = imports.ui.placeDisplay; const Gettext = imports.gettext.domain('gnome-shell-extensions'); const _ = Gettext.gettext; +const N_ = function(x) { return x; } const ExtensionUtils = imports.misc.extensionUtils; const Me = ExtensionUtils.getCurrentExtension(); const Convenience = Me.imports.convenience; +const PlaceDisplay = Me.imports.placeDisplay; const PLACE_ICON_SIZE = 16; -function iconForPlace(place) { - let split = place.id.split(':'); - let kind = split.shift(); - let uri = split.join(':'); +const PlaceMenuItem = new Lang.Class({ + Name: 'PlaceMenuItem', + Extends: PopupMenu.PopupMenuItem, - let gicon = new Gio.ThemedIcon({ name: 'folder-symbolic' }); - switch(kind) { - case 'special': - switch(uri) { - case 'home': - gicon = new Gio.ThemedIcon({ name: 'user-home-symbolic' }); - break; - case 'desktop': - // FIXME: There is no user-desktop-symbolic - gicon = new Gio.ThemedIcon({ name: 'folder-symbolic' }); - break; - } - break; - case 'bookmark': - let info = Gio.File.new_for_uri(uri).query_info('standard::symbolic-icon', 0, null); - gicon = info.get_symbolic_icon(info); - break; - case 'mount': - gicon = place._mount.get_symbolic_icon(); - break; - } + _init: function(info) { + this.parent(info.name); + this._info = info; - return new St.Icon({ gicon: gicon, - icon_size: PLACE_ICON_SIZE }); + this.addActor(new St.Icon({ gicon: info.icon, + icon_size: PLACE_ICON_SIZE }), + { align: St.Align.END, span: -1 }); + }, + + activate: function(event) { + this._info.launch(event.get_time()); + + this.parent(event); + }, +}); + +const SECTIONS = { + 'special': N_("Places"), + 'devices': N_("Devices"), + 'bookmarks': N_("Bookmarks"), + 'network': N_("Network") } const PlacesMenu = new Lang.Class({ @@ -60,98 +57,45 @@ const PlacesMenu = new Lang.Class({ this.parent('folder-symbolic'); this.placesManager = new PlaceDisplay.PlacesManager(); - this.defaultItems = []; - this.bookmarkItems = []; - this.deviceItems = []; - this._createDefaultPlaces(); - this._bookmarksSection = new PopupMenu.PopupMenuSection(); - this.menu.addMenuItem(this._bookmarksSection); - this._createBookmarks(); - this._devicesMenuItem = new PopupMenu.PopupSubMenuMenuItem(_("Removable Devices")); - this.menu.addMenuItem(this._devicesMenuItem); - this._createDevices(); + this._sections = { }; - this._bookmarksId = this.placesManager.connect('bookmarks-updated',Lang.bind(this,this._redisplayBookmarks)); - this._mountsId = this.placesManager.connect('mounts-updated',Lang.bind(this,this._redisplayDevices)); + for (let foo in SECTIONS) { + let id = foo; // stupid JS closure semantics... + this._sections[id] = { section: new PopupMenu.PopupMenuSection(), + title: Gettext.gettext(SECTIONS[id]) }; + this.placesManager.connect(id + '-updated', Lang.bind(this, function() { + this._redisplay(id); + })); + + this._create(id); + this.menu.addMenuItem(this._sections[id].section); + } }, destroy: function() { - this.placesManager.disconnect(this._bookmarksId); - this.placesManager.disconnect(this._mountsId); + this.placesManager.destroy(); this.parent(); }, - _redisplayBookmarks: function(){ - this._clearBookmarks(); - this._createBookmarks(); + _redisplay: function(id) { + this._sections[id].section.removeAll(); + this._create(id); }, - _redisplayDevices: function(){ - this._clearDevices(); - this._createDevices(); - }, + _create: function(id) { + let title = new PopupMenu.PopupMenuItem(this._sections[id].title, + { reactive: false, + style_class: 'popup-subtitle-menu-item' }); + this._sections[id].section.addMenuItem(title); - _createDefaultPlaces : function() { - this.defaultPlaces = this.placesManager.getDefaultPlaces(); + let places = this.placesManager.get(id); - for (let placeid = 0; placeid < this.defaultPlaces.length; placeid++) { - this.defaultItems[placeid] = new PopupMenu.PopupMenuItem(this.defaultPlaces[placeid].name); - let icon = iconForPlace(this.defaultPlaces[placeid]); - this.defaultItems[placeid].addActor(icon, { align: St.Align.END, span: -1 }); - this.defaultItems[placeid].place = this.defaultPlaces[placeid]; - this.menu.addMenuItem(this.defaultItems[placeid]); - this.defaultItems[placeid].connect('activate', function(actor,event) { - actor.place.launch(); - }); + for (let i = 0; i < places.length; i++) + this._sections[id].section.addMenuItem(new PlaceMenuItem(places[i])); - } - }, - - _createBookmarks : function() { - this.bookmarks = this.placesManager.getBookmarks(); - - for (let bookmarkid = 0; bookmarkid < this.bookmarks.length; bookmarkid++) { - this.bookmarkItems[bookmarkid] = new PopupMenu.PopupMenuItem(this.bookmarks[bookmarkid].name); - let icon = iconForPlace(this.bookmarks[bookmarkid]); - this.bookmarkItems[bookmarkid].addActor(icon, { align: St.Align.END, span: -1 }); - this.bookmarkItems[bookmarkid].place = this.bookmarks[bookmarkid]; - this._bookmarksSection.addMenuItem(this.bookmarkItems[bookmarkid]); - this.bookmarkItems[bookmarkid].connect('activate', function(actor,event) { - actor.place.launch(); - }); - } - }, - - _createDevices : function() { - this.devices = this.placesManager.getMounts(); - - for (let devid = 0; devid < this.devices.length; devid++) { - this.deviceItems[devid] = new PopupMenu.PopupMenuItem(this.devices[devid].name); - let icon = iconForPlace(this.devices[devid]); - this.deviceItems[devid].addActor(icon, { align: St.Align.END, span: -1 }); - this.deviceItems[devid].place = this.devices[devid]; - this._devicesMenuItem.menu.addMenuItem(this.deviceItems[devid]); - this.deviceItems[devid].connect('activate', function(actor,event) { - actor.place.launch(); - }); - } - - if (this.devices.length == 0) - this._devicesMenuItem.actor.hide(); - else - this._devicesMenuItem.actor.show(); - }, - - _clearBookmarks : function(){ - this._bookmarksSection.removeAll(); - this.bookmarkItems = []; - }, - - _clearDevices : function(){ - this._devicesMenuItem.menu.removeAll(); - this.deviceItems = []; - }, + this._sections[id].section.actor.visible = places.length > 0; + } }); function init() { diff --git a/extensions/places-menu/placeDisplay.js b/extensions/places-menu/placeDisplay.js new file mode 100644 index 00000000..8984d068 --- /dev/null +++ b/extensions/places-menu/placeDisplay.js @@ -0,0 +1,304 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const GLib = imports.gi.GLib; +const Gio = imports.gi.Gio; +const Shell = imports.gi.Shell; +const Lang = imports.lang; +const Mainloop = imports.mainloop; +const Signals = imports.signals; +const St = imports.gi.St; + +const DND = imports.ui.dnd; +const Main = imports.ui.main; +const Params = imports.misc.params; +const Search = imports.ui.search; +const Util = imports.misc.util; + +const Gettext = imports.gettext.domain('gnome-shell-extensions'); +const _ = Gettext.gettext; +const N_ = function(x) { return x; } + +const PlaceInfo = new Lang.Class({ + Name: 'PlaceInfo', + + _init: function(kind, file, name, icon) { + this.kind = kind; + this.file = file; + this.name = name || this._getFileName(); + this.icon = icon ? new Gio.ThemedIcon({ name: icon }) : this.getIcon(); + }, + + isRemovable: function() { + return false; + }, + + launch: function(timestamp) { + let launchContext = global.create_app_launch_context(); + launchContext.set_timestamp(timestamp); + + try { + Gio.AppInfo.launch_default_for_uri(this.file.get_uri(), + launchContext); + } catch(e if e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_MOUNTED)) { + this.file.mount_enclosing_volume(0, null, null, function(file, result) { + file.mount_enclosing_volume_finish(result); + Gio.AppInfo.launch_default_for_uri(file.get_uri(), launchContext); + }); + } catch(e) { + Main.notifyError(_("Failed to launch \"%s\"").format(this.name), e.message); + } + }, + + getIcon: function() { + try { + let info = this.file.query_info('standard::symbolic-icon', 0, null); + return info.get_symbolic_icon(); + } catch(e if e instanceof Gio.IOErrorEnum) { + // return a generic icon for this kind + switch (this.kind) { + case 'network': + return new Gio.ThemedIcon({ name: 'folder-remote-symbolic' }); + case 'devices': + return new Gio.ThemedIcon({ name: 'drive-harddisk-symbolic' }); + case 'special': + case 'bookmarks': + default: + if (!this.file.is_native()) + return new Gio.ThemedIcon({ name: 'folder-remote-symbolic' }); + else + return new Gio.ThemedIcon({ name: 'folder-symbolic' }); + } + } + }, + + _getFileName: function() { + try { + let info = this.file.query_info('standard::display-name', 0, null); + return info.get_display_name(); + } catch(e if e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_SUPPORTED)) { + return this.file.get_basename(); + } + }, +}); + +const PlaceDeviceInfo = new Lang.Class({ + Name: 'PlaceDeviceInfo', + Extends: PlaceInfo, + + _init: function(kind, mount) { + this._mount = mount; + this.parent(kind, mount.get_root(), mount.get_name()); + }, + + getIcon: function() { + return this._mount.get_symbolic_icon(); + } +}); + +const DEFAULT_DIRECTORIES = [ + GLib.UserDirectory.DIRECTORY_DOCUMENTS, + GLib.UserDirectory.DIRECTORY_PICTURES, + GLib.UserDirectory.DIRECTORY_MUSIC, + GLib.UserDirectory.DIRECTORY_DOWNLOAD, + GLib.UserDirectory.DIRECTORY_VIDEOS, +]; + +const PlacesManager = new Lang.Class({ + Name: 'PlacesManager', + + _init: function() { + this._places = { + special: [], + devices: [], + bookmarks: [], + network: [], + }; + + let homePath = GLib.get_home_dir(); + + this._places.special.push(new PlaceInfo('special', + Gio.File.new_for_path(homePath), + _("Home"))); + for (let i = 0; i < DEFAULT_DIRECTORIES.length; i++) { + let specialPath = GLib.get_user_special_dir(DEFAULT_DIRECTORIES[i]); + if (specialPath == homePath) + continue; + this._places.special.push(new PlaceInfo('special', + Gio.File.new_for_path(specialPath))); + } + + /* + * Show devices, code more or less ported from nautilus-places-sidebar.c + */ + this._volumeMonitor = Gio.VolumeMonitor.get(); + this._connectVolumeMonitorSignals(); + this._updateMounts(); + + this._bookmarksPath = GLib.build_filenamev([GLib.get_user_config_dir(), 'gtk-3.0', 'bookmarks']); + this._bookmarksFile = Gio.file_new_for_path(this._bookmarksPath); + this._monitor = this._bookmarksFile.monitor_file(Gio.FileMonitorFlags.NONE, null); + this._bookmarkTimeoutId = 0; + this._monitor.connect('changed', Lang.bind(this, function () { + if (this._bookmarkTimeoutId > 0) + return; + /* Defensive event compression */ + this._bookmarkTimeoutId = Mainloop.timeout_add(100, Lang.bind(this, function () { + this._bookmarkTimeoutId = 0; + this._reloadBookmarks(); + return false; + })); + })); + + this._reloadBookmarks(); + }, + + _connectVolumeMonitorSignals: function() { + const signals = ['volume-added', 'volume-removed', 'volume-changed', + 'mount-added', 'mount-removed', 'mount-changed', + 'drive-connected', 'drive-disconnected', 'drive-changed']; + + this._volumeMonitorSignals = []; + let func = Lang.bind(this, this._updateMounts); + for (let i = 0; i < signals.length; i++) { + let id = this._volumeMonitor.connect(signals[i], func); + this._volumeMonitorSignals.push(id); + } + }, + + destroy: function() { + for (let i = 0; i < this._volumeMonitorSignals.length; i++) + this._volumeMonitor.disconnect(this._volumeMonitorSignals[i]); + + this._monitor.cancel(); + if (this._bookmarkTimeoutId) + Mainloop.source_remove(this._bookmarkTimeoutId); + }, + + _updateMounts: function() { + this._places.devices = []; + this._places.network = []; + + /* Add standard places */ + this._places.devices.push(new PlaceInfo('devices', + Gio.File.new_for_path('/'), + _("File System"), + 'drive-harddisk-symbolic')); + this._places.network.push(new PlaceInfo('network', + Gio.File.new_for_uri('network:///'), + _("Browse network"), + 'network-workgroup-symbolic')); + + /* first go through all connected drives */ + let drives = this._volumeMonitor.get_connected_drives(); + for (let i = 0; i < drives.length; i++) { + let volumes = drives[i].get_volumes(); + + for(let j = 0; j < volumes.length; j++) { + let mount = volumes[j].get_mount(); + let kind = 'devices'; + if (volumes[j].get_identifier('class').indexOf('network') >= 0) + kind = 'network'; + + if(mount != null) + this._addMount(kind, mount); + } + } + + /* add all volumes that is not associated with a drive */ + let volumes = this._volumeMonitor.get_volumes(); + for(let i = 0; i < volumes.length; i++) { + if(volumes[i].get_drive() != null) + continue; + + let kind = 'devices'; + if (volumes.get_identifier('class').indexOf('network') >= 0) + kind = 'network'; + + let mount = volumes[i].get_mount(); + if(mount != null) + this._addMount(kind, mount); + } + + /* add mounts that have no volume (/etc/mtab mounts, ftp, sftp,...) */ + let mounts = this._volumeMonitor.get_mounts(); + for(let i = 0; i < mounts.length; i++) { + if(mounts[i].is_shadowed()) + continue; + + if(mounts[i].get_volume()) + continue; + + let root = mounts[i].get_default_location(); + let kind; + if (root.is_native()) + kind = 'devices'; + else + kind = 'network'; + + this._addMount(kind, mounts[i]); + } + + this.emit('devices-updated'); + this.emit('network-updated'); + }, + + _reloadBookmarks: function() { + + this._bookmarks = []; + + if (!GLib.file_test(this._bookmarksPath, GLib.FileTest.EXISTS)) + return; + + let content = Shell.get_file_contents_utf8_sync(this._bookmarksPath); + let lines = content.split('\n'); + + let bookmarks = []; + for (let i = 0; i < lines.length; i++) { + let line = lines[i]; + let components = line.split(' '); + let bookmark = components[0]; + + if (!bookmark) + continue; + + let file = Gio.File.new_for_uri(bookmark); + let duplicate = false; + for (let i = 0; i < this._places.special.length; i++) { + if (file.equal(this._places.special[i].file)) { + duplicate = true; + break; + } + } + if (duplicate) + continue; + for (let i = 0; i < bookmarks.length; i++) { + if (file.equal(bookmarks[i].file)) { + duplicate = true; + break; + } + } + if (duplicate) + continue; + + let label = null; + if (components.length > 1) + label = components.slice(1).join(' '); + + bookmarks.push(new PlaceInfo('bookmarks', file, label)); + } + + this._places.bookmarks = bookmarks; + + this.emit('bookmarks-updated'); + }, + + _addMount: function(kind, mount) { + let devItem = new PlaceDeviceInfo(kind, mount); + this._places[kind].push(devItem); + }, + + get: function (kind) { + return this._places[kind]; + } +}); +Signals.addSignalMethods(PlacesManager.prototype);