feat: add -f/--follow to render appended content like tail -f

Follow a local markdown file and render newly appended content to
stdout as it arrives, without repainting, so scrollback and piping
keep working.

- watch the parent directory via fsnotify (survives atomic saves),
  debounce write bursts, and read only appended bytes
- flush at block-safe boundaries: the last blank line outside a
  fenced code block, with CommonMark-aware fence tracking
- flush a trailing unterminated block after an 8s idle timeout,
  re-opening a split code fence in the next chunk
- on truncation or rewrite, print a divider and re-render the file
- render non-markdown files per complete line as wrapped code blocks
This commit is contained in:
Danny 2026-07-17 10:29:29 -07:00
commit 2ee83916f0
5 changed files with 647 additions and 0 deletions

346
follow.go Normal file
View file

@ -0,0 +1,346 @@
package main
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glow/v2/utils"
"github.com/charmbracelet/lipgloss"
"github.com/fsnotify/fsnotify"
)
const (
// followDebounce coalesces the burst of write events a single save
// produces before reading the file.
followDebounce = 100 * time.Millisecond
// followIdleFlush renders a trailing block that has no terminating blank
// line yet, once the file has been quiet for this long.
followIdleFlush = 8 * time.Second
)
// fenceState tracks whether a scan position is inside a fenced code block.
type fenceState struct {
open bool
char byte
length int
line string
}
// trimIndent removes up to three leading spaces. Four or more means an
// indented code block, which cannot open or close a fence.
func trimIndent(line []byte) []byte {
for i := 0; i < 3 && len(line) > 0 && line[0] == ' '; i++ {
line = line[1:]
}
if len(line) > 0 && line[0] == ' ' {
return nil
}
return line
}
// openingFence reports whether line opens a fenced code block, returning the
// fence character and length.
func openingFence(line []byte) (byte, int, bool) {
trimmed := trimIndent(line)
if len(trimmed) < 3 {
return 0, 0, false
}
ch := trimmed[0]
if ch != '`' && ch != '~' {
return 0, 0, false
}
n := 0
for n < len(trimmed) && trimmed[n] == ch {
n++
}
if n < 3 {
return 0, 0, false
}
// the info string of a backtick fence cannot contain backticks
if ch == '`' && bytes.IndexByte(trimmed[n:], '`') >= 0 {
return 0, 0, false
}
return ch, n, true
}
// closesFence reports whether line closes the fence described by st.
func closesFence(line []byte, st fenceState) bool {
trimmed := trimIndent(line)
n := 0
for n < len(trimmed) && trimmed[n] == st.char {
n++
}
if n < st.length {
return false
}
return len(bytes.TrimSpace(trimmed[n:])) == 0
}
// lastBoundary scans the complete lines of data, starting in fence state st,
// and returns the offset just past the last blank line that sits outside a
// fenced code block (0 if there is none), along with the fence state after
// the last complete line.
func lastBoundary(data []byte, st fenceState) (int, fenceState) {
boundary, pos := 0, 0
for {
nl := bytes.IndexByte(data[pos:], '\n')
if nl < 0 {
break
}
line := data[pos : pos+nl]
pos += nl + 1
switch {
case st.open:
if closesFence(line, st) {
st = fenceState{}
}
default:
if ch, n, ok := openingFence(line); ok {
st = fenceState{open: true, char: ch, length: n, line: string(line)}
} else if len(bytes.TrimSpace(line)) == 0 {
boundary = pos
}
}
}
return boundary, st
}
// follower tails a file and renders appended markdown in block-safe chunks.
type follower struct {
path string
w io.Writer
render func(string) (string, error)
isCode bool
ext string
offset int64
rewritten bool
pending []byte
st fenceState
// reopenLine is the opening fence line of a code block that a forced
// flush rendered before its closing fence arrived; it is prepended to
// the next chunk so the remainder still renders as code.
reopenLine string
}
// emit renders a chunk and appends it to the output.
func (f *follower) emit(chunk []byte) error {
content := string(chunk)
if f.reopenLine != "" && !f.isCode {
content = f.reopenLine + "\n" + content
}
if f.isCode {
content = utils.WrapCodeBlock(content, f.ext)
}
out, err := f.render(content)
if err != nil {
return fmt.Errorf("unable to render markdown: %w", err)
}
out = strings.Trim(out, "\n")
if out == "" {
return nil
}
if _, err := fmt.Fprintf(f.w, "%s\n\n", out); err != nil {
return fmt.Errorf("unable to write to writer: %w", err)
}
return nil
}
// flushComplete renders everything up to the last safe block boundary and
// keeps the remainder pending.
func (f *follower) flushComplete() error {
var boundary int
if f.isCode {
boundary = bytes.LastIndexByte(f.pending, '\n') + 1
} else {
boundary, _ = lastBoundary(f.pending, f.st)
}
if boundary <= 0 {
return nil
}
chunk := f.pending[:boundary]
f.pending = append([]byte(nil), f.pending[boundary:]...)
err := f.emit(chunk)
// a boundary is always outside a fence, so any reopened fence is closed
f.st = fenceState{}
f.reopenLine = ""
return err
}
// flushAll renders everything pending, including a trailing block that has no
// terminating blank line yet. If that leaves a fence open, the next chunk
// reopens it.
func (f *follower) flushAll() error {
if len(f.pending) == 0 {
return nil
}
chunk := f.pending
f.pending = nil
if !f.isCode {
_, st := lastBoundary(chunk, f.st)
err := f.emit(chunk)
f.st = st
if st.open {
f.reopenLine = st.line
} else {
f.reopenLine = ""
}
return err
}
return f.emit(chunk)
}
// renderWhole renders the file from the beginning, as on startup or after the
// file was rewritten.
func (f *follower) renderWhole() error {
raw, err := os.ReadFile(f.path)
if err != nil {
return fmt.Errorf("unable to read file: %w", err)
}
f.offset = int64(len(raw))
f.pending = utils.RemoveFrontmatter(raw)
f.st = fenceState{}
f.reopenLine = ""
return f.flushAll()
}
// printDivider separates a rewritten file's fresh render from prior output.
func (f *follower) printDivider() error {
out, err := f.render("---")
if err != nil {
return fmt.Errorf("unable to render markdown: %w", err)
}
if out = strings.Trim(out, "\n"); out == "" {
return nil
}
if _, err := fmt.Fprintf(f.w, "%s\n\n", out); err != nil {
return fmt.Errorf("unable to write to writer: %w", err)
}
return nil
}
// readNew ingests whatever the file gained since the last read. A shrunken or
// replaced file is treated like tail -f treats truncation: print a divider
// and render the whole file again.
func (f *follower) readNew() error {
fi, err := os.Stat(f.path)
if err != nil {
// the file is momentarily gone (e.g. mid atomic save); wait for it
// to reappear
return nil
}
if f.rewritten || fi.Size() < f.offset {
f.rewritten = false
if err := f.printDivider(); err != nil {
return err
}
return f.renderWhole()
}
if fi.Size() == f.offset {
return nil
}
file, err := os.Open(f.path)
if err != nil {
return fmt.Errorf("unable to read file: %w", err)
}
defer file.Close() //nolint:errcheck
if _, err := file.Seek(f.offset, io.SeekStart); err != nil {
return fmt.Errorf("unable to read file: %w", err)
}
data, err := io.ReadAll(file)
if err != nil {
return fmt.Errorf("unable to read file: %w", err)
}
f.offset += int64(len(data))
f.pending = append(f.pending, data...)
return f.flushComplete()
}
// runFollow renders path and then appends newly written content as it
// arrives, until interrupted.
func runFollow(path string, w io.Writer) error {
abs, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("unable to resolve path: %w", err)
}
isCode := !utils.IsMarkdownFile(abs)
r, err := glamour.NewTermRenderer(
glamour.WithColorProfile(lipgloss.ColorProfile()),
utils.GlamourStyle(style, isCode),
glamour.WithWordWrap(int(width)),
glamour.WithPreservedNewLines(),
)
if err != nil {
return fmt.Errorf("unable to create renderer: %w", err)
}
f := &follower{
path: abs,
w: w,
render: r.Render,
isCode: isCode,
ext: filepath.Ext(abs),
}
if err := f.renderWhole(); err != nil {
return err
}
// watch the parent directory rather than the file itself so the watch
// survives editors that save atomically via rename
watcher, err := fsnotify.NewWatcher()
if err != nil {
return fmt.Errorf("unable to watch file: %w", err)
}
defer watcher.Close() //nolint:errcheck
if err := watcher.Add(filepath.Dir(abs)); err != nil {
return fmt.Errorf("unable to watch file: %w", err)
}
debounce := time.NewTimer(followDebounce)
debounce.Stop()
idle := time.NewTimer(followIdleFlush)
idle.Stop()
for {
select {
case ev, ok := <-watcher.Events:
if !ok {
return nil
}
if filepath.Clean(ev.Name) != abs {
continue
}
if ev.Op&fsnotify.Create != 0 {
f.rewritten = true
}
if ev.Op&(fsnotify.Write|fsnotify.Create) != 0 {
debounce.Reset(followDebounce)
}
case err, ok := <-watcher.Errors:
if !ok {
return nil
}
return fmt.Errorf("unable to watch file: %w", err)
case <-debounce.C:
if err := f.readNew(); err != nil {
return err
}
if len(f.pending) > 0 {
idle.Reset(followIdleFlush)
} else {
idle.Stop()
}
case <-idle.C:
if err := f.flushAll(); err != nil {
return err
}
}
}
}

155
follow_test.go Normal file
View file

@ -0,0 +1,155 @@
package main
import (
"bytes"
"strings"
"testing"
)
func TestLastBoundary(t *testing.T) {
tests := []struct {
name string
data string
boundary int
open bool
}{
{
name: "no blank line",
data: "a paragraph\nstill going",
boundary: 0,
},
{
name: "blank line ends paragraph",
data: "para\n\nmore",
boundary: len("para\n\n"),
},
{
name: "blank line inside fence is not a boundary",
data: "```\ncode\n\nmore code\n",
boundary: 0,
open: true,
},
{
name: "boundary after closed fence",
data: "```go\ncode\n```\n\ntail",
boundary: len("```go\ncode\n```\n\n"),
},
{
name: "short closing fence does not close",
data: "````\n```\n\n",
boundary: 0,
open: true,
},
{
name: "tilde fence ignores backticks",
data: "~~~\n```\n\n~~~\n\n",
boundary: len("~~~\n```\n\n~~~\n\n"),
},
{
name: "indented fence marker is code not fence",
data: " ```\n\nafter",
boundary: len(" ```\n\n"),
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
boundary, st := lastBoundary([]byte(tc.data), fenceState{})
if boundary != tc.boundary {
t.Errorf("boundary = %d, want %d", boundary, tc.boundary)
}
if st.open != tc.open {
t.Errorf("fence open = %v, want %v", st.open, tc.open)
}
})
}
}
// newTestFollower returns a follower whose renderer is the identity function,
// so output inspection sees the exact markdown each chunk rendered.
func newTestFollower(isCode bool) (*follower, *bytes.Buffer) {
var buf bytes.Buffer
return &follower{
w: &buf,
render: func(s string) (string, error) { return s, nil },
isCode: isCode,
ext: ".txt",
}, &buf
}
func TestFlushCompleteHoldsPartialBlock(t *testing.T) {
f, buf := newTestFollower(false)
f.pending = []byte("done\n\npartial")
if err := f.flushComplete(); err != nil {
t.Fatal(err)
}
if got := buf.String(); got != "done\n\n" {
t.Errorf("output = %q, want %q", got, "done\n\n")
}
if got := string(f.pending); got != "partial" {
t.Errorf("pending = %q, want %q", got, "partial")
}
}
func TestFlushCompleteWaitsForFenceClose(t *testing.T) {
f, buf := newTestFollower(false)
f.pending = []byte("```\ncode\n\n")
if err := f.flushComplete(); err != nil {
t.Fatal(err)
}
if buf.Len() != 0 {
t.Errorf("output = %q, want empty while fence is open", buf.String())
}
f.pending = append(f.pending, []byte("```\n\n")...)
if err := f.flushComplete(); err != nil {
t.Fatal(err)
}
if got := buf.String(); !strings.Contains(got, "code") {
t.Errorf("output = %q, want the closed fence rendered", got)
}
}
func TestFlushAllReopensFence(t *testing.T) {
f, buf := newTestFollower(false)
f.pending = []byte("```go\nfirst half\n")
if err := f.flushAll(); err != nil {
t.Fatal(err)
}
if got := buf.String(); !strings.Contains(got, "first half") {
t.Errorf("output = %q, want forced flush of open fence", got)
}
if f.reopenLine != "```go" {
t.Errorf("reopenLine = %q, want %q", f.reopenLine, "```go")
}
buf.Reset()
f.pending = []byte("second half\n```\n\n")
if err := f.flushComplete(); err != nil {
t.Fatal(err)
}
got := buf.String()
if !strings.HasPrefix(got, "```go\n") {
t.Errorf("output = %q, want chunk prefixed with reopened fence", got)
}
if f.reopenLine != "" || f.st.open {
t.Errorf("fence state not cleared after close: reopen=%q open=%v", f.reopenLine, f.st.open)
}
}
func TestFlushCompletePlainTextFlushesWholeLines(t *testing.T) {
f, buf := newTestFollower(true)
f.pending = []byte("one\ntwo\npart")
if err := f.flushComplete(); err != nil {
t.Fatal(err)
}
got := buf.String()
if !strings.Contains(got, "one\ntwo") {
t.Errorf("output = %q, want complete lines flushed", got)
}
if strings.Contains(got, "part") {
t.Errorf("output = %q, must not contain the partial line", got)
}
if got := string(f.pending); got != "part" {
t.Errorf("pending = %q, want %q", got, "part")
}
}

32
main.go
View file

@ -44,6 +44,7 @@ var (
showLineNumbers bool
preserveNewLines bool
mouse bool
follow bool
rootCmd = &cobra.Command{
Use: "glow [SOURCE|DIR]",
@ -222,6 +223,10 @@ func stdinIsPipe() (bool, error) {
}
func execute(cmd *cobra.Command, args []string) error {
if follow {
return executeFollow(cmd, args)
}
// if stdin is a pipe then use stdin for input. note that you can also
// explicitly use a - to read from stdin.
if yes, err := stdinIsPipe(); err != nil {
@ -262,6 +267,32 @@ func execute(cmd *cobra.Command, args []string) error {
return nil
}
func executeFollow(cmd *cobra.Command, args []string) error {
if pager || cmd.Flags().Changed("pager") || tui || cmd.Flags().Changed("tui") {
return errors.New("--follow cannot be combined with --pager or --tui")
}
if yes, err := stdinIsPipe(); err != nil {
return err
} else if yes {
return errors.New("--follow requires a local file, not stdin")
}
if len(args) != 1 {
return errors.New("--follow requires exactly one local file")
}
if isURL(args[0]) {
return errors.New("--follow requires a local file, not a URL")
}
path := utils.ExpandPath(args[0])
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("unable to follow %s: %w", args[0], err)
}
if info.IsDir() {
return fmt.Errorf("unable to follow %s: it is a directory", args[0])
}
return runFollow(path, os.Stdout)
}
func executeArg(cmd *cobra.Command, arg string, w io.Writer) error {
// create an io.Reader from the markdown source in cli-args
src, err := sourceFromArg(arg)
@ -407,6 +438,7 @@ func init() {
rootCmd.Flags().BoolVarP(&showAllFiles, "all", "a", false, "show system files and directories (TUI-mode only)")
rootCmd.Flags().BoolVarP(&showLineNumbers, "line-numbers", "l", false, "show line numbers (TUI-mode only)")
rootCmd.Flags().BoolVarP(&preserveNewLines, "preserve-new-lines", "n", false, "preserve newlines in the output")
rootCmd.Flags().BoolVarP(&follow, "follow", "f", false, "render new content appended to a file as it grows (like tail -f)")
rootCmd.Flags().BoolVarP(&mouse, "mouse", "m", false, "enable mouse wheel (TUI-mode only)")
_ = rootCmd.Flags().MarkHidden("mouse")

67
plans/follow-mode.md Normal file
View file

@ -0,0 +1,67 @@
# Plan: `-f` / `--follow` mode
`tail -f` for markdown: append-only output to stdout, no repainting. Scrollback
stays intact and output works when piped.
## Scope
- Only valid for a single local file argument — error out for stdin, URLs, and
directories (`cannot follow non-file sources`).
- `-f` is a free short flag (taken: `-p -t -s -w -a -l -n -m`).
## Design
### Core loop
- fsnotify watches the file's **parent directory** (survives atomic renames
from editors) and filters events for the target filename.
- Keep a read offset. On write events, read from offset to EOF and append the
new bytes to an input buffer. Nothing is printed yet.
- Debounce write bursts (~100ms) since saves often produce multiple events.
### Chunker (flush only at safe boundaries)
Markdown can't be rendered mid-block, so the buffer is only flushed at safe
boundaries. State to track is small:
- **Fenced code blocks:** track opening ``` / ~~~ (fence char and length, per
CommonMark rules) and hold everything until the matching closing fence.
- **Outside a fence:** a blank line is the natural terminator — it ends a
paragraph, list, or table.
A flushable chunk = everything up to the last blank line that isn't inside an
open fence. Render the chunk with glamour, print it, keep the remainder
buffered. Trim glamour's leading/trailing blank-line padding so consecutive
chunks read as one continuous document.
### Startup
Render the existing file content as the first chunk, then start following —
matching `tail -f` showing the tail before waiting.
### Truncation / rewrite
If file size < offset, the file was truncated or rewritten (e.g., an in-place
edit + save). Do what `tail -f` does: print a visible divider, re-render the
whole file below it, reset the offset. Scrolls, never repaints.
## Caveats (documented behavior)
1. **Append semantics, not edit semantics.** Ideal for files being appended to
(build logs, LLM output streaming, accumulated notes). Mid-file edits fall
back to the truncation path above.
2. **Trailing partial block.** A paragraph not yet followed by a blank line is
ambiguous — the next write might continue it, and we can't unprint.
Decision: flush complete blocks eagerly; a trailing unterminated block is
flushed after an 8-second idle timeout. If the idle flush splits an open
code fence, the opening fence line is re-emitted with the next chunk so
the remainder still renders as code.
3. **Cross-chunk features degrade slightly.** Reference-style links defined in
a later chunk and setext headings won't resolve across chunk boundaries,
since each chunk renders independently. Rare in appended markdown; worth a
line in the docs.
## Pipeline summary
fsnotify → offset reader → fence-aware blank-line chunker → per-chunk glamour
render → append to stdout, with truncation handled by divider-plus-rerender.

47
summaries/follow-mode.md Normal file
View file

@ -0,0 +1,47 @@
# Summary: `-f` / `--follow` mode
Implemented `tail -f` for markdown (plan: `plans/follow-mode.md`): `glow -f
file.md` renders the file, then appends newly written content to stdout as the
file grows. Output is append-only — no repainting — so scrollback survives and
piping works.
## Changes
- **`follow.go`** (new) — the feature:
- fsnotify watches the file's **parent directory**, so atomic-rename saves
from editors don't kill the watch; events are debounced 100ms.
- An offset reader pulls only appended bytes into a pending buffer.
- A chunker flushes at the last blank line outside a fenced code block, with
CommonMark-correct fence tracking (backtick/tilde, fence length, ≤3-space
indent, no backticks in a backtick fence's info string).
- A trailing block with no terminating blank line flushes after an **8s idle
timeout**. If that splits an open code fence, the opening fence line is
re-emitted with the next chunk so the remainder still renders as code
(goldmark treats the unclosed fence as code-to-EOF).
- A truncated or rewritten file (in-place edit, atomic save) gets a rendered
`---` divider and a full re-render below prior output.
- Non-markdown files render per complete line, wrapped as code blocks.
- **`main.go`** — registers `-f`/`--follow`; validates: exactly one local
file, with clear errors for stdin, URLs, directories, and combining with
`--pager`/`--tui`.
- **`follow_test.go`** (new) — unit tests for boundary detection (fences,
short closing fence, tilde fences, indented pseudo-fences) and the flush
paths (partial block held, fence held open, fence reopen after idle flush,
plain-text line flushing).
## Verification
- `go build`, `go vet`, `go test ./...` all pass.
- `golangci-lint run`: zero issues in the new code (remaining findings are
pre-existing in `ui/` and untouched parts of `main.go`).
- End-to-end script drove the built binary while appending to a watched file:
partial paragraph stayed hidden until its blank line; a fence with an
internal blank line held until closed; a rewrite produced divider + fresh
render; a trailing unterminated paragraph appeared only after the 8s idle
flush; all validation errors read correctly.
## Known limitations (documented in the plan)
- Append semantics: mid-file edits fall back to divider + full re-render.
- Reference-style links and setext headings don't resolve across chunk
boundaries.