Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a413d409b5 | ||
|
|
cac0ffa2a0 | ||
|
|
cad68f64c6 | ||
|
|
4046f4c46a | ||
|
|
6d4df4edee |
@@ -3,6 +3,12 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: Git tag to release (e.g. v0.4.0)
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -15,6 +21,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v5
|
uses: actions/setup-go@v5
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.4.0"
|
".": "0.5.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-2
@@ -4,6 +4,13 @@ All notable changes to this project are documented here. The format is based on
|
|||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
||||||
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.5.0](https://github.com/eSlider/go-onlyoffice/compare/v0.4.0...v0.5.0) (2026-06-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **office:** scrollable panes, nav tree drill-down, and item actions ([fe2ee48](https://github.com/eSlider/go-onlyoffice/commit/fe2ee481b9c0f959b798fd40679084a8c4bc4ba2))
|
||||||
|
|
||||||
## [0.4.0](https://github.com/eSlider/go-onlyoffice/compare/v0.3.2...v0.4.0) (2026-06-24)
|
## [0.4.0](https://github.com/eSlider/go-onlyoffice/compare/v0.3.2...v0.4.0) (2026-06-24)
|
||||||
|
|
||||||
|
|
||||||
@@ -40,8 +47,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
Install: `go install github.com/eslider/go-onlyoffice/cmd/office@latest`.
|
Install: `go install github.com/eslider/go-onlyoffice/cmd/office@latest`.
|
||||||
- **`cmd/internal/bootstrap`** — shared env loading and client auth for `oo`
|
- **`cmd/internal/bootstrap`** — shared env loading and client auth for `oo`
|
||||||
and `office`.
|
and `office`.
|
||||||
- Unit tests for preview formatters, menu/selection model, fetch mappers, and
|
- Integration tests under `cmd/office/fetch/` and `cmd/office/preview/`
|
||||||
key routing; integration tests under `cmd/office/fetch/` (`-tags=integration`).
|
(`-tags=integration`) hit the live OnlyOffice API — no fixture maps or
|
||||||
|
httptest vendor mocks. Unit tests remain for pure UI/formatting helpers only.
|
||||||
|
|
||||||
### Added — library
|
### Added — library
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -593,10 +597,10 @@ Spreadsheet files: inline CSV/JSON preview in the right pane; install
|
|||||||
[`vex`](https://github.com/CodeOne45/vex-tui) on `PATH` for full-screen
|
[`vex`](https://github.com/CodeOne45/vex-tui) on `PATH` for full-screen
|
||||||
xlsx/csv viewing (`v` on a file row — coming in next iteration).
|
xlsx/csv viewing (`v` on a file row — coming in next iteration).
|
||||||
|
|
||||||
Integration tests for list loaders:
|
Integration tests for list loaders and preview (live API only — no mocks):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go test -tags=integration ./cmd/office/fetch/...
|
go test -tags=integration ./cmd/office/fetch/... ./cmd/office/preview/...
|
||||||
```
|
```
|
||||||
|
|
||||||
**Project / task documents (`oo`):**
|
**Project / task documents (`oo`):**
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
//go:build integration
|
|
||||||
|
|
||||||
package fetch_test
|
package fetch_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -12,34 +10,99 @@ import (
|
|||||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestIntegrationListProjects(t *testing.T) {
|
func skipWithoutLiveAPI(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
|
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
|
||||||
t.Skip("ONLYOFFICE_URL not set")
|
t.Skip("ONLYOFFICE_URL not set")
|
||||||
}
|
}
|
||||||
client, err := bootstrap.NewClient(context.Background())
|
if os.Getenv("ONLYOFFICE_USER") == "" && os.Getenv("ONLYOFFICE_NAME") == "" {
|
||||||
if err != nil {
|
t.Skip("ONLYOFFICE_USER not set")
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
loader := &fetch.Loader{Client: client}
|
if os.Getenv("ONLYOFFICE_PASS") == "" && os.Getenv("ONLYOFFICE_PASSWORD") == "" {
|
||||||
items, err := loader.List(context.Background(), model.SubjectProjects)
|
t.Skip("ONLYOFFICE_PASS not set")
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
}
|
||||||
t.Logf("projects: %d", len(items))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIntegrationListMailInbox(t *testing.T) {
|
func liveLoader(t *testing.T) (*fetch.Loader, context.Context) {
|
||||||
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
|
t.Helper()
|
||||||
t.Skip("ONLYOFFICE_URL not set")
|
skipWithoutLiveAPI(t)
|
||||||
}
|
|
||||||
client, err := bootstrap.NewClient(context.Background())
|
client, err := bootstrap.NewClient(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
loader := &fetch.Loader{Client: client}
|
return &fetch.Loader{Client: client}, context.Background()
|
||||||
items, err := loader.List(context.Background(), model.SubjectMailInbox)
|
}
|
||||||
|
|
||||||
|
func TestIntegrationListAllSubjects(t *testing.T) {
|
||||||
|
loader, ctx := liveLoader(t)
|
||||||
|
|
||||||
|
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 _, 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", spec.Subject, err)
|
||||||
|
}
|
||||||
|
t.Logf("%s: %d items", spec.Subject, len(items))
|
||||||
|
for i, it := range items {
|
||||||
|
if it.ID == "" {
|
||||||
|
t.Errorf("item[%d] missing ID", i)
|
||||||
|
}
|
||||||
|
if it.Title == "" {
|
||||||
|
t.Errorf("item[%d] missing Title", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationListProjectsMapsRealFields(t *testing.T) {
|
||||||
|
loader, ctx := liveLoader(t)
|
||||||
|
spec := model.ListSpec{Subject: model.SubjectProjects}
|
||||||
|
items, err := loader.List(ctx, spec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Logf("mail messages: %d", len(items))
|
if len(items) == 0 {
|
||||||
|
t.Skip("no projects on instance")
|
||||||
|
}
|
||||||
|
detail, err := loader.Detail(ctx, items[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Detail: %v", err)
|
||||||
|
}
|
||||||
|
if detail == nil {
|
||||||
|
t.Fatal("nil detail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationMailInboxDetail(t *testing.T) {
|
||||||
|
loader, ctx := liveLoader(t)
|
||||||
|
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectMailInbox})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Skip("inbox empty")
|
||||||
|
}
|
||||||
|
detail, err := loader.Detail(ctx, items[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetMailMessage: %v", err)
|
||||||
|
}
|
||||||
|
if detail["subject"] == nil {
|
||||||
|
t.Fatalf("message missing subject: %+v", detail)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+156
-17
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ func ItemsFromMaps(rows []map[string]any, kind model.Kind, fields FieldMap) []mo
|
|||||||
if title == "" {
|
if title == "" {
|
||||||
title = str(row, "name")
|
title = str(row, "name")
|
||||||
}
|
}
|
||||||
|
if title == "" {
|
||||||
|
title = "(untitled)"
|
||||||
|
}
|
||||||
raw := row
|
raw := row
|
||||||
out[i] = model.Item{
|
out[i] = model.Item{
|
||||||
ID: idStr(row, fields.IDKey),
|
ID: idStr(row, fields.IDKey),
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
package fetch_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
|
|
||||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestItemsFromMaps(t *testing.T) {
|
|
||||||
rows := []map[string]any{
|
|
||||||
{"id": float64(1), "title": "Alpha", "status": "Open"},
|
|
||||||
{"id": float64(2), "title": "Beta", "status": "Closed"},
|
|
||||||
}
|
|
||||||
items := fetch.ItemsFromMaps(rows, model.KindTask, fetch.TaskItemFields)
|
|
||||||
if len(items) != 2 {
|
|
||||||
t.Fatalf("len=%d", len(items))
|
|
||||||
}
|
|
||||||
if items[0].ID != "1" || items[0].Title != "Alpha" {
|
|
||||||
t.Fatalf("item0=%+v", items[0])
|
|
||||||
}
|
|
||||||
if items[0].Kind != model.KindTask {
|
|
||||||
t.Fatalf("kind=%v", items[0].Kind)
|
|
||||||
}
|
|
||||||
if items[1].Subtitle != "Closed" {
|
|
||||||
t.Fatalf("subtitle=%q", items[1].Subtitle)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"}}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
package preview_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestContactMarkdownCompany(t *testing.T) {
|
|
||||||
md := preview.ContactMarkdown(map[string]any{
|
|
||||||
"displayName": "Acme GmbH",
|
|
||||||
"isCompany": true,
|
|
||||||
"about": "Widget supplier",
|
|
||||||
})
|
|
||||||
if !strings.Contains(md, "Acme GmbH") {
|
|
||||||
t.Fatalf("title missing: %q", md)
|
|
||||||
}
|
|
||||||
if !strings.Contains(md, "Company") {
|
|
||||||
t.Fatalf("type label missing: %q", md)
|
|
||||||
}
|
|
||||||
if !strings.Contains(md, "Widget supplier") {
|
|
||||||
t.Fatalf("about missing: %q", md)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestOpportunityMarkdown(t *testing.T) {
|
|
||||||
md := preview.OpportunityMarkdown(map[string]any{
|
|
||||||
"title": "Big Deal",
|
|
||||||
"description": "Annual contract",
|
|
||||||
"bidValue": float64(50000),
|
|
||||||
"bidCurrency": "EUR",
|
|
||||||
})
|
|
||||||
if !strings.Contains(md, "Big Deal") {
|
|
||||||
t.Fatalf("title missing: %q", md)
|
|
||||||
}
|
|
||||||
if !strings.Contains(md, "50000") || !strings.Contains(md, "EUR") {
|
|
||||||
t.Fatalf("bid missing: %q", md)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMailMarkdown(t *testing.T) {
|
|
||||||
md := preview.MailMarkdown(map[string]any{
|
|
||||||
"subject": "Hello",
|
|
||||||
"from": "Alice <alice@example.com>",
|
|
||||||
"body": "<p>Hi there</p>",
|
|
||||||
})
|
|
||||||
if !strings.Contains(md, "Hello") {
|
|
||||||
t.Fatalf("subject missing: %q", md)
|
|
||||||
}
|
|
||||||
if !strings.Contains(md, "alice@example.com") {
|
|
||||||
t.Fatalf("from missing: %q", md)
|
|
||||||
}
|
|
||||||
if strings.Contains(md, "<p>") {
|
|
||||||
t.Fatalf("html not stripped: %q", md)
|
|
||||||
}
|
|
||||||
if !strings.Contains(md, "Hi there") {
|
|
||||||
t.Fatalf("body text missing: %q", md)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEventMarkdown(t *testing.T) {
|
|
||||||
md := preview.EventMarkdown(map[string]any{
|
|
||||||
"title": "Standup",
|
|
||||||
"start": "2026-06-24T09:00:00",
|
|
||||||
"end": "2026-06-24T09:15:00",
|
|
||||||
})
|
|
||||||
if !strings.Contains(md, "Standup") {
|
|
||||||
t.Fatalf("title missing: %q", md)
|
|
||||||
}
|
|
||||||
if !strings.Contains(md, "2026-06-24") {
|
|
||||||
t.Fatalf("dates missing: %q", md)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTaskMarkdown(t *testing.T) {
|
|
||||||
md := preview.TaskMarkdown(map[string]any{
|
|
||||||
"title": "Deploy",
|
|
||||||
"status": "Open",
|
|
||||||
"deadline": "2026-07-01",
|
|
||||||
"description": "Roll out v2",
|
|
||||||
})
|
|
||||||
if !strings.Contains(md, "Deploy") {
|
|
||||||
t.Fatalf("title missing: %q", md)
|
|
||||||
}
|
|
||||||
if !strings.Contains(md, "Open") {
|
|
||||||
t.Fatalf("status missing: %q", md)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package preview_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/internal/bootstrap"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
|
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
||||||
|
)
|
||||||
|
|
||||||
|
func skipWithoutLiveAPI(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
|
||||||
|
t.Skip("ONLYOFFICE_URL not set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func liveLoader(t *testing.T) (*fetch.Loader, context.Context) {
|
||||||
|
t.Helper()
|
||||||
|
skipWithoutLiveAPI(t)
|
||||||
|
client, err := bootstrap.NewClient(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return &fetch.Loader{Client: client}, context.Background()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationPreviewProjectFromAPI(t *testing.T) {
|
||||||
|
loader, ctx := liveLoader(t)
|
||||||
|
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjects})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Skip("no projects")
|
||||||
|
}
|
||||||
|
raw, err := loader.Detail(ctx, items[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
md := preview.EntityMarkdown(string(model.KindProject), raw)
|
||||||
|
if md == "" {
|
||||||
|
t.Fatal("empty markdown")
|
||||||
|
}
|
||||||
|
if !strings.Contains(md, items[0].Title) {
|
||||||
|
t.Fatalf("markdown missing project title %q:\n%s", items[0].Title, md)
|
||||||
|
}
|
||||||
|
rendered, err := preview.RenderMarkdown(md, 80)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rendered == "" {
|
||||||
|
t.Fatal("glamour produced empty output")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationPreviewContactFromAPI(t *testing.T) {
|
||||||
|
loader, ctx := liveLoader(t)
|
||||||
|
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectContacts})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Skip("no contacts")
|
||||||
|
}
|
||||||
|
raw, err := loader.Detail(ctx, items[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
md := preview.ContactMarkdown(raw)
|
||||||
|
if md == "" || !strings.Contains(md, "#") {
|
||||||
|
t.Fatalf("unexpected contact markdown: %q", md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationPreviewOpportunityFromAPI(t *testing.T) {
|
||||||
|
loader, ctx := liveLoader(t)
|
||||||
|
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectOpportunities})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Skip("no opportunities")
|
||||||
|
}
|
||||||
|
raw, err := loader.Detail(ctx, items[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
md := preview.OpportunityMarkdown(raw)
|
||||||
|
if !strings.Contains(md, items[0].Title) {
|
||||||
|
t.Fatalf("markdown missing deal title:\n%s", md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationPreviewMailFromAPI(t *testing.T) {
|
||||||
|
loader, ctx := liveLoader(t)
|
||||||
|
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectMailInbox})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Skip("inbox empty")
|
||||||
|
}
|
||||||
|
raw, err := loader.Detail(ctx, items[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
md := preview.MailMarkdown(raw)
|
||||||
|
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.ListSpec{Subject: model.SubjectEvents})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Skip("no events in range")
|
||||||
|
}
|
||||||
|
md := preview.EventMarkdown(items[0].Raw)
|
||||||
|
if !strings.Contains(md, items[0].Title) {
|
||||||
|
t.Fatalf("markdown missing event title:\n%s", md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationPreviewTaskFromAPI(t *testing.T) {
|
||||||
|
loader, ctx := liveLoader(t)
|
||||||
|
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectTasks})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
t.Skip("no tasks")
|
||||||
|
}
|
||||||
|
raw, err := loader.Detail(ctx, items[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
md := preview.TaskMarkdown(raw)
|
||||||
|
if !strings.Contains(md, items[0].Title) {
|
||||||
|
t.Fatalf("markdown missing task title:\n%s", md)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntegrationPreviewCSVFromDownloadedFile(t *testing.T) {
|
||||||
|
skipWithoutLiveAPI(t)
|
||||||
|
if os.Getenv("ONLYOFFICE_PROJECT_ID") == "" {
|
||||||
|
t.Skip("ONLYOFFICE_PROJECT_ID not set")
|
||||||
|
}
|
||||||
|
loader, ctx := liveLoader(t)
|
||||||
|
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjectFiles})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var csvItem *model.Item
|
||||||
|
for i := range items {
|
||||||
|
if strings.HasSuffix(strings.ToLower(items[i].Title), ".csv") {
|
||||||
|
csvItem = &items[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if csvItem == nil {
|
||||||
|
t.Skip("no csv file in project documents")
|
||||||
|
}
|
||||||
|
var buf strings.Builder
|
||||||
|
n, err := loader.Client.DownloadFile(ctx, csvItem.ID, &buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DownloadFile: %v", err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
t.Fatal("empty download")
|
||||||
|
}
|
||||||
|
md, err := preview.CSVToMarkdownTable([]byte(buf.String()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CSVToMarkdownTable: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(md, "|") {
|
||||||
|
t.Fatalf("expected pipe table: %q", md)
|
||||||
|
}
|
||||||
|
}
|
||||||
+285
-103
@@ -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
|
||||||
|
}
|
||||||
|
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) renderPreview() {
|
func (m *Model) syncAllContent() {
|
||||||
_, _, prevW := LayoutWidths(m.width)
|
m.syncMenuContent()
|
||||||
text, err := preview.RenderMarkdown(m.previewMD, prevW-4)
|
m.syncListContent()
|
||||||
if err != nil {
|
m.syncPreviewContent()
|
||||||
text = m.previewMD
|
|
||||||
}
|
|
||||||
m.preview.SetContent(text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) renderMenu(w int) string {
|
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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
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}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user