gitshark

Clone repository

git clone https://gitshark.de/git/workaround/Gitshark.git
git clone git@gitshark.de:workaround/Gitshark.git

← Commits

✨ (web): Show the repo sidebar on every page and give tags their own view

ad7acc5700a824bb14cbe6f70fb2bfa905a9d6e9 · Phillip Souza Furtner · 2026-07-08T12:11:53Z

Changes

24 files changed, +595 -440

MODIFY README.md +2 -2
diff --git a/README.md b/README.md
index 7c1f095..0c97f99 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/src/main/java/de/workaround/web/IssueResource.java b/src/main/java/de/workaround/web/IssueResource.java
index 8774fdf..caebffb 100644
--- a/src/main/java/de/workaround/web/IssueResource.java
+++ b/src/main/java/de/workaround/web/IssueResource.java
@@ -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
diff --git a/src/main/java/de/workaround/web/MergeRequestResource.java b/src/main/java/de/workaround/web/MergeRequestResource.java
index 9137590..1bbf892 100644
--- a/src/main/java/de/workaround/web/MergeRequestResource.java
+++ b/src/main/java/de/workaround/web/MergeRequestResource.java
@@ -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
diff --git a/src/main/java/de/workaround/web/RepoNav.java b/src/main/java/de/workaround/web/RepoNav.java
new file mode 100644
index 0000000..9625043
--- /dev/null
+++ b/src/main/java/de/workaround/web/RepoNav.java
@@ -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
diff --git a/src/main/java/de/workaround/web/RepoNavService.java b/src/main/java/de/workaround/web/RepoNavService.java
new file mode 100644
index 0000000..5b03f35
--- /dev/null
+++ b/src/main/java/de/workaround/web/RepoNavService.java
@@ -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
diff --git a/src/main/java/de/workaround/web/RepositoryResource.java b/src/main/java/de/workaround/web/RepositoryResource.java
index f981b0b..b7471cc 100644
--- a/src/main/java/de/workaround/web/RepositoryResource.java
+++ b/src/main/java/de/workaround/web/RepositoryResource.java
@@ -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
diff --git a/src/main/resources/META-INF/resources/shark.css b/src/main/resources/META-INF/resources/shark.css
index bb991d5..5165b88 100644
--- a/src/main/resources/META-INF/resources/shark.css
+++ b/src/main/resources/META-INF/resources/shark.css
@@ -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
diff --git a/src/main/resources/templates/IssueResource/issue.html b/src/main/resources/templates/IssueResource/issue.html
index b037058..a52adb7 100644
--- a/src/main/resources/templates/IssueResource/issue.html
+++ b/src/main/resources/templates/IssueResource/issue.html
@@ -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
diff --git a/src/main/resources/templates/IssueResource/issues.html b/src/main/resources/templates/IssueResource/issues.html
index 6bb7679..5a2dbae 100644
--- a/src/main/resources/templates/IssueResource/issues.html
+++ b/src/main/resources/templates/IssueResource/issues.html
@@ -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
diff --git a/src/main/resources/templates/IssueResource/newIssue.html b/src/main/resources/templates/IssueResource/newIssue.html
index a179876..46e51e6 100644
--- a/src/main/resources/templates/IssueResource/newIssue.html
+++ b/src/main/resources/templates/IssueResource/newIssue.html
@@ -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
diff --git a/src/main/resources/templates/MergeRequestResource/mergeRequest.html b/src/main/resources/templates/MergeRequestResource/mergeRequest.html
index c7d4a57..58e7cbe 100644
--- a/src/main/resources/templates/MergeRequestResource/mergeRequest.html
+++ b/src/main/resources/templates/MergeRequestResource/mergeRequest.html
@@ -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">&larr; 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
diff --git a/src/main/resources/templates/MergeRequestResource/mergeRequests.html b/src/main/resources/templates/MergeRequestResource/mergeRequests.html
index ac30838..07e359d 100644
--- a/src/main/resources/templates/MergeRequestResource/mergeRequests.html
+++ b/src/main/resources/templates/MergeRequestResource/mergeRequests.html
@@ -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> &rarr; <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> &rarr; <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> &rarr; <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> &rarr; <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
diff --git a/src/main/resources/templates/MergeRequestResource/newMergeRequest.html b/src/main/resources/templates/MergeRequestResource/newMergeRequest.html
index b61d01c..eae98e3 100644
--- a/src/main/resources/templates/MergeRequestResource/newMergeRequest.html
+++ b/src/main/resources/templates/MergeRequestResource/newMergeRequest.html
@@ -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">&larr; 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">&larr; 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
diff --git a/src/main/resources/templates/RepositoryResource/blob.html b/src/main/resources/templates/RepositoryResource/blob.html
index d84b9f6..ee215be 100644
--- a/src/main/resources/templates/RepositoryResource/blob.html
+++ b/src/main/resources/templates/RepositoryResource/blob.html
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/branches.html b/src/main/resources/templates/RepositoryResource/branches.html
index 662c7a3..a6522ab 100644
--- a/src/main/resources/templates/RepositoryResource/branches.html
+++ b/src/main/resources/templates/RepositoryResource/branches.html
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/commits.html b/src/main/resources/templates/RepositoryResource/commits.html
index 2b7d95e..a3db51c 100644
--- a/src/main/resources/templates/RepositoryResource/commits.html
+++ b/src/main/resources/templates/RepositoryResource/commits.html
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/overview.html b/src/main/resources/templates/RepositoryResource/overview.html
index 834c981..3b0b28b 100644
--- a/src/main/resources/templates/RepositoryResource/overview.html
+++ b/src/main/resources/templates/RepositoryResource/overview.html
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/sidebar.html b/src/main/resources/templates/RepositoryResource/sidebar.html
new file mode 100644
index 0000000..f4316ab
--- /dev/null
+++ b/src/main/resources/templates/RepositoryResource/sidebar.html
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/tabs.html b/src/main/resources/templates/RepositoryResource/tabs.html
deleted file mode 100644
index abbbb78..0000000
--- a/src/main/resources/templates/RepositoryResource/tabs.html
+++ /dev/null
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/tags.html b/src/main/resources/templates/RepositoryResource/tags.html
new file mode 100644
index 0000000..0b8f762
--- /dev/null
+++ b/src/main/resources/templates/RepositoryResource/tags.html
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/tree.html b/src/main/resources/templates/RepositoryResource/tree.html
index fcb93fb..29f0528 100644
--- a/src/main/resources/templates/RepositoryResource/tree.html
+++ b/src/main/resources/templates/RepositoryResource/tree.html
@@ -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
diff --git a/src/test/java/de/workaround/web/IssueUiTest.java b/src/test/java/de/workaround/web/IssueUiTest.java
index e5a3f94..a69a793 100644
--- a/src/test/java/de/workaround/web/IssueUiTest.java
+++ b/src/test/java/de/workaround/web/IssueUiTest.java
@@ -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
diff --git a/src/test/java/de/workaround/web/MergeRequestUiTest.java b/src/test/java/de/workaround/web/MergeRequestUiTest.java
index 3e585c1..e6bce3e 100644
--- a/src/test/java/de/workaround/web/MergeRequestUiTest.java
+++ b/src/test/java/de/workaround/web/MergeRequestUiTest.java
@@ -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
diff --git a/src/test/java/de/workaround/web/WebUiTest.java b/src/test/java/de/workaround/web/WebUiTest.java
index f81093a..301a4d1 100644
--- a/src/test/java/de/workaround/web/WebUiTest.java
+++ b/src/test/java/de/workaround/web/WebUiTest.java
@@ -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());

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog