diff --git a/data/icons/git-symbolic.svg b/data/icons/git-symbolic.svg new file mode 100644 index 0000000000..8d3a0dbd30 --- /dev/null +++ b/data/icons/git-symbolic.svg @@ -0,0 +1,4 @@ + +image/svg+xml + + diff --git a/data/io.elementary.code.gresource.xml b/data/io.elementary.code.gresource.xml index 2149e3b022..4f1e7c8c50 100644 --- a/data/io.elementary.code.gresource.xml +++ b/data/io.elementary.code.gresource.xml @@ -2,7 +2,6 @@ Application.css - icons/48/git.svg icons/SymbolOutline/abstractclass.svg icons/SymbolOutline/abstractmethod.svg icons/SymbolOutline/abstractproperty.svg @@ -30,8 +29,10 @@ icons/panel-right-symbolic.svg + icons/48/git.svg icons/48/open-project.svg icons/filter-symbolic.svg + icons/git-symbolic.svg icons/emblem-git-modified-symbolic.svg icons/emblem-git-new-symbolic.svg diff --git a/data/io.elementary.code.gschema.xml b/data/io.elementary.code.gschema.xml index 397b293ed1..0f78a64f73 100644 --- a/data/io.elementary.code.gschema.xml +++ b/data/io.elementary.code.gschema.xml @@ -162,6 +162,16 @@ The default build directory's relative path. The directory, relative to the project root, at which to open the terminal pane and where to run build commands by default. + + '' + The default Projects folder + The path to the folder below which projects are saved or cloned + + + '' + The default git remote + The URL of the remote from where repositories can be cloned, for example https://github.com/elementary/ + false Request dark Gtk stylesheet variant diff --git a/src/Dialogs/CloneRepositoryDialog.vala b/src/Dialogs/CloneRepositoryDialog.vala new file mode 100644 index 0000000000..2ff3687360 --- /dev/null +++ b/src/Dialogs/CloneRepositoryDialog.vala @@ -0,0 +1,258 @@ +/* + * SPDX-License-Identifier: GPL-2.0-or-later + * SPDX-FileCopyrightText: 2025 elementary, Inc. + * + * Authored by: Jeremy Wootten + */ + +public class Scratch.Dialogs.CloneRepositoryDialog : Granite.MessageDialog { + // Git project name rules according to GitLab + // - Must start and end with a letter ( a-zA-Z ) or digit ( 0-9 ). + // - Can contain only letters ( a-zA-Z ), digits ( 0-9 ), underscores ( _ ), dots ( . ), or dashes ( - ). + // - Must not contain consecutive special characters. + // - Cannot end in . git or . atom . + private const string NAME_REGEX = """^[0-9a-zA-Z].([-_.]?[0-9a-zA-Z])*$"""; //TODO additional validation required + + private Regex name_regex; + private Gtk.Label projects_folder_label; + private Granite.ValidatedEntry remote_repository_uri_entry; + private Granite.ValidatedEntry local_project_name_entry; + private Gtk.Button clone_button; + private Gtk.Stack stack; + private Gtk.Spinner spinner; + private Gtk.Revealer revealer; + + public bool can_clone { get; private set; default = false; } + public string suggested_local_folder { get; construct; } + public string suggested_remote { get; construct; } + + public bool cloning_in_progress { + set { + if (value) { + stack.visible_child_name = "cloning"; + spinner.start (); + + } else { + stack.visible_child_name = "entries"; + spinner.stop (); + } + } + } + + public CloneRepositoryDialog (string _suggested_local_folder, string _suggested_remote) { + Object ( + suggested_local_folder: _suggested_local_folder, + suggested_remote: _suggested_remote + ); + } + + construct { + transient_for = ((Gtk.Application)(GLib.Application.get_default ())).get_active_window (); + image_icon = new ThemedIcon ("git"); + badge_icon = new ThemedIcon ("emblem-downloads"); + modal = true; + + ///TRANSLATORS "Git" is a proper name and must not be translated + primary_text = _("Create a local clone of a Git repository"); + secondary_text = _("The source repository and local folder must exist and have the required read and write permissions"); + + var cancel_button = add_button (_("Cancel"), Gtk.ResponseType.CANCEL); + clone_button = (Gtk.Button)add_button (_("Clone Repository"), Gtk.ResponseType.APPLY); + set_default (clone_button); + + try { + name_regex = new Regex (NAME_REGEX, OPTIMIZE, ANCHORED | NOTEMPTY); + } catch (RegexError e) { + warning ("%s\n", e.message); + } + + remote_repository_uri_entry = new Granite.ValidatedEntry () { + placeholder_text = _("https://example.com/username/projectname.git"), + input_purpose = URL, + activates_default = true + }; + remote_repository_uri_entry.changed.connect (on_remote_uri_changed); + remote_repository_uri_entry.text = suggested_remote; + + // The suggested folder is assumed to be valid as it is generated internally + projects_folder_label = new Gtk.Label (suggested_local_folder) { + hexpand = true, + halign = START + }; + + var folder_chooser_button_child = new Gtk.Box (HORIZONTAL, 6); + folder_chooser_button_child.add (projects_folder_label); + folder_chooser_button_child.add ( + new Gtk.Image.from_icon_name ("folder-open-symbolic", BUTTON) + ); + + var folder_chooser_button = new Gtk.Button () { + child = folder_chooser_button_child + }; + folder_chooser_button.clicked.connect (() => { + var chooser = new Gtk.FileChooserNative ( + _("Select folder where the cloned repository will be created"), + this.transient_for, + SELECT_FOLDER, + _("Select"), + _("Cancel") + ); + chooser.set_current_folder (projects_folder_label.label); + chooser.response.connect ((res) => { + if (res == Gtk.ResponseType.ACCEPT) { + projects_folder_label.label = chooser.get_filename (); + update_can_clone (); + } + + chooser.destroy (); + }); + chooser.show (); + + }); + + local_project_name_entry = new Granite.ValidatedEntry () { + activates_default = true + }; + local_project_name_entry.changed.connect (validate_local_name); + + var content_box = new Gtk.Grid (); + content_box.attach (new CloneEntry (_("Repository URL"), remote_repository_uri_entry), 0, 0); + content_box.attach (new CloneEntry (_("Location"), folder_chooser_button), 0, 1); + content_box.attach (new CloneEntry (_("Name of Clone"), local_project_name_entry), 0, 2); + content_box.attach (revealer, 1, 2); + + var cloning_box = new Gtk.Box (HORIZONTAL, 12) { + valign = CENTER, + halign = CENTER + }; + var cloning_label = new Granite.HeaderLabel (_("Cloning in progress")); + spinner = new Gtk.Spinner (); + cloning_box.add (cloning_label); + cloning_box.add (spinner); + + stack = new Gtk.Stack (); + stack.add_named (content_box, "entries"); + stack.add_named (cloning_box, "cloning"); + stack.visible_child_name = "entries"; + + custom_bin.add (stack); + custom_bin.show_all (); + + bind_property ("can-clone", clone_button, "sensitive", DEFAULT | SYNC_CREATE); + spinner.bind_property ("active", clone_button, "visible", INVERT_BOOLEAN); + spinner.bind_property ("active", cancel_button, "visible", INVERT_BOOLEAN); + can_clone = false; + + // Focus cancel button so that entry placeholder text shows + cancel_button.grab_focus (); + } + + public string get_projects_folder () { + return projects_folder_label.label; + } + + public string get_remote () { + if (remote_repository_uri_entry.is_valid) { + var uri = remote_repository_uri_entry.text; + var last_separator = uri.last_index_of (Path.DIR_SEPARATOR_S); + return uri.slice (0, last_separator + 1); + } else { + return suggested_remote; + } + } + + public string get_valid_source_repository_uri () requires (can_clone) { + //TODO Further validation here? + return remote_repository_uri_entry.text; + } + + public string get_valid_target () requires (can_clone) { + return Path.build_filename (Path.DIR_SEPARATOR_S, projects_folder_label.label, local_project_name_entry.text); + } + + private void update_can_clone () { + can_clone = remote_repository_uri_entry.is_valid && + local_project_name_entry.is_valid && + projects_folder_label.label != ""; + + // Checking whether the target folder already exists and is not empty occurs after pressing apply + } + + private void on_remote_uri_changed (Gtk.Editable source) { + var entry = (Granite.ValidatedEntry)source; + if (entry.is_valid) { //entry is a URL + //Only accept HTTPS url atm but may also accept ssh address in future + entry.is_valid = validate_https_address (entry.text); + } + + update_can_clone (); + } + + private bool validate_https_address (string address) { + var valid = false; + string? scheme, userinfo, host, path, query, fragment; + int port; + try { + Uri.split ( + address, + UriFlags.NONE, + out scheme, + out userinfo, + out host, + out port, + out path, + out query, + out fragment + ); + + if (query == null && + fragment == null && + scheme == "https" && + host != null && //e.g. github.com + userinfo == null && //User is first part of pat + (port < 0 || port == 443)) { //TODO Allow non-standard port to be selected + + if (path.has_prefix (Path.DIR_SEPARATOR_S)) { + path = path.substring (1, -1); + } + + var parts = path.split (Path.DIR_SEPARATOR_S); + valid = parts.length == 2 && parts[1].has_suffix (".git"); + if (valid) { + local_project_name_entry.text = parts[1].slice (0, -4); + } + } + } catch (UriError e) { + warning ("Uri split error %s", e.message); + } + + return valid; + } + + private void validate_local_name () { + unowned var name = local_project_name_entry.text; + MatchInfo? match_info; + bool valid = false; + if (name_regex.match (name, ANCHORED | NOTEMPTY, out match_info) && match_info.matches ()) { + valid = !name.has_suffix (".git") && !name.has_suffix (".atom"); + } + + local_project_name_entry.is_valid = valid; + update_can_clone (); + } + + private class CloneEntry : Gtk.Box { + public CloneEntry (string label_text, Gtk.Widget entry) { + var label = new Granite.HeaderLabel (label_text) { + mnemonic_widget = entry + }; + + add (label); + add (entry); + } + + construct { + orientation = VERTICAL; + } + } +} diff --git a/src/MainWindow.vala b/src/MainWindow.vala index 440e025c3b..8399e64286 100644 --- a/src/MainWindow.vala +++ b/src/MainWindow.vala @@ -70,6 +70,7 @@ namespace Scratch { public const string ACTION_GROUP = "win"; public const string ACTION_PREFIX = ACTION_GROUP + "."; public const string ACTION_FIND = "action-find"; + public const string ACTION_CLONE_REPO = "action-clone-repo"; public const string ACTION_FIND_NEXT = "action-find-next"; public const string ACTION_FIND_PREVIOUS = "action-find-previous"; public const string ACTION_FIND_GLOBAL = "action-find-global"; @@ -130,6 +131,7 @@ namespace Scratch { private Services.GitManager git_manager; private const ActionEntry[] ACTION_ENTRIES = { + { ACTION_CLONE_REPO, action_clone_repo }, { ACTION_FIND, action_find, "s"}, { ACTION_FIND_NEXT, action_find_next }, { ACTION_FIND_PREVIOUS, action_find_previous }, @@ -205,6 +207,7 @@ namespace Scratch { action_accelerators.set (ACTION_FIND_PREVIOUS, "g"); action_accelerators.set (ACTION_FIND_GLOBAL + "::", "f"); action_accelerators.set (ACTION_OPEN, "o"); + action_accelerators.set (ACTION_OPEN_FOLDER, "o"); action_accelerators.set (ACTION_REVERT, "o"); action_accelerators.set (ACTION_SAVE, "s"); action_accelerators.set (ACTION_SAVE_AS, "s"); @@ -1040,6 +1043,76 @@ namespace Scratch { } } + private void action_clone_repo (SimpleAction action, Variant? param) { + var default_projects_folder = Scratch.settings.get_string ("default-projects-folder"); + if (default_projects_folder == "" && git_manager.active_project_path != "") { + default_projects_folder = Path.get_dirname (git_manager.active_project_path); + } + + var default_remote = Scratch.settings.get_string ("default-remote"); + var clone_dialog = new Dialogs.CloneRepositoryDialog (default_projects_folder, default_remote); + clone_dialog.response.connect ((res) => { + // Persist last entries (not necessarily valid) + Scratch.settings.set_string ("default-remote", clone_dialog.get_remote ()); + Scratch.settings.set_string ("default-projects-folder", clone_dialog.get_projects_folder ()); + // Clone dialog show spinner during cloning so keep visible + //TODO Show more information re progress using Ggit callbacks + if (res == Gtk.ResponseType.APPLY && clone_dialog.can_clone) { + clone_dialog.cloning_in_progress = true; + var uri = clone_dialog.get_valid_source_repository_uri (); + var target = clone_dialog.get_valid_target (); + git_manager.clone_repository.begin ( + uri, + target, + (obj, res) => { + clone_dialog.cloning_in_progress = false; + File? workdir = null; + string? error = null; + if (git_manager.clone_repository.end (res, out workdir, out error)) { + open_folder (workdir); + clone_dialog.destroy (); + var message_dialog = new Granite.MessageDialog.with_image_from_icon_name ( + "Repository %s successfully cloned".printf (uri), + "Local repository working directory is %s".printf (workdir.get_uri ()), + "dialog-information", + Gtk.ButtonsType.CLOSE + ) { + transient_for = this + }; + message_dialog.response.connect (message_dialog.destroy); + message_dialog.present (); + } else { + clone_dialog.hide (); + var message_dialog = new Granite.MessageDialog.with_image_from_icon_name ( + "Unable to clone %s".printf (uri), + error, + "dialog-error", + Gtk.ButtonsType.CLOSE + ) { + transient_for = this + }; + message_dialog.add_button (_("Retry"), 1); + message_dialog.response.connect ((res) => { + if (res == 1) { + clone_dialog.show (); + } else { + clone_dialog.destroy (); + } + + message_dialog.destroy (); + }); + message_dialog.present (); + } + } + ); + } else { + clone_dialog.destroy (); + } + }); + + clone_dialog.present (); + } + private void action_collapse_all_folders () { folder_manager_view.collapse_all (); } diff --git a/src/Services/GitManager.vala b/src/Services/GitManager.vala index 3b43bb88aa..5b80d3084e 100644 --- a/src/Services/GitManager.vala +++ b/src/Services/GitManager.vala @@ -112,5 +112,54 @@ namespace Scratch.Services { return build_path; } + + public async bool clone_repository ( + string uri, + string local_folder, + out File? repo_workdir, + out string? error + ) { + repo_workdir = null; + error = null; + + var fetch_options = new Ggit.FetchOptions (); + fetch_options.set_download_tags (Ggit.RemoteDownloadTagsType.UNSPECIFIED); + //TODO Set callbacks for authentification and progress + fetch_options.set_remote_callbacks (null); + + var clone_options = new Ggit.CloneOptions (); + clone_options.set_local (Ggit.CloneLocal.AUTO); + clone_options.set_is_bare (false); + clone_options.set_fetch_options (fetch_options); + + var e_message = ""; // Cannot capture out parameter so make local proxy + var folder_file = File.new_for_path (local_folder); + Ggit.Repository? new_repo = null; + + SourceFunc callback = clone_repository.callback; + new Thread ("cloning", () => { + try { + new_repo = Ggit.Repository.clone ( + uri, + folder_file, + clone_options + ); + } catch (Error e) { + e_message = e.message; + new_repo = null; + } + + Idle.add ((owned)callback); + }); + + yield; + if (new_repo != null) { + repo_workdir = new_repo.get_workdir (); + } else { + error = e_message; + } + + return new_repo != null; + } } } diff --git a/src/Widgets/ChooseProjectButton.vala b/src/Widgets/ChooseProjectButton.vala index 90fba92596..3b035c5b2d 100644 --- a/src/Widgets/ChooseProjectButton.vala +++ b/src/Widgets/ChooseProjectButton.vala @@ -71,9 +71,23 @@ public class Code.ChooseProjectButton : Gtk.MenuButton { project_scrolled.add (project_listbox); + var add_folder_button = new PopoverMenuItem (_("Open Folder…")) { + action_name = Scratch.MainWindow.ACTION_PREFIX + Scratch.MainWindow.ACTION_OPEN_FOLDER, + action_target = new Variant.string (""), + icon_name = "folder-open-symbolic", + }; + + var clone_button = new PopoverMenuItem (_("Clone Git Repository…")) { + action_name = Scratch.MainWindow.ACTION_PREFIX + Scratch.MainWindow.ACTION_CLONE_REPO, + icon_name = "git-symbolic" + }; + var popover_content = new Gtk.Box (Gtk.Orientation.VERTICAL, 0); popover_content.add (project_filter); popover_content.add (project_scrolled); + popover_content.add (new Gtk.Separator (HORIZONTAL)); + popover_content.add (add_folder_button); + popover_content.add (clone_button); popover_content.show_all (); diff --git a/src/Widgets/PopoverMenuItem.vala b/src/Widgets/PopoverMenuItem.vala new file mode 100644 index 0000000000..72461986fc --- /dev/null +++ b/src/Widgets/PopoverMenuItem.vala @@ -0,0 +1,48 @@ +/* +* SPDX-License-Identifier: GPL-2.0-or-later +* SPDX-FileCopyrightText: 2017-2023 elementary, Inc. (https://elementary.io) +*/ + +public class Code.PopoverMenuItem : Gtk.Button { + /** + * The label for the button + */ + public string text { get; construct; } + + /** + * The icon name for the button + */ + public string icon_name { get; set; } + + public PopoverMenuItem (string text) { + Object (text: text); + } + + class construct { + set_css_name ("modelbutton"); + } + + construct { + var image = new Gtk.Image (); + + var label = new Granite.AccelLabel (text); + + var box = new Gtk.Box (HORIZONTAL, 6); + box.add (image); + box.add (label); + + child = box; + + get_accessible ().accessible_role = MENU_ITEM; + + clicked.connect (() => { + var popover = (Gtk.Popover) get_ancestor (typeof (Gtk.Popover)); + if (popover != null) { + popover.popdown (); + } + }); + + bind_property ("action-name", label, "action-name"); + bind_property ("icon-name", image, "icon-name"); + } +} diff --git a/src/Widgets/Sidebar.vala b/src/Widgets/Sidebar.vala index 209548d2c5..ff3060cf42 100644 --- a/src/Widgets/Sidebar.vala +++ b/src/Widgets/Sidebar.vala @@ -56,13 +56,6 @@ public class Code.Sidebar : Gtk.Grid { var actionbar = new Gtk.ActionBar (); actionbar.get_style_context ().add_class (Gtk.STYLE_CLASS_INLINE_TOOLBAR); - var add_folder_button = new Gtk.Button.from_icon_name ("folder-open-symbolic", Gtk.IconSize.SMALL_TOOLBAR) { - action_name = Scratch.MainWindow.ACTION_PREFIX + Scratch.MainWindow.ACTION_OPEN_FOLDER, - action_target = new Variant.string (""), - always_show_image = true, - label = _("Open Folder…") - }; - var collapse_all_menu_item = new GLib.MenuItem (_("Collapse All"), Scratch.MainWindow.ACTION_PREFIX + Scratch.MainWindow.ACTION_COLLAPSE_ALL_FOLDERS); @@ -74,14 +67,17 @@ public class Code.Sidebar : Gtk.Grid { project_menu.append_item (order_projects_menu_item); project_menu_model = project_menu; - var project_more_button = new Gtk.MenuButton (); - project_more_button.image = new Gtk.Image.from_icon_name ("view-more-symbolic", Gtk.IconSize.SMALL_TOOLBAR); - project_more_button.use_popover = false; - project_more_button.menu_model = project_menu_model; - project_more_button.tooltip_text = _("Manage project folders"); + var label = new Gtk.Label ( _("Manage project folders…")) { + halign = START + }; + var project_menu_button = new Gtk.MenuButton () { + hexpand = true, + use_popover = false, + menu_model = project_menu_model, + child = label + }; - actionbar.add (add_folder_button); - actionbar.pack_end (project_more_button); + actionbar.pack_start (project_menu_button); add (headerbar); add (stack_switcher); diff --git a/src/meson.build b/src/meson.build index 27af837449..b5a5aca596 100644 --- a/src/meson.build +++ b/src/meson.build @@ -21,6 +21,7 @@ code_files = files( 'Dialogs/PreferencesDialog.vala', 'Dialogs/RestoreConfirmationDialog.vala', 'Dialogs/CloseProjectsConfirmationDialog.vala', + 'Dialogs/CloneRepositoryDialog.vala', 'Dialogs/OverwriteUncommittedConfirmationDialog.vala', 'Dialogs/GlobalSearchDialog.vala', 'Dialogs/NewBranchDialog.vala', @@ -49,6 +50,7 @@ code_files = files( 'Widgets/HeaderBar.vala', 'Widgets/Sidebar.vala', 'Widgets/PaneSwitcher.vala', + 'Widgets/PopoverMenuItem.vala', 'Widgets/SearchBar.vala', 'Widgets/SourceList/CellRendererBadge.vala', 'Widgets/SourceList/CellRendererExpander.vala',