gitshark

Clone repository

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

← Commits

✨ (settings): Add per-user content width setting

ba29a0d94852c85bee3be1b9834bb7af96258288 · Michael Hainz · 2026-07-10T10:35:04Z

Changes

12 files changed, +298 -14

MODIFY README.md +1 -0
diff --git a/README.md b/README.md
index 508f4d1..c5ffef8 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,7 @@
30 30 - 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, the repository owner, or a collaborator. 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
31 31 - OIDC login (authorization code flow) via `GET /login`; on first login the user account is created without a username and the browser is redirected to `/onboarding`, where the user picks a URL-safe handle (`^[a-z0-9][a-z0-9-]{0,38}$`, unique across users and organisations). The chosen handle — not the OIDC `preferred_username` claim (which is an SPN form in kanidm and not URL-safe) — is used in all repo, SSH, ActivityPub, and webfinger URLs. The `name` claim becomes an editable display name; both can be changed later at `/settings/profile`. A request filter blocks all app pages until a handle is chosen. The code-flow callback is pinned to `/login` (`redirect-path` + `restore-path-after-redirect`, so strict-`redirect_uri` IdPs like kanidm register one URI) and after login the user lands back on the page they came from — the header's Log in button carries `?redirect=<current page>` (local paths only, open-redirect guarded). Expired ID tokens are refreshed silently with the refresh token (`refresh-expired=true`, 60 s proactive skew, session cookie usable 12 h past expiry) instead of logging the user out. Logout is local-session only via `POST /logout` (the kanidm provider advertises no `end_session_endpoint`, so RP-Initiated Logout is disabled)
32 32 - Profile pictures: users can upload a PNG/JPEG/GIF/WebP avatar (≤ 2 MB, content-type and magic bytes both validated) at `/settings/profile`, stored on the filesystem keyed by user UUID and served publicly at `GET /users/{username}/avatar`; shown wherever a local user is rendered (header nav, repo lists, repo sidebar, issue/MR/comment authors) via a reusable Qute avatar tag, removable, and falling back to an initials badge when absent. Git commit authors and remote federation actors are not local users and keep their existing pseudo-avatars
33 +- Per-user content width setting (Appearance section on `/settings/profile`): **Full** (default — repository pages span the entire screen, other pages cap at 1400px), **Comfortable (fixed 1400px)**, or **Compact (fixed 1120px)** on every page; the width also aligns the header nav content (the bar background stays edge-to-edge), and windows narrower than the cap just fill the screen. Guide: [profile settings](docs/users/profile.md)
33 34 - **Collaborators** — the repository owner can grant other local users read+write access
34 35 (one flat role) on a per-repository settings page (`…/settings/collaborators`, reached from a
35 36 "Manage collaborators" link on the owner-only repository Settings page). Collaborators can read and push — UI, HTTP, and SSH alike — even
MODIFY docs/users/profile.md +24 -0
diff --git a/docs/users/profile.md b/docs/users/profile.md
index 2b9f2b3..19f49fb 100644
--- a/docs/users/profile.md
+++ b/docs/users/profile.md
@@ -17,6 +17,30 @@
17 17
18 18 ---
19 19
20 +## Appearance
21 +
22 +The **Appearance** section controls how wide pages are rendered for you. Pick
23 +one of three **Content width** presets and press **Save**:
24 +
25 +| Preset | Width | Good for |
26 +|---|---|---|
27 +| **Full** (default) | Repository pages (`/repos/<owner>/<name>/…`) span the entire screen; all other pages cap at a comfortable 1400px | Diffs, code browsing, wide tables |
28 +| **Comfortable** | Fixed 1400px column on every page, centered | Balanced reading width |
29 +| **Compact** | Fixed 1120px column on every page, centered | Focused reading on large monitors |
30 +
31 +Comfortable and Compact are uniform across the whole app: every page uses the
32 +same fixed pixel column, so the width doesn't shift around when you resize the
33 +window or navigate. Full is uniform per page type instead — repository pages
34 +get the entire screen (diffs and file trees benefit most), everything else the
35 +centered 1400px column. Whatever width the current page uses, the header bar's
36 +content (logo, navigation, account menu) aligns with the same column while the
37 +bar's background still spans the full screen; on windows narrower than the cap
38 +the content simply fills the screen. The setting applies as soon as you save
39 +and sticks across sessions (it's stored on your account, not in the browser).
40 +Visitors who aren't logged in always get the Full layout.
41 +
42 +---
43 +
20 44 ## Profile picture
21 45
22 46 The **Profile picture** section lets you upload an avatar:
ADD src/main/java/de/workaround/account/AppearanceService.java +37 -0
diff --git a/src/main/java/de/workaround/account/AppearanceService.java b/src/main/java/de/workaround/account/AppearanceService.java
new file mode 100644
index 0000000..efdb590
--- /dev/null
+++ b/src/main/java/de/workaround/account/AppearanceService.java
@@ -0,0 +1,37 @@
1 +package de.workaround.account;
2 +
3 +import java.util.Locale;
4 +
5 +import de.workaround.model.User;
6 +import jakarta.enterprise.context.ApplicationScoped;
7 +import jakarta.inject.Inject;
8 +import jakarta.transaction.Transactional;
9 +
10 +/**
11 + * Persists per-user display preferences chosen on the settings page. Currently only the content
12 + * width preset, which scales the layout's main column (see {@link User.ContentWidth}).
13 + */
14 +@ApplicationScoped
15 +public class AppearanceService
16 +{
17 + @Inject
18 + User.Repo users;
19 +
20 + @Transactional
21 + public void setContentWidth(User user, String preset)
22 + {
23 + User.ContentWidth parsed;
24 + try
25 + {
26 + parsed = User.ContentWidth.valueOf(preset == null ? "" : preset.trim().toUpperCase(Locale.ROOT));
27 + }
28 + catch (IllegalArgumentException e)
29 + {
30 + throw new InvalidContentWidthException("Unknown content width preset.");
31 + }
32 + User managed = users.findById(user.id);
33 + managed.contentWidth = parsed;
34 + user.contentWidth = parsed;
35 + }
36 +
37 +}
MODIFY src/main/java/de/workaround/account/CurrentUser.java +10 -0
diff --git a/src/main/java/de/workaround/account/CurrentUser.java b/src/main/java/de/workaround/account/CurrentUser.java
index 000607c..43f4f54 100644
--- a/src/main/java/de/workaround/account/CurrentUser.java
+++ b/src/main/java/de/workaround/account/CurrentUser.java
@@ -43,6 +43,16 @@
43 43 return cached;
44 44 }
45 45
46 + /**
47 + * CSS class for the user's content-width preset, applied on {@code <body>} by the layout so
48 + * header nav and main column share one width. Empty for anonymous visitors and the FULL default.
49 + */
50 + public String contentWidthClass()
51 + {
52 + User user = get();
53 + return user == null ? "" : user.contentWidth.cssClass;
54 + }
55 +
46 56 public User require()
47 57 {
48 58 User user = get();
ADD src/main/java/de/workaround/account/InvalidContentWidthException.java +10 -0
diff --git a/src/main/java/de/workaround/account/InvalidContentWidthException.java b/src/main/java/de/workaround/account/InvalidContentWidthException.java
new file mode 100644
index 0000000..27de44c
--- /dev/null
+++ b/src/main/java/de/workaround/account/InvalidContentWidthException.java
@@ -0,0 +1,10 @@
1 +package de.workaround.account;
2 +
3 +public class InvalidContentWidthException extends RuntimeException
4 +{
5 + public InvalidContentWidthException(String message)
6 + {
7 + super(message);
8 + }
9 +
10 +}
MODIFY src/main/java/de/workaround/account/SettingsResource.java +30 -5
diff --git a/src/main/java/de/workaround/account/SettingsResource.java b/src/main/java/de/workaround/account/SettingsResource.java
index 333142e..a7becb0 100644
--- a/src/main/java/de/workaround/account/SettingsResource.java
+++ b/src/main/java/de/workaround/account/SettingsResource.java
@@ -14,6 +14,7 @@
14 14 import de.workaround.http.AccessTokenService;
15 15 import de.workaround.model.AccessToken;
16 16 import de.workaround.model.SshKey;
17 +import de.workaround.model.User;
17 18 import io.quarkus.qute.CheckedTemplate;
18 19 import io.quarkus.qute.TemplateInstance;
19 20 import jakarta.inject.Inject;
@@ -41,7 +42,7 @@
41 42 static native TemplateInstance tokenCreated(String plaintext);
42 43
43 44 static native TemplateInstance profile(String username, String displayName, boolean hasAvatar,
44 - Instant avatarUpdatedAt, String error);
45 + Instant avatarUpdatedAt, User.ContentWidth contentWidth, String error);
45 46 }
46 47
47 48 @Inject
@@ -59,12 +60,16 @@
59 60 @Inject
60 61 AvatarService avatars;
61 62
63 + @Inject
64 + AppearanceService appearance;
65 +
62 66 @GET
63 67 @Path("/profile")
64 68 public TemplateInstance profile()
65 69 {
66 70 de.workaround.model.User user = currentUser.require();
67 - return Templates.profile(user.username, user.displayName, user.hasAvatar(), user.avatarUpdatedAt, null);
71 + return Templates.profile(user.username, user.displayName, user.hasAvatar(), user.avatarUpdatedAt,
72 + user.contentWidth, null);
68 73 }
69 74
70 75 @POST
@@ -83,7 +88,27 @@
83 88 {
84 89 return Response.status(Response.Status.BAD_REQUEST)
85 90 .entity(Templates.profile(username, displayName, user.hasAvatar(), user.avatarUpdatedAt,
86 - e.getMessage()))
91 + user.contentWidth, e.getMessage()))
92 + .build();
93 + }
94 + }
95 +
96 + @POST
97 + @Path("/profile/appearance")
98 + @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
99 + public Response updateAppearance(@FormParam("contentWidth") String contentWidth)
100 + {
101 + de.workaround.model.User user = currentUser.require();
102 + try
103 + {
104 + appearance.setContentWidth(user, contentWidth);
105 + return Response.seeOther(URI.create("/settings/profile")).build();
106 + }
107 + catch (InvalidContentWidthException e)
108 + {
109 + return Response.status(Response.Status.BAD_REQUEST)
110 + .entity(Templates.profile(user.username, user.displayName, user.hasAvatar(),
111 + user.avatarUpdatedAt, user.contentWidth, e.getMessage()))
87 112 .build();
88 113 }
89 114 }
@@ -98,7 +123,7 @@
98 123 {
99 124 return Response.status(Response.Status.BAD_REQUEST)
100 125 .entity(Templates.profile(user.username, user.displayName, user.hasAvatar(),
101 - user.avatarUpdatedAt, "No image was uploaded."))
126 + user.avatarUpdatedAt, user.contentWidth, "No image was uploaded."))
102 127 .build();
103 128 }
104 129 try
@@ -110,7 +135,7 @@
110 135 {
111 136 return Response.status(Response.Status.BAD_REQUEST)
112 137 .entity(Templates.profile(user.username, user.displayName, user.hasAvatar(),
113 - user.avatarUpdatedAt, e.getMessage()))
138 + user.avatarUpdatedAt, user.contentWidth, e.getMessage()))
114 139 .build();
115 140 }
116 141 catch (IOException e)
MODIFY src/main/java/de/workaround/model/User.java +20 -0
diff --git a/src/main/java/de/workaround/model/User.java b/src/main/java/de/workaround/model/User.java
index ed01d6c..0fd8fb9 100644
--- a/src/main/java/de/workaround/model/User.java
+++ b/src/main/java/de/workaround/model/User.java
@@ -9,6 +9,8 @@
9 9 import io.quarkus.hibernate.panache.PanacheEntity;
10 10 import io.quarkus.hibernate.panache.PanacheRepository;
11 11 import jakarta.persistence.Entity;
12 +import jakarta.persistence.EnumType;
13 +import jakarta.persistence.Enumerated;
12 14 import jakarta.persistence.GeneratedValue;
13 15 import jakarta.persistence.GenerationType;
14 16 import jakarta.persistence.Id;
@@ -42,11 +44,29 @@
42 44
43 45 public Instant avatarUpdatedAt;
44 46
47 + // Display preference for the main content column; presets scale the layout max-width.
48 + @Enumerated(EnumType.STRING)
49 + public ContentWidth contentWidth = ContentWidth.FULL;
50 +
45 51 public boolean hasAvatar()
46 52 {
47 53 return avatarContentType != null;
48 54 }
49 55
56 + public enum ContentWidth
57 + {
58 + FULL(""),
59 + COMFORTABLE("width-comfortable"),
60 + COMPACT("width-compact");
61 +
62 + public final String cssClass;
63 +
64 + ContentWidth(String cssClass)
65 + {
66 + this.cssClass = cssClass;
67 + }
68 + }
69 +
50 70 public interface Repo extends PanacheRepository.Managed<User, UUID>
51 71 {
52 72 @Find
MODIFY src/main/resources/META-INF/resources/shark.css +24 -8
diff --git a/src/main/resources/META-INF/resources/shark.css b/src/main/resources/META-INF/resources/shark.css
index 39a3bdb..12d81c8 100644
--- a/src/main/resources/META-INF/resources/shark.css
+++ b/src/main/resources/META-INF/resources/shark.css
@@ -94,6 +94,14 @@
94 94 }
95 95
96 96 body {
97 + /* Per-user content width preset (settings > profile > appearance): one shared width for the
98 + header nav content and the page's main column. --preset-w is what the user picked (Full =
99 + the whole screen; the other presets are fixed px caps so the column stays put while the
100 + window is resized — narrower windows simply fill the viewport). Non-repo pages additionally
101 + cap at a comfortable 1400px; repo pages (the :has override below) get the raw preset width,
102 + so Full there means the entire screen for diffs and file trees. */
103 + --preset-w: 100%;
104 + --content-w: min(var(--preset-w), 1400px);
97 105 margin: 0;
98 106 background: var(--canvas);
99 107 color: var(--ink);
@@ -101,6 +109,18 @@
101 109 -webkit-font-smoothing: antialiased;
102 110 }
103 111
112 +body:has(.repo-layout) {
113 + --content-w: var(--preset-w);
114 +}
115 +
116 +body.width-comfortable {
117 + --preset-w: 1400px;
118 +}
119 +
120 +body.width-compact {
121 + --preset-w: 1120px;
122 +}
123 +
104 124 h1, h2, h3 {
105 125 color: var(--ink);
106 126 line-height: 1.2;
@@ -160,7 +180,9 @@
160 180 display: flex;
161 181 align-items: center;
162 182 gap: var(--s4);
163 - padding: var(--s3) var(--s5);
183 + /* bar background spans the viewport, its content aligns with the preset content column;
184 + max() keeps the normal gutter when the window is narrower than the preset cap */
185 + padding: var(--s3) max(var(--s5), calc((100% - var(--content-w)) / 2 + var(--s5)));
164 186 background: var(--surface);
165 187 border-bottom: 1px solid var(--border);
166 188 }
@@ -302,17 +324,11 @@
302 324 }
303 325
304 326 main {
305 - max-width: 1120px;
327 + max-width: var(--content-w);
306 328 margin: var(--s6) auto;
307 329 padding: 0 var(--s5);
308 330 }
309 331
310 -/* repo pages use the full viewport width: sidebar pinned far left, content fills the rest */
311 -main:has(.repo-layout) {
312 - max-width: none;
313 - margin: var(--s6) 0;
314 -}
315 -
316 332 /* in-app repo hero */
317 333
318 334 .hero {
ADD src/main/resources/db/migration/V15__user_content_width.sql +5 -0
diff --git a/src/main/resources/db/migration/V15__user_content_width.sql b/src/main/resources/db/migration/V15__user_content_width.sql
new file mode 100644
index 0000000..eb5b0df
--- /dev/null
+++ b/src/main/resources/db/migration/V15__user_content_width.sql
@@ -0,0 +1,5 @@
1 +-- Per-user display preference for the main content column width. Presets scale the
2 +-- layout's max-width: FULL keeps the current 1120px, COMFORTABLE 85% (952px),
3 +-- COMPACT 70% (784px). Existing and new users default to FULL (no visual change).
4 +ALTER TABLE users
5 + ADD COLUMN content_width varchar(16) NOT NULL DEFAULT 'FULL';
MODIFY src/main/resources/templates/SettingsResource/profile.html +14 -0
diff --git a/src/main/resources/templates/SettingsResource/profile.html b/src/main/resources/templates/SettingsResource/profile.html
index a2b8864..c37df63 100644
--- a/src/main/resources/templates/SettingsResource/profile.html
+++ b/src/main/resources/templates/SettingsResource/profile.html
@@ -12,6 +12,20 @@
12 12 <button class="btn btn-primary">Save</button>
13 13 </form>
14 14
15 +<h2>Appearance</h2>
16 +<form method="post" action="/settings/profile/appearance">
17 + <p><label>Content width
18 + <select name="contentWidth">
19 + <option value="FULL"{#if contentWidth.name == 'FULL'} selected{/if}>Full (default)</option>
20 + <option value="COMFORTABLE"{#if contentWidth.name == 'COMFORTABLE'} selected{/if}>Comfortable (1400 px)</option>
21 + <option value="COMPACT"{#if contentWidth.name == 'COMPACT'} selected{/if}>Compact (1120 px)</option>
22 + </select></label></p>
23 + <p class="muted">How wide the app is on large screens. Full spans repository pages across the whole
24 + screen and caps other pages at 1400 px; Comfortable and Compact center a fixed-width column
25 + on every page, header included.</p>
26 + <button class="btn btn-primary">Save</button>
27 +</form>
28 +
15 29 <h2>Profile picture</h2>
16 30 {#if hasAvatar}
17 31 <img class="avatar-preview" src="/users/{username}/avatar?v={avatarUpdatedAt.toEpochMilli}" alt="Current profile picture">
MODIFY src/main/resources/templates/layout.html +1 -1
diff --git a/src/main/resources/templates/layout.html b/src/main/resources/templates/layout.html
index b235d9a..ac77942 100644
--- a/src/main/resources/templates/layout.html
+++ b/src/main/resources/templates/layout.html
@@ -8,7 +8,7 @@
8 8 <link rel="stylesheet" href="/shark.css">
9 9 <script defer src="/shark-hotkeys.js"></script>
10 10 </head>
11 -<body>
11 +<body class="{cdi:currentUser.contentWidthClass}">
12 12 <header class="site">
13 13 <a class="brand" href="/"><svg class="bmark" viewBox="0 0 64 64" width="26" height="26" aria-hidden="true"><rect width="64" height="64" rx="15" fill="#168b85"></rect><path d="M12 42 H52 M24 42 C29 42 31 50 38 50" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round"></path><circle cx="16" cy="42" r="3.2" fill="#fff"></circle><circle cx="38" cy="50" r="3.2" fill="#fff"></circle><path d="M26 42 C28 32 32 24 39 14 C40 22 42 33 45 42 Z" fill="#fff"></path></svg><span class="g">git</span><span class="s">shark</span></a>
14 14 <nav>
ADD src/test/java/de/workaround/web/ContentWidthTest.java +122 -0
diff --git a/src/test/java/de/workaround/web/ContentWidthTest.java b/src/test/java/de/workaround/web/ContentWidthTest.java
new file mode 100644
index 0000000..7e5e432
--- /dev/null
+++ b/src/test/java/de/workaround/web/ContentWidthTest.java
@@ -0,0 +1,122 @@
1 +package de.workaround.web;
2 +
3 +import org.junit.jupiter.api.Test;
4 +
5 +import de.workaround.model.User;
6 +import io.quarkus.test.junit.QuarkusTest;
7 +import io.quarkus.test.security.TestSecurity;
8 +import jakarta.inject.Inject;
9 +import jakarta.transaction.Transactional;
10 +
11 +import static io.restassured.RestAssured.given;
12 +import static org.hamcrest.CoreMatchers.containsString;
13 +import static org.hamcrest.CoreMatchers.not;
14 +
15 +@QuarkusTest
16 +class ContentWidthTest
17 +{
18 + @Test
19 + @TestSecurity(user = "width-default-user")
20 + void profileOffersContentWidthPresetsWithFullAsDefault() throws Exception
21 + {
22 + persistUser("width-default-user");
23 +
24 + given()
25 + .when().get("/settings/profile")
26 + .then()
27 + .statusCode(200)
28 + .body(containsString("name=\"contentWidth\""))
29 + .body(containsString("value=\"FULL\" selected"))
30 + .body(containsString("value=\"COMFORTABLE\""))
31 + .body(containsString("value=\"COMPACT\""));
32 + }
33 +
34 + @Test
35 + @TestSecurity(user = "width-compact-user")
36 + void savedContentWidthAppliesOnEveryPage() throws Exception
37 + {
38 + persistUser("width-compact-user");
39 +
40 + given().redirects().follow(false)
41 + .contentType("application/x-www-form-urlencoded")
42 + .formParam("contentWidth", "COMPACT")
43 + .when().post("/settings/profile/appearance")
44 + .then()
45 + .statusCode(303);
46 +
47 + given()
48 + .when().get("/explore")
49 + .then()
50 + .statusCode(200)
51 + // the preset class sits on <body> so header nav and content scale together
52 + .body(containsString("<body class=\"width-compact\">"));
53 +
54 + given()
55 + .when().get("/settings/profile")
56 + .then()
57 + .statusCode(200)
58 + .body(containsString("value=\"COMPACT\" selected"));
59 + }
60 +
61 + @Test
62 + void anonymousVisitorsKeepTheFullWidthDefault() throws Exception
63 + {
64 + given()
65 + .when().get("/explore")
66 + .then()
67 + .statusCode(200)
68 + .body(containsString("<body class=\"\">"))
69 + .body(not(containsString("width-compact")));
70 + }
71 +
72 + @Test
73 + @TestSecurity(user = "width-bogus-user")
74 + void unknownPresetIsRejected() throws Exception
75 + {
76 + persistUser("width-bogus-user");
77 +
78 + given()
79 + .contentType("application/x-www-form-urlencoded")
80 + .formParam("contentWidth", "BOGUS")
81 + .when().post("/settings/profile/appearance")
82 + .then()
83 + .statusCode(400);
84 + }
85 +
86 + @Test
87 + void stylesheetDefinesTheWidthPresets() throws Exception
88 + {
89 + given()
90 + .when().get("/shark.css")
91 + .then()
92 + .statusCode(200)
93 + // presets live on <body> and drive one shared width variable for header and main;
94 + // fixed px caps so the column doesn't shift while resizing the window
95 + .body(containsString("body.width-comfortable"))
96 + .body(containsString("body.width-compact"))
97 + .body(containsString("--content-w"))
98 + .body(containsString("1400px"))
99 + .body(containsString("1120px"))
100 + // repo pages escape the default 1400px cap and use the full preset width
101 + .body(containsString("body:has(.repo-layout)"));
102 + }
103 +
104 + @Transactional
105 + User persistUser(String name)
106 + {
107 + User existing = userRepo.findByOidcSubOptional(name).orElse(null);
108 + if (existing != null)
109 + {
110 + return existing;
111 + }
112 + User user = new User();
113 + user.oidcSub = name;
114 + user.username = name;
115 + user.persist();
116 + return user;
117 + }
118 +
119 + @Inject
120 + User.Repo userRepo;
121 +
122 +}

Keyboard shortcuts

?Show this help
g hGo home
EscClose dialog