background code checker for golang

view: add scrolling viewport to build and run modes

+134 -48
+5 -1
config.go
··· 17 17 Help bool `toml:"help"` 18 18 Styles Styles `toml:"styles"` 19 19 Signs Signs `toml:"signs"` 20 + Context int `toml:"context"` 20 21 } 21 22 22 23 type Styles struct { ··· 45 46 Error string `toml:"error"` 46 47 Warning string `toml:"warning"` 47 48 System string `toml:"system"` 49 + HorizontalBar string `toml:"line"` 48 50 Separator string `toml:"separator"` 49 51 PassiveIndent string `toml:"passiveIndent"` 50 52 ActiveIndent string `toml:"activeIndent"` ··· 216 218 Help: true, 217 219 Mode: "build", 218 220 Package: "./cmd/gust", 221 + Context: 0, 219 222 Styles: Styles{ 220 223 Mode: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("5"))}, 221 224 Success: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("2"))}, ··· 224 227 Warning: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("3"))}, 225 228 File: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("4"))}, 226 229 Duration: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("7"))}, 227 - Separator: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("8"))}, 230 + Separator: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("12"))}, 228 231 Line: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("7"))}, 229 232 Stdout: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("3"))}, 230 233 Stderr: Style{lipgloss.NewStyle().Foreground(lipgloss.Color("1"))}, ··· 239 242 Error: "err", 240 243 Warning: "wrn", 241 244 System: "sys", 245 + HorizontalBar: "─", 242 246 Separator: " · ", 243 247 PassiveIndent: " | ", 244 248 ActiveIndent: " > ",
+1 -1
flake.nix
··· 30 30 version = "0.1.0"; 31 31 src = gitignoreSource ./.; 32 32 subPackages = ["cmd/gust"]; 33 - vendorHash = "sha256-BXjnNZMITu8uC/B5hFv41QRXtOv5tDMBRySU4dcyu6E="; 33 + vendorHash = "sha256-XKydHm/KX6sGU5ndQTNsIqaF0BpHew9NB+Q7gcAKBh4="; 34 34 env.CGO_ENABLED = 0; 35 35 }; 36 36 };
+79 -19
model.go
··· 7 7 "os" 8 8 "os/exec" 9 9 "path/filepath" 10 + "strings" 10 11 "sync" 11 12 "time" 12 13 13 14 "github.com/charmbracelet/bubbles/spinner" 15 + "github.com/charmbracelet/bubbles/viewport" 14 16 tea "github.com/charmbracelet/bubbletea" 17 + "github.com/charmbracelet/lipgloss" 15 18 "github.com/fsnotify/fsnotify" 16 19 ) 17 20 ··· 121 124 122 125 // Model represents the application state 123 126 type Model struct { 124 - stderr string 125 - stdout string 127 + stdio []IoLine 126 128 127 129 messages []CompilerMessage 128 130 fs Fs 129 131 w *fsnotify.Watcher 130 132 config Config 131 - context int 132 133 133 134 status Status 134 135 systemErrors []error ··· 137 138 start *time.Time 138 139 end *time.Time 139 140 141 + viewport viewport.Model 142 + ready bool 140 143 width, height int 141 144 } 142 145 146 + type Io []IoLine 147 + type IoLine struct { 148 + Kind IoKind 149 + Line string 150 + } 151 + type IoKind int 152 + 153 + const ( 154 + Stdout IoKind = iota 155 + Stderr 156 + ) 157 + 158 + func (m Model) stderr() string { 159 + var b strings.Builder 160 + 161 + for _, l := range m.stdio { 162 + if l.Kind == Stderr { 163 + b.WriteString(l.Line) 164 + } 165 + } 166 + 167 + return b.String() 168 + } 169 + 143 170 func NewModel(watcher *fsnotify.Watcher, config Config, options ...func(*Config)) Model { 144 171 for _, o := range options { 145 172 o(&config) 146 173 } 147 174 148 175 return Model{ 149 - w: watcher, 150 - status: NewStatus(), 151 - context: 1, 152 - config: config, 176 + w: watcher, 177 + status: NewStatus(), 178 + config: config, 153 179 } 154 180 } 155 181 ··· 158 184 } 159 185 160 186 func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 187 + var cmd tea.Cmd 188 + 161 189 switch msg := msg.(type) { 162 190 case tea.WindowSizeMsg: 163 - m.width, m.height = msg.Width, msg.Height 164 - return m, nil 191 + headerHeight := lipgloss.Height(m.viewStatus()) 192 + footerHeight := lipgloss.Height(m.viewHelp()) 193 + verticalMarginHeight := headerHeight + footerHeight 194 + 195 + m.width, m.height = msg.Width, msg.Height-verticalMarginHeight 196 + if !m.ready { 197 + m.viewport = viewport.New(m.width, m.height) 198 + m.viewport.YPosition = headerHeight 199 + m.ready = true 200 + } else { 201 + m.viewport.Width, m.viewport.Height = m.width, m.height 202 + } 203 + 204 + return m, updateViewport 165 205 case tea.KeyMsg: 166 206 switch msg.String() { 167 207 case "q", "ctrl+c": ··· 190 230 m.config.Help = !m.config.Help 191 231 return m, nil 192 232 default: 193 - return m, nil 233 + m.viewport, cmd = m.viewport.Update(msg) 234 + return m, cmd 194 235 } 195 236 case StatusKind, spinner.TickMsg: 196 237 var cmd tea.Cmd ··· 200 241 m, cmd := m.reload() 201 242 return m, cmd 202 243 case stdoutMsg: 203 - m.stdout = m.stdout + msg.line 244 + m.stdio = append(m.stdio, IoLine{ 245 + Kind: Stdout, 246 + Line: msg.line, 247 + }) 204 248 return m, m.checkStdout() 205 249 case stderrMsg: 206 - m.stderr = m.stderr + msg.line 250 + m.stdio = append(m.stdio, IoLine{ 251 + Kind: Stderr, 252 + Line: msg.line, 253 + }) 207 254 if m.config.Mode == "build" { 208 - messages := Parse(m.stderr) 255 + messages := Parse(m.stderr()) 209 256 fs := Fs{} 210 257 if !m.config.Summarized { 211 258 fs = BuildFs(messages) 212 - fs.PopulateContext(messages, m.context) 259 + fs.PopulateContext(messages, m.config.Context) 213 260 } 214 261 m.messages = messages 215 262 m.fs = fs ··· 218 265 case processState: 219 266 end := time.Now() 220 267 if msg.state == nil { 221 - return m, tea.Batch(m.checkCmdStatus(), m.checkStderr(), m.checkStdout()) 268 + return m, tea.Batch(m.checkCmdStatus(), m.checkStderr(), m.checkStdout(), updateViewport) 222 269 } else if msg.state.Success() { 223 270 m.end = &end 224 271 return m, Success.Cmd() ··· 226 273 m.end = &end 227 274 return m, Error.Cmd() 228 275 } else { 229 - return m, tea.Batch(m.checkCmdStatus(), m.checkStderr(), m.checkStdout()) 276 + return m, tea.Batch(m.checkCmdStatus(), m.checkStderr(), m.checkStdout(), updateViewport) 277 + } 278 + case updateViewportMsg: 279 + m.viewport.SetContent(m.viewBody()) 280 + if m.config.Mode == "build" { 281 + m.viewport.GotoTop() 282 + } else { 283 + m.viewport.GotoBottom() 230 284 } 285 + return m, nil 231 286 default: 232 287 return m, nil 233 288 } ··· 237 292 238 293 func reload() tea.Msg { 239 294 return reloadMsg{} 295 + } 296 + 297 + type updateViewportMsg struct{} 298 + 299 + func updateViewport() tea.Msg { 300 + return updateViewportMsg{} 240 301 } 241 302 242 303 type modifiedMsg struct{} ··· 279 340 } 280 341 281 342 // Reset state 282 - m.stderr = "" 283 - m.stdout = "" 343 + m.stdio = []IoLine{} 284 344 m.messages = nil 285 345 m.fs = nil 286 346 ··· 303 363 return m, Error.Cmd() 304 364 } 305 365 306 - return m, tea.Batch(status, m.checkCmdStatus(), m.checkStdout(), m.checkStderr()) 366 + return m, tea.Batch(status, m.checkCmdStatus(), m.checkStdout(), m.checkStderr(), updateViewport) 307 367 } 308 368 309 369 type stdoutMsg struct {
+49 -27
view.go
··· 5 5 "strconv" 6 6 "strings" 7 7 "time" 8 + "unicode/utf8" 8 9 9 10 "github.com/charmbracelet/lipgloss" 10 11 "github.com/muesli/reflow/wordwrap" ··· 12 13 13 14 func (m Model) View() string { 14 15 var b strings.Builder 15 - style := m.config.Styles 16 - sign := m.config.Signs 17 16 18 17 b.WriteString(m.viewStatus()) 19 18 19 + m.viewport.SetContent(m.viewBody()) 20 + b.WriteString(m.viewport.View()) 21 + 22 + b.WriteString("\n") 23 + 24 + b.WriteString(m.viewHelp()) 25 + 26 + return b.String() 27 + } 28 + 29 + func (m Model) viewBody() string { 30 + style := m.config.Styles 31 + sign := m.config.Signs 32 + 33 + var b strings.Builder 20 34 if m.config.Mode == "build" { 21 - m.viewBuild(&b) 35 + b.WriteString(m.viewBuild()) 22 36 } else { 23 - m.viewRun(&b) 37 + b.WriteString(m.viewRun()) 24 38 } 25 39 26 40 for _, e := range m.systemErrors { ··· 31 45 } 32 46 33 47 b.WriteString("\n") 34 - 35 - if m.config.Help { 36 - b.WriteString(m.viewHelp()) 37 - } 38 - 39 48 return b.String() 40 49 } 41 50 42 - func (m Model) viewBuild(b *strings.Builder) { 51 + func (m Model) viewBuild() string { 52 + var b strings.Builder 43 53 maxLineLen := 1 44 54 maxColLen := 1 45 55 style := m.config.Styles ··· 128 138 129 139 b.WriteString("\n") 130 140 } 141 + 142 + return b.String() 131 143 } 132 144 133 - func (m Model) viewRun(b *strings.Builder) { 145 + func (m Model) viewRun() string { 146 + var b strings.Builder 134 147 style := m.config.Styles 135 148 136 - width := m.width 137 - if m.stdout != "" && m.stderr != "" { 138 - width = width / 2 139 - b.WriteString(style.Stdout.Width(width).Render("stdout")) 140 - b.WriteString(style.Stdout.Width(width).Render("stderr")) 141 - } else if m.stdout != "" { 142 - b.WriteString(style.Stdout.Render("stdout")) 143 - } else if m.stderr != "" { 144 - b.WriteString(style.Stdout.Render("stderr")) 149 + for _, l := range m.stdio { 150 + switch l.Kind { 151 + case Stdout: 152 + b.WriteString(style.Stdout.Render("stdout")) 153 + case Stderr: 154 + b.WriteString(style.Stderr.Render("stderr")) 155 + default: 156 + b.WriteString(strings.Repeat(" ", 6)) 157 + } 158 + b.WriteString(" ") 159 + b.WriteString(l.Line) 145 160 } 146 161 147 - leftSide := fmt.Sprintf("\n%s", m.stdout) 148 - rightSide := fmt.Sprintf("\n%s", m.stderr) 149 - 150 - columns := lipgloss.JoinHorizontal(lipgloss.Top, leftSide, rightSide) 151 - 152 - b.WriteString(columns) 162 + return b.String() 153 163 } 154 164 155 165 func (m Model) viewStatus() string { ··· 210 220 b.WriteString(style.Duration.Render(time.Now().Sub(*m.start).String())) 211 221 } 212 222 223 + line := strings.Repeat(sign.HorizontalBar, max(0, (m.viewport.Width-lipgloss.Width(b.String()))/utf8.RuneCountInString(sign.HorizontalBar))) 224 + b.WriteString(style.Separator.Render(line)) 225 + 213 226 b.WriteString("\n") 214 227 215 228 return b.String() 216 229 } 217 230 218 231 func (m Model) viewHelp() string { 232 + if !m.config.Help { 233 + return "" 234 + } 235 + 219 236 var b strings.Builder 220 237 style := m.config.Styles 221 238 sign := m.config.Signs ··· 247 264 b.WriteString("h/? ") 248 265 b.WriteString(style.Separator.Render("hide help")) 249 266 250 - return lipgloss.NewStyle().PaddingTop(2).Render(b.String()) 267 + percentageBox := fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100) 268 + line := strings.Repeat(sign.HorizontalBar, max(0, (m.viewport.Width-lipgloss.Width(b.String())-lipgloss.Width(percentageBox))/utf8.RuneCountInString(sign.HorizontalBar))) 269 + b.WriteString(style.Separator.Render(line)) 270 + b.WriteString(style.Duration.Render(percentageBox)) 271 + 272 + return lipgloss.NewStyle().Render(b.String()) 251 273 }