diff --git a/data/Application.css b/data/Application.css index 4020d41d6..f607b3d3e 100644 --- a/data/Application.css +++ b/data/Application.css @@ -20,3 +20,52 @@ textview.scrubber { border: 0; } +.fuzzy-popover { + padding-top: 0.5rem; + padding-bottom: 1rem; +} + +.fuzzy-popover entry { + margin-left: 1rem; + margin-right: 1rem; +} + +.fuzzy-popover scrolledwindow { + margin-top: 1rem; +} + +.fuzzy-list { + background-color: transparent; +} + +.fuzzy-item { + padding: 0.5rem; + margin-left: 10px; + margin-right: 10px; + background-color: transparent; +} + +.fuzzy-item.preselect-fuzzy, +.fuzzy-item:hover { + border-radius: 0.5rem; +} + +.fuzzy-item:hover { + background-color: @theme_unfocused_selected_bg_color; +} + +.fuzzy-item.preselect-fuzzy { + background-color: @selected_bg_color; +} + +.fuzzy-item .fuzzy-file-icon { + margin-right: 0.5rem; +} + +.fuzzy-item label:nth-child(1) { + font-weight: 700; +} + +.fuzzy-item.preselect-fuzzy label { + opacity: 0.7; +} diff --git a/plugins/fuzzy-search/file-item.vala b/plugins/fuzzy-search/file-item.vala new file mode 100644 index 000000000..62d5a6e46 --- /dev/null +++ b/plugins/fuzzy-search/file-item.vala @@ -0,0 +1,58 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2023 elementary, Inc. + * + * Authored by: Marvin Ahlgrimm + * Colin Kiama + */ + +public class FileItem : Gtk.ListBoxRow { + private SearchResult result; + + public string filepath { + get { + return result.full_path; + } + } + public FileItem (SearchResult res, bool should_distinguish_project = false) { + this.get_style_context ().add_class ("fuzzy-item"); + this.get_style_context ().add_class ("flat"); + + 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 ( + @"$(should_distinguish_project ? result.project + " • " : "")$(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; + + 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); + + var container_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 1) { + valign = Gtk.Align.CENTER + }; + + container_box.add (image); + container_box.add (path_box); + + this.child = container_box; + } +} diff --git a/plugins/fuzzy-search/fuzzy-finder.vala b/plugins/fuzzy-search/fuzzy-finder.vala new file mode 100644 index 000000000..9bf5d1829 --- /dev/null +++ b/plugins/fuzzy-search/fuzzy-finder.vala @@ -0,0 +1,296 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2023 elementary, Inc. + * + * Authored by: Marvin Ahlgrimm + * Colin Kiama + */ + +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 CURRENT_PROJECT_PRIORITY_BONUS = 20; // Bonus if search result is for current project +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, GLib.Cancellable cancellable) { + var matches = new Gee.ArrayList (); + return fuzzy_match_recursive_internal (pattern, str, 0, 0, 0, cancellable, matches); + } + + private SearchResult fuzzy_match_recursive_internal (string pattern, + string str, + int pattern_current_index, + int str_current_index, + int next_match, + GLib.Cancellable cancellable, + 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 first_match = true; + + recursion_count++; + if (cancellable.is_cancelled () || 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) { + if (cancellable.is_cancelled ()) { + return new SearchResult (false, out_score); + } + + var lower_case_char = pattern.get_char (pattern_current_index).tolower (); + var lower_case_str_char = str.get_char (str_current_index).tolower (); + + // Match found. + if (lower_case_char == lower_case_str_char) { + if (next_match >= max_matches) { + return new SearchResult (false, out_score); + } + + if (first_match && src_matches != null) { + matches.clear (); + matches.insert_all (0, src_matches); + first_match = 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, + cancellable, + 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); + } + + ++next_match; + ++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++) { + if (cancellable.is_cancelled ()) { + return new SearchResult (false, out_score); + } + + 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; + } + + bool 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, + string current_project, + GLib.Cancellable cancellable) { + var results = new Gee.ArrayList (); + + SourceFunc callback = fuzzy_find_async.callback; + new Thread ("fuzzy-find", () => { + results = fuzzy_find (search_str, current_project, cancellable); + Idle.add ((owned) callback); + }); + + yield; + return results; + } + + public Gee.ArrayList fuzzy_find (string search_str, + string current_project, + GLib.Cancellable cancellable) { + var results = new Gee.ArrayList (); + var projects = project_paths.values.to_array (); + + for (int i = 0; i < projects.length; i++) { + if (cancellable.is_cancelled ()) { + if (results.size <= 20) { + return results; + } + + return (Gee.ArrayList) results.slice (0, 20); + } + + var project = projects[i]; + + for (int j = 0; j < project.relative_file_paths.size; j++) { + if (cancellable.is_cancelled ()) { + return results; + } + + var path = project.relative_file_paths[j]; + SearchResult path_search_result; + SearchResult filename_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 + string project_name = ""; + + if (project_paths.size > 1) { + project_name = Path.get_basename (project.root_path); + path_search_result = fuzzy_match (search_str, @"$project_name/$path", cancellable); + } else { + path_search_result = fuzzy_match (search_str, path, cancellable); + } + + string filename = Path.get_basename (path); + filename_search_result = fuzzy_match (search_str, filename, cancellable); + + var root_path = project.root_path; + + if (filename_search_result.found) { + filename_search_result.relative_path = path; + filename_search_result.full_path = @"$root_path/$path"; + filename_search_result.project = project_name; + filename_search_result.score += project.root_path == current_project + ? CURRENT_PROJECT_PRIORITY_BONUS + : 0; + + results.add (filename_search_result); + } else if (path_search_result.found) { + path_search_result.relative_path = path; + path_search_result.full_path = @"$root_path/$path"; + path_search_result.project = project_name; + path_search_result.score = (int) (path_search_result.score * 0.5) + + (project.root_path == current_project + ? CURRENT_PROJECT_PRIORITY_BONUS + : 0); + + results.add (path_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, GLib.Cancellable cancellable) { + var finder = new RecursiveFinder (recursion_limit, max_matches); + return finder.fuzzy_match_recursive (pattern, str, cancellable); + } +} diff --git a/plugins/fuzzy-search/fuzzy-search-indexer.vala b/plugins/fuzzy-search/fuzzy-search-indexer.vala new file mode 100644 index 000000000..4c02bd344 --- /dev/null +++ b/plugins/fuzzy-search/fuzzy-search-indexer.vala @@ -0,0 +1,315 @@ +/* +* SPDX-License-Identifier: GPL-3.0-or-later +* SPDX-FileCopyrightText: 2023 elementary, Inc. +* +* Authored by: Colin Kiama +*/ +const long SECONDS_IN_MICROSECONDS = 1000000; // 1 Million microseconds = 1 second; + +public enum IndexerMessageType { + INITIAL, + PROJECT_UPDATE +} + +public enum IndexerStatus { + INITIALISING, + INITIAL_PROCESSING, + IDLE, + PROCESSING +} + +public enum ProjectUpdateType { + ADDED, + REMOVED, + FILE_CREATED, + DIRECTORY_CREATED, + FILE_DELETED +} + +public interface IndexerMessage : GLib.Object { + public abstract IndexerMessageType message_type { get; construct; } +} + +public class InitialIndexRequest : GLib.Object, IndexerMessage { + public IndexerMessageType message_type { get; construct; } + public string project_path { get; construct; } + + public InitialIndexRequest (string project_path) { + Object ( + message_type: IndexerMessageType.INITIAL, + project_path: project_path + ); + } +} + +public class ProjectUpdate : GLib.Object, IndexerMessage { + public IndexerMessageType message_type { get; construct; } + public ProjectUpdateType update_type { get; construct; } + public string source_path { get; construct; } + public string? destination_path { get; construct; } + public string? project_path { get; construct; } + + public ProjectUpdate (ProjectUpdateType update_type, string source_path, string? destination_path = null) { + Object ( + message_type: IndexerMessageType.PROJECT_UPDATE, + update_type: update_type, + source_path: source_path, + destination_path: destination_path + ); + } +} + +public class Scratch.Services.FuzzySearchIndexer : GLib.Object { + public Gee.HashMap project_paths { get; private set; } + + private Gee.ArrayList initial_indexing_queue; + private GLib.Settings folder_settings; + private GLib.Cancellable cancellable; + private Gee.ConcurrentList processing_queue; + private IndexerStatus status; + + public FuzzySearchIndexer (GLib.Cancellable cancellable) { + this.cancellable = cancellable; + status = IndexerStatus.INITIALISING; + initial_indexing_queue = new Gee.ArrayList (); + processing_queue = new Gee.ConcurrentList (); + project_paths = new Gee.HashMap (); + + folder_settings = new GLib.Settings ("io.elementary.code.folder-manager"); + folder_settings.changed["opened-folders"].connect (handle_opened_projects_change); + } + + public void handle_folder_item_change (GLib.File source, GLib.File? dest, GLib.FileMonitorEvent event) { + switch (event) { + case GLib.FileMonitorEvent.CREATED: + string path = source.get_path (); + bool is_directory = FileUtils.test (path, GLib.FileTest.IS_DIR); + + var project_update = new ProjectUpdate ( + is_directory ? ProjectUpdateType.DIRECTORY_CREATED : ProjectUpdateType.FILE_CREATED, + path + ); + + processing_queue.add (project_update); + break; + case GLib.FileMonitorEvent.DELETED: + string path = source.get_path (); + + var project_update = new ProjectUpdate (ProjectUpdateType.FILE_DELETED, path); + processing_queue.add (project_update); + break; + default: + break; + } + } + + public async void start_async () { + string[] initial_projects = folder_settings.get_strv ("opened-folders"); + if (initial_projects != null) { + foreach (unowned string path in initial_projects) { + initial_indexing_queue.add (new InitialIndexRequest (path)); + } + } + + new Thread (null, () => { + while (cancellable.is_cancelled () == false) { + switch (status) { + case IndexerStatus.INITIALISING: + if (initial_indexing_queue.size < 1 ) { + status = IndexerStatus.IDLE; + debug ("Find Project Files: Indexer is now idle!\n"); + break; + } + + if (initial_indexing_queue.size > 0) { + process_initial_indexing_requests_async.begin ( + initial_indexing_queue, + project_paths, + (obj, res) => { + process_initial_indexing_requests_async.end (res); + status = IndexerStatus.IDLE; + }); + + status = IndexerStatus.INITIAL_PROCESSING; + debug ("Find Project Files: Indexer is now doing initial processing!"); + } + + break; + // Indexer initialization is complete, now waiting for incoming messages to process. + case IndexerStatus.IDLE: + if (processing_queue.size > 0) { + var first_item = processing_queue.get (0); + process_next_message_async.begin (first_item, (obj, res) => { + process_next_message_async.end (res); + processing_queue.remove (first_item); + status = IndexerStatus.IDLE; + debug ("Find Project Files: Indexer is now idle!"); + }); + + status = IndexerStatus.PROCESSING; + debug ("Find Project Files: Indexer now processing!"); + } + break; + case IndexerStatus.INITIAL_PROCESSING: + case IndexerStatus.PROCESSING: + break; + default: + break; + } + + Thread.usleep (1 * SECONDS_IN_MICROSECONDS); + } + + folder_settings.changed["opened-folders"].disconnect (handle_opened_projects_change); + Idle.add (start_async.callback); + }); + + yield; + } + + private async void process_next_message_async (IndexerMessage message) { + switch (message.message_type) { + case IndexerMessageType.PROJECT_UPDATE: + process_project_update_async.begin ((ProjectUpdate) message, (obj, res) => { + process_project_update_async.end (res); + }); + + break; + default: + break; + } + } + + private async void process_project_update_async (ProjectUpdate message) { + switch (message.update_type) { + case ProjectUpdateType.ADDED: + add_project_async.begin (message, (obj, res) => { + add_project_async.end (res); + debug ("Find Project Files: Added project: %s", message.source_path); + }); + + break; + case ProjectUpdateType.REMOVED: + remove_project (message); + debug ("Find Project Files: Removed project: %s", message.source_path); + break; + case ProjectUpdateType.FILE_CREATED: + add_file (message); + debug ("Find Project Files: Added file: %s", message.source_path); + + break; + case ProjectUpdateType.DIRECTORY_CREATED: + add_directory_async.begin (message, (obj, res) => { + add_directory_async.end (res); + debug ("Find Project Files: Added directory: %s", message.source_path); + }); + + break; + case ProjectUpdateType.FILE_DELETED: + remove_file (message); + debug ("Find Project Files: Deleted directory: %s", message.source_path); + break; + } + } + + private void remove_file (ProjectUpdate message) { + string path = message.source_path; + string project_key = get_project_path_of_file (path); + if (project_key == null) { + return; + } + + Services.SearchProject project_search = project_paths[project_key]; + project_search.remove_file (path, this.cancellable); + processing_queue.remove (message); + } + + private void add_file (ProjectUpdate message) { + string path = message.source_path; + string project_key = get_project_path_of_file (path); + if (project_key == null) { + return; + } + + Services.SearchProject project_search = project_paths[project_key]; + project_search.add_file (path, this.cancellable); + processing_queue.remove (message); + } + + private async void add_directory_async (ProjectUpdate message) { + string path = message.source_path; + string project_key = get_project_path_of_file (path); + if (project_key == null) { + return; + } + + Services.SearchProject project_search = project_paths[project_key]; + project_search.add_directory_async.begin (path, this.cancellable, (obj, res) => { + project_search.add_directory_async.end (res); + processing_queue.remove (message); + }); + } + + private async void add_project_async (ProjectUpdate message) { + string path = message.source_path; + var monitor = Services.GitManager.get_monitored_repository (path); + var project_search = new Services.SearchProject (path, monitor); + project_paths[path] = project_search; + + project_search.parse_async.begin (path, this.cancellable, (obj, res) => { + project_search.parse_async.end (res); + processing_queue.remove (message); + }); + } + + private void remove_project (ProjectUpdate message) { + string path = message.source_path; + project_paths.unset (path); + } + + private void handle_opened_projects_change () { + string[] opened_projects_array = folder_settings.get_strv ("opened-folders"); + var opened_projects = new Gee.ArrayList.wrap (opened_projects_array); + // Handle project additions + foreach (string project in opened_projects) { + if (project_paths.keys.contains (project) == false) { + processing_queue.add (new ProjectUpdate (ProjectUpdateType.ADDED, project)); + } + } + + // Handle project removals + foreach (string project in project_paths.keys) { + if (opened_projects.contains (project) == false) { + processing_queue.add ( new ProjectUpdate (ProjectUpdateType.REMOVED, project)); + } + } + } + + private async void process_initial_indexing_requests_async ( + Gee.ArrayList request_queue, + Gee.HashMap project_paths) { + for (int i = 0; i < request_queue.size; i++) { + var request = request_queue[i]; + var monitor = Services.GitManager.get_monitored_repository (request.project_path); + var project_search = new Services.SearchProject (request.project_path, monitor); + + project_paths[request.project_path] = project_search; + project_search.parse_async.begin (request.project_path, cancellable, (obj, res) => { + project_search.parse_async.end (res); + request_queue.remove (request); + }); + } + } + + private string? get_project_path_of_file (string file_path) { + var iter = project_paths.keys.iterator (); + while (iter.next ()) { + string project_path = iter.get (); + if (file_path.has_prefix (project_path)) { + return project_path; + } + } + + return null; + } +} diff --git a/plugins/fuzzy-search/fuzzy-search-popover.vala b/plugins/fuzzy-search/fuzzy-search-popover.vala new file mode 100644 index 000000000..419da2f03 --- /dev/null +++ b/plugins/fuzzy-search/fuzzy-search-popover.vala @@ -0,0 +1,288 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2023 elementary, Inc. + * + * Authored by: Marvin Ahlgrimm + * Colin Kiama + */ + +public class Scratch.FuzzySearchPopover : Gtk.Popover { + private Gtk.SearchEntry search_term_entry; + private Services.FuzzyFinder fuzzy_finder; + private Gtk.ListBox search_result_container; + private int preselected_index; + private Gtk.ScrolledWindow scrolled; + private Gee.ArrayList items; + private Scratch.Services.FuzzySearchIndexer indexer; + private int window_height; + private int max_items; + private Gee.LinkedList cancellables; + private Gtk.EventControllerKey search_term_entry_key_controller; + private Gtk.Label title_label; + public Scratch.MainWindow current_window { get; construct; } + public bool sidebar_is_visible { get; set; } + + public signal void open_file (string filepath); + public signal void close_search (); + + public FuzzySearchPopover (Scratch.Services.FuzzySearchIndexer search_indexer, Scratch.MainWindow window) { + Object ( + modal: true, + relative_to: window.document_view, + width_request: 500, + current_window: window + ); + + int height; + current_window.get_size (null, out height); + window_height = height; + + fuzzy_finder = new Services.FuzzyFinder (search_indexer.project_paths); + indexer = search_indexer; + items = new Gee.ArrayList (); + cancellables = new Gee.LinkedList (); + + // 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 (45 /* 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 { + pointing_to = { 0, 32, 1, 1 }; + this.get_style_context ().add_class ("fuzzy-popover"); + + title_label = new Gtk.Label (_("Find project files")); + title_label.halign = Gtk.Align.START; + title_label.get_style_context ().add_class ("h4"); + + search_term_entry = new Gtk.SearchEntry (); + search_term_entry.halign = Gtk.Align.FILL; + search_term_entry.hexpand = true; + + search_result_container = new Gtk.ListBox () { + selection_mode = Gtk.SelectionMode.NONE, + activate_on_single_click = true, + can_focus = false + }; + + search_result_container.get_style_context ().add_class ("fuzzy-list"); + + search_result_container.row_activated.connect ((row) => { + var file_item = row as FileItem; + if (file_item == null) { + return; + } + + handle_item_selection (items.index_of (file_item)); + }); + + search_term_entry_key_controller = new Gtk.EventControllerKey (search_term_entry); + search_term_entry_key_controller.key_pressed.connect ((keyval, keycode, state) => { + // Handle key up/down to select other files found by fuzzy search + switch (keyval) { + case 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; + case 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; + case Gdk.Key.Escape: + // Handle seperately, otherwise it takes 2 escape hits to close the modal + close_search (); + return true; + default: + break; + } + + return false; + }); + + search_term_entry.activate.connect (() => { + if (items.size > 0) { + handle_item_selection (preselected_index); + } + }); + + search_term_entry.changed.connect ((e) => { + if (search_term_entry.text.length >= 1) { + var previous_text = search_term_entry.text; + if (cancellables.size > 0) { + var last_cancellable = cancellables.last (); + last_cancellable.cancel (); + } + + Timeout.add (1, () => { + var next_cancellable = new GLib.Cancellable (); + cancellables.add (next_cancellable); + fuzzy_finder.fuzzy_find_async.begin (search_term_entry.text, + get_current_project (), + next_cancellable, + (obj, res) => { + if (next_cancellable.is_cancelled ()) { + cancellables.remove (next_cancellable); + return; + } + + var results = fuzzy_finder.fuzzy_find_async.end (res); + if (results == null) { + return; + } + + 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, indexer.project_paths.size > 1); + file_item.can_focus = false; + + if (first) { + first = false; + file_item.get_style_context ().add_class ("preselect-fuzzy"); + preselected_index = 0; + } + + search_result_container.add (file_item); + items.add (file_item); + } + + scrolled.hide (); + scrolled.show_all (); + + // Reset scrolling + scrolled.vadjustment.value = 0; + }); + + return Source.REMOVE; + }); + } else { + foreach (var c in search_result_container.get_children ()) { + search_result_container.remove (c); + } + + items.clear (); + scrolled.hide (); + } + }); + + var entry_layout = new Gtk.Box (Gtk.Orientation.VERTICAL, 0); + entry_layout.valign = Gtk.Align.START; + + entry_layout.add (title_label); + entry_layout.add (search_term_entry); + search_term_entry.valign = Gtk.Align.START; + + scrolled = new Gtk.ScrolledWindow (null, null) { + propagate_natural_height = true, + hexpand = true, + }; + + scrolled.add (search_result_container); + + var box = new Gtk.Box (Gtk.Orientation.VERTICAL, 0); + box.pack_start (entry_layout, false, false); + box.pack_end (scrolled, true, true); + box.show_all (); + + scrolled.hide (); + this.add (box); + } + + private void handle_item_selection (int index) { + var item = items.get (index); + open_file (item.filepath.strip ()); + } + + 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); + } + + private string get_current_project () { + Scratch.Services.Document current_document = current_window.document_view.current_document; + if (current_document == null) { + return ""; + } + + if (current_document.is_file_temporary) { + return ""; + } + + string file_path = current_document.file.get_path (); + + var iter = indexer.project_paths.keys.iterator (); + while (iter.next ()) { + string project_path = iter.get (); + if (file_path.has_prefix (project_path)) { + return project_path; + } + } + + return ""; + } + } diff --git a/plugins/fuzzy-search/fuzzy-search-project.vala b/plugins/fuzzy-search/fuzzy-search-project.vala new file mode 100644 index 000000000..a87430683 --- /dev/null +++ b/plugins/fuzzy-search/fuzzy-search-project.vala @@ -0,0 +1,148 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2023 elementary, Inc. + * + * Authored by: Marvin Ahlgrimm + * Authored by: Colin Kiama + */ + +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 (); + } + + public async void parse_async (string path, GLib.Cancellable cancellable) { + new Thread (null, () => { + parse_async_internal.begin (path, cancellable, (obj, res) => { + parse_async_internal.end (res); + }); + + Idle.add (parse_async.callback); + }); + + yield; + } + + public void remove_file (string path, GLib.Cancellable cancellable) { + if (cancellable.is_cancelled ()) { + return; + } + + string subpath = path.replace (root_path, ""); + string deleted_path = subpath.substring (1, subpath.length - 1); + + // Remove File + if (relative_file_paths.contains (deleted_path)) { + relative_file_paths.remove (deleted_path); + return; + } + + int start_length = relative_file_paths.size; + // Remove directory + for (int i = start_length - 1; i > -1; i--) { + string relative_path = relative_file_paths[i]; + if (relative_path.has_prefix (deleted_path)) { + relative_file_paths.remove (relative_path); + } + } + + } + + public void add_file (string path, GLib.Cancellable cancellable) { + if (cancellable.is_cancelled ()) { + return; + } + + 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); + } + + string subpath = path.replace (root_path, ""); + relative_file_paths.add (subpath.substring (1, subpath.length - 1)); + } + + public async void add_directory_async (string path, GLib.Cancellable cancellable) { + parse_async_internal.begin (path, cancellable, (obj, res) => { + parse_async_internal.end (res); + }); + } + + private async void parse_async_internal (string path, GLib.Cancellable cancellable) { + if (cancellable.is_cancelled ()) { + return; + } + + try { + // Ignore dot-prefixed directories + string path_basename = Path.get_basename (path); + if (FileUtils.test (path, GLib.FileTest.IS_DIR) && path_basename.has_prefix (".")) { + return; + } + + 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) { + if (cancellable.is_cancelled ()) { + return; + } + + var new_search_path = ""; + if (path.has_suffix (GLib.Path.DIR_SEPARATOR_S)) { + new_search_path = path.substring (0, path.length - 1); + } else { + new_search_path = path; + } + + parse_async_internal.begin ( + new_search_path + GLib.Path.DIR_SEPARATOR_S + name, + cancellable, + (obj, res) => { + parse_async_internal.end (res); + }); + + 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. + + // Relative paths are used because the longer the path is the less accurate are the results + if (check_if_valid_path_to_add (path)) { + string subpath = path.replace (root_path, ""); + relative_file_paths.add (subpath.substring (1, subpath.length - 1)); + } + } + } + + private bool check_if_valid_path_to_add (string path) { + try { + File file = File.new_for_path (path); + var file_info = file.query_info ("standard::*", 0); + return Utils.check_if_valid_text_file (path, file_info); + } catch (Error e) { + return false; + } + } +} diff --git a/plugins/fuzzy-search/fuzzy-search.plugin b/plugins/fuzzy-search/fuzzy-search.plugin new file mode 100644 index 000000000..57b3e4f42 --- /dev/null +++ b/plugins/fuzzy-search/fuzzy-search.plugin @@ -0,0 +1,9 @@ +[Plugin] +Module=fuzzy-search +Loader=C +IAge=1 +Name=Find Project Files +Description=Fuzzy search for files in all open projects (Show using ALT + F) +Icon=system-search +Authors=Marvin Ahlgrimm;Colin Kiama +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 000000000..ea07cd4e5 --- /dev/null +++ b/plugins/fuzzy-search/fuzzy-search.vala @@ -0,0 +1,121 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2023 elementary, Inc. + * + * Authored by: Marvin Ahlgrimm + */ + + +public class Scratch.Plugins.FuzzySearch: Peas.ExtensionBase, Peas.Activatable { + public Object object { owned get; construct; } + private const uint ACCEL_KEY = Gdk.Key.F; + private const Gdk.ModifierType ACCEL_MODTYPE = Gdk.ModifierType.MOD1_MASK; + + private Scratch.Services.FuzzySearchIndexer indexer; + private MainWindow window = null; + private Scratch.Services.Interface plugins; + private Gtk.EventControllerKey key_controller; + private Gtk.MenuItem fuzzy_menuitem; + private GLib.Cancellable cancellable; + + public void update_state () { + + } + + public void activate () { + plugins = (Scratch.Services.Interface) object; + + plugins.hook_window.connect ((w) => { + if (window != null) { + return; + } + + cancellable = new GLib.Cancellable (); + indexer = new Scratch.Services.FuzzySearchIndexer (cancellable); + + indexer.start_async.begin ((obj, res) => { + indexer.start_async.end (res); + }); + + window = w; + key_controller = new Gtk.EventControllerKey (window) { + propagation_phase = BUBBLE + }; + + key_controller.key_pressed.connect (on_window_key_press_event); + + fuzzy_menuitem = new Gtk.MenuItem.with_label (_("Find Project Files")); + var child = ((Gtk.Bin)fuzzy_menuitem).get_child (); + if (child is Gtk.AccelLabel) { + ((Gtk.AccelLabel)child).set_accel (ACCEL_KEY, ACCEL_MODTYPE); + } + + fuzzy_menuitem.activate.connect (fuzzy_find); + fuzzy_menuitem.show (); + window.sidebar.project_menu.append (fuzzy_menuitem); + }); + + plugins.hook_folder_item_change.connect ((src, dest, event) => { + if (indexer == null) { + return; + } + + indexer.handle_folder_item_change (src, dest, event); + }); + } + + bool on_window_key_press_event (uint keyval, uint keycode, Gdk.ModifierType state) { + /* f shows fuzzy search dialog */ + switch (Gdk.keyval_to_upper (keyval)) { + case ACCEL_KEY: + if (state == ACCEL_MODTYPE) { + fuzzy_find (); + return true; + } + + break; + default: + return false; + } + + return false; + } + + private void fuzzy_find () { + var settings = new GLib.Settings ("io.elementary.code.folder-manager"); + + string[] opened_folders = settings.get_strv ("opened-folders"); + if (opened_folders == null || opened_folders.length < 1) { + return; + } + + var popover = new Scratch.FuzzySearchPopover (indexer, window); + popover.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); + popover.popdown (); + }); + + popover.close_search.connect (() => popover.popdown ()); + popover.popup (); + } + + public void deactivate () { + key_controller.key_pressed.disconnect (on_window_key_press_event); + window.sidebar.project_menu.remove (fuzzy_menuitem); + if (cancellable != null) { + cancellable.cancel (); + } + } +} + +[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 000000000..5a717fcd1 --- /dev/null +++ b/plugins/fuzzy-search/meson.build @@ -0,0 +1,38 @@ +module_name = 'fuzzy-search' + +module_files = [ + 'file-item.vala', + 'fuzzy-search.vala', + 'fuzzy-finder.vala', + 'fuzzy-search-indexer.vala', + 'fuzzy-search-popover.vala', + 'fuzzy-search-project.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 000000000..62105ad71 --- /dev/null +++ b/plugins/fuzzy-search/search-result.vala @@ -0,0 +1,22 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * SPDX-FileCopyrightText: 2023 elementary, Inc. + * + * Authored by: Marvin Ahlgrimm + */ + +public class SearchResult { + public string full_path; + public string relative_path; + public string project; + public bool found; + public int score; + + public SearchResult (bool fo, int sc) { + full_path = ""; + relative_path = ""; + project = ""; + found = fo; + score = sc; + } +} diff --git a/plugins/meson.build b/plugins/meson.build index aa8891eb7..906dcd078 100644 --- a/plugins/meson.build +++ b/plugins/meson.build @@ -10,3 +10,4 @@ subdir('preserve-indent') subdir('spell') subdir('vim-emulation') subdir('word-completion') +subdir('fuzzy-search') diff --git a/src/FolderManager/File.vala b/src/FolderManager/File.vala index 8b23a7ad8..1e3c36d04 100644 --- a/src/FolderManager/File.vala +++ b/src/FolderManager/File.vala @@ -102,20 +102,7 @@ namespace Scratch.FolderManager { // checks if we're dealing with a textfile public bool is_valid_textfile { get { - if (path.has_prefix (".goutputstream")) { - return false; - } - - if (info.get_is_backup ()) { - return false; - } - - if (info.get_file_type () == FileType.REGULAR && - ContentType.is_a (info.get_content_type (), "text/*")) { - return true; - } - - return false; + return Utils.check_if_valid_text_file (path, info); } } diff --git a/src/FolderManager/FileView.vala b/src/FolderManager/FileView.vala index 5b65ac266..6de7f1ec6 100644 --- a/src/FolderManager/FileView.vala +++ b/src/FolderManager/FileView.vala @@ -25,6 +25,7 @@ public class Scratch.FolderManager.FileView : Granite.Widgets.SourceList, Code.P private GLib.Settings settings; private Scratch.Services.GitManager git_manager; private ActionGroup? toplevel_action_group = null; + private Scratch.Services.PluginsManager plugins; public signal void select (string file); @@ -37,6 +38,10 @@ public class Scratch.FolderManager.FileView : Granite.Widgets.SourceList, Code.P } } + public FileView (Scratch.Services.PluginsManager plugins_manager) { + plugins = plugins_manager; + } + construct { icon_name = "folder-symbolic"; title = _("Folders"); @@ -231,6 +236,9 @@ public class Scratch.FolderManager.FileView : Granite.Widgets.SourceList, Code.P } } + public void folder_item_update_hook (GLib.File source, GLib.File? dest, GLib.FileMonitorEvent event) { + plugins.hook_folder_item_change (source, dest, event); + } private void rename_items_with_same_name (Item item) { string item_name = item.file.name; diff --git a/src/FolderManager/FolderItem.vala b/src/FolderManager/FolderItem.vala index de0250080..73d0e56f8 100644 --- a/src/FolderManager/FolderItem.vala +++ b/src/FolderManager/FolderItem.vala @@ -258,6 +258,8 @@ namespace Scratch.FolderManager { return; // Ignore changes due to temp files and streams } + view.folder_item_update_hook (source, dest, event); + if (!children_loaded) { // No child items except dummy, child never expanded /* Empty folder with dummy item will come here even if expanded */ switch (event) { diff --git a/src/MainWindow.vala b/src/MainWindow.vala index 151e857c6..e5ad5ac2e 100644 --- a/src/MainWindow.vala +++ b/src/MainWindow.vala @@ -439,7 +439,7 @@ namespace Scratch { sidebar = new Code.Sidebar (); - folder_manager_view = new FolderManager.FileView (); + folder_manager_view = new FolderManager.FileView (plugins); sidebar.add_tab (folder_manager_view); folder_manager_view.show_all (); diff --git a/src/Services/GitManager.vala b/src/Services/GitManager.vala index 891fdc1a3..679e0e0c1 100644 --- a/src/Services/GitManager.vala +++ b/src/Services/GitManager.vala @@ -32,6 +32,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 (); @@ -51,9 +55,9 @@ namespace Scratch.Services { try { var git_repo = Ggit.Repository.open (root_folder.file.file); if (!project_gitrepo_map.has_key (root_path)) { - monitored_repo = new MonitoredRepository (git_repo); project_gitrepo_map.@set (root_path, monitored_repo); + return project_gitrepo_map.@get (root_path); } } catch (Error e) { debug ( diff --git a/src/Services/PluginManager.vala b/src/Services/PluginManager.vala index 906c988aa..479c6a2a9 100644 --- a/src/Services/PluginManager.vala +++ b/src/Services/PluginManager.vala @@ -29,6 +29,7 @@ namespace Scratch.Services { public signal void hook_toolbar (Scratch.HeaderBar toolbar); public signal void hook_document (Scratch.Services.Document doc); public signal void hook_preferences_dialog (Scratch.Dialogs.Preferences dialog); + public signal void hook_folder_item_change (File file, File? other_file, FileMonitorEvent event_type); public Scratch.TemplateManager template_manager { private set; get; } @@ -66,6 +67,7 @@ namespace Scratch.Services { public signal void hook_toolbar (Scratch.HeaderBar toolbar); public signal void hook_document (Scratch.Services.Document doc); public signal void hook_preferences_dialog (Scratch.Dialogs.Preferences dialog); + public signal void hook_folder_item_change (File file, File? other_file, FileMonitorEvent event_type); public signal void extension_added (Peas.PluginInfo info); public signal void extension_removed (Peas.PluginInfo info); @@ -90,10 +92,12 @@ namespace Scratch.Services { ((Peas.Activatable)ext).activate (); extension_added (info); }); + exts.extension_removed.connect ((info, ext) => { ((Peas.Activatable)ext).deactivate (); extension_removed (info); }); + exts.foreach (on_extension_foreach); // Connect managers signals to interface's signals @@ -104,6 +108,7 @@ namespace Scratch.Services { this.hook_share_menu.connect ((m) => { plugin_iface.hook_share_menu (m); }); + this.hook_toolbar.connect ((t) => { plugin_iface.hook_toolbar (t); }); @@ -111,9 +116,14 @@ namespace Scratch.Services { this.hook_document.connect ((d) => { plugin_iface.hook_document (d); }); + this.hook_preferences_dialog.connect ((d) => { plugin_iface.hook_preferences_dialog (d); }); + + this.hook_folder_item_change.connect ((source, dest, event) => { + plugin_iface.hook_folder_item_change (source, dest, event); + }); } void on_extension_foreach (Peas.ExtensionSet set, Peas.PluginInfo info, Peas.Extension extension) { diff --git a/src/Utils.vala b/src/Utils.vala index bf3b8f824..3b09bfc74 100644 --- a/src/Utils.vala +++ b/src/Utils.vala @@ -163,4 +163,21 @@ namespace Scratch.Utils { return true; } + public bool check_if_valid_text_file (string path, FileInfo info) { + if (path.has_prefix (".goutputstream")) { + return false; + } + + if (info.get_is_backup ()) { + return false; + } + + if (info.get_file_type () == FileType.REGULAR && + ContentType.is_a (info.get_content_type (), "text/*")) { + return true; + } + + return false; + } + } diff --git a/src/Widgets/Sidebar.vala b/src/Widgets/Sidebar.vala index 578acef4f..4b4102a03 100644 --- a/src/Widgets/Sidebar.vala +++ b/src/Widgets/Sidebar.vala @@ -25,6 +25,7 @@ public class Code.Sidebar : Gtk.Grid { public Gtk.Stack stack { get; private set; } public Code.ChooseProjectButton choose_project_button { get; private set; } public Hdy.HeaderBar headerbar { get; private set; } + public Gtk.Menu project_menu { get; construct; } private Gtk.StackSwitcher stack_switcher; @@ -70,7 +71,7 @@ public class Code.Sidebar : Gtk.Grid { order_projects_menu_item.action_name = Scratch.MainWindow.ACTION_PREFIX + Scratch.MainWindow.ACTION_ORDER_FOLDERS; - var project_menu = new Gtk.Menu (); + project_menu = new Gtk.Menu (); project_menu.append (collapse_all_menu_item); project_menu.append (order_projects_menu_item); project_menu.show_all ();