main 5041353

source.go RawHistoryFolder

1package noctalia
2
3import (
4	"bytes"
5	"context"
6	"encoding/json"
7	"errors"
8	"fmt"
9	"io"
10	"os"
11	"path/filepath"
12	"strings"
13	"sync"
14
15	"github.com/bnema/dumber/internal/application/port"
16	"github.com/bnema/dumber/internal/domain/entity"
17)
18
19const (
20	providerName     = "noctalia"
21	formatName       = formatColorsJSON
22	formatColorsJSON = "colors-json"
23	formatDumberJSON = "dumber-json"
24	colorsJSONName   = "Noctalia colors.json"
25	dumberJSONName   = "Noctalia Dumber JSON"
26)
27
28var _ port.ExternalThemeSource = (*FileSource)(nil)
29
30// FileSource reads a Noctalia external theme file.
31//
32// The default colors-json format reads Noctalia's native colors.json directly.
33// The dumber-json format remains available for explicit Dumber-specific user templates.
34type FileSource struct {
35	mu       sync.RWMutex
36	enabled  bool
37	path     string
38	format   string
39	identity string
40}
41
42// NewFileSource creates a Noctalia colors-json file source.
43func NewFileSource(enabled bool, path string) *FileSource {
44	source := &FileSource{}
45	source.Configure(entity.ExternalThemeConfig{
46		Enabled:  enabled,
47		Provider: providerName,
48		Format:   formatName,
49		Path:     path,
50	})
51	return source
52}
53
54// NewFileSourceFromConfig creates a file source from external theme config.
55func NewFileSourceFromConfig(cfg entity.ExternalThemeConfig) *FileSource {
56	source := &FileSource{}
57	source.Configure(cfg)
58	return source
59}
60
61// Configure updates the source from a normalized or raw external theme config snapshot.
62func (s *FileSource) Configure(cfg entity.ExternalThemeConfig) {
63	provider, format := normalizeProviderAndFormat(cfg.Provider, cfg.Format)
64	enabled := cfg.Enabled && provider == providerName && isSupportedFormat(format)
65	path := strings.TrimSpace(cfg.Path)
66	identityPath := path
67	if expandedPath, err := ExpandPath(path); err == nil {
68		identityPath = expandedPath
69	}
70
71	s.mu.Lock()
72	defer s.mu.Unlock()
73	s.enabled = enabled
74	s.path = path
75	s.format = format
76	s.identity = externalThemeIdentity(enabled, provider, format, identityPath)
77}
78
79// ExternalThemeIdentity returns the current normalized external theme identity.
80func (s *FileSource) ExternalThemeIdentity() string {
81	s.mu.RLock()
82	defer s.mu.RUnlock()
83	return s.identity
84}
85
86// IsEnabled returns whether this source should be used.
87func (s *FileSource) IsEnabled() bool {
88	s.mu.RLock()
89	defer s.mu.RUnlock()
90	return s.enabled
91}
92
93// Get reads and parses the configured Noctalia theme file.
94func (s *FileSource) Get(ctx context.Context) (*entity.ExternalTheme, error) {
95	s.mu.RLock()
96	enabled := s.enabled
97	pathConfig := s.path
98	format := s.format
99	s.mu.RUnlock()
100
101	if !enabled {
102		return nil, nil
103	}
104	if err := ctx.Err(); err != nil {
105		return nil, err
106	}
107
108	path, err := ExpandPath(pathConfig)
109	if err != nil {
110		return nil, err
111	}
112	if path == "" {
113		return nil, errors.New("noctalia theme path is empty")
114	}
115
116	data, err := os.ReadFile(path)
117	if err != nil {
118		return nil, fmt.Errorf("read noctalia theme file: %w", err)
119	}
120	if ctxErr := ctx.Err(); ctxErr != nil {
121		return nil, ctxErr
122	}
123
124	switch format {
125	case formatColorsJSON:
126		return ParseColorsJSON(data)
127	case formatDumberJSON:
128		return ParseDumberJSON(data)
129	default:
130		return nil, fmt.Errorf("unsupported noctalia theme format %q", format)
131	}
132}
133
134// ExpandPath expands environment variables, leading ~, and cleans the path.
135func ExpandPath(path string) (string, error) {
136	expanded := os.ExpandEnv(strings.TrimSpace(path))
137	if expanded == "" {
138		return "", nil
139	}
140	if expanded == "~" || strings.HasPrefix(expanded, "~/") {
141		home, err := os.UserHomeDir()
142		if err != nil {
143			return "", fmt.Errorf("expand home directory: %w", err)
144		}
145		if expanded == "~" {
146			expanded = home
147		} else {
148			expanded = filepath.Join(home, expanded[2:])
149		}
150	}
151	return filepath.Clean(expanded), nil
152}
153
154// ParseColorsJSON parses Noctalia's native colors.json file.
155//
156// Noctalia colors.json represents the active palette only, so Dumber applies the
157// mapped palette to both light and dark resolved palettes. The mapper uses
158// optional mShadow/mHover roles when present to preserve background/surface/input
159// contrast, while falling back to older required roles for compatibility.
160func ParseColorsJSON(data []byte) (*entity.ExternalTheme, error) {
161	var raw colorsThemeJSON
162	decoder := json.NewDecoder(bytes.NewReader(data))
163	if err := decoder.Decode(&raw); err != nil {
164		return nil, fmt.Errorf("parse noctalia colors json: %w", err)
165	}
166	if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
167		return nil, errors.New("parse noctalia colors json: trailing data")
168	}
169
170	palette, err := raw.toPalette()
171	if err != nil {
172		return nil, err
173	}
174	light := palette
175	dark := palette
176	return &entity.ExternalTheme{
177		Name:         colorsJSONName,
178		Provider:     providerName,
179		LightPalette: &light,
180		DarkPalette:  &dark,
181	}, nil
182}
183
184// ParseDumberJSON parses Noctalia user-template-generated Dumber JSON.
185func ParseDumberJSON(data []byte) (*entity.ExternalTheme, error) {
186	var raw dumberThemeJSON
187	decoder := json.NewDecoder(bytes.NewReader(data))
188	if err := decoder.Decode(&raw); err != nil {
189		return nil, fmt.Errorf("parse noctalia dumber json: %w", err)
190	}
191	if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
192		return nil, errors.New("parse noctalia dumber json: trailing data")
193	}
194	if raw.Light == nil {
195		return nil, errors.New("parse noctalia dumber json: missing light palette")
196	}
197	if raw.Dark == nil {
198		return nil, errors.New("parse noctalia dumber json: missing dark palette")
199	}
200
201	light := raw.Light.toEntity()
202	dark := raw.Dark.toEntity()
203	if err := validatePalette(&light, "light"); err != nil {
204		return nil, err
205	}
206	if err := validatePalette(&dark, "dark"); err != nil {
207		return nil, err
208	}
209
210	return &entity.ExternalTheme{
211		Name:         firstNonEmpty(raw.Name, raw.Source, dumberJSONName),
212		Provider:     providerName,
213		LightPalette: &light,
214		DarkPalette:  &dark,
215	}, nil
216}
217
218type colorsThemeJSON struct {
219	MPrimary          *string `json:"mPrimary"`
220	MSurface          *string `json:"mSurface"`
221	MOnSurface        *string `json:"mOnSurface"`
222	MSurfaceVariant   *string `json:"mSurfaceVariant"`
223	MOnSurfaceVariant *string `json:"mOnSurfaceVariant"`
224	MOutline          *string `json:"mOutline"`
225	MShadow           *string `json:"mShadow"`
226	MHover            *string `json:"mHover"`
227}
228
229func (raw colorsThemeJSON) toPalette() (entity.ColorPalette, error) {
230	surface, err := requiredColorsHex("mSurface", raw.MSurface)
231	if err != nil {
232		return entity.ColorPalette{}, err
233	}
234	surfaceVariantBase, err := requiredColorsHex("mSurfaceVariant", raw.MSurfaceVariant)
235	if err != nil {
236		return entity.ColorPalette{}, err
237	}
238	text, err := requiredColorsHex("mOnSurface", raw.MOnSurface)
239	if err != nil {
240		return entity.ColorPalette{}, err
241	}
242	muted, err := requiredColorsHex("mOnSurfaceVariant", raw.MOnSurfaceVariant)
243	if err != nil {
244		return entity.ColorPalette{}, err
245	}
246	accent, err := requiredColorsHex("mPrimary", raw.MPrimary)
247	if err != nil {
248		return entity.ColorPalette{}, err
249	}
250	border, err := requiredColorsHex("mOutline", raw.MOutline)
251	if err != nil {
252		return entity.ColorPalette{}, err
253	}
254	background, err := optionalColorsHex("mShadow", raw.MShadow, surface)
255	if err != nil {
256		return entity.ColorPalette{}, err
257	}
258	surfaceVariant, err := optionalColorsHex("mHover", raw.MHover, surfaceVariantBase)
259	if err != nil {
260		return entity.ColorPalette{}, err
261	}
262
263	return entity.ColorPalette{
264		Background:     background,
265		Surface:        surface,
266		SurfaceVariant: surfaceVariant,
267		Text:           text,
268		Muted:          muted,
269		Accent:         accent,
270		Border:         border,
271	}, nil
272}
273
274func requiredColorsHex(field string, value *string) (string, error) {
275	if value == nil || *value == "" {
276		return "", fmt.Errorf("parse noctalia colors json: missing %s", field)
277	}
278	return validateColorsHex(field, *value)
279}
280
281func optionalColorsHex(field string, value *string, fallback string) (string, error) {
282	if value == nil {
283		return fallback, nil
284	}
285	return validateColorsHex(field, *value)
286}
287
288func validateColorsHex(field, value string) (string, error) {
289	if !entity.IsValidHex(value) {
290		return "", fmt.Errorf("parse noctalia colors json: %s: not a valid CSS-safe hex color (#RRGGBB)", field)
291	}
292	return value, nil
293}
294
295type dumberThemeJSON struct {
296	Source string       `json:"source"`
297	Mode   string       `json:"mode"`
298	Name   string       `json:"name"`
299	Light  *paletteJSON `json:"light"`
300	Dark   *paletteJSON `json:"dark"`
301}
302
303type paletteJSON struct {
304	Background     string `json:"background"`
305	Surface        string `json:"surface"`
306	SurfaceVariant string `json:"surface_variant"`
307	Text           string `json:"text"`
308	Muted          string `json:"muted"`
309	Accent         string `json:"accent"`
310	Border         string `json:"border"`
311}
312
313func (p paletteJSON) toEntity() entity.ColorPalette {
314	return entity.ColorPalette{
315		Background:     p.Background,
316		Surface:        p.Surface,
317		SurfaceVariant: p.SurfaceVariant,
318		Text:           p.Text,
319		Muted:          p.Muted,
320		Accent:         p.Accent,
321		Border:         p.Border,
322	}
323}
324
325func validatePalette(p *entity.ColorPalette, prefix string) error {
326	warnings := entity.ValidatePaletteOverrideHex(p, prefix)
327	if len(warnings) == 0 {
328		return nil
329	}
330	return fmt.Errorf("parse noctalia dumber json: %s: %s", warnings[0].Field, warnings[0].Message)
331}
332
333func firstNonEmpty(values ...string) string {
334	for _, value := range values {
335		if strings.TrimSpace(value) != "" {
336			return value
337		}
338	}
339	return ""
340}
341
342func normalizeProviderAndFormat(providerInput, formatInput string) (string, string) {
343	provider := strings.ToLower(strings.TrimSpace(providerInput))
344	if provider == "" {
345		provider = providerName
346	}
347	format := strings.ToLower(strings.TrimSpace(formatInput))
348	if format == "" {
349		format = formatName
350	}
351	return provider, format
352}
353
354func isSupportedFormat(format string) bool {
355	switch format {
356	case formatColorsJSON, formatDumberJSON:
357		return true
358	default:
359		return false
360	}
361}
362
363func externalThemeIdentity(enabled bool, provider, format, path string) string {
364	if !enabled {
365		return ""
366	}
367	return provider + "\x00" + format + "\x00" + strings.TrimSpace(path)
368}

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