Skip to content

Clone remote repository. #1548

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 67 commits into from
Jun 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
e1d2e5c
Add clone button to sidebar
jeremypw Mar 26, 2025
ebb8ed1
Sketch out functionality
jeremypw Mar 26, 2025
5200d9d
Fix action, Tweak regex
jeremypw Mar 26, 2025
01d124f
Various corrections
jeremypw Mar 26, 2025
e39eac8
Clone repository
jeremypw Mar 26, 2025
7e3da04
Reformat, local repo name, make active option
jeremypw Apr 3, 2025
3b4ec49
Merge branch 'master' into jeremypw/implement-clone
jeremypw Apr 3, 2025
328c75b
Merge branch 'master' into jeremypw/implement-clone
jeremypw Apr 22, 2025
a267a98
Fix lint
jeremypw Apr 22, 2025
7881aee
Merge branch 'master' into jeremypw/implement-clone
jeremypw May 18, 2025
85b2bb3
Merge branch 'master' into jeremypw/implement-clone
jeremypw May 27, 2025
2251850
Allow narrower sidebar
jeremypw May 27, 2025
15c6fc5
Merge branch 'master' into jeremypw/implement-clone
jeremypw May 28, 2025
ea1a356
Update src/Dialogs/CloneRepositoryDialog.vala
jeremypw May 28, 2025
576ca43
Update src/Dialogs/CloneRepositoryDialog.vala
jeremypw May 28, 2025
9c38d00
Replace local folder path entry with button linked to filechooser
jeremypw May 28, 2025
5287f59
Set mnemonic widget for CloneEntry label
jeremypw May 28, 2025
9f8bf46
Fix construction as advised, add comments
jeremypw May 28, 2025
497bac7
Better variable names
jeremypw May 28, 2025
b4c79ed
Remove activates_default = false;
jeremypw May 28, 2025
55490d2
Rewording
jeremypw May 28, 2025
7a4bf5d
Use "emblem-downloads" icon
jeremypw May 28, 2025
a59c64b
No space before ellipsis
jeremypw May 28, 2025
6138cfa
Do not use Gtk.Button.from_icon_name
jeremypw May 28, 2025
0582c77
More explicit labels
jeremypw May 28, 2025
50293b5
Merge branch 'master' into jeremypw/implement-clone
danirabbit May 28, 2025
ec4b2d0
Simplify
jeremypw May 28, 2025
4dd6f6d
Merge branch 'master' into jeremypw/implement-clone
jeremypw May 28, 2025
ffbb883
Show all only on content_box
jeremypw May 28, 2025
636d871
Use box padding not margin
jeremypw May 28, 2025
7cadd28
CloneRepositoryDialog: design tweaks (#1584)
danirabbit May 30, 2025
2c315a2
Move buttons from actionbar to projectchooser (#1583)
danirabbit May 30, 2025
1bfa1aa
Check URL is in correct form using function not regex
jeremypw Jun 1, 2025
d428416
Merge branch 'master' into jeremypw/implement-clone
jeremypw Jun 4, 2025
3273fe5
Prefill local name; improve NAME_REGEX
jeremypw Jun 4, 2025
67be8d9
Restrict project folder names
jeremypw Jun 4, 2025
273113c
Merge branch 'master' into jeremypw/implement-clone
jeremypw Jun 4, 2025
731a05a
Fix extraneous comment
jeremypw Jun 4, 2025
e3a22b9
Remove set as active checkbox for now
jeremypw Jun 4, 2025
25bbd0e
Use SYNC_CREATE
jeremypw Jun 4, 2025
4d66c36
Fix unneeded try-catch
jeremypw Jun 4, 2025
379ef30
Move cloning into response callback
jeremypw Jun 4, 2025
c2fe76f
Close dialog before cloning starts
jeremypw Jun 4, 2025
22e57b7
Add TODO comment
jeremypw Jun 4, 2025
7d60208
Fix undefined variable
jeremypw Jun 5, 2025
2595107
Handle clone failure with dialog - option to retry
jeremypw Jun 5, 2025
1454a5b
Handle no current active project
jeremypw Jun 5, 2025
1b3232d
Persist projects folder and remote entries
jeremypw Jun 5, 2025
0ae3fb9
Fix lint
jeremypw Jun 5, 2025
e949241
Merge branch 'master' into jeremypw/implement-clone
jeremypw Jun 5, 2025
5536cc4
Merge branch 'master' into jeremypw/implement-clone
leonardo-lemos Jun 13, 2025
784460d
Merge branch 'master' into jeremypw/implement-clone
zeebok Jun 15, 2025
6915fce
Merge branch 'master' into jeremypw/implement-clone
jeremypw Jun 20, 2025
01346c9
Add on accelerator to open a project
jeremypw Jun 20, 2025
4119847
Merge branch 'master' into jeremypw/implement-clone
danirabbit Jun 20, 2025
db0e6f5
Merge branch 'master' into jeremypw/implement-clone
jeremypw Jun 22, 2025
af36e42
Set parent to error dialog, fix spelling
jeremypw Jun 22, 2025
e463c9f
Make clone function async
jeremypw Jun 23, 2025
9d95150
Include success messagedialog
jeremypw Jun 23, 2025
d049990
Only construct parameters in Object ()
jeremypw Jun 23, 2025
1a09d91
Use a thread for cloning, show spinner
jeremypw Jun 23, 2025
fc536e0
Remove unneeded throws Error
jeremypw Jun 23, 2025
49e95a9
Fix some coding issues
jeremypw Jun 24, 2025
e4cf12b
Fix typo
jeremypw Jun 24, 2025
4469d6c
Show spinner on separate page; hide buttons
jeremypw Jun 24, 2025
1afe983
Cleanup comments
jeremypw Jun 24, 2025
6779e66
Reorganise, reinstate default action
jeremypw Jun 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions data/icons/git-symbolic.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion data/io.elementary.code.gresource.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
<gresources>
<gresource prefix="/io/elementary/code">
<file alias="Application.css" compressed="true">Application.css</file>
<file alias="git.svg" compressed="true" preprocess="xml-stripblanks">icons/48/git.svg</file>
<file alias="lang-class-abstract.svg" compressed="true" preprocess="xml-stripblanks">icons/SymbolOutline/abstractclass.svg</file>
<file alias="lang-method-abstract.svg" compressed="true" preprocess="xml-stripblanks">icons/SymbolOutline/abstractmethod.svg</file>
<file alias="lang-property-abstract.svg" compressed="true" preprocess="xml-stripblanks">icons/SymbolOutline/abstractproperty.svg</file>
Expand Down Expand Up @@ -30,8 +29,10 @@
<file alias="scalable/actions/panel-right-symbolic.svg" compressed="true" preprocess="xml-stripblanks">icons/panel-right-symbolic.svg</file>
</gresource>
<gresource prefix="/io/elementary/code/icons">
<file alias="48x48/actions/git.svg" compressed="true" preprocess="xml-stripblanks">icons/48/git.svg</file>
<file alias="48x48/actions/open-project.svg" compressed="true" preprocess="xml-stripblanks">icons/48/open-project.svg</file>
<file alias="scalable/actions/filter-symbolic.svg" compressed="true" preprocess="xml-stripblanks">icons/filter-symbolic.svg</file>
<file alias="scalable/actions/git-symbolic.svg" compressed="true" preprocess="xml-stripblanks">icons/git-symbolic.svg</file>
<file alias="scalable/emblems/emblem-git-modified-symbolic.svg" compressed="true" preprocess="xml-stripblanks">icons/emblem-git-modified-symbolic.svg</file>
<file alias="scalable/emblems/emblem-git-new-symbolic.svg" compressed="true" preprocess="xml-stripblanks">icons/emblem-git-new-symbolic.svg</file>
</gresource>
Expand Down
10 changes: 10 additions & 0 deletions data/io.elementary.code.gschema.xml
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,16 @@
<summary>The default build directory's relative path.</summary>
<description>The directory, relative to the project root, at which to open the terminal pane and where to run build commands by default.</description>
</key>
<key name="default-projects-folder" type="s">
<default>''</default>
<summary>The default Projects folder</summary>
<description>The path to the folder below which projects are saved or cloned</description>
</key>
<key name="default-remote" type="s">
<default>''</default>
<summary>The default git remote</summary>
<description>The URL of the remote from where repositories can be cloned, for example https://github.com/elementary/</description>
</key>
<key name="prefer-dark-style" type="b">
<default>false</default>
<summary>Request dark Gtk stylesheet variant</summary>
Expand Down
258 changes: 258 additions & 0 deletions src/Dialogs/CloneRepositoryDialog.vala
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
/*
* SPDX-License-Identifier: GPL-2.0-or-later
* SPDX-FileCopyrightText: 2025 elementary, Inc. <https://elementary.io>
*
* Authored by: Jeremy Wootten <[email protected]>
*/

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;
}
}
}
Loading