✨ (web): Show the repo sidebar on every page and give tags their own view
Changes
24 files changed, +595 -440
MODIFY
README.md
+2 -2
@@ -14,12 +14,12 @@
14
14
- push and private read authenticate with **personal access tokens** (HTTP Basic password)
15
15
- Clone/fetch/push over `ssh://git@<host>:2222/<owner>/<repo>.git`
16
16
- public-key authentication only; keys managed per user in the UI
17
-- Web UI: landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser with self-hosted syntax highlighting (extension-based language detection, falls back to plain text for unknown extensions and binary files), a rendered README (commonmark-java, XSS-safe) shown below the file list on the repository overview page, commit log (paginated), branches & tags, one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Repository pages share Files / Commits / Branches / Issues / Merge requests tab navigation that preserves the selected ref, and the clone panel has copy-to-clipboard buttons for the HTTP and SSH `git clone` commands. Keyboard shortcuts are an optional, progressive enhancement (`?` opens a help overlay, `Escape` closes it, `g h` goes home) — every page works fully without JavaScript
17
+- Web UI: landing page with login CTA for visitors (`/`), repository list for authenticated users (`/`), public repository browse at `/explore`, file/tree browser with self-hosted syntax highlighting (extension-based language detection, falls back to plain text for unknown extensions and binary files), a rendered README (commonmark-java, XSS-safe) shown below the file list on the repository overview page, commit log (paginated), branches, tags (own dedicated page, separate from branches), one-time handle selection (`/onboarding`), profile settings (`/settings/profile`). Every repository sub-page shows a persistent left sidebar with repo identity, a Clone button opening the clone dialog, a pin toggle, and section navigation (Code, Commits, Branches, Tags, Issues, Merge requests) with per-section counts and active-section highlighting, and the clone panel has copy-to-clipboard buttons for the HTTP and SSH `git clone` commands. Keyboard shortcuts are an optional, progressive enhancement (`?` opens a help overlay, `Escape` closes it, `g h` goes home) — every page works fully without JavaScript
18
18
- Per-repository issues: title, optional description, per-repo sequential number (`#1`, `#2`, …), and author; created and managed by the repo owner, readable by anyone who can read the repo, via a dedicated "New issue" page
19
19
- Issues move through a fixed lifecycle (Planned → In development → Done); the repo navigation shows the open (Planned + In development) issue count, and Done issues collapse into an "Archive" section on the issues page
20
20
- Issues auto-close from pushed commit messages, GitHub-style (`close(s|d)`/`fix(es|ed)`/`resolve(s|d)` + `#<number>`, e.g. `fixes #12`), over both HTTP and SSH pushes
21
21
- Per-repository merge requests: source → target branch within one repo, with a title, optional description, a per-repo sequential number displayed bang-prefixed (`!1`, `!2`, …, distinct from issues' `#`), and an author; created and managed by the repo owner, readable by anyone who can read the repo, via a dedicated "New merge request" page where the owner picks source and target from the repo's branches
22
-- Merge requests move through the lifecycle Open → Merged / Closed; the repo navigation and shared tab bar show the open merge-request count, and merged/closed ones collapse into an "Archive" section on the list page (same pattern as issues)
22
+- Merge requests move through the lifecycle Open → Merged / Closed; the repo navigation and left sidebar show the open merge-request count, and merged/closed ones collapse into an "Archive" section on the list page (same pattern as issues)
23
23
- The merge request detail page renders the live diff of the source branch relative to the merge base with the target (three-dot diff), file by file with per-line add/delete coloring and a changed-files / +additions / −deletions summary — always computed live from git, never duplicated into the database
24
24
- The owner can Merge or Close an open merge request from the detail page; merging runs entirely in-core against the bare repository (no working tree), fast-forwarding when possible or else recording a two-parent merge commit authored by the acting user and advancing the target branch ref. An automatic merge that would conflict is rejected; a source branch already contained in the target is treated as already merged
25
25
- Line-level review comments on a merge request's diff: any authenticated user who can read the repository can comment on a specific diff line (added, deleted, or context) from the merge-request detail page; comments render inline beneath the line they anchor to. A comment can be deleted by its author or by the repository owner. Comments are anchored to a file plus the diff line's old/new line numbers and must land on a line that's part of the current diff. Hovering a commentable line reveals a comment icon on the right; clicking it opens the form inline — a progressive-enhancement disclosure that works without JavaScript
MODIFY
src/main/java/de/workaround/web/IssueResource.java
+16 -6
@@ -22,8 +22,10 @@
22
22
import jakarta.ws.rs.POST;
23
23
import jakarta.ws.rs.PathParam;
24
24
import jakarta.ws.rs.Produces;
25
+import jakarta.ws.rs.core.Context;
25
26
import jakarta.ws.rs.core.MediaType;
26
27
import jakarta.ws.rs.core.Response;
28
+import jakarta.ws.rs.core.UriInfo;
27
29
28
30
@jakarta.ws.rs.Path("/repos/{owner}/{name}/issues")
29
31
@Produces(MediaType.TEXT_HTML)
@@ -32,11 +34,13 @@
32
34
@CheckedTemplate
33
35
static class Templates
34
36
{
35
- static native TemplateInstance issues(Repository repo, boolean owner, List<Issue> open, List<Issue> done);
37
+ static native TemplateInstance issues(Repository repo, RepoNav nav, boolean owner, List<Issue> open,
38
+ List<Issue> done);
36
39
37
- static native TemplateInstance newIssue(Repository repo);
40
+ static native TemplateInstance newIssue(Repository repo, RepoNav nav);
38
41
39
- static native TemplateInstance issue(Repository repo, boolean owner, Issue issue, List<Issue.Status> statuses);
42
+ static native TemplateInstance issue(Repository repo, RepoNav nav, boolean owner, Issue issue,
43
+ List<Issue.Status> statuses);
40
44
}
41
45
42
46
@Inject
@@ -51,6 +55,12 @@
51
55
@Inject
52
56
IssueService issueService;
53
57
58
+ @Inject
59
+ RepoNavService repoNav;
60
+
61
+ @Context
62
+ UriInfo uriInfo;
63
+
54
64
@GET
55
65
public TemplateInstance list(@PathParam("owner") String owner, @PathParam("name") String name)
56
66
{
@@ -61,7 +71,7 @@
61
71
// open issues stay visible; DONE issues are tucked into a collapsible archive on the page
62
72
List<Issue> open = all.stream().filter(issue -> issue.status != Issue.Status.DONE).toList();
63
73
List<Issue> done = all.stream().filter(issue -> issue.status == Issue.Status.DONE).toList();
64
- return Templates.issues(repo, isOwner, open, done);
74
+ return Templates.issues(repo, repoNav.build(repo, uriInfo), isOwner, open, done);
65
75
}
66
76
67
77
@GET
@@ -74,7 +84,7 @@
74
84
{
75
85
throw new de.workaround.git.ForbiddenOperationException("Only the repository owner can open issues");
76
86
}
77
- return Templates.newIssue(repo);
87
+ return Templates.newIssue(repo, repoNav.build(repo, uriInfo));
78
88
}
79
89
80
90
@POST
@@ -97,7 +107,7 @@
97
107
Issue issue = issueService.find(repo, parseId(id)).orElseThrow(NotFoundException::new);
98
108
User user = currentUser.get();
99
109
boolean isOwner = user != null && user.id.equals(repo.owner.id);
100
- return Templates.issue(repo, isOwner, issue, List.of(Issue.Status.values()));
110
+ return Templates.issue(repo, repoNav.build(repo, uriInfo), isOwner, issue, List.of(Issue.Status.values()));
101
111
}
102
112
103
113
@POST
MODIFY
src/main/java/de/workaround/web/MergeRequestResource.java
+17 -7
@@ -29,8 +29,10 @@
29
29
import jakarta.ws.rs.POST;
30
30
import jakarta.ws.rs.PathParam;
31
31
import jakarta.ws.rs.Produces;
32
+import jakarta.ws.rs.core.Context;
32
33
import jakarta.ws.rs.core.MediaType;
33
34
import jakarta.ws.rs.core.Response;
35
+import jakarta.ws.rs.core.UriInfo;
34
36
35
37
@jakarta.ws.rs.Path("/repos/{owner}/{name}/merge-requests")
36
38
@Produces(MediaType.TEXT_HTML)
@@ -39,12 +41,13 @@
39
41
@CheckedTemplate
40
42
static class Templates
41
43
{
42
- static native TemplateInstance mergeRequests(Repository repo, boolean owner, List<MergeRequest> open,
43
- List<MergeRequest> closed);
44
+ static native TemplateInstance mergeRequests(Repository repo, RepoNav nav, boolean owner,
45
+ List<MergeRequest> open, List<MergeRequest> closed);
44
46
45
- static native TemplateInstance newMergeRequest(Repository repo, List<String> branches, String defaultBranch);
47
+ static native TemplateInstance newMergeRequest(Repository repo, RepoNav nav, List<String> branches,
48
+ String defaultBranch);
46
49
47
- static native TemplateInstance mergeRequest(Repository repo, boolean owner, boolean loggedIn,
50
+ static native TemplateInstance mergeRequest(Repository repo, RepoNav nav, boolean owner, boolean loggedIn,
48
51
UUID currentUserId, MergeRequest mr, List<FileDiffView> files, int additions, int deletions);
49
52
}
50
53
@@ -83,6 +86,12 @@
83
86
@Inject
84
87
MergeRequestComment.Repo commentRepo;
85
88
89
+ @Inject
90
+ RepoNavService repoNav;
91
+
92
+ @Context
93
+ UriInfo uriInfo;
94
+
86
95
@GET
87
96
public TemplateInstance list(@PathParam("owner") String owner, @PathParam("name") String name)
88
97
{
@@ -90,7 +99,7 @@
90
99
List<MergeRequest> all = mergeRequestService.list(repo);
91
100
List<MergeRequest> open = all.stream().filter(mr -> mr.status == MergeRequest.Status.OPEN).toList();
92
101
List<MergeRequest> closed = all.stream().filter(mr -> mr.status != MergeRequest.Status.OPEN).toList();
93
- return Templates.mergeRequests(repo, isOwner(repo), open, closed);
102
+ return Templates.mergeRequests(repo, repoNav.build(repo, uriInfo), isOwner(repo), open, closed);
94
103
}
95
104
96
105
@GET
@@ -105,7 +114,7 @@
105
114
Path path = service.repositoryPath(repo);
106
115
List<String> branches = browse.branches(path).stream().map(GitBrowseService.BranchInfo::name).toList();
107
116
String defaultBranch = browse.isEmpty(path) ? null : browse.defaultBranch(path);
108
- return Templates.newMergeRequest(repo, branches, defaultBranch);
117
+ return Templates.newMergeRequest(repo, repoNav.build(repo, uriInfo), branches, defaultBranch);
109
118
}
110
119
111
120
@POST
@@ -162,7 +171,8 @@
162
171
fileIndex++;
163
172
}
164
173
}
165
- return Templates.mergeRequest(repo, isOwner(repo), loggedIn, currentUserId, mr, files, additions, deletions);
174
+ return Templates.mergeRequest(repo, repoNav.build(repo, uriInfo), isOwner(repo), loggedIn, currentUserId, mr,
175
+ files, additions, deletions);
166
176
}
167
177
168
178
@POST
ADD
src/main/java/de/workaround/web/RepoNav.java
+14 -0
@@ -0,0 +1,14 @@
1
+package de.workaround.web;
2
+
3
+import de.workaround.model.Repository;
4
+
5
+/**
6
+ * Everything the repository sidebar needs on any repo sub-page: the repository itself, clone URLs, the current
7
+ * request path (so the pin toggle returns the user to where they were), pin state, and the per-section counts shown
8
+ * next to each nav item. Assembled once per request by {@link RepoNavService}.
9
+ */
10
+public record RepoNav(Repository repo, boolean loggedIn, boolean pinned, boolean empty, String defaultBranch,
11
+ int commitCount, int branchCount, int tagCount, long openIssueCount, long openMrCount, String httpUrl,
12
+ String sshUrl, String currentPath)
13
+{
14
+}
ADD
src/main/java/de/workaround/web/RepoNavService.java
+67 -0
@@ -0,0 +1,67 @@
1
+package de.workaround.web;
2
+
3
+import java.nio.file.Path;
4
+
5
+import de.workaround.account.CurrentUser;
6
+import de.workaround.git.GitBrowseService;
7
+import de.workaround.git.GitRepositoryService;
8
+import de.workaround.git.IssueService;
9
+import de.workaround.git.MergeRequestService;
10
+import de.workaround.git.RepositoryPinService;
11
+import de.workaround.model.Repository;
12
+import de.workaround.model.User;
13
+import jakarta.enterprise.context.ApplicationScoped;
14
+import jakarta.inject.Inject;
15
+import jakarta.ws.rs.core.UriInfo;
16
+import org.eclipse.microprofile.config.inject.ConfigProperty;
17
+
18
+/**
19
+ * Builds the {@link RepoNav} rendered by the repository sidebar, which is shown on every repo-scoped page (code,
20
+ * commits, branches, issues, merge requests). Centralised here so all three web resources compute the counts, clone
21
+ * URLs and pin state the same way.
22
+ */
23
+@ApplicationScoped
24
+public class RepoNavService
25
+{
26
+ @Inject
27
+ CurrentUser currentUser;
28
+
29
+ @Inject
30
+ GitRepositoryService service;
31
+
32
+ @Inject
33
+ GitBrowseService browse;
34
+
35
+ @Inject
36
+ RepositoryPinService pinService;
37
+
38
+ @Inject
39
+ IssueService issueService;
40
+
41
+ @Inject
42
+ MergeRequestService mergeRequestService;
43
+
44
+ @ConfigProperty(name = "gitshark.ssh.port")
45
+ int sshPort;
46
+
47
+ public RepoNav build(Repository repo, UriInfo uriInfo)
48
+ {
49
+ Path path = service.repositoryPath(repo);
50
+ boolean empty = browse.isEmpty(path);
51
+ String defaultBranch = empty ? null : browse.defaultBranch(path);
52
+ User user = currentUser.get();
53
+ boolean loggedIn = user != null;
54
+ boolean pinned = loggedIn && pinService.isPinned(user, repo);
55
+ int commitCount = empty ? 0 : browse.commitCount(path, defaultBranch);
56
+ int branchCount = browse.branches(path).size();
57
+ int tagCount = browse.tags(path).size();
58
+ long openIssueCount = issueService.countOpen(repo);
59
+ long openMrCount = mergeRequestService.countOpen(repo);
60
+ String httpUrl = uriInfo.getBaseUri().resolve("/git/" + repo.owner.username + "/" + repo.name + ".git")
61
+ .toString();
62
+ String sshUrl = "ssh://git@" + uriInfo.getBaseUri().getHost() + ":" + sshPort + "/" + repo.owner.username
63
+ + "/" + repo.name + ".git";
64
+ return new RepoNav(repo, loggedIn, pinned, empty, defaultBranch, commitCount, branchCount, tagCount,
65
+ openIssueCount, openMrCount, httpUrl, sshUrl, uriInfo.getRequestUri().getRawPath());
66
+ }
67
+}
MODIFY
src/main/java/de/workaround/web/RepositoryResource.java
+42 -59
@@ -16,8 +16,6 @@
16
16
import de.workaround.git.AccessPolicy;
17
17
import de.workaround.git.GitBrowseService;
18
18
import de.workaround.git.GitRepositoryService;
19
-import de.workaround.git.IssueService;
20
-import de.workaround.git.MergeRequestService;
21
19
import de.workaround.git.RepositoryPinService;
22
20
import de.workaround.model.Repository;
23
21
import de.workaround.model.User;
@@ -39,7 +37,6 @@
39
37
import jakarta.ws.rs.core.UriInfo;
40
38
import org.commonmark.parser.Parser;
41
39
import org.commonmark.renderer.html.HtmlRenderer;
42
-import org.eclipse.microprofile.config.inject.ConfigProperty;
43
40
44
41
@jakarta.ws.rs.Path("/repos/{owner}/{name}")
45
42
@Produces(MediaType.TEXT_HTML)
@@ -48,23 +45,24 @@
48
45
@CheckedTemplate
49
46
static class Templates
50
47
{
51
- static native TemplateInstance overview(Repository repo, boolean owner, boolean empty, String defaultBranch,
52
- List<GitBrowseService.TreeEntry> entries, String httpUrl, String sshUrl, boolean loggedIn,
53
- boolean pinned, GitBrowseService.CommitInfo latestCommit, String latestCommitAge, int commitCount,
54
- int branchCount, int tagCount, long openIssueCount, long openMrCount, String readmeName,
55
- String readmeHtml);
48
+ static native TemplateInstance overview(Repository repo, RepoNav nav, boolean owner,
49
+ List<GitBrowseService.TreeEntry> entries, GitBrowseService.CommitInfo latestCommit, String latestCommitAge,
50
+ String readmeName, String readmeHtml);
56
51
57
- static native TemplateInstance tree(Repository repo, String ref, String path,
58
- List<GitBrowseService.TreeEntry> entries, List<Crumb> crumbs, String activeTab);
52
+ static native TemplateInstance tree(Repository repo, RepoNav nav, String ref, String path,
53
+ List<GitBrowseService.TreeEntry> entries, List<Crumb> crumbs);
59
54
60
- static native TemplateInstance blob(Repository repo, String ref, String path, boolean binary, String content,
61
- String language, List<Crumb> crumbs, String activeTab);
55
+ static native TemplateInstance blob(Repository repo, RepoNav nav, String ref, String path, boolean binary,
56
+ String content, String language, List<Crumb> crumbs);
62
57
63
- static native TemplateInstance commits(Repository repo, String ref, List<GitBrowseService.CommitInfo> commits,
64
- int page, int prevPage, int nextPage, int size, boolean hasNext, String activeTab);
58
+ static native TemplateInstance commits(Repository repo, RepoNav nav, String ref,
59
+ List<GitBrowseService.CommitInfo> commits, int page, int prevPage, int nextPage, int size,
60
+ boolean hasNext);
65
61
66
- static native TemplateInstance branches(Repository repo, List<GitBrowseService.BranchInfo> branches,
67
- List<String> tags, String activeTab, String tabRef);
62
+ static native TemplateInstance branches(Repository repo, RepoNav nav,
63
+ List<GitBrowseService.BranchInfo> branches);
64
+
65
+ static native TemplateInstance tags(Repository repo, RepoNav nav, List<String> tags);
68
66
}
69
67
70
68
@Inject
@@ -83,13 +81,7 @@
83
81
RepositoryPinService pinService;
84
82
85
83
@Inject
86
- IssueService issueService;
87
-
88
- @Inject
89
- MergeRequestService mergeRequestService;
90
-
91
- @ConfigProperty(name = "gitshark.ssh.port")
92
- int sshPort;
84
+ RepoNavService repoNav;
93
85
94
86
@Context
95
87
UriInfo uriInfo;
@@ -98,36 +90,26 @@
98
90
public TemplateInstance overview(@PathParam("owner") String owner, @PathParam("name") String name)
99
91
{
100
92
Repository repo = requireReadable(owner, name);
93
+ RepoNav nav = repoNav.build(repo, uriInfo);
101
94
Path path = service.repositoryPath(repo);
102
- boolean empty = browse.isEmpty(path);
103
- String defaultBranch = empty ? null : browse.defaultBranch(path);
104
- List<GitBrowseService.TreeEntry> entries = empty
95
+ List<GitBrowseService.TreeEntry> entries = nav.empty()
105
96
? List.of()
106
- : browse.listTree(path, defaultBranch, "").orElse(List.of());
97
+ : browse.listTree(path, nav.defaultBranch(), "").orElse(List.of());
107
98
User user = currentUser.get();
108
99
boolean isOwner = user != null && user.id.equals(repo.owner.id);
109
- boolean loggedIn = user != null;
110
- boolean pinned = loggedIn && pinService.isPinned(user, repo);
111
- GitBrowseService.CommitInfo latestCommit = empty ? null
112
- : browse.commits(path, defaultBranch, 0, 1)
100
+ GitBrowseService.CommitInfo latestCommit = nav.empty() ? null
101
+ : browse.commits(path, nav.defaultBranch(), 0, 1)
113
102
.filter(commitPage -> !commitPage.commits().isEmpty())
114
103
.map(commitPage -> commitPage.commits().get(0))
115
104
.orElse(null);
116
105
String latestCommitAge = latestCommit == null ? null : relativeAge(latestCommit.date());
117
- int commitCount = empty ? 0 : browse.commitCount(path, defaultBranch);
118
- int branchCount = browse.branches(path).size();
119
- int tagCount = browse.tags(path).size();
120
- long openIssueCount = issueService.countOpen(repo);
121
- long openMrCount = mergeRequestService.countOpen(repo);
122
106
GitBrowseService.TreeEntry readmeEntry = findReadme(entries);
123
107
String readmeName = readmeEntry == null ? null : readmeEntry.name();
124
- String readmeHtml = readmeEntry == null ? null : browse.blob(path, defaultBranch, readmeEntry.path())
108
+ String readmeHtml = readmeEntry == null ? null : browse.blob(path, nav.defaultBranch(), readmeEntry.path())
125
109
.filter(blob -> !blob.binary())
126
110
.map(blob -> renderMarkdown(new String(blob.content(), StandardCharsets.UTF_8)))
127
111
.orElse(null);
128
- return Templates.overview(repo, isOwner, empty, defaultBranch, entries, httpUrl(repo), sshUrl(repo),
129
- loggedIn, pinned, latestCommit, latestCommitAge, commitCount, branchCount, tagCount, openIssueCount,
130
- openMrCount, readmeName, readmeHtml);
112
+ return Templates.overview(repo, nav, isOwner, entries, latestCommit, latestCommitAge, readmeName, readmeHtml);
131
113
}
132
114
133
115
// README file names the overview looks for, in order of preference (matched case-insensitively).
@@ -217,10 +199,11 @@
217
199
{
218
200
String path = rawPath == null || rawPath.isEmpty() ? "" : rawPath.substring(1);
219
201
Repository repo = requireReadable(owner, name);
202
+ RepoNav nav = repoNav.build(repo, uriInfo);
220
203
Path repoPath = service.repositoryPath(repo);
221
204
return browse.listTree(repoPath, ref, path)
222
- .map(entries -> Templates.tree(repo, ref, path, entries, breadcrumbs(repo, ref, path), "files"))
223
- .orElseGet(() -> blobView(repo, repoPath, ref, path));
205
+ .map(entries -> Templates.tree(repo, nav, ref, path, entries, breadcrumbs(repo, ref, path)))
206
+ .orElseGet(() -> blobView(repo, nav, repoPath, ref, path));
224
207
}
225
208
226
209
@GET
@@ -242,13 +225,14 @@
242
225
@QueryParam("size") @DefaultValue("50") int size)
243
226
{
244
227
Repository repo = requireReadable(owner, name);
228
+ RepoNav nav = repoNav.build(repo, uriInfo);
245
229
int boundedSize = Math.min(Math.max(size, 1), 100);
246
230
int boundedPage = Math.max(page, 0);
247
231
GitBrowseService.CommitPage commitPage = browse
248
232
.commits(service.repositoryPath(repo), ref, boundedPage, boundedSize)
249
233
.orElseThrow(NotFoundException::new);
250
- return Templates.commits(repo, ref, commitPage.commits(), boundedPage, boundedPage - 1, boundedPage + 1,
251
- boundedSize, commitPage.hasNext(), "commits");
234
+ return Templates.commits(repo, nav, ref, commitPage.commits(), boundedPage, boundedPage - 1, boundedPage + 1,
235
+ boundedSize, commitPage.hasNext());
252
236
}
253
237
254
238
@GET
@@ -256,9 +240,19 @@
256
240
public TemplateInstance branches(@PathParam("owner") String owner, @PathParam("name") String name)
257
241
{
258
242
Repository repo = requireReadable(owner, name);
243
+ RepoNav nav = repoNav.build(repo, uriInfo);
259
244
Path path = service.repositoryPath(repo);
260
- String tabRef = browse.isEmpty(path) ? null : browse.defaultBranch(path);
261
- return Templates.branches(repo, browse.branches(path), browse.tags(path), "branches", tabRef);
245
+ return Templates.branches(repo, nav, browse.branches(path));
246
+ }
247
+
248
+ @GET
249
+ @jakarta.ws.rs.Path("tags")
250
+ public TemplateInstance tags(@PathParam("owner") String owner, @PathParam("name") String name)
251
+ {
252
+ Repository repo = requireReadable(owner, name);
253
+ RepoNav nav = repoNav.build(repo, uriInfo);
254
+ Path path = service.repositoryPath(repo);
255
+ return Templates.tags(repo, nav, browse.tags(path));
262
256
}
263
257
264
258
@POST
@@ -309,12 +303,12 @@
309
303
return URI.create("/");
310
304
}
311
305
312
- private TemplateInstance blobView(Repository repo, Path repoPath, String ref, String path)
306
+ private TemplateInstance blobView(Repository repo, RepoNav nav, Path repoPath, String ref, String path)
313
307
{
314
308
GitBrowseService.BlobView blob = browse.blob(repoPath, ref, path).orElseThrow(NotFoundException::new);
315
309
String content = blob.binary() ? null : new String(blob.content(), StandardCharsets.UTF_8);
316
310
String language = blob.binary() ? null : highlightLanguage(path);
317
- return Templates.blob(repo, ref, path, blob.binary(), content, language, breadcrumbs(repo, ref, path), "files");
311
+ return Templates.blob(repo, nav, ref, path, blob.binary(), content, language, breadcrumbs(repo, ref, path));
318
312
}
319
313
320
314
// Extension → highlight.js language id. Every value here MUST have a grammar in the bundled highlight assets
@@ -429,15 +423,4 @@
429
423
return repo;
430
424
}
431
425
432
- private String httpUrl(Repository repo)
433
- {
434
- return uriInfo.getBaseUri().resolve("/git/" + repo.owner.username + "/" + repo.name + ".git").toString();
435
- }
436
-
437
- private String sshUrl(Repository repo)
438
- {
439
- return "ssh://git@" + uriInfo.getBaseUri().getHost() + ":" + sshPort
440
- + "/" + repo.owner.username + "/" + repo.name + ".git";
441
- }
442
-
443
426
}
MODIFY
src/main/resources/META-INF/resources/shark.css
+14 -45
@@ -491,51 +491,6 @@
491
491
.btn-sm { padding: 5px 11px; font-size: 12.5px; }
492
492
.btn-lg { padding: 11px 20px; font-size: 15px; }
493
493
494
-/* tabs */
495
-
496
-.tabs {
497
- display: flex;
498
- gap: 4px;
499
- border-bottom: 1px solid var(--border);
500
- margin-bottom: var(--s4);
501
-}
502
-
503
-.tab {
504
- display: flex;
505
- align-items: center;
506
- gap: 7px;
507
- padding: 9px 14px;
508
- color: var(--muted);
509
- border-bottom: 2px solid transparent;
510
- margin-bottom: -1px;
511
- font-size: 14px;
512
- font-weight: 500;
513
-}
514
-
515
-.tab:hover {
516
- color: var(--accent-deep);
517
- text-decoration: none;
518
-}
519
-
520
-.tab.active {
521
- font-weight: 600;
522
- color: var(--ink);
523
- border-bottom-color: var(--accent);
524
-}
525
-
526
-.tab .ct {
527
- font: 500 11px/1 var(--mono);
528
- color: var(--faint);
529
- background: var(--border-soft);
530
- border-radius: 999px;
531
- padding: 2px 7px;
532
-}
533
-
534
-.tab.active .ct {
535
- color: var(--accent-deep);
536
- background: var(--accent-soft);
537
-}
538
-
539
494
/* panels & tables */
540
495
541
496
.panel {
@@ -1224,6 +1179,12 @@
1224
1179
gap: 40px;
1225
1180
}
1226
1181
1182
+/* the flexible main column must be allowed to shrink so wide content (diffs, tables) scrolls instead of
1183
+ forcing the whole grid — and the sidebar — wider than the viewport */
1184
+.repo-main {
1185
+ min-width: 0;
1186
+}
1187
+
1227
1188
.repo-side .owner {
1228
1189
font: 500 13px/1 var(--mono);
1229
1190
color: var(--muted);
@@ -1238,6 +1199,14 @@
1238
1199
color: var(--ink);
1239
1200
}
1240
1201
1202
+.repo-side .repo-name a {
1203
+ color: inherit;
1204
+}
1205
+
1206
+.repo-side .repo-name a:hover {
1207
+ color: var(--accent-deep);
1208
+}
1209
+
1241
1210
.repo-side .tag {
1242
1211
display: inline-flex;
1243
1212
align-items: center;
MODIFY
src/main/resources/templates/IssueResource/issue.html
+28 -24
@@ -1,27 +1,31 @@
1
1
{#include layout}
2
2
{#title}{issue.title} – {repo.name}{/title}
3
-<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4
-<p><a href="/repos/{repo.owner.username}/{repo.name}/issues">← Issues</a></p>
5
-<h2>{issue.title} <span class="issue-no">#{issue.number}</span></h2>
6
-<p>
7
- <span class="badge status-{issue.status}">{issue.status.label}</span>
8
- <span class="muted">opened by {issue.author.username}</span>
9
-</p>
10
-{#if issue.description}
11
-<pre class="issue-desc">{issue.description}</pre>
12
-{#else}
13
-<p class="muted">No description provided.</p>
14
-{/if}
15
-{#if owner}
16
-<form class="issue-actions" method="post" action="/repos/{repo.owner.username}/{repo.name}/issues/{issue.id}/status">
17
- <span class="lbl">Move to</span>
18
- {#for status in statuses}
19
- <button type="submit" class="btn btn-secondary btn-sm" name="status" value="{status}"{#if status == issue.status} disabled{/if}>{status.label}</button>
20
- {/for}
21
-</form>
22
-<form method="post" action="/repos/{repo.owner.username}/{repo.name}/issues/{issue.id}/delete"
23
- onsubmit="return confirm('Delete this issue? This cannot be undone.')">
24
- <button type="submit" class="btn btn-danger">Delete issue</button>
25
-</form>
26
-{/if}
3
+<div class="repo-layout">
4
+ {#include RepositoryResource/sidebar nav=nav active='issues' /}
5
+ <section class="repo-main">
6
+ <p><a href="/repos/{repo.owner.username}/{repo.name}/issues">← Issues</a></p>
7
+ <h2>{issue.title} <span class="issue-no">#{issue.number}</span></h2>
8
+ <p>
9
+ <span class="badge status-{issue.status}">{issue.status.label}</span>
10
+ <span class="muted">opened by {issue.author.username}</span>
11
+ </p>
12
+ {#if issue.description}
13
+ <pre class="issue-desc">{issue.description}</pre>
14
+ {#else}
15
+ <p class="muted">No description provided.</p>
16
+ {/if}
17
+ {#if owner}
18
+ <form class="issue-actions" method="post" action="/repos/{repo.owner.username}/{repo.name}/issues/{issue.id}/status">
19
+ <span class="lbl">Move to</span>
20
+ {#for status in statuses}
21
+ <button type="submit" class="btn btn-secondary btn-sm" name="status" value="{status}"{#if status == issue.status} disabled{/if}>{status.label}</button>
22
+ {/for}
23
+ </form>
24
+ <form method="post" action="/repos/{repo.owner.username}/{repo.name}/issues/{issue.id}/delete"
25
+ onsubmit="return confirm('Delete this issue? This cannot be undone.')">
26
+ <button type="submit" class="btn btn-danger">Delete issue</button>
27
+ </form>
28
+ {/if}
29
+ </section>
30
+</div>
27
31
{/include}
MODIFY
src/main/resources/templates/IssueResource/issues.html
+41 -37
@@ -1,41 +1,45 @@
1
1
{#include layout}
2
2
{#title}Issues – {repo.name}{/title}
3
-<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4
-<div class="issues-head">
5
- <h2>Issues</h2>
6
- {#if owner}
7
- <a class="btn btn-primary" href="/repos/{repo.owner.username}/{repo.name}/issues/new">New issue</a>
8
- {/if}
3
+<div class="repo-layout">
4
+ {#include RepositoryResource/sidebar nav=nav active='issues' /}
5
+ <section class="repo-main">
6
+ <div class="issues-head">
7
+ <h2>Issues</h2>
8
+ {#if owner}
9
+ <a class="btn btn-primary" href="/repos/{repo.owner.username}/{repo.name}/issues/new">New issue</a>
10
+ {/if}
11
+ </div>
12
+ {#if open.isEmpty()}
13
+ <p class="muted">No open issues.</p>
14
+ {#else}
15
+ <div class="panel">
16
+ {#for issue in open}
17
+ <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/issues/{issue.id}">
18
+ <span class="fname">
19
+ <span class="badge status-{issue.status}">{issue.status.label}</span>
20
+ <span class="n">{issue.title}</span>
21
+ <span class="issue-no">#{issue.number}</span>
22
+ </span>
23
+ </a>
24
+ {/for}
25
+ </div>
26
+ {/if}
27
+ {#if done.size > 0}
28
+ <details class="archive">
29
+ <summary>Archive <span class="ct">{done.size}</span></summary>
30
+ <div class="panel">
31
+ {#for issue in done}
32
+ <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/issues/{issue.id}">
33
+ <span class="fname">
34
+ <span class="badge status-{issue.status}">{issue.status.label}</span>
35
+ <span class="n">{issue.title}</span>
36
+ <span class="issue-no">#{issue.number}</span>
37
+ </span>
38
+ </a>
39
+ {/for}
40
+ </div>
41
+ </details>
42
+ {/if}
43
+ </section>
9
44
</div>
10
-{#if open.isEmpty()}
11
-<p class="muted">No open issues.</p>
12
-{#else}
13
-<div class="panel">
14
- {#for issue in open}
15
- <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/issues/{issue.id}">
16
- <span class="fname">
17
- <span class="badge status-{issue.status}">{issue.status.label}</span>
18
- <span class="n">{issue.title}</span>
19
- <span class="issue-no">#{issue.number}</span>
20
- </span>
21
- </a>
22
- {/for}
23
-</div>
24
-{/if}
25
-{#if done.size > 0}
26
-<details class="archive">
27
- <summary>Archive <span class="ct">{done.size}</span></summary>
28
- <div class="panel">
29
- {#for issue in done}
30
- <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/issues/{issue.id}">
31
- <span class="fname">
32
- <span class="badge status-{issue.status}">{issue.status.label}</span>
33
- <span class="n">{issue.title}</span>
34
- <span class="issue-no">#{issue.number}</span>
35
- </span>
36
- </a>
37
- {/for}
38
- </div>
39
-</details>
40
-{/if}
41
45
{/include}
MODIFY
src/main/resources/templates/IssueResource/newIssue.html
+15 -11
@@ -1,14 +1,18 @@
1
1
{#include layout}
2
2
{#title}New issue – {repo.name}{/title}
3
-<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4
-<p><a href="/repos/{repo.owner.username}/{repo.name}/issues">← Issues</a></p>
5
-<h2>New issue</h2>
6
-<form class="issue-form" method="post" action="/repos/{repo.owner.username}/{repo.name}/issues">
7
- <input type="text" name="title" placeholder="Issue title" required autocomplete="off">
8
- <textarea name="description" placeholder="Description (optional)" rows="6"></textarea>
9
- <div class="form-actions">
10
- <button type="submit" class="btn btn-primary">Create issue</button>
11
- <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/issues">Cancel</a>
12
- </div>
13
-</form>
3
+<div class="repo-layout">
4
+ {#include RepositoryResource/sidebar nav=nav active='issues' /}
5
+ <section class="repo-main">
6
+ <p><a href="/repos/{repo.owner.username}/{repo.name}/issues">← Issues</a></p>
7
+ <h2>New issue</h2>
8
+ <form class="issue-form" method="post" action="/repos/{repo.owner.username}/{repo.name}/issues">
9
+ <input type="text" name="title" placeholder="Issue title" required autocomplete="off">
10
+ <textarea name="description" placeholder="Description (optional)" rows="6"></textarea>
11
+ <div class="form-actions">
12
+ <button type="submit" class="btn btn-primary">Create issue</button>
13
+ <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/issues">Cancel</a>
14
+ </div>
15
+ </form>
16
+ </section>
17
+</div>
14
18
{/include}
MODIFY
src/main/resources/templates/MergeRequestResource/mergeRequest.html
+5 -1
@@ -1,6 +1,8 @@
1
1
{#include layout}
2
2
{#title}{mr.title} – {repo.name}{/title}
3
-<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
3
+<div class="repo-layout">
4
+{#include RepositoryResource/sidebar nav=nav active='merge-requests' /}
5
+<section class="repo-main">
4
6
<p><a href="/repos/{repo.owner.username}/{repo.name}/merge-requests">← Merge requests</a></p>
5
7
<h2>{mr.title} <span class="issue-no">!{mr.number}</span></h2>
6
8
<p>
@@ -91,4 +93,6 @@
91
93
</div>
92
94
{/for}
93
95
{/if}
96
+</section>
97
+</div>
94
98
{/include}
MODIFY
src/main/resources/templates/MergeRequestResource/mergeRequests.html
+43 -39
@@ -1,43 +1,47 @@
1
1
{#include layout}
2
2
{#title}Merge requests – {repo.name}{/title}
3
-<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4
-<div class="issues-head">
5
- <h2>Merge requests</h2>
6
- {#if owner}
7
- <a class="btn btn-primary" href="/repos/{repo.owner.username}/{repo.name}/merge-requests/new">New merge request</a>
8
- {/if}
3
+<div class="repo-layout">
4
+ {#include RepositoryResource/sidebar nav=nav active='merge-requests' /}
5
+ <section class="repo-main">
6
+ <div class="issues-head">
7
+ <h2>Merge requests</h2>
8
+ {#if owner}
9
+ <a class="btn btn-primary" href="/repos/{repo.owner.username}/{repo.name}/merge-requests/new">New merge request</a>
10
+ {/if}
11
+ </div>
12
+ {#if open.isEmpty()}
13
+ <p class="muted">No open merge requests.</p>
14
+ {#else}
15
+ <div class="panel">
16
+ {#for mr in open}
17
+ <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/merge-requests/{mr.id}">
18
+ <span class="fname">
19
+ <span class="badge status-{mr.status}">{mr.status.label}</span>
20
+ <span class="n">{mr.title}</span>
21
+ <span class="mr-branches"><code>{mr.sourceBranch}</code> → <code>{mr.targetBranch}</code></span>
22
+ <span class="issue-no">!{mr.number}</span>
23
+ </span>
24
+ </a>
25
+ {/for}
26
+ </div>
27
+ {/if}
28
+ {#if closed.size > 0}
29
+ <details class="archive">
30
+ <summary>Archive <span class="ct">{closed.size}</span></summary>
31
+ <div class="panel">
32
+ {#for mr in closed}
33
+ <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/merge-requests/{mr.id}">
34
+ <span class="fname">
35
+ <span class="badge status-{mr.status}">{mr.status.label}</span>
36
+ <span class="n">{mr.title}</span>
37
+ <span class="mr-branches"><code>{mr.sourceBranch}</code> → <code>{mr.targetBranch}</code></span>
38
+ <span class="issue-no">!{mr.number}</span>
39
+ </span>
40
+ </a>
41
+ {/for}
42
+ </div>
43
+ </details>
44
+ {/if}
45
+ </section>
9
46
</div>
10
-{#if open.isEmpty()}
11
-<p class="muted">No open merge requests.</p>
12
-{#else}
13
-<div class="panel">
14
- {#for mr in open}
15
- <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/merge-requests/{mr.id}">
16
- <span class="fname">
17
- <span class="badge status-{mr.status}">{mr.status.label}</span>
18
- <span class="n">{mr.title}</span>
19
- <span class="mr-branches"><code>{mr.sourceBranch}</code> → <code>{mr.targetBranch}</code></span>
20
- <span class="issue-no">!{mr.number}</span>
21
- </span>
22
- </a>
23
- {/for}
24
-</div>
25
-{/if}
26
-{#if closed.size > 0}
27
-<details class="archive">
28
- <summary>Archive <span class="ct">{closed.size}</span></summary>
29
- <div class="panel">
30
- {#for mr in closed}
31
- <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/merge-requests/{mr.id}">
32
- <span class="fname">
33
- <span class="badge status-{mr.status}">{mr.status.label}</span>
34
- <span class="n">{mr.title}</span>
35
- <span class="mr-branches"><code>{mr.sourceBranch}</code> → <code>{mr.targetBranch}</code></span>
36
- <span class="issue-no">!{mr.number}</span>
37
- </span>
38
- </a>
39
- {/for}
40
- </div>
41
-</details>
42
-{/if}
43
47
{/include}
MODIFY
src/main/resources/templates/MergeRequestResource/newMergeRequest.html
+32 -28
@@ -1,31 +1,35 @@
1
1
{#include layout}
2
2
{#title}New merge request – {repo.name}{/title}
3
-<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4
-<p><a href="/repos/{repo.owner.username}/{repo.name}/merge-requests">← Merge requests</a></p>
5
-<h2>New merge request</h2>
6
-<form class="issue-form" method="post" action="/repos/{repo.owner.username}/{repo.name}/merge-requests">
7
- <input type="text" name="title" placeholder="Merge request title" required autocomplete="off">
8
- <div class="mr-branch-picker">
9
- <label>Merge
10
- <select name="sourceBranch" required>
11
- {#for branch in branches}
12
- <option value="{branch}">{branch}</option>
13
- {/for}
14
- </select>
15
- </label>
16
- <span class="into">into</span>
17
- <label>
18
- <select name="targetBranch" required>
19
- {#for branch in branches}
20
- <option value="{branch}"{#if branch == defaultBranch} selected{/if}>{branch}</option>
21
- {/for}
22
- </select>
23
- </label>
24
- </div>
25
- <textarea name="description" placeholder="Description (optional)" rows="6"></textarea>
26
- <div class="form-actions">
27
- <button type="submit" class="btn btn-primary">Create merge request</button>
28
- <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/merge-requests">Cancel</a>
29
- </div>
30
-</form>
3
+<div class="repo-layout">
4
+ {#include RepositoryResource/sidebar nav=nav active='merge-requests' /}
5
+ <section class="repo-main">
6
+ <p><a href="/repos/{repo.owner.username}/{repo.name}/merge-requests">← Merge requests</a></p>
7
+ <h2>New merge request</h2>
8
+ <form class="issue-form" method="post" action="/repos/{repo.owner.username}/{repo.name}/merge-requests">
9
+ <input type="text" name="title" placeholder="Merge request title" required autocomplete="off">
10
+ <div class="mr-branch-picker">
11
+ <label>Merge
12
+ <select name="sourceBranch" required>
13
+ {#for branch in branches}
14
+ <option value="{branch}">{branch}</option>
15
+ {/for}
16
+ </select>
17
+ </label>
18
+ <span class="into">into</span>
19
+ <label>
20
+ <select name="targetBranch" required>
21
+ {#for branch in branches}
22
+ <option value="{branch}"{#if branch == defaultBranch} selected{/if}>{branch}</option>
23
+ {/for}
24
+ </select>
25
+ </label>
26
+ </div>
27
+ <textarea name="description" placeholder="Description (optional)" rows="6"></textarea>
28
+ <div class="form-actions">
29
+ <button type="submit" class="btn btn-primary">Create merge request</button>
30
+ <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/merge-requests">Cancel</a>
31
+ </div>
32
+ </form>
33
+ </section>
34
+</div>
31
35
{/include}
MODIFY
src/main/resources/templates/RepositoryResource/blob.html
+18 -15
@@ -1,20 +1,23 @@
1
1
{#include layout}
2
2
{#title}{path} at {ref} – {repo.name}{/title}
3
-<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4
-{#include RepositoryResource/tabs repo=repo tabRef=ref activeTab=activeTab /}
5
-<nav class="breadcrumb" aria-label="Path">
6
- {#for crumb in crumbs}
7
- {#if crumb.href}<a href="{crumb.href}">{crumb.label}</a>{#else}<span class="current">{crumb.label}</span>{/if}
8
- {#if crumb_hasNext}<span class="sep">/</span>{/if}
9
- {/for}
10
-</nav>
11
-{#if binary}
12
-<p>Binary file. <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/raw/{ref}/{path}">Download</a></p>
13
-{#else if language}
14
-<pre><code class="language-{language}">{content}</code></pre>
15
-{#else}
16
-<pre>{content}</pre>
17
-{/if}
3
+<div class="repo-layout">
4
+ {#include RepositoryResource/sidebar nav=nav active='code' /}
5
+ <section class="repo-main">
6
+ <nav class="breadcrumb" aria-label="Path">
7
+ {#for crumb in crumbs}
8
+ {#if crumb.href}<a href="{crumb.href}">{crumb.label}</a>{#else}<span class="current">{crumb.label}</span>{/if}
9
+ {#if crumb_hasNext}<span class="sep">/</span>{/if}
10
+ {/for}
11
+ </nav>
12
+ {#if binary}
13
+ <p>Binary file. <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/raw/{ref}/{path}">Download</a></p>
14
+ {#else if language}
15
+ <pre><code class="language-{language}">{content}</code></pre>
16
+ {#else}
17
+ <pre>{content}</pre>
18
+ {/if}
19
+ </section>
20
+</div>
18
21
{#scripts}
19
22
{#if language}
20
23
<script src="/highlight.min.js"></script>
MODIFY
src/main/resources/templates/RepositoryResource/branches.html
+15 -24
@@ -1,27 +1,18 @@
1
1
{#include layout}
2
2
{#title}Branches – {repo.name}{/title}
3
-<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4
-<p>Branches and tags</p>
5
-{#include RepositoryResource/tabs repo=repo tabRef=tabRef activeTab=activeTab /}
6
-<h2>Branches</h2>
7
-<table>
8
- <tr><th>Branch</th><th></th></tr>
9
- {#for branch in branches}
10
- <tr>
11
- <td><a href="/repos/{repo.owner.username}/{repo.name}/tree/{branch.name}/">{branch.name}</a></td>
12
- <td>{#if branch.defaultBranch}<span class="badge badge-default">default</span>{/if}</td>
13
- </tr>
14
- {/for}
15
-</table>
16
-<h2>Tags</h2>
17
-{#if tags.isEmpty()}
18
-<p>No tags.</p>
19
-{#else}
20
-<table>
21
- <tr><th>Tag</th></tr>
22
- {#for tag in tags}
23
- <tr><td><a href="/repos/{repo.owner.username}/{repo.name}/tree/{tag}/">{tag}</a></td></tr>
24
- {/for}
25
-</table>
26
-{/if}
3
+<div class="repo-layout">
4
+ {#include RepositoryResource/sidebar nav=nav active='branches' /}
5
+ <section class="repo-main">
6
+ <h2>Branches</h2>
7
+ <table>
8
+ <tr><th>Branch</th><th></th></tr>
9
+ {#for branch in branches}
10
+ <tr>
11
+ <td><a href="/repos/{repo.owner.username}/{repo.name}/tree/{branch.name}/">{branch.name}</a></td>
12
+ <td>{#if branch.defaultBranch}<span class="badge badge-default">default</span>{/if}</td>
13
+ </tr>
14
+ {/for}
15
+ </table>
16
+ </section>
17
+</div>
27
18
{/include}
MODIFY
src/main/resources/templates/RepositoryResource/commits.html
+25 -22
@@ -1,25 +1,28 @@
1
1
{#include layout}
2
2
{#title}Commits at {ref} – {repo.name}{/title}
3
-<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4
-<p>Commits on <code>{ref}</code></p>
5
-{#include RepositoryResource/tabs repo=repo tabRef=ref activeTab=activeTab /}
6
-<table>
7
- <tr><th>Commit</th><th>Message</th><th>Author</th><th>Date</th></tr>
8
- {#for commit in commits}
9
- <tr>
10
- <td><code>{commit.shortId}</code></td>
11
- <td>{commit.message}</td>
12
- <td>{commit.author}</td>
13
- <td>{commit.date}</td>
14
- </tr>
15
- {/for}
16
-</table>
17
-<p class="pagination">
18
- {#if page > 0}
19
- <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/commits/{ref}?page={prevPage}&size={size}">Newer</a>
20
- {/if}
21
- {#if hasNext}
22
- <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/commits/{ref}?page={nextPage}&size={size}">Older</a>
23
- {/if}
24
-</p>
3
+<div class="repo-layout">
4
+ {#include RepositoryResource/sidebar nav=nav active='commits' /}
5
+ <section class="repo-main">
6
+ <p>Commits on <code>{ref}</code></p>
7
+ <table>
8
+ <tr><th>Commit</th><th>Message</th><th>Author</th><th>Date</th></tr>
9
+ {#for commit in commits}
10
+ <tr>
11
+ <td><code>{commit.shortId}</code></td>
12
+ <td>{commit.message}</td>
13
+ <td>{commit.author}</td>
14
+ <td>{commit.date}</td>
15
+ </tr>
16
+ {/for}
17
+ </table>
18
+ <p class="pagination">
19
+ {#if page > 0}
20
+ <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/commits/{ref}?page={prevPage}&size={size}">Newer</a>
21
+ {/if}
22
+ {#if hasNext}
23
+ <a class="btn btn-secondary" href="/repos/{repo.owner.username}/{repo.name}/commits/{ref}?page={nextPage}&size={size}">Older</a>
24
+ {/if}
25
+ </p>
26
+ </section>
27
+</div>
25
28
{/include}
MODIFY
src/main/resources/templates/RepositoryResource/overview.html
+7 -59
@@ -3,54 +3,23 @@
3
3
4
4
<div class="repo-layout">
5
5
6
- <aside class="repo-side">
7
- <div class="owner">{repo.owner.username} /</div>
8
- <div class="repo-name">{repo.name}</div>
9
- <span class="tag{#if repo.visibility.name() == 'PRIVATE'} tag-private{/if}"><span class="dot"></span> {repo.visibility.name()}</span>
10
- {#if repo.description}
11
- <p class="desc">{repo.description}</p>
12
- {/if}
13
- <div class="repo-actions">
14
- <button type="button" class="btn-clone" data-open-dialog="clone-dialog">
15
- <span class="g">⤓</span> Clone
16
- </button>
17
- {#if loggedIn}
18
- <form class="inline" method="post" action="/repos/{repo.owner.username}/{repo.name}/{#if pinned}unpin{#else}pin{/if}">
19
- <input type="hidden" name="redirect" value="/repos/{repo.owner.username}/{repo.name}">
20
- <button class="btn-icon{#if pinned} pinned{/if}" type="submit" title="{#if pinned}Unpin{#else}Pin{/if} repository" aria-label="{#if pinned}Unpin{#else}Pin{/if} repository">
21
- <svg viewBox="0 0 24 24" fill="{#if pinned}currentColor{#else}none{/if}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></svg>
22
- </button>
23
- </form>
24
- {/if}
25
- </div>
26
- <div class="rule"></div>
27
- <nav class="repo-nav">
28
- <a class="active" href="/repos/{repo.owner.username}/{repo.name}"><span class="g">⌗</span> Code</a>
29
- {#if !empty}
30
- <a href="/repos/{repo.owner.username}/{repo.name}/commits/{defaultBranch}"><span class="g">◴</span> Commits <span class="ct">{commitCount}</span></a>
31
- {/if}
32
- <a href="/repos/{repo.owner.username}/{repo.name}/branches"><span class="g">⑂</span> Branches <span class="ct">{branchCount}</span></a>
33
- <a href="/repos/{repo.owner.username}/{repo.name}/branches"><span class="g">⬡</span> Tags <span class="ct">{tagCount}</span></a>
34
- <a href="/repos/{repo.owner.username}/{repo.name}/issues"><span class="g">◇</span> Issues <span class="ct">{openIssueCount}</span></a>
35
- <a href="/repos/{repo.owner.username}/{repo.name}/merge-requests"><span class="g">⇄</span> Merge requests <span class="ct">{openMrCount}</span></a>
36
- </nav>
37
- </aside>
6
+ {#include RepositoryResource/sidebar nav=nav active='code' /}
38
7
39
8
<section class="repo-main">
40
- {#if empty}
9
+ {#if nav.empty}
41
10
<h2>Quick start</h2>
42
11
<p>This repository is empty. Push an existing repository:</p>
43
- <pre>git remote add origin {httpUrl}
12
+ <pre>git remote add origin {nav.httpUrl}
44
13
git push -u origin main</pre>
45
14
{#else}
46
15
<div class="branchbar">
47
- <a class="branchsel" href="/repos/{repo.owner.username}/{repo.name}/branches"><span class="g">⑂</span> {defaultBranch} <span class="car">▾</span></a>
48
- <span class="meta"><b>{commitCount}</b> commits · <b>{entries.size()}</b> entries</span>
16
+ <a class="branchsel" href="/repos/{repo.owner.username}/{repo.name}/branches"><span class="g">⑂</span> {nav.defaultBranch} <span class="car">▾</span></a>
17
+ <span class="meta"><b>{nav.commitCount}</b> commits · <b>{entries.size()}</b> entries</span>
49
18
</div>
50
19
51
20
<div class="panel">
52
21
{#if latestCommit}
53
- <a class="commitrow" href="/repos/{repo.owner.username}/{repo.name}/commits/{defaultBranch}">
22
+ <a class="commitrow" href="/repos/{repo.owner.username}/{repo.name}/commits/{nav.defaultBranch}">
54
23
<span class="av">{latestCommit.author.charAt(0)}</span>
55
24
<span class="who">{latestCommit.author}</span>
56
25
<span class="msg">{latestCommit.message}</span>
@@ -59,7 +28,7 @@
59
28
{/if}
60
29
<div class="files">
61
30
{#for entry in entries}
62
- <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/tree/{defaultBranch}/{entry.path}">
31
+ <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/tree/{nav.defaultBranch}/{entry.path}">
63
32
<span class="fname">
64
33
<span class="g{#if entry.directory} dir{/if}">{#if entry.directory}▸{#else}≡{/if}</span>
65
34
<span class="n">{entry.name}</span>
@@ -89,25 +58,4 @@
89
58
</section>
90
59
91
60
</div>
92
-
93
-<dialog id="clone-dialog" class="clone-dialog">
94
- <form method="dialog" class="clone-close">
95
- <button class="btn-icon" aria-label="Close">✕</button>
96
- </form>
97
- <h2>Clone repository</h2>
98
- <input type="radio" name="clone-proto" id="clone-https" checked>
99
- <input type="radio" name="clone-proto" id="clone-ssh">
100
- <div class="clone-tabs">
101
- <label for="clone-https">HTTPS</label>
102
- <label for="clone-ssh">SSH</label>
103
- </div>
104
- <div class="clone-url url-https">
105
- <code>git clone {httpUrl}</code>
106
- <button type="button" class="btn-icon copy-btn" data-copy="git clone {httpUrl}" aria-label="Copy clone command" title="Copy">⧉</button>
107
- </div>
108
- <div class="clone-url url-ssh">
109
- <code>git clone {sshUrl}</code>
110
- <button type="button" class="btn-icon copy-btn" data-copy="git clone {sshUrl}" aria-label="Copy clone command" title="Copy">⧉</button>
111
- </div>
112
-</dialog>
113
61
{/include}
ADD
src/main/resources/templates/RepositoryResource/sidebar.html
+53 -0
@@ -0,0 +1,53 @@
1
+<aside class="repo-side">
2
+ <div class="owner">{nav.repo.owner.username} /</div>
3
+ <div class="repo-name"><a href="/repos/{nav.repo.owner.username}/{nav.repo.name}">{nav.repo.name}</a></div>
4
+ <span class="tag{#if nav.repo.visibility.name() == 'PRIVATE'} tag-private{/if}"><span class="dot"></span> {nav.repo.visibility.name()}</span>
5
+ {#if nav.repo.description}
6
+ <p class="desc">{nav.repo.description}</p>
7
+ {/if}
8
+ <div class="repo-actions">
9
+ <button type="button" class="btn-clone" data-open-dialog="clone-dialog">
10
+ <span class="g">⤓</span> Clone
11
+ </button>
12
+ {#if nav.loggedIn}
13
+ <form class="inline" method="post" action="/repos/{nav.repo.owner.username}/{nav.repo.name}/{#if nav.pinned}unpin{#else}pin{/if}">
14
+ <input type="hidden" name="redirect" value="{nav.currentPath}">
15
+ <button class="btn-icon{#if nav.pinned} pinned{/if}" type="submit" title="{#if nav.pinned}Unpin{#else}Pin{/if} repository" aria-label="{#if nav.pinned}Unpin{#else}Pin{/if} repository">
16
+ <svg viewBox="0 0 24 24" fill="{#if nav.pinned}currentColor{#else}none{/if}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="17" x2="12" y2="22"/><path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z"/></svg>
17
+ </button>
18
+ </form>
19
+ {/if}
20
+ </div>
21
+ <div class="rule"></div>
22
+ <nav class="repo-nav">
23
+ <a class="{#if active == 'code'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}"><span class="g">⌗</span> Code</a>
24
+ {#if !nav.empty}
25
+ <a class="{#if active == 'commits'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/commits/{nav.defaultBranch}"><span class="g">◴</span> Commits <span class="ct">{nav.commitCount}</span></a>
26
+ {/if}
27
+ <a class="{#if active == 'branches'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/branches"><span class="g">⑂</span> Branches <span class="ct">{nav.branchCount}</span></a>
28
+ <a class="{#if active == 'tags'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/tags"><span class="g">⬡</span> Tags <span class="ct">{nav.tagCount}</span></a>
29
+ <a class="{#if active == 'issues'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/issues"><span class="g">◇</span> Issues <span class="ct">{nav.openIssueCount}</span></a>
30
+ <a class="{#if active == 'merge-requests'}active{/if}" href="/repos/{nav.repo.owner.username}/{nav.repo.name}/merge-requests"><span class="g">⇄</span> Merge requests <span class="ct">{nav.openMrCount}</span></a>
31
+ </nav>
32
+</aside>
33
+
34
+<dialog id="clone-dialog" class="clone-dialog">
35
+ <form method="dialog" class="clone-close">
36
+ <button class="btn-icon" aria-label="Close">✕</button>
37
+ </form>
38
+ <h2>Clone repository</h2>
39
+ <input type="radio" name="clone-proto" id="clone-https" checked>
40
+ <input type="radio" name="clone-proto" id="clone-ssh">
41
+ <div class="clone-tabs">
42
+ <label for="clone-https">HTTPS</label>
43
+ <label for="clone-ssh">SSH</label>
44
+ </div>
45
+ <div class="clone-url url-https">
46
+ <code>git clone {nav.httpUrl}</code>
47
+ <button type="button" class="btn-icon copy-btn" data-copy="git clone {nav.httpUrl}" aria-label="Copy clone command" title="Copy">⧉</button>
48
+ </div>
49
+ <div class="clone-url url-ssh">
50
+ <code>git clone {nav.sshUrl}</code>
51
+ <button type="button" class="btn-icon copy-btn" data-copy="git clone {nav.sshUrl}" aria-label="Copy clone command" title="Copy">⧉</button>
52
+ </div>
53
+</dialog>
DELETE
src/main/resources/templates/RepositoryResource/tabs.html
+0 -9
@@ -1,9 +0,0 @@
1
-{#if tabRef}
2
-<nav class="tabs">
3
- <a class="tab{#if activeTab == 'files'} active{/if}" href="/repos/{repo.owner.username}/{repo.name}/tree/{tabRef}/">Files</a>
4
- <a class="tab{#if activeTab == 'commits'} active{/if}" href="/repos/{repo.owner.username}/{repo.name}/commits/{tabRef}">Commits</a>
5
- <a class="tab{#if activeTab == 'branches'} active{/if}" href="/repos/{repo.owner.username}/{repo.name}/branches">Branches</a>
6
- <a class="tab{#if activeTab == 'issues'} active{/if}" href="/repos/{repo.owner.username}/{repo.name}/issues">Issues</a>
7
- <a class="tab{#if activeTab == 'merge-requests'} active{/if}" href="/repos/{repo.owner.username}/{repo.name}/merge-requests">Merge requests</a>
8
-</nav>
9
-{/if}
ADD
src/main/resources/templates/RepositoryResource/tags.html
+19 -0
@@ -0,0 +1,19 @@
1
+{#include layout}
2
+{#title}Tags – {repo.name}{/title}
3
+<div class="repo-layout">
4
+ {#include RepositoryResource/sidebar nav=nav active='tags' /}
5
+ <section class="repo-main">
6
+ <h2>Tags</h2>
7
+ {#if tags.isEmpty()}
8
+ <p>No tags.</p>
9
+ {#else}
10
+ <table>
11
+ <tr><th>Tag</th></tr>
12
+ {#for tag in tags}
13
+ <tr><td><a href="/repos/{repo.owner.username}/{repo.name}/tree/{tag}/">{tag}</a></td></tr>
14
+ {/for}
15
+ </table>
16
+ {/if}
17
+ </section>
18
+</div>
19
+{/include}
MODIFY
src/main/resources/templates/RepositoryResource/tree.html
+22 -19
@@ -1,23 +1,26 @@
1
1
{#include layout}
2
2
{#title}{path ?: '/'} at {ref} – {repo.name}{/title}
3
-<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4
-{#include RepositoryResource/tabs repo=repo tabRef=ref activeTab=activeTab /}
5
-<nav class="breadcrumb" aria-label="Path">
6
- {#for crumb in crumbs}
7
- {#if crumb.href}<a href="{crumb.href}">{crumb.label}</a>{#else}<span class="current">{crumb.label}</span>{/if}
8
- {#if crumb_hasNext}<span class="sep">/</span>{/if}
9
- {/for}
10
-</nav>
11
-<div class="panel">
12
- <div class="files">
13
- {#for entry in entries}
14
- <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/tree/{ref}/{entry.path}">
15
- <span class="fname">
16
- <span class="g{#if entry.directory} dir{/if}">{#if entry.directory}▸{#else}≡{/if}</span>
17
- <span class="n">{entry.name}</span>
18
- </span>
19
- </a>
20
- {/for}
21
- </div>
3
+<div class="repo-layout">
4
+ {#include RepositoryResource/sidebar nav=nav active='code' /}
5
+ <section class="repo-main">
6
+ <nav class="breadcrumb" aria-label="Path">
7
+ {#for crumb in crumbs}
8
+ {#if crumb.href}<a href="{crumb.href}">{crumb.label}</a>{#else}<span class="current">{crumb.label}</span>{/if}
9
+ {#if crumb_hasNext}<span class="sep">/</span>{/if}
10
+ {/for}
11
+ </nav>
12
+ <div class="panel">
13
+ <div class="files">
14
+ {#for entry in entries}
15
+ <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/tree/{ref}/{entry.path}">
16
+ <span class="fname">
17
+ <span class="g{#if entry.directory} dir{/if}">{#if entry.directory}▸{#else}≡{/if}</span>
18
+ <span class="n">{entry.name}</span>
19
+ </span>
20
+ </a>
21
+ {/for}
22
+ </div>
23
+ </div>
24
+ </section>
22
25
</div>
23
26
{/include}
MODIFY
src/test/java/de/workaround/web/IssueUiTest.java
+17 -0
@@ -147,6 +147,23 @@
147
147
}
148
148
149
149
@Test
150
+ @TestSecurity(user = "iss-side")
151
+ void issuePagesShowTheRepositorySidebar()
152
+ {
153
+ User owner = persistUser("iss-side");
154
+ Repository repo = service.create(owner, "sb", Repository.Visibility.PUBLIC, null);
155
+ Issue issue = issueService.create(owner, repo, "Sidebar please", null);
156
+ String base = "/repos/" + owner.username + "/sb";
157
+
158
+ given().when().get(base + "/issues")
159
+ .then().statusCode(200).body(containsString("class=\"repo-nav\""));
160
+ given().when().get(base + "/issues/" + issue.id)
161
+ .then().statusCode(200).body(containsString("class=\"repo-nav\""));
162
+ given().when().get(base + "/issues/new")
163
+ .then().statusCode(200).body(containsString("class=\"repo-nav\""));
164
+ }
165
+
166
+ @Test
150
167
void anonymousCannotCreateIssues()
151
168
{
152
169
User owner = persistUser("iss-owner2-" + UUID.randomUUID().toString().substring(0, 8));
MODIFY
src/test/java/de/workaround/web/MergeRequestUiTest.java
+17 -0
@@ -127,6 +127,23 @@
127
127
}
128
128
129
129
@Test
130
+ @TestSecurity(user = "mru-side")
131
+ void mergeRequestPagesShowTheRepositorySidebar()
132
+ {
133
+ User owner = persistUser("mru-side");
134
+ seed(owner, "sb");
135
+ String base = "/repos/" + owner.username + "/sb/merge-requests";
136
+
137
+ String location = given().redirects().follow(false).contentType("application/x-www-form-urlencoded")
138
+ .formParam("title", "Sidebar MR").formParam("sourceBranch", "feature").formParam("targetBranch", "main")
139
+ .when().post(base).then().statusCode(303).extract().header("Location");
140
+
141
+ given().when().get(base).then().statusCode(200).body(containsString("class=\"repo-nav\""));
142
+ given().when().get(base + "/new").then().statusCode(200).body(containsString("class=\"repo-nav\""));
143
+ given().when().get(location).then().statusCode(200).body(containsString("class=\"repo-nav\""));
144
+ }
145
+
146
+ @Test
130
147
@TestSecurity(user = "mru-count")
131
148
void repoOverviewShowsOpenMergeRequestCount()
132
149
{
MODIFY
src/test/java/de/workaround/web/WebUiTest.java
+66 -33
@@ -268,52 +268,53 @@
268
268
}
269
269
270
270
@Test
271
- void repositoryPagesRenderTabsWithActiveSection() throws Exception
271
+ void everyRepositorySubPageShowsTheLeftSidebar() throws Exception
272
272
{
273
- User owner = persistUser("ui-tabs-" + unique());
274
- Repository repo = service.create(owner, "tabbed", Repository.Visibility.PUBLIC, null);
273
+ User owner = persistUser("ui-side-" + unique());
274
+ Repository repo = service.create(owner, "sided", Repository.Visibility.PUBLIC, null);
275
275
GitTestSeeder.seed(service.repositoryPath(repo),
276
276
Map.of("a.txt", "a\n".getBytes(StandardCharsets.UTF_8)));
277
277
278
- String base = "/repos/" + owner.username + "/tabbed";
278
+ String base = "/repos/" + owner.username + "/sided";
279
279
280
- given().when().get(base + "/tree/main")
281
- .then().statusCode(200)
282
- .body(containsString("class=\"tabs\""))
283
- .body(containsString("class=\"tab active\" href=\"" + base + "/tree/main/\">Files"))
284
- .body(containsString("class=\"tab\" href=\"" + base + "/commits/main\">Commits"))
285
- .body(containsString("class=\"tab\" href=\"" + base + "/branches\">Branches"));
286
-
287
- given().when().get(base + "/tree/main/a.txt")
288
- .then().statusCode(200)
289
- .body(containsString("class=\"tab active\" href=\"" + base + "/tree/main/\">Files"));
290
-
291
- given().when().get(base + "/commits/main")
292
- .then().statusCode(200)
293
- .body(containsString("class=\"tab active\" href=\"" + base + "/commits/main\">Commits"));
294
-
295
- given().when().get(base + "/branches")
296
- .then().statusCode(200)
297
- .body(containsString("class=\"tab active\" href=\"" + base + "/branches\">Branches"));
280
+ // the left repo bar (identity + section nav) is present on every repo sub-page, not just the code overview...
281
+ for (String sub : java.util.List.of("", "/tree/main", "/tree/main/a.txt", "/commits/main", "/branches",
282
+ "/issues", "/merge-requests"))
283
+ {
284
+ given().when().get(base + sub)
285
+ .then().statusCode(200)
286
+ .body(containsString("class=\"repo-side\""))
287
+ .body(containsString("class=\"repo-nav\""))
288
+ // ...and the old horizontal tab strip is gone: navigation lives only in the sidebar now
289
+ .body(not(containsString("class=\"tabs\"")));
290
+ }
298
291
}
299
292
300
293
@Test
301
- void tabLinksPreserveSelectedRef() throws Exception
294
+ void sidebarMarksTheActiveSection() throws Exception
302
295
{
303
- User owner = persistUser("ui-kate-" + unique());
304
- Repository repo = service.create(owner, "refkeep", Repository.Visibility.PUBLIC, null);
296
+ User owner = persistUser("ui-active-" + unique());
297
+ Repository repo = service.create(owner, "actrepo", Repository.Visibility.PUBLIC, null);
305
298
GitTestSeeder.seed(service.repositoryPath(repo),
306
299
Map.of("a.txt", "a\n".getBytes(StandardCharsets.UTF_8)));
307
- try (org.eclipse.jgit.api.Git git = org.eclipse.jgit.api.Git.open(service.repositoryPath(repo).toFile()))
308
- {
309
- git.branchCreate().setName("feature").setStartPoint("main").call();
310
- }
311
300
312
- String base = "/repos/" + owner.username + "/refkeep";
313
- given().when().get(base + "/tree/feature")
301
+ String base = "/repos/" + owner.username + "/actrepo";
302
+
303
+ given().when().get(base)
314
304
.then().statusCode(200)
315
- .body(containsString("class=\"tab active\" href=\"" + base + "/tree/feature/\">Files"))
316
- .body(containsString("href=\"" + base + "/commits/feature\">Commits"));
305
+ .body(containsString("class=\"active\" href=\"" + base + "\">"));
306
+ given().when().get(base + "/commits/main")
307
+ .then().statusCode(200)
308
+ .body(containsString("class=\"active\" href=\"" + base + "/commits/main\">"));
309
+ given().when().get(base + "/branches")
310
+ .then().statusCode(200)
311
+ .body(containsString("class=\"active\" href=\"" + base + "/branches\">"));
312
+ given().when().get(base + "/issues")
313
+ .then().statusCode(200)
314
+ .body(containsString("class=\"active\" href=\"" + base + "/issues\">"));
315
+ given().when().get(base + "/merge-requests")
316
+ .then().statusCode(200)
317
+ .body(containsString("class=\"active\" href=\"" + base + "/merge-requests\">"));
317
318
}
318
319
319
320
@Test
@@ -356,6 +357,38 @@
356
357
}
357
358
358
359
@Test
360
+ void tagsHaveTheirOwnPageSeparateFromBranches() throws Exception
361
+ {
362
+ User owner = persistUser("ui-tags-" + unique());
363
+ Repository repo = service.create(owner, "tagged", Repository.Visibility.PUBLIC, null);
364
+ GitTestSeeder.seed(service.repositoryPath(repo),
365
+ Map.of("a.txt", "a\n".getBytes(StandardCharsets.UTF_8)));
366
+ try (org.eclipse.jgit.api.Git git = org.eclipse.jgit.api.Git.open(service.repositoryPath(repo).toFile()))
367
+ {
368
+ git.tag().setName("v1.0").call();
369
+ }
370
+
371
+ String base = "/repos/" + owner.username + "/tagged";
372
+
373
+ // the tag lives on its own dedicated page, which marks the Tags nav item active
374
+ given().when().get(base + "/tags")
375
+ .then().statusCode(200)
376
+ .body(containsString("v1.0"))
377
+ .body(containsString("class=\"active\" href=\"" + base + "/tags\">"));
378
+
379
+ // the sidebar points Tags at that page, not at /branches
380
+ given().when().get(base)
381
+ .then().statusCode(200)
382
+ .body(containsString("href=\"" + base + "/tags\">"));
383
+
384
+ // the branches page is branches-only now: the tag no longer shows up there
385
+ given().when().get(base + "/branches")
386
+ .then().statusCode(200)
387
+ .body(containsString("main"))
388
+ .body(not(containsString("v1.0")));
389
+ }
390
+
391
+ @Test
359
392
void repositoryPageShowsCloneUrls() throws Exception
360
393
{
361
394
User owner = persistUser("ui-gina-" + unique());