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

charmbracelet / glow / 13264505889

11 Feb 2025 01:53PM UTC coverage: 3.74% (-0.001%) from 3.741%
13264505889

push

github

web-flow
fix: correct abs to rel path conversion (#683)

0 of 3 new or added lines in 1 file covered. (0.0%)

1 existing line in 1 file now uncovered.

77 of 2059 relevant lines covered (3.74%)

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
2

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

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

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

23
var (
24
        config Config
25

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

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

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

50
type errMsg struct{ err error }
51

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

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

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

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

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

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

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

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

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

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

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

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

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

×
121
        var batch []tea.Cmd
×
122
        if m.pager.viewport.HighPerformanceRendering {
×
123
                batch = append(batch, tea.ClearScrollArea)
×
124
        }
×
125

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

132
func newModel(cfg Config) tea.Model {
×
133
        initSections()
×
134

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

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

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

×
154
        path := cfg.Path
×
155
        if path == "" {
×
156
                path = "."
×
157
        }
×
158
        info, err := os.Stat(path)
×
159
        if err != nil {
×
160
                log.Error("unable to stat file", "file", path, "error", err)
×
161
                m.fatalErr = err
×
162
                return m
×
163
        }
×
164
        if info.IsDir() {
×
165
                m.state = stateShowStash
×
166
        } else {
×
167
                cwd, _ := os.Getwd()
×
168
                m.state = stateShowDocument
×
169
                m.pager.currentDocument = markdown{
×
170
                        localPath: path,
×
171
                        Note:      stripAbsolutePath(path, cwd),
×
172
                        Modtime:   info.ModTime(),
×
173
                }
×
174
        }
×
175

176
        return m
×
177
}
178

179
func (m model) Init() tea.Cmd {
×
180
        cmds := []tea.Cmd{m.stash.spinner.Tick}
×
181

×
182
        switch m.state {
×
183
        case stateShowStash:
×
184
                cmds = append(cmds, findLocalFiles(*m.common))
×
185
        case stateShowDocument:
×
186
                content, err := os.ReadFile(m.common.cfg.Path)
×
187
                if err != nil {
×
188
                        log.Error("unable to read file", "file", m.common.cfg.Path, "error", err)
×
189
                        return func() tea.Msg { return errMsg{err} }
×
190
                }
191
                body := string(utils.RemoveFrontmatter(content))
×
192
                cmds = append(cmds, renderWithGlamour(m.pager, body))
×
193
        }
194

195
        return tea.Batch(cmds...)
×
196
}
197

198
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
×
199
        // If there's been an error, any key exits
×
200
        if m.fatalErr != nil {
×
201
                if _, ok := msg.(tea.KeyMsg); ok {
×
202
                        return m, tea.Quit
×
203
                }
×
204
        }
205

206
        var cmds []tea.Cmd
×
207

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

228
                case "q":
×
229
                        var cmd tea.Cmd
×
230

×
231
                        switch m.state {
×
232
                        case stateShowStash:
×
233
                                // pass through all keys if we're editing the filter
×
234
                                if m.stash.filterState == filtering {
×
235
                                        m.stash, cmd = m.stash.update(msg)
×
236
                                        return m, cmd
×
237
                                }
×
238
                        }
239

240
                        return m, tea.Quit
×
241

242
                case "left", "h", "delete":
×
243
                        if m.state == stateShowDocument {
×
244
                                cmds = append(cmds, m.unloadDocument()...)
×
245
                                return m, tea.Batch(cmds...)
×
246
                        }
×
247

248
                case "ctrl+z":
×
249
                        return m, tea.Suspend
×
250

251
                // Ctrl+C always quits no matter where in the application you are.
252
                case "ctrl+c":
×
253
                        return m, tea.Quit
×
254
                }
255

256
        // Window size is received when starting up and on every resize
257
        case tea.WindowSizeMsg:
×
258
                m.common.width = msg.Width
×
259
                m.common.height = msg.Height
×
260
                m.stash.setSize(msg.Width, msg.Height)
×
261
                m.pager.setSize(msg.Width, msg.Height)
×
262

263
        case initLocalFileSearchMsg:
×
264
                m.localFileFinder = msg.ch
×
265
                m.common.cwd = msg.cwd
×
266
                cmds = append(cmds, findNextLocalFile(m))
×
267

268
        case fetchedMarkdownMsg:
×
269
                // We've loaded a markdown file's contents for rendering
×
270
                m.pager.currentDocument = *msg
×
271
                body := string(utils.RemoveFrontmatter([]byte(msg.Body)))
×
272
                cmds = append(cmds, renderWithGlamour(m.pager, body))
×
273

274
        case contentRenderedMsg:
×
275
                m.state = stateShowDocument
×
276

277
        case localFileSearchFinished:
×
278
                // Always pass these messages to the stash so we can keep it updated
×
279
                // about network activity, even if the user isn't currently viewing
×
280
                // the stash.
×
281
                stashModel, cmd := m.stash.update(msg)
×
282
                m.stash = stashModel
×
283
                return m, cmd
×
284

285
        case foundLocalFileMsg:
×
286
                newMd := localFileToMarkdown(m.common.cwd, gitcha.SearchResult(msg))
×
287
                m.stash.addMarkdowns(newMd)
×
288
                if m.stash.filterApplied() {
×
289
                        newMd.buildFilterValue()
×
290
                }
×
291
                if m.stash.shouldUpdateFilter() {
×
292
                        cmds = append(cmds, filterMarkdowns(m.stash))
×
293
                }
×
294
                cmds = append(cmds, findNextLocalFile(m))
×
295

296
        case filteredMarkdownMsg:
×
297
                if m.state == stateShowDocument {
×
298
                        newStashModel, cmd := m.stash.update(msg)
×
299
                        m.stash = newStashModel
×
300
                        cmds = append(cmds, cmd)
×
301
                }
×
302
        }
