processor/history_render.go GO 225 lines View on github.com → Search inside
1// SPDX-License-Identifier: MIT23package processor45import (6	"os"7	"strings"8	"time"910	"github.com/mattn/go-isatty"11)1213// historyDateLayout is the canonical date-only format used in window14// headers and metadata. Matches the spec example "2024-01-09 → 2026-05-20".15const historyDateLayout = "2006-01-02"1617// historyHeader renders the centred two-line "<break> <name> · last N18// commits · from → to <break>" block that every tabular report uses.19func historyHeader(reportName string, w HistoryWindow, wide bool) string {20	break_ := tabularBreakFor(wide)21	var sb strings.Builder22	sb.WriteString(break_)23	sb.WriteString(formatHeaderLine(reportName, w))24	sb.WriteByte('\n')25	sb.WriteString(break_)26	return sb.String()27}2829func formatHeaderLine(reportName string, w HistoryWindow) string {30	if w.Commits == 0 {31		return reportName + " · no commits"32	}33	from := w.From.UTC().Format(historyDateLayout)34	to := w.To.UTC().Format(historyDateLayout)35	return reportName + " · last " + itoa(w.Commits) + " commits · " + from + " → " + to36}3738// tabularBreakFor returns the 79- or 109-column break the existing renderers39// produce, honouring --no-hborder and --ci. Centralised here so every40// history renderer agrees with the language tables.41func tabularBreakFor(wide bool) string {42	if wide {43		return getTabularWideBreak()44	}45	return getTabularShortBreak()46}4748// renderBar returns a unicode bar of the given cell width filled to ratio.49// Falls back to ASCII '#' when --ci is on or output is not a TTY (CSV-safe50// callers should not use this helper).51func renderBar(ratio float64, width int) string {52	if width <= 0 {53		return ""54	}55	if ratio < 0 {56		ratio = 057	}58	if ratio > 1 {59		ratio = 160	}6162	if asciiOutput() {63		filled := int(ratio*float64(width) + 0.5)64		return strings.Repeat("#", filled) + strings.Repeat(" ", width-filled)65	}6667	// 8 sub-cell levels using the Block Elements range U+2581..U+2588.68	const blocks = "▏▎▍▌▋▊▉█"69	total := ratio * float64(width)70	full := int(total)71	remainder := total - float64(full)72	var sb strings.Builder73	for i := 0; i < full; i++ {74		sb.WriteRune('█')75	}76	if full < width {77		idx := int(remainder * 8)78		if idx > 0 {79			runes := []rune(blocks)80			sb.WriteRune(runes[idx-1])81			full++82		}83	}84	for i := full; i < width; i++ {85		sb.WriteRune(' ')86	}87	return sb.String()88}8990// renderSparkline downsamples series to the given cell width and renders it91// with U+2581..U+2587 spark characters (or ASCII when --ci / no TTY). The92// full block U+2588 is intentionally excluded so peak cells keep a 1-pixel93// gap at the top — without it, adjacent tall cells merge into a solid wall94// when trajectories rise monotonically.95// Used by plans 04–05; placed here so the helpers travel together.96func renderSparkline(series []float64, width int) string {97	if width <= 0 || len(series) == 0 {98		return ""99	}100	buckets := downsampleSeries(series, width)101	mx := 0.0102	for _, v := range buckets {103		if v > mx {104			mx = v105		}106	}107	if mx == 0 {108		if asciiOutput() {109			return strings.Repeat(".", width)110		}111		return strings.Repeat("▁", width)112	}113114	if asciiOutput() {115		const ramp = " .:-=+*#%@"116		var sb strings.Builder117		for _, v := range buckets {118			idx := int(v / mx * float64(len(ramp)-1))119			sb.WriteByte(ramp[idx])120		}121		return sb.String()122	}123124	const ticks = "▁▂▃▄▅▆▇"125	runes := []rune(ticks)126	var sb strings.Builder127	for _, v := range buckets {128		idx := int(v / mx * float64(len(runes)-1))129		sb.WriteRune(runes[idx])130	}131	return sb.String()132}133134// downsampleSeries averages contiguous chunks of series into n buckets. If135// len(series) <= n it pads to n with the trailing value.136func downsampleSeries(series []float64, n int) []float64 {137	if n <= 0 {138		return nil139	}140	out := make([]float64, n)141	if len(series) == 0 {142		return out143	}144	if len(series) <= n {145		copy(out, series)146		for i := len(series); i < n; i++ {147			out[i] = series[len(series)-1]148		}149		return out150	}151	step := float64(len(series)) / float64(n)152	for i := range n {153		lo := int(float64(i) * step)154		hi := min(int(float64(i+1)*step), len(series))155		if hi <= lo {156			hi = lo + 1157		}158		sum := 0.0159		for j := lo; j < hi; j++ {160			sum += series[j]161		}162		out[i] = sum / float64(hi-lo)163	}164	return out165}166167// asciiOutput returns true when callers should avoid box/block glyphs168// (CI mode or non-TTY stdout).169func asciiOutput() bool {170	if Ci {171		return true172	}173	if FileOutput != "" {174		return true175	}176	return !isatty.IsTerminal(os.Stdout.Fd()) && !isatty.IsCygwinTerminal(os.Stdout.Fd())177}178179// itoa is a tiny strconv.Itoa wrapper kept local so this file doesn't pull180// strconv just for one call site.181func itoa(n int) string {182	if n == 0 {183		return "0"184	}185	neg := false186	if n < 0 {187		neg = true188		n = -n189	}190	buf := make([]byte, 0, 12)191	for n > 0 {192		buf = append(buf, byte('0'+n%10))193		n /= 10194	}195	if neg {196		buf = append(buf, '-')197	}198	for i, j := 0, len(buf)-1; i < j; i, j = i+1, j-1 {199		buf[i], buf[j] = buf[j], buf[i]200	}201	return string(buf)202}203204// formatWindowComment builds the "# window: depth=… commits=… from=… to=…"205// comment line used by CSV outputs.206func formatWindowComment(w HistoryWindow) string {207	var depth string208	if w.Depth == 0 {209		depth = "all"210	} else {211		depth = itoa(w.Depth)212	}213	return "# window: depth=" + depth +214		" commits=" + itoa(w.Commits) +215		" from=" + formatWindowDate(w.From) +216		" to=" + formatWindowDate(w.To)217}218219func formatWindowDate(t time.Time) string {220	if t.IsZero() {221		return ""222	}223	return t.UTC().Format(historyDateLayout)224}

Findings

✓ No findings reported for this file.

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.