feat: add a compact job list view to the Jobs tab
Each job can now render as a single line — name on the left, status on the
right — instead of the three-line block, so many more jobs fit without
scrolling. A toggle button beside the Folder filter switches between the two
modes and is labelled with the action it performs, matching the existing
"Disable auto" convention.
The choice is persisted as Config.JobListView ("detailed" / "compact", stored
as job_list_view in gosentry.json). Empty, legacy, and unrecognised values all
normalize to detailed, so existing installs keep the current look and the file
never gains a value no reader understands.
Selection, the details panel, the folder filter, and live status updates work
unchanged in both modes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+67
-4
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user