|
| 1 | +// Copyright 2025 The Tessera authors. All Rights Reserved. |
| 2 | +// |
| 3 | +// Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +// you may not use this file except in compliance with the License. |
| 5 | +// You may obtain a copy of the License at |
| 6 | +// |
| 7 | +// http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +// |
| 9 | +// Unless required by applicable law or agreed to in writing, software |
| 10 | +// distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +// See the License for the specific language governing permissions and |
| 13 | +// limitations under the License. |
| 14 | + |
| 15 | +// Package tui provides a Bubbletea-based TUI for the fsck command. |
| 16 | +package tui |
| 17 | + |
| 18 | +import ( |
| 19 | + "bufio" |
| 20 | + "context" |
| 21 | + "flag" |
| 22 | + "fmt" |
| 23 | + "io" |
| 24 | + "time" |
| 25 | + |
| 26 | + "github.com/transparency-dev/tessera/cmd/fsck/tui" |
| 27 | + "github.com/transparency-dev/tessera/fsck" |
| 28 | + "k8s.io/klog/v2" |
| 29 | + |
| 30 | + tea "github.com/charmbracelet/bubbletea" |
| 31 | + "github.com/charmbracelet/lipgloss" |
| 32 | + "github.com/charmbracelet/x/ansi" |
| 33 | +) |
| 34 | + |
| 35 | +// RunApp runs the TUI app, using the provided fsck instance to fetch updates from to populate the UI. |
| 36 | +func RunApp(ctx context.Context, f *fsck.Fsck) error { |
| 37 | + m := NewFsckAppModel() |
| 38 | + p := tea.NewProgram(m) |
| 39 | + |
| 40 | + // Redirect logging so as to appear above the UI |
| 41 | + _ = flag.Set("logtostderr", "false") |
| 42 | + _ = flag.Set("alsologtostderr", "false") |
| 43 | + r, w := io.Pipe() |
| 44 | + klog.SetOutput(w) |
| 45 | + go func() { |
| 46 | + s := bufio.NewScanner(r) |
| 47 | + for s.Scan() { |
| 48 | + l := ansi.StringWidth(s.Text()) |
| 49 | + txt := ansi.InsertLine(l) + s.Text() |
| 50 | + p.Send(tea.Println(txt)()) |
| 51 | + } |
| 52 | + }() |
| 53 | + |
| 54 | + // Send periodic status updates to the UI from fsck. |
| 55 | + go func() { |
| 56 | + for { |
| 57 | + select { |
| 58 | + case <-ctx.Done(): |
| 59 | + // Have the UI update one last time to show where we got to (this helps ensure we see 100% |
| 60 | + // on the progress bars if we're exiting because the fsck has completed). |
| 61 | + p.Send(UpdateCmd(f.Status())()) |
| 62 | + // Give the UI a bit of time to render... |
| 63 | + <-time.After(100 * time.Millisecond) |
| 64 | + // And then we're out. |
| 65 | + p.Send(tea.Quit()) |
| 66 | + case <-time.After(100 * time.Millisecond): |
| 67 | + p.Send(UpdateCmd(f.Status())()) |
| 68 | + } |
| 69 | + } |
| 70 | + }() |
| 71 | + |
| 72 | + if _, err := p.Run(); err != nil { |
| 73 | + return err |
| 74 | + } |
| 75 | + return nil |
| 76 | +} |
| 77 | + |
| 78 | +// NewFsckAppModel creates a new BubbleTea model for the TUI. |
| 79 | +func NewFsckAppModel() *FsckAppModel { |
| 80 | + r := &FsckAppModel{} |
| 81 | + return r |
| 82 | +} |
| 83 | + |
| 84 | +// FsckAppModel represents the UI model for the FSCK TUI. |
| 85 | +type FsckAppModel struct { |
| 86 | + // entriesBar is the status/progress bar representing progress through the entry bundles. |
| 87 | + entriesBar *tui.LayerProgressModel |
| 88 | + // titlesBars is the list status/progress bars representing progress through the various levels of tiles in the log. |
| 89 | + // The zeroth entry corresponds to the tiles on level zero. |
| 90 | + tilesBars []*tui.LayerProgressModel |
| 91 | + |
| 92 | + // width is the width of the app window |
| 93 | + width int |
| 94 | +} |
| 95 | + |
| 96 | +// Init is called by Bubbleteam early on to set up the app. |
| 97 | +func (m *FsckAppModel) Init() tea.Cmd { |
| 98 | + return nil |
| 99 | +} |
| 100 | + |
| 101 | +// Update is called by Bubbletea to handle events. |
| 102 | +func (m *FsckAppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 103 | + switch msg := msg.(type) { |
| 104 | + case tea.KeyMsg: |
| 105 | + // Handle user input. |
| 106 | + // Quit if they pressed Q, escape, or CTRL-C. |
| 107 | + switch msg.String() { |
| 108 | + case "q", "esc", "ctrl+c": |
| 109 | + return m, tea.Quit |
| 110 | + } |
| 111 | + return m, nil |
| 112 | + case tea.WindowSizeMsg: |
| 113 | + m.width = msg.Width |
| 114 | + |
| 115 | + var cmd tea.Cmd |
| 116 | + cmds := []tea.Cmd{} |
| 117 | + |
| 118 | + if m.entriesBar != nil { |
| 119 | + _, cmd = m.entriesBar.Update(msg) |
| 120 | + cmds = append(cmds, cmd) |
| 121 | + } |
| 122 | + |
| 123 | + for _, t := range m.tilesBars { |
| 124 | + _, cmd = t.Update(msg) |
| 125 | + cmds = append(cmds, cmd) |
| 126 | + } |
| 127 | + return m, tea.Batch(cmds...) |
| 128 | + case updateMsg: |
| 129 | + // Ignore empty updates |
| 130 | + if len(msg.s.TileRanges) == 0 { |
| 131 | + return m, nil |
| 132 | + } |
| 133 | + |
| 134 | + // Create the range progress bars now that we know how details about the tree. |
| 135 | + if len(m.tilesBars) != len(msg.s.TileRanges) { |
| 136 | + m.entriesBar = tui.NewLayerProgressBar("Entry bundles", m.width, 0) |
| 137 | + |
| 138 | + bs := make([]*tui.LayerProgressModel, 0, len(msg.s.TileRanges)) |
| 139 | + for i := range msg.s.TileRanges { |
| 140 | + bs = append(bs, tui.NewLayerProgressBar(fmt.Sprintf("Tiles level %02d", i), m.width, i)) |
| 141 | + } |
| 142 | + m.tilesBars = bs |
| 143 | + } |
| 144 | + |
| 145 | + // Update all the range progress bars with the latest state. |
| 146 | + _, cmd := m.entriesBar.Update(tui.LayerUpdateMsg{Ranges: msg.s.EntryRanges}) |
| 147 | + cmds := []tea.Cmd{cmd} |
| 148 | + for i := range m.tilesBars { |
| 149 | + _, cmd = m.tilesBars[i].Update(tui.LayerUpdateMsg{Ranges: msg.s.TileRanges[i]}) |
| 150 | + cmds = append(cmds, cmd) |
| 151 | + } |
| 152 | + |
| 153 | + return m, tea.Batch(cmds...) |
| 154 | + default: |
| 155 | + return m, nil |
| 156 | + } |
| 157 | +} |
| 158 | + |
| 159 | +// View is called by Bubbletea to render the UI components. |
| 160 | +func (m *FsckAppModel) View() string { |
| 161 | + // Build the progress bars, we'll use this below. |
| 162 | + bars := []string{} |
| 163 | + for i := len(m.tilesBars) - 1; i >= 0; i-- { |
| 164 | + t := m.tilesBars[i] |
| 165 | + bars = append(bars, t.View()) |
| 166 | + } |
| 167 | + if m.entriesBar != nil { |
| 168 | + bars = append(bars, m.entriesBar.View()) |
| 169 | + } |
| 170 | + barsView := lipgloss.JoinVertical(lipgloss.Bottom, bars...) |
| 171 | + |
| 172 | + content := lipgloss.NewStyle(). |
| 173 | + Width(m.width). |
| 174 | + Height(lipgloss.Height(barsView)+1). |
| 175 | + Align(lipgloss.Center, lipgloss.Top). |
| 176 | + Border(lipgloss.NormalBorder(), true, false, false, false). |
| 177 | + Render(barsView) |
| 178 | + |
| 179 | + return lipgloss.JoinVertical(lipgloss.Top, content) |
| 180 | +} |
| 181 | + |
| 182 | +// updateMsg is used to tell the model about updated status from the fsck library. |
| 183 | +type updateMsg struct { |
| 184 | + s fsck.Status |
| 185 | +} |
| 186 | + |
| 187 | +// UpdateCmd returns a Cmd which Bubbletea can execute in order to retrieve and updateMsg. |
| 188 | +func UpdateCmd(status fsck.Status) tea.Cmd { |
| 189 | + return func() tea.Msg { |
| 190 | + return updateMsg{s: status} |
| 191 | + } |
| 192 | +} |
0 commit comments