main 5041353

theme.go RawHistoryFolder

1package systemviews
2
3import (
4	"fmt"
5	"strings"
6	"unicode"
7
8	"github.com/bnema/dumber/internal/application/dto"
9)
10
11type shellTheme struct {
12	RootClass  string
13	InlineVars string
14}
15
16const (
17	shellDarkClass  = "sv-dark"
18	shellLightClass = "sv-light"
19)
20
21var cssNamedColors = func() map[string]struct{} {
22	//nolint:lll // CSS named color list is clearer as a single literal.
23	names := strings.Fields(`aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen`)
24	colors := make(map[string]struct{}, len(names))
25	for _, name := range names {
26		colors[name] = struct{}{}
27	}
28	return colors
29}()
30
31func currentPrefersDark() bool {
32	return currentPrefersDarkImpl()
33}
34
35func resolveShellTheme(appearance dto.WebUIAppearanceConfig) shellTheme {
36	if isZeroAppearance(appearance) {
37		return shellTheme{}
38	}
39
40	var palette dto.ColorPalette
41	var rootClass string
42
43	switch strings.ToLower(strings.TrimSpace(appearance.ColorScheme)) {
44	case "prefer-dark":
45		palette = appearance.DarkPalette
46		rootClass = shellDarkClass
47	case "prefer-light":
48		palette = appearance.LightPalette
49		rootClass = shellLightClass
50	default:
51		if currentPrefersDark() {
52			palette = appearance.DarkPalette
53			rootClass = shellDarkClass
54		} else {
55			palette = appearance.LightPalette
56			rootClass = shellLightClass
57		}
58	}
59
60	return shellTheme{
61		RootClass:  rootClass,
62		InlineVars: buildInlineVars(appearance, palette),
63	}
64}
65
66func buildInlineVars(appearance dto.WebUIAppearanceConfig, palette dto.ColorPalette) string {
67	parts := make([]string, 0, 11)
68	appendInlineVar := func(name, value string, sanitize func(string) (string, bool)) {
69		value, ok := sanitize(value)
70		if !ok {
71			return
72		}
73		parts = append(parts, fmt.Sprintf("%s: %s;", name, value))
74	}
75
76	appendInlineVar("--sv-background", palette.Background, sanitizeCSSColor)
77	appendInlineVar("--sv-surface", palette.Surface, sanitizeCSSColor)
78	appendInlineVar("--sv-surface-variant", palette.SurfaceVariant, sanitizeCSSColor)
79	appendInlineVar("--sv-text", palette.Text, sanitizeCSSColor)
80	appendInlineVar("--sv-muted", palette.Muted, sanitizeCSSColor)
81	appendInlineVar("--sv-accent", palette.Accent, sanitizeCSSColor)
82	appendInlineVar("--sv-border", palette.Border, sanitizeCSSColor)
83	appendInlineVar("--sv-font-sans", appearance.SansFont, sanitizeCSSFontFamily)
84	appendInlineVar("--sv-font-serif", appearance.SerifFont, sanitizeCSSFontFamily)
85	appendInlineVar("--sv-font-mono", appearance.MonospaceFont, sanitizeCSSFontFamily)
86	if appearance.DefaultFontSize > 0 {
87		parts = append(parts, fmt.Sprintf("--sv-font-size: %dpx;", appearance.DefaultFontSize))
88	}
89
90	return strings.Join(parts, " ")
91}
92
93func sanitizeCSSColor(value string) (string, bool) {
94	value = strings.TrimSpace(value)
95	if value == "" || hasCSSBreakout(value) {
96		return "", false
97	}
98	lower := strings.ToLower(value)
99	if isHexColor(lower) || isFunctionalColor(lower) || isNamedColor(lower) {
100		return value, true
101	}
102	return "", false
103}
104
105func sanitizeCSSFontFamily(value string) (string, bool) {
106	value = strings.TrimSpace(value)
107	if value == "" || hasCSSBreakout(value) {
108		return "", false
109	}
110
111	validated := value
112	if quote := value[0]; quote == '\'' || quote == '"' {
113		if len(value) < 2 || value[len(value)-1] != quote {
114			return "", false
115		}
116		validated = value[1 : len(value)-1]
117		if validated == "" || strings.ContainsAny(validated, "'\"") {
118			return "", false
119		}
120	} else if strings.ContainsAny(value, "'\"") {
121		return "", false
122	}
123
124	for _, r := range validated {
125		switch {
126		case unicode.IsLetter(r), unicode.IsMark(r), unicode.IsDigit(r):
127		case strings.ContainsRune(" ,._-", r):
128		default:
129			return "", false
130		}
131	}
132	return value, true
133}
134
135func hasCSSBreakout(value string) bool {
136	return strings.ContainsAny(value, ";{}\n\r") || strings.Contains(value, "/*") || strings.Contains(value, "*/")
137}
138
139func isHexColor(value string) bool {
140	if len(value) != 4 && len(value) != 5 && len(value) != 7 && len(value) != 9 {
141		return false
142	}
143	if value[0] != '#' {
144		return false
145	}
146	for _, r := range value[1:] {
147		if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') {
148			return false
149		}
150	}
151	return true
152}
153
154func isFunctionalColor(value string) bool {
155	prefixes := []string{"rgb(", "rgba(", "hsl(", "hsla(", "oklch(", "oklab(", "color(", "lab(", "lch(", "hwb("}
156	matched := false
157	for _, prefix := range prefixes {
158		if strings.HasPrefix(value, prefix) {
159			matched = true
160			break
161		}
162	}
163	if !matched || !strings.HasSuffix(value, ")") {
164		return false
165	}
166	for _, r := range strings.TrimSuffix(value[strings.IndexByte(value, '(')+1:], ")") {
167		if (r < '0' || r > '9') && (r < 'a' || r > 'z') && !strings.ContainsRune(" .,%/-", r) {
168			return false
169		}
170	}
171	return true
172}
173
174func isNamedColor(value string) bool {
175	if value == "transparent" || value == "currentcolor" {
176		return true
177	}
178	_, ok := cssNamedColors[value]
179	return ok
180}
181
182func isZeroAppearance(appearance dto.WebUIAppearanceConfig) bool {
183	return appearance.ColorScheme == "" &&
184		appearance.SansFont == "" &&
185		appearance.SerifFont == "" &&
186		appearance.MonospaceFont == "" &&
187		appearance.GtkFont == "" &&
188		appearance.DefaultFontSize == 0 &&
189		appearance.LightPalette == (dto.ColorPalette{}) &&
190		appearance.DarkPalette == (dto.ColorPalette{})
191}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close