Files
gosentry/src/ui/history_view.go
T
mixeme fb2f9b6e30 fix(ui): restore History column sort after Fyne 2.7 header-tap change
Fyne 2.7.4 made Table.Select() reject cells with Row < 0, so OnSelected
was never fired for header clicks and the Time sort toggle stopped working.
Replace the plain Label header with a custom historyHeader widget that
implements fyne.Tappable; UpdateHeader wires OnTapped for col 0 directly.
Also swap "asc"/"desc" text for ▲/▼ glyphs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 22:24:59 +03:00

224 lines
5.6 KiB
Go

package ui
import (
"sort"
"strings"
"time"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
func newEvent(jobID int, jobName string, state string, detail string) event {
// Use the same timestamp shape as command run records so the History tab is
// visually consistent across startup, UI actions, manual runs, and schedules.
return event{
Time: time.Now().Format("2006-01-02 15:04:05"),
JobID: jobID,
JobName: jobName,
Trigger: "UI",
State: state,
Detail: detail,
}
}
func collectActivity(jobs []job, runtimes map[int]*domain.JobRuntime) []event {
var events []event
for _, current := range jobs {
// At startup this is usually empty because jobs.yaml does not persist
// runtime logs. The function still centralizes the merge for future
// history loading from log metadata.
if rt := runtimes[current.ID]; rt != nil {
events = append(events, rt.Logs...)
}
}
sort.SliceStable(events, func(left int, right int) bool {
return events[left].Time < events[right].Time
})
return events
}
// logColumnMinWidth/logColumnMaxWidth bound the dynamically sized Log column.
// The minimum keeps the column readable when names are short or absent; the
// maximum stops a single very long file name from dominating the table (the
// table still scrolls horizontally past it).
const (
logColumnMinWidth = 240
logColumnMaxWidth = 520
logColumnPadding = 24
)
// logColumnWidth measures the widest Log cell value so the column can be sized
// to fit its content. Fyne tables do not auto-size columns, so without this the
// fixed width clips file names like "20260601-100000_SomeJobName.log".
func logColumnWidth(events []event) float32 {
width := float32(logColumnMinWidth)
for _, current := range events {
text := logFileName(current.LogFile)
if text == "" {
continue
}
w := fyne.MeasureText(text, theme.TextSize(), fyne.TextStyle{}).Width + logColumnPadding
if w > width {
width = w
}
}
if width > logColumnMaxWidth {
width = logColumnMaxWidth
}
return width
}
// historyHeader is a bold tappable label used in the History table header row.
// In Fyne 2.7+ OnSelected is not fired for header cells (Row < 0), so the sort
// toggle is wired through the Tappable interface instead.
type historyHeader struct {
widget.BaseWidget
label *widget.Label
OnTapped func()
}
func newHistoryHeader() *historyHeader {
h := &historyHeader{label: widget.NewLabel("")}
h.label.TextStyle = fyne.TextStyle{Bold: true}
h.label.Wrapping = fyne.TextTruncate
h.ExtendBaseWidget(h)
return h
}
func (h *historyHeader) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(h.label)
}
func (h *historyHeader) Tapped(*fyne.PointEvent) {
if h.OnTapped != nil {
h.OnTapped()
}
}
func (h *historyHeader) SetText(text string) {
h.label.SetText(text)
}
func newHistoryView(events *[]event) (*fyne.Container, func()) {
descending := false
headerText := func(id widget.TableCellID) string {
headers := []string{"Time", "Trigger", "Job", "State", "Detail", "Log"}
if id.Row < 0 && id.Col == 0 {
if descending {
return "Time ▼"
}
return "Time ▲"
}
if id.Row < 0 && id.Col >= 0 && id.Col < len(headers) {
return headers[id.Col]
}
return ""
}
sortedEvents := func() []event {
result := append([]event(nil), (*events)...)
sort.SliceStable(result, func(left int, right int) bool {
if descending {
return result[left].Time > result[right].Time
}
return result[left].Time < result[right].Time
})
return result
}
table := widget.NewTable(
func() (int, int) {
return len(*events), 6
},
func() fyne.CanvasObject {
label := widget.NewLabel("")
label.Wrapping = fyne.TextTruncate
return label
},
func(id widget.TableCellID, item fyne.CanvasObject) {
label := item.(*widget.Label)
label.SetText(historyCellText(id, sortedEvents()))
label.TextStyle = fyne.TextStyle{}
label.Refresh()
},
)
table.ShowHeaderRow = true
table.CreateHeader = func() fyne.CanvasObject {
return newHistoryHeader()
}
table.UpdateHeader = func(id widget.TableCellID, item fyne.CanvasObject) {
h := item.(*historyHeader)
h.SetText(headerText(id))
if id.Row < 0 && id.Col == 0 {
h.OnTapped = func() {
descending = !descending
table.Refresh()
}
} else {
h.OnTapped = nil
}
h.Refresh()
}
table.OnSelected = func(id widget.TableCellID) {
table.Unselect(id)
}
table.SetColumnWidth(0, 150)
table.SetColumnWidth(1, 90)
table.SetColumnWidth(2, 170)
table.SetColumnWidth(3, 90)
table.SetColumnWidth(4, 260)
table.SetColumnWidth(5, logColumnWidth(*events))
// refresh recomputes the content-fit Log column width before redrawing, so
// newly recorded events with longer file names widen the column instead of
// being truncated.
refresh := func() {
table.SetColumnWidth(5, logColumnWidth(*events))
table.Refresh()
}
return container.NewPadded(table), refresh
}
func historyCellText(id widget.TableCellID, events []event) string {
if id.Row < 0 || id.Row >= len(events) {
return ""
}
current := events[id.Row]
trigger := current.Trigger
if trigger == "" {
trigger = "Unknown"
}
switch id.Col {
case 0:
return current.Time
case 1:
return trigger
case 2:
return current.JobName
case 3:
return current.State
case 4:
return current.Detail
case 5:
return logFileName(current.LogFile)
default:
return ""
}
}
func logFileName(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
path = strings.ReplaceAll(path, "\\", "/")
if slash := strings.LastIndex(path, "/"); slash >= 0 {
return path[slash+1:]
}
return path
}