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 @@
+
+
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',