Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion domain/gitstatus.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,5 @@ type GitChangeStatus uint8

const (
GitUnchanged GitChangeStatus = iota
GitAdded // present in working copy, absent in HEAD
GitModified // present in both but value differs
)
17 changes: 17 additions & 0 deletions domain/window.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package domain

// WindowGeometry is the persisted main-window geometry, restored on the next
// launch.
//
// Sizes are stored in density-independent pixels (Dp), not device pixels, so
// they round-trip correctly across monitors with different DPI scaling — a
// window sized on a 2x display reopens at the same logical size on a 1x one.
//
// Position is intentionally absent: Gio's cross-platform window Config exposes
// only the window's size and mode, not its on-screen location, so location
// cannot be captured or restored through the portable API.
type WindowGeometry struct {
WidthDp int `json:"widthDp"`
HeightDp int `json:"heightDp"`
Maximized bool `json:"maximized"`
}
1 change: 1 addition & 0 deletions infrastructure/storage/json_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type AppData struct {
RecentValuesEntries []domain.RecentValuesEntry `json:"recentValuesEntries,omitempty"`
ShowDocs *bool `json:"showDocs,omitempty"`
ChartUIStates map[string]domain.ChartUIState `json:"chartUiStates,omitempty"`
WindowGeometry *domain.WindowGeometry `json:"windowGeometry,omitempty"`
}

// JSONStore reads and writes AppData to a JSON file.
Expand Down
64 changes: 61 additions & 3 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"log"
"log/slog"
"os"
Expand All @@ -14,11 +15,17 @@ import (
"github.com/qdeck-app/qdeck/infrastructure/storage"
"github.com/qdeck-app/qdeck/service"
"github.com/qdeck-app/qdeck/ui"
"github.com/qdeck-app/qdeck/ui/platform/screen"
)

const (
defaultWindowWidth = 1200
defaultWindowHeight = 800

// preferredWindowWidth is used on a first launch
preferredWindowWidth = 1600
// windowScreenMargin is the gap left between the default window and the work-area edges
windowScreenMargin = 80
)

