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
+8 -4
View File
@@ -575,13 +575,17 @@ office
| Key | Action | | Key | Action |
|---|---| |---|---|
| `Tab` | Switch pane (menu → list → preview) | | `Tab` | Next pane (menu → list → preview) |
| `↑↓` / `j` / `k` | Navigate | | `Shift+Tab` | Previous pane |
| `Space` / `Enter` | Toggle selection on list row | | `↑↓` / `j` / `k` | Navigate / scroll within focused pane |
| `Enter` | Load preview for focused row | | `Enter` / `→` | Expand tree branch or open leaf list |
| `Space` | Toggle multi-select on list row |
| `Enter` / `a` | Action menu (view, delete, download, …) |
| `r` | Refresh current list | | `r` | Refresh current list |
| `q` | Quit | | `q` | Quit |
Navigate the **tree** on the left: expand modules (`▸`/`▾`), drill to a **leaf** (marked `•`) — the center list loads only at the last level. Under **Projects → By project**, live projects appear as subnodes with **Tasks** and **Files** leaves.
Optional env for DOCX preview via Document Server (see [`.env.example`](.env.example)): Optional env for DOCX preview via Document Server (see [`.env.example`](.env.example)):
```bash ```bash
+24 -55
View File
@@ -1,5 +1,3 @@
//go:build integration
package fetch_test package fetch_test
import ( import (
@@ -38,50 +36,34 @@ func liveLoader(t *testing.T) (*fetch.Loader, context.Context) {
func TestIntegrationListAllSubjects(t *testing.T) { func TestIntegrationListAllSubjects(t *testing.T) {
loader, ctx := liveLoader(t) loader, ctx := liveLoader(t)
cases := []struct { cases := []model.ListSpec{
subject model.Subject {Subject: model.SubjectProjects},
skip string {Subject: model.SubjectTasks},
}{ {Subject: model.SubjectCalendars},
{model.SubjectProjects, ""}, {Subject: model.SubjectEvents},
{model.SubjectTasks, ""}, {Subject: model.SubjectContacts},
{model.SubjectCalendars, ""}, {Subject: model.SubjectPersons},
{model.SubjectEvents, ""}, {Subject: model.SubjectCompanies},
{model.SubjectContacts, ""}, {Subject: model.SubjectOpportunities},
{model.SubjectPersons, ""}, {Subject: model.SubjectCases},
{model.SubjectCompanies, ""}, {Subject: model.SubjectCRMTasks},
{model.SubjectOpportunities, ""}, {Subject: model.SubjectMailInbox},
{model.SubjectCases, ""}, {Subject: model.SubjectUsers},
{model.SubjectCRMTasks, ""},
{model.SubjectMailInbox, ""},
{model.SubjectMailSent, ""},
{model.SubjectMailDrafts, ""},
{model.SubjectMailTrash, ""},
{model.SubjectMailSpam, ""},
{model.SubjectUsers, ""},
{model.SubjectProjectFiles, "ONLYOFFICE_PROJECT_ID not set"},
} }
for _, tc := range cases { for _, spec := range cases {
t.Run(string(tc.subject), func(t *testing.T) { t.Run(string(spec.Subject), func(t *testing.T) {
if tc.subject == model.SubjectProjectFiles { items, err := loader.List(ctx, spec)
if os.Getenv("ONLYOFFICE_PROJECT_ID") == "" {
t.Skip(tc.skip)
}
}
items, err := loader.List(ctx, tc.subject)
if err != nil { if err != nil {
t.Fatalf("List(%s): %v", tc.subject, err) t.Fatalf("List(%s): %v", spec.Subject, err)
} }
t.Logf("%s: %d items", tc.subject, len(items)) t.Logf("%s: %d items", spec.Subject, len(items))
for i, it := range items { for i, it := range items {
if it.ID == "" { if it.ID == "" {
t.Errorf("item[%d] missing ID: %+v", i, it) t.Errorf("item[%d] missing ID", i)
} }
if it.Title == "" { if it.Title == "" {
t.Errorf("item[%d] missing Title: id=%s kind=%s", i, it.ID, it.Kind) t.Errorf("item[%d] missing Title", i)
}
if it.Kind == "" {
t.Errorf("item[%d] missing Kind: id=%s", i, it.ID)
} }
} }
}) })
@@ -90,30 +72,26 @@ func TestIntegrationListAllSubjects(t *testing.T) {
func TestIntegrationListProjectsMapsRealFields(t *testing.T) { func TestIntegrationListProjectsMapsRealFields(t *testing.T) {
loader, ctx := liveLoader(t) loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.SubjectProjects) spec := model.ListSpec{Subject: model.SubjectProjects}
items, err := loader.List(ctx, spec)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(items) == 0 { if len(items) == 0 {
t.Skip("no projects on instance") t.Skip("no projects on instance")
} }
it := items[0] detail, err := loader.Detail(ctx, items[0])
if it.Raw == nil {
t.Fatal("expected Raw payload from API")
}
detail, err := loader.Detail(ctx, it)
if err != nil { if err != nil {
t.Fatalf("Detail: %v", err) t.Fatalf("Detail: %v", err)
} }
if detail == nil { if detail == nil {
t.Fatal("nil detail") t.Fatal("nil detail")
} }
t.Logf("project id=%s title=%q keys=%d", it.ID, it.Title, len(detail))
} }
func TestIntegrationMailInboxDetail(t *testing.T) { func TestIntegrationMailInboxDetail(t *testing.T) {
loader, ctx := liveLoader(t) loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.SubjectMailInbox) items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectMailInbox})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -128,12 +106,3 @@ func TestIntegrationMailInboxDetail(t *testing.T) {
t.Fatalf("message missing subject: %+v", detail) t.Fatalf("message missing subject: %+v", detail)
} }
} }
func TestIntegrationEventsDateRange(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.SubjectEvents)
if err != nil {
t.Fatal(err)
}
t.Logf("events in next 7 days: %d", len(items))
}
+154 -15
View File
@@ -3,6 +3,9 @@ package fetch
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"path/filepath"
"strconv"
"time" "time"
onlyoffice "github.com/eslider/go-onlyoffice" onlyoffice "github.com/eslider/go-onlyoffice"
@@ -14,15 +17,18 @@ type Loader struct {
Client *onlyoffice.Client Client *onlyoffice.Client
} }
// List returns items for the given subject. // List returns items for the given list spec (nav leaf).
func (l *Loader) List(ctx context.Context, subject model.Subject) ([]model.Item, error) { func (l *Loader) List(ctx context.Context, spec model.ListSpec) ([]model.Item, error) {
if l == nil || l.Client == nil { if l == nil || l.Client == nil {
return nil, fmt.Errorf("fetch: client is nil") return nil, fmt.Errorf("fetch: client is nil")
} }
switch subject { switch spec.Subject {
case model.SubjectProjects: case model.SubjectProjects:
return l.listProjects(ctx) return l.listProjects(ctx)
case model.SubjectTasks: case model.SubjectTasks:
if spec.ProjectID != "" {
return l.listTasksForProject(ctx, spec.ProjectID)
}
return l.listTasks(ctx) return l.listTasks(ctx)
case model.SubjectCalendars: case model.SubjectCalendars:
return l.listCalendars(ctx) return l.listCalendars(ctx)
@@ -55,14 +61,29 @@ func (l *Loader) List(ctx context.Context, subject model.Subject) ([]model.Item,
case model.SubjectUsers: case model.SubjectUsers:
return l.listUsers(ctx) return l.listUsers(ctx)
case model.SubjectProjectFiles: case model.SubjectProjectFiles:
return l.listProjectFiles(ctx) pid := spec.ProjectID
if pid == "" {
pid = onlyoffice.GetEnvironmentDefaults().ProjectID
}
if pid == "" {
return nil, fmt.Errorf("set ONLYOFFICE_PROJECT_ID or pick a project in the tree")
}
return l.listProjectFiles(ctx, pid)
case model.SubjectTaskFiles: case model.SubjectTaskFiles:
return nil, fmt.Errorf("select a task in Tasks first (task files need task id)") if spec.TaskID == "" {
return nil, fmt.Errorf("pick a task under Projects in the tree")
}
return l.listTaskFiles(ctx, spec.TaskID)
default: default:
return nil, fmt.Errorf("unsupported subject %q", subject) return nil, fmt.Errorf("unsupported subject %q", spec.Subject)
} }
} }
// LoadProjectsForNav returns projects to inject as dynamic tree nodes.
func (l *Loader) LoadProjectsForNav(ctx context.Context) ([]model.Item, error) {
return l.listProjects(ctx)
}
// Detail fetches full record data for preview when list row is insufficient. // Detail fetches full record data for preview when list row is insufficient.
func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, error) { func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, error) {
switch item.Kind { switch item.Kind {
@@ -72,7 +93,7 @@ func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, e
return l.Client.GetContact(ctx, item.ID) return l.Client.GetContact(ctx, item.ID)
case model.KindMail: case model.KindMail:
return l.Client.GetMailMessage(ctx, item.ID) return l.Client.GetMailMessage(ctx, item.ID)
case model.KindTask: case model.KindTask, model.KindCRMTask:
return l.Client.GetTaskByID(ctx, item.ID) return l.Client.GetTaskByID(ctx, item.ID)
case model.KindProject: case model.KindProject:
return l.Client.GetProjectByID(ctx, item.ID) return l.Client.GetProjectByID(ctx, item.ID)
@@ -84,6 +105,130 @@ func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, e
} }
} }
// Execute runs a user-selected action on an item.
func (l *Loader) Execute(ctx context.Context, action model.ActionID, item model.Item, destPath string) (string, error) {
switch action {
case model.ActionView:
return "view", nil
case model.ActionDelete:
return l.executeDelete(ctx, item)
case model.ActionDownload:
return l.executeDownload(ctx, item, destPath)
default:
return "", fmt.Errorf("unsupported action %q", action)
}
}
func (l *Loader) executeDelete(ctx context.Context, item model.Item) (string, error) {
switch item.Kind {
case model.KindProject:
id, err := strconv.Atoi(item.ID)
if err != nil {
return "", err
}
if _, err := l.Client.DeleteProject(id); err != nil {
return "", err
}
return fmt.Sprintf("Deleted project %s", item.Title), nil
case model.KindTask:
if _, err := l.Client.DeleteTask(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted task %s", item.Title), nil
case model.KindContact:
if _, err := l.Client.DeleteContact(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted contact %s", item.Title), nil
case model.KindOpportunity:
if _, err := l.Client.DeleteOpportunity(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted deal %s", item.Title), nil
case model.KindCase:
if _, err := l.Client.DeleteCase(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted case %s", item.Title), nil
case model.KindCRMTask:
if _, err := l.Client.DeleteCRMTask(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted CRM task %s", item.Title), nil
case model.KindMail:
id, err := strconv.Atoi(item.ID)
if err != nil {
return "", err
}
if _, err := l.Client.RemoveMailMessages(ctx, id); err != nil {
return "", err
}
return fmt.Sprintf("Deleted message %s", item.Title), nil
case model.KindFile:
id, err := strconv.Atoi(item.ID)
if err != nil {
return "", err
}
if err := l.Client.DeleteFiles(ctx, []int{id}); err != nil {
return "", err
}
return fmt.Sprintf("Deleted file %s", item.Title), nil
default:
return "", fmt.Errorf("delete not supported for %s", item.Kind)
}
}
func (l *Loader) executeDownload(ctx context.Context, item model.Item, destPath string) (string, error) {
if item.Kind != model.KindFile {
return "", fmt.Errorf("download only for files")
}
if destPath == "" {
destPath = filepath.Join(os.TempDir(), "office", item.Title)
}
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return "", err
}
f, err := os.Create(destPath)
if err != nil {
return "", err
}
defer f.Close()
if _, err := l.Client.DownloadFile(ctx, item.ID, f); err != nil {
return "", err
}
return fmt.Sprintf("Downloaded to %s", destPath), nil
}
func (l *Loader) listTasksForProject(ctx context.Context, projectID string) ([]model.Item, error) {
rows, err := l.Client.ListTasks(ctx, projectID, "")
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil
}
func (l *Loader) listTaskFiles(ctx context.Context, taskID string) ([]model.Item, error) {
files, err := l.Client.GetTaskFiles(ctx, taskID)
if err != nil {
return nil, err
}
var items []model.Item
for _, f := range files {
id, title := "", ""
if f.ID != nil {
id = f.ID.String()
}
if f.Title != nil {
title = *f.Title
}
items = append(items, model.Item{
ID: id, Title: title, Kind: model.KindFile,
Raw: map[string]any{"id": id, "title": title},
})
}
return items, nil
}
func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) { func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) {
projects, err := l.Client.GetProjects() projects, err := l.Client.GetProjects()
if err != nil { if err != nil {
@@ -214,14 +359,8 @@ func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) {
return items, nil return items, nil
} }
func (l *Loader) listProjectFiles(ctx context.Context) ([]model.Item, error) { func (l *Loader) listProjectFiles(ctx context.Context, projectID string) ([]model.Item, error) {
def := l.Client // need project id from defaults resp, err := l.Client.GetProjectFiles(ctx, projectID)
_ = def
pid := onlyoffice.GetEnvironmentDefaults().ProjectID
if pid == "" {
return nil, fmt.Errorf("set ONLYOFFICE_PROJECT_ID for project files")
}
resp, err := l.Client.GetProjectFiles(ctx, pid)
if err != nil { if err != nil {
return nil, err return nil, err
} }
+64
View File
@@ -0,0 +1,64 @@
package model
// ActionID identifies an operation on a list item.
type ActionID string
const (
ActionView ActionID = "view"
ActionDelete ActionID = "delete"
ActionRefresh ActionID = "refresh"
ActionDownload ActionID = "download"
)
// ItemAction is one CRUD-style operation offered for an item.
type ItemAction struct {
ID ActionID
Label string
Danger bool
}
// ActionsFor returns available operations for an item kind.
func ActionsFor(kind Kind) []ItemAction {
switch kind {
case KindProject:
return []ItemAction{
{ID: ActionView, Label: "View details"},
{ID: ActionDelete, Label: "Delete project", Danger: true},
}
case KindTask, KindCRMTask:
return []ItemAction{
{ID: ActionView, Label: "View details"},
{ID: ActionDelete, Label: "Delete task", Danger: true},
}
case KindContact:
return []ItemAction{
{ID: ActionView, Label: "View details"},
{ID: ActionDelete, Label: "Delete contact", Danger: true},
}
case KindOpportunity:
return []ItemAction{
{ID: ActionView, Label: "View details"},
{ID: ActionDelete, Label: "Delete deal", Danger: true},
}
case KindCase:
return []ItemAction{
{ID: ActionView, Label: "View details"},
{ID: ActionDelete, Label: "Delete case", Danger: true},
}
case KindMail:
return []ItemAction{
{ID: ActionView, Label: "Read message"},
{ID: ActionDelete, Label: "Delete message", Danger: true},
}
case KindFile:
return []ItemAction{
{ID: ActionView, Label: "Preview file"},
{ID: ActionDownload, Label: "Download"},
{ID: ActionDelete, Label: "Delete file", Danger: true},
}
case KindEvent, KindCalendar, KindUser:
return []ItemAction{{ID: ActionView, Label: "View details"}}
default:
return []ItemAction{{ID: ActionView, Label: "View details"}}
}
}
+12
View File
@@ -50,6 +50,18 @@ const (
FocusPreview FocusPreview
) )
// PrevFocusPane cycles preview → list → menu → preview.
func PrevFocusPane(p FocusPane) FocusPane {
switch p {
case FocusMenu:
return FocusPreview
case FocusList:
return FocusMenu
default:
return FocusList
}
}
// NextFocusPane cycles menu → list → preview → menu. // NextFocusPane cycles menu → list → preview → menu.
func NextFocusPane(p FocusPane) FocusPane { func NextFocusPane(p FocusPane) FocusPane {
switch p { switch p {
-179
View File
@@ -1,179 +0,0 @@
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
@@ -1,49 +0,0 @@
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)
}
}
+209
View File
@@ -0,0 +1,209 @@
package model
// ListSpec tells the fetch layer what to load when a nav leaf is active.
type ListSpec struct {
Subject Subject
ProjectID string
TaskID string
}
// NavNode is one row in the navigation tree.
type NavNode struct {
ID string
Label string
Branch bool
List *ListSpec // set on leaves that open the center list
ParentID string
}
// NavTree is a hierarchical navigator; the center list loads only on leaves.
type NavTree struct {
nodes map[string]NavNode
roots []string
children map[string][]string
expanded map[string]bool
cursor int
visible []string
}
// DefaultNavTree returns the OnlyOffice module tree (static skeleton).
func DefaultNavTree() *NavTree {
t := &NavTree{
nodes: make(map[string]NavNode),
children: make(map[string][]string),
expanded: make(map[string]bool),
}
add := func(id, label, parent string, branch bool, list *ListSpec) {
t.nodes[id] = NavNode{ID: id, Label: label, Branch: branch, List: list, ParentID: parent}
if parent == "" {
t.roots = append(t.roots, id)
} else {
t.children[parent] = append(t.children[parent], id)
}
}
add("projects", "Projects", "", true, nil)
add("projects.browse", "Browse", "projects", true, nil)
add("projects.browse.all", "All projects", "projects.browse", false, &ListSpec{Subject: SubjectProjects})
add("projects.browse.tasks", "All tasks", "projects.browse", false, &ListSpec{Subject: SubjectTasks})
add("projects.dynamic", "By project", "projects", true, nil)
add("calendar", "Calendar", "", true, nil)
add("calendar.cals", "Calendars", "calendar", false, &ListSpec{Subject: SubjectCalendars})
add("calendar.events", "Events", "calendar", false, &ListSpec{Subject: SubjectEvents})
add("crm", "CRM", "", true, nil)
add("crm.contacts", "Contacts", "crm", false, &ListSpec{Subject: SubjectContacts})
add("crm.persons", "Persons", "crm", false, &ListSpec{Subject: SubjectPersons})
add("crm.companies", "Companies", "crm", false, &ListSpec{Subject: SubjectCompanies})
add("crm.opportunities", "Opportunities", "crm", false, &ListSpec{Subject: SubjectOpportunities})
add("crm.cases", "Cases", "crm", false, &ListSpec{Subject: SubjectCases})
add("crm.tasks", "CRM tasks", "crm", false, &ListSpec{Subject: SubjectCRMTasks})
add("mail", "Mail", "", true, nil)
add("mail.inbox", "Inbox", "mail", false, &ListSpec{Subject: SubjectMailInbox})
add("mail.sent", "Sent", "mail", false, &ListSpec{Subject: SubjectMailSent})
add("mail.drafts", "Drafts", "mail", false, &ListSpec{Subject: SubjectMailDrafts})
add("mail.trash", "Trash", "mail", false, &ListSpec{Subject: SubjectMailTrash})
add("mail.spam", "Spam", "mail", false, &ListSpec{Subject: SubjectMailSpam})
add("users", "Users", "", true, nil)
add("users.dir", "Directory", "users", false, &ListSpec{Subject: SubjectUsers})
t.rebuildVisible()
return t
}
// RootLabels returns top-level labels (for tests).
func (t *NavTree) RootLabels() []string {
out := make([]string, len(t.roots))
for i, id := range t.roots {
out[i] = t.nodes[id].Label
}
return out
}
func (t *NavTree) Cursor() int { return t.cursor }
func (t *NavTree) VisibleCount() int { return len(t.visible) }
func (t *NavTree) NodeAtVisible(i int) (NavNode, bool) {
if i < 0 || i >= len(t.visible) {
return NavNode{}, false
}
n, ok := t.nodes[t.visible[i]]
return n, ok
}
func (t *NavTree) DepthAtVisible(i int) int {
if i < 0 || i >= len(t.visible) {
return 0
}
depth := 0
id := t.visible[i]
for {
n, ok := t.nodes[id]
if !ok || n.ParentID == "" {
break
}
depth++
id = n.ParentID
}
return depth
}
func (t *NavTree) IsExpandable(i int) bool {
n, ok := t.NodeAtVisible(i)
if !ok {
return false
}
return n.Branch && len(t.children[n.ID]) > 0
}
func (t *NavTree) IsExpanded(i int) bool {
n, ok := t.NodeAtVisible(i)
if !ok {
return false
}
return t.expanded[n.ID]
}
func (t *NavTree) ToggleExpand(i int) {
n, ok := t.NodeAtVisible(i)
if !ok || !n.Branch {
return
}
if t.expanded[n.ID] {
delete(t.expanded, n.ID)
} else {
t.expanded[n.ID] = true
}
t.rebuildVisible()
}
func (t *NavTree) MoveUp() { t.SetCursor(t.cursor - 1) }
func (t *NavTree) MoveDown() { t.SetCursor(t.cursor + 1) }
func (t *NavTree) SetCursor(c int) {
if c < 0 {
c = 0
}
if c >= len(t.visible) {
c = len(t.visible) - 1
}
if c < 0 {
c = 0
}
t.cursor = c
}
// CurrentListSpec returns the list spec when the cursor is on a leaf.
func (t *NavTree) CurrentListSpec() (*ListSpec, bool) {
n, ok := t.NodeAtVisible(t.cursor)
if !ok || n.List == nil {
return nil, false
}
spec := *n.List
return &spec, true
}
// Activate expands a branch or returns leaf list spec on Enter.
func (t *NavTree) Activate() (*ListSpec, bool) {
n, ok := t.NodeAtVisible(t.cursor)
if !ok {
return nil, false
}
if n.Branch {
t.expanded[n.ID] = true
t.rebuildVisible()
return nil, false
}
if n.List != nil {
spec := *n.List
return &spec, true
}
return nil, false
}
func (t *NavTree) rebuildVisible() {
t.visible = t.visible[:0]
var walk func(id string, depth int)
walk = func(id string, depth int) {
t.visible = append(t.visible, id)
if !t.expanded[id] {
return
}
for _, child := range t.children[id] {
walk(child, depth+1)
}
}
for _, root := range t.roots {
walk(root, 0)
}
if t.cursor >= len(t.visible) {
t.cursor = len(t.visible) - 1
}
if t.cursor < 0 {
t.cursor = 0
}
}
+31
View File
@@ -0,0 +1,31 @@
package model
import "strings"
// InjectProjectNodes adds dynamic project branches under projects.dynamic.
func (t *NavTree) InjectProjectNodes(projects []Item) {
// Drop prior dynamic project.* nodes.
for id := range t.nodes {
if strings.HasPrefix(id, "project.") {
delete(t.nodes, id)
}
}
t.children["projects.dynamic"] = nil
for _, p := range projects {
pid := "project." + p.ID
t.nodes[pid] = NavNode{ID: pid, Label: p.Title, Branch: true, ParentID: "projects.dynamic"}
t.children["projects.dynamic"] = append(t.children["projects.dynamic"], pid)
taskID := pid + ".tasks"
fileID := pid + ".files"
t.nodes[taskID] = NavNode{
ID: taskID, Label: "Tasks", Branch: false, ParentID: pid,
List: &ListSpec{Subject: SubjectTasks, ProjectID: p.ID},
}
t.nodes[fileID] = NavNode{
ID: fileID, Label: "Files", Branch: false, ParentID: pid,
List: &ListSpec{Subject: SubjectProjectFiles, ProjectID: p.ID},
}
t.children[pid] = []string{taskID, fileID}
}
t.rebuildVisible()
}
+75
View File
@@ -0,0 +1,75 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestNavTreeHasExpectedRoots(t *testing.T) {
tree := model.DefaultNavTree()
roots := tree.RootLabels()
want := []string{"Projects", "Calendar", "CRM", "Mail", "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 TestNavExpandCollapse(t *testing.T) {
tree := model.DefaultNavTree()
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 TestNavLeafReturnsListSpec(t *testing.T) {
tree := model.DefaultNavTree()
tree.ToggleExpand(0) // Projects
tree.ToggleExpand(1) // Browse
// Find "All projects" leaf cursor
var found int = -1
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.List != nil && n.List.Subject == model.SubjectProjects {
found = i
break
}
}
if found < 0 {
t.Fatal("all projects leaf not found")
}
tree.SetCursor(found)
spec, ok := tree.CurrentListSpec()
if !ok || spec.Subject != model.SubjectProjects {
t.Fatalf("spec=%v ok=%v", spec, ok)
}
}
func TestPrevFocusPane(t *testing.T) {
if got := model.PrevFocusPane(model.FocusList); got != model.FocusMenu {
t.Fatalf("got %v", got)
}
if got := model.PrevFocusPane(model.FocusMenu); got != model.FocusPreview {
t.Fatalf("got %v", got)
}
}
func TestActionsForContact(t *testing.T) {
acts := model.ActionsFor(model.KindContact)
if len(acts) < 2 {
t.Fatalf("expected view+delete, got %d", len(acts))
}
}
@@ -33,7 +33,7 @@ func liveLoader(t *testing.T) (*fetch.Loader, context.Context) {
func TestIntegrationPreviewProjectFromAPI(t *testing.T) { func TestIntegrationPreviewProjectFromAPI(t *testing.T) {
loader, ctx := liveLoader(t) loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.SubjectProjects) items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjects})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -62,7 +62,7 @@ func TestIntegrationPreviewProjectFromAPI(t *testing.T) {
func TestIntegrationPreviewContactFromAPI(t *testing.T) { func TestIntegrationPreviewContactFromAPI(t *testing.T) {
loader, ctx := liveLoader(t) loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.SubjectContacts) items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectContacts})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -81,7 +81,7 @@ func TestIntegrationPreviewContactFromAPI(t *testing.T) {
func TestIntegrationPreviewOpportunityFromAPI(t *testing.T) { func TestIntegrationPreviewOpportunityFromAPI(t *testing.T) {
loader, ctx := liveLoader(t) loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.SubjectOpportunities) items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectOpportunities})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -100,7 +100,7 @@ func TestIntegrationPreviewOpportunityFromAPI(t *testing.T) {
func TestIntegrationPreviewMailFromAPI(t *testing.T) { func TestIntegrationPreviewMailFromAPI(t *testing.T) {
loader, ctx := liveLoader(t) loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.SubjectMailInbox) items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectMailInbox})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -112,14 +112,14 @@ func TestIntegrationPreviewMailFromAPI(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
md := preview.MailMarkdown(raw) md := preview.MailMarkdown(raw)
if !strings.Contains(md, items[0].Title) { if md == "" || !strings.Contains(md, "From:") {
t.Fatalf("markdown missing subject %q:\n%s", items[0].Title, md) t.Fatalf("unexpected mail markdown:\n%s", md)
} }
} }
func TestIntegrationPreviewEventFromAPI(t *testing.T) { func TestIntegrationPreviewEventFromAPI(t *testing.T) {
loader, ctx := liveLoader(t) loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.SubjectEvents) items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectEvents})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -134,7 +134,7 @@ func TestIntegrationPreviewEventFromAPI(t *testing.T) {
func TestIntegrationPreviewTaskFromAPI(t *testing.T) { func TestIntegrationPreviewTaskFromAPI(t *testing.T) {
loader, ctx := liveLoader(t) loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.SubjectTasks) items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectTasks})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -157,7 +157,7 @@ func TestIntegrationPreviewCSVFromDownloadedFile(t *testing.T) {
t.Skip("ONLYOFFICE_PROJECT_ID not set") t.Skip("ONLYOFFICE_PROJECT_ID not set")
} }
loader, ctx := liveLoader(t) loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.SubjectProjectFiles) items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjectFiles})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+326 -144
View File
@@ -3,7 +3,6 @@ package ui
import ( import (
"context" "context"
"fmt" "fmt"
"os/exec"
"strings" "strings"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
@@ -16,9 +15,9 @@ import (
) )
type listLoadedMsg struct { type listLoadedMsg struct {
items []model.Item items []model.Item
subject model.Subject spec model.ListSpec
err error err error
} }
type previewLoadedMsg struct { type previewLoadedMsg struct {
@@ -26,59 +25,76 @@ type previewLoadedMsg struct {
err error 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. // Model is the root Bubble Tea model for the office TUI.
type Model struct { type Model struct {
client *onlyoffice.Client client *onlyoffice.Client
loader *fetch.Loader loader *fetch.Loader
menu *model.MenuTree nav *model.NavTree
items []model.Item listSpec model.ListSpec
hasList bool
items []model.Item
selection *model.Selection selection *model.Selection
subject model.Subject listIdx int
listIdx int focus model.FocusPane
focus model.FocusPane width int
width int height int
height int status string
status string err string
err string loading bool
loading bool menuVP viewport.Model
preview viewport.Model listVP viewport.Model
previewVP viewport.Model
previewMD string 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. // NewModel constructs the TUI with an authenticated client.
func NewModel(client *onlyoffice.Client) Model { func NewModel(client *onlyoffice.Client) Model {
vp := viewport.New(40, 20)
return Model{ return Model{
client: client, client: client,
loader: &fetch.Loader{Client: client}, loader: &fetch.Loader{Client: client},
menu: model.DefaultMenuTree(), nav: model.DefaultNavTree(),
selection: model.NewSelection(), selection: model.NewSelection(),
focus: model.FocusMenu, focus: model.FocusMenu,
preview: vp, status: "Tab/Shift+Tab: pane · Enter: open/activate · a: actions · q: quit",
status: "office — Tab: pane Space: select Enter: preview q: quit",
} }
} }
// Init implements tea.Model.
func (m Model) Init() tea.Cmd { 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) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
m.layoutViewports() m.layoutViewports()
m.syncAllContent()
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyMsg:
key := msg.String() key := msg.String()
if key == "?" { if key == "?" {
m.status = helpText() m.status = helpText()
return m, nil return m, nil
} }
if m.actionMode && m.focus == model.FocusList {
return m.handleActionKey(key)
}
action := KeyAction(key, m.focus) action := KeyAction(key, m.focus)
if action == ActionNone && (key == "up" || key == "k") { if action == ActionNone && (key == "up" || key == "k") {
action = ResolveMoveUp(m.focus) action = ResolveMoveUp(m.focus)
@@ -89,49 +105,85 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case ActionNextPane: case ActionNextPane:
m.focus = model.NextFocusPane(m.focus) m.focus = model.NextFocusPane(m.focus)
return m, nil return m, nil
case ActionPrevPane:
m.focus = model.PrevFocusPane(m.focus)
return m, nil
case ActionMoveUp: case ActionMoveUp:
m.moveUp() m.moveUp()
m.syncAllContent()
return m, nil return m, nil
case ActionMoveDown: case ActionMoveDown:
m.moveDown() m.moveDown()
m.syncAllContent()
return m, nil return m, nil
case ActionToggleSelect: 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 return m, nil
case ActionOpenPreview: case ActionOpenPreview:
m.selection.Toggle(&m.items, m.listIdx) if m.hasList {
return m, m.loadPreviewCmd() m.selection.Toggle(&m.items, m.listIdx)
case ActionRefresh: return m, m.loadPreviewCmd()
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()) { return m, nil
m.menu.ToggleExpand(m.menu.Cursor()) case ActionRefresh:
if m.hasList {
m.loading = true
return m, m.loadListCmd(m.listSpec)
} }
return m, nil 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: case listLoadedMsg:
m.loading = false m.loading = false
if msg.err != nil { if msg.err != nil {
m.err = msg.err.Error() m.err = msg.err.Error()
m.items = nil 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.syncListContent()
m.items = msg.items
m.subject = msg.subject
return m, nil return m, nil
case previewLoadedMsg: case previewLoadedMsg:
m.loading = false m.loading = false
if msg.err != nil { if msg.err != nil {
@@ -139,26 +191,53 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else { } else {
m.previewMD = msg.text 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 return m, nil
} }
var cmd tea.Cmd var cmd tea.Cmd
if m.focus == model.FocusPreview { switch m.focus {
m.preview, cmd = m.preview.Update(msg) 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 return m, cmd
} }
// View implements tea.Model.
func (m Model) View() string { func (m Model) View() string {
if m.width == 0 { if m.width == 0 {
return "Loading…\n" return "Loading…\n"
} }
menuW, listW, prevW := LayoutWidths(m.width) menuW, listW, prevW := LayoutWidths(m.width)
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(menuW).Height(m.height - 2) h := m.height - 2
listStyle := paneStyle(m.focus == model.FocusList).Width(listW).Height(m.height - 2) menuStyle := paneStyle(m.focus == model.FocusMenu).Width(menuW).Height(h)
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(prevW).Height(m.height - 2) listStyle := paneStyle(m.focus == model.FocusList).Width(listW).Height(h)
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(prevW).Height(h)
status := m.status status := m.status
if m.loading { if m.loading {
@@ -170,121 +249,230 @@ func (m Model) View() string {
bar := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(status) bar := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(status)
return lipgloss.JoinHorizontal(lipgloss.Top, return lipgloss.JoinHorizontal(lipgloss.Top,
menuStyle.Render(m.renderMenu(menuW)), menuStyle.Render(m.menuVP.View()),
listStyle.Render(m.renderList(listW)), listStyle.Render(m.listVP.View()),
prevStyle.Render(m.preview.View()), prevStyle.Render(m.previewVP.View()),
) + "\n" + bar ) + "\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() { func (m *Model) layoutViewports() {
_, _, prevW := LayoutWidths(m.width) menuW, listW, prevW := LayoutWidths(m.width)
h := m.height - 4 h := m.height - 4
if h < 4 { if h < 4 {
h = 4 h = 4
} }
m.preview.Width = prevW - 2 inner := func(w int) int {
m.preview.Height = h if w > 4 {
m.renderPreview() return w - 2
}
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 { 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 line = "> " + line
} else { } else {
line = " " + line line = " " + line
} }
if len(line) > w-1 {
line = line[:w-2] + "…"
}
b.WriteString(line + "\n") 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 { func (m *Model) syncListContent() {
title := string(m.subject)
if title == "" {
title = "Items"
}
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, "%s (%d)\n\n", title, len(m.items)) if !m.hasList {
for i, it := range m.items { b.WriteString("List\n\n")
mark := "[ ]" b.WriteString("Select a leaf node in the tree\n")
if it.Selected { b.WriteString("(marked with •) and press Enter.\n")
mark = "[x]" } 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 { m.listVP.SetContent(b.String())
b.WriteString("(empty)\n") 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() { func (m *Model) moveUp() {
switch m.focus { switch m.focus {
case model.FocusMenu: case model.FocusMenu:
m.menu.MoveUp() m.nav.MoveUp()
case model.FocusList: case model.FocusList:
if m.listIdx > 0 { if m.hasList && !m.actionMode && m.listIdx > 0 {
m.listIdx-- m.listIdx--
} }
case model.FocusPreview: case model.FocusPreview:
m.preview.LineUp(1) m.previewVP.LineUp(1)
} }
} }
func (m *Model) moveDown() { func (m *Model) moveDown() {
switch m.focus { switch m.focus {
case model.FocusMenu: case model.FocusMenu:
m.menu.MoveDown() m.nav.MoveDown()
case model.FocusList: case model.FocusList:
if m.listIdx < len(m.items)-1 { if m.hasList && !m.actionMode && m.listIdx < len(m.items)-1 {
m.listIdx++ m.listIdx++
} }
case model.FocusPreview: 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 { return func() tea.Msg {
ctx := context.Background() ctx := context.Background()
items, err := m.loader.List(ctx, subject) items, err := m.loader.List(ctx, spec)
return listLoadedMsg{items: items, subject: subject, err: err} return listLoadedMsg{items: items, spec: spec, err: err}
} }
} }
@@ -304,17 +492,20 @@ func (m *Model) loadPreviewCmd() tea.Cmd {
} }
} }
func (m *Model) openVexCmd() tea.Cmd { func (m *Model) executeActionCmd(action model.ActionID, item model.Item) tea.Cmd {
if m.listIdx < 0 || m.listIdx >= len(m.items) { return func() tea.Msg {
return nil 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 { func (m *Model) loadNavProjectsCmd() tea.Cmd {
return previewLoadedMsg{text: "_Press v on a spreadsheet file row._\n"} 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 { func paneStyle(focused bool) lipgloss.Style {
@@ -326,14 +517,5 @@ func paneStyle(focused bool) lipgloss.Style {
} }
func helpText() string { func helpText() string {
return "Tab: pane | j/k: move | Space: select | Enter: preview | r: refresh | v: vex | q: quit" return "Tab/Shift+Tab: pane · ↑↓/jk: scroll · Enter: open leaf/actions · Space: select · a: actions · r: refresh · 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()
} }
+9 -9
View File
@@ -13,14 +13,11 @@ func TestKeyActionMenuDown(t *testing.T) {
} }
} }
func TestKeyActionListToggleSelect(t *testing.T) { func TestKeyActionShiftTabPrevPane(t *testing.T) {
if got := ui.KeyAction(" ", model.FocusList); got != ui.ActionToggleSelect { if got := ui.KeyAction("shift+tab", model.FocusList); got != ui.ActionPrevPane {
t.Fatalf("got %v", got) t.Fatalf("got %v", got)
} }
} if got := ui.KeyAction("backtab", model.FocusPreview); got != ui.ActionPrevPane {
func TestKeyActionPreviewNoOp(t *testing.T) {
if got := ui.KeyAction(" ", model.FocusPreview); got != ui.ActionNone {
t.Fatalf("got %v", got) t.Fatalf("got %v", got)
} }
} }
@@ -31,12 +28,15 @@ func TestKeyActionTabNextPane(t *testing.T) {
} }
} }
func TestKeyActionOpenActions(t *testing.T) {
if got := ui.KeyAction("a", model.FocusList); got != ui.ActionOpenActions {
t.Fatalf("got %v", got)
}
}
func TestLayoutWidths(t *testing.T) { func TestLayoutWidths(t *testing.T) {
menu, list, preview := ui.LayoutWidths(120) menu, list, preview := ui.LayoutWidths(120)
if menu+list+preview > 120 { if menu+list+preview > 120 {
t.Fatalf("widths exceed total: %d+%d+%d", menu, list, preview) t.Fatalf("widths exceed total: %d+%d+%d", menu, list, preview)
} }
if menu < 20 || list < 30 || preview < 30 {
t.Fatalf("widths too narrow: %d %d %d", menu, list, preview)
}
} }
+8
View File
@@ -11,7 +11,9 @@ const (
ActionMoveDown ActionMoveDown
ActionToggleSelect ActionToggleSelect
ActionOpenPreview ActionOpenPreview
ActionOpenActions
ActionNextPane ActionNextPane
ActionPrevPane
ActionRefresh ActionRefresh
ActionQuit ActionQuit
ActionOpenVex ActionOpenVex
@@ -24,6 +26,8 @@ func KeyAction(key string, pane model.FocusPane) Action {
return ActionQuit return ActionQuit
case "tab": case "tab":
return ActionNextPane return ActionNextPane
case "shift+tab", "backtab":
return ActionPrevPane
case "r": case "r":
return ActionRefresh return ActionRefresh
case "up", "k": case "up", "k":
@@ -34,6 +38,10 @@ func KeyAction(key string, pane model.FocusPane) Action {
if pane == model.FocusList { if pane == model.FocusList {
return ActionToggleSelect return ActionToggleSelect
} }
case "a":
if pane == model.FocusList {
return ActionOpenActions
}
case "enter": case "enter":
if pane == model.FocusList { if pane == model.FocusList {
return ActionOpenPreview return ActionOpenPreview