gitshark

Clone repository

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

← Commits

✨ (web): Add path breadcrumbs and fully-clickable directory rows

9eff04e8766182cf63aca77223c6fdbd946c28d8 · Phillip Souza Furtner · 2026-07-07T11:55:18Z

Changes

5 files changed, +129 -16

MODIFY src/main/java/de/workaround/web/RepositoryResource.java +38 -4
diff --git a/src/main/java/de/workaround/web/RepositoryResource.java b/src/main/java/de/workaround/web/RepositoryResource.java
index e61189d..bc5ef36 100644
--- a/src/main/java/de/workaround/web/RepositoryResource.java
+++ b/src/main/java/de/workaround/web/RepositoryResource.java
@@ -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
diff --git a/src/main/resources/META-INF/resources/shark.css b/src/main/resources/META-INF/resources/shark.css
index f1a71ba..ce6a3dc 100644
--- a/src/main/resources/META-INF/resources/shark.css
+++ b/src/main/resources/META-INF/resources/shark.css
@@ -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
diff --git a/src/main/resources/templates/RepositoryResource/blob.html b/src/main/resources/templates/RepositoryResource/blob.html
index a8d74f1..5b264f7 100644
--- a/src/main/resources/templates/RepositoryResource/blob.html
+++ b/src/main/resources/templates/RepositoryResource/blob.html
@@ -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> &middot; /{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
diff --git a/src/main/resources/templates/RepositoryResource/tree.html b/src/main/resources/templates/RepositoryResource/tree.html
index dcc01ba..682bac8 100644
--- a/src/main/resources/templates/RepositoryResource/tree.html
+++ b/src/main/resources/templates/RepositoryResource/tree.html
@@ -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> &middot; /{path}</p>
5 -<table>
6 - <tr><th>Name</th></tr>
7 - {#for entry in entries}
8 - <tr>
9 - <td>
10 - {#if entry.directory}&#x1F4C1;{/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
diff --git a/src/test/java/de/workaround/web/WebUiTest.java b/src/test/java/de/workaround/web/WebUiTest.java
index 6b58ca5..2fd5fc1 100644
--- a/src/test/java/de/workaround/web/WebUiTest.java
+++ b/src/test/java/de/workaround/web/WebUiTest.java
@@ -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))

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog