mirror of
https://github.com/charmbracelet/glow.git
synced 2026-08-22 08:04:18 +02:00
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:
parent
53788271b3
commit
2ee83916f0
5 changed files with 647 additions and 0 deletions
346
follow.go
Normal file
346
follow.go
Normal 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
155
follow_test.go
Normal 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
32
main.go
|
|
@ -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
67
plans/follow-mode.md
Normal 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
47
summaries/follow-mode.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue