Files
go-onlyoffice/cmd/office/ui/app.go
T
eSliderandCursor fe2ee481b9 feat(office): scrollable panes, nav tree drill-down, and item actions
Add viewports to all three columns with cursor sync scrolling. Support
Shift+Tab reverse pane focus. Replace flat menu with hierarchical NavTree
(list loads only on leaf nodes; dynamic project subnodes with Tasks/Files).
Enter or a opens per-item action menu (view, delete, download) wired to
the OnlyOffice API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 16:43:06 +01:00

522 lines
12 KiB
Go

package ui
import (
"context"
"fmt"
"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
spec model.ListSpec
err error
}
type previewLoadedMsg struct {
text string
err error
}
type actionDoneMsg struct {
message string
err error
}
type navProjectsMsg struct {
projects []model.Item
err error
}
// Model is the root Bubble Tea model for the office TUI.
type Model struct {
client *onlyoffice.Client
loader *fetch.Loader
nav *model.NavTree
listSpec model.ListSpec
hasList bool
items []model.Item
selection *model.Selection
listIdx int
focus model.FocusPane
width int
height int
status string
err string
loading bool
menuVP viewport.Model
listVP viewport.Model
previewVP viewport.Model
previewMD string
// Action menu overlay on list pane
actionMode bool
actionIdx int
itemActions []model.ItemAction
}
// NewModel constructs the TUI with an authenticated client.
func NewModel(client *onlyoffice.Client) Model {
return Model{
client: client,
loader: &fetch.Loader{Client: client},
nav: model.DefaultNavTree(),
selection: model.NewSelection(),
focus: model.FocusMenu,
status: "Tab/Shift+Tab: pane · Enter: open/activate · a: actions · q: quit",
}
}
func (m Model) Init() tea.Cmd {
return m.loadNavProjectsCmd()
}
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()
m.syncAllContent()
return m, nil
case tea.KeyMsg:
key := msg.String()
if key == "?" {
m.status = helpText()
return m, nil
}
if m.actionMode && m.focus == model.FocusList {
return m.handleActionKey(key)
}
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 ActionPrevPane:
m.focus = model.PrevFocusPane(m.focus)
return m, nil
case ActionMoveUp:
m.moveUp()
m.syncAllContent()
return m, nil
case ActionMoveDown:
m.moveDown()
m.syncAllContent()
return m, nil
case ActionToggleSelect:
if m.hasList {
m.selection.Toggle(&m.items, m.listIdx)
m.syncListContent()
}
return m, nil
case ActionOpenActions:
if m.hasList && len(m.items) > 0 {
m.openActionMenu()
m.syncListContent()
}
return m, nil
case ActionOpenPreview:
if m.hasList {
m.selection.Toggle(&m.items, m.listIdx)
return m, m.loadPreviewCmd()
}
return m, nil
case ActionRefresh:
if m.hasList {
m.loading = true
return m, m.loadListCmd(m.listSpec)
}
return m, nil
}
if m.focus == model.FocusMenu {
switch key {
case " ":
m.nav.ToggleExpand(m.nav.Cursor())
m.syncMenuContent()
return m, nil
case "enter":
if spec, ok := m.nav.Activate(); ok {
return m.withList(*spec)
}
m.syncMenuContent()
return m, nil
case "right", "l":
if spec, ok := m.nav.CurrentListSpec(); ok {
return m.withList(*spec)
}
m.nav.Activate()
m.syncMenuContent()
return m, nil
}
}
if m.focus == model.FocusList && key == "enter" && m.hasList {
m.openActionMenu()
m.syncListContent()
return m, nil
}
case listLoadedMsg:
m.loading = false
if msg.err != nil {
m.err = msg.err.Error()
m.items = nil
m.hasList = false
} else {
m.err = ""
m.items = msg.items
m.listSpec = msg.spec
m.hasList = true
m.listIdx = 0
}
m.syncListContent()
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.syncPreviewContent()
return m, nil
case actionDoneMsg:
m.loading = false
m.actionMode = false
if msg.err != nil {
m.err = msg.err.Error()
} else {
m.status = msg.message
m.err = ""
if m.hasList {
m.loading = true
return m, m.loadListCmd(m.listSpec)
}
}
return m, nil
case navProjectsMsg:
if msg.err == nil {
m.nav.InjectProjectNodes(msg.projects)
m.syncMenuContent()
}
return m, nil
}
var cmd tea.Cmd
switch m.focus {
case model.FocusMenu:
m.menuVP, cmd = m.menuVP.Update(msg)
case model.FocusList:
m.listVP, cmd = m.listVP.Update(msg)
case model.FocusPreview:
m.previewVP, cmd = m.previewVP.Update(msg)
}
return m, cmd
}
func (m Model) View() string {
if m.width == 0 {
return "Loading…\n"
}
menuW, listW, prevW := LayoutWidths(m.width)
h := m.height - 2
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(menuW).Height(h)
listStyle := paneStyle(m.focus == model.FocusList).Width(listW).Height(h)
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(prevW).Height(h)
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.menuVP.View()),
listStyle.Render(m.listVP.View()),
prevStyle.Render(m.previewVP.View()),
) + "\n" + bar
}
func (m Model) withList(spec model.ListSpec) (Model, tea.Cmd) {
m.listSpec = spec
m.selection.Clear()
m.listIdx = 0
m.hasList = false
m.actionMode = false
m.focus = model.FocusList
m.loading = true
return m, m.loadListCmd(spec)
}
func (m *Model) openActionMenu() {
if m.listIdx < 0 || m.listIdx >= len(m.items) {
return
}
m.itemActions = model.ActionsFor(m.items[m.listIdx].Kind)
m.actionIdx = 0
m.actionMode = true
}
func (m *Model) handleActionKey(key string) (tea.Model, tea.Cmd) {
switch key {
case "esc":
m.actionMode = false
m.syncListContent()
return m, nil
case "up", "k":
if m.actionIdx > 0 {
m.actionIdx--
}
m.syncListContent()
return m, nil
case "down", "j":
if m.actionIdx < len(m.itemActions)-1 {
m.actionIdx++
}
m.syncListContent()
return m, nil
case "enter":
if m.listIdx < 0 || m.listIdx >= len(m.items) || m.actionIdx >= len(m.itemActions) {
return m, nil
}
act := m.itemActions[m.actionIdx]
item := m.items[m.listIdx]
m.actionMode = false
if act.ID == model.ActionView {
return m, m.loadPreviewCmd()
}
m.loading = true
return m, m.executeActionCmd(act.ID, item)
}
return m, nil
}
func (m *Model) layoutViewports() {
menuW, listW, prevW := LayoutWidths(m.width)
h := m.height - 4
if h < 4 {
h = 4
}
inner := func(w int) int {
if w > 4 {
return w - 2
}
return w
}
m.menuVP = viewport.New(inner(menuW), h)
m.listVP = viewport.New(inner(listW), h)
m.previewVP = viewport.New(inner(prevW), h)
}
func (m *Model) syncAllContent() {
m.syncMenuContent()
m.syncListContent()
m.syncPreviewContent()
}
func (m *Model) syncMenuContent() {
var b strings.Builder
b.WriteString("Navigation\n\n")
for i := 0; i < m.nav.VisibleCount(); i++ {
n, _ := m.nav.NodeAtVisible(i)
depth := m.nav.DepthAtVisible(i)
prefix := strings.Repeat(" ", depth)
line := prefix + n.Label
if m.nav.IsExpandable(i) {
if m.nav.IsExpanded(i) {
line = prefix + "▾ " + n.Label
} else {
line = prefix + "▸ " + n.Label
}
} else if n.List != nil {
line = prefix + "• " + n.Label
}
if i == m.nav.Cursor() && m.focus == model.FocusMenu {
line = "> " + line
} else {
line = " " + line
}
b.WriteString(line + "\n")
}
m.menuVP.SetContent(b.String())
syncVPToLine(&m.menuVP, m.nav.Cursor()+2)
}
func (m *Model) syncListContent() {
var b strings.Builder
if !m.hasList {
b.WriteString("List\n\n")
b.WriteString("Select a leaf node in the tree\n")
b.WriteString("(marked with •) and press Enter.\n")
} else {
fmt.Fprintf(&b, "%s (%d)\n\n", m.listSpec.Subject, len(m.items))
if m.actionMode {
item := m.items[m.listIdx]
fmt.Fprintf(&b, "Actions for: %s\n\n", item.Title)
for i, act := range m.itemActions {
cursor := " "
if i == m.actionIdx {
cursor = "> "
}
label := act.Label
if act.Danger {
label = "⚠ " + label
}
fmt.Fprintf(&b, "%s%s\n", cursor, label)
}
b.WriteString("\nEnter: run · Esc: cancel\n")
} else {
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
}
b.WriteString(line + "\n")
}
if len(m.items) == 0 && !m.loading {
b.WriteString("(empty)\n")
}
b.WriteString("\nEnter/a: actions · Space: select\n")
}
}
m.listVP.SetContent(b.String())
line := m.listIdx + 3
if m.actionMode {
line = m.actionIdx + 4
}
syncVPToLine(&m.listVP, line)
}
func (m *Model) syncPreviewContent() {
menuW, listW, prevW := LayoutWidths(m.width)
_ = menuW
_ = listW
w := prevW - 4
if w < 20 {
w = 20
}
text, err := preview.RenderMarkdown(m.previewMD, w)
if err != nil {
text = m.previewMD
}
m.previewVP.SetContent(text)
}
func syncVPToLine(vp *viewport.Model, line int) {
if line < 0 {
line = 0
}
if line < vp.YOffset {
vp.YOffset = line
} else if line >= vp.YOffset+vp.Height {
vp.YOffset = line - vp.Height + 1
}
if vp.YOffset < 0 {
vp.YOffset = 0
}
}
func (m *Model) moveUp() {
switch m.focus {
case model.FocusMenu:
m.nav.MoveUp()
case model.FocusList:
if m.hasList && !m.actionMode && m.listIdx > 0 {
m.listIdx--
}
case model.FocusPreview:
m.previewVP.LineUp(1)
}
}
func (m *Model) moveDown() {
switch m.focus {
case model.FocusMenu:
m.nav.MoveDown()
case model.FocusList:
if m.hasList && !m.actionMode && m.listIdx < len(m.items)-1 {
m.listIdx++
}
case model.FocusPreview:
m.previewVP.LineDown(1)
}
}
func (m *Model) loadListCmd(spec model.ListSpec) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
items, err := m.loader.List(ctx, spec)
return listLoadedMsg{items: items, spec: spec, 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) executeActionCmd(action model.ActionID, item model.Item) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
msg, err := m.loader.Execute(ctx, action, item, "")
return actionDoneMsg{message: msg, err: err}
}
}
func (m *Model) loadNavProjectsCmd() tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
projects, err := m.loader.LoadProjectsForNav(ctx)
return navProjectsMsg{projects: projects, err: err}
}
}
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/Shift+Tab: pane · ↑↓/jk: scroll · Enter: open leaf/actions · Space: select · a: actions · r: refresh · q: quit"
}