• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

charmbracelet / glow / 14249847881

03 Apr 2025 06:10PM UTC coverage: 3.722% (-0.007%) from 3.729%
14249847881

Pull #735

github

andreynering
fix(lint): fix all linting issues
Pull Request #735: chore: do some basic repo maintenance (editorconfig, linting, etc)

0 of 86 new or added lines in 14 files covered. (0.0%)

1 existing line in 1 file now uncovered.

77 of 2069 relevant lines covered (3.72%)

0.05 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/ui/ui.go
1
// Package ui provides the main UI for the glow application.
2
package ui
3

4
import (
5
        "fmt"
6
        "os"
7
        "path/filepath"
8
        "strings"
9
        "time"
10

11
        tea "github.com/charmbracelet/bubbletea"
12
        "github.com/charmbracelet/glamour/styles"
13
        "github.com/charmbracelet/glow/v2/utils"
14
        "github.com/charmbracelet/log"
15
        "github.com/muesli/gitcha"
16
        te "github.com/muesli/termenv"
17
)
18

19
const (
20
        statusMessageTimeout = time.Second * 3 // how long to show status messages like "stashed!"
21
        ellipsis             = "…"
22
)
23

24
var (
25
        config Config
26

27
        markdownExtensions = []string{
28
                "*.md", "*.mdown", "*.mkdn", "*.mkd", "*.markdown",
29
        }
30
)
31

32
// NewProgram returns a new Tea program.
33
func NewProgram(cfg Config, content string) *tea.Program {
×
34
        log.Debug(
×
35
                "Starting glow",
×
36
                "high_perf_pager",
×
37
                cfg.HighPerformancePager,
×
38
                "glamour",
×
39
                cfg.GlamourEnabled,
×
40
        )
×
41

×
42
        config = cfg
×
43
        opts := []tea.ProgramOption{tea.WithAltScreen()}
×
44
        if cfg.EnableMouse {
×
45
                opts = append(opts, tea.WithMouseCellMotion())
×
46
        }
×
47
        m := newModel(cfg, content)
×
48
        return tea.NewProgram(m, opts...)
×
49
}
50

51
type errMsg struct{ err error }
52

53
func (e errMsg) Error() string { return e.err.Error() }
×
54

55
type (
56
        initLocalFileSearchMsg struct {
57
                cwd string
58
                ch  chan gitcha.SearchResult
59
        }
60
)
61

62
type (
63
        foundLocalFileMsg       gitcha.SearchResult
64
        localFileSearchFinished struct{}
65
        statusMessageTimeoutMsg applicationContext
66
)
67

68
// applicationContext indicates the area of the application something applies
69
// to. Occasionally used as an argument to commands and messages.
70
type applicationContext int
71

72
const (
73
        stashContext applicationContext = iota
74
        pagerContext
75
)
76

77
// state is the top-level application state.
78
type state int
79

80
const (
81
        stateShowStash state = iota
82
        stateShowDocument
83
)
84

85
func (s state) String() string {
×
86
        return map[state]string{
×
87
                stateShowStash:    "showing file listing",
×
88
                stateShowDocument: "showing document",
×
89
        }[s]
×
90
}
×
91

92
// Common stuff we'll need to access in all models.
93
type commonModel struct {
94
        cfg    Config
95
        cwd    string
96
        width  int
97
        height int
98
}
99

100
type model struct {
101
        common   *commonModel
102
        state    state
103
        fatalErr error
104

105
        // Sub-models
106
        stash stashModel
107
        pager pagerModel
108

109
        // Channel that receives paths to local markdown files
110
        // (via the github.com/muesli/gitcha package)
111
        localFileFinder chan gitcha.SearchResult
112
}
113

114
// unloadDocument unloads a document from the pager. Note that while this
115
// method alters the model we also need to send along any commands returned.
116
func (m *model) unloadDocument() []tea.Cmd {
×
117
        m.state = stateShowStash
×
118
        m.stash.viewState = stashStateReady
×
119
        m.pager.unload()
×
120
        m.pager.showHelp = false
×
121

×
122
        var batch []tea.Cmd
×
123
        if m.pager.viewport.HighPerformanceRendering {
×
NEW
124
                batch = append(batch, tea.ClearScrollArea) //nolint:staticcheck
×
125
        }
×
126

127
        if !m.stash.shouldSpin() {
×
128
                batch = append(batch, m.stash.spinner.Tick)
×
129
        }
×
130
        return batch
×
131
}
132

