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))
}
+156 -17
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,12 +61,27 @@ 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
case model.SubjectTaskFiles: if pid == "" {
return nil, fmt.Errorf("select a task in Tasks first (task files need task id)") pid = onlyoffice.GetEnvironmentDefaults().ProjectID
default:
return nil, fmt.Errorf("unsupported subject %q", subject)
} }
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:
if spec.TaskID == "" {
return nil, fmt.Errorf("pick a task under Projects in the tree")
}
return l.listTaskFiles(ctx, spec.TaskID)
default:
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.
@@ -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)
} }
+286 -104
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"
@@ -17,7 +16,7 @@ import (
type listLoadedMsg struct { type listLoadedMsg struct {
items []model.Item items []model.Item
subject model.Subject spec model.ListSpec
err error err error
} }
@@ -26,14 +25,25 @@ 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
listSpec model.ListSpec
hasList bool
items []model.Item 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
@@ -41,44 +51,50 @@ type Model struct {
status string status string
err string err string
loading bool loading bool
preview viewport.Model menuVP 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:
if m.hasList {
m.selection.Toggle(&m.items, m.listIdx) 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:
if m.hasList {
m.selection.Toggle(&m.items, m.listIdx) m.selection.Toggle(&m.items, m.listIdx)
return m, m.loadPreviewCmd() return m, m.loadPreviewCmd()
}
return m, nil
case ActionRefresh: case ActionRefresh:
if m.hasList {
m.loading = true m.loading = true
return m, m.loadListCmd(m.subject) return m, m.loadListCmd(m.listSpec)
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 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.err = ""
m.items = msg.items m.items = msg.items
m.subject = msg.subject m.listSpec = msg.spec
m.hasList = true
m.listIdx = 0
}
m.syncListContent()
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,66 +249,141 @@ 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) 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) renderMenu(w int) string { func (m *Model) syncAllContent() {
m.syncMenuContent()
m.syncListContent()
m.syncPreviewContent()
}
func (m *Model) syncMenuContent() {
var b strings.Builder var b strings.Builder
b.WriteString("Modules\n\n") b.WriteString("Navigation\n\n")
for i := 0; i < m.menu.VisibleCount(); i++ { for i := 0; i < m.nav.VisibleCount(); i++ {
label, depth := m.menu.LabelAt(i) n, _ := m.nav.NodeAtVisible(i)
depth := m.nav.DepthAtVisible(i)
prefix := strings.Repeat(" ", depth) prefix := strings.Repeat(" ", depth)
line := prefix + label line := prefix + n.Label
if m.menu.IsExpandable(i) { if m.nav.IsExpandable(i) {
if m.menu.IsExpanded(i) { if m.nav.IsExpanded(i) {
line = prefix + "▾ " + label line = prefix + "▾ " + n.Label
} else { } else {
line = prefix + "▸ " + label line = prefix + "▸ " + n.Label
} }
} else if n.List != nil {
line = prefix + "• " + n.Label
} }
if i == m.menu.Cursor() && m.focus == model.FocusMenu { 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 {
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 { for i, it := range m.items {
mark := "[ ]" mark := "[ ]"
if it.Selected { if it.Selected {
@@ -243,48 +397,82 @@ func (m *Model) renderList(w int) string {
if it.Subtitle != "" { if it.Subtitle != "" {
line += " — " + it.Subtitle line += " — " + it.Subtitle
} }
if len(line) > w-1 {
line = line[:w-2] + "…"
}
b.WriteString(line + "\n") b.WriteString(line + "\n")
} }
if len(m.items) == 0 && !m.loading { if len(m.items) == 0 && !m.loading {
b.WriteString("(empty)\n") b.WriteString("(empty)\n")
} }
return b.String() 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() { 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 nil
}
item := m.items[m.listIdx]
if item.Kind != model.KindFile {
return func() tea.Msg { return func() tea.Msg {
return previewLoadedMsg{text: "_Press v on a spreadsheet file row._\n"} 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}
} }
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