feat(office): add Workspace TUI with shared bootstrap and test suite
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>
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user