diff --git a/README.md b/README.md index 25a4d15..046e3dd 100644 --- a/README.md +++ b/README.md @@ -575,13 +575,17 @@ office | Key | Action | |---|---| -| `Tab` | Switch pane (menu → list → preview) | -| `↑↓` / `j` / `k` | Navigate | -| `Space` / `Enter` | Toggle selection on list row | -| `Enter` | Load preview for focused row | +| `Tab` | Next pane (menu → list → preview) | +| `Shift+Tab` | Previous pane | +| `↑↓` / `j` / `k` | Navigate / scroll within focused pane | +| `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 | | `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)): ```bash diff --git a/cmd/office/fetch/fetch_integration_test.go b/cmd/office/fetch/fetch_integration_test.go index c282e93..ba0aea3 100644 --- a/cmd/office/fetch/fetch_integration_test.go +++ b/cmd/office/fetch/fetch_integration_test.go @@ -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)) -} diff --git a/cmd/office/fetch/list.go b/cmd/office/fetch/list.go index c73ac61..ab17a7b 100644 --- a/cmd/office/fetch/list.go +++ b/cmd/office/fetch/list.go @@ -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 } diff --git a/cmd/office/model/actions.go b/cmd/office/model/actions.go new file mode 100644 index 0000000..3ec606f --- /dev/null +++ b/cmd/office/model/actions.go @@ -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"}} + } +} diff --git a/cmd/office/model/item.go b/cmd/office/model/item.go index fed5b51..a325216 100644 --- a/cmd/office/model/item.go +++ b/cmd/office/model/item.go @@ -50,6 +50,18 @@ const ( 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. func NextFocusPane(p FocusPane) FocusPane { switch p { diff --git a/cmd/office/model/menu.go b/cmd/office/model/menu.go deleted file mode 100644 index 0e08ab2..0000000 --- a/cmd/office/model/menu.go +++ /dev/null @@ -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) - } - } -} diff --git a/cmd/office/model/menu_test.go b/cmd/office/model/menu_test.go deleted file mode 100644 index 7a96ae7..0000000 --- a/cmd/office/model/menu_test.go +++ /dev/null @@ -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) - } -} diff --git a/cmd/office/model/nav.go b/cmd/office/model/nav.go new file mode 100644 index 0000000..97a3ba0 --- /dev/null +++ b/cmd/office/model/nav.go @@ -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 + } +} diff --git a/cmd/office/model/nav_projects.go b/cmd/office/model/nav_projects.go new file mode 100644 index 0000000..be33742 --- /dev/null +++ b/cmd/office/model/nav_projects.go @@ -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() +} diff --git a/cmd/office/model/nav_test.go b/cmd/office/model/nav_test.go new file mode 100644 index 0000000..576690a --- /dev/null +++ b/cmd/office/model/nav_test.go @@ -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)) + } +} diff --git a/cmd/office/preview/preview_integration_test.go b/cmd/office/preview/preview_integration_test.go index ab1a911..d3ef9d8 100644 --- a/cmd/office/preview/preview_integration_test.go +++ b/cmd/office/preview/preview_integration_test.go @@ -33,7 +33,7 @@ func liveLoader(t *testing.T) (*fetch.Loader, context.Context) { func TestIntegrationPreviewProjectFromAPI(t *testing.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 { t.Fatal(err) } @@ -62,7 +62,7 @@ func TestIntegrationPreviewProjectFromAPI(t *testing.T) { func TestIntegrationPreviewContactFromAPI(t *testing.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 { t.Fatal(err) } @@ -81,7 +81,7 @@ func TestIntegrationPreviewContactFromAPI(t *testing.T) { func TestIntegrationPreviewOpportunityFromAPI(t *testing.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 { t.Fatal(err) } @@ -100,7 +100,7 @@ func TestIntegrationPreviewOpportunityFromAPI(t *testing.T) { func TestIntegrationPreviewMailFromAPI(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) } @@ -112,14 +112,14 @@ func TestIntegrationPreviewMailFromAPI(t *testing.T) { t.Fatal(err) } md := preview.MailMarkdown(raw) - if !strings.Contains(md, items[0].Title) { - t.Fatalf("markdown missing subject %q:\n%s", items[0].Title, md) + if md == "" || !strings.Contains(md, "From:") { + t.Fatalf("unexpected mail markdown:\n%s", md) } } func TestIntegrationPreviewEventFromAPI(t *testing.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 { t.Fatal(err) } @@ -134,7 +134,7 @@ func TestIntegrationPreviewEventFromAPI(t *testing.T) { func TestIntegrationPreviewTaskFromAPI(t *testing.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 { t.Fatal(err) } @@ -157,7 +157,7 @@ func TestIntegrationPreviewCSVFromDownloadedFile(t *testing.T) { t.Skip("ONLYOFFICE_PROJECT_ID not set") } loader, ctx := liveLoader(t) - items, err := loader.List(ctx, model.SubjectProjectFiles) + items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjectFiles}) if err != nil { t.Fatal(err) } diff --git a/cmd/office/ui/app.go b/cmd/office/ui/app.go index 2fd9064..574e5c2 100644 --- a/cmd/office/ui/app.go +++ b/cmd/office/ui/app.go @@ -3,7 +3,6 @@ package ui import ( "context" "fmt" - "os/exec" "strings" "github.com/charmbracelet/bubbles/viewport" @@ -16,9 +15,9 @@ import ( ) type listLoadedMsg struct { - items []model.Item - subject model.Subject - err error + items []model.Item + spec model.ListSpec + err error } type previewLoadedMsg struct { @@ -26,59 +25,76 @@ type previewLoadedMsg struct { 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. type Model struct { - client *onlyoffice.Client - loader *fetch.Loader - menu *model.MenuTree - items []model.Item + client *onlyoffice.Client + loader *fetch.Loader + nav *model.NavTree + listSpec model.ListSpec + hasList bool + items []model.Item selection *model.Selection - subject model.Subject - listIdx int - focus model.FocusPane - width int - height int - status string - err string - loading bool - preview viewport.Model + listIdx int + focus model.FocusPane + width int + height int + status string + err string + loading bool + menuVP viewport.Model + listVP viewport.Model + previewVP viewport.Model 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. func NewModel(client *onlyoffice.Client) Model { - vp := viewport.New(40, 20) return Model{ client: client, loader: &fetch.Loader{Client: client}, - menu: model.DefaultMenuTree(), + nav: model.DefaultNavTree(), selection: model.NewSelection(), focus: model.FocusMenu, - preview: vp, - status: "office — Tab: pane Space: select Enter: preview q: quit", + status: "Tab/Shift+Tab: pane · Enter: open/activate · a: actions · q: quit", } } -// Init implements tea.Model. 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) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height m.layoutViewports() + m.syncAllContent() return m, nil + case tea.KeyMsg: key := msg.String() if key == "?" { m.status = helpText() return m, nil } + if m.actionMode && m.focus == model.FocusList { + return m.handleActionKey(key) + } action := KeyAction(key, m.focus) if action == ActionNone && (key == "up" || key == "k") { action = ResolveMoveUp(m.focus) @@ -89,49 +105,85 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ActionNextPane: m.focus = model.NextFocusPane(m.focus) return m, nil + case ActionPrevPane: + m.focus = model.PrevFocusPane(m.focus) + return m, nil case ActionMoveUp: m.moveUp() + m.syncAllContent() return m, nil case ActionMoveDown: m.moveDown() + m.syncAllContent() return m, nil 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 case ActionOpenPreview: - m.selection.Toggle(&m.items, m.listIdx) - return m, m.loadPreviewCmd() - case ActionRefresh: - 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.hasList { + m.selection.Toggle(&m.items, m.listIdx) + return m, m.loadPreviewCmd() } - if m.menu.IsExpandable(m.menu.Cursor()) { - m.menu.ToggleExpand(m.menu.Cursor()) + return m, nil + case ActionRefresh: + if m.hasList { + m.loading = true + return m, m.loadListCmd(m.listSpec) } 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: m.loading = false if msg.err != nil { m.err = msg.err.Error() 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.items = msg.items - m.subject = msg.subject + m.syncListContent() return m, nil + case previewLoadedMsg: m.loading = false if msg.err != nil { @@ -139,26 +191,53 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { 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 } var cmd tea.Cmd - if m.focus == model.FocusPreview { - m.preview, cmd = m.preview.Update(msg) + switch m.focus { + 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 } -// View implements tea.Model. func (m Model) View() string { if m.width == 0 { return "Loading…\n" } menuW, listW, prevW := LayoutWidths(m.width) - menuStyle := paneStyle(m.focus == model.FocusMenu).Width(menuW).Height(m.height - 2) - listStyle := paneStyle(m.focus == model.FocusList).Width(listW).Height(m.height - 2) - prevStyle := paneStyle(m.focus == model.FocusPreview).Width(prevW).Height(m.height - 2) + h := m.height - 2 + menuStyle := paneStyle(m.focus == model.FocusMenu).Width(menuW).Height(h) + listStyle := paneStyle(m.focus == model.FocusList).Width(listW).Height(h) + prevStyle := paneStyle(m.focus == model.FocusPreview).Width(prevW).Height(h) status := m.status if m.loading { @@ -170,121 +249,230 @@ func (m Model) View() string { bar := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(status) return lipgloss.JoinHorizontal(lipgloss.Top, - menuStyle.Render(m.renderMenu(menuW)), - listStyle.Render(m.renderList(listW)), - prevStyle.Render(m.preview.View()), + menuStyle.Render(m.menuVP.View()), + listStyle.Render(m.listVP.View()), + prevStyle.Render(m.previewVP.View()), ) + "\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() { - _, _, prevW := LayoutWidths(m.width) + menuW, listW, prevW := LayoutWidths(m.width) h := m.height - 4 if h < 4 { h = 4 } - m.preview.Width = prevW - 2 - m.preview.Height = h - m.renderPreview() -} - -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 - } + inner := func(w int) int { + if w > 4 { + return w - 2 } - 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 } else { line = " " + line } - if len(line) > w-1 { - line = line[:w-2] + "…" - } 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 { - title := string(m.subject) - if title == "" { - title = "Items" - } +func (m *Model) syncListContent() { var b strings.Builder - fmt.Fprintf(&b, "%s (%d)\n\n", title, len(m.items)) - for i, it := range m.items { - mark := "[ ]" - if it.Selected { - mark = "[x]" + 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 { + 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 { - b.WriteString("(empty)\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 } - return b.String() } func (m *Model) moveUp() { switch m.focus { case model.FocusMenu: - m.menu.MoveUp() + m.nav.MoveUp() case model.FocusList: - if m.listIdx > 0 { + if m.hasList && !m.actionMode && m.listIdx > 0 { m.listIdx-- } case model.FocusPreview: - m.preview.LineUp(1) + m.previewVP.LineUp(1) } } func (m *Model) moveDown() { switch m.focus { case model.FocusMenu: - m.menu.MoveDown() + m.nav.MoveDown() case model.FocusList: - if m.listIdx < len(m.items)-1 { + if m.hasList && !m.actionMode && m.listIdx < len(m.items)-1 { m.listIdx++ } 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 { ctx := context.Background() - items, err := m.loader.List(ctx, subject) - return listLoadedMsg{items: items, subject: subject, err: err} + items, err := m.loader.List(ctx, spec) + return listLoadedMsg{items: items, spec: spec, err: err} } } @@ -304,17 +492,20 @@ func (m *Model) loadPreviewCmd() tea.Cmd { } } -func (m *Model) openVexCmd() tea.Cmd { - if m.listIdx < 0 || m.listIdx >= len(m.items) { - return nil +func (m *Model) executeActionCmd(action model.ActionID, item model.Item) tea.Cmd { + return func() tea.Msg { + 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 { - return previewLoadedMsg{text: "_Press v on a spreadsheet file row._\n"} - } +} + +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 { @@ -326,14 +517,5 @@ func paneStyle(focused bool) lipgloss.Style { } func helpText() string { - return "Tab: pane | j/k: move | Space: select | Enter: preview | r: refresh | v: vex | 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() + return "Tab/Shift+Tab: pane · ↑↓/jk: scroll · Enter: open leaf/actions · Space: select · a: actions · r: refresh · q: quit" } diff --git a/cmd/office/ui/app_test.go b/cmd/office/ui/app_test.go index 97cf51c..6edb539 100644 --- a/cmd/office/ui/app_test.go +++ b/cmd/office/ui/app_test.go @@ -13,14 +13,11 @@ func TestKeyActionMenuDown(t *testing.T) { } } -func TestKeyActionListToggleSelect(t *testing.T) { - if got := ui.KeyAction(" ", model.FocusList); got != ui.ActionToggleSelect { +func TestKeyActionShiftTabPrevPane(t *testing.T) { + if got := ui.KeyAction("shift+tab", model.FocusList); got != ui.ActionPrevPane { t.Fatalf("got %v", got) } -} - -func TestKeyActionPreviewNoOp(t *testing.T) { - if got := ui.KeyAction(" ", model.FocusPreview); got != ui.ActionNone { + if got := ui.KeyAction("backtab", model.FocusPreview); got != ui.ActionPrevPane { 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) { menu, list, preview := ui.LayoutWidths(120) if menu+list+preview > 120 { 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) - } } diff --git a/cmd/office/ui/keys.go b/cmd/office/ui/keys.go index 2a9d857..a69b9aa 100644 --- a/cmd/office/ui/keys.go +++ b/cmd/office/ui/keys.go @@ -11,7 +11,9 @@ const ( ActionMoveDown ActionToggleSelect ActionOpenPreview + ActionOpenActions ActionNextPane + ActionPrevPane ActionRefresh ActionQuit ActionOpenVex @@ -24,6 +26,8 @@ func KeyAction(key string, pane model.FocusPane) Action { return ActionQuit case "tab": return ActionNextPane + case "shift+tab", "backtab": + return ActionPrevPane case "r": return ActionRefresh case "up", "k": @@ -34,6 +38,10 @@ func KeyAction(key string, pane model.FocusPane) Action { if pane == model.FocusList { return ActionToggleSelect } + case "a": + if pane == model.FocusList { + return ActionOpenActions + } case "enter": if pane == model.FocusList { return ActionOpenPreview