feat: render interactive tui
This commit is contained in:
parent
7d04391940
commit
8e02c2c8c5
5
go.mod
5
go.mod
@ -3,17 +3,18 @@ module github.com/openclaw/gitcrawl
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/pelletier/go-toml/v2 v2.3.0
|
||||
golang.org/x/term v0.42.0
|
||||
modernc.org/sqlite v1.50.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
modernc.org/libc v1.72.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
|
||||
6
go.sum
6
go.sum
@ -19,8 +19,10 @@ golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
|
||||
|
||||
@ -456,13 +456,17 @@ func (a *App) runTUI(ctx context.Context, args []string) error {
|
||||
if clusters == nil {
|
||||
clusters = []store.ClusterSummary{}
|
||||
}
|
||||
return a.writeOutput("tui", map[string]any{
|
||||
"repository": repo.FullName,
|
||||
"inferred_repository": inferred,
|
||||
"mode": "cluster-browser",
|
||||
"sort": sort,
|
||||
"clusters": clusters,
|
||||
}, true)
|
||||
payload := clusterBrowserPayload{
|
||||
Repository: repo.FullName,
|
||||
InferredRepository: inferred,
|
||||
Mode: "cluster-browser",
|
||||
Sort: sort,
|
||||
Clusters: clusters,
|
||||
}
|
||||
if a.format != FormatText || !a.canRunInteractiveTUI() {
|
||||
return a.writeOutput("tui", payload, true)
|
||||
}
|
||||
return a.runInteractiveTUI(payload)
|
||||
}
|
||||
|
||||
func (a *App) resolveOptionalRepository(ctx context.Context, rt localRuntime, args []string) (store.Repository, bool, error) {
|
||||
|
||||
277
internal/cli/tui.go
Normal file
277
internal/cli/tui.go
Normal file
@ -0,0 +1,277 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/mattn/go-isatty"
|
||||
"github.com/openclaw/gitcrawl/internal/store"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
type clusterBrowserPayload struct {
|
||||
Repository string `json:"repository"`
|
||||
InferredRepository bool `json:"inferred_repository"`
|
||||
Mode string `json:"mode"`
|
||||
Sort string `json:"sort"`
|
||||
Clusters []store.ClusterSummary `json:"clusters"`
|
||||
}
|
||||
|
||||
type clusterBrowserModel struct {
|
||||
payload clusterBrowserPayload
|
||||
selected int
|
||||
offset int
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func (a *App) canRunInteractiveTUI() bool {
|
||||
out, ok := a.Stdout.(*os.File)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return isatty.IsTerminal(out.Fd()) && isatty.IsTerminal(os.Stdin.Fd())
|
||||
}
|
||||
|
||||
func (a *App) runInteractiveTUI(payload clusterBrowserPayload) error {
|
||||
out, ok := a.Stdout.(*os.File)
|
||||
if !ok {
|
||||
return a.writeOutput("tui", payload, true)
|
||||
}
|
||||
return runClusterBrowserTUI(os.Stdin, out, payload)
|
||||
}
|
||||
|
||||
func runClusterBrowserTUI(in *os.File, out *os.File, payload clusterBrowserPayload) error {
|
||||
oldState, err := term.MakeRaw(int(in.Fd()))
|
||||
if err != nil {
|
||||
return fmt.Errorf("enter raw terminal mode: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = term.Restore(int(in.Fd()), oldState)
|
||||
}()
|
||||
|
||||
fmt.Fprint(out, "\x1b[?1049h\x1b[?25l")
|
||||
defer fmt.Fprint(out, "\x1b[?25h\x1b[?1049l")
|
||||
|
||||
model := clusterBrowserModel{payload: payload}
|
||||
if err := renderClusterBrowser(out, &model); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var buf [16]byte
|
||||
for {
|
||||
n, err := in.Read(buf[:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
if handleClusterBrowserInput(&model, string(buf[:n])) {
|
||||
return nil
|
||||
}
|
||||
if err := renderClusterBrowser(out, &model); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleClusterBrowserInput(model *clusterBrowserModel, input string) bool {
|
||||
switch input {
|
||||
case "q", "Q", "\x03":
|
||||
return true
|
||||
case "j", "\x1b[B":
|
||||
moveClusterSelection(model, 1)
|
||||
case "k", "\x1b[A":
|
||||
moveClusterSelection(model, -1)
|
||||
case "\x06", "\x1b[6~":
|
||||
moveClusterSelection(model, visibleClusterRows(model)-1)
|
||||
case "\x02", "\x1b[5~":
|
||||
moveClusterSelection(model, -(visibleClusterRows(model) - 1))
|
||||
case "g":
|
||||
model.selected = 0
|
||||
case "G":
|
||||
if len(model.payload.Clusters) > 0 {
|
||||
model.selected = len(model.payload.Clusters) - 1
|
||||
}
|
||||
}
|
||||
keepSelectionVisible(model)
|
||||
return false
|
||||
}
|
||||
|
||||
func moveClusterSelection(model *clusterBrowserModel, delta int) {
|
||||
if len(model.payload.Clusters) == 0 {
|
||||
model.selected = 0
|
||||
return
|
||||
}
|
||||
model.selected += delta
|
||||
if model.selected < 0 {
|
||||
model.selected = 0
|
||||
}
|
||||
if model.selected >= len(model.payload.Clusters) {
|
||||
model.selected = len(model.payload.Clusters) - 1
|
||||
}
|
||||
}
|
||||
|
||||
func keepSelectionVisible(model *clusterBrowserModel) {
|
||||
rows := visibleClusterRows(model)
|
||||
if rows <= 0 {
|
||||
model.offset = 0
|
||||
return
|
||||
}
|
||||
if model.selected < model.offset {
|
||||
model.offset = model.selected
|
||||
}
|
||||
if model.selected >= model.offset+rows {
|
||||
model.offset = model.selected - rows + 1
|
||||
}
|
||||
if model.offset < 0 {
|
||||
model.offset = 0
|
||||
}
|
||||
}
|
||||
|
||||
func renderClusterBrowser(out io.Writer, model *clusterBrowserModel) error {
|
||||
width, height, err := term.GetSize(int(os.Stdout.Fd()))
|
||||
if err != nil || width <= 0 || height <= 0 {
|
||||
width, height = 100, 32
|
||||
}
|
||||
model.width = width
|
||||
model.height = height
|
||||
keepSelectionVisible(model)
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("\x1b[H\x1b[2J")
|
||||
writeTUILine(&b, width, "\x1b[1mGitcrawl\x1b[0m "+model.payload.Repository+" sort="+model.payload.Sort)
|
||||
if model.payload.InferredRepository {
|
||||
writeTUILine(&b, width, "repo inferred from local database")
|
||||
} else {
|
||||
writeTUILine(&b, width, "")
|
||||
}
|
||||
writeTUILine(&b, width, strings.Repeat("-", width))
|
||||
|
||||
rows := visibleClusterRows(model)
|
||||
if len(model.payload.Clusters) == 0 {
|
||||
writeTUILine(&b, width, "no clusters found")
|
||||
} else {
|
||||
end := model.offset + rows
|
||||
if end > len(model.payload.Clusters) {
|
||||
end = len(model.payload.Clusters)
|
||||
}
|
||||
for i := model.offset; i < end; i++ {
|
||||
cluster := model.payload.Clusters[i]
|
||||
marker := " "
|
||||
styleStart := ""
|
||||
styleEnd := ""
|
||||
if i == model.selected {
|
||||
marker = ">"
|
||||
styleStart = "\x1b[7m"
|
||||
styleEnd = "\x1b[0m"
|
||||
}
|
||||
number := ""
|
||||
if cluster.RepresentativeNumber > 0 {
|
||||
number = fmt.Sprintf(" #%d", cluster.RepresentativeNumber)
|
||||
}
|
||||
line := fmt.Sprintf("%s %-24s %4d %-12s%s %s",
|
||||
marker,
|
||||
truncateRunes(cluster.StableSlug, 24),
|
||||
cluster.MemberCount,
|
||||
truncateRunes(cluster.RepresentativeKind, 12),
|
||||
number,
|
||||
firstNonEmpty(cluster.RepresentativeTitle, cluster.Title),
|
||||
)
|
||||
writeTUIStyledLine(&b, width, styleStart, line, styleEnd)
|
||||
}
|
||||
}
|
||||
|
||||
for currentVisualLineCount(b.String()) < height-6 {
|
||||
writeTUILine(&b, width, "")
|
||||
}
|
||||
writeTUILine(&b, width, strings.Repeat("-", width))
|
||||
writeClusterDetail(&b, width, model)
|
||||
writeTUILine(&b, width, "j/k or arrows move g/G jump ctrl-f/ctrl-b page q quit")
|
||||
_, err = io.WriteString(out, b.String())
|
||||
return err
|
||||
}
|
||||
|
||||
func writeClusterDetail(b *strings.Builder, width int, model *clusterBrowserModel) {
|
||||
if len(model.payload.Clusters) == 0 {
|
||||
writeTUILine(b, width, "")
|
||||
writeTUILine(b, width, "")
|
||||
return
|
||||
}
|
||||
cluster := model.payload.Clusters[model.selected]
|
||||
writeTUILine(b, width, fmt.Sprintf("%s %s members=%d status=%s",
|
||||
cluster.StableSlug,
|
||||
threadRef(cluster),
|
||||
cluster.MemberCount,
|
||||
firstNonEmpty(cluster.Status, "unknown"),
|
||||
))
|
||||
writeTUILine(b, width, firstNonEmpty(cluster.RepresentativeTitle, cluster.Title))
|
||||
writeTUILine(b, width, "updated "+cluster.UpdatedAt)
|
||||
}
|
||||
|
||||
func threadRef(cluster store.ClusterSummary) string {
|
||||
if cluster.RepresentativeNumber == 0 {
|
||||
return ""
|
||||
}
|
||||
if cluster.RepresentativeKind == "" {
|
||||
return fmt.Sprintf("#%d", cluster.RepresentativeNumber)
|
||||
}
|
||||
return fmt.Sprintf("%s #%d", cluster.RepresentativeKind, cluster.RepresentativeNumber)
|
||||
}
|
||||
|
||||
func visibleClusterRows(model *clusterBrowserModel) int {
|
||||
rows := model.height - 9
|
||||
if rows < 1 {
|
||||
return 1
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func writeTUILine(b *strings.Builder, width int, line string) {
|
||||
if width <= 0 {
|
||||
width = 80
|
||||
}
|
||||
b.WriteString(truncateRunes(line, width))
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
|
||||
func writeTUIStyledLine(b *strings.Builder, width int, styleStart, line, styleEnd string) {
|
||||
if width <= 0 {
|
||||
width = 80
|
||||
}
|
||||
b.WriteString(styleStart)
|
||||
b.WriteString(truncateRunes(line, width))
|
||||
b.WriteString(styleEnd)
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
|
||||
func truncateRunes(value string, max int) string {
|
||||
if max <= 0 {
|
||||
return ""
|
||||
}
|
||||
if utf8.RuneCountInString(value) <= max {
|
||||
return value
|
||||
}
|
||||
if max <= 3 {
|
||||
return strings.Repeat(".", max)
|
||||
}
|
||||
runes := []rune(value)
|
||||
return string(runes[:max-3]) + "..."
|
||||
}
|
||||
|
||||
func currentVisualLineCount(value string) int {
|
||||
return strings.Count(value, "\n")
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user