303

304
        // Process children
305
        switch m.state {
×
306
        case stateShowStash:
×
307
                newStashModel, cmd := m.stash.update(msg)
×
308
                m.stash = newStashModel
×
309
                cmds = append(cmds, cmd)
×
310

311
        case stateShowDocument:
×
312
                newPagerModel, cmd := m.pager.update(msg)
×
313
                m.pager = newPagerModel
×
314
                cmds = append(cmds, cmd)
×
315
        }
316

317
        return m, tea.Batch(cmds...)
×
318
}
319

320
func (m model) View() string {
×
321
        if m.fatalErr != nil {
×
322
                return errorView(m.fatalErr, true)
×
323
        }
×
324

325
        switch m.state {
×
326
        case stateShowDocument:
×
327
                return m.pager.View()
×
328
        default:
×
329
                return m.stash.view()
×
330
        }
331
}
332

333
func errorView(err error, fatal bool) string {
×
334
        exitMsg := "press any key to "
×
335
        if fatal {
×
336
                exitMsg += "exit"
×
337
        } else {
×
338
                exitMsg += "return"
×
339
        }
×
340
        s := fmt.Sprintf("%s\n\n%v\n\n%s",
×
341
                errorTitleStyle.Render("ERROR"),
×
342
                err,
×
343
                subtleStyle.Render(exitMsg),
×
344
        )
×
345
        return "\n" + indent(s, 3)
×
346
}
347

348
// COMMANDS
349

350
func findLocalFiles(m commonModel) tea.Cmd {
×
351
        return func() tea.Msg {
×
352
                log.Info("findLocalFiles")
×
353
                var (
×
354
                        cwd = m.cfg.Path
×
355
                        err error
×
356
                )
×
357

×
358
                if cwd == "" {
×
359
                        cwd, err = os.Getwd()
×
360
                } else {
×
361
                        var info os.FileInfo
×
362
                        info, err = os.Stat(cwd)
×
363
                        if err == nil && info.IsDir() {
×
364
                                cwd, err = filepath.Abs(cwd)
×
365
                        }
×
366
                }
367

368
                // Note that this is one error check for both cases above
369
                if err != nil {
×
370
                        log.Error("error finding local files", "error", err)
×
371
                        return errMsg{err}
×
372
                }
×
373

374
                log.Debug("local directory is", "cwd", cwd)
×
375

×
376
                // Switch between FindFiles and FindAllFiles to bypass .gitignore rules
×
377
                var ch chan gitcha.SearchResult
×
378
                if m.cfg.ShowAllFiles {
×
379
                        ch, err = gitcha.FindAllFilesExcept(cwd, markdownExtensions, nil)
×
380
                } else {
×
381
                        ch, err = gitcha.FindFilesExcept(cwd, markdownExtensions, ignorePatterns(m))
×
382
                }
×
383

384
                if err != nil {
×
385
                        log.Error("error finding local files", "error", err)
×
386
                        return errMsg{err}
×
387
                }
×
388

389
                return initLocalFileSearchMsg{ch: ch, cwd: cwd}
×
390
        }
391
}
392

393
func findNextLocalFile(m model) tea.Cmd {
×
394
        return func() tea.Msg {
×
395
                res, ok := <-m.localFileFinder
×
396

×
397
                if ok {
×
398
                        // Okay now find the next one
×
399
                        return foundLocalFileMsg(res)
×
400
                }
×
401
                // We're done
402
                log.Debug("local file search finished")
×
403
                return localFileSearchFinished{}
×
404
        }
405
}
406

407
func waitForStatusMessageTimeout(appCtx applicationContext, t *time.Timer) tea.Cmd {
×
408
        return func() tea.Msg {
×
409
                <-t.C
×
410
                return statusMessageTimeoutMsg(appCtx)
×
411
        }
×
412
}
413

414
// ETC
415

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

427
func stripAbsolutePath(fullPath, cwd string) string {
×
NEW
428
        fp, _ := filepath.EvalSymlinks(fullPath)
×
NEW
429
        cp, _ := filepath.EvalSymlinks(cwd)
×
NEW
430
        return strings.ReplaceAll(fp, cp+string(os.PathSeparator), "")
×
UNCOV
431
}
×
432

433
// Lightweight version of reflow's indent function.
434
func indent(s string, n int) string {
×
435
        if n <= 0 || s == "" {
×
436
                return s
×
437
        }
×
438
        l := strings.Split(s, "\n")
×
439
        b := strings.Builder{}
×
440
        i := strings.Repeat(" ", n)
×
441
        for _, v := range l {
×
442
                fmt.Fprintf(&b, "%s%s\n", i, v)
×
443
        }
×
444
        return b.String()
×
445
}
446

447
func min(a, b int) int {
×
448
        if a < b {
×
449
                return a
×
450
        }
×
451
        return b
×
452
}
453

454
func max(a, b int) int {
×
455
        if a > b {
×
456
                return a
×
457
        }
×
458
        return b
×
459
}
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

© 2026 Coveralls, Inc