clone

a lazygit-style terminal viewer for openspec repos: watch changes, tasks and spec deltas land as agents work

main
lazyopenspec / internal / ui / mainpane.go
mainpane.go 13.1K
1package ui23import (4 "fmt"5 "path/filepath"6 "strings"7 "time"89 tea "charm.land/bubbletea/v2"10 "charm.land/glamour/v2"11 "charm.land/lipgloss/v2"1213 "src.rest/cat/lazyopenspec/internal/gitx"14 "src.rest/cat/lazyopenspec/internal/spec"15)1617// target is the item shown in the main pane.18type target struct {19 panel panelID20 change spec.Change21 spec spec.Spec22 ok bool23}2425func (t target) key() string {26 switch t.panel {27 case panelSpecs:28 return targetKey(t.panel, t.spec.ID)29 default:30 return targetKey(t.panel, t.change.ID)31 }32}3334func targetKey(p panelID, id string) string { return fmt.Sprintf("%d:%s", p, id) }3536// tab is one file viewable for a target.37type tab struct {38 key string39 label string40 path string41 mod time.Time42 content string43}4445func (m Model) target() target {46 if m.snap == nil {47 return target{}48 }49 p := m.lastList50 i := m.cursors[p]51 switch p {52 case panelChanges:53 if cs := m.changes(); i < len(cs) {54 return target{panel: p, change: cs[i], ok: true}55 }56 case panelSpecs:57 if i < len(m.snap.Specs) {58 return target{panel: p, spec: m.snap.Specs[i], ok: true}59 }60 case panelArchive:61 if i < len(m.snap.Archive) {62 return target{panel: p, change: m.snap.Archive[i], ok: true}63 }64 }65 return target{}66}6768// pendingDeltas lists active-change delta artifacts that target a spec.69func (m Model) pendingDeltas(specID string) []tab {70 if m.snap == nil {71 return nil72 }73 var tabs []tab74 for _, c := range m.snap.Changes {75 for _, a := range c.Artifacts {76 if a.Kind == spec.KindDelta && a.Capability() == specID {77 tabs = append(tabs, tab{78 key: "Δ " + c.ID, label: "Δ " + c.ID,79 path: a.Path, mod: a.ModTime, content: a.Content,80 })81 }82 }83 }84 return tabs85}8687func (m Model) tabsFor(t target) []tab {88 if !t.ok {89 return nil90 }91 if t.panel == panelSpecs {92 tabs := []tab{{key: "spec.md", label: "spec.md", path: t.spec.Path, mod: t.spec.ModTime, content: t.spec.Content}}93 return append(tabs, m.pendingDeltas(t.spec.ID)...)94 }95 tabs := make([]tab, 0, len(t.change.Artifacts))96 for _, a := range t.change.Artifacts {97 tabs = append(tabs, tab{key: a.Rel, label: a.Label(), path: a.Path, mod: a.ModTime, content: a.Content})98 }99 return tabs100}101102func (m Model) currentTab(t target) (tab, bool) {103 tabs := m.tabsFor(t)104 if len(tabs) == 0 {105 return tab{}, false106 }107 want := m.tabs[t.key()]108 for _, tb := range tabs {109 if tb.key == want {110 return tb, true111 }112 }113 return tabs[0], true114}115116func (m *Model) cycleTab(delta int) {117 t := m.target()118 tabs := m.tabsFor(t)119 if len(tabs) == 0 {120 return121 }122 cur, _ := m.currentTab(t)123 i := 0124 for j, tb := range tabs {125 if tb.key == cur.key {126 i = j127 }128 }129 i = (i + delta + len(tabs)) % len(tabs)130 m.tabs[t.key()] = tabs[i].key131}132133const mainChromeRows = 5 // 3 header rows, tab bar, rule134135func (m Model) mainInner() (w, h int) {136 g := m.geometry()137 return max(g.main.w-2, 0), max(g.main.h-2, 0)138}139140// syncMain refreshes the viewport when the visible content's identity141// changes. Scroll position is kept per target/tab so live edits to the file142// being read don't yank the view back to the top.143func (m *Model) syncMain() tea.Cmd {144 w, h := m.mainInner()145 vpH := max(h-mainChromeRows, 0)146 t := m.target()147 tb, hasTab := m.currentTab(t)148 dirty := m.git.Dirty[tb.path]149150 viewKey := fmt.Sprintf("%s|%s|%t", t.key(), tb.key, m.diffMode)151 state := "loading"152 if m.snap != nil {153 state = fmt.Sprint(m.snap.Exists)154 }155 contentKey := fmt.Sprintf("%s|%d|%s|%dx%d|%t|%s", viewKey, tb.mod.UnixNano(), dirty, w, vpH, m.theme.isDark, state)156 if contentKey == m.mainKey && m.vp.Width() == w && m.vp.Height() == vpH {157 return nil158 }159 if m.viewKey != "" {160 m.scroll[m.viewKey] = m.vp.YOffset()161 }162 m.mainKey, m.viewKey = contentKey, viewKey163164 m.vp.SetWidth(w)165 m.vp.SetHeight(vpH)166167 var cmd tea.Cmd168 switch {169 case m.snap != nil && !m.snap.Exists:170 m.vp.SetContent(m.theme.muted.Render(fmt.Sprintf(171 "\n No %s/ directory in %s yet.\n Waiting for one to appear (run `openspec init`).",172 spec.DirName, m.opts.Root)))173 case !t.ok:174 m.vp.SetContent(m.theme.muted.Render("\n Nothing selected."))175 case !hasTab:176 m.vp.SetContent(m.theme.muted.Render("\n No artifacts yet. Waiting for proposal.md…"))177 case m.diffMode:178 var content string179 content, cmd = m.diffContent(tb, dirty)180 m.vp.SetContent(content)181 default:182 m.vp.SetContent(m.renderer.render(tb.path, tb.mod, tb.content, w, m.theme.glamourStyle()))183 }184 m.vp.SetYOffset(m.scroll[viewKey])185 return cmd186}187188func (m *Model) diffContent(tb tab, dirty string) (string, tea.Cmd) {189 if !m.git.IsRepo {190 return m.theme.muted.Render("\n Not a git repository; diff unavailable."), nil191 }192 if dirty == "" {193 return m.theme.muted.Render("\n No uncommitted changes to " + m.relPath(tb.path) + "."), nil194 }195 key := fmt.Sprintf("%s|%d|%s", tb.path, tb.mod.UnixNano(), dirty)196 prev, havePrev := m.diffs[tb.path]197 var cmd tea.Cmd198 if (!havePrev || prev.key != key) && !m.diffReq[key] {199 m.diffReq[key] = true200 root, path := m.opts.Root, tb.path201 untracked := dirty == "??"202 cmd = func() tea.Msg {203 text, err := gitx.Diff(root, path, untracked)204 return diffMsg{path: path, key: key, text: text, err: err}205 }206 }207 switch {208 case !havePrev:209 // Nothing to show yet. A stale diff for the same file is better than a210 // placeholder: it keeps the scroll position while the new one loads.211 return m.theme.muted.Render("\n Loading diff…"), cmd212 case prev.err != nil:213 return m.theme.remSt.Render("\n git diff failed: " + prev.err.Error()), cmd214 default:215 return m.colorDiff(prev.text), cmd216 }217}218219func (m Model) colorDiff(diff string) string {220 t := m.theme221 var sb strings.Builder222 for line := range strings.SplitSeq(strings.TrimRight(diff, "\n"), "\n") {223 switch {224 case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"),225 strings.HasPrefix(line, "diff "), strings.HasPrefix(line, "index "),226 strings.HasPrefix(line, "new file"), strings.HasPrefix(line, "deleted file"):227 line = t.muted.Render(line)228 case strings.HasPrefix(line, "@@"):229 line = t.diffHunk.Render(line)230 case strings.HasPrefix(line, "+"):231 line = t.diffAdd.Render(line)232 case strings.HasPrefix(line, "-"):233 line = t.diffDel.Render(line)234 }235 sb.WriteString(" ")236 sb.WriteString(line)237 sb.WriteString("\n")238 }239 return sb.String()240}241242func (m Model) relPath(path string) string {243 if rel, err := filepath.Rel(m.opts.Root, path); err == nil {244 return rel245 }246 return path247}248249// renderMain draws the main pane: item header, tab bar, and viewport.250func (m Model) renderMain(r rect) string {251 w := max(r.w-2, 0)252 t := m.target()253 focused := m.focus == panelMain254255 var title string256 var header []string257 switch {258 case !t.ok:259 title = "lazyopenspec"260 header = []string{"", "", ""}261 case t.panel == panelSpecs:262 title = "Spec " + m.theme.bold.Render(t.spec.ID)263 header = m.specHeader(t.spec, w)264 case t.change.Archived:265 title = "Archived " + m.theme.bold.Render(t.change.Name)266 header = m.changeHeader(t.change, w)267 default:268 title = "Change " + m.theme.bold.Render(t.change.ID)269 header = m.changeHeader(t.change, w)270 }271272 lines := append(header, m.tabBar(t, w), m.theme.subtle.Render(strings.Repeat("─", w)))273 body := strings.Join(lines, "\n") + "\n" + m.vp.View()274275 footer := ""276 if m.vp.TotalLineCount() > m.vp.Height() {277 footer = m.theme.muted.Render(fmt.Sprintf("%3.f%%", m.vp.ScrollPercent()*100))278 }279 return box(m.theme, title, footer, body, r.w, r.h, focused)280}281282func (m Model) changeHeader(c spec.Change, w int) []string {283 th := m.theme284 right := ""285 if c.Archived {286 right = "archived " + c.ArchivedOn287 } else {288 right = "edited " + ago(m.now, c.ModTime)289 }290 if c.Schema != "" {291 right = c.Schema + " " + right292 }293 line1 := spread(" "+th.bold.Render(c.Name), th.muted.Render(right)+" ", w)294295 summary := c.Summary296 switch {297 case summary != "":298 case c.Has(spec.KindProposal):299 summary = "(proposal is empty so far)"300 default:301 summary = "(no proposal yet)"302 }303 line2 := " " + th.muted.Render(summary)304305 chip := func(label string, ok bool) string {306 if ok {307 return th.addSt.Render("✓ " + label)308 }309 return th.subtle.Render("○ " + label)310 }311 parts := []string{312 chip("proposal", c.Has(spec.KindProposal)),313 chip("design", c.Has(spec.KindDesign)),314 chip("specs", c.Has(spec.KindDelta)),315 chip("tasks", c.Has(spec.KindTasks)),316 }317 if c.Tasks.Total > 0 {318 parts = append(parts, m.progressBar(c.Tasks, 12)+" "+m.taskCount(c.Tasks))319 }320 if d := c.Deltas; d.Total() > 0 {321 parts = append(parts, m.deltaSummary(d))322 }323 line3 := " " + strings.Join(parts, " ")324 return []string{line1, line2, line3}325}326327func (m Model) specHeader(s spec.Spec, w int) []string {328 th := m.theme329 title := s.Title330 if title == "" {331 title = s.ID332 }333 line1 := spread(" "+th.bold.Render(title), th.muted.Render("edited "+ago(m.now, s.ModTime))+" ", w)334 purpose := s.Purpose335 if purpose == "" {336 purpose = "(no purpose)"337 }338 line2 := " " + th.muted.Render(purpose)339 stats := fmt.Sprintf("%d requirements %d scenarios", s.Requirements, s.Scenarios)340 line3 := " " + th.muted.Render(stats)341 if pending := m.pendingDeltas(s.ID); len(pending) > 0 {342 ids := make([]string, len(pending))343 for i, p := range pending {344 ids[i] = strings.TrimPrefix(p.label, "Δ ")345 }346 line3 += " " + th.modSt.Render("Δ pending: "+strings.Join(ids, ", "))347 }348 return []string{line1, line2, line3}349}350351func (m Model) tabBar(t target, w int) string {352 th := m.theme353 tabs := m.tabsFor(t)354 cur, _ := m.currentTab(t)355 var parts []string356 active := 0357 for i, tb := range tabs {358 // Style each piece separately: underlined styles re-wrap every rune359 // and would mangle an already-styled substring.360 st := th.tabInactive361 if tb.key == cur.key {362 st = th.tabActive363 active = i364 }365 part := st.Render(tb.label)366 if m.now.Sub(tb.mod) < freshWindow {367 part = th.freshSt.Render("● ") + part368 }369 if mark := m.gitMark(tb.path); mark != "" {370 part += " " + mark371 }372 parts = append(parts, part)373 }374 right := ""375 if m.diffMode {376 right = th.modSt.Render("[diff vs HEAD]") + " "377 }378 return spread(m.tabWindow(parts, active, w-lipgloss.Width(right)-1), right, w)379}380381// tabWindow joins as many tabs as fit in w cells, always including the active382// one, and marks tabs hidden off either edge with an arrow.383func (m Model) tabWindow(parts []string, active, w int) string {384 sep := m.theme.subtle.Render(" │ ")385 join := func(lo, hi int) string {386 s := " "387 if lo > 0 {388 s += m.theme.muted.Render("‹ ")389 }390 s += strings.Join(parts[lo:hi], sep)391 if hi < len(parts) {392 s += m.theme.muted.Render(" ›")393 }394 return s395 }396 if len(parts) == 0 {397 return " "398 }399 lo, hi := active, active+1400 for grew := true; grew; {401 grew = false402 if hi < len(parts) && lipgloss.Width(join(lo, hi+1)) <= w {403 hi, grew = hi+1, true404 }405 if lo > 0 && lipgloss.Width(join(lo-1, hi)) <= w {406 lo, grew = lo-1, true407 }408 }409 return join(lo, hi)410}411412func (m Model) gitMark(path string) string {413 code, ok := m.git.Dirty[path]414 if !ok {415 return ""416 }417 switch strings.TrimSpace(code) {418 case "??", "A":419 return m.theme.addSt.Render("+")420 case "D":421 return m.theme.remSt.Render("-")422 default:423 return m.theme.modSt.Render("M")424 }425}426427func (m Model) progressBar(ts spec.TaskStats, width int) string {428 if ts.Total == 0 || width <= 0 {429 return m.theme.subtle.Render(strings.Repeat("▱", max(width, 0)))430 }431 filled := ts.Done * width / ts.Total432 st := m.theme.title433 if ts.Complete() {434 st = m.theme.addSt435 }436 return st.Render(strings.Repeat("▰", filled)) + m.theme.subtle.Render(strings.Repeat("▱", width-filled))437}438439func (m Model) taskCount(ts spec.TaskStats) string {440 s := fmt.Sprintf("%d/%d", ts.Done, ts.Total)441 if ts.Complete() {442 return m.theme.addSt.Render(s)443 }444 return m.theme.muted.Render(s)445}446447func (m Model) deltaSummary(d spec.DeltaStats) string {448 th := m.theme449 var parts []string450 if d.Added > 0 {451 parts = append(parts, th.addSt.Render(fmt.Sprintf("+%d", d.Added)))452 }453 if d.Modified > 0 {454 parts = append(parts, th.modSt.Render(fmt.Sprintf("~%d", d.Modified)))455 }456 if d.Removed > 0 {457 parts = append(parts, th.remSt.Render(fmt.Sprintf("-%d", d.Removed)))458 }459 if d.Renamed > 0 {460 parts = append(parts, th.renSt.Render(fmt.Sprintf("→%d", d.Renamed)))461 }462 return th.muted.Render("Δ ") + strings.Join(parts, " ")463}464465// mdRenderer caches glamour renderers per style/width and rendered output466// per file version, so redraws and repeat visits are cheap.467type mdRenderer struct {468 renderers map[string]*glamour.TermRenderer469 cache map[string]string470}471472func newMDRenderer() *mdRenderer {473 return &mdRenderer{renderers: map[string]*glamour.TermRenderer{}, cache: map[string]string{}}474}475476func (r *mdRenderer) render(path string, mod time.Time, src string, width int, style string) string {477 key := fmt.Sprintf("%s|%d|%d|%s", path, mod.UnixNano(), width, style)478 if out, ok := r.cache[key]; ok {479 return out480 }481 rkey := fmt.Sprintf("%s|%d", style, width)482 tr, ok := r.renderers[rkey]483 if !ok {484 var err error485 tr, err = glamour.NewTermRenderer(486 glamour.WithStandardStyle(style),487 glamour.WithWordWrap(max(width-4, 20)),488 )489 if err != nil {490 return src491 }492 if len(r.renderers) >= 4 { // one per width; drop old sizes after resizes493 clear(r.renderers)494 }495 r.renderers[rkey] = tr496 }497 out, err := tr.Render(src)498 if err != nil {499 out = src500 }501 if len(r.cache) > 256 {502 clear(r.cache)503 }504 r.cache[key] = out505 return out506}