/** * Vertical Workspaces * recentFilesSearchProvider.js * * @author GdH * @copyright 2022 - 2024 * @license GPL-3.0 */ 'use strict'; import GLib from 'gi://GLib'; import St from 'gi://St'; import Gio from 'gi://Gio'; import * as Main from 'resource:///org/gnome/shell/ui/main.js'; let Me; let opt; // gettext let _; // prefix helps to eliminate results from other search providers // so it needs to be something less common // needs to be accessible from vw module export const PREFIX = 'fq//'; const ID = 'recent-files'; export const RecentFilesSearchProviderModule = class { // export for other modules static _PREFIX = PREFIX; constructor(me) { Me = me; opt = Me.opt; _ = Me.gettext; this._firstActivation = true; this.moduleEnabled = false; this._recentFilesSearchProvider = null; this._enableTimeoutId = 0; } cleanGlobals() { Me = null; opt = null; _ = null; } update(reset) { this.moduleEnabled = opt.get('recentFilesSearchProviderModule'); reset = reset || !this.moduleEnabled; if (reset && !this._firstActivation) { this._disableModule(); } else if (!reset) { this._firstActivation = false; this._activateModule(); } if (reset && this._firstActivation) console.debug(' RecentFilesSearchProviderModule - Keeping untouched'); } _activateModule() { // delay because Fedora had problem to register a new provider soon after Shell restarts this._enableTimeoutId = GLib.timeout_add( GLib.PRIORITY_DEFAULT, 2000, () => { if (!this._recentFilesSearchProvider) { this._recentFilesSearchProvider = new RecentFilesSearchProvider(); this._registerProvider(this._recentFilesSearchProvider); } this._enableTimeoutId = 0; return GLib.SOURCE_REMOVE; } ); console.debug(' RecentFilesSearchProviderModule - Activated'); } _disableModule() { if (this._recentFilesSearchProvider) { this._unregisterProvider(this._recentFilesSearchProvider); this._recentFilesSearchProvider = null; } if (this._enableTimeoutId) { GLib.source_remove(this._enableTimeoutId); this._enableTimeoutId = 0; } console.debug(' RecentFilesSearchProviderModule - Disabled'); } _registerProvider(provider) { const searchResults = Main.overview.searchController._searchResults; provider.searchInProgress = false; searchResults._providers.push(provider); // create results display and add it to the _content searchResults._ensureProviderDisplay.bind(searchResults)(provider); } _unregisterProvider(provider) { const searchResults = Main.overview.searchController._searchResults; searchResults._unregisterProvider(provider); } }; class RecentFilesSearchProvider { constructor() { this.id = ID; const appId = 'org.gnome.Nautilus.desktop'; // A real appInfo created from a commandline has often issues with overriding get_id() method, so we use dict instead this.appInfo = { get_id: () => appId, get_name: () => _('Recent Files'), get_icon: () => Gio.icon_new_for_string('focus-windows-symbolic'), should_show: () => true, get_commandline: () => '/usr/bin/nautilus -w recent:///', launch: () => {}, }; this.canLaunchSearch = true; this.isRemoteProvider = false; this._recentFilesManager = new RecentFilesManager(); } getInitialResultSet(terms/* , cancellable*/) { const rfm = this._recentFilesManager; rfm.loadFromFile(); const uris = rfm.getUris(); const dict = {}; for (let uri of uris) { dict[uri] = {}; dict[uri]['uri'] = uri; dict[uri]['path'] = rfm.getPath(uri); dict[uri]['filename'] = rfm.getDisplayName(uri); dict[uri]['dir'] = rfm.getDirPath(uri); dict[uri]['age'] = rfm.getAge(uri); dict[uri]['appInfo'] = rfm.getDefaultAppAppInfo(uri); } this.files = dict; return new Promise(resolve => resolve(this._getResultSet(terms))); } _getResultSet(terms) { if (!terms[0].startsWith(PREFIX)) return []; // do not modify original terms let termsCopy = [...terms]; // search for terms without prefix termsCopy[0] = termsCopy[0].replace(PREFIX, ''); const candidates = Object.values(this.files); const _terms = [].concat(termsCopy); const term = _terms.join(' '); const results = []; let m; for (let file of candidates) { if (opt.SEARCH_FUZZY) m = Me.Util.fuzzyMatch(term, file.filename); else m = Me.Util.strictMatch(term, file.filename); if (m !== -1) results.push(file); } results.sort((a, b) => a.age > b.age); const resultIds = results.map(item => item.uri); return resultIds; } getResultMetas(resultIds/* , callback = null*/) { const metas = resultIds.map(id => this.getResultMeta(id)); return new Promise(resolve => resolve(metas)); } getResultMeta(resultId) { const result = this.files[resultId]; return { 'id': resultId, 'name': `${Math.floor(result.age)}: ${result.filename}`, 'description': `${result.dir}`, 'createIcon': size => this._recentFilesManager.getDefaultAppIcon(resultId, size), }; } launchSearch(terms, timeStamp) { const appInfo = Gio.AppInfo.create_from_commandline('/usr/bin/nautilus -w recent:///', 'Nautilus', null); appInfo.launch([], global.create_app_launch_context(timeStamp, -1)); // unlike on 42, on 44 if a window with the same uri is already open it will not get focus/activation // Gio.app_info_launch_default_for_uri('recent:///', global.create_app_launch_context(timeStamp, -1)); // following solution for some reason ignores the recent:/// uri // this.appInfo.launch_uris(['recent:///'], global.create_app_launch_context(timeStamp, -1)); } activateResult(resultId, terms, timeStamp) { const uri = resultId; const context = global.create_app_launch_context(timeStamp, -1); if (Me.Util.isShiftPressed()) { Main.overview.toggle(); this.appInfo.launch_uris([uri], context); } else if (Gio.app_info_launch_default_for_uri(uri, context)) { // update recent list after successful activation this._recentFilesManager.updateAdded(resultId); this._recentFilesManager.saveToFile(); } else { this.appInfo.launch_uris([uri], context); } } filterResults(results /* , maxResults*/) { // return results.slice(0, maxResults); return results.slice(0, 20); } getSubsearchResultSet(previousResults, terms/* , cancellable*/) { return this.getInitialResultSet(terms); } } class RecentFilesManager { constructor(path) { path = path ?? GLib.build_filenamev([GLib.get_user_data_dir(), 'recently-used.xbel']); this._recentlyUsedPath = path; this._bookmarks = new GLib.BookmarkFile(); } loadFromFile() { try { this._bookmarks.load_from_file(this._recentlyUsedPath); } catch (e) { if (!e.matches(GLib.BookmarkFileError, GLib.BookmarkFileError.FILE_NOT_FOUND)) console.error(`Could not open recent files: ${e.message}`); } } saveToFile() { try { this._bookmarks.to_file(this._recentlyUsedPath); } catch (e) { if (!e.matches(GLib.BookmarkFileError, GLib.BookmarkFileError.FILE_NOT_FOUND)) console.error(`Could not open recent files to save data: ${e.message}`); } } getUris() { return this._bookmarks.get_uris(); } getPath(uri) { // GLib.filename_from_uri() removes uri schema and converts string to utf-8 return GLib.filename_from_uri(uri)[0]; // result is array } getDisplayName(uri) { const path = this.getPath(uri); return GLib.filename_display_basename(path); } getDirPath(uri) { const path = this.getPath(uri); const filename = this.getDisplayName(uri); return path.replace(`${filename}`, ''); } getMimeType(uri) { return this._bookmarks.get_mime_type(uri); } getAdded(uri) { return this._bookmarks.get_added(uri); } updateAdded(uri) { this._bookmarks.set_added_date_time(uri, GLib.DateTime.new_now_local()); } // age in days (float) getAge(uri) { return (Date.now() / 1000 - this._bookmarks.get_added(uri)) / 60 / 60 / 24; } getDefaultAppAppInfo(uri) { const mimeType = this.getMimeType(uri); return Gio.AppInfo.get_default_for_type(mimeType, false); } getDefaultAppIcon(uri, size) { let icon, gicon; const appInfo = this.getDefaultAppAppInfo(uri); if (appInfo) gicon = appInfo.get_icon(); if (gicon) icon = new St.Icon({ gicon, icon_size: size }); else icon = new St.Icon({ icon_name: 'icon-missing', icon_size: size }); return icon; } }