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>
This commit is contained in:
2026-06-24 16:43:06 +01:00
co-authored by Cursor
parent e969624297
commit fe2ee481b9
14 changed files with 929 additions and 464 deletions
+326 -144
View File
@@ -3,7 +3,6 @@ package ui
import (
"context"
"fmt"
"os/exec"
"strings"
"github.com/charmbracelet/bubbles/viewport"
@@ -16,9 +15,9 @@ import (
)
type listLoadedMsg struct {
items []model.Item
subject model.Subject
err error
items []model.Item
spec model.ListSpec
err error
}
type previewLoadedMsg struct {
@@ -26,59 +25,76 @@ type previewLoadedMsg struct {
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
menu *model.MenuTree
items []model.Item
client *onlyoffice.Client
loader *fetch.Loader
nav *model.NavTree
listSpec model.ListSpec
hasList bool
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
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
ready bool
// 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 {
vp := viewport.New(40, 20)
return Model{
client: client,
loader: &fetch.Loader{Client: client},
menu: model.DefaultMenuTree(),
nav: model.DefaultNavTree(),
selection: model.NewSelection(),
focus: model.FocusMenu,
preview: vp,
status: "office — Tab: pane Space: select Enter: preview q: quit",
status: "Tab/Shift+Tab: pane · Enter: open/activate · a: actions · q: quit",
}
}
// Init implements tea.Model.
func (m Model) Init() tea.Cmd {
return nil
return m.loadNavProjectsCmd()
}
// 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()
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)
@@ -89,49 +105,85 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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:
m.selection.Toggle(&m.items, m.listIdx)
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:
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.hasList {
m.selection.Toggle(&m.items, m.listIdx)
return m, m.loadPreviewCmd()
}
if m.menu.IsExpandable(m.menu.Cursor()) {
m.menu.ToggleExpand(m.menu.Cursor())
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
return m, nil
m.hasList = false
} else {
m.err = ""
m.items = msg.items
m.listSpec = msg.spec
m.hasList = true
m.listIdx = 0
}
m.err = ""
m.items = msg.items
m.subject = msg.subject
m.syncListContent()
return m, nil
case previewLoadedMsg:
m.loading = false
if msg.err != nil {
@@ -139,26 +191,53 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.previewMD = msg.text
}
m.renderPreview()
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
if m.focus == model.FocusPreview {
m.preview, cmd = m.preview.Update(msg)
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
}
// 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)
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 {
@@ -170,121 +249,230 @@ func (m Model) View() string {
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()),
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() {
_, _, prevW := LayoutWidths(m.width)
menuW, listW, 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
}
inner := func(w int) int {
if w > 4 {
return w - 2
}
if i == m.menu.Cursor() && m.focus == model.FocusMenu {
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
}
if len(line) > w-1 {
line = line[:w-2] + "…"
}
b.WriteString(line + "\n")
}
return b.String()
m.menuVP.SetContent(b.String())
syncVPToLine(&m.menuVP, m.nav.Cursor()+2)
}
func (m *Model) renderList(w int) string {
title := string(m.subject)
if title == "" {
title = "Items"
}
func (m *Model) syncListContent() {
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]"
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")
}
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")
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
}
return b.String()
}
func (m *Model) moveUp() {
switch m.focus {
case model.FocusMenu:
m.menu.MoveUp()
m.nav.MoveUp()
case model.FocusList:
if m.listIdx > 0 {
if m.hasList && !m.actionMode && m.listIdx > 0 {
m.listIdx--
}
case model.FocusPreview:
m.preview.LineUp(1)
m.previewVP.LineUp(1)
}
}
func (m *Model) moveDown() {
switch m.focus {
case model.FocusMenu:
m.menu.MoveDown()
m.nav.MoveDown()
case model.FocusList:
if m.listIdx < len(m.items)-1 {
if m.hasList && !m.actionMode && m.listIdx < len(m.items)-1 {
m.listIdx++
}
case model.FocusPreview:
m.preview.LineDown(1)
m.previewVP.LineDown(1)
}
}
func (m *Model) loadListCmd(subject model.Subject) tea.Cmd {
func (m *Model) loadListCmd(spec model.ListSpec) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
items, err := m.loader.List(ctx, subject)
return listLoadedMsg{items: items, subject: subject, err: err}
items, err := m.loader.List(ctx, spec)
return listLoadedMsg{items: items, spec: spec, err: err}
}
}
@@ -304,17 +492,20 @@ func (m *Model) loadPreviewCmd() tea.Cmd {
}
}
func (m *Model) openVexCmd() tea.Cmd {
if m.listIdx < 0 || m.listIdx >= len(m.items) {
return nil
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}
}
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"}
}
}
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}
}
return nil
}
func paneStyle(focused bool) lipgloss.Style {
@@ -326,14 +517,5 @@ func paneStyle(focused bool) lipgloss.Style {
}
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()
return "Tab/Shift+Tab: pane · ↑↓/jk: scroll · Enter: open leaf/actions · Space: select · a: actions · r: refresh · q: quit"
}