diff --git a/data/Application.css b/data/Application.css index 4020d41d60..710081404b 100644 --- a/data/Application.css +++ b/data/Application.css @@ -20,3 +20,21 @@ textview.scrubber { border: 0; } +.fuzzy-item { + padding: 5px; + margin-left: 10px; + margin-right: 10px; +} + +.fuzzy-item .fuzzy-file-icon { + margin-right: 5px; +} + +.fuzzy-item.preselect-fuzzy { + background-color: @selected_bg_color; + border-radius: 5px; +} + +.fuzzy-item.preselect-fuzzy label { + opacity: 0.7; +} \ No newline at end of file diff --git a/plugins/fuzzy-search/file-item.vala b/plugins/fuzzy-search/file-item.vala new file mode 100644 index 0000000000..13ad2a9cee --- /dev/null +++ b/plugins/fuzzy-search/file-item.vala @@ -0,0 +1,46 @@ +public class FileItem : Gtk.Box { + private SearchResult result; + + public string filepath { + get { + return result.full_path; + } + } + public FileItem (SearchResult res) { + result = res; + Icon icon; + var path_box = new Gtk.Box (Gtk.Orientation.VERTICAL, 1); + path_box.valign = Gtk.Align.CENTER; + + var path_label = new Gtk.Label (result.relative_path); + path_label.halign = Gtk.Align.START; + + var filename_label = new Gtk.Label (Path.get_basename (result.relative_path)); + filename_label.halign = Gtk.Align.START; + var attrs = new Pango.AttrList (); + attrs.insert (Pango.attr_weight_new (Pango.Weight.BOLD)); + filename_label.attributes = attrs; + + try { + var fi = File.new_for_path (result.full_path); + var info = fi.query_info ("standard::*", 0); + icon = ContentType.get_icon (info.get_content_type ()); + } catch (Error e) { + icon = ContentType.get_icon ("text/plain"); + } + + var image = new Gtk.Image.from_gicon (icon, Gtk.IconSize.DND); + image.get_style_context ().add_class ("fuzzy-file-icon"); + + path_box.add (filename_label); + path_box.add (path_label); + + add (image); + add (path_box); + } + + construct { + orientation = Gtk.Orientation.HORIZONTAL; + valign = Gtk.Align.CENTER; + } +} diff --git a/plugins/fuzzy-search/fuzzy-finder.vala b/plugins/fuzzy-search/fuzzy-finder.vala new file mode 100644 index 0000000000..52683be94c --- /dev/null +++ b/plugins/fuzzy-search/fuzzy-finder.vala @@ -0,0 +1,229 @@ +const int SEQUENTIAL_BONUS = 15; // bonus for adjacent matches +const int SEPARATOR_BONUS = 30; // bonus if match occurs after a separator +const int CAMEL_BONUS = 30; // bonus if match is uppercase and prev is lower +const int FIRST_LETTER_BONUS = 15; // bonus if the first letter is matched +const int LEADING_LETTER_PENALTY = -5; // penalty applied for every letter in str before the first match +const int MAX_LEADING_LETTER_PENALTY = -15; // maximum penalty for leading letters +const int UNMATCHED_LETTER_PENALTY = -1; + + +public class Scratch.Services.FuzzyFinder { + private class RecursiveFinder { + int recursion_limit; + int max_matches; + int recursion_count; + + public RecursiveFinder (int limit = 10, int mx_mtchs = 40) { + recursion_limit = limit; + max_matches = mx_mtchs; + recursion_count = 0; + } + + private bool limit_reached () { + return recursion_count >= recursion_limit; + } + + public SearchResult fuzzy_match_recursive (string pattern, string str) { + var matches = new Gee.ArrayList (); + return fuzzy_match_recursive_internal (pattern,str, 0, 0, 0, matches); + } + + private SearchResult fuzzy_match_recursive_internal (string pattern, string str, int pattern_current_index, int str_current_index, int next_match, + Gee.ArrayList matches, Gee.ArrayList? src_matches = null) { + var out_score = 0; + // Recursion params + bool recursive_match = false; + var best_recursive_matches = new Gee.ArrayList (); + var best_recursive_score = 0; + // Loop through pattern and str looking for a match. + bool firstMatch = true; + + recursion_count++; + if (limit_reached ()) { + return new SearchResult(false, out_score); + } + + // Return if we reached ends of strings. + if (pattern_current_index == pattern.length || str_current_index == str.length) { + return new SearchResult(false, out_score); + } + + while (pattern_current_index < pattern.length && str_current_index < str.length) { + var lowerCaseChar = pattern.get_char (pattern_current_index).tolower (); + var lowerCaseStrChar = str.get_char (str_current_index).tolower (); + + // Match found. + if (lowerCaseChar == lowerCaseStrChar) { + if (next_match >= max_matches) { + return new SearchResult (false, out_score); + } + + if (firstMatch && src_matches != null) { + matches.clear (); + matches.insert_all (0, src_matches); + firstMatch = false; + } + + var recursive_matches = new Gee.ArrayList (); + var recursive_result_search = fuzzy_match_recursive_internal ( + pattern, + str, + pattern_current_index, + str_current_index + 1, + next_match, + recursive_matches, + matches + ); + + if (recursive_result_search.found) { + // Pick best recursive score. + if (!recursive_match || recursive_result_search.score > best_recursive_score) { + best_recursive_matches.clear (); + best_recursive_matches.insert_all (0, recursive_matches); + best_recursive_score = recursive_result_search.score; + } + recursive_match = true; + } + + if (matches.size <= next_match) { + matches.add (str_current_index); + } else { + matches[next_match++] = str_current_index; + } + ++pattern_current_index; + } + ++str_current_index; + } + + var matched = pattern_current_index == pattern.length; + if (matched) { + out_score = 100; + + // Apply leading letter penalty + var penalty = LEADING_LETTER_PENALTY * matches[0]; + penalty = + penalty < MAX_LEADING_LETTER_PENALTY + ? MAX_LEADING_LETTER_PENALTY + : penalty; + out_score += penalty; + + //Apply unmatched penalty + var unmatched = str.length - next_match; + out_score += UNMATCHED_LETTER_PENALTY * unmatched; + + // Apply ordering bonuses + for (var i = 0; i < next_match; i++) { + var current_index = matches[i]; + + if (i > 0) { + var previous_index = matches[i - 1]; + + if (current_index == previous_index + 1) { + out_score += SEQUENTIAL_BONUS; + } + } + + // Check for bonuses based on neighbor character value. + if (current_index > 0) { + // Camel case + var neighbor = str[current_index - 1]; + var curr = str[current_index]; + if (neighbor != neighbor.toupper () && curr != curr.tolower ()) { + out_score += CAMEL_BONUS; + } + var is_neighbour_separator = neighbor == '_' || neighbor == ' '; + if (is_neighbour_separator) { + out_score += SEPARATOR_BONUS; + } + } else { + // First letter + out_score += FIRST_LETTER_BONUS; + } + } + + // Return best result + if (out_score <= 0) { + return new SearchResult (false, out_score); + } else if (recursive_match && (!matched || best_recursive_score > out_score)) { + // Recursive score is better than "this" + matches.insert_all (0, best_recursive_matches); + out_score = best_recursive_score; + return new SearchResult (true, out_score); + } else if (matched) { + // "this" score is better than recursive + return new SearchResult (true, out_score); + } else { + return new SearchResult (false, out_score); + } + } + return new SearchResult (false, out_score); + } + } + + int recursion_limit; + int max_matches; + Gee.HashMap project_paths; + + public FuzzyFinder(Gee.HashMap pps, int limit = 10, int mx_mtchs = 256) { + max_matches = mx_mtchs; + recursion_limit = limit; + project_paths = pps; + } + + public async Gee.ArrayList fuzzy_find_async (string search_str) { + var results = new Gee.ArrayList (); + + SourceFunc callback = fuzzy_find_async.callback; + new Thread("fuzzy-find", () => { + results = fuzzy_find(search_str); + Idle.add((owned) callback); + }); + + yield; + return results; + } + + public Gee.ArrayList fuzzy_find (string search_str) { + var results = new Gee.ArrayList (); + + foreach (var project in project_paths.values) { + foreach (var path in project.relative_file_paths) { + SearchResult search_result; + + // If there is more than one project prepend the project name + // to the front of the path + // This helps to search for specific files only in one project, e.g. + // "code/fuzfind" will probably only return fuzzy_finder.vala from this project + // even if their is a "fuzzy_finder" file in another project + if (project_paths.size > 1) { + var project_name= Path.get_basename (project.root_path); + search_result = fuzzy_match (search_str, @"$project_name/$path"); + } else { + search_result = fuzzy_match (search_str, path); + } + + if (search_result.found) { + var root_path = project.root_path; + search_result.relative_path = path; + search_result.full_path = @"$root_path/$path"; + results.add (search_result); + } + } + } + + results.sort ((a, b) => { + return b.score - a.score; + }); + + if (results.size <= 20) { + return results; + } + + return (Gee.ArrayList) results.slice (0, 20); + } + + private SearchResult fuzzy_match (string pattern, string str) { + var finder = new RecursiveFinder (recursion_limit, max_matches); + return finder.fuzzy_match_recursive (pattern,str); + } + } diff --git a/plugins/fuzzy-search/fuzzy-search-dialog.vala b/plugins/fuzzy-search/fuzzy-search-dialog.vala new file mode 100644 index 0000000000..33628d1f0c --- /dev/null +++ b/plugins/fuzzy-search/fuzzy-search-dialog.vala @@ -0,0 +1,192 @@ +public class Scratch.Dialogs.FuzzySearchDialog : Gtk.Dialog { + private Gtk.Entry search_term_entry; + private Services.FuzzyFinder fuzzy_finder; + private Gtk.Box search_result_container; + private int preselected_index; + private Gtk.ScrolledWindow scrolled; + Gee.HashMap project_paths; + Gee.ArrayList items; + private int window_height; + private int max_items; + + public signal void open_file (string filepath); + public signal void close_search (); + + public FuzzySearchDialog (Gee.HashMap pps, int height) { + Object ( + transient_for: ((Gtk.Application) GLib.Application.get_default ()).active_window, + deletable: false, + modal: true, + title: _("Search project files…"), + resizable: false, + width_request: 600 + ); + window_height = height; + fuzzy_finder = new Services.FuzzyFinder (pps); + project_paths = pps; + items = new Gee.ArrayList (); + + // Limit the shown results if the window height is too small + if (window_height > 400) { + max_items = 5; + } else { + max_items = 3; + } + + scrolled.set_max_content_height (43 /* height */ * max_items); + } + + private void calculate_scroll_offset (int old_position, int new_position) { + // Shortcut if jumping from first to last or the other way round + if (new_position == 0 && old_position > new_position) { + scrolled.vadjustment.value = 0; + return; + } else if (old_position == 0 && new_position == items.size - 1) { + scrolled.vadjustment.value = scrolled.vadjustment.get_upper (); + return; + } + + var size_box = scrolled.vadjustment.get_upper () / items.size; + var current_top = scrolled.vadjustment.value; + var current_bottom = current_top + size_box * (max_items - 2); + if (old_position < new_position) { + // Down movement + var new_adjust = size_box * (preselected_index); + if (new_adjust >= current_bottom) { + scrolled.vadjustment.value = size_box * (preselected_index - (max_items - 1)); + } + } else if (old_position > new_position) { + // Up movement + var new_adjust = size_box * (preselected_index); + if (new_adjust < current_top) { + scrolled.vadjustment.value = new_adjust; + } + } + } + + construct { + search_term_entry = new Gtk.Entry (); + search_term_entry.halign = Gtk.Align.CENTER; + search_term_entry.expand = true; + search_term_entry.width_request = 575; + + var box = get_content_area (); + box.orientation = Gtk.Orientation.VERTICAL; + + var layout = new Gtk.Grid () { + column_spacing = 12, + row_spacing = 6 + }; + layout.attach (search_term_entry, 0, 0, 2); + layout.show_all (); + + search_result_container = new Gtk.Box (Gtk.Orientation.VERTICAL, 1); + scrolled = new Gtk.ScrolledWindow (null, null); + scrolled.add (search_result_container); + scrolled.margin_top = 10; + + search_term_entry.key_press_event.connect ((e) => { + // Handle key up/down to select other files found by fuzzy search + if (e.keyval == Gdk.Key.Down) { + if (items.size > 0) { + var old_index = preselected_index; + var item = items.get (preselected_index++); + if (preselected_index >= items.size) { + preselected_index = 0; + } + var next_item = items.get (preselected_index); + preselect_new_item (item, next_item); + + calculate_scroll_offset (old_index, preselected_index); + } + + return true; + } else if (e.keyval == Gdk.Key.Up) { + if (items.size > 0) { + var old_index = preselected_index; + var item = items.get (preselected_index--); + if (preselected_index < 0) { + preselected_index = items.size -1; + } + var next_item = items.get (preselected_index); + preselect_new_item (item, next_item); + + calculate_scroll_offset (old_index, preselected_index); + } + return true; + } else if (e.keyval == Gdk.Key.Escape) { + // Handle seperatly, otherwise it takes 2 escape hits to close the modal + close_search (); + return true; + } + return false; + }); + + search_term_entry.activate.connect (() => { + if (items.size > 0) { + var item = items.get (preselected_index); + open_file (item.filepath.strip ()); + } + }); + + search_term_entry.changed.connect ((e) => { + if (search_term_entry.text.length >= 1) { + var previous_text = search_term_entry.text; + fuzzy_finder.fuzzy_find_async.begin (search_term_entry.text, (obj, res) =>{ + var results = fuzzy_finder.fuzzy_find_async.end(res); + bool first = true; + + // If the entry is empty or the text has changed + // since searching, do nothing + if (previous_text.length == 0 || previous_text != search_term_entry.text) { + return; + } + + + foreach (var c in search_result_container.get_children ()) { + search_result_container.remove (c); + } + items.clear (); + + foreach (var result in results) { + var file_item = new FileItem (result); + + if (first) { + first = false; + file_item.get_style_context ().add_class ("preselect-fuzzy"); + preselected_index = 0; + } + + file_item.get_style_context ().add_class ("fuzzy-item"); + + search_result_container.add (file_item); + items.add (file_item); + } + + + scrolled.hide (); + scrolled.show_all (); + // Reset scrolling + scrolled.vadjustment.value = 0; + }); + } else { + foreach (var c in search_result_container.get_children ()) { + search_result_container.remove (c); + } + items.clear (); + scrolled.hide (); + } + }); + + scrolled.propagate_natural_height = true; + + box.add (layout); + box.add (scrolled); + } + + private void preselect_new_item (FileItem old_item, FileItem new_item) { + var class_name = "preselect-fuzzy"; + old_item.get_style_context ().remove_class (class_name); + new_item.get_style_context ().add_class (class_name); + } + } diff --git a/plugins/fuzzy-search/fuzzy-search.plugin b/plugins/fuzzy-search/fuzzy-search.plugin new file mode 100644 index 0000000000..1bb55ef5be --- /dev/null +++ b/plugins/fuzzy-search/fuzzy-search.plugin @@ -0,0 +1,9 @@ +[Plugin] +Module=fuzzy-search +Loader=C +IAge=1 +Name=Fuzzy Search +Description=Fuzzy search all project files +Icon=system-search +Authors=Marvin Ahlgrimm +Copyright=Copyright © 2021 Marvin Ahlgrimm diff --git a/plugins/fuzzy-search/fuzzy-search.vala b/plugins/fuzzy-search/fuzzy-search.vala new file mode 100644 index 0000000000..2ea9c0def9 --- /dev/null +++ b/plugins/fuzzy-search/fuzzy-search.vala @@ -0,0 +1,122 @@ +public class Scratch.Services.SearchProject { + public string root_path { get; private set; } + public Gee.ArrayList relative_file_paths { get; private set; } + private MonitoredRepository? monitored_repo; + + public SearchProject (string root, MonitoredRepository? repo) { + root_path = root; + monitored_repo = repo; + relative_file_paths = new Gee.ArrayList (); + + parse (root_path); + } + + private void parse (string path) { + try { + try { + // Don't use paths which are ignored from .gitignore + if (monitored_repo != null && monitored_repo.path_is_ignored (path)) { + return; + } + } catch (Error e) { + warning ("An error occurred while checking if item '%s' is git-ignored: %s", path, e.message); + } + + var dir = Dir.open (path); + var name = dir.read_name (); + while (name != null) { + var new_search_path = ""; + if (path.has_suffix ("/")) { + new_search_path = path.substring (0, path.length - 1); + } else { + new_search_path = path; + } + parse (new_search_path + "/" + name); + name = dir.read_name (); + } + } catch (FileError e) { + // This adds branch is reached when a non-directory was reached, i.e. is a file + // If a file was reached, add it's relative path (starting after the project root path) + // to the list. + // Relativ paths are used because the longer the path is the less accurate are the results + var subpath = path.replace (root_path, ""); + relative_file_paths.add (subpath.substring (1, subpath.length-1)); + } + } +} + +public class Scratch.Plugins.FuzzySearch: Peas.ExtensionBase, Peas.Activatable { + MainWindow window = null; + + Scratch.Services.Interface plugins; + public Object object {owned get; construct;} + + public void update_state () { + } + + public void activate () { + plugins = (Scratch.Services.Interface) object; + + plugins.hook_window.connect ((w) => { + if (window != null) + return; + + window = w; + window.key_press_event.connect (on_window_key_press_event); + }); + } + + bool on_window_key_press_event (Gdk.EventKey event) { + /* p shows fuzzy search dialog */ + if (event.keyval == Gdk.Key.p + && Gdk.ModifierType.CONTROL_MASK in event.state) { + var settings = new GLib.Settings ("io.elementary.code.folder-manager"); + int diag_x; + int diag_y; + int window_x; + int window_y; + int window_height; + int window_width; + window.get_position (out window_x, out window_y); + window.get_size (out window_width, out window_height); + + var project_paths = new Gee.HashMap (); + + foreach (unowned string path in settings.get_strv ("opened-folders")) { + var monitor = Services.GitManager.get_monitored_repository (path); + project_paths[path] = new Services.SearchProject(path, monitor); + } + var dialog = new Scratch.Dialogs.FuzzySearchDialog (project_paths, window_height); + dialog.get_position(out diag_x, out diag_y); + + dialog.open_file.connect ((filepath) => { + var file = new Scratch.FolderManager.File (filepath); + var doc = new Scratch.Services.Document (window.actions, file.file); + + window.open_document (doc); + dialog.destroy (); + }); + + dialog.close_search.connect (() => dialog.destroy ()); + // Move the dialog a bit under the top of the application window + dialog.move(diag_x, window_y + 50); + + dialog.run (); + + return true; + } + + return false; + } + + public void deactivate () {} +} + +[ModuleInit] +public void peas_register_types (GLib.TypeModule module) { + var objmodule = module as Peas.ObjectModule; + objmodule.register_extension_type ( + typeof (Peas.Activatable), + typeof (Scratch.Plugins.FuzzySearch) + ); +} diff --git a/plugins/fuzzy-search/meson.build b/plugins/fuzzy-search/meson.build new file mode 100644 index 0000000000..9a44cafb19 --- /dev/null +++ b/plugins/fuzzy-search/meson.build @@ -0,0 +1,36 @@ +module_name = 'fuzzy-search' + +module_files = [ + 'fuzzy-search.vala', + 'fuzzy-finder.vala', + 'file-item.vala', + 'fuzzy-search-dialog.vala', + 'search-result.vala', +] + +module_deps = [ + codecore_dep, +] + +shared_module( + module_name, + module_files, + dependencies: module_deps, + install: true, + install_dir: join_paths(pluginsdir, module_name), +) + +custom_target(module_name + '.plugin_merge', + input: module_name + '.plugin', + output: module_name + '.plugin', + command : [msgfmt, + '--desktop', + '--keyword=Description', + '--keyword=Name', + '-d' + join_paths(meson.source_root (), 'po', 'plugins'), + '--template=@INPUT@', + '-o@OUTPUT@', + ], + install : true, + install_dir: join_paths(pluginsdir, module_name), +) diff --git a/plugins/fuzzy-search/search-result.vala b/plugins/fuzzy-search/search-result.vala new file mode 100644 index 0000000000..88b1fc86b5 --- /dev/null +++ b/plugins/fuzzy-search/search-result.vala @@ -0,0 +1,13 @@ +public class SearchResult { + public string full_path; + public string relative_path; + public bool found; + public int score; + + public SearchResult (bool fo, int sc) { + full_path = ""; + relative_path = ""; + found = fo; + score = sc; + } +} \ No newline at end of file diff --git a/plugins/meson.build b/plugins/meson.build index 2e8855ee1d..48709f4d38 100644 --- a/plugins/meson.build +++ b/plugins/meson.build @@ -13,3 +13,4 @@ subdir('strip-trailing-save') subdir('terminal') subdir('vim-emulation') subdir('word-completion') +subdir('fuzzy-search') diff --git a/src/Services/GitManager.vala b/src/Services/GitManager.vala index 99a28b72e4..d51f73182b 100644 --- a/src/Services/GitManager.vala +++ b/src/Services/GitManager.vala @@ -23,6 +23,9 @@ namespace Scratch.Services { public ListStore project_liststore { get; private set; } public string active_project_path { get; set; default = "";} + public signal void opened_project (string root_path); + public signal void removed_project (string root_path); + static Gee.HashMap project_gitrepo_map; static GitManager? instance; @@ -32,6 +35,10 @@ namespace Scratch.Services { project_gitrepo_map = new Gee.HashMap (); } + public static MonitoredRepository? get_monitored_repository (string root_path) { + return project_gitrepo_map[root_path]; + } + public static GitManager get_instance () { if (instance == null) { instance = new GitManager (); @@ -56,6 +63,7 @@ namespace Scratch.Services { var monitored_repo = new MonitoredRepository (git_repo); project_gitrepo_map.@set (root_path, monitored_repo); + opened_project (root_path); return project_gitrepo_map.@get (root_path); } catch (Error e) { debug ("Error opening git repo for %s, means this probably isn't one: %s", root_path, e.message); @@ -76,6 +84,7 @@ namespace Scratch.Services { uint position; if (project_liststore.find (root_folder, out position)) { project_liststore.remove (position); + removed_project (root_path); } else { critical ("Can't remove: %s", root_path); } diff --git a/src/Services/PluginManager.vala b/src/Services/PluginManager.vala index c8036d5db5..dbe1c08843 100644 --- a/src/Services/PluginManager.vala +++ b/src/Services/PluginManager.vala @@ -1,7 +1,7 @@ // -*- Mode: vala; indent-tabs-mode: nil; tab-width: 4 -*- /*** BEGIN LICENSE - + Copyright (C) 2013 Mario Guerriero This program is free software: you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License version 3, as published