Skip to content
Open
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
6 changes: 3 additions & 3 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,9 @@ func main() {
if *jsonMode {
json.NewEncoder(os.Stdout).Encode(project)
} else if *skylineMode {
render.Skyline(project, *animateMode)
render.Skyline(os.Stdout, project, *animateMode)
} else {
render.Tree(project)
render.Tree(os.Stdout, project)
}
}

Expand Down Expand Up @@ -255,7 +255,7 @@ func runDepsMode(absRoot, root string, jsonMode bool, diffRef string, changedFil
if jsonMode {
json.NewEncoder(os.Stdout).Encode(depsProject)
} else {
render.Depgraph(depsProject)
render.Depgraph(os.Stdout, depsProject)
}
}

Expand Down
34 changes: 9 additions & 25 deletions mcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,9 @@ func handleGetStructure(ctx context.Context, req *mcp.CallToolRequest, input Pat
Files: files,
}

output := captureOutput(func() {
render.Tree(project)
})
var buf bytes.Buffer
render.Tree(&buf, project)
output := stripANSI(buf.String())

// Add hub file summary
fg, err := scanner.BuildFileGraph(input.Path)
Expand Down Expand Up @@ -229,9 +229,9 @@ func handleGetDependencies(ctx context.Context, req *mcp.CallToolRequest, input
ExternalDeps: scanner.ReadExternalDeps(absRoot),
}

output := captureOutput(func() {
render.Depgraph(depsProject)
})
var buf bytes.Buffer
render.Depgraph(&buf, depsProject)
output := buf.String()

return textResult(output), nil, nil
}
Expand Down Expand Up @@ -273,9 +273,9 @@ func handleGetDiff(ctx context.Context, req *mcp.CallToolRequest, input DiffInpu
Impact: impact,
}

output := captureOutput(func() {
render.Tree(project)
})
var buf bytes.Buffer
render.Tree(&buf, project)
output := stripANSI(buf.String())

return textResult(output), nil, nil
}
Expand Down Expand Up @@ -472,22 +472,6 @@ func stripANSI(s string) string {
return ansiRegex.ReplaceAllString(s, "")
}

// captureOutput captures stdout from a function and strips ANSI codes
func captureOutput(f func()) string {
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w

f()

w.Close()
os.Stdout = old

var buf bytes.Buffer
buf.ReadFrom(r)
return stripANSI(buf.String())
}

// === WATCH HANDLERS ===

func handleStartWatch(ctx context.Context, req *mcp.CallToolRequest, input WatchInput) (*mcp.CallToolResult, any, error) {
Expand Down
47 changes: 24 additions & 23 deletions render/depgraph.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package render

import (
"fmt"
"io"
"path/filepath"
"regexp"
"sort"
Expand Down Expand Up @@ -48,13 +49,13 @@ func getSystemName(dirPath string) string {
}

// Depgraph renders the dependency flow visualization
func Depgraph(project scanner.DepsProject) {
func Depgraph(w io.Writer, project scanner.DepsProject) {
files := project.Files
externalDeps := project.ExternalDeps
projectName := filepath.Base(project.Root)

if len(files) == 0 {
fmt.Println(" No source files found.")
fmt.Fprintln(w, " No source files found.")
return
}

Expand Down Expand Up @@ -127,7 +128,7 @@ func Depgraph(project scanner.DepsProject) {
systems[system] = append(systems[system], f)
}

fmt.Println()
fmt.Fprintln(w)

// Build external deps by language
extByLang := make(map[string][]string)
Expand Down Expand Up @@ -184,12 +185,12 @@ func Depgraph(project scanner.DepsProject) {
innerWidth := maxWidth - 2

// Print header box
fmt.Printf("╭%s╮\n", strings.Repeat("─", innerWidth))
fmt.Fprintf(w, "╭%s╮\n", strings.Repeat("─", innerWidth))
titlePadded := CenterString(title, innerWidth)
fmt.Printf("│%s│\n", titlePadded)
fmt.Fprintf(w, "│%s│\n", titlePadded)

if len(depLines) > 0 {
fmt.Printf("├%s┤\n", strings.Repeat("─", innerWidth))
fmt.Fprintf(w, "├%s┤\n", strings.Repeat("─", innerWidth))
contentWidth := innerWidth - 2

for _, line := range depLines {
Expand All @@ -200,15 +201,15 @@ func Depgraph(project scanner.DepsProject) {
} else {
breakAt++
}
fmt.Printf("│ %-*s │\n", contentWidth, line[:breakAt])
fmt.Fprintf(w, "│ %-*s │\n", contentWidth, line[:breakAt])
line = " " + strings.TrimLeft(line[breakAt:], " ")
}
fmt.Printf("│ %-*s │\n", contentWidth, line)
fmt.Fprintf(w, "│ %-*s │\n", contentWidth, line)
}
}

fmt.Printf("╰%s╯\n", strings.Repeat("─", innerWidth))
fmt.Println()
fmt.Fprintf(w, "╰%s╯\n", strings.Repeat("─", innerWidth))
fmt.Fprintln(w)

// Sort systems
var systemNames []string
Expand Down Expand Up @@ -239,7 +240,7 @@ func Depgraph(project scanner.DepsProject) {
if headerLen < 1 {
headerLen = 1
}
fmt.Printf("%s %s\n", systemName, strings.Repeat("═", headerLen))
fmt.Fprintf(w, "%s %s\n", systemName, strings.Repeat("═", headerLen))

rendered := make(map[string]bool)

Expand Down Expand Up @@ -282,9 +283,9 @@ func Depgraph(project scanner.DepsProject) {
if len(subTargets) > 3 {
chain += fmt.Sprintf(" +%d", len(subTargets)-3)
}
fmt.Printf(" %s\n", chain)
fmt.Fprintf(w, " %s\n", chain)
} else {
fmt.Printf(" %s ───▶ %s\n", nameNoExt, tName)
fmt.Fprintf(w, " %s ───▶ %s\n", nameNoExt, tName)
}
} else {
var targetStrs []string
Expand All @@ -293,13 +294,13 @@ func Depgraph(project scanner.DepsProject) {
}

if len(targets) <= 4 {
fmt.Printf(" %s ───▶ %s\n", nameNoExt, strings.Join(targetStrs, ", "))
fmt.Fprintf(w, " %s ───▶ %s\n", nameNoExt, strings.Join(targetStrs, ", "))
} else {
fmt.Printf(" %s ──┬──▶ %s\n", nameNoExt, targetStrs[0])
fmt.Fprintf(w, " %s ──┬──▶ %s\n", nameNoExt, targetStrs[0])
for _, t := range targetStrs[1 : len(targetStrs)-1] {
fmt.Printf(" %s ├──▶ %s\n", strings.Repeat(" ", len(nameNoExt)), t)
fmt.Fprintf(w, " %s ├──▶ %s\n", strings.Repeat(" ", len(nameNoExt)), t)
}
fmt.Printf(" %s └──▶ %s\n", strings.Repeat(" ", len(nameNoExt)), targetStrs[len(targetStrs)-1])
fmt.Fprintf(w, " %s └──▶ %s\n", strings.Repeat(" ", len(nameNoExt)), targetStrs[len(targetStrs)-1])
}
}

Expand All @@ -316,10 +317,10 @@ func Depgraph(project scanner.DepsProject) {
}

if standaloneCount > 0 {
fmt.Printf(" +%d standalone files\n", standaloneCount)
fmt.Fprintf(w, " +%d standalone files\n", standaloneCount)
}

fmt.Println()
fmt.Fprintln(w)
}

// HUBS section
Expand All @@ -342,12 +343,12 @@ func Depgraph(project scanner.DepsProject) {
}

if len(hubs) > 0 {
fmt.Println(strings.Repeat("─", 61))
fmt.Fprintln(w, strings.Repeat("─", 61))
var hubStrs []string
for _, h := range hubs {
hubStrs = append(hubStrs, fmt.Sprintf("%s (%d←)", extPattern.ReplaceAllString(h.name, ""), h.count))
}
fmt.Printf("HUBS: %s\n", strings.Join(hubStrs, ", "))
fmt.Fprintf(w, "HUBS: %s\n", strings.Join(hubStrs, ", "))
}
}

Expand All @@ -360,6 +361,6 @@ func Depgraph(project scanner.DepsProject) {
for _, targets := range internalDeps {
internalCount += len(targets)
}
fmt.Printf("%d files · %d functions · %d deps\n", len(files), totalFuncs, internalCount)
fmt.Println()
fmt.Fprintf(w, "%d files · %d functions · %d deps\n", len(files), totalFuncs, internalCount)
fmt.Fprintln(w)
}
54 changes: 28 additions & 26 deletions render/skyline.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package render

import (
"fmt"
"io"
"math"
"math/rand/v2"
"os"
Expand Down Expand Up @@ -206,8 +207,8 @@ func createBuildings(sorted []extAgg, width int) []building {
return arranged
}

// Skyline renders the city skyline visualization
func Skyline(project scanner.Project, animate bool) {
// Skyline renders the city skyline visualization to the given writer
func Skyline(w io.Writer, project scanner.Project, animate bool) {
files := project.Files
projectName := filepath.Base(project.Root)

Expand All @@ -221,7 +222,7 @@ func Skyline(project scanner.Project, animate bool) {
arranged := createBuildings(sorted, width)

if len(arranged) == 0 {
fmt.Println(Dim + "No source files to display" + Reset)
fmt.Fprintln(w, Dim+"No source files to display"+Reset)
return
}

Expand All @@ -236,15 +237,16 @@ func Skyline(project scanner.Project, animate bool) {
sceneRight := min(width, leftMargin+totalWidth+scenePadding)
sceneWidth := sceneRight - sceneLeft

if animate {
renderAnimated(arranged, width, leftMargin, sceneLeft, sceneRight, sceneWidth, codeFiles, projectName, sorted)
// If writer is not os.Stdout, disable animation
if animate && w == os.Stdout {
renderAnimated(w, arranged, width, leftMargin, sceneLeft, sceneRight, sceneWidth, codeFiles, projectName, sorted)
} else {
renderStatic(arranged, width, leftMargin, sceneLeft, sceneRight, sceneWidth, codeFiles, projectName, sorted)
renderStatic(w, arranged, width, leftMargin, sceneLeft, sceneRight, sceneWidth, codeFiles, projectName, sorted)
}
}

// renderStatic renders static skyline
func renderStatic(arranged []building, width, leftMargin, sceneLeft, sceneRight, sceneWidth int,
// renderStatic renders static skyline to the given writer
func renderStatic(w io.Writer, arranged []building, width, leftMargin, sceneLeft, sceneRight, sceneWidth int,
codeFiles []scanner.FileInfo, projectName string, sorted []extAgg) {
// Build grid
grid := make([][]rune, skyHeight+maxHeight+1)
Expand Down Expand Up @@ -310,7 +312,7 @@ func renderStatic(arranged []building, width, leftMargin, sceneLeft, sceneRight,
col += buildingWidth + b.gap
}

fmt.Println()
fmt.Fprintln(w)

// Print with colors
colPositions := make([][3]interface{}, 0) // start, end, color
Expand All @@ -326,22 +328,22 @@ func renderStatic(arranged []building, width, leftMargin, sceneLeft, sceneRight,
ch := grid[row][c]
switch ch {
case '◐':
fmt.Print(Bold + Yellow + string(ch) + Reset)
fmt.Fprintf(w, "%s%s%s", Bold, Yellow, string(ch), Reset)
case '·', '✦', '*':
fmt.Print(DimWhite + string(ch) + Reset)
fmt.Fprintf(w, "%s%s%s", DimWhite, string(ch), Reset)
default:
fmt.Print(" ")
fmt.Fprint(w, " ")
}
}
fmt.Println()
fmt.Fprintln(w)
}

// Building rows
for row := skyHeight; row < len(grid); row++ {
for c := 0; c < width; c++ {
ch := grid[row][c]
if ch == ' ' {
fmt.Print(" ")
fmt.Fprint(w, " ")
} else if ch == '▄' {
color := White
for _, pos := range colPositions {
Expand All @@ -350,11 +352,11 @@ func renderStatic(arranged []building, width, leftMargin, sceneLeft, sceneRight,
break
}
}
fmt.Print(color + string(ch) + Reset)
fmt.Fprintf(w, "%s%s%s", color, string(ch), Reset)
} else if ch == '.' || (ch >= 'a' && ch <= 'z') {
fmt.Print(DimWhite + string(ch) + Reset)
fmt.Fprintf(w, "%s%s%s", DimWhite, string(ch), Reset)
} else if (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' {
fmt.Print(BoldWhite + string(ch) + Reset)
fmt.Fprintf(w, "%s%s%s", BoldWhite, string(ch), Reset)
} else {
color := White
for _, pos := range colPositions {
Expand All @@ -363,28 +365,28 @@ func renderStatic(arranged []building, width, leftMargin, sceneLeft, sceneRight,
break
}
}
fmt.Print(color + string(ch) + Reset)
fmt.Fprintf(w, "%s%s%s", color, string(ch), Reset)
}
}
fmt.Println()
fmt.Fprintln(w)
}

// Ground
ground := strings.Repeat(" ", max(0, sceneLeft)) + strings.Repeat("▀", sceneWidth)
fmt.Println(DimWhite + ground + Reset)
fmt.Fprintln(w, DimWhite+ground+Reset)

// Stats
fmt.Println()
fmt.Fprintln(w)
title := fmt.Sprintf("─── %s ───", projectName)
fmt.Printf("%s%s%s\n", BoldWhite, CenterString(title, width), Reset)
fmt.Fprintf(w, "%s%s%s\n", BoldWhite, CenterString(title, width), Reset)

var codeSize int64
for _, f := range codeFiles {
codeSize += f.Size
}
stats := fmt.Sprintf("%d languages · %d files · %s", len(sorted), len(codeFiles), formatSize(codeSize))
fmt.Printf("%s%s%s\n", Cyan, CenterString(stats, width), Reset)
fmt.Println()
fmt.Fprintf(w, "%s%s%s\n", Cyan, CenterString(stats, width), Reset)
fmt.Fprintln(w)
}

// animationModel holds state for bubbletea animation
Expand Down Expand Up @@ -596,7 +598,7 @@ func (m animationModel) View() string {
}

// renderAnimated renders animated skyline using bubbletea
func renderAnimated(arranged []building, width, leftMargin, sceneLeft, sceneRight, sceneWidth int,
func renderAnimated(w io.Writer, arranged []building, width, leftMargin, sceneLeft, sceneRight, sceneWidth int,
codeFiles []scanner.FileInfo, projectName string, sorted []extAgg) {
// Generate star positions
var starPositions [][2]int
Expand Down Expand Up @@ -638,7 +640,7 @@ func renderAnimated(arranged []building, width, leftMargin, sceneLeft, sceneRigh
p.Run()

// After animation, print static final frame to main screen
renderStatic(arranged, width, leftMargin, sceneLeft, sceneRight, sceneWidth, codeFiles, projectName, sorted)
renderStatic(w, arranged, width, leftMargin, sceneLeft, sceneRight, sceneWidth, codeFiles, projectName, sorted)
}

func max(a, b int) int {
Expand Down
Loading