Skip to content

Commit 34b0af4

Browse files
authored
Clone remote repository. (#1548)
1 parent 9b8347a commit 34b0af4

File tree

10 files changed

+470
-15
lines changed

10 files changed

+470
-15
lines changed

data/icons/git-symbolic.svg

Lines changed: 4 additions & 0 deletions
Loading

data/io.elementary.code.gresource.xml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
<gresources>
33
<gresource prefix="/io/elementary/code">
44
<file alias="Application.css" compressed="true">Application.css</file>
5-
<file alias="git.svg" compressed="true" preprocess="xml-stripblanks">icons/48/git.svg</file>
65
<file alias="lang-class-abstract.svg" compressed="true" preprocess="xml-stripblanks">icons/SymbolOutline/abstractclass.svg</file>
76
<file alias="lang-method-abstract.svg" compressed="true" preprocess="xml-stripblanks">icons/SymbolOutline/abstractmethod.svg</file>
87
<file alias="lang-property-abstract.svg" compressed="true" preprocess="xml-stripblanks">icons/SymbolOutline/abstractproperty.svg</file>
@@ -30,8 +29,10 @@
3029
<file alias="scalable/actions/panel-right-symbolic.svg" compressed="true" preprocess="xml-stripblanks">icons/panel-right-symbolic.svg</file>
3130
</gresource>
3231
<gresource prefix="/io/elementary/code/icons">
32+
<file alias="48x48/actions/git.svg" compressed="true" preprocess="xml-stripblanks">icons/48/git.svg</file>
3333
<file alias="48x48/actions/open-project.svg" compressed="true" preprocess="xml-stripblanks">icons/48/open-project.svg</file>
3434
<file alias="scalable/actions/filter-symbolic.svg" compressed="true" preprocess="xml-stripblanks">icons/filter-symbolic.svg</file>
35+
<file alias="scalable/actions/git-symbolic.svg" compressed="true" preprocess="xml-stripblanks">icons/git-symbolic.svg</file>
3536
<file alias="scalable/emblems/emblem-git-modified-symbolic.svg" compressed="true" preprocess="xml-stripblanks">icons/emblem-git-modified-symbolic.svg</file>
3637
<file alias="scalable/emblems/emblem-git-new-symbolic.svg" compressed="true" preprocess="xml-stripblanks">icons/emblem-git-new-symbolic.svg</file>
3738
</gresource>

data/io.elementary.code.gschema.xml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,16 @@
162162
<summary>The default build directory's relative path.</summary>
163163
<description>The directory, relative to the project root, at which to open the terminal pane and where to run build commands by default.</description>
164164
</key>
165+
<key name="default-projects-folder" type="s">
166+
<default>''</default>
167+
<summary>The default Projects folder</summary>
168+
<description>The path to the folder below which projects are saved or cloned</description>
169+
</key>
170+
<key name="default-remote" type="s">
171+
<default>''</default>
172+
<summary>The default git remote</summary>
173+
<description>The URL of the remote from where repositories can be cloned, for example https://github.com/elementary/</description>
174+
</key>
165175
<key name="prefer-dark-style" type="b">
166176
<default>false</default>
167177
<summary>Request dark Gtk stylesheet variant</summary>
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
/*
2+
* SPDX-License-Identifier: GPL-2.0-or-later
3+
* SPDX-FileCopyrightText: 2025 elementary, Inc. <https://elementary.io>
4+
*
5+
* Authored by: Jeremy Wootten <[email protected]>
6+
*/
7+
8+
public class Scratch.Dialogs.CloneRepositoryDialog : Granite.MessageDialog {
9+
// Git project name rules according to GitLab
10+
// - Must start and end with a letter ( a-zA-Z ) or digit ( 0-9 ).
11+
// - Can contain only letters ( a-zA-Z ), digits ( 0-9 ), underscores ( _ ), dots ( . ), or dashes ( - ).
12+
// - Must not contain consecutive special characters.
13+
// - Cannot end in . git or . atom .
14+
private const string NAME_REGEX = """^[0-9a-zA-Z].([-_.]?[0-9a-zA-Z])*$"""; //TODO additional validation required
15+
16+
private Regex name_regex;
17+
private Gtk.Label projects_folder_label;
18+
private Granite.ValidatedEntry remote_repository_uri_entry;
19+
private Granite.ValidatedEntry local_project_name_entry;
20+
private Gtk.Button clone_button;
21+
private Gtk.Stack stack;
22+
private Gtk.Spinner spinner;
23+
private Gtk.Revealer revealer;
24+
25+
public bool can_clone { get; private set; default = false; }
26+
public string suggested_local_folder { get; construct; }
27+
public string suggested_remote { get; construct; }
28+
29+
public bool cloning_in_progress {
30+
set {
31+
if (value) {
32+
stack.visible_child_name = "cloning";
33+
spinner.start ();
34+
35+
} else {
36+
stack.visible_child_name = "entries";
37+
spinner.stop ();
38+
}
39+
}
40+
}
41+
42+
public CloneRepositoryDialog (string _suggested_local_folder, string _suggested_remote) {
43+
Object (
44+
suggested_local_folder: _suggested_local_folder,
45+
suggested_remote: _suggested_remote
46+
);
47+
}
48+
49+
construct {
50+
transient_for = ((Gtk.Application)(GLib.Application.get_default ())).get_active_window ();
51+
image_icon = new ThemedIcon ("git");
52+
badge_icon = new ThemedIcon ("emblem-downloads");
53+
modal = true;
54+
55+
///TRANSLATORS "Git" is a proper name and must not be translated
56+
primary_text = _("Create a local clone of a Git repository");
57+
secondary_text = _("The source repository and local folder must exist and have the required read and write permissions");
58+
59+
var cancel_button = add_button (_("Cancel"), Gtk.ResponseType.CANCEL);
60+
clone_button = (Gtk.Button)add_button (_("Clone Repository"), Gtk.ResponseType.APPLY);
61+
set_default (clone_button);
62+
63+
try {
64+
name_regex = new Regex (NAME_REGEX, OPTIMIZE, ANCHORED | NOTEMPTY);
65+
} catch (RegexError e) {
66+
warning ("%s\n", e.message);
67+
}
68+
69+
remote_repository_uri_entry = new Granite.ValidatedEntry () {
70+
placeholder_text = _("https://example.com/username/projectname.git"),
71+
input_purpose = URL,
72+
activates_default = true
73+
};
74+
remote_repository_uri_entry.changed.connect (on_remote_uri_changed);
75+
remote_repository_uri_entry.text = suggested_remote;
76+
77+
// The suggested folder is assumed to be valid as it is generated internally
78+
projects_folder_label = new Gtk.Label (suggested_local_folder) {
79+
hexpand = true,
80+
halign = START
81+
};
82+
83+
var folder_chooser_button_child = new Gtk.Box (HORIZONTAL, 6);
84+
folder_chooser_button_child.add (projects_folder_label);
85+
folder_chooser_button_child.add (
86+
new Gtk.Image.from_icon_name ("folder-open-symbolic", BUTTON)
87+
);
88+
89+
var folder_chooser_button = new Gtk.Button () {
90+
child = folder_chooser_button_child
91+
};
92+
folder_chooser_button.clicked.connect (() => {
93+
var chooser = new Gtk.FileChooserNative (
94+
_("Select folder where the cloned repository will be created"),
95+
this.transient_for,
96+
SELECT_FOLDER,
97+
_("Select"),
98+
_("Cancel")
99+
);
100+
chooser.set_current_folder (projects_folder_label.label);
101+
chooser.response.connect ((res) => {
102+
if (res == Gtk.ResponseType.ACCEPT) {
103+
projects_folder_label.label = chooser.get_filename ();
104+
update_can_clone ();
105+
}
106+
107+
chooser.destroy ();
108+
});
109+
chooser.show ();
110+
111+
});
112+
113+
local_project_name_entry = new Granite.ValidatedEntry () {
114+
activates_default = true
115+
};
116+
local_project_name_entry.changed.connect (validate_local_name);
117+
118+
var content_box = new Gtk.Grid ();
119+
content_box.attach (new CloneEntry (_("Repository URL"), remote_repository_uri_entry), 0, 0);
120+
content_box.attach (new CloneEntry (_("Location"), folder_chooser_button), 0, 1);
121+
content_box.attach (new CloneEntry (_("Name of Clone"), local_project_name_entry), 0, 2);
122+
content_box.attach (revealer, 1, 2);
123+
124+
var cloning_box = new Gtk.Box (HORIZONTAL, 12) {
125+
valign = CENTER,
126+
halign = CENTER
127+
};
128+
var cloning_label = new Granite.HeaderLabel (_("Cloning in progress"));
129+
spinner = new Gtk.Spinner ();
130+
cloning_box.add (cloning_label);
131+
cloning_box.add (spinner);
132+
133+
stack = new Gtk.Stack ();
134+
stack.add_named (content_box, "entries");
135+
stack.add_named (cloning_box, "cloning");
136+
stack.visible_child_name = "entries";
137+
138+
custom_bin.add (stack);
139+
custom_bin.show_all ();
140+
141+
bind_property ("can-clone", clone_button, "sensitive", DEFAULT | SYNC_CREATE);
142+
spinner.bind_property ("active", clone_button, "visible", INVERT_BOOLEAN);
143+
spinner.bind_property ("active", cancel_button, "visible", INVERT_BOOLEAN);
144+
can_clone = false;
145+
146+
// Focus cancel button so that entry placeholder text shows
147+
cancel_button.grab_focus ();
148+
}
149+
150+
public string get_projects_folder () {
151+
return projects_folder_label.label;
152+
}
153+
154+
public string get_remote () {
155+
if (remote_repository_uri_entry.is_valid) {
156+
var uri = remote_repository_uri_entry.text;
157+
var last_separator = uri.last_index_of (Path.DIR_SEPARATOR_S);
158+
return uri.slice (0, last_separator + 1);
159+
} else {
160+
return suggested_remote;
161+
}
162+
}
163+
164+
public string get_valid_source_repository_uri () requires (can_clone) {
165+
//TODO Further validation here?
166+
return remote_repository_uri_entry.text;
167+
}
168+
169+
public string get_valid_target () requires (can_clone) {
170+
return Path.build_filename (Path.DIR_SEPARATOR_S, projects_folder_label.label, local_project_name_entry.text);
171+
}
172+
173+
private void update_can_clone () {
174+
can_clone = remote_repository_uri_entry.is_valid &&
175+
local_project_name_entry.is_valid &&
176+
projects_folder_label.label != "";
177+
178+
// Checking whether the target folder already exists and is not empty occurs after pressing apply
179+
}
180+
181+
private void on_remote_uri_changed (Gtk.Editable source) {
182+
var entry = (Granite.ValidatedEntry)source;
183+
if (entry.is_valid) { //entry is a URL
184+
//Only accept HTTPS url atm but may also accept ssh address in future
185+
entry.is_valid = validate_https_address (entry.text);
186+
}
187+
188+
update_can_clone ();
189+
}
190+
191+
private bool validate_https_address (string address) {
192+
var valid = false;
193+
string? scheme, userinfo, host, path, query, fragment;
194+
int port;
195+
try {
196+
Uri.split (
197+
address,
198+
UriFlags.NONE,
199+
out scheme,
200+
out userinfo,
201+
out host,
202+
out port,
203+
out path,
204+
out query,
205+
out fragment
206+
);
207+
208+
if (query == null &&
209+
fragment == null &&
210+
scheme == "https" &&
211+
host != null && //e.g. github.com
212+
userinfo == null && //User is first part of pat
213+
(port < 0 || port == 443)) { //TODO Allow non-standard port to be selected
214+
215+
if (path.has_prefix (Path.DIR_SEPARATOR_S)) {
216+
path = path.substring (1, -1);
217+
}
218+
219+
var parts = path.split (Path.DIR_SEPARATOR_S);
220+
valid = parts.length == 2 && parts[1].has_suffix (".git");
221+
if (valid) {
222+
local_project_name_entry.text = parts[1].slice (0, -4);
223+
}
224+
}
225+
} catch (UriError e) {
226+
warning ("Uri split error %s", e.message);
227+
}
228+
229+
return valid;
230+
}
231+
232+
private void validate_local_name () {
233+
unowned var name = local_project_name_entry.text;
234+
MatchInfo? match_info;
235+
bool valid = false;
236+
if (name_regex.match (name, ANCHORED | NOTEMPTY, out match_info) && match_info.matches ()) {
237+
valid = !name.has_suffix (".git") && !name.has_suffix (".atom");
238+
}
239+
240+
local_project_name_entry.is_valid = valid;
241+
update_can_clone ();
242+
}
243+
244+
private class CloneEntry : Gtk.Box {
245+
public CloneEntry (string label_text, Gtk.Widget entry) {
246+
var label = new Granite.HeaderLabel (label_text) {
247+
mnemonic_widget = entry
248+
};
249+
250+
add (label);
251+
add (entry);
252+
}
253+
254+
construct {
255+
orientation = VERTICAL;
256+
}
257+
}
258+
}

0 commit comments

Comments
 (0)