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 4046f4c46a
commit cad68f64c6
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))
}