Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Sidecar is a live, scrollable Markdown viewer for a narrow terminal pane. It’s
```
sidecar [file.md] # default: ./SIDECAR.md
sidecar init [file.md] # create the file, and optionally keep it out of git
sidecar --no-flash [file] disable the subtle change-flash (▸ still shows)
```

## Why two panes
Expand Down Expand Up @@ -33,6 +34,9 @@ It then offers to add a note to `CLAUDE.md`, and optionally a per-turn `UserProm
- Scrolls with `j` and `k`, the arrow keys, `PgUp` and `PgDn`, and `g` and `G` for top and bottom. Sidecar keeps your scroll position across reloads. It doesn’t capture the mouse, so your terminal’s text selection and clickable links keep working.
- Reloads on demand with `r`, and quits with `q`.
- Re-renders when you resize the terminal, at the pane width minus 2. It never renders wider than the pane.
- Points at what changed: on reload, a changed bullet's `•` becomes a bright
`▸` (until the next change), and changed lines get a brief, subtle background
flash. Disable the flash with `--no-flash`.
- Shows a thin status bar: the filename, the time since the last update, and the scroll percentage.

## Rendering style
Expand Down
168 changes: 168 additions & 0 deletions diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// diff.go
package main

import (
"fmt"
"regexp"
"strings"
)

var ansiRE = regexp.MustCompile(`\x1b\[[0-9;]*m`)

// stripANSI removes SGR escape sequences, leaving the visible text.
func stripANSI(s string) string {
return ansiRE.ReplaceAllString(s, "")
}

// changedLines returns the indices into newLines that are new or modified
// relative to oldLines — the lines not covered by the longest common
// subsequence of the two, compared on ANSI-stripped visible text. An inserted
// line marks only itself, not the identical lines shifted below it.
func changedLines(oldLines, newLines []string) map[int]bool {
o := make([]string, len(oldLines))
for i, l := range oldLines {
o[i] = stripANSI(l)
}
n := make([]string, len(newLines))
for i, l := range newLines {
n[i] = stripANSI(l)
}

changed := map[int]bool{}

// Trim the common prefix and suffix — unchanged lines need no diffing and
// keep the DP table proportional to the edited region, not the whole file.
start := 0
for start < len(o) && start < len(n) && o[start] == n[start] {
start++
}
endO, endN := len(o), len(n)
for endO > start && endN > start && o[endO-1] == n[endN-1] {
endO--
endN--
}
om, nm := o[start:endO], n[start:endN]

// Guard against a pathological table on very large files: past this many
// cells, skip line marking entirely rather than allocate hundreds of MB
// per render. Real queues are far smaller; this only trips on huge docs.
const maxDiffCells = 1 << 20 // ~4 MB with int32 cells; ~1000 lines a side
if len(om)*len(nm) > maxDiffCells {
return changed // empty — no markers, but the render still happens
}

// LCS length table over the differing middle. int32 halves the per-cell
// footprint vs int — LCS lengths are bounded by the line count, far under
// int32 max.
lcs := make([][]int32, len(om)+1)
for i := range lcs {
lcs[i] = make([]int32, len(nm)+1)
}
for i := len(om) - 1; i >= 0; i-- {
for j := len(nm) - 1; j >= 0; j-- {
if om[i] == nm[j] {
lcs[i][j] = lcs[i+1][j+1] + 1
} else if lcs[i+1][j] >= lcs[i][j+1] {
lcs[i][j] = lcs[i+1][j]
} else {
lcs[i][j] = lcs[i][j+1]
}
}
}

// Walk; lines in the middle of nm not on the common subsequence are changed.
i, j := 0, 0
for j < len(nm) {
if i < len(om) && om[i] == nm[j] {
i++
j++
} else if i < len(om) && lcs[i+1][j] >= lcs[i][j+1] {
i++
} else {
changed[start+j] = true
j++
}
}
return changed
}

// composeMarked renders the display string: changed bullet lines get their
// "• " swapped for a bright "▸ ", and (when flash is true) every changed line
// gets a subtle background tint. Order matters — the bullet is swapped first so
// applyLineBg re-establishes the background after the reset the swap introduces.
func composeMarked(lines []string, changed map[int]bool, flash bool, width int) string {
ur, ug, ub := hexToRGB(colorUpdated)
tr, tg, tb := hexToRGB(colorText)
updatedMark := fmt.Sprintf("\x1b[1;38;2;%d;%d;%dm▸ \x1b[22;38;2;%d;%d;%dm", ur, ug, ub, tr, tg, tb)

// owner[i] = index of the bullet line that owns line i (itself if it is a
// bullet; the nearest preceding bullet within the same block otherwise), or
// -1 if none. Single O(n) pass, reset at blank lines so ownership can't
// cross a block boundary.
owner := make([]int, len(lines))
cur := -1
for i, ln := range lines {
if visibleWidth(ln) == 0 {
cur = -1
} else if isBulletLine(ln) {
cur = i
}
owner[i] = cur
}

swap := map[int]bool{}
for i := range lines {
if changed[i] && owner[i] >= 0 {
swap[owner[i]] = true
}
}

out := make([]string, len(lines))
for i, ln := range lines {
if swap[i] {
ln = strings.Replace(ln, "• ", updatedMark, 1)
}
if (changed[i] || swap[i]) && flash && visibleWidth(ln) > 0 {
ln = applyLineBg(ln, colorFlashLineBg, width)
}
out[i] = ln
}
return strings.Join(out, "\n")
}

// isBulletLine reports whether the line's first visible content is glamour's
// "• " item prefix (so a "•" inside body text isn't matched).
func isBulletLine(ln string) bool {
t := strings.TrimLeft(stripANSI(ln), " ")
return strings.HasPrefix(t, "• ")
}

// applyLineBg tints the whole visible line with the given hex background,
// re-applying it after each SGR reset (a reset would otherwise clear the
// background mid-line), and pads to width so the tint spans the pane.
// It re-applies the background after each standalone ESC[0m reset; this relies
// on termenv emitting resets as a bare ESC[0m (a combined ESC[0;…m would drop
// the tint mid-line — not produced by the current renderer).
func applyLineBg(ln, hex string, width int) string {
r, g, b := hexToRGB(hex)
bg := fmt.Sprintf("\x1b[48;2;%d;%d;%dm", r, g, b)
const reset = "\x1b[0m"
body := strings.ReplaceAll(ln, reset, reset+bg)
pad := ""
if v := visibleWidth(ln); v < width {
pad = strings.Repeat(" ", width-v)
}
return bg + body + pad + reset
}

// hexToRGB parses "#RRGGBB" into its components. Input is expected to be a
// valid hex color const; on a malformed value it falls back to a visible
// mid-grey rather than silently yielding black.
func hexToRGB(hex string) (int, int, int) {
hex = strings.TrimPrefix(hex, "#")
var r, g, b int
if n, err := fmt.Sscanf(hex, "%02x%02x%02x", &r, &g, &b); n != 3 || err != nil {
return 128, 128, 128
}
return r, g, b
}
195 changes: 195 additions & 0 deletions diff_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// diff_test.go
package main

import (
"strings"
"testing"
)

func idx(m map[int]bool) []int {
out := []int{}
for i := 0; i < 100; i++ {
if m[i] {
out = append(out, i)
}
}
return out
}

func eq(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

func TestChangedLinesIdentical(t *testing.T) {
l := []string{"a", "b", "c"}
if got := idx(changedLines(l, l)); len(got) != 0 {
t.Errorf("identical → %v, want none", got)
}
}

func TestChangedLinesOneModified(t *testing.T) {
old := []string{"a", "b", "c"}
nw := []string{"a", "B", "c"}
if got := idx(changedLines(old, nw)); !eq(got, []int{1}) {
t.Errorf("modified → %v, want [1]", got)
}
}

func TestChangedLinesInsertionNoCascade(t *testing.T) {
// Inserting one line must mark ONLY the new line, not everything below.
old := []string{"a", "b", "c"}
nw := []string{"a", "NEW", "b", "c"}
if got := idx(changedLines(old, nw)); !eq(got, []int{1}) {
t.Errorf("insertion → %v, want [1] (no cascade)", got)
}
}

func TestChangedLinesIgnoresANSI(t *testing.T) {
old := []string{"\x1b[31mhello\x1b[0m"}
nw := []string{"\x1b[32mhello\x1b[0m"} // same text, different color
if got := idx(changedLines(old, nw)); len(got) != 0 {
t.Errorf("restyle-only → %v, want none", got)
}
}

func TestComposeMarkedBulletSwap(t *testing.T) {
// A changed bullet line: the literal "• " becomes a styled "▸ ".
lines := []string{"\x1b[38;2;208;208;208m• \x1b[0malpha"}
out := composeMarked(lines, map[int]bool{0: true}, false, 40)
if strings.Contains(stripANSI(out), "• ") {
t.Errorf("bullet not replaced:\n%q", out)
}
if !strings.Contains(stripANSI(out), "▸ alpha") {
t.Errorf("expected ▸ alpha, got:\n%q", stripANSI(out))
}
}

func TestComposeMarkedUnchangedLineUntouched(t *testing.T) {
lines := []string{"\x1b[38;2;208;208;208m• \x1b[0malpha"}
out := composeMarked(lines, map[int]bool{}, false, 40) // nothing changed
if out != lines[0] {
t.Errorf("unchanged line altered:\n%q", out)
}
}

func TestComposeMarkedNonBulletNoMarker(t *testing.T) {
// A changed non-bullet line gets no ▸ (bullets only), and without flash
// its text is unchanged.
lines := []string{"\x1b[38;2;209;154;102m▍ Heading\x1b[0m"}
out := composeMarked(lines, map[int]bool{0: true}, false, 40)
if strings.Contains(out, "▸") {
t.Errorf("non-bullet line should not get ▸:\n%q", out)
}
}

func TestComposeMarkedFlashAddsBackground(t *testing.T) {
lines := []string{"\x1b[38;2;208;208;208m• \x1b[0malpha"}
out := composeMarked(lines, map[int]bool{0: true}, true, 40)
if !strings.Contains(out, "\x1b[48;2;") {
t.Errorf("flash should inject a background SGR:\n%q", out)
}
// The ▸ still shows through the flash.
if !strings.Contains(stripANSI(out), "▸ alpha") {
t.Errorf("▸ missing under flash:\n%q", stripANSI(out))
}
}

func TestComposeMarkedNoFlashNoBackground(t *testing.T) {
lines := []string{"\x1b[38;2;208;208;208m• \x1b[0malpha"}
out := composeMarked(lines, map[int]bool{0: true}, false, 40)
if strings.Contains(out, "\x1b[48;2;") {
t.Errorf("no flash should not inject a background:\n%q", out)
}
}

func TestComposeMarkedWrappedContinuationMarksOwningBullet(t *testing.T) {
// A long bullet wrapped onto a continuation line; the edit landed on the
// continuation (index 1). The ▸ must appear on the bullet line (index 0).
lines := []string{
"\x1b[38;2;208;208;208m• \x1b[0malpha the first",
"\x1b[38;2;208;208;208m and its wrapped tail\x1b[0m",
}
out := composeMarked(lines, map[int]bool{1: true}, false, 40)
got := strings.Split(stripANSI(out), "\n")
if !strings.Contains(got[0], "▸ ") {
t.Errorf("owning bullet not marked:\n%q", got[0])
}
if strings.Contains(got[1], "▸") {
t.Errorf("continuation line should not itself get ▸:\n%q", got[1])
}
}

func TestComposeMarkedChangedProseAfterBlankNoMarker(t *testing.T) {
// A changed non-bullet line preceded by a blank line: the backward walk
// stops at the blank, so nothing is marked.
lines := []string{
"",
"\x1b[38;2;208;208;208mjust prose\x1b[0m",
}
out := composeMarked(lines, map[int]bool{1: true}, false, 40)
if strings.Contains(out, "▸") {
t.Errorf("prose after a blank should get no marker:\n%q", stripANSI(out))
}
}

func TestChangedLinesTrimmedContext(t *testing.T) {
old := []string{"h", "a", "b", "c", "z"}
nw := []string{"h", "a", "X", "c", "z"} // only index 2 changed
if got := idx(changedLines(old, nw)); !eq(got, []int{2}) {
t.Errorf("trimmed-context change → %v, want [2]", got)
}
}

func TestChangedLinesDeletionNoSpuriousMark(t *testing.T) {
// Deleting a line should not mark the surrounding context in the new render.
old := []string{"a", "b", "c"}
nw := []string{"a", "c"}
if got := idx(changedLines(old, nw)); len(got) != 0 {
t.Errorf("deletion → %v, want none (nothing added in new)", got)
}
}

func TestComposeMarkedNeverWiderThanWidth(t *testing.T) {
for _, w := range []int{20, 40, 80} {
lines, _ := func() ([]string, error) {
out, err := renderMarkdown("# Title\n\n- a fairly long bullet item that will wrap\n- short\n\nsome prose here too\n", w)
return strings.Split(out, "\n"), err
}()
all := map[int]bool{}
for i := range lines {
all[i] = true
}
out := composeMarked(lines, all, true, w) // flash on, everything changed
for _, ln := range strings.Split(out, "\n") {
if visibleWidth(ln) > w {
t.Errorf("width %d: composed line exceeds pane (%d):\n%q", w, visibleWidth(ln), ln)
}
}
}
}

func TestChangedLinesCapDegradesToEmpty(t *testing.T) {
// Two large, fully-different slices would blow the table; the cap must
// return an empty set instead of allocating/panicking.
n := 5000
old := make([]string, n)
nw := make([]string, n)
for i := 0; i < n; i++ {
old[i] = "old-" + string(rune('a'+i%26))
nw[i] = "new-" + string(rune('a'+i%26))
}
// Force no common prefix/suffix so the trim can't shrink it.
old[0], nw[0] = "A", "B"
old[n-1], nw[n-1] = "Y", "Z"
if got := len(changedLines(old, nw)); got != 0 {
t.Errorf("oversized diff should mark nothing, got %d", got)
}
}
Loading