✨ (web): Add path breadcrumbs and fully-clickable directory rows
Changes
5 files changed, +129 -16
MODIFY
src/main/java/de/workaround/web/RepositoryResource.java
+38 -4
@@ -5,6 +5,7 @@
5
5
import java.nio.file.Path;
6
6
import java.time.Duration;
7
7
import java.time.Instant;
8
+import java.util.ArrayList;
8
9
import java.util.List;
9
10
import java.util.Locale;
10
11
import java.util.Map;
@@ -49,10 +50,10 @@
49
50
int branchCount, int tagCount);
50
51
51
52
static native TemplateInstance tree(Repository repo, String ref, String path,
52
- List<GitBrowseService.TreeEntry> entries);
53
+ List<GitBrowseService.TreeEntry> entries, List<Crumb> crumbs);
53
54
54
55
static native TemplateInstance blob(Repository repo, String ref, String path, boolean binary, String content,
55
- String language);
56
+ String language, List<Crumb> crumbs);
56
57
57
58
static native TemplateInstance commits(Repository repo, String ref, List<GitBrowseService.CommitInfo> commits,
58
59
int page, int prevPage, int nextPage, int size, boolean hasNext);
@@ -166,7 +167,7 @@
166
167
Repository repo = requireReadable(owner, name);
167
168
Path repoPath = service.repositoryPath(repo);
168
169
return browse.listTree(repoPath, ref, path)
169
- .map(entries -> Templates.tree(repo, ref, path, entries))
170
+ .map(entries -> Templates.tree(repo, ref, path, entries, breadcrumbs(repo, ref, path)))
170
171
.orElseGet(() -> blobView(repo, repoPath, ref, path));
171
172
}
172
173
@@ -260,7 +261,7 @@
260
261
GitBrowseService.BlobView blob = browse.blob(repoPath, ref, path).orElseThrow(NotFoundException::new);
261
262
String content = blob.binary() ? null : new String(blob.content(), StandardCharsets.UTF_8);
262
263
String language = blob.binary() ? null : highlightLanguage(path);
263
- return Templates.blob(repo, ref, path, blob.binary(), content, language);
264
+ return Templates.blob(repo, ref, path, blob.binary(), content, language, breadcrumbs(repo, ref, path));
264
265
}
265
266
266
267
// Extension → highlight.js language id. Every value here MUST have a grammar in the bundled highlight assets
@@ -331,6 +332,39 @@
331
332
return languages;
332
333
}
333
334
335
+ /** A single segment of the path breadcrumb. {@code href} is {@code null} for the current location (rendered plain). */
336
+ public record Crumb(String label, String href)
337
+ {
338
+ }
339
+
340
+ /**
341
+ * Builds the path breadcrumb for a tree or blob view: the ref, then one crumb per path segment. Every crumb links
342
+ * to its directory tree except the last, which is the current location and is rendered as plain text.
343
+ */
344
+ private static List<Crumb> breadcrumbs(Repository repo, String ref, String path)
345
+ {
346
+ String treeBase = "/repos/" + repo.owner.username + "/" + repo.name + "/tree/" + ref;
347
+ List<Crumb> crumbs = new ArrayList<>();
348
+ crumbs.add(new Crumb(ref, treeBase));
349
+ if (path != null && !path.isEmpty())
350
+ {
351
+ StringBuilder cumulative = new StringBuilder();
352
+ for (String segment : path.split("/"))
353
+ {
354
+ if (segment.isEmpty())
355
+ {
356
+ continue;
357
+ }
358
+ cumulative.append('/').append(segment);
359
+ crumbs.add(new Crumb(segment, treeBase + cumulative));
360
+ }
361
+ }
362
+ // the current location is not a link
363
+ Crumb current = crumbs.get(crumbs.size() - 1);
364
+ crumbs.set(crumbs.size() - 1, new Crumb(current.label(), null));
365
+ return crumbs;
366
+ }
367
+
334
368
private Repository requireReadable(String owner, String name)
335
369
{
336
370
Repository repo = service.find(owner, name).orElseThrow(NotFoundException::new);
MODIFY
src/main/resources/META-INF/resources/shark.css
+25 -0
@@ -577,6 +577,31 @@
577
577
display: inline;
578
578
}
579
579
580
+/* path breadcrumb */
581
+
582
+nav.breadcrumb {
583
+ display: flex;
584
+ flex-wrap: wrap;
585
+ align-items: center;
586
+ gap: 6px;
587
+ font-family: var(--mono);
588
+ font-size: 14px;
589
+ margin: 0 0 var(--s4);
590
+}
591
+
592
+nav.breadcrumb a {
593
+ color: var(--accent-deep);
594
+}
595
+
596
+nav.breadcrumb .current {
597
+ color: var(--ink);
598
+ font-weight: 600;
599
+}
600
+
601
+nav.breadcrumb .sep {
602
+ color: var(--faint);
603
+}
604
+
580
605
/* code */
581
606
582
607
code, pre {
MODIFY
src/main/resources/templates/RepositoryResource/blob.html
+6 -1
@@ -1,7 +1,12 @@
1
1
{#include layout}
2
2
{#title}{path} at {ref} – {repo.name}{/title}
3
3
<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4
-<p><code>{ref}</code> · /{path}</p>
4
+<nav class="breadcrumb" aria-label="Path">
5
+ {#for crumb in crumbs}
6
+ {#if crumb.href}<a href="{crumb.href}">{crumb.label}</a>{#else}<span class="current">{crumb.label}</span>{/if}
7
+ {#if crumb_hasNext}<span class="sep">/</span>{/if}
8
+ {/for}
9
+</nav>
5
10
{#if binary}
6
11
<p>Binary file. <a href="/repos/{repo.owner.username}/{repo.name}/raw/{ref}/{path}">Download</a></p>
7
12
{#else if language}
MODIFY
src/main/resources/templates/RepositoryResource/tree.html
+17 -11
@@ -1,16 +1,22 @@
1
1
{#include layout}
2
2
{#title}{path ?: '/'} at {ref} – {repo.name}{/title}
3
3
<h1><a href="/repos/{repo.owner.username}/{repo.name}">{repo.owner.username}/{repo.name}</a></h1>
4
-<p><code>{ref}</code> · /{path}</p>
5
-<table>
6
- <tr><th>Name</th></tr>
7
- {#for entry in entries}
8
- <tr>
9
- <td>
10
- {#if entry.directory}📁{/if}
11
- <a href="/repos/{repo.owner.username}/{repo.name}/tree/{ref}/{entry.path}">{entry.name}</a>
12
- </td>
13
- </tr>
4
+<nav class="breadcrumb" aria-label="Path">
5
+ {#for crumb in crumbs}
6
+ {#if crumb.href}<a href="{crumb.href}">{crumb.label}</a>{#else}<span class="current">{crumb.label}</span>{/if}
7
+ {#if crumb_hasNext}<span class="sep">/</span>{/if}
14
8
{/for}
15
-</table>
9
+</nav>
10
+<div class="panel">
11
+ <div class="files">
12
+ {#for entry in entries}
13
+ <a class="frow" href="/repos/{repo.owner.username}/{repo.name}/tree/{ref}/{entry.path}">
14
+ <span class="fname">
15
+ <span class="g{#if entry.directory} dir{/if}">{#if entry.directory}▸{#else}≡{/if}</span>
16
+ <span class="n">{entry.name}</span>
17
+ </span>
18
+ </a>
19
+ {/for}
20
+ </div>
21
+</div>
16
22
{/include}
MODIFY
src/test/java/de/workaround/web/WebUiTest.java
+43 -0
@@ -180,6 +180,49 @@
180
180
}
181
181
}
182
182
183
+ @Test
184
+ void fileAndTreeViewsShowClickableBreadcrumbPath() throws Exception
185
+ {
186
+ User owner = persistUser("ui-jane-" + unique());
187
+ Repository repo = service.create(owner, "crumbs", Repository.Visibility.PUBLIC, null);
188
+ GitTestSeeder.seed(service.repositoryPath(repo),
189
+ Map.of("src/main/java/App.java", "class App {}\n".getBytes(StandardCharsets.UTF_8)));
190
+
191
+ String base = "/repos/" + owner.username + "/crumbs";
192
+
193
+ // directory view: the ref and each ancestor segment link to their tree; the current dir is not a link
194
+ given().when().get(base + "/tree/main/src/main")
195
+ .then().statusCode(200)
196
+ .body(containsString("class=\"breadcrumb\""))
197
+ .body(containsString("href=\"" + base + "/tree/main\""))
198
+ .body(containsString("href=\"" + base + "/tree/main/src\""));
199
+
200
+ // file view: clicking the ref jumps to the repo root, clicking a segment jumps into that directory
201
+ given().when().get(base + "/tree/main/src/main/java/App.java")
202
+ .then().statusCode(200)
203
+ .body(containsString("class=\"breadcrumb\""))
204
+ .body(containsString("href=\"" + base + "/tree/main\""))
205
+ .body(containsString("href=\"" + base + "/tree/main/src/main/java\""));
206
+ }
207
+
208
+ @Test
209
+ void directoryEntriesAreFullyClickableRows() throws Exception
210
+ {
211
+ User owner = persistUser("ui-karl-" + unique());
212
+ Repository repo = service.create(owner, "rows", Repository.Visibility.PUBLIC, null);
213
+ GitTestSeeder.seed(service.repositoryPath(repo), Map.of(
214
+ "docs/guide.md", "x\n".getBytes(StandardCharsets.UTF_8),
215
+ "README.md", "x\n".getBytes(StandardCharsets.UTF_8)));
216
+
217
+ String base = "/repos/" + owner.username + "/rows";
218
+
219
+ // the whole row (icon + name) is a single anchor, so the entire box is clickable — not just the text
220
+ given().when().get(base + "/tree/main")
221
+ .then().statusCode(200)
222
+ .body(containsString("<a class=\"frow\" href=\"" + base + "/tree/main/docs\""))
223
+ .body(containsString("<a class=\"frow\" href=\"" + base + "/tree/main/README.md\""));
224
+ }
225
+
183
226
private static String readResource(String path) throws Exception
184
227
{
185
228
try (java.io.InputStream in = WebUiTest.class.getClassLoader().getResourceAsStream(path))