Introduce the office Bubble Tea browser (module tree, multi-select lists, markdown preview) alongside oo, with cmd/internal/bootstrap for shared auth, unit tests across preview/model/ui/fetch, CI build for both binaries, and GoReleaser archives for office. Co-authored-by: Cursor <cursoragent@cursor.com>
340 lines
7.7 KiB
Go
340 lines
7.7 KiB
Go
package ui
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
|
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
|
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
|
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
|
)
|
|
|
|
type listLoadedMsg struct {
|
|
items []model.Item
|
|
subject model.Subject
|
|
err error
|
|
}
|
|
|
|
type previewLoadedMsg struct {
|
|
text string
|
|
err error
|
|
}
|
|
|
|
// Model is the root Bubble Tea model for the office TUI.
|
|
type Model struct {
|
|
client *onlyoffice.Client
|
|
loader *fetch.Loader
|
|
menu *model.MenuTree
|
|
items []model.Item
|
|
selection *model.Selection
|
|
subject model.Subject
|
|
listIdx int
|
|
focus model.FocusPane
|
|
width int
|
|
height int
|
|
status string
|
|
err string
|
|
loading bool
|
|
preview viewport.Model
|
|
previewMD string
|
|
ready bool
|
|
}
|
|
|
|
// NewModel constructs the TUI with an authenticated client.
|
|
func NewModel(client *onlyoffice.Client) Model {
|
|
vp := viewport.New(40, 20)
|
|
return Model{
|
|
client: client,
|
|
loader: &fetch.Loader{Client: client},
|
|
menu: model.DefaultMenuTree(),
|
|
selection: model.NewSelection(),
|
|
focus: model.FocusMenu,
|
|
preview: vp,
|
|
status: "office — Tab: pane Space: select Enter: preview q: quit",
|
|
}
|
|
}
|
|
|
|
// Init implements tea.Model.
|
|
func (m Model) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
// Update implements tea.Model.
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.layoutViewports()
|
|
return m, nil
|
|
case tea.KeyMsg:
|
|
key := msg.String()
|
|
if key == "?" {
|
|
m.status = helpText()
|
|
return m, nil
|
|
}
|
|
action := KeyAction(key, m.focus)
|
|
if action == ActionNone && (key == "up" || key == "k") {
|
|
action = ResolveMoveUp(m.focus)
|
|
}
|
|
switch action {
|
|
case ActionQuit:
|
|
return m, tea.Quit
|
|
case ActionNextPane:
|
|
m.focus = model.NextFocusPane(m.focus)
|
|
return m, nil
|
|
case ActionMoveUp:
|
|
m.moveUp()
|
|
return m, nil
|
|
case ActionMoveDown:
|
|
m.moveDown()
|
|
return m, nil
|
|
case ActionToggleSelect:
|
|
m.selection.Toggle(&m.items, m.listIdx)
|
|
return m, nil
|
|
case ActionOpenPreview:
|
|
m.selection.Toggle(&m.items, m.listIdx)
|
|
return m, m.loadPreviewCmd()
|
|
case ActionRefresh:
|
|
m.loading = true
|
|
return m, m.loadListCmd(m.subject)
|
|
case ActionOpenVex:
|
|
return m, m.openVexCmd()
|
|
}
|
|
if m.focus == model.FocusMenu && (key == "enter" || key == " ") {
|
|
if subj, ok := m.menu.CurrentSubject(); ok {
|
|
m.subject = subj
|
|
m.selection.Clear()
|
|
m.listIdx = 0
|
|
m.focus = model.FocusList
|
|
m.loading = true
|
|
return m, m.loadListCmd(subj)
|
|
}
|
|
if m.menu.IsExpandable(m.menu.Cursor()) {
|
|
m.menu.ToggleExpand(m.menu.Cursor())
|
|
}
|
|
return m, nil
|
|
}
|
|
case listLoadedMsg:
|
|
m.loading = false
|
|
if msg.err != nil {
|
|
m.err = msg.err.Error()
|
|
m.items = nil
|
|
return m, nil
|
|
}
|
|
m.err = ""
|
|
m.items = msg.items
|
|
m.subject = msg.subject
|
|
return m, nil
|
|
case previewLoadedMsg:
|
|
m.loading = false
|
|
if msg.err != nil {
|
|
m.previewMD = fmt.Sprintf("# Error\n\n%s\n", msg.err.Error())
|
|
} else {
|
|
m.previewMD = msg.text
|
|
}
|
|
m.renderPreview()
|
|
return m, nil
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
if m.focus == model.FocusPreview {
|
|
m.preview, cmd = m.preview.Update(msg)
|
|
}
|
|
return m, cmd
|
|
}
|
|
|
|
// View implements tea.Model.
|
|
func (m Model) View() string {
|
|
if m.width == 0 {
|
|
return "Loading…\n"
|
|
}
|
|
menuW, listW, prevW := LayoutWidths(m.width)
|
|
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(menuW).Height(m.height - 2)
|
|
listStyle := paneStyle(m.focus == model.FocusList).Width(listW).Height(m.height - 2)
|
|
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(prevW).Height(m.height - 2)
|
|
|
|
status := m.status
|
|
if m.loading {
|
|
status = "Loading…"
|
|
}
|
|
if m.err != "" {
|
|
status = "Error: " + m.err
|
|
}
|
|
bar := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(status)
|
|
|
|
return lipgloss.JoinHorizontal(lipgloss.Top,
|
|
menuStyle.Render(m.renderMenu(menuW)),
|
|
listStyle.Render(m.renderList(listW)),
|
|
prevStyle.Render(m.preview.View()),
|
|
) + "\n" + bar
|
|
}
|
|
|
|
func (m *Model) layoutViewports() {
|
|
_, _, prevW := LayoutWidths(m.width)
|
|
h := m.height - 4
|
|
if h < 4 {
|
|
h = 4
|
|
}
|
|
m.preview.Width = prevW - 2
|
|
m.preview.Height = h
|
|
m.renderPreview()
|
|
}
|
|
|
|
func (m *Model) renderPreview() {
|
|
_, _, prevW := LayoutWidths(m.width)
|
|
text, err := preview.RenderMarkdown(m.previewMD, prevW-4)
|
|
if err != nil {
|
|
text = m.previewMD
|
|
}
|
|
m.preview.SetContent(text)
|
|
}
|
|
|
|
func (m *Model) renderMenu(w int) string {
|
|
var b strings.Builder
|
|
b.WriteString("Modules\n\n")
|
|
for i := 0; i < m.menu.VisibleCount(); i++ {
|
|
label, depth := m.menu.LabelAt(i)
|
|
prefix := strings.Repeat(" ", depth)
|
|
line := prefix + label
|
|
if m.menu.IsExpandable(i) {
|
|
if m.menu.IsExpanded(i) {
|
|
line = prefix + "▾ " + label
|
|
} else {
|
|
line = prefix + "▸ " + label
|
|
}
|
|
}
|
|
if i == m.menu.Cursor() && m.focus == model.FocusMenu {
|
|
line = "> " + line
|
|
} else {
|
|
line = " " + line
|
|
}
|
|
if len(line) > w-1 {
|
|
line = line[:w-2] + "…"
|
|
}
|
|
b.WriteString(line + "\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (m *Model) renderList(w int) string {
|
|
title := string(m.subject)
|
|
if title == "" {
|
|
title = "Items"
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "%s (%d)\n\n", title, len(m.items))
|
|
for i, it := range m.items {
|
|
mark := "[ ]"
|
|
if it.Selected {
|
|
mark = "[x]"
|
|
}
|
|
cursor := " "
|
|
if i == m.listIdx && m.focus == model.FocusList {
|
|
cursor = "> "
|
|
}
|
|
line := fmt.Sprintf("%s%s %s", cursor, mark, it.Title)
|
|
if it.Subtitle != "" {
|
|
line += " — " + it.Subtitle
|
|
}
|
|
if len(line) > w-1 {
|
|
line = line[:w-2] + "…"
|
|
}
|
|
b.WriteString(line + "\n")
|
|
}
|
|
if len(m.items) == 0 && !m.loading {
|
|
b.WriteString("(empty)\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func (m *Model) moveUp() {
|
|
switch m.focus {
|
|
case model.FocusMenu:
|
|
m.menu.MoveUp()
|
|
case model.FocusList:
|
|
if m.listIdx > 0 {
|
|
m.listIdx--
|
|
}
|
|
case model.FocusPreview:
|
|
m.preview.LineUp(1)
|
|
}
|
|
}
|
|
|
|
func (m *Model) moveDown() {
|
|
switch m.focus {
|
|
case model.FocusMenu:
|
|
m.menu.MoveDown()
|
|
case model.FocusList:
|
|
if m.listIdx < len(m.items)-1 {
|
|
m.listIdx++
|
|
}
|
|
case model.FocusPreview:
|
|
m.preview.LineDown(1)
|
|
}
|
|
}
|
|
|
|
func (m *Model) loadListCmd(subject model.Subject) tea.Cmd {
|
|
return func() tea.Msg {
|
|
ctx := context.Background()
|
|
items, err := m.loader.List(ctx, subject)
|
|
return listLoadedMsg{items: items, subject: subject, err: err}
|
|
}
|
|
}
|
|
|
|
func (m *Model) loadPreviewCmd() tea.Cmd {
|
|
if m.listIdx < 0 || m.listIdx >= len(m.items) {
|
|
return nil
|
|
}
|
|
item := m.items[m.listIdx]
|
|
return func() tea.Msg {
|
|
ctx := context.Background()
|
|
raw, err := m.loader.Detail(ctx, item)
|
|
if err != nil {
|
|
return previewLoadedMsg{err: err}
|
|
}
|
|
md := preview.EntityMarkdown(string(item.Kind), raw)
|
|
return previewLoadedMsg{text: md}
|
|
}
|
|
}
|
|
|
|
func (m *Model) openVexCmd() tea.Cmd {
|
|
if m.listIdx < 0 || m.listIdx >= len(m.items) {
|
|
return nil
|
|
}
|
|
item := m.items[m.listIdx]
|
|
if item.Kind != model.KindFile {
|
|
return func() tea.Msg {
|
|
return previewLoadedMsg{text: "_Press v on a spreadsheet file row._\n"}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func paneStyle(focused bool) lipgloss.Style {
|
|
s := lipgloss.NewStyle().Padding(0, 1)
|
|
if focused {
|
|
return s.Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("62"))
|
|
}
|
|
return s.Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("238"))
|
|
}
|
|
|
|
func helpText() string {
|
|
return "Tab: pane | j/k: move | Space: select | Enter: preview | r: refresh | v: vex | q: quit"
|
|
}
|
|
|
|
// OpenVex launches the vex-tui binary for a file path when available.
|
|
func OpenVex(path string) error {
|
|
if _, err := exec.LookPath("vex"); err != nil {
|
|
return fmt.Errorf("vex not found on PATH")
|
|
}
|
|
cmd := exec.Command("vex", path)
|
|
return cmd.Run()
|
|
}
|