diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 447b9c6..8b8bb9b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -203,4 +203,4 @@ size guideline: |------|----------| | `jobs_view.go` | `newJobsView` — list, toolbar, button wiring, and layout | | `jobs_view_details.go` | `detailsPanel` struct — widget creation, `update`, `clear`, `container` | -| `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs` | +| `jobs_view_helpers.go` | Pure helpers — `filteredJobIndexes`, `folderOptions`, `filterValue`, `indexOfID`, `lastJobLogs`, `nextJobListView`, `viewToggleText` | diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 48e38ef..63ad059 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,19 @@ All notable GoSentry changes are recorded in this file. ## Unreleased +**Compact job list view.** + +- The Jobs sidebar can now render each job as a single line — name on the left, + status on the right — instead of the three-line block. A toggle button beside + the Folder filter switches between **Compact** and **Detailed**; it is + labelled with the action it performs, like the "Disable auto" button. Compact + fits many more jobs on screen without scrolling; selection, the details panel, + the folder filter, and live status updates all work unchanged in both modes. +- The choice is persisted as a new `Config.JobListView` field + (`"detailed"` / `"compact"`, written to `gosentry.json` as `job_list_view`), + so it survives a restart. Empty/legacy configs and any unrecognised value + normalize to detailed, so existing installs keep the current look. + **Timeouts: 0 now means "no timeout" at both levels.** - The global **Default timeout** in Settings now defaults to `0`, meaning jobs diff --git a/src/app/operations.go b/src/app/operations.go index 2769f79..6e0eb56 100644 --- a/src/app/operations.go +++ b/src/app/operations.go @@ -193,6 +193,27 @@ func (s *Service) SetGlobalPause(paused bool) error { return nil } +// SetJobListView persists the Jobs list density preference. Unlike +// SetGlobalPause this touches nothing but the config: no job changed, so there +// is no SaveJobs, and no event is emitted — the choice is presentational and the +// Jobs view refreshes its own list, whereas an event would trigger a pointless +// whole-window refresh. Anything that is not "compact" is stored as detailed so +// the file never gains an unrecognised value. +func (s *Service) SetJobListView(view domain.JobListView) error { + if !view.IsCompact() { + view = domain.JobListViewDetailed + } + s.mu.Lock() + if s.store.Config.JobListView == view { + s.mu.Unlock() + return nil + } + s.store.Config.JobListView = view + err := s.store.SaveConfig() + s.mu.Unlock() + return err +} + // ShouldNotifyOnFailure reports whether the user has enabled desktop // notifications for failed job runs. It reads the config under mu so it is // safe to call from any goroutine. diff --git a/src/app/operations_test.go b/src/app/operations_test.go index b9f60bb..d45d1b4 100644 --- a/src/app/operations_test.go +++ b/src/app/operations_test.go @@ -590,6 +590,63 @@ func TestSetGlobalPausePersistsToConfigFile(t *testing.T) { } } +func TestSetJobListViewPersistsToConfigFile(t *testing.T) { + svc := newTempService(t, nil) + + readConfig := func(stage string) domain.Config { + t.Helper() + data, err := os.ReadFile(svc.store.Paths.ConfigPath) + if err != nil { + t.Fatalf("reading config file %s: %v", stage, err) + } + var cfg domain.Config + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("unmarshalling config %s: %v", stage, err) + } + return cfg + } + + if err := svc.SetJobListView(domain.JobListViewCompact); err != nil { + t.Fatalf("SetJobListView(compact): %v", err) + } + if got := readConfig("after compact").JobListView; got != domain.JobListViewCompact { + t.Errorf("persisted JobListView = %q, want %q", got, domain.JobListViewCompact) + } + + if err := svc.SetJobListView(domain.JobListViewDetailed); err != nil { + t.Fatalf("SetJobListView(detailed): %v", err) + } + if got := readConfig("after detailed").JobListView; got != domain.JobListViewDetailed { + t.Errorf("persisted JobListView = %q, want %q", got, domain.JobListViewDetailed) + } +} + +// TestSetJobListViewNormalizesUnknownValue guards the config file against +// gaining a value no reader understands: anything but "compact" is stored as +// "detailed". +func TestSetJobListViewNormalizesUnknownValue(t *testing.T) { + svc := newTempService(t, nil) + + if err := svc.SetJobListView(domain.JobListViewCompact); err != nil { + t.Fatalf("SetJobListView(compact): %v", err) + } + if err := svc.SetJobListView("tiny"); err != nil { + t.Fatalf("SetJobListView(tiny): %v", err) + } + + data, err := os.ReadFile(svc.store.Paths.ConfigPath) + if err != nil { + t.Fatalf("reading config file: %v", err) + } + var cfg domain.Config + if err := json.Unmarshal(data, &cfg); err != nil { + t.Fatalf("unmarshalling config: %v", err) + } + if cfg.JobListView != domain.JobListViewDetailed { + t.Errorf("persisted JobListView = %q, want %q", cfg.JobListView, domain.JobListViewDetailed) + } +} + func TestServiceRebuiltFromPausedStoreStartsPaused(t *testing.T) { jobs := []domain.Job{{ID: 1, Name: "A", Schedule: "@every 1m", Command: "echo", Enabled: true}} svc := newTempService(t, jobs) diff --git a/src/domain/config.go b/src/domain/config.go index 2e66bd2..f093c3f 100644 --- a/src/domain/config.go +++ b/src/domain/config.go @@ -28,6 +28,26 @@ const ( ThemeGoSentry Theme = "gosentry" ) +// JobListView selects how densely the Jobs tab renders its sidebar list. Like +// Theme it is a UI-only choice with no effect on scheduling; it lives in Config +// so the user's preference survives a restart. +type JobListView string + +const ( + // JobListViewDetailed is the three-line row: name, metadata, status. + JobListViewDetailed JobListView = "detailed" + // JobListViewCompact is the one-line row: name on the left, status on the + // right, so many more jobs fit without scrolling. + JobListViewCompact JobListView = "compact" +) + +// IsCompact reports whether the compact rendering is selected. Only the exact +// "compact" value counts, so empty, legacy, and unrecognised values all read as +// detailed — every consumer normalizes them the same way. +func (v JobListView) IsCompact() bool { + return v == JobListViewCompact +} + // OverlapPolicy decides what happens when a job's next run fires while the // previous run is still active. type OverlapPolicy string @@ -62,6 +82,10 @@ type Config struct { // Theme selects the visual appearance. Empty is treated as ThemeDefault so // configs written before this field existed keep the original look. Theme Theme `json:"theme,omitempty"` + // JobListView selects the Jobs list density. Empty is treated as + // JobListViewDetailed so configs written before this field existed keep the + // current three-line rows. + JobListView JobListView `json:"job_list_view,omitempty"` } // DefaultConfig returns the built-in default settings. It is the config used @@ -79,6 +103,7 @@ func DefaultConfig() Config { ExecutionMode: ExecutionModeParallel, OverlapPolicy: OverlapPolicySkip, Theme: ThemeDefault, + JobListView: JobListViewDetailed, DefaultTimeoutSeconds: 0, } } diff --git a/src/domain/config_test.go b/src/domain/config_test.go new file mode 100644 index 0000000..bf3ab90 --- /dev/null +++ b/src/domain/config_test.go @@ -0,0 +1,30 @@ +package domain + +import "testing" + +// TestJobListViewIsCompact pins the normalization rule: only the exact +// "compact" value selects the one-line rows, so empty and unrecognised values +// (including configs written before the field existed) keep the detailed look. +func TestJobListViewIsCompact(t *testing.T) { + cases := []struct { + view JobListView + want bool + }{ + {JobListViewCompact, true}, + {JobListViewDetailed, false}, + {"", false}, + {"Compact", false}, + {"tiny", false}, + } + for _, tc := range cases { + if got := tc.view.IsCompact(); got != tc.want { + t.Errorf("JobListView(%q).IsCompact() = %v, want %v", tc.view, got, tc.want) + } + } +} + +func TestDefaultConfigUsesDetailedJobList(t *testing.T) { + if got := DefaultConfig().JobListView; got != JobListViewDetailed { + t.Errorf("default JobListView = %q, want %q", got, JobListViewDetailed) + } +} diff --git a/src/storage/store_test.go b/src/storage/store_test.go index 3913766..ff1f568 100644 --- a/src/storage/store_test.go +++ b/src/storage/store_test.go @@ -174,6 +174,9 @@ func TestLoadOrCreateConfigCreatesDefaultsOnFirstRun(t *testing.T) { if got.Theme != domain.ThemeDefault { t.Errorf("default Theme = %q, want %q", got.Theme, domain.ThemeDefault) } + if got.JobListView != domain.JobListViewDetailed { + t.Errorf("default JobListView = %q, want %q", got.JobListView, domain.JobListViewDetailed) + } // The function must have written the defaults to gosentry.json. if _, err := os.Stat(paths.ConfigPath); err != nil { t.Errorf("gosentry.json should have been created: %v", err) diff --git a/src/ui/jobs_view.go b/src/ui/jobs_view.go index 7c689d8..97ef7be 100644 --- a/src/ui/jobs_view.go +++ b/src/ui/jobs_view.go @@ -70,6 +70,7 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { } selectedFolder := allFolders schedulerPaused := svc.Store().Config.Paused + listView := svc.Store().Config.JobListView filteredJobs := filteredJobIndexes(jobs, selectedFolder) dp := newDetailsPanel(job{}, &domain.JobRuntime{}, svc.Store().Config.OverlapPolicy, svc.Store().Config.DefaultTimeoutSeconds) @@ -105,17 +106,44 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { } } + // applyRowMode expresses the current view mode as visibility on the row's + // four labels. widget.List caches the row template's MinSize, and + // list.Refresh() re-creates the template and recomputes it, so hiding lines + // is what actually shrinks the rows: compactVBoxLayout and the border layout + // both skip hidden children when measuring. + applyRowMode := func(inlineStatus, meta, status fyne.CanvasObject) { + if listView.IsCompact() { + inlineStatus.Show() + meta.Hide() + status.Hide() + return + } + inlineStatus.Hide() + meta.Show() + status.Show() + } + list = widget.NewList( func() int { return len(filteredJobs) }, func() fyne.CanvasObject { name := widget.NewLabelWithStyle("Job name", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) + // Truncating stops a long name from pushing the compact row's status + // off the right-hand edge. + name.Wrapping = fyne.TextTruncate + inlineStatus := widget.NewLabel("status") meta := widget.NewLabel("schedule") status := widget.NewLabel("status") - return container.New(compactVBoxLayout{spacing: jobRowSpacing}, name, meta, status) + applyRowMode(inlineStatus, meta, status) + nameLine := container.NewBorder(nil, nil, nil, inlineStatus, name) + return container.New(compactVBoxLayout{spacing: jobRowSpacing}, nameLine, meta, status) }, func(id widget.ListItemID, item fyne.CanvasObject) { row := item.(*fyne.Container) - name := row.Objects[0].(*widget.Label) + // NewBorder keeps the center object first and appends the border slots + // after it, so nameLine is [name, inlineStatus]. + nameLine := row.Objects[0].(*fyne.Container) + name := nameLine.Objects[0].(*widget.Label) + inlineStatus := nameLine.Objects[1].(*widget.Label) meta := row.Objects[1].(*widget.Label) status := row.Objects[2].(*widget.Label) @@ -124,7 +152,12 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { // Keep each row compact: folder, schedule, and command are shown in one // metadata line so the left pane stays useful even with many jobs. meta.SetText(app.DisplayFolder(current.Folder) + " " + current.Schedule + " " + app.DisplayInvocation(current)) - status.SetText(app.StatusText(current, runtimes[current.ID])) + statusText := app.StatusText(current, runtimes[current.ID]) + status.SetText(statusText) + inlineStatus.SetText(statusText) + // A full Refresh reuses rows built under the previous mode, so + // visibility cannot be left to the create callback alone. + applyRowMode(inlineStatus, meta, status) }, ) list.OnSelected = func(id widget.ListItemID) { @@ -158,6 +191,32 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { }) folderSelect.SetSelected(selectedFolder) + // viewToggleIcon pairs with viewToggleText: both name the action the button + // performs, not the state it is in, matching stopAllButton's convention. + viewToggleIcon := func(current domain.JobListView) fyne.Resource { + if current.IsCompact() { + return theme.ViewFullScreenIcon() + } + return theme.ListIcon() + } + viewButton := widget.NewButtonWithIcon(viewToggleText(listView), viewToggleIcon(listView), nil) + viewButton.OnTapped = func() { + next := nextJobListView(listView) + listView = next + if err := svc.SetJobListView(next); err != nil { + // Roll the mode back and leave the button as it was, so the button + // never claims a preference that did not reach disk. + listView = nextJobListView(next) + dialog.ShowError(err, w) + return + } + viewButton.SetText(viewToggleText(listView)) + viewButton.SetIcon(viewToggleIcon(listView)) + // Refresh re-creates the row template, which is what recomputes the + // cached row height for the new mode. Selection is untouched. + list.Refresh() + } + addButton := widget.NewButtonWithIcon("New job", theme.ContentAddIcon(), func() { showJobDialog(w, "New job", job{Schedule: "@every 1m", Command: "echo GoSentry job ran", Enabled: true}, func(saved job) { created, err := svc.CreateJob(saved) @@ -294,7 +353,11 @@ func newJobsView(w fyne.Window, svc *app.Service) (fyne.CanvasObject, func()) { toolbar := container.NewHBox(addButton, editButton, runButton, pauseButton, deleteButton, layout.NewSpacer()) globalControls := container.NewHBox(stopAllButton, schedulerState, layout.NewSpacer()) - sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), folderSelect, toolbar) + // The view toggle sits beside the folder filter: the border layout gives it + // its MinSize on the right and lets the select fill the rest, so the header + // gains no height. + filterRow := container.NewBorder(nil, nil, nil, viewButton, folderSelect) + sidebarHeader := container.NewVBox(globalControls, widget.NewSeparator(), widget.NewLabelWithStyle("Folder", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), filterRow, toolbar) sidebar := container.NewBorder(sidebarHeader, nil, nil, nil, list) fixedSidebar := container.New(minWidthLayout{width: minJobsSidebarWidth}, sidebar) diff --git a/src/ui/jobs_view_helpers.go b/src/ui/jobs_view_helpers.go index 31007e6..6b75bb2 100644 --- a/src/ui/jobs_view_helpers.go +++ b/src/ui/jobs_view_helpers.go @@ -1,6 +1,10 @@ package ui -import "strings" +import ( + "strings" + + "gitea.mixdep.ru/mix/gosentry/src/domain" +) // lastJobLogs returns a fresh slice of the most recent activity entries for the // "Selected job activity" panel. Logs are stored newest-first (see @@ -47,6 +51,26 @@ func filterValue(folder string) string { return strings.TrimSpace(folder) } +// nextJobListView returns the mode the view toggle switches to. Anything that +// is not compact reads as detailed, so unknown and legacy values flip to +// compact just as an explicit "detailed" does. +func nextJobListView(current domain.JobListView) domain.JobListView { + if current.IsCompact() { + return domain.JobListViewDetailed + } + return domain.JobListViewCompact +} + +// viewToggleText labels the view toggle with the action it performs, not the +// current state — the same convention as the "Disable auto"/"Enable auto" +// button. It also keeps the on-disk strings away from the user. +func viewToggleText(current domain.JobListView) string { + if current.IsCompact() { + return "Detailed" + } + return "Compact" +} + func indexOfID(jobs []job, id int) int { for index, current := range jobs { if current.ID == id { diff --git a/src/ui/jobs_view_test.go b/src/ui/jobs_view_test.go index f99f7a0..cf89a4e 100644 --- a/src/ui/jobs_view_test.go +++ b/src/ui/jobs_view_test.go @@ -3,7 +3,12 @@ package ui import ( "testing" + "gitea.mixdep.ru/mix/gosentry/src/app" "gitea.mixdep.ru/mix/gosentry/src/domain" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/test" + "fyne.io/fyne/v2/widget" ) func TestFilterValue(t *testing.T) { @@ -93,3 +98,179 @@ func TestFilteredJobIndexesEmptySlice(t *testing.T) { t.Errorf("empty job list should return empty indexes, got %v", got) } } + +func TestNextJobListViewFlipsBothWays(t *testing.T) { + cases := []struct { + current, want domain.JobListView + }{ + {domain.JobListViewDetailed, domain.JobListViewCompact}, + {domain.JobListViewCompact, domain.JobListViewDetailed}, + // Empty and unknown values read as detailed, so they flip to compact. + {"", domain.JobListViewCompact}, + {"tiny", domain.JobListViewCompact}, + } + for _, tc := range cases { + if got := nextJobListView(tc.current); got != tc.want { + t.Errorf("nextJobListView(%q) = %q, want %q", tc.current, got, tc.want) + } + } +} + +// findFirst walks a widget tree depth-first and returns the first object the +// match function accepts. Tests use it to reach widgets newJobsView builds +// internally rather than returning. +func findFirst(root fyne.CanvasObject, match func(fyne.CanvasObject) bool) fyne.CanvasObject { + if match(root) { + return root + } + container, ok := root.(*fyne.Container) + if !ok { + return nil + } + for _, child := range container.Objects { + if found := findFirst(child, match); found != nil { + return found + } + } + return nil +} + +// jobsSidebar narrows the search to the left pane. The details panel has a +// widget.List of its own (the activity log), so a search from the whole view +// would find the wrong one. +func jobsSidebar(t *testing.T, content fyne.CanvasObject) fyne.CanvasObject { + t.Helper() + found := findFirst(content, func(o fyne.CanvasObject) bool { + wrapper, ok := o.(*fyne.Container) + if !ok { + return false + } + _, ok = wrapper.Layout.(minWidthLayout) + return ok + }) + if found == nil { + t.Fatal("jobs view has no fixed-width sidebar") + } + return found +} + +func jobsList(t *testing.T, content fyne.CanvasObject) *widget.List { + t.Helper() + found := findFirst(jobsSidebar(t, content), func(o fyne.CanvasObject) bool { + _, ok := o.(*widget.List) + return ok + }) + if found == nil { + t.Fatal("jobs sidebar contains no list widget") + } + return found.(*widget.List) +} + +func jobsViewToggle(t *testing.T, content fyne.CanvasObject) *widget.Button { + t.Helper() + found := findFirst(jobsSidebar(t, content), func(o fyne.CanvasObject) bool { + button, ok := o.(*widget.Button) + return ok && (button.Text == "Compact" || button.Text == "Detailed") + }) + if found == nil { + t.Fatal("jobs sidebar has no view toggle button") + } + return found.(*widget.Button) +} + +// TestJobListViewToggleShrinksRowsAndPersists is the end-to-end guard for the +// compact view: one tap must shrink the list rows, relabel the button with the +// opposite action, and reach the config — and toggling back must undo all +// three. Row height is measured through List.CreateItem/UpdateItem because +// that is exactly what widget.List caches as the row height. +func TestJobListViewToggleShrinksRowsAndPersists(t *testing.T) { + testApp := test.NewApp() + defer testApp.Quit() + w := testApp.NewWindow("test") + defer w.Close() + + store := newTestStore(t) + jobs := []domain.Job{ + {ID: 1, Name: "Nightly backup", Folder: "Maintenance", Schedule: "@every 1m", Command: "echo hi", Enabled: true}, + } + svc := app.NewService(store, jobs) + defer svc.Stop() + + content, _ := newJobsView(w, svc) + w.SetContent(content) + + list := jobsList(t, content) + viewButton := jobsViewToggle(t, content) + if viewButton.Text != "Compact" { + t.Fatalf("a default config should open detailed: button text = %q, want %q", viewButton.Text, "Compact") + } + + rowHeight := func() float32 { + t.Helper() + row := list.CreateItem() + list.UpdateItem(0, row) + return row.MinSize().Height + } + detailedHeight := rowHeight() + + test.Tap(viewButton) + if store.Config.JobListView != domain.JobListViewCompact { + t.Errorf("after tapping, Config.JobListView = %q, want %q", store.Config.JobListView, domain.JobListViewCompact) + } + if viewButton.Text != "Detailed" { + t.Errorf("after tapping, button text = %q, want %q", viewButton.Text, "Detailed") + } + compactHeight := rowHeight() + if compactHeight >= detailedHeight { + t.Errorf("compact row height = %v, want less than detailed %v", compactHeight, detailedHeight) + } + + test.Tap(viewButton) + if store.Config.JobListView != domain.JobListViewDetailed { + t.Errorf("after tapping back, Config.JobListView = %q, want %q", store.Config.JobListView, domain.JobListViewDetailed) + } + if viewButton.Text != "Compact" { + t.Errorf("after tapping back, button text = %q, want %q", viewButton.Text, "Compact") + } + if got := rowHeight(); got != detailedHeight { + t.Errorf("row height after switching back = %v, want the original %v", got, detailedHeight) + } +} + +// TestJobListViewCompactConfigOpensCompact checks the persisted preference is +// honoured at build time, not just after a tap. +func TestJobListViewCompactConfigOpensCompact(t *testing.T) { + testApp := test.NewApp() + defer testApp.Quit() + w := testApp.NewWindow("test") + defer w.Close() + + store := newTestStore(t) + store.Config.JobListView = domain.JobListViewCompact + svc := app.NewService(store, nil) + defer svc.Stop() + + content, _ := newJobsView(w, svc) + w.SetContent(content) + + if got := jobsViewToggle(t, content).Text; got != "Detailed" { + t.Errorf("button text for a compact config = %q, want %q", got, "Detailed") + } +} + +func TestViewToggleTextNamesTheAction(t *testing.T) { + cases := []struct { + current domain.JobListView + want string + }{ + {domain.JobListViewDetailed, "Compact"}, + {domain.JobListViewCompact, "Detailed"}, + {"", "Compact"}, + {"tiny", "Compact"}, + } + for _, tc := range cases { + if got := viewToggleText(tc.current); got != tc.want { + t.Errorf("viewToggleText(%q) = %q, want %q", tc.current, got, tc.want) + } + } +} diff --git a/src/ui/mainwindow_test.go b/src/ui/mainwindow_test.go index c6b6ccc..926dc87 100644 --- a/src/ui/mainwindow_test.go +++ b/src/ui/mainwindow_test.go @@ -11,10 +11,13 @@ import ( "fyne.io/fyne/v2/test" ) -func newTestService(t *testing.T) *app.Service { +// newTestStore builds a Store rooted in a temp directory. It is separate from +// newTestService so tests that need a non-default Config (or their own jobs) +// can adjust it before handing it to app.NewService. +func newTestStore(t *testing.T) *storage.Store { t.Helper() dir := t.TempDir() - store := &storage.Store{ + return &storage.Store{ Paths: storage.Paths{ ExecutablePath: filepath.Join(dir, "gosentry"), AppDir: dir, @@ -35,7 +38,11 @@ func newTestService(t *testing.T) *app.Service { NotifyOnFailure: true, }, } - return app.NewService(store, nil) +} + +func newTestService(t *testing.T) *app.Service { + t.Helper() + return app.NewService(newTestStore(t), nil) } func TestMainViewBuilds(t *testing.T) {