133
func newModel(cfg Config, content string) tea.Model {
×
134
        initSections()
×
135

×
136
        if cfg.GlamourStyle == styles.AutoStyle {
×
137
                if te.HasDarkBackground() {
×
138
                        cfg.GlamourStyle = styles.DarkStyle
×
139
                } else {
×
140
                        cfg.GlamourStyle = styles.LightStyle
×
141
                }
×
142
        }
143

144
        common := commonModel{
×
145
                cfg: cfg,
×
146
        }
×
147

×
148
        m := model{
×
149
                common: &common,
×
150
                state:  stateShowStash,
×
151
                pager:  newPagerModel(&common),
×
152
                stash:  newStashModel(&common),
×
153
        }
×
154

×
155
        path := cfg.Path
×
156
        if path == "" && content != "" {
×
157
                m.state = stateShowDocument
×
158
                m.pager.currentDocument = markdown{Body: content}
×
159
                return m
×
160
        }
×
161

162
        if path == "" {
×
163
                path = "."
×
164
        }
×
165
        info, err := os.Stat(path)
×
166
        if err != nil {
×
167
                log.Error("unable to stat file", "file", path, "error", err)
×
168
                m.fatalErr = err
×
169
                return m
×
170
        }
×
171
        if info.IsDir() {
×
172
                m.state = stateShowStash
×
173
        } else {
×
174
                cwd, _ := os.Getwd()
×
175
                m.state = stateShowDocument
×
176
                m.pager.currentDocument = markdown{
×
177
                        localPath: path,
×
178
                        Note:      stripAbsolutePath(path, cwd),
×
179
                        Modtime:   info.ModTime(),
×
180
                }
×
181
        }
×
182

183
        return m
×
184
}
185

186
func (m model) Init() tea.Cmd {
×
187
        cmds := []tea.Cmd{m.stash.spinner.Tick}
×
188

×
189
        switch m.state {
×
190
        case stateShowStash:
×
191
                cmds = append(cmds, findLocalFiles(*m.common))
×
192
        case stateShowDocument:
×
193
                content, err := os.ReadFile(m.common.cfg.Path)
×
194
                if err != nil {
×
195
                        log.Error("unable to read file", "file", m.common.cfg.Path, "error", err)
×
196
                        return func() tea.Msg { return errMsg{err} }
×
197
                }
198
                body := string(utils.RemoveFrontmatter(content))
×
199
                cmds = append(cmds, renderWithGlamour(m.pager, body))
×
200
        }
201

202
        return tea.Batch(cmds...)
×
203
}
204

205
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
×
206
        // If there's been an error, any key exits
×
207
        if m.fatalErr != nil {
×
208
                if _, ok := msg.(tea.KeyMsg); ok {
×
209
                        return m, tea.Quit
×
210
                }
×
211
        }
212

213
        var cmds []tea.Cmd
×
214