func main() {
Expand All @@ -35,12 +42,12 @@ func main() {
repoSvc := service.NewRepoService(settings)
chartSvc := service.NewChartService(settings)
valuesSvc := service.NewValuesService()
recentSvc := service.NewRecentService(jsonStore)
appState := service.NewAppStateService(jsonStore)
templateSvc := service.NewTemplateService()

w := new(app.Window)
w.Option(app.Title("QDeck - Helm Values Editor"))
w.Option(app.Size(unit.Dp(defaultWindowWidth), unit.Dp(defaultWindowHeight)))
w.Option(windowGeometryOptions(appState)...)

// On Linux and Windows, disable compositor decorations and draw our own
// window control buttons in the breadcrumb bar.
Expand All @@ -49,7 +56,7 @@ func main() {
w.Option(app.Decorated(false))
}

application := ui.NewApplication(w, repoSvc, chartSvc, valuesSvc, recentSvc, templateSvc, customDecor)
application := ui.NewApplication(w, repoSvc, chartSvc, valuesSvc, appState, templateSvc, customDecor)
if err := application.Run(); err != nil {
log.Fatal(err)
}
Expand All @@ -59,3 +66,54 @@ func main() {

app.Main()
}

// windowGeometryOptions restores the last persisted window size and maximized
// state, falling back to the default size when nothing has been saved yet (or
// the saved record is unreadable). Size is stored in Dp, so app.Size rescales
// it correctly for the current monitor's DPI.
func windowGeometryOptions(appState *service.AppStateService) []app.Option {
geom, err := appState.LoadWindowGeometry(context.Background())
if err != nil || geom == nil || geom.WidthDp <= 0 || geom.HeightDp <= 0 {
w, h := defaultWindowSize()

return []app.Option{app.Size(w, h)}
}

opts := []app.Option{app.Size(unit.Dp(geom.WidthDp), unit.Dp(geom.HeightDp))}
if geom.Maximized {
opts = append(opts, app.Maximized.Option())
}

return opts
}

// defaultWindowSize is the size used on a first launch (no saved geometry).
func defaultWindowSize() (unit.Dp, unit.Dp) {
areaW, areaH, ok := screen.WorkAreaDp()

return fitWindowSize(areaW, areaH, ok)
}

// fitWindowSize picks the first-launch window size from the primary work area
// (in Dp). Width grows toward preferredWindowWidth when the display allows it
// and shrinks below the base default on a narrow screen, so the window never
// opens wider or taller than the work area (less a margin). When the work area
// is unknown (ok == false), the fixed base default is used unchanged.
func fitWindowSize(areaW, areaH int, ok bool) (unit.Dp, unit.Dp) {
w := unit.Dp(defaultWindowWidth)
h := unit.Dp(defaultWindowHeight)

if !ok {
return w, h
}

if fit := unit.Dp(areaW) - windowScreenMargin; fit > 0 {
w = min(unit.Dp(preferredWindowWidth), fit)
}

if fit := unit.Dp(areaH) - windowScreenMargin; fit > 0 {
h = min(unit.Dp(defaultWindowHeight), fit)
}

return w, h
}
60 changes: 43 additions & 17 deletions service/recent_service.go → service/app_state_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,23 @@ const (
maxRecentValues = 10
)

// RecentService manages recent charts and values files.
// AppStateService is the persistence facade over the application's on-disk
// AppData blob (JSONStore): recent charts and values, per-chart UI state, and
// user preferences such as "show docs" and the window geometry.
// All mutating methods use JSONStore.Update for atomic read-modify-write,
// eliminating the need for a service-level mutex.
type RecentService struct {
type AppStateService struct {
store *storage.JSONStore
}

func NewRecentService(store *storage.JSONStore) *RecentService {
return &RecentService{store: store}
func NewAppStateService(store *storage.JSONStore) *AppStateService {
return &AppStateService{store: store}
}

// ListRecentCharts returns all recent charts sorted by most recently opened.
//
//nolint:dupl // Structurally similar to ListRecentValues but operates on different slice.
func (s *RecentService) ListRecentCharts(ctx context.Context) ([]domain.RecentChart, error) {
func (s *AppStateService) ListRecentCharts(ctx context.Context) ([]domain.RecentChart, error) {
if ctx.Err() != nil {
return nil, fmt.Errorf("list recent charts: %w", ctx.Err())
}
Expand All @@ -45,7 +47,7 @@ func (s *RecentService) ListRecentCharts(ctx context.Context) ([]domain.RecentCh
}

// AddRecentChart adds a chart to the recent list, deduplicating and enforcing max limit.
func (s *RecentService) AddRecentChart(ctx context.Context, entry domain.RecentChart) error {
func (s *AppStateService) AddRecentChart(ctx context.Context, entry domain.RecentChart) error {
if err := entry.IsValid(); err != nil {
return fmt.Errorf("add recent chart: %w", err)
}
Expand All @@ -69,7 +71,7 @@ func (s *RecentService) AddRecentChart(ctx context.Context, entry domain.RecentC
// RemoveRecentChart removes a chart at the given index.
//
//nolint:dupl // same shape as RemoveRecentValues{,Entry} but a different AppData slice
func (s *RecentService) RemoveRecentChart(ctx context.Context, idx int) error {
func (s *AppStateService) RemoveRecentChart(ctx context.Context, idx int) error {
return removeAt(ctx, s.store, "remove recent chart", idx,
func(data *storage.AppData) *[]domain.RecentChart { return &data.RecentCharts },
)
Expand All @@ -78,7 +80,7 @@ func (s *RecentService) RemoveRecentChart(ctx context.Context, idx int) error {
// ListRecentValues returns all recent values files sorted by most recently opened.
//
//nolint:dupl // Structurally similar to ListRecentCharts but operates on different slice.
func (s *RecentService) ListRecentValues(ctx context.Context) ([]domain.RecentValuesFile, error) {
func (s *AppStateService) ListRecentValues(ctx context.Context) ([]domain.RecentValuesFile, error) {
if ctx.Err() != nil {
return nil, fmt.Errorf("list recent values: %w", ctx.Err())
}
Expand All @@ -92,7 +94,7 @@ func (s *RecentService) ListRecentValues(ctx context.Context) ([]domain.RecentVa
}

// AddRecentValues adds a values file path to the recent list.
func (s *RecentService) AddRecentValues(ctx context.Context, path string) error {
func (s *AppStateService) AddRecentValues(ctx context.Context, path string) error {
if path == "" {
return errors.New("add recent values: path required")
}
Expand All @@ -109,15 +111,15 @@ func (s *RecentService) AddRecentValues(ctx context.Context, path string) error
// RemoveRecentValues removes a values file at the given index.
//
//nolint:dupl // same shape as RemoveRecentChart / RemoveRecentValuesEntry but a different AppData slice
func (s *RecentService) RemoveRecentValues(ctx context.Context, idx int) error {
func (s *AppStateService) RemoveRecentValues(ctx context.Context, idx int) error {
return removeAt(ctx, s.store, "remove recent values", idx,
func(data *storage.AppData) *[]domain.RecentValuesFile { return &data.RecentValues },
)
}

// LoadShowDocs returns the persisted "show docs" preference.
// Returns false when the preference has never been saved.
func (s *RecentService) LoadShowDocs(ctx context.Context) (bool, error) {
func (s *AppStateService) LoadShowDocs(ctx context.Context) (bool, error) {
data, err := s.store.Load(ctx)
if err != nil {
return false, fmt.Errorf("load show docs: %w", err)
Expand All @@ -131,7 +133,7 @@ func (s *RecentService) LoadShowDocs(ctx context.Context) (bool, error) {
}

// SaveShowDocs persists the "show docs" preference.
func (s *RecentService) SaveShowDocs(ctx context.Context, show bool) error {
func (s *AppStateService) SaveShowDocs(ctx context.Context, show bool) error {
if err := s.store.Update(ctx, func(data *storage.AppData) error {
data.ShowDocs = &show

Expand All @@ -143,9 +145,33 @@ func (s *RecentService) SaveShowDocs(ctx context.Context, show bool) error {
return nil
}

// LoadWindowGeometry returns the persisted main-window geometry, or nil when
// none has been saved yet (the caller falls back to default size/mode).
func (s *AppStateService) LoadWindowGeometry(ctx context.Context) (*domain.WindowGeometry, error) {
data, err := s.store.Load(ctx)
if err != nil {
return nil, fmt.Errorf("load window geometry: %w", err)
}

return data.WindowGeometry, nil
}

// SaveWindowGeometry persists the main-window geometry restored on next launch.
func (s *AppStateService) SaveWindowGeometry(ctx context.Context, geom domain.WindowGeometry) error {
if err := s.store.Update(ctx, func(data *storage.AppData) error {
data.WindowGeometry = &geom

return nil
}); err != nil {
return fmt.Errorf("save window geometry: %w", err)
}

return nil
}

// LoadChartUIState returns the persisted UI state for the given chart key.
// The bool is false when no state has been saved for the key yet.
func (s *RecentService) LoadChartUIState(ctx context.Context, key string) (domain.ChartUIState, bool, error) {
func (s *AppStateService) LoadChartUIState(ctx context.Context, key string) (domain.ChartUIState, bool, error) {
if key == "" {
return domain.ChartUIState{}, false, nil
}
Expand All @@ -170,7 +196,7 @@ const maxChartUIStates = 100
// maxChartUIStates, the entry with the oldest LastTouchedAt is evicted —
// approximate LRU, so the most-recently-used charts survive. One eviction per
// Save call is sufficient because the cap can only be exceeded by one entry.
func (s *RecentService) SaveChartUIState(ctx context.Context, key string, st domain.ChartUIState) error {
func (s *AppStateService) SaveChartUIState(ctx context.Context, key string, st domain.ChartUIState) error {
if key == "" {
return nil
}
Expand Down Expand Up @@ -221,7 +247,7 @@ const maxRecentValuesEntries = 10
// ListRecentValuesEntries returns all recent values+chart pairs.
//
//nolint:dupl // Structurally similar to ListRecentCharts but operates on different slice.
func (s *RecentService) ListRecentValuesEntries(ctx context.Context) ([]domain.RecentValuesEntry, error) {
func (s *AppStateService) ListRecentValuesEntries(ctx context.Context) ([]domain.RecentValuesEntry, error) {
if ctx.Err() != nil {
return nil, fmt.Errorf("list recent values entries: %w", ctx.Err())
}
Expand All @@ -235,7 +261,7 @@ func (s *RecentService) ListRecentValuesEntries(ctx context.Context) ([]domain.R
}

// AddRecentValuesEntry adds a values+chart pair, deduplicating and enforcing max limit.
func (s *RecentService) AddRecentValuesEntry(ctx context.Context, entry domain.RecentValuesEntry) error {
func (s *AppStateService) AddRecentValuesEntry(ctx context.Context, entry domain.RecentValuesEntry) error {
if entry.ValuesPath == "" {
return errors.New("add recent values entry: valuesPath required")
}
Expand All @@ -255,7 +281,7 @@ func (s *RecentService) AddRecentValuesEntry(ctx context.Context, entry domain.R
// RemoveRecentValuesEntry removes a values entry at the given index.
//
//nolint:dupl // same shape as RemoveRecentChart / RemoveRecentValues but a different AppData slice
func (s *RecentService) RemoveRecentValuesEntry(ctx context.Context, idx int) error {
func (s *AppStateService) RemoveRecentValuesEntry(ctx context.Context, idx int) error {
return removeAt(ctx, s.store, "remove recent values entry", idx,
func(data *storage.AppData) *[]domain.RecentValuesEntry { return &data.RecentValuesEntries },
)
Expand Down
5 changes: 1 addition & 4 deletions service/values_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,6 @@ func (s *ValuesService) SaveRawBytes(ctx context.Context, raw []byte, destPath s
}

// CompareWithBaseline compares a current values file against baseline content (e.g. from git HEAD).
// Returns a map of flat keys to their change status. Only added/modified keys are included.
func (s *ValuesService) CompareWithBaseline(
ctx context.Context, currentFilePath string, baselineContent []byte,
) (map[string]domain.GitChangeStatus, error) {
Expand Down Expand Up @@ -494,9 +493,7 @@ func (s *ValuesService) CompareWithBaseline(
key := string(e.Key)

baseVal, exists := baseLookup[key]
if !exists {
changes[key] = domain.GitAdded
} else if baseVal != e.Value {
if exists && baseVal != e.Value {
changes[key] = domain.GitModified
}
}
Expand Down
Loading
Loading