package gui import ( "context" "log/slog" "os" "path/filepath" "strings" "sync" "time" "gioui.org/layout" "gioui.org/op/clip" "gioui.org/text" "gioui.org/widget" "gioui.org/widget/material" "tslink/core" "tslink/netdiag" ) type logsPage struct { app *App list widget.List search widget.Editor level widget.Enum source widget.Enum follow widget.Bool redact widget.Bool copyBtn widget.Clickable saveBtn widget.Clickable uploadBtn widget.Clickable urlCopyBtn widget.Clickable // mu guards the upload/save result fields, written from a goroutine. mu sync.Mutex uploading bool uploadURL string uploadTarget string uploadErr string savedPath string // Cached filter result. Re-running the query over the whole ring on every // frame is wasted work: it can only change when a record is appended or // the query itself changes. cached []core.LogEntry cachedSeq uint64 cachedLen int cachedQ core.LogQuery } // entries returns the filtered records, recomputing only when the buffer or // the query moved. func (p *logsPage) entries(buf *core.LogBuffer) []core.LogEntry { q := p.query() seq, n := buf.LastSeq(), buf.Len() if p.cached != nil && seq == p.cachedSeq && n == p.cachedLen && q == p.cachedQ { return p.cached } p.cached = buf.Filter(q) p.cachedSeq, p.cachedLen, p.cachedQ = seq, n, q return p.cached } func newLogsPage(a *App) *logsPage { p := &logsPage{app: a} p.list.Axis = layout.Vertical p.search.SingleLine = true p.level.Value = "all" p.source.Value = "all" p.follow.Value = true p.redact.Value = true return p } func (p *logsPage) minLevel() slog.Level { switch p.level.Value { case "debug": return slog.LevelDebug case "info": return slog.LevelInfo case "warn": return slog.LevelWarn case "error": return slog.LevelError default: return slog.LevelDebug - 4 // below everything } } func (p *logsPage) query() core.LogQuery { q := core.LogQuery{ MinLevel: p.minLevel(), Text: strings.TrimSpace(p.search.Text()), } if p.source.Value != "all" { q.Source = p.source.Value } return q } func (p *logsPage) Layout(a *App, gtx C, st core.State) D { th := a.th if a.opt.Logs == nil { return th.EmptyState(gtx, IconList, th.T(KLogsEmpty), "") } buf := a.opt.Logs p.handleActions(a, gtx, buf) p.list.ScrollToEnd = p.follow.Value entries := p.entries(buf) return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { return layout.Inset{Bottom: SpaceMD}.Layout(gtx, func(gtx C) D { return p.toolbar(a, gtx, buf, len(entries)) }) }), layout.Flexed(1, func(gtx C) D { return p.logList(a, gtx, entries) }), ) } func (p *logsPage) handleActions(a *App, gtx C, buf *core.LogBuffer) { th := a.th export := func() string { return buf.ExportText(core.ExportOptions{ Query: p.query(), NoRedact: !p.redact.Value, Header: a.diagnosticHeader(), }) } if p.copyBtn.Clicked(gtx) { a.copyToClipboard(gtx, export(), th.T(KCopied)) } if p.saveBtn.Clicked(gtx) { path, err := saveLogFile(export()) p.mu.Lock() if err != nil { p.savedPath = "" p.uploadErr = err.Error() } else { p.savedPath = path p.uploadErr = "" } p.mu.Unlock() if err != nil { a.notify(th.T(KError)+": "+err.Error(), LevelFail) } else { a.notify(path, LevelOK) } } if p.uploadBtn.Clicked(gtx) { p.startUpload(a, export()) } if p.urlCopyBtn.Clicked(gtx) { p.mu.Lock() url := p.uploadURL p.mu.Unlock() if url != "" { a.copyToClipboard(gtx, url, th.T(KCopied)) } } } // startUpload publishes the bundle to a public paste service. // // This sends the user's logs off the machine, so the redaction toggle is on by // default and the button label says "upload and share" rather than something // vaguer: nobody should be surprised about what just left their computer. func (p *logsPage) startUpload(a *App, text string) { p.mu.Lock() if p.uploading { p.mu.Unlock() return } p.uploading = true p.uploadURL = "" p.uploadErr = "" p.mu.Unlock() go func() { ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() res, err := netdiag.Upload(ctx, "", text, a.logger.With("from", "paste")) p.mu.Lock() p.uploading = false if err != nil { p.uploadErr = err.Error() } else { p.uploadURL = res.URL p.uploadTarget = res.Target } p.mu.Unlock() if err != nil { a.notify(a.th.T(KLogsUploadFail)+": "+Truncate(err.Error(), 80), LevelFail) } else { a.notify(a.th.T(KLogsUploaded)+" "+res.URL, LevelOK) } if a.win != nil { a.win.Invalidate() } }() } // saveLogFile writes the bundle next to the user's home directory. There is no // native file picker without pulling in another dependency, so the app picks a // predictable path and reports it rather than silently doing nothing. func saveLogFile(content string) (string, error) { dir, err := os.UserHomeDir() if err != nil || dir == "" { dir = "." } name := "tslink-log-" + time.Now().Format("20060102-150405") + ".txt" path := filepath.Join(dir, name) if err := os.WriteFile(path, []byte(content), 0o600); err != nil { return "", err } return path, nil } func (p *logsPage) toolbar(a *App, gtx C, buf *core.LogBuffer, shown int) D { th := a.th counts := buf.Counts() total := buf.Len() card := th.Card() card.Pad = SpaceMD return card.Layout(th, gtx, func(gtx C) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, // Row 1: search + level filter. layout.Rigid(func(gtx C) D { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Flexed(1, func(gtx C) D { return p.searchField(a, gtx) }), HGap(SpaceMD), layout.Rigid(func(gtx C) D { return th.Segmented(gtx, &p.level, []SegmentOption{ {Key: "all", Label: th.T(KLogsAll), Count: total}, {Key: "debug", Label: "DBG", Count: counts[slog.LevelDebug]}, {Key: "info", Label: "INF", Count: counts[slog.LevelInfo]}, {Key: "warn", Label: "WRN", Count: counts[slog.LevelWarn], Level: LevelWarn}, {Key: "error", Label: "ERR", Count: counts[slog.LevelError], Level: LevelFail}, }) }), ) }), // Row 2: source filter. layout.Rigid(func(gtx C) D { sources := buf.Sources() if len(sources) == 0 { return D{} } if len(sources) > 6 { sources = sources[:6] } opts := make([]SegmentOption, 0, len(sources)+1) opts = append(opts, SegmentOption{Key: "all", Label: th.T(KLogsAll), Count: -1}) for _, s := range sources { opts = append(opts, SegmentOption{Key: s, Label: s, Count: -1}) } return layout.Inset{Top: SpaceSM}.Layout(gtx, func(gtx C) D { return th.Segmented(gtx, &p.source, opts) }) }), // Row 3: toggles + actions. layout.Rigid(func(gtx C) D { return layout.Inset{Top: SpaceMD}.Layout(gtx, func(gtx C) D { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx C) D { return th.Toggle(gtx, &p.follow, th.T(KLogsFollow)) }), HGap(SpaceLG), layout.Rigid(func(gtx C) D { return th.Toggle(gtx, &p.redact, th.T(KLogsRedact)) }), layout.Flexed(1, func(gtx C) D { return layout.E.Layout(gtx, func(gtx C) D { return p.actions(a, gtx) }) }), ) }) }), // Row 4: counts + upload result. layout.Rigid(func(gtx C) D { return layout.Inset{Top: SpaceSM}.Layout(gtx, func(gtx C) D { return p.statusLine(a, gtx, buf, shown, total) }) }), ) }) } func (p *logsPage) searchField(a *App, gtx C) D { th := a.th return layout.Stack{}.Layout(gtx, layout.Expanded(func(gtx C) D { FillRRect(gtx, gtx.Constraints.Min, RadiusSM, th.P.BgElevated) StrokeRRect(gtx, gtx.Constraints.Min, RadiusSM, 1, th.P.Border) return D{Size: gtx.Constraints.Min} }), layout.Stacked(func(gtx C) D { gtx.Constraints.Min.X = gtx.Constraints.Max.X return layout.Inset{ Top: 7, Bottom: 7, Left: SpaceMD, Right: SpaceMD, }.Layout(gtx, func(gtx C) D { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx C) D { return IconSearch(gtx, gtx.Dp(14), th.P.TextDim) }), HGap(SpaceSM), layout.Flexed(1, func(gtx C) D { ed := material.Editor(th.Theme, &p.search, th.T(KLogsSearch)) ed.TextSize = SizeBody ed.Color = th.P.TextPri ed.HintColor = th.P.TextDim return ed.Layout(gtx) }), ) }) }), ) } func (p *logsPage) actions(a *App, gtx C) D { th := a.th p.mu.Lock() uploading := p.uploading p.mu.Unlock() uploadLabel := th.T(KLogsUpload) if uploading { uploadLabel = th.T(KLogsUploading) } return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx C) D { return th.Button(gtx, &p.copyBtn, ButtonStyle{ Kind: ButtonGhost, Text: th.T(KLogsCopyAll), Icon: IconCopy, }) }), HGap(SpaceSM), layout.Rigid(func(gtx C) D { return th.Button(gtx, &p.saveBtn, ButtonStyle{ Kind: ButtonGhost, Text: th.T(KLogsSaveFile), Icon: IconSave, }) }), HGap(SpaceSM), layout.Rigid(func(gtx C) D { return th.Button(gtx, &p.uploadBtn, ButtonStyle{ Kind: ButtonSubtle, Text: uploadLabel, Icon: IconUpload, Disabled: uploading, }) }), ) } func (p *logsPage) statusLine(a *App, gtx C, buf *core.LogBuffer, shown, total int) D { th := a.th p.mu.Lock() url, target, upErr, saved := p.uploadURL, p.uploadTarget, p.uploadErr, p.savedPath p.mu.Unlock() return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx C) D { txt := th.T(KLogsShown) + " " + itoa(shown) + " / " + itoa(total) if d := buf.Dropped(); d > 0 { txt += " ยท " + itoa(int(d)) + " " + th.T(KLogsDropped) } return th.Caption(txt).Layout(gtx) }), layout.Flexed(1, func(gtx C) D { return layout.E.Layout(gtx, func(gtx C) D { switch { case url != "": return layout.Flex{Alignment: layout.Middle}.Layout(gtx, layout.Rigid(func(gtx C) D { return OneLine(th.MonoLabel(SizeCaption, th.P.OK, url)).Layout(gtx) }), layout.Rigid(func(gtx C) D { if target == "" { return D{} } return layout.Inset{Left: 6}.Layout(gtx, th.Caption("("+target+")").Layout) }), layout.Rigid(func(gtx C) D { return th.IconButton(gtx, &p.urlCopyBtn, IconCopy, LevelOK) }), ) case upErr != "": return OneLine(th.Text(SizeCaption, th.P.Fail, Truncate(upErr, 90))).Layout(gtx) case saved != "": return OneLine(th.MonoLabel(SizeCaption, th.P.TextSec, saved)).Layout(gtx) default: return OneLine(th.Caption(th.T(KLogsRedactHint))).Layout(gtx) } }) }), ) } func (p *logsPage) logList(a *App, gtx C, entries []core.LogEntry) D { th := a.th card := th.Card() card.Pad = SpaceSM return card.Layout(th, gtx, func(gtx C) D { if len(entries) == 0 { return th.EmptyState(gtx, IconSearch, th.T(KLogsEmpty), "") } gtx.Constraints.Min.Y = gtx.Constraints.Max.Y defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop() return material.List(th.Theme, &p.list).Layout(gtx, len(entries), func(gtx C, i int) D { return p.logRow(th, gtx, entries[i]) }) }) } func (p *logsPage) logRow(th *Theme, gtx C, e core.LogEntry) D { lvlCol := th.P.TextDim switch { case e.Level >= slog.LevelError: lvlCol = th.P.Fail case e.Level >= slog.LevelWarn: lvlCol = th.P.Warn case e.Level >= slog.LevelInfo: lvlCol = th.P.Info } msgCol := th.P.TextSec if e.Level >= slog.LevelWarn { msgCol = th.P.TextPri } msg := e.Msg attrs := make([]string, 0, len(e.Attrs)) for _, at := range e.Attrs { if at.Key == "from" { continue } attrs = append(attrs, at.Key+"="+core.Redact(at.Value)) } return layout.Inset{Top: 2, Bottom: 2, Left: SpaceSM, Right: SpaceSM}.Layout(gtx, func(gtx C) D { return layout.Flex{Alignment: layout.Start}.Layout(gtx, layout.Rigid(func(gtx C) D { return th.MonoLabel(SizeMono, WithAlpha(th.P.TextDim, 0.9), e.Time.Format("15:04:05.000")).Layout(gtx) }), HGap(SpaceSM), layout.Rigid(func(gtx C) D { gtx.Constraints.Min.X = gtx.Dp(28) return th.MonoLabel(SizeMono, lvlCol, core.LevelLabel(e.Level)).Layout(gtx) }), HGap(SpaceSM), layout.Rigid(func(gtx C) D { if e.Source == "" { return D{} } gtx.Constraints.Max.X = gtx.Dp(96) l := th.MonoLabel(SizeMono, WithAlpha(th.P.Info, 0.85), e.Source) l.MaxLines = 1 l.Alignment = text.End return l.Layout(gtx) }), HGap(SpaceSM), layout.Flexed(1, func(gtx C) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { l := th.MonoLabel(SizeMono, msgCol, core.Redact(msg)) l.MaxLines = 3 return l.Layout(gtx) }), layout.Rigid(func(gtx C) D { if len(attrs) == 0 { return D{} } l := th.MonoLabel(SizeMono, WithAlpha(th.P.TextDim, 0.95), strings.Join(attrs, " ")) l.MaxLines = 2 return l.Layout(gtx) }), ) }), ) }) }