×
215
        switch msg := msg.(type) {
×
216
        case tea.KeyMsg:
×
217
                switch msg.String() {
×
218
                case "esc":
×
219
                        if m.state == stateShowDocument || m.stash.viewState == stashStateLoadingDocument {
×
220
                                batch := m.unloadDocument()
×
221
                                return m, tea.Batch(batch...)
×
222
                        }
×
223
                case "r":
×
224
                        var cmd tea.Cmd
×
225
                        if m.state == stateShowStash {
×
226
                                // pass through all keys if we're editing the filter
×
227
                                if m.stash.filterState == filtering {
×
228
                                        m.stash, cmd = m.stash.update(msg)
×
229
                                        return m, cmd
×
230
                                }
×
231
                                m.stash.markdowns = nil
×
232
                                return m, m.Init()
×
233
                        }
234

235
                case "q":
×
236
                        var cmd tea.Cmd
×
237

×
NEW
238
                        switch m.state { //nolint:exhaustive
×
239
                        case stateShowStash:
×
240
                                // pass through all keys if we're editing the filter
×
241
                                if m.stash.filterState == filtering {
×
242
                                        m.stash, cmd = m.stash.update(msg)
×
243
                                        return m, cmd
×
244
                                }
×
245
                        }
246

247
                        return m, tea.Quit
×
248

249
                case "left", "h", "delete":
×
250
                        if m.state == stateShowDocument {
×
251
                                cmds = append(cmds, m.unloadDocument()...)
×
252
                                return m, tea.Batch(cmds...)
×
253
                        }
×
254

255
                case "ctrl+z":
×
256
                        return m, tea.Suspend
×
257

258
                // Ctrl+C always quits no matter where in the application you are.
259
                case "ctrl+c":
×
260
                        return m, tea.Quit
×
261
                }
262

263
        // Window size is received when starting up and on every resize
264
        case tea.WindowSizeMsg:
×
265
                m.common.width = msg.Width
×
266
                m.common.height = msg.Height
×
267
                m.stash.setSize(msg.Width, msg.Height)
×
268
                m.pager.setSize(msg.Width, msg.Height)
×
269

270
        case initLocalFileSearchMsg:
×
271
                m.localFileFinder = msg.ch
×
272
                m.common.cwd = msg.cwd
×
273
                cmds = append(cmds, findNextLocalFile(m))
×
274

275
        case fetchedMarkdownMsg:
×
276
                // We've loaded a markdown file's contents for rendering
×
277
                m.pager.currentDocument = *msg
×
278
                body := string(utils.RemoveFrontmatter([]byte(msg.Body)))
×
279
                cmds = append(cmds, renderWithGlamour(m.pager, body))
×
280

281
        case contentRenderedMsg:
×
282
                m.state = stateShowDocument
×
283

284
        case localFileSearchFinished:
×
285
                // Always pass these messages to the stash so we can keep it updated
×
286
                // about network activity, even if the user isn't currently viewing
×
287
                // the stash.
×
288
                stashModel, cmd := m.stash.update(msg)
×
289
                m.stash = stashModel
×
290
                return m, cmd
×
291

292
        case foundLocalFileMsg:
×
293
                newMd := localFileToMarkdown(m.common.cwd, gitcha.SearchResult(msg))
×
294
                m.stash.addMarkdowns(newMd)
×
295
                if m.stash.filterApplied() {
×
296
                        newMd.buildFilterValue()
×
297
                }
×
298
                if m.stash.shouldUpdateFilter() {
×
299
                        cmds = append(cmds, filterMarkdowns(m.stash))
×
300
                }
×
301
                cmds = append(cmds, findNextLocalFile(m))
×
302

303
        case filteredMarkdownMsg:
×
304
                if m.state == stateShowDocument {
×
305
                        newStashModel, cmd := m.stash.update(msg)
×
306
                        m.stash = newStashModel
×
307
                        cmds = append(cmds, cmd)
×
308
                }
×
309
        }
310

311
        // Process children
312
        switch m.state {
×
313
        case stateShowStash:
×
314
                newStashModel, cmd := m.stash.update(msg)
×
315
                m.stash = newStashModel
×
316
                cmds = append(cmds, cmd)
×
317

318
        case stateShowDocument:
×
319
                newPagerModel, cmd := m.pager.update(msg)
×
320
                m.pager = newPagerModel
×
321
                cmds = append(cmds, cmd)
×
322
        }
323

324
        return m, tea.Batch(cmds...)
×
325
}
326

327
func (m model) View() string {
×
328
        if m.fatalErr != nil {
×
329
                return errorView(m.fatalErr, true)
×
330
        }
×
331

NEW
332
        switch m.state { //nolint:exhaustive
×
333
        case stateShowDocument:
×
334
                return m.pager.View()
×
335
        default:
×
336
                return m.stash.view()
×
337
        }
338
}
339

340
func errorView(err error, fatal bool) string {
×
341
        exitMsg := "press any key to "
×
342
        if fatal {
×
343
                exitMsg += "exit"
×
344
        } else {
×
345
                exitMsg += "return"
×
346
        }
×
347
        s := fmt.Sprintf("%s\n\n%v\n\n%s",
×
348
                errorTitleStyle.Render("ERROR"),
×
349
                err,
×
350
                subtleStyle.Render(exitMsg),
×
351
        )
×
352
        return "\n" + indent(s, 3)
×
353
}
354

355
// COMMANDS
356

357
func findLocalFiles(m commonModel) tea.Cmd {
×
358
        return func() tea.Msg {
×
359
                log.Info("findLocalFiles")
×
360
                var (
×
361
                        cwd = m.cfg.Path
×
362
                        err error
×
363
                )
×
364

×
365
                if cwd == "" {
×
366
                        cwd, err = os.Getwd()
×
367
                } else {
×
368
                        var info os.FileInfo
×
369
                        info, err = os.Stat(cwd)
×
370
                        if err == nil && info.IsDir() {
×
371
                                cwd, err = filepath.Abs(cwd)
×
372
                        }
×
373
                }
374

375
                // Note that this is one error check for both cases above
376
                if err != nil {
×
377
                        log.Error("error finding local files", "error", err)
×
378
                        return errMsg{err}
×
379
                }
×
380

381
                log.Debug("local directory is", "cwd", cwd)
×
382

×
383
                // Switch between FindFiles and FindAllFiles to bypass .gitignore rules
×
384
                var ch chan gitcha.SearchResult
×
385
                if m.cfg.ShowAllFiles {
×
386
                        ch, err = gitcha.FindAllFilesExcept(cwd, markdownExtensions, nil)
×
387
                } else {
×
388
                        ch, err = gitcha.FindFilesExcept(cwd, markdownExtensions, ignorePatterns(m))
×
389
                }
×
390

391
                if err != nil {
×
392
                        log.Error("error finding local files", "error", err)
×
393
                        return errMsg{err}
×
394
                }
×
395

396
                return initLocalFileSearchMsg{ch: ch, cwd: cwd}
×
397
        }
398
}
399

400
func findNextLocalFile(m model) tea.Cmd {
×
401
        return func() tea.Msg {
×
402
                res, ok := <-m.localFileFinder
×
403

×
404
                if ok {
×
405
                        // Okay now find the next one
×
406
                        return foundLocalFileMsg(res)
×
407
                }
×
408
                // We're done
409
                log.Debug("local file search finished")
×
410
                return localFileSearchFinished{}
×
411
        }
412
}
413

414
func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.Cmd {
×
415
        return func() tea.Msg {
×
416
                <-t.C
×
417
                return statusMessageTimeoutMsg(appCtx)
×
418
        }
×
419
}
420

421
// ETC
422

423
// Convert a Gitcha result to an internal representation of a markdown
424
// document. Note that we could be doing things like checking if the file is
425
// a directory, but we trust that gitcha has already done that.
426
func localFileToMarkdown(cwd string, res gitcha.SearchResult) *markdown {
×
427
        return &markdown{
×
428
                localPath: res.Path,
×
429
                Note:      stripAbsolutePath(res.Path, cwd),
×
430
                Modtime:   res.Info.ModTime(),
×
431
        }
×
432
}
×
433

434
func stripAbsolutePath(fullPath, cwd string) string {
×
435
        fp, _ := filepath.EvalSymlinks(fullPath)
×
436
        cp, _ := filepath.EvalSymlinks(cwd)
×
437
        return strings.ReplaceAll(fp, cp+string(os.PathSeparator), "")
×
438
}
×
439

440
// Lightweight version of reflow's indent function.
441
func indent(s string, n int) string {
×
442
        if n <= 0 || s == "" {
×
443
                return s
×
444
        }
×
445
        l := strings.Split(s, "\n")
×
446
        b := strings.Builder{}
×
447
        i := strings.Repeat(" ", n)
×
448
        for _, v := range l {
×
449
                fmt.Fprintf(&b, "%s%s\n", i, v)
×
450
        }
×
451
        return b.String()
×
452
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc