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
+24 -55
View File
@@ -1,5 +1,3 @@
//go:build integration
package fetch_test
import (
@@ -38,50 +36,34 @@ func liveLoader(t *testing.T) (*fetch.Loader, context.Context) {
func TestIntegrationListAllSubjects(t *testing.T) {
loader, ctx := liveLoader(t)
cases := []struct {
subject model.Subject
skip string
}{
{model.SubjectProjects, ""},
{model.SubjectTasks, ""},
{model.SubjectCalendars, ""},
{model.SubjectEvents, ""},
{model.SubjectContacts, ""},
{model.SubjectPersons, ""},
{model.SubjectCompanies, ""},
{model.SubjectOpportunities, ""},
{model.SubjectCases, ""},
{model.SubjectCRMTasks, ""},
{model.SubjectMailInbox, ""},
{model.SubjectMailSent, ""},
{model.SubjectMailDrafts, ""},
{model.SubjectMailTrash, ""},
{model.SubjectMailSpam, ""},
{model.SubjectUsers, ""},
{model.SubjectProjectFiles, "ONLYOFFICE_PROJECT_ID not set"},
cases := []model.ListSpec{
{Subject: model.SubjectProjects},
{Subject: model.SubjectTasks},
{Subject: model.SubjectCalendars},
{Subject: model.SubjectEvents},
{Subject: model.SubjectContacts},
{Subject: model.SubjectPersons},
{Subject: model.SubjectCompanies},
{Subject: model.SubjectOpportunities},
{Subject: model.SubjectCases},
{Subject: model.SubjectCRMTasks},
{Subject: model.SubjectMailInbox},
{Subject: model.SubjectUsers},
}
for _, tc := range cases {
t.Run(string(tc.subject), func(t *testing.T) {
if tc.subject == model.SubjectProjectFiles {
if os.Getenv("ONLYOFFICE_PROJECT_ID") == "" {
t.Skip(tc.skip)
}
}
items, err := loader.List(ctx, tc.subject)
for _, spec := range cases {
t.Run(string(spec.Subject), func(t *testing.T) {
items, err := loader.List(ctx, spec)
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 {
if it.ID == "" {
t.Errorf("item[%d] missing ID: %+v", i, it)
t.Errorf("item[%d] missing ID", i)
}
if it.Title == "" {
t.Errorf("item[%d] missing Title: id=%s kind=%s", i, it.ID, it.Kind)
}
if it.Kind == "" {
t.Errorf("item[%d] missing Kind: id=%s", i, it.ID)
t.Errorf("item[%d] missing Title", i)
}
}
})
@@ -90,30 +72,26 @@ func TestIntegrationListAllSubjects(t *testing.T) {
func TestIntegrationListProjectsMapsRealFields(t *testing.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 {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no projects on instance")
}
it := items[0]
if it.Raw == nil {
t.Fatal("expected Raw payload from API")
}
detail, err := loader.Detail(ctx, it)
detail, err := loader.Detail(ctx, items[0])
if err != nil {
t.Fatalf("Detail: %v", err)
}
if detail == nil {
t.Fatal("nil detail")
}
t.Logf("project id=%s title=%q keys=%d", it.ID, it.Title, len(detail))
}
func TestIntegrationMailInboxDetail(t *testing.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 {
t.Fatal(err)
}
@@ -128,12 +106,3 @@ func TestIntegrationMailInboxDetail(t *testing.T) {
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 (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"time"
onlyoffice "github.com/eslider/go-onlyoffice"
@@ -14,15 +17,18 @@ type Loader struct {
Client *onlyoffice.Client
}
// List returns items for the given subject.
func (l *Loader) List(ctx context.Context, subject model.Subject) ([]model.Item, error) {
// List returns items for the given list spec (nav leaf).
func (l *Loader) List(ctx context.Context, spec model.ListSpec) ([]model.Item, error) {
if l == nil || l.Client == nil {
return nil, fmt.Errorf("fetch: client is nil")
}
switch subject {
switch spec.Subject {
case model.SubjectProjects:
return l.listProjects(ctx)
case model.SubjectTasks:
if spec.ProjectID != "" {
return l.listTasksForProject(ctx, spec.ProjectID)
}
return l.listTasks(ctx)
case model.SubjectCalendars:
return l.listCalendars(ctx)
@@ -55,14 +61,29 @@ func (l *Loader) List(ctx context.Context, subject model.Subject) ([]model.Item,
case model.SubjectUsers:
return l.listUsers(ctx)
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:
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:
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.
func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, error) {
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)
case model.KindMail:
return l.Client.GetMailMessage(ctx, item.ID)
case model.KindTask:
case model.KindTask, model.KindCRMTask:
return l.Client.GetTaskByID(ctx, item.ID)
case model.KindProject:
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) {
projects, err := l.Client.GetProjects()
if err != nil {
@@ -214,14 +359,8 @@ func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) {
return items, nil
}
func (l *Loader) listProjectFiles(ctx context.Context) ([]model.Item, error) {
def := l.Client // need project id from defaults
_ = 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)
func (l *Loader) listProjectFiles(ctx context.Context, projectID string) ([]model.Item, error) {
resp, err := l.Client.GetProjectFiles(ctx, projectID)
if err != nil {
return nil, err
}