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:
2026-06-24 16:29:04 +01:00
co-authored by Cursor
parent 65f157b0c2
commit 7358ff5c32
32 changed files with 2267 additions and 94 deletions
+114
View File
@@ -0,0 +1,114 @@
package model
// Kind identifies the OnlyOffice entity type for list items and preview routing.
type Kind string
const (
KindProject Kind = "project"
KindTask Kind = "task"
KindContact Kind = "contact"
KindOpportunity Kind = "opportunity"
KindCase Kind = "case"
KindCRMTask Kind = "crm_task"
KindMail Kind = "mail"
KindEvent Kind = "event"
KindCalendar Kind = "calendar"
KindFile Kind = "file"
KindUser Kind = "user"
)
// Subject identifies a menu leaf / list source.
type Subject string
const (
SubjectProjects Subject = "projects"
SubjectTasks Subject = "tasks"
SubjectCalendars Subject = "calendars"
SubjectEvents Subject = "events"
SubjectContacts Subject = "contacts"
SubjectPersons Subject = "persons"
SubjectCompanies Subject = "companies"
SubjectOpportunities Subject = "opportunities"
SubjectCases Subject = "cases"
SubjectCRMTasks Subject = "crm_tasks"
SubjectMailInbox Subject = "mail_inbox"
SubjectMailSent Subject = "mail_sent"
SubjectMailDrafts Subject = "mail_drafts"
SubjectMailTrash Subject = "mail_trash"
SubjectMailSpam Subject = "mail_spam"
SubjectProjectFiles Subject = "project_files"
SubjectTaskFiles Subject = "task_files"
SubjectUsers Subject = "users"
)
// FocusPane is which column has keyboard focus.
type FocusPane int
const (
FocusMenu FocusPane = iota
FocusList
FocusPreview
)
// NextFocusPane cycles menu → list → preview → menu.
func NextFocusPane(p FocusPane) FocusPane {
switch p {
case FocusMenu:
return FocusList
case FocusList:
return FocusPreview
default:
return FocusMenu
}
}
// Item is one row in the center list pane.
type Item struct {
ID, Title, Subtitle string
Kind Kind
Raw map[string]any
Selected bool
}
// Selection tracks multi-selected item IDs within the current subject.
type Selection struct {
ids map[string]struct{}
}
// NewSelection returns an empty selection set.
func NewSelection() *Selection {
return &Selection{ids: make(map[string]struct{})}
}
// Toggle flips selection on items[idx] and updates the ID set.
func (s *Selection) Toggle(items *[]Item, idx int) {
if idx < 0 || idx >= len(*items) {
return
}
it := &(*items)[idx]
it.Selected = !it.Selected
if it.Selected {
s.ids[it.ID] = struct{}{}
} else {
delete(s.ids, it.ID)
}
}
// Count returns the number of selected items.
func (s *Selection) Count() int {
return len(s.ids)
}
// Clear removes all selections.
func (s *Selection) Clear() {
s.ids = make(map[string]struct{})
}
// IDs returns selected item IDs in stable order (map iteration order is fine for display).
func (s *Selection) IDs() []string {
out := make([]string, 0, len(s.ids))
for id := range s.ids {
out = append(out, id)
}
return out
}
+45
View File
@@ -0,0 +1,45 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestSelectionToggle(t *testing.T) {
items := []model.Item{
{ID: "1", Title: "A"},
{ID: "2", Title: "B"},
}
sel := model.NewSelection()
if sel.Count() != 0 {
t.Fatalf("initial count=%d", sel.Count())
}
sel.Toggle(&items, 0)
if !items[0].Selected || sel.Count() != 1 {
t.Fatalf("after toggle 0: selected=%v count=%d", items[0].Selected, sel.Count())
}
sel.Toggle(&items, 0)
if items[0].Selected || sel.Count() != 0 {
t.Fatalf("after second toggle: selected=%v count=%d", items[0].Selected, sel.Count())
}
}
func TestSelectionClearOnSubjectChange(t *testing.T) {
items := []model.Item{{ID: "1", Title: "A", Selected: true}}
sel := model.NewSelection()
sel.Toggle(&items, 0)
sel.Clear()
if sel.Count() != 0 {
t.Fatalf("count=%d after clear", sel.Count())
}
}
func TestNextFocusPane(t *testing.T) {
if got := model.NextFocusPane(model.FocusMenu); got != model.FocusList {
t.Fatalf("menu->list got %v", got)
}
if got := model.NextFocusPane(model.FocusPreview); got != model.FocusMenu {
t.Fatalf("preview->menu got %v", got)
}
}
+179
View File
@@ -0,0 +1,179 @@
package model
// menuNode is one row in the left navigation tree (flat slice with depth).
type menuNode struct {
label string
depth int
subject Subject // zero when branch node
expandable bool
parent int // index of parent branch, -1 for roots
}
// MenuTree is the left-pane OnlyOffice module navigator.
type MenuTree struct {
nodes []menuNode
cursor int
expanded map[int]bool
visible []int // indices into nodes, recomputed on expand/collapse
}
// DefaultMenuTree returns the standard OnlyOffice Workspace module tree.
func DefaultMenuTree() *MenuTree {
nodes := []menuNode{
{label: "Projects", depth: 0, expandable: true, parent: -1},
{label: "All projects", depth: 1, subject: SubjectProjects, parent: 0},
{label: "Tasks", depth: 1, subject: SubjectTasks, parent: 0},
{label: "Calendar", depth: 0, expandable: true, parent: -1},
{label: "Calendars", depth: 1, subject: SubjectCalendars, parent: 3},
{label: "Events", depth: 1, subject: SubjectEvents, parent: 3},
{label: "CRM", depth: 0, expandable: true, parent: -1},
{label: "Contacts", depth: 1, subject: SubjectContacts, parent: 6},
{label: "Persons", depth: 1, subject: SubjectPersons, parent: 6},
{label: "Companies", depth: 1, subject: SubjectCompanies, parent: 6},
{label: "Opportunities", depth: 1, subject: SubjectOpportunities, parent: 6},
{label: "Cases", depth: 1, subject: SubjectCases, parent: 6},
{label: "CRM Tasks", depth: 1, subject: SubjectCRMTasks, parent: 6},
{label: "Mail", depth: 0, expandable: true, parent: -1},
{label: "Inbox", depth: 1, subject: SubjectMailInbox, parent: 13},
{label: "Sent", depth: 1, subject: SubjectMailSent, parent: 13},
{label: "Drafts", depth: 1, subject: SubjectMailDrafts, parent: 13},
{label: "Trash", depth: 1, subject: SubjectMailTrash, parent: 13},
{label: "Spam", depth: 1, subject: SubjectMailSpam, parent: 13},
{label: "Documents", depth: 0, expandable: true, parent: -1},
{label: "Project files", depth: 1, subject: SubjectProjectFiles, parent: 19},
{label: "Task files", depth: 1, subject: SubjectTaskFiles, parent: 19},
{label: "Users", depth: 0, expandable: true, parent: -1},
{label: "Directory", depth: 1, subject: SubjectUsers, parent: 22},
}
m := &MenuTree{
nodes: nodes,
expanded: map[int]bool{},
}
m.rebuildVisible()
return m
}
// RootLabels returns top-level module names in order.
func (m *MenuTree) RootLabels() []string {
var out []string
for i, n := range m.nodes {
if n.depth == 0 {
out = append(out, n.label)
}
_ = i
}
return out
}
// Cursor returns the visible-row cursor index.
func (m *MenuTree) Cursor() int { return m.cursor }
// SetCursor sets the visible-row cursor.
func (m *MenuTree) SetCursor(c int) {
if c < 0 {
c = 0
}
if c >= len(m.visible) {
c = len(m.visible) - 1
}
if c < 0 {
c = 0
}
m.cursor = c
}
// MoveDown advances the menu cursor.
func (m *MenuTree) MoveDown() {
m.SetCursor(m.cursor + 1)
}
// MoveUp moves the menu cursor up.
func (m *MenuTree) MoveUp() {
m.SetCursor(m.cursor - 1)
}
// VisibleCount returns the number of visible menu rows.
func (m *MenuTree) VisibleCount() int { return len(m.visible) }
// LabelAt returns the label and depth for visible row i.
func (m *MenuTree) LabelAt(i int) (label string, depth int) {
if i < 0 || i >= len(m.visible) {
return "", 0
}
n := m.nodes[m.visible[i]]
return n.label, n.depth
}
// IsExpandable reports whether visible row i is a branch node.
func (m *MenuTree) IsExpandable(visibleIdx int) bool {
if visibleIdx < 0 || visibleIdx >= len(m.visible) {
return false
}
return m.nodes[m.visible[visibleIdx]].expandable
}
// IsExpanded reports whether the branch at visible row i is expanded.
func (m *MenuTree) IsExpanded(visibleIdx int) bool {
if visibleIdx < 0 || visibleIdx >= len(m.visible) {
return false
}
return m.expanded[m.visible[visibleIdx]]
}
// ToggleExpand expands or collapses the branch at visible row i.
func (m *MenuTree) ToggleExpand(visibleIdx int) {
if !m.IsExpandable(visibleIdx) {
return
}
idx := m.visible[visibleIdx]
if m.expanded[idx] {
delete(m.expanded, idx)
} else {
m.expanded[idx] = true
}
m.rebuildVisible()
}
// SelectIndex returns the subject for visible row i when it is a leaf.
func (m *MenuTree) SelectIndex(visibleIdx int) (Subject, bool) {
if visibleIdx < 0 || visibleIdx >= len(m.visible) {
return "", false
}
n := m.nodes[m.visible[visibleIdx]]
if n.subject == "" {
return "", false
}
return n.subject, true
}
// CurrentSubject returns the subject at the current cursor if it is a leaf.
func (m *MenuTree) CurrentSubject() (Subject, bool) {
return m.SelectIndex(m.cursor)
}
func (m *MenuTree) rebuildVisible() {
m.visible = m.visible[:0]
for i, n := range m.nodes {
if n.depth == 0 {
m.visible = append(m.visible, i)
if m.expanded[i] {
m.appendChildren(i)
}
continue
}
}
if m.cursor >= len(m.visible) {
m.cursor = len(m.visible) - 1
}
if m.cursor < 0 {
m.cursor = 0
}
}
func (m *MenuTree) appendChildren(branchIdx int) {
for i, n := range m.nodes {
if n.parent == branchIdx {
m.visible = append(m.visible, i)
}
}
}
+49
View File
@@ -0,0 +1,49 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestMenuTreeHasExpectedRoots(t *testing.T) {
tree := model.DefaultMenuTree()
roots := tree.RootLabels()
want := []string{"Projects", "Calendar", "CRM", "Mail", "Documents", "Users"}
if len(roots) != len(want) {
t.Fatalf("roots=%v want %v", roots, want)
}
for i, w := range want {
if roots[i] != w {
t.Fatalf("root[%d]=%q want %q", i, roots[i], w)
}
}
}
func TestMenuExpandCollapse(t *testing.T) {
tree := model.DefaultMenuTree()
if !tree.IsExpandable(0) {
t.Fatal("Projects should be expandable")
}
tree.ToggleExpand(0)
if !tree.IsExpanded(0) {
t.Fatal("Projects should expand")
}
tree.ToggleExpand(0)
if tree.IsExpanded(0) {
t.Fatal("Projects should collapse")
}
}
func TestMenuSelectLeafReturnsSubject(t *testing.T) {
tree := model.DefaultMenuTree()
// Expand Projects and select "All projects" (first child).
tree.ToggleExpand(0)
subj, ok := tree.SelectIndex(1)
if !ok {
t.Fatal("expected leaf subject")
}
if subj != model.SubjectProjects {
t.Fatalf("subject=%v want Projects", subj)
}
}