1// Package ui is the Bubble Tea front end for lazyopenspec.2package ui34import (5 "os"6 "os/exec"7 "path/filepath"8 "sort"9 "strings"10 "time"1112 "charm.land/bubbles/v2/viewport"13 tea "charm.land/bubbletea/v2"1415 "src.rest/cat/lazyopenspec/internal/gitx"16 "src.rest/cat/lazyopenspec/internal/spec"17)1819type panelID int2021const (22 panelChanges panelID = iota23 panelSpecs24 panelArchive25 panelActivity26 panelMain2728 numLists = 429)3031var panelTitles = [numLists]string{"Changes", "Specs", "Archive", "Activity"}3233const (34 maxEvents = 50035 gitEvery = 5 * time.Second36 freshWindow = 15 * time.Second37 warmWindow = 2 * time.Minute38 scrollStep = 339 defaultPollMs = 40040)4142// Options configure the UI.43type Options struct {44 Root string // repository root, symlinks resolved45 Display string // root as the user typed it, for the header46 Interval time.Duration47 Follow bool48 Portrait PortraitMode // stack panels above the main pane: auto, always, never49}5051type (52 pollMsg time.Time53 scannedMsg spec.Fingerprint54 loadedMsg struct {55 snap *spec.Snapshot56 err error57 }58 gitMsg gitx.Status59 diffMsg struct {60 path string61 key string // path + mtime + git status the diff was taken at62 text string63 err error64 }65 editorDoneMsg struct{ err error }66)6768// Model is the root Bubble Tea model.69type Model struct {70 opts Options71 theme theme7273 width, height int74 focus panelID75 lastList panelID // list panel driving the main pane76 cursors [numLists]int77 offsets [numLists]int78 selected [numLists]string // selected item ID per list, kept across reloads79 sortRecent bool80 follow bool81 diffMode bool82 zoom bool83 showHelp bool84 portraitMode PortraitMode8586 snap *spec.Snapshot87 fp spec.Fingerprint88 loading bool89 err error90 git gitx.Status91 gitAt time.Time92 gitBusy bool // a git read is in flight93 gitAgain bool // another read was requested while busy94 events []spec.Event // newest first95 now time.Time96 lastSeen time.Time // time of the most recent event9798 vp viewport.Model99 tabs map[string]string // target key -> selected tab key100 scroll map[string]int // content key -> remembered y offset101 mainKey string // identity of the content currently in vp102 viewKey string // target/tab/mode shown in vp, for scroll memory103 renderer *mdRenderer104 diffs map[string]diffMsg // latest diff per path105 diffReq map[string]bool // diff keys currently being fetched106}107108// New constructs the root model.109func New(opts Options) Model {110 if opts.Interval <= 0 {111 opts.Interval = defaultPollMs * time.Millisecond112 }113 vp := viewport.New()114 vp.MouseWheelEnabled = false // the root model routes wheel events115 return Model{116 opts: opts,117 theme: newTheme(true),118 follow: opts.Follow,119 portraitMode: opts.Portrait,120 vp: vp,121 tabs: map[string]string{},122 scroll: map[string]int{},123 renderer: newMDRenderer(),124 diffs: map[string]diffMsg{},125 diffReq: map[string]bool{},126 now: time.Now(),127 }128}129130// Init starts the first load, git read, and poll loop.131func (m Model) Init() tea.Cmd {132 return tea.Batch(tea.RequestBackgroundColor, m.scanCmd())133}134135func (m Model) scanCmd() tea.Cmd {136 dir := filepath.Join(m.opts.Root, spec.DirName)137 return func() tea.Msg { return scannedMsg(spec.Scan(dir)) }138}139140func (m Model) loadCmd() tea.Cmd {141 root := m.opts.Root142 return func() tea.Msg {143 snap, err := spec.Load(root)144 return loadedMsg{snap, err}145 }146}147148// refreshGit starts a git read unless one is in flight, in which case it149// queues exactly one more so results never arrive out of order.150func (m *Model) refreshGit() tea.Cmd {151 if m.gitBusy {152 m.gitAgain = true153 return nil154 }155 m.gitBusy, m.gitAt = true, m.now156 root := m.opts.Root157 dir := filepath.Join(root, spec.DirName)158 return func() tea.Msg { return gitMsg(gitx.Read(root, dir)) }159}160161func (m Model) pollCmd() tea.Cmd {162 return tea.Tick(m.opts.Interval, func(t time.Time) tea.Msg { return pollMsg(t) })163}164165// Update handles messages.166func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {167 var cmds []tea.Cmd168 switch msg := msg.(type) {169 case tea.WindowSizeMsg:170 m.width, m.height = msg.Width, msg.Height171 m.clampAll()172173 case tea.BackgroundColorMsg:174 m.theme = newTheme(msg.IsDark())175 m.mainKey = "" // re-render markdown with the new style176177 case scannedMsg:178 // The fingerprint is only recorded when a load starts, so edits that179 // land while a load is in flight are caught by the next scan.180 fp := spec.Fingerprint(msg)181 if !m.loading && (m.fp == nil || len(m.fp.Changed(fp)) > 0) {182 m.fp = fp183 m.loading = true184 cmds = append(cmds, m.loadCmd())185 }186 cmds = append(cmds, m.pollCmd())187188 case pollMsg:189 m.now = time.Time(msg)190 cmds = append(cmds, m.scanCmd())191 if m.now.Sub(m.gitAt) >= gitEvery {192 cmds = append(cmds, m.refreshGit())193 }194195 case loadedMsg:196 m.loading = false197 m.err = msg.err198 if msg.err != nil {199 m.fp = nil // retry on the next poll even if nothing else changes200 } else {201 cmds = append(cmds, m.applySnapshot(msg.snap)...)202 }203204 case gitMsg:205 m.git = gitx.Status(msg)206 m.gitBusy = false207 if m.gitAgain {208 m.gitAgain = false209 cmds = append(cmds, m.refreshGit())210 }211212 case diffMsg:213 m.diffs[msg.path] = msg214 delete(m.diffReq, msg.key)215 m.mainKey = ""216217 case editorDoneMsg:218 if msg.err != nil {219 m.err = msg.err220 }221222 case tea.KeyPressMsg:223 cmds = append(cmds, m.handleKey(msg))224225 case tea.MouseWheelMsg:226 m.handleWheel(msg)227228 case tea.MouseClickMsg:229 m.handleClick(msg)230 }231232 cmds = append(cmds, m.syncMain())233 return m, tea.Batch(cmds...)234}235236// applySnapshot swaps in a freshly loaded snapshot, records activity, and237// keeps each list's selection pinned to the same item ID.238func (m *Model) applySnapshot(next *spec.Snapshot) []tea.Cmd {239 prev := m.snap240 m.snap = next241 m.now = time.Now()242 events := spec.Diff(prev, next, m.now)243 for i := len(events) - 1; i >= 0; i-- {244 m.events = append([]spec.Event{events[i]}, m.events...)245 }246 if len(m.events) > maxEvents {247 m.events = m.events[:maxEvents]248 }249 if len(events) > 0 {250 m.lastSeen = m.now251 }252 if m.cursors[panelActivity] > 0 || m.offsets[panelActivity] > 0 {253 // Keep a scrolled-back activity cursor on the same event; at the top254 // it keeps tracking the newest one.255 m.cursors[panelActivity] += len(events)256 m.offsets[panelActivity] += len(events)257 }258 for p := range panelID(numLists) {259 if p == panelActivity {260 continue261 }262 if i := m.indexOf(p, m.selected[p]); i >= 0 {263 m.cursors[p] = i264 }265 }266 if m.follow && len(events) > 0 {267 m.followEvent(events[len(events)-1])268 }269 m.clampAll()270 return []tea.Cmd{m.refreshGit()}271}272273// followEvent moves the selection to the item an event refers to.274func (m *Model) followEvent(e spec.Event) {275 var p panelID276 switch e.Item {277 case spec.ItemChange:278 p = panelChanges279 case spec.ItemSpec:280 p = panelSpecs281 case spec.ItemArchive:282 p = panelArchive283 }284 i := m.indexOf(p, e.ID)285 if i < 0 {286 return287 }288 m.cursors[p] = i289 m.selected[p] = e.ID290 m.lastList = p291 if m.focus != panelMain {292 m.focus = p293 }294 if e.Rel != "" {295 m.tabs[targetKey(p, e.ID)] = e.Rel296 }297}298299func (m Model) changes() []spec.Change {300 if m.snap == nil {301 return nil302 }303 cs := m.snap.Changes304 if m.sortRecent {305 cs = append([]spec.Change(nil), cs...)306 sort.SliceStable(cs, func(i, j int) bool { return cs[i].ModTime.After(cs[j].ModTime) })307 }308 return cs309}310311func (m Model) count(p panelID) int {312 if m.snap == nil && p != panelActivity {313 return 0314 }315 switch p {316 case panelChanges:317 return len(m.snap.Changes)318 case panelSpecs:319 return len(m.snap.Specs)320 case panelArchive:321 return len(m.snap.Archive)322 case panelActivity:323 return len(m.events)324 }325 return 0326}327328// idAt returns the item ID at index i of list p.329func (m Model) idAt(p panelID, i int) string {330 if i < 0 || i >= m.count(p) {331 return ""332 }333 switch p {334 case panelChanges:335 return m.changes()[i].ID336 case panelSpecs:337 return m.snap.Specs[i].ID338 case panelArchive:339 return m.snap.Archive[i].ID340 case panelActivity:341 e := m.events[i]342 return e.Time.Format(time.RFC3339Nano) + e.ID + e.Detail343 }344 return ""345}346347func (m Model) indexOf(p panelID, id string) int {348 if id == "" {349 return -1350 }351 for i := range m.count(p) {352 if m.idAt(p, i) == id {353 return i354 }355 }356 return -1357}358359func (m *Model) clampAll() {360 g := m.geometry()361 for p := range panelID(numLists) {362 n := m.count(p)363 h := g.panels[p].h - 2364 c := clamp(m.cursors[p], 0, max(n-1, 0))365 off := m.offsets[p]366 if c < off {367 off = c368 }369 if h > 0 && c >= off+h {370 off = c - h + 1371 }372 off = clamp(off, 0, max(n-h, 0))373 m.cursors[p], m.offsets[p] = c, off374 m.selected[p] = m.idAt(p, c)375 }376}377378func (m *Model) handleKey(msg tea.KeyPressMsg) tea.Cmd {379 if m.showHelp {380 m.showHelp = false381 return nil382 }383 key := msg.String()384 switch key {385 case "q", "ctrl+c":386 return tea.Quit387 case "?":388 m.showHelp = true389 case "tab":390 m.setFocus((m.focus + 1) % (numLists + 1))391 case "shift+tab":392 m.setFocus((m.focus + numLists) % (numLists + 1))393 case "1", "2", "3", "4":394 m.setFocus(panelID(key[0] - '1'))395 case "0":396 m.setFocus(panelMain)397 case "enter", "l", "right":398 if m.focus == panelActivity {399 if i := m.cursors[panelActivity]; i < len(m.events) {400 m.jumpTo(m.events[i])401 }402 } else if m.focus < numLists {403 m.setFocus(panelMain)404 }405 case "esc", "h", "left":406 if m.focus == panelMain {407 m.setFocus(m.lastList)408 }409 case "j", "down":410 m.scrollOrMove(1)411 case "k", "up":412 m.scrollOrMove(-1)413 case "g", "home":414 m.scrollOrMove(-1 << 20)415 case "G", "end":416 m.scrollOrMove(1 << 20)417 case "J", "ctrl+d", "pgdown":418 m.vp.HalfPageDown()419 case "K", "ctrl+u", "pgup":420 m.vp.HalfPageUp()421 case "]":422 m.cycleTab(1)423 case "[":424 m.cycleTab(-1)425 case "d":426 m.diffMode = !m.diffMode427 case "f", "F":428 m.follow = !m.follow429 case "s":430 m.sortRecent = !m.sortRecent431 if i := m.indexOf(panelChanges, m.selected[panelChanges]); i >= 0 {432 m.cursors[panelChanges] = i433 }434 m.clampAll()435 case "z":436 m.zoom = !m.zoom437 m.clampAll()438 case "v":439 // Flip the current layout; this pins it until toggled again.440 if m.portrait() {441 m.portraitMode = PortraitNever442 } else {443 m.portraitMode = PortraitAlways444 }445 m.clampAll()446 case "r":447 m.loading = true448 return tea.Batch(m.loadCmd(), m.refreshGit())449 case "c":450 m.events = nil451 m.cursors[panelActivity] = 0452 m.clampAll()453 case "e":454 return m.editCmd()455 }456 return nil457}458459func (m *Model) setFocus(p panelID) {460 m.focus = p461 defer m.clampAll() // squashed panel heights depend on focus462 if p < numLists && p != panelActivity {463 m.lastList = p464 }465 if p == panelActivity {466 if i := m.cursors[panelActivity]; i < len(m.events) {467 m.peekEvent(m.events[i])468 }469 }470}471472func (m *Model) scrollOrMove(delta int) {473 if m.focus == panelMain {474 switch {475 case delta <= -1<<20:476 m.vp.GotoTop()477 case delta >= 1<<20:478 m.vp.GotoBottom()479 case delta > 0:480 m.vp.ScrollDown(delta)481 default:482 m.vp.ScrollUp(-delta)483 }484 return485 }486 m.cursors[m.focus] += delta487 m.clampAll()488 if m.focus == panelActivity {489 if i := m.cursors[panelActivity]; i < len(m.events) {490 m.peekEvent(m.events[i])491 }492 return493 }494 m.lastList = m.focus495}496497// peekEvent shows an activity item in the main pane without moving focus.498func (m *Model) peekEvent(e spec.Event) {499 p := map[spec.ItemKind]panelID{500 spec.ItemChange: panelChanges, spec.ItemSpec: panelSpecs, spec.ItemArchive: panelArchive,501 }[e.Item]502 if i := m.indexOf(p, e.ID); i >= 0 {503 m.cursors[p] = i504 m.selected[p] = e.ID505 m.lastList = p506 if e.Rel != "" {507 m.tabs[targetKey(p, e.ID)] = e.Rel508 }509 m.clampAll()510 }511}512513// jumpTo focuses the list containing an event's item.514func (m *Model) jumpTo(e spec.Event) {515 m.peekEvent(e)516 m.focus = m.lastList517}518519func (m *Model) handleWheel(msg tea.MouseWheelMsg) {520 mouse := msg.Mouse()521 delta := 0522 switch mouse.Button {523 case tea.MouseWheelDown:524 delta = 1525 case tea.MouseWheelUp:526 delta = -1527 default:528 return529 }530 g := m.geometry()531 if g.main.contains(mouse.X, mouse.Y) {532 if delta > 0 {533 m.vp.ScrollDown(scrollStep)534 } else {535 m.vp.ScrollUp(scrollStep)536 }537 return538 }539 for p := range panelID(numLists) {540 if g.panels[p].contains(mouse.X, mouse.Y) {541 m.setFocus(p)542 m.scrollOrMove(delta)543 return544 }545 }546}547548func (m *Model) handleClick(msg tea.MouseClickMsg) {549 mouse := msg.Mouse()550 if mouse.Button != tea.MouseLeft {551 return552 }553 g := m.geometry()554 if g.main.contains(mouse.X, mouse.Y) {555 m.setFocus(panelMain)556 return557 }558 for p := range panelID(numLists) {559 r := g.panels[p]560 if !r.contains(mouse.X, mouse.Y) {561 continue562 }563 row := mouse.Y - r.y - 1564 if row >= 0 && row < r.h-2 {565 if i := m.offsets[p] + row; i < m.count(p) {566 m.cursors[p] = i567 }568 }569 m.clampAll()570 m.setFocus(p)571 return572 }573}574575func (m *Model) editCmd() tea.Cmd {576 t := m.target()577 tab, ok := m.currentTab(t)578 if !ok {579 return nil580 }581 editor := os.Getenv("VISUAL")582 if editor == "" {583 editor = os.Getenv("EDITOR")584 }585 if editor == "" {586 editor = "vi"587 }588 args := append(strings.Fields(editor), tab.path)589 c := exec.Command(args[0], args[1:]...) //nolint:gosec // user-chosen editor590 return tea.ExecProcess(c, func(err error) tea.Msg { return editorDoneMsg{err} })591}592593// View renders the UI.594func (m Model) View() tea.View {595 v := tea.NewView(m.render())596 v.AltScreen = true597 v.MouseMode = tea.MouseModeCellMotion598 v.WindowTitle = "lazyopenspec - " + filepath.Base(m.opts.Root)599 return v600}