Is there an indicator to quickly access recently used files?

I am looking for a tray icon indicator that - upon click on the icon - shows me the list of my recently used files. This would be a great way to quickly access these files.


Have your recently used files in the panel

With the script below, you can have an arbitrary number of recently used items at hand in the panel, e.g. 6 items:

enter image description here

...or 20 items:

enter image description here

...depending on your settings.

How it works

The setup exists of two items:

  1. an icon, named (exactly) recent.png
  2. a script

Both need to be in one and the same folder. after that, simply run the script.

How to set up

Possibly, you need to install python3-gi:

sudo apt-get install python3-gi

Then:

  1. Copy the script below into an empty file, save it as recused.py

    #!/usr/bin/env python3
    import signal
    import gi
    gi.require_version('Gtk', '3.0')
    gi.require_version('AppIndicator3', '0.1')
    from gi.repository import Gtk, AppIndicator3, GObject
    import time
    from threading import Thread
    import os
    import subprocess
    
    # --- set the number of recently used files to appear below
    n = 20
    # ---
    
    home = os.environ["HOME"]
    recdata = os.path.join(home, ".local/share/recently-used.xbel")
    currpath = os.path.dirname(os.path.realpath(__file__))
    
    class Indicator():
        def __init__(self):
            self.app = 'show_recent'
            iconpath = os.path.join(currpath, "recent.png")
            self.indicator = AppIndicator3.Indicator.new(
                self.app, iconpath,
                AppIndicator3.IndicatorCategory.OTHER)
            self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)       
            self.indicator.set_menu(self.create_menu())
            # the thread:
            self.update = Thread(target=self.check_recent)
            # daemonize the thread to make the indicator stopable
            self.update.setDaemon(True)
            self.update.start()
    
        def get_files(self):
            # create the list of recently used files
            used = [l for l in open(recdata) if \
                    all([
                        '<bookmark href="file://' in l,
                        not "/tmp" in l,
                        "." in l,
                         ])]
            relevant = [l.split('="') for l in set(used)]
            relevant = [[it[1][7:-7], it[-2][:-10]] for it in relevant]
            relevant.sort(key=lambda x: x[1])
            return [item[0].replace("%20", " ") for item in relevant[::-1][:n]]
    
        def create_menu(self):
            # creates the (initial) menu
            self.menu = Gtk.Menu()
            # separator
            menu_sep = Gtk.SeparatorMenuItem()
            self.menu.append(menu_sep)
            # item_quit.show() 
            self.menu.show_all()
            return self.menu
    
        def open_file(self, *args):
            # opens the file with the default application
            index = self.menu.get_children().index(self.menu.get_active())
            selection = self.menu_items2[index]
            subprocess.Popen(["xdg-open", selection])
    
        def set_new(self):
            # update the list, appearing in the menu
            for i in self.menu.get_children():
                self.menu.remove(i)
            for file in self.menu_items2:
                sub = Gtk.MenuItem(file)
                self.menu.append(sub)
                sub.connect('activate', self.open_file)
            # separator
            menu_sep = Gtk.SeparatorMenuItem()
            self.menu.append(menu_sep)
            # quit
            item_quit = Gtk.MenuItem('Quit')
            item_quit.connect('activate', self.stop)
            self.menu.append(item_quit)
            self.menu.show_all()
    
        def check_recent(self):
            self.menu_items1 = []
            while True:
                time.sleep(3)
                self.menu_items2 = self.get_files()
                if self.menu_items2 != self.menu_items1:
                    GObject.idle_add(
                        self.set_new, 
                        priority=GObject.PRIORITY_DEFAULT
                        )
                self.menu_items1 = self.menu_items2
    
        def stop(self, source):
            Gtk.main_quit()
    
    Indicator()
    # this is where we call GObject.threads_init()
    GObject.threads_init()
    signal.signal(signal.SIGINT, signal.SIG_DFL)
    Gtk.main()
    
  2. In the head section of the script, set the number of items to show:

    # --- set the number of recently used files to appear below
    n = 20
    # ---
    
  3. In one and the same folder save the icon below as (exactly) recent.png

    enter image description here

    (right-click on it -> save as)

  4. Test- run the script by the command:

    python3 /path/to/recused.py
    
  5. If all works fine, add to Startup Applications: Dash > Startup Applications > Add. Add the command:

    /bin/bash -c "sleep 15 && python3 /path/to/recused.py"
    

Notes

  • This script was based on this previous answer, in case you are interested in an alternative, be it that that one was application-specific, and used a periodically updated .desktop file.
  • The script was tested on 16.04, but should work on earlier versions as well.
  • Due to a bug in the recently-used.xbelfile, sometimes a file is mentioned twice; once with, and once without extension. I solved that by filtering the latter out. The consequence is that files without extension won't appear in the list. If that is an issue, let me know, we can try to find another fix in that case.
  • If a file does not exist (anymore), the indicator simply (obviously) does not open the file. Most likely, I will polish the script a bit more to filter out those outdated entries from the recently-used.xbel file.

Explanation

Many applications, editing files and using a Gtk window, keep track of opened files in the file: ~/.local/share/recently-used.xbel. "Records" include the date & time, the application and the file that was opened.

The script reads the .xbel file, sorts the items by date/time, includes the first n- (depending on your settings) files in the menu of the indicator. The menu is updated (only if necessary) every 3 seconds as it is. Subsequently, when an item is selected, the file is opened with the command:

xdg-open <file>

Thus the selected file will be opened with the default application. It is very well possible to make sure the file is opened with the actual application it was last opened. That takes a somewhat more sophisticated parsing however. I will add that as an option to the planned ppa version on Launchpad.

The same goes for an option window to set a few options, like the number of files to show etc.

Note

The indicator is now merged with a few other things in this one.


Although you do not mention which Ubuntu flavour you are using, there is a nice app for Xubuntu. More specifically, it is part of Xfce, so you might still be able to use it in Ubuntu if you use Xfce. The app is called Places.

To enable, right-click on the panel, and from the menu select Panel -> Add New Items..., and there choose Places. This shows a list of folders and Recent Documents you just opened. See below:

enter image description here

You can configure it nicely too:

enter image description here

Here is the project's page.


If you are not using Xfce, you can install it very easily, as explained in this answer. Simply do sudo apt-get install xfce-panel, and then run (see link). Notice that Xfce panel might not be compatible with all the other apps in your Unity (or other) panel. In my experience I have had no problem at all with it, so it might still be a good solution.


Not a tray icon but a quicklist script for the unity launcher (works with Umlauts), and you can pin files as well: Ubuntu-Recentquicklists

URQ screenshot

[detailed install instructions here]

Quick instructions:

  • download .zip
  • extract
  • make install.sh and ubuntu-recentquicklists.py executable
  • run install.sh script

Features:

  • automatically adds recent files
  • manually add non-recent files / pin recent files
  • specify how many items per list you want and
  • how old the items may be
  • resolve symbolic links
  • display the full path (or just the filename, as in the screenshot)

Update Feb/24/2017: the indicator now has option to pin web links.

Introduction

Files Indicator presented below is simple indicator for accessing user's files and folders. It allows checking recently used files, bookmarking files and directories.

enter image description here

Update: indicator now also supports launching .desktop files that have been pinned. For instance if you have firefox.desktop pinned, it will launch firefox. Thus, the indicator can be used as quick launcher for programs. The feature is on the way into PPA at the time of writing (Nov 19, 7:53 pm GMT, should take about 24 hours to process), but is already on github as well as here, in updated source code.

enter image description here

Obtaining the indicator

The indicator is available from my personal PPA as well as GitHub. Use the following steps to obtain it:

sudo add-apt-repository ppa:1047481448-2/sergkolo
sudo apt-get update
sudo apt-get install files-indicator

Source code

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

#
# Author: Serg Kolo , contact: [email protected]
# Date: November 19 , 2016
# Purpose: appindicator for accessing files and folders
# Tested on: Ubuntu 16.04 LTS
#
#
# Licensed under The MIT License (MIT).
# See included LICENSE file or the notice below.
#
# Copyright © 2016 Sergiy Kolodyazhnyy
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included
# in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import gi
gi.require_version('AppIndicator3', '0.1')
gi.require_version('Notify', '0.7')
from gi.repository import GLib as glib
from gi.repository import AppIndicator3 as appindicator
from gi.repository import Gtk as gtk
from gi.repository import Gio
from gi.repository import Notify
from collections import OrderedDict
# from collections import OrderedDict
import urllib.parse
import subprocess
import copy
import shutil
import dbus
import math
import json
import os

class FilesIndicator(object):

    def __init__(self):
        self.app = appindicator.Indicator.new(
            'files-indicator', "document-open-recent",
            appindicator.IndicatorCategory.HARDWARE
        )
        self.user_home = os.path.expanduser('~')
        filename = '.pinned_files.json'
        self.pinned_list = os.path.join(self.user_home,filename)

        self.config = os.path.join(self.user_home,'.files_indicator.json')
        self.max_items = 15
        self.name_length = 20
        self.read_config()

        self.app.set_status(appindicator.IndicatorStatus.ACTIVE)

        self.cached_files = self.get_recent_files()
        self.make_menu()
        self.update()

    def read_config(self,*args):
        config = {}
        try:
            with open(self.config) as f:
                 config = json.load(f)

        except FileNotFoundError:
            print('>>> ',self.config,' not found.Creating one')
            f = open(self.config,'w')
            config = {'max_items':self.max_items,
                      'name_length':self.name_length
            }
            json.dump(config,f,indent=4)
            f.close()
        except json.JSONDecodeError:
            print(">>> Can't read ",self.pinned_list,',may be corrupt')
            return None
        else:
            self.max_items = config['max_items']
            self.name_length = config['name_length']

    def add_menu_item(self, menu_obj, item_type, image, label, action, args):
        """ dynamic function that can add menu items depending on
            the item type and other arguments"""
        menu_item, icon = None, None
        if item_type is gtk.ImageMenuItem and label:
            menu_item = gtk.ImageMenuItem.new_with_label(label)
            menu_item.set_always_show_image(True)
            if '/' in image:
                icon = gtk.Image.new_from_file(image)
            else:
                icon = gtk.Image.new_from_icon_name(image, 48)
            menu_item.set_image(icon)
        elif item_type is gtk.ImageMenuItem and not label:
            menu_item = gtk.ImageMenuItem()
            menu_item.set_always_show_image(True)
            if '/' in image:
                icon = gtk.Image.new_from_file(image)
            else:
                icon = gtk.Image.new_from_icon_name(image, 16)
            menu_item.set_image(icon)
        elif item_type is gtk.MenuItem:
            menu_item = gtk.MenuItem(label)
        elif item_type is gtk.SeparatorMenuItem:
            menu_item = gtk.SeparatorMenuItem()
        if action:
            menu_item.connect('activate', action, *args)

        menu_obj.append(menu_item)
        menu_item.show()

    def get_user_dirs(self,*args):
        user_dirs = []
        for index,val in glib.UserDirectory.__enum_values__.items():
            if index == 8: continue
            dir = glib.get_user_special_dir(index)
            if dir: user_dirs.append(dir)
        return user_dirs

    def get_file_icon(self,*args):
        if args[-1].endswith('.desktop'):
            desk_file = Gio.DesktopAppInfo.new_from_filename(args[-1])
            icon = desk_file.get_icon()
            if type(icon) == Gio.ThemedIcon:
                themed_name = icon.get_names()[0]
                theme = gtk.IconTheme.get_default()
                name = theme.lookup_icon(themed_name, 48, 0).get_filename()
            if type(icon) == Gio.FileIcon:
                name = icon.get_file().get_uri()

            icon_url= urllib.parse.unquote(name).replace('file://','') 
            return icon_url

        file = Gio.File.new_for_path(args[-1])
        file_info = file.query_info("standard::*",0)
        icon_string = file_info.get_icon().to_string()
        if 'folder-' in icon_string:
            return icon_string.split()[-2]
        return icon_string.split()[-1]

    def get_recent_files(self,*args):
        manager = gtk.RecentManager.get_default()
        try:
            files = OrderedDict()
            for index,item in enumerate(manager.get_items(),1):
                    uri = item.get_uri()
                    uri_decoded = urllib.parse.unquote(uri)
                    filepath = uri_decoded.replace('file://','')
                    if not os.path.exists(filepath): continue
                    basename = os.path.basename(uri_decoded)
                    files[basename] = filepath
                    if index == self.max_items:
                        break
        except Exception as e:
            print(e)
            return None
        finally:  return files

    def callback(self,*args):
        self.update()

    def update(self,*args):
        current_files = self.get_recent_files()
        if current_files != self.cached_files:
             self.make_menu()
             self.cached_files = current_files
        glib.timeout_add_seconds(3,self.callback)

    def add_submenu(self,top_menu,label):
        menuitem = gtk.MenuItem(label)
        submenu = gtk.Menu()
        menuitem.set_submenu(submenu)
        top_menu.append(menuitem)
        menuitem.show()
        return submenu

    def make_menu(self):
        if hasattr(self, 'app_menu'):
            for item in self.app_menu.get_children():
                    self.app_menu.remove(item)
        else:
            self.app_menu = gtk.Menu()
        recent = self.add_submenu(self.app_menu,'Recent Files')
        recent_dict = self.get_recent_files()

        content = [recent,gtk.ImageMenuItem,'gtk-add',
                   'Add to Recent Files',self.add_recent,[None]
        ]      
        self.add_menu_item(*content) 

        content = [recent,gtk.ImageMenuItem,'user-trash',
                   'Clear recent files list',self.clear_recent,[None]
        ]      
        self.add_menu_item(*content)

        content = [recent,gtk.SeparatorMenuItem,
                   None,None,
                   None,[None]
        ]
        self.add_menu_item(*content)
        self.add_menu_item(*content) 
        if not recent_dict:
            content = [recent,gtk.MenuItem,None,
                       'No items',None,None
            ]
            self.add_menu_item(*content)
            last = None
            for i in recent.get_children():
                last = i
            last.set_sensitive(False)
        else:
            for name,data in recent_dict.items():
                icon = self.get_file_icon(data)
                content = [recent, gtk.ImageMenuItem,
                           icon, name[:self.name_length],
                           self.open_item, [data]
                ]
                self.add_menu_item(*content)

        # Pinned files
        bookmarks = self.add_submenu(self.app_menu,'Pinned Files')
        content = [bookmarks,gtk.ImageMenuItem,
                   'bookmark_add','Pin a file',
                   self.pin_file,[bookmarks,None]
        ]
        self.add_menu_item(*content)

        content = [bookmarks,gtk.ImageMenuItem,
                   'remove','Remove item',
                   self.remove_pinned,['files']
        ]
        self.add_menu_item(*content)

        content = [bookmarks,gtk.ImageMenuItem,
                   'user-trash','Remove All',
                   self.remove_all_pinned,[None]
        ]
        self.add_menu_item(*content)
        content = [bookmarks,gtk.SeparatorMenuItem,
                   None,None,
                   None,[None]
        ]
        self.add_menu_item(*content)
        self.add_menu_item(*content) 

        pinned_files = self.get_pinned()

        if (pinned_files and 
            'files' in pinned_files.keys() and
            pinned_files['files']):
            for filepath in pinned_files['files']:
                icon = self.get_file_icon(filepath) 
                content = [bookmarks,gtk.ImageMenuItem,
                           icon,os.path.basename(filepath),
                           self.open_item,[filepath]
                ]
                self.add_menu_item(*content)
        else:
            content = [bookmarks,gtk.MenuItem,None,
                       'No items',None,None
            ]
            self.add_menu_item(*content)
            last = None
            for i in bookmarks.get_children():
                last = i
            last.set_sensitive(False)

        places = self.add_submenu(self.app_menu,'Places')
        content = [places,gtk.ImageMenuItem,'add',
                   'Pin Directory',self.pin_dir,[None]
        ]

        self.add_menu_item(*content)

        content = [places,gtk.ImageMenuItem,
                   'remove','Remove Pinned',
                   self.remove_pinned,['dirs']
        ]
        self.add_menu_item(*content)

        content = [places,gtk.SeparatorMenuItem,
                   None,None,
                   None,[None]
        ]
        self.add_menu_item(*content)

        content = [places,gtk.MenuItem,None,
                   'Standard Dirs',None,None
        ]
        self.add_menu_item(*content)
        last = None
        for i in places.get_children():
            last = i
        last.set_sensitive(False)
        for dir in self.get_user_dirs():
            icon = self.get_file_icon(dir)
            content = [places,gtk.ImageMenuItem,icon,
                       os.path.basename(dir),self.open_item,[dir]

            ]
            self.add_menu_item(*content)

        content = [places,gtk.SeparatorMenuItem,
                   None,None,
                   None,[None]
        ]
        self.add_menu_item(*content)

        content = [places,gtk.MenuItem,None,
                   'Pinned Dirs',None,None
        ]
        self.add_menu_item(*content)
        last = None
        for i in places.get_children():
            last = i
        last.set_sensitive(False)

        if (pinned_files and 
           'dirs' in pinned_files.keys() and
           pinned_files['dirs']):
            for dir in pinned_files['dirs']:
                icon = self.get_file_icon(dir)
                print(icon)
                content = [places,gtk.ImageMenuItem,icon,
                           os.path.basename(dir),self.open_item,[dir]

                ]
                self.add_menu_item(*content)
        else:
            content = [places,gtk.MenuItem,None,
                       'No items',None,None
            ]
            self.add_menu_item(*content)
            last = None
            for i in places.get_children():
                last = i
            last.set_sensitive(False)

        content = [self.app_menu,gtk.SeparatorMenuItem,
                   None,None,
                   None,[None]
        ]
        self.add_menu_item(*content)
        content = [self.app_menu,gtk.ImageMenuItem,'exit',
                   'quit',self.quit,[None]
        ]
        self.add_menu_item(*content)
        self.app.set_menu(self.app_menu)

    def check_directory(self,*args):
        current_set = set(os.listdir(args[-1]))
        return current_set - self.cached_set


    def get_pinned(self,*args):
        try:
            with open(self.pinned_list) as f:
                 return json.load(f,object_pairs_hook=OrderedDict)

        except FileNotFoundError:
            print('>>> ',self.pinned_list,' not found')
            return None
        except json.JSONDecodeError:
            print(">>> Can't read ",self.pinned_list,',may be corrupt')
            return None

    def pin_dir(self,*args):
        # TODO
        current_list = self.get_pinned()
        if not current_list:
                current_list = OrderedDict()
                current_list['dirs'] = []
                f = open(self.pinned_list,'w')
                f.write("")
                f.close()

        if not args[-1]:
                cmd = "zenity --file-selection --directory --separator || --multiple"
                dirs = self.run_cmd(cmd.split())
        else:
                dirs = args[-1]

        dir_list = []
        if not dirs: return None
        dir_list = dirs.decode().strip().split("||")
        if not 'dirs' in current_list.keys():
             current_list['dirs'] = []
        for f in dir_list:
                #icon = self.get_file_icon(f)
                current_list['dirs'].append(f)

        with open(self.pinned_list,'w') as f:
                json.dump(current_list,f,indent=4)
        self.make_menu()

    def pin_file(self,*args):
        current_list = self.get_pinned()
        if not current_list:
                current_list = OrderedDict()
                current_list['files'] = []
                f = open(self.pinned_list,'w')
                f.write("")
                f.close()
        if not args[-1]:
                cmd = "zenity --file-selection --separator || --multiple "
                files = self.run_cmd(cmd.split())
        else:
                files = args[-1]

        file_list = []
        if not files: return None
        file_list = files.decode().strip().split("||")
        if not 'files' in current_list.keys():
            current_list['files'] = []
        for f in file_list:
                #icon = self.get_file_icon(f)
                current_list['files'].append(f)

        with open(self.pinned_list,'w') as f:
                json.dump(current_list,f,indent=4)
        self.make_menu()

    def remove_all_pinned(self,*args):
        try:
            #os.unlink(self.pinned_list)

            with open(self.pinned_list) as f:
                pinned = json.load(f)
            pinned.pop('files')       
            with open(self.pinned_list,'w') as f:
                    json.dump(pinned,f,indent=4)
        except:
            pass
        finally:
            self.make_menu()

    def remove_pinned(self,*args):
        key = args[-1]
        pinned = self.get_pinned() 
        if not pinned: return
        cmd_str = "zenity --forms --add-combo Remove --combo-values"
        vals = "|".join(pinned[key])
        cmd = cmd_str.split() + [vals]
        item = self.run_cmd(cmd)
        if item: 
            path = item.decode().strip()
            index = pinned[key].index(path)
            pinned[key].pop(index)
            with open(self.pinned_list,'w') as f:
                json.dump(pinned,f,indent=4)
            self.make_menu()

    def add_recent(self,*args):
        cmd = "zenity --file-selection --separator || --multiple "
        files = self.run_cmd(cmd.split())
        file_list = []
        if not files: return
        file_list = files.decode().strip().split("||")
        items = ['file://' + f for f in file_list]
        for f in items: gtk.RecentManager().get_default().add_item(f)

    def clear_recent(self,*args):
        try:
            gtk.RecentManager.get_default().purge_items()
            self.make_menu()
        except:
            pass

    def open_item(self,*args):
        #self.run_cmd(['xdg-open',args[-1]]) 
        if args[-1].endswith('.desktop'):
            desk_file = Gio.DesktopAppInfo.new_from_filename(args[-1])
            return desk_file.launch_uris()
        return subprocess.Popen(['xdg-open',args[-1]])

    def quit(self,*args):
        gtk.main_quit()

    def run_cmd(self, cmdlist):
        """ utility: reusable function for running external commands """
        #new_env = dict(os.environ)
        #new_env['LC_ALL'] = 'C'
        try:
            stdout = subprocess.check_output(cmdlist) #env=new_env)
        except subprocess.CalledProcessError:
            pass
        else:
            if stdout:
                return stdout

    def run(self):
        """ Launches the indicator """
        try:
            gtk.main()
        except KeyboardInterrupt:
            pass

    def quit(self, *args):
        """ closes indicator """
        gtk.main_quit()


def main():
    """ defines program entry point """
    indicator = FilesIndicator()
    indicator.run()

if __name__ == '__main__':
  try:
    main()
  except  KeyboardInterrupt:
    gtk.main_quit()

Configurations

The indicator is configured via two json files stored in user's home directory.

~/.files_indicator.json controls user interface , the length of menu entries and maximum numbers in the Recent Files Menu.

{
    "name_length": 30,
    "max_items": 10
}

The ~/.pinned_files.json controls the lists of pinned files and folders. Each item is a list/array.

{
    "dirs": [
        "/home/xieerqi/\u56fe\u7247/Wallpapers"
    ],
    "files": [
        "/home/xieerqi/work_in_progress/videonauth_code.py",
        "/home/xieerqi/work_in_progress/spin_button.py"
    ]
}