Compare commits

..
9 Commits
Author SHA1 Message Date
eSliderandGitHub eed57a68d4 Merge pull request #4 from eSlider/release-please--branches--main--components--go-onlyoffice
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.22) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 37s
chore(main): release 0.6.0
2026-06-24 21:26:47 +01:00
github-actions[bot]andGitHub b2aae21390 chore(main): release 0.6.0 2026-06-24 16:36:03 +00:00
eSliderandCursor 3d4aa10cf9 docs: CHANGELOG for office v0.5.1 release
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.22) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 37s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 17:35:44 +01:00
eSliderandCursor 3b47d1400b feat(office): table detail panes and Alt+1/2/3 layout toggles
Add spreadsheet-style list, split detail pane with forms and document preview,
and Alt+1/2/3 to show/hide panes with even width distribution.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 17:33:50 +01:00
eSliderandGitHub a413d409b5 Merge pull request #3 from eSlider/release-please--branches--main--components--go-onlyoffice
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.22) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 37s
chore(main): release 0.5.0
2026-06-24 16:53:54 +01:00
github-actions[bot]andGitHub cac0ffa2a0 chore(main): release 0.5.0 2026-06-24 15:43:20 +00:00
eSliderandCursor cad68f64c6 feat(office): scrollable panes, nav tree drill-down, and item actions
Add viewports to all three columns with cursor sync scrolling. Support
Shift+Tab reverse pane focus. Replace flat menu with hierarchical NavTree
(list loads only on leaf nodes; dynamic project subnodes with Tasks/Files).
Enter or a opens per-item action menu (view, delete, download) wired to
the OnlyOffice API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 16:43:06 +01:00
eSliderandCursor 4046f4c46a test(office): replace fixture tests with live OnlyOffice API integration
Remove synthetic map-based entity and fetch mapper unit tests; cover all
menu subjects and preview pipelines via integration tests against the real
Workspace API. Handle draft mail without subjects in list mapping.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 16:37:46 +01:00
eSliderandCursor 6d4df4edee ci: allow manual GoReleaser dispatch for existing tags
Release Please creates GitHub releases without pushing tag events that
reliably trigger GoReleaser; workflow_dispatch publishes binaries for a
given tag on demand.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 16:33:12 +01:00
37 changed files with 3522 additions and 646 deletions
+7
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "0.4.0" ".": "0.6.0"
} }
+29 -45
View File
@@ -4,6 +4,25 @@ 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.6.0](https://github.com/eSlider/go-onlyoffice/compare/v0.5.0...v0.6.0) (2026-06-24)
### Features
* **office:** table detail panes and Alt+1/2/3 layout toggles ([934af21](https://github.com/eSlider/go-onlyoffice/commit/934af21bc93a54df9f40aacb4d89468b00d7562e))
### Documentation
* CHANGELOG for office v0.5.1 release ([2985070](https://github.com/eSlider/go-onlyoffice/commit/29850701f8c9ab0c6d6427f63eaa189a1c558140))
## [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)
@@ -33,55 +52,20 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
## [0.5.1] — 2026-06-24
### Added — `office` TUI ### Added — `office` TUI
- **`cmd/office`** — Bubble Tea terminal UI mirroring OnlyOffice Workspace: - **Alt+1 / Alt+2 / Alt+3** — show/hide left (nav), middle (table), and right (detail) panes.
three-pane layout (module tree, multi-select list, markdown preview). - Visible panes share **100% terminal width** evenly; pane content fills its column.
Install: `go install github.com/eslider/go-onlyoffice/cmd/office@latest`. - Tab focus skips hidden panes.
- **`cmd/internal/bootstrap`** — shared env loading and client auth for `oo`
and `office`.
- Unit tests for preview formatters, menu/selection model, fetch mappers, and
key routing; integration tests under `cmd/office/fetch/` (`-tags=integration`).
### Added — library ### Changed — `office` TUI
- **CRM dedupe & cleanup** — `crm_normalize.go`, `crm_dedupe_plan.go`, `crm_dedupe.go`: - Split detail pane: form/document top (~72%), CRUD action bar bottom.
merge duplicate companies/persons/deals by normalized name; remove duplicate - Middle pane: multi-column table with sort, selection, and full-width columns.
contact-info rows and opportunity members (including same `displayName` with - Project list columns: ID, Title, Tasks (open/closed), Documents, Users.
different ids); fix malformed deal titles (` @ Company` → `Company`). - Row selection auto-loads detail; files show document preview, entities show forms.
`MergeContacts`, `UpdateOpportunityTitle`, `ListAllContacts`,
`ListAllOpportunities`, `DeleteContactInfo`, `RemoveOpportunityMember`,
`IsOpportunityMember`, `HasContactInfo`, `CleanupCRM`.
- Slogan-aware grouping: `StripSloganSuffix`, `CompanyGroupingKey` merge names
like `Affirm` and `Affirm — Fraud Engineering` (companies, deals, members).
- **Workspace mail** — [`mails.go`](mails.go): `ListMailAccounts`, `ListMailFolders`,
`ListMailMessages`, `GetMailMessage`, `RemoveMailMessages` against OnlyOffice
Mail addon (`/api/2.0/mail/*`).
- [`crm_integration_test.go`](crm_integration_test.go) — live merge, rename,
dedupe smoke tests.
### Added — CLI
- `oo mails accounts|folders|list|get|delete` — OnlyOffice Workspace mail (same `ONLYOFFICE_*` creds).
- `oo companies dedupe|dedupe-persons`, `oo persons dedupe`, `oo contacts dedupe-info`,
`oo opportunities dedupe|dedupe-members|fix-titles`, `oo crm cleanup`.
### Added — CI/CD
- GitHub Actions: `test.yml` (vet, unit tests, `oo` build), `release-please.yml`
(semver from conventional commits), `release.yml` (GoReleaser binaries for `oo`).
### Changed — CLI
- README: expanded **oo CLI use cases** (CRM cleanup, applications sync, mail, contacts).
- `oo applications sync` — empty position uses company-only deal title; skips
duplicate opportunity members; paginated find-or-create lookups.
- `oo` now loads OnlyOffice credentials only from `.env` in the current
working directory.
- Produktor.io shorthand env names are accepted by the CLI dotenv loader:
`OO_URL` → `ONLYOFFICE_URL`, `OO_USER` → `ONLYOFFICE_USER`, and
`OO_PASS` → `ONLYOFFICE_PASS`. Canonical `ONLYOFFICE_*` values still win
when already set in the process environment.
## [0.6.0] - 2026-04-24 ## [0.6.0] - 2026-04-24
+10 -6
View File
@@ -575,13 +575,17 @@ office
| Key | Action | | Key | Action |
|---|---| |---|---|
| `Tab` | Switch pane (menu → list → preview) | | `Tab` | Next pane (menu → list → preview) |
| `↑↓` / `j` / `k` | Navigate | | `Shift+Tab` | Previous pane |
| `Space` / `Enter` | Toggle selection on list row | | `↑↓` / `j` / `k` | Navigate / scroll within focused pane |
| `Enter` | Load preview for focused row | | `Enter` / `→` | Expand tree branch or open leaf list |
| `Space` | Toggle multi-select on list row |
| `Enter` / `a` | Action menu (view, delete, download, …) |
| `r` | Refresh current list | | `r` | Refresh current list |
| `q` | Quit | | `q` | Quit |
Navigate the **tree** on the left: expand modules (`▸`/`▾`), drill to a **leaf** (marked `•`) — the center list loads only at the last level. Under **Projects → By project**, live projects appear as subnodes with **Tasks** and **Files** leaves.
Optional env for DOCX preview via Document Server (see [`.env.example`](.env.example)): Optional env for DOCX preview via Document Server (see [`.env.example`](.env.example)):
```bash ```bash
@@ -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`):**
+81 -18
View File
@@ -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)
}
} }
+172 -22
View File
@@ -3,6 +3,9 @@ package fetch
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"path/filepath"
"strconv"
"time" "time"
onlyoffice "github.com/eslider/go-onlyoffice" onlyoffice "github.com/eslider/go-onlyoffice"
@@ -14,15 +17,18 @@ type Loader struct {
Client *onlyoffice.Client Client *onlyoffice.Client
} }
// List returns items for the given subject. // List returns items for the given list spec (nav leaf).
func (l *Loader) List(ctx context.Context, subject model.Subject) ([]model.Item, error) { func (l *Loader) List(ctx context.Context, spec model.ListSpec) ([]model.Item, error) {
if l == nil || l.Client == nil { if l == nil || l.Client == nil {
return nil, fmt.Errorf("fetch: client is nil") return nil, fmt.Errorf("fetch: client is nil")
} }
switch subject { switch spec.Subject {
case model.SubjectProjects: case model.SubjectProjects:
return l.listProjects(ctx) return l.listProjects(ctx)
case model.SubjectTasks: case model.SubjectTasks:
if spec.ProjectID != "" {
return l.listTasksForProject(ctx, spec.ProjectID)
}
return l.listTasks(ctx) return l.listTasks(ctx)
case model.SubjectCalendars: case model.SubjectCalendars:
return l.listCalendars(ctx) return l.listCalendars(ctx)
@@ -55,12 +61,27 @@ func (l *Loader) List(ctx context.Context, subject model.Subject) ([]model.Item,
case model.SubjectUsers: case model.SubjectUsers:
return l.listUsers(ctx) return l.listUsers(ctx)
case model.SubjectProjectFiles: case model.SubjectProjectFiles:
return l.listProjectFiles(ctx) pid := spec.ProjectID
case model.SubjectTaskFiles: if pid == "" {
return nil, fmt.Errorf("select a task in Tasks first (task files need task id)") pid = onlyoffice.GetEnvironmentDefaults().ProjectID
default:
return nil, fmt.Errorf("unsupported subject %q", subject)
} }
if pid == "" {
return nil, fmt.Errorf("set ONLYOFFICE_PROJECT_ID or pick a project in the tree")
}
return l.listProjectFiles(ctx, pid)
case model.SubjectTaskFiles:
if spec.TaskID == "" {
return nil, fmt.Errorf("pick a task under Projects in the tree")
}
return l.listTaskFiles(ctx, spec.TaskID)
default:
return nil, fmt.Errorf("unsupported subject %q", spec.Subject)
}
}
// LoadProjectsForNav returns projects to inject as dynamic tree nodes.
func (l *Loader) LoadProjectsForNav(ctx context.Context) ([]model.Item, error) {
return l.listProjects(ctx)
} }
// Detail fetches full record data for preview when list row is insufficient. // Detail fetches full record data for preview when list row is insufficient.
@@ -72,7 +93,7 @@ func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, e
return l.Client.GetContact(ctx, item.ID) return l.Client.GetContact(ctx, item.ID)
case model.KindMail: case model.KindMail:
return l.Client.GetMailMessage(ctx, item.ID) return l.Client.GetMailMessage(ctx, item.ID)
case model.KindTask: case model.KindTask, model.KindCRMTask:
return l.Client.GetTaskByID(ctx, item.ID) return l.Client.GetTaskByID(ctx, item.ID)
case model.KindProject: case model.KindProject:
return l.Client.GetProjectByID(ctx, item.ID) return l.Client.GetProjectByID(ctx, item.ID)
@@ -84,6 +105,130 @@ func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, e
} }
} }
// Execute runs a user-selected action on an item.
func (l *Loader) Execute(ctx context.Context, action model.ActionID, item model.Item, destPath string) (string, error) {
switch action {
case model.ActionView:
return "view", nil
case model.ActionDelete:
return l.executeDelete(ctx, item)
case model.ActionDownload:
return l.executeDownload(ctx, item, destPath)
default:
return "", fmt.Errorf("unsupported action %q", action)
}
}
func (l *Loader) executeDelete(ctx context.Context, item model.Item) (string, error) {
switch item.Kind {
case model.KindProject:
id, err := strconv.Atoi(item.ID)
if err != nil {
return "", err
}
if _, err := l.Client.DeleteProject(id); err != nil {
return "", err
}
return fmt.Sprintf("Deleted project %s", item.Title), nil
case model.KindTask:
if _, err := l.Client.DeleteTask(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted task %s", item.Title), nil
case model.KindContact:
if _, err := l.Client.DeleteContact(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted contact %s", item.Title), nil
case model.KindOpportunity:
if _, err := l.Client.DeleteOpportunity(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted deal %s", item.Title), nil
case model.KindCase:
if _, err := l.Client.DeleteCase(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted case %s", item.Title), nil
case model.KindCRMTask:
if _, err := l.Client.DeleteCRMTask(ctx, item.ID); err != nil {
return "", err
}
return fmt.Sprintf("Deleted CRM task %s", item.Title), nil
case model.KindMail:
id, err := strconv.Atoi(item.ID)
if err != nil {
return "", err
}
if _, err := l.Client.RemoveMailMessages(ctx, id); err != nil {
return "", err
}
return fmt.Sprintf("Deleted message %s", item.Title), nil
case model.KindFile:
id, err := strconv.Atoi(item.ID)
if err != nil {
return "", err
}
if err := l.Client.DeleteFiles(ctx, []int{id}); err != nil {
return "", err
}
return fmt.Sprintf("Deleted file %s", item.Title), nil
default:
return "", fmt.Errorf("delete not supported for %s", item.Kind)
}
}
func (l *Loader) executeDownload(ctx context.Context, item model.Item, destPath string) (string, error) {
if item.Kind != model.KindFile {
return "", fmt.Errorf("download only for files")
}
if destPath == "" {
destPath = filepath.Join(os.TempDir(), "office", item.Title)
}
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return "", err
}
f, err := os.Create(destPath)
if err != nil {
return "", err
}
defer f.Close()
if _, err := l.Client.DownloadFile(ctx, item.ID, f); err != nil {
return "", err
}
return fmt.Sprintf("Downloaded to %s", destPath), nil
}
func (l *Loader) listTasksForProject(ctx context.Context, projectID string) ([]model.Item, error) {
rows, err := l.Client.ListTasks(ctx, projectID, "")
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil
}
func (l *Loader) listTaskFiles(ctx context.Context, taskID string) ([]model.Item, error) {
files, err := l.Client.GetTaskFiles(ctx, taskID)
if err != nil {
return nil, err
}
var items []model.Item
for _, f := range files {
id, title := "", ""
if f.ID != nil {
id = f.ID.String()
}
if f.Title != nil {
title = *f.Title
}
items = append(items, model.Item{
ID: id, Title: title, Kind: model.KindFile,
Raw: map[string]any{"id": id, "title": title},
})
}
return items, nil
}
func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) { func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) {
projects, err := l.Client.GetProjects() projects, err := l.Client.GetProjects()
if err != nil { if err != nil {
@@ -99,13 +244,24 @@ func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) {
if p.Title != nil { if p.Title != nil {
title = *p.Title title = *p.Title
} }
status := "" raw := map[string]any{
if p.Status != nil { "id": id,
status = fmt.Sprint(*p.Status) "title": title,
}
if p.TaskCount != nil {
raw["taskCount"] = *p.TaskCount
}
if p.TaskCountTotal != nil {
raw["taskCountTotal"] = *p.TaskCountTotal
}
if p.DocumentsCount != nil {
raw["documentsCount"] = *p.DocumentsCount
}
if p.ParticipantCount != nil {
raw["participantCount"] = *p.ParticipantCount
} }
items[i] = model.Item{ items[i] = model.Item{
ID: id, Title: title, Subtitle: status, Kind: model.KindProject, ID: id, Title: title, Kind: model.KindProject, Raw: raw,
Raw: map[string]any{"id": id, "title": title, "status": status},
} }
} }
return items, nil return items, nil
@@ -214,14 +370,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
} }
+3
View File
@@ -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),
-28
View File
@@ -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)
}
}
+43
View File
@@ -0,0 +1,43 @@
package fetch
import (
"bytes"
"context"
"fmt"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
// PreviewMarkdown loads item data and returns markdown for the preview pane.
func (l *Loader) PreviewMarkdown(ctx context.Context, item model.Item) (string, error) {
if l == nil || l.Client == nil {
return "", fmt.Errorf("fetch: client is nil")
}
if item.Kind == model.KindFile {
return l.filePreviewMarkdown(ctx, item)
}
raw, err := l.Detail(ctx, item)
if err != nil {
return "", err
}
return preview.EntityMarkdown(string(item.Kind), raw), nil
}
func (l *Loader) filePreviewMarkdown(ctx context.Context, item model.Item) (string, error) {
if item.ID == "" {
return "", fmt.Errorf("file id missing")
}
name := item.Title
if meta, err := l.Client.GetFile(ctx, item.ID); err == nil && meta != nil {
if t := onlyoffice.FileEntryTitle(meta); t != "" {
name = t
}
}
var buf bytes.Buffer
if _, err := l.Client.DownloadFile(ctx, item.ID, &buf); err != nil {
return "", err
}
return preview.FileBytesToMarkdown(name, buf.Bytes())
}
+43
View File
@@ -0,0 +1,43 @@
package fetch
import (
"context"
"fmt"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// SaveItem persists editable form fields for supported entity kinds.
func (l *Loader) SaveItem(ctx context.Context, item model.Item, title, description string) error {
if l == nil || l.Client == nil {
return fmt.Errorf("fetch: client is nil")
}
switch item.Kind {
case model.KindTask:
return l.UpdateTask(ctx, item.ID, title, description)
case model.KindProject:
id, err := strconv.Atoi(item.ID)
if err != nil {
return err
}
_, err = l.Client.UpdateProject(onlyoffice.ProjectUpdateRequest{
ID: id,
Title: title,
Description: description,
})
return err
default:
return fmt.Errorf("save not supported for %s", item.Kind)
}
}
// DetailForm loads form field values for the detail pane.
func (l *Loader) DetailForm(ctx context.Context, item model.Item) (model.FormFields, error) {
raw, err := l.Detail(ctx, item)
if err != nil {
return model.FormFields{}, err
}
return model.FormFieldsFromRaw(item.Kind, raw), nil
}
+36
View File
@@ -0,0 +1,36 @@
package fetch
import (
"context"
"fmt"
"strconv"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// UpdateTask saves title and description for a project task.
func (l *Loader) UpdateTask(ctx context.Context, taskID, title, description string) error {
if l == nil || l.Client == nil {
return fmt.Errorf("fetch: client is nil")
}
id, err := strconv.Atoi(taskID)
if err != nil {
return fmt.Errorf("task id %q: %w", taskID, err)
}
_, err = l.Client.UpdateProjectTask(onlyoffice.ProjectTaskUpdateRequest{
ID: id,
Title: title,
Description: description,
})
return err
}
// TaskFields loads title and description for a project task item.
func (l *Loader) TaskFields(ctx context.Context, item model.Item) (title, description string, err error) {
fields, err := l.DetailForm(ctx, item)
if err != nil {
return "", "", err
}
return fields.Primary, fields.Secondary, nil
}
+68
View File
@@ -0,0 +1,68 @@
//go:build integration
package fetch_test
import (
"context"
"os"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestIntegrationUpdateTaskTitleDescription(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")
}
item := items[0]
title, desc, err := loader.TaskFields(ctx, item)
if err != nil {
t.Fatal(err)
}
restoreTitle, restoreDesc := title, desc
t.Cleanup(func() {
_ = loader.UpdateTask(context.Background(), item.ID, restoreTitle, restoreDesc)
})
newTitle := title + " (office TUI test)"
newDesc := desc + "\n\n_edited by office integration test_"
if err := loader.UpdateTask(ctx, item.ID, newTitle, newDesc); err != nil {
t.Fatal(err)
}
gotTitle, gotDesc, err := loader.TaskFields(ctx, item)
if err != nil {
t.Fatal(err)
}
if gotTitle != newTitle {
t.Fatalf("title: got %q want %q", gotTitle, newTitle)
}
if gotDesc != newDesc {
t.Fatalf("description mismatch")
}
}
func TestIntegrationTaskFieldsFromLiveAPI(t *testing.T) {
if os.Getenv("ONLYOFFICE_URL") == "" && os.Getenv("ONLYOFFICE_HOST") == "" {
t.Skip("ONLYOFFICE_URL not set")
}
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")
}
title, desc, err := loader.TaskFields(ctx, items[0])
if err != nil {
t.Fatal(err)
}
if title == "" {
t.Fatal("empty title")
}
_ = desc
}
+1 -1
View File
@@ -26,7 +26,7 @@ func main() {
} }
m := ui.NewModel(client) m := ui.NewModel(client)
p := tea.NewProgram(m, tea.WithAltScreen()) p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := p.Run(); err != nil { if _, err := p.Run(); err != nil {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
+64
View File
@@ -0,0 +1,64 @@
package model
// ActionID identifies an operation on a list item.
type ActionID string
const (
ActionView ActionID = "view"
ActionSave ActionID = "save"
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 (detail pane action bar).
func ActionsFor(kind Kind) []ItemAction {
switch kind {
case KindProject:
return []ItemAction{
{ID: ActionSave, Label: "Save"},
{ID: ActionDelete, Label: "Delete", Danger: true},
}
case KindTask:
return []ItemAction{
{ID: ActionSave, Label: "Save"},
{ID: ActionDelete, Label: "Delete task", Danger: true},
}
case KindCRMTask:
return []ItemAction{
{ID: ActionDelete, Label: "Delete", Danger: true},
}
case KindContact:
return []ItemAction{
{ID: ActionDelete, Label: "Delete contact", Danger: true},
}
case KindOpportunity:
return []ItemAction{
{ID: ActionDelete, Label: "Delete deal", Danger: true},
}
case KindCase:
return []ItemAction{
{ID: ActionDelete, Label: "Delete case", Danger: true},
}
case KindMail:
return []ItemAction{
{ID: ActionDelete, Label: "Delete message", Danger: true},
}
case KindFile:
return []ItemAction{
{ID: ActionDownload, Label: "Download"},
{ID: ActionDelete, Label: "Delete file", Danger: true},
}
case KindEvent, KindCalendar, KindUser:
return nil
default:
return nil
}
}
+287
View File
@@ -0,0 +1,287 @@
package model
import (
"fmt"
"sort"
"strconv"
"strings"
)
// TableColumn is one column in the center data table.
type TableColumn struct {
Key string
Title string
Width int
}
var subjectExtraKeys = map[Subject][]string{
SubjectTasks: {"status", "deadline", "responsible"},
SubjectCalendars: {"description"},
SubjectEvents: {"start", "end"},
SubjectContacts: {"primaryEmail", "displayName"},
SubjectPersons: {"primaryEmail", "displayName"},
SubjectCompanies: {"primaryEmail", "displayName"},
SubjectOpportunities: {"stage", "bidValue", "bidCurrency"},
SubjectCases: {"status"},
SubjectCRMTasks: {"status", "deadline"},
SubjectMailInbox: {"from", "date"},
SubjectMailSent: {"to", "date"},
SubjectMailDrafts: {"to", "date"},
SubjectMailTrash: {"from", "date"},
SubjectMailSpam: {"from", "date"},
SubjectUsers: {"email", "displayName"},
}
// BuildColumns derives table columns from the list subject and item payloads.
func BuildColumns(subject Subject, items []Item) []TableColumn {
if subject == SubjectProjects {
return buildProjectColumns(items)
}
cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 10},
{Key: "title", Title: "Title", Width: 28},
}
if hasSubtitle(items) {
cols = append(cols, TableColumn{Key: "subtitle", Title: "Subtitle", Width: 22})
}
seen := map[string]bool{"_sel": true, "id": true, "title": true, "subtitle": true}
for _, key := range subjectExtraKeys[subject] {
if seen[key] || !columnHasData(items, key) {
continue
}
seen[key] = true
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
}
discovered := discoverRawKeys(items, seen, 8)
for _, key := range discovered {
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
}
sizeColumns(cols, items)
return cols
}
func buildProjectColumns(items []Item) []TableColumn {
cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 8},
{Key: "title", Title: "Title", Width: 28},
{Key: "tasks", Title: "Tasks (open/closed)", Width: 18},
{Key: "documents", Title: "Documents", Width: 11},
{Key: "users", Title: "Users", Width: 8},
}
sizeColumns(cols, items)
return cols
}
// CellText returns the display string for one table cell.
func CellText(it Item, key string) string {
switch key {
case "_sel":
if it.Selected {
return "●"
}
return "○"
case "id":
return it.ID
case "title":
return it.Title
case "subtitle":
return it.Subtitle
case "tasks":
return formatProjectTasks(it.Raw)
case "documents":
return intRaw(it.Raw, "documentsCount")
case "users":
return intRaw(it.Raw, "participantCount")
case "kind":
return string(it.Kind)
default:
if it.Raw == nil {
return ""
}
return formatAny(it.Raw[key])
}
}
func formatProjectTasks(raw map[string]any) string {
if raw == nil {
return "0/0"
}
open := intRawVal(raw, "taskCount")
total := intRawVal(raw, "taskCountTotal")
closed := total - open
if closed < 0 {
closed = 0
}
return fmt.Sprintf("%d/%d", open, closed)
}
func intRaw(raw map[string]any, key string) string {
if raw == nil {
return ""
}
v := intRawVal(raw, key)
if v == 0 {
if _, ok := raw[key]; !ok {
return ""
}
}
return fmt.Sprintf("%d", v)
}
func intRawVal(raw map[string]any, key string) int {
if raw == nil {
return 0
}
switch v := raw[key].(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case string:
n, _ := strconv.Atoi(v)
return n
default:
if raw[key] == nil {
return 0
}
n, _ := strconv.Atoi(fmt.Sprint(raw[key]))
return n
}
}
func hasSubtitle(items []Item) bool {
for _, it := range items {
if it.Subtitle != "" {
return true
}
}
return false
}
func columnHasData(items []Item, key string) bool {
for _, it := range items {
if CellText(it, key) != "" {
return true
}
}
return false
}
func discoverRawKeys(items []Item, seen map[string]bool, limit int) []string {
counts := map[string]int{}
for _, it := range items {
if it.Raw == nil {
continue
}
for k := range it.Raw {
if seen[k] || k == "id" || k == "title" {
continue
}
counts[k]++
}
}
type kv struct {
k string
n int
}
var ranked []kv
for k, n := range counts {
ranked = append(ranked, kv{k, n})
}
sort.Slice(ranked, func(i, j int) bool {
if ranked[i].n == ranked[j].n {
return ranked[i].k < ranked[j].k
}
return ranked[i].n > ranked[j].n
})
out := make([]string, 0, limit)
for _, r := range ranked {
if len(out) >= limit {
break
}
out = append(out, r.k)
}
return out
}
func titleLabel(key string) string {
if key == "" {
return ""
}
parts := strings.Split(key, "_")
for i, p := range parts {
if p == "" {
continue
}
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
return strings.Join(parts, " ")
}
func defaultWidth(key string) int {
switch key {
case "id", "status", "stage":
return 10
case "start", "end", "deadline", "date":
return 18
case "from", "to", "email", "primaryEmail":
return 22
case "description", "displayName":
return 24
default:
return 14
}
}
func sizeColumns(cols []TableColumn, items []Item) {
for i := range cols {
maxW := runeLen(cols[i].Title)
for _, it := range items {
n := runeLen(CellText(it, cols[i].Key))
if n > maxW {
maxW = n
}
}
maxW += 2
if maxW < 4 {
maxW = 4
}
if maxW > 36 {
maxW = 36
}
cols[i].Width = maxW
}
}
func runeLen(s string) int {
return len([]rune(s))
}
func formatAny(v any) string {
if v == nil {
return ""
}
switch x := v.(type) {
case string:
return x
case bool:
if x {
return "yes"
}
return "no"
case float64:
if x == float64(int64(x)) {
return fmt.Sprintf("%d", int64(x))
}
return fmt.Sprintf("%g", x)
default:
return fmt.Sprint(v)
}
}
+55
View File
@@ -0,0 +1,55 @@
package model
import "testing"
func TestBuildProjectColumns(t *testing.T) {
items := []Item{{
ID: "1", Title: "Alpha", Kind: KindProject,
Raw: map[string]any{
"taskCount": 3, "taskCountTotal": 5,
"documentsCount": 2, "participantCount": 4,
},
}}
cols := BuildColumns(SubjectProjects, items)
want := []string{"_sel", "id", "title", "tasks", "documents", "users"}
if len(cols) != len(want) {
t.Fatalf("got %d columns, want %d", len(cols), len(want))
}
for i, key := range want {
if cols[i].Key != key {
t.Fatalf("col[%d]=%q want %q", i, cols[i].Key, key)
}
}
for _, c := range cols {
if c.Key == "status" || c.Key == "subtitle" {
t.Fatalf("unexpected column %q", c.Key)
}
}
}
func TestFormatProjectTasksOpenClosed(t *testing.T) {
raw := map[string]any{"taskCount": 3, "taskCountTotal": 8}
if got := formatProjectTasks(raw); got != "3/5" {
t.Fatalf("got %q want 3/5", got)
}
}
func TestCellTextProjectDocumentsUsers(t *testing.T) {
it := Item{Raw: map[string]any{"documentsCount": 7, "participantCount": 2}}
if got := CellText(it, "documents"); got != "7" {
t.Fatalf("documents: %q", got)
}
if got := CellText(it, "users"); got != "2" {
t.Fatalf("users: %q", got)
}
}
func TestBuildColumnsIncludesSelectionAndCoreFields(t *testing.T) {
items := []Item{
{ID: "1", Title: "Alpha", Kind: KindTask, Raw: map[string]any{"status": "Open"}},
}
cols := BuildColumns(SubjectTasks, items)
if cols[0].Key != "_sel" {
t.Fatalf("first column should be selection, got %q", cols[0].Key)
}
}
+115
View File
@@ -0,0 +1,115 @@
package model
import "fmt"
// FormFields holds editable/display fields loaded from API detail.
type FormFields struct {
PrimaryLabel string
SecondaryLabel string
Primary string
Secondary string
ReadOnly bool
}
// KindHeading returns a short label for the detail pane header.
func KindHeading(kind Kind, id string) string {
return fmt.Sprintf("%s %s", kindLabel(kind), id)
}
func kindLabel(kind Kind) string {
switch kind {
case KindProject:
return "Project"
case KindTask:
return "Task"
case KindCRMTask:
return "CRM task"
case KindContact:
return "Contact"
case KindOpportunity:
return "Deal"
case KindCase:
return "Case"
case KindMail:
return "Mail"
case KindEvent:
return "Event"
case KindCalendar:
return "Calendar"
case KindFile:
return "Document"
case KindUser:
return "User"
default:
return string(kind)
}
}
// FormFieldsFromRaw maps API detail JSON to form fields for the right pane.
func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
switch kind {
case KindMail:
body := strRaw(raw, "body")
if body == "" {
body = strRaw(raw, "htmlBody")
}
return FormFields{
PrimaryLabel: "Subject", SecondaryLabel: "Body",
Primary: strRaw(raw, "subject"), Secondary: body,
ReadOnly: true,
}
case KindUser:
return FormFields{
PrimaryLabel: "Name", SecondaryLabel: "Email",
Primary: strRaw(raw, "displayName"), Secondary: strRaw(raw, "email"),
ReadOnly: true,
}
case KindContact:
name := strRaw(raw, "displayName")
if name == "" {
name = strRaw(raw, "title")
}
return FormFields{
PrimaryLabel: "Name", SecondaryLabel: "About",
Primary: name, Secondary: strRaw(raw, "about"),
ReadOnly: true,
}
case KindEvent, KindCalendar:
return FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: strRaw(raw, "title"), Secondary: strRaw(raw, "description"),
ReadOnly: true,
}
default:
title := strRaw(raw, "title")
if title == "" {
title = strRaw(raw, "name")
}
if title == "" {
title = strRaw(raw, "subject")
}
return FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: title, Secondary: strRaw(raw, "description"),
ReadOnly: kind != KindTask && kind != KindProject,
}
}
}
func strRaw(m map[string]any, key string) string {
if m == nil {
return ""
}
if v, ok := m[key].(string); ok {
return v
}
if m[key] == nil {
return ""
}
return fmt.Sprint(m[key])
}
// IsDocumentKind is true when the right pane should show file content, not a form.
func IsDocumentKind(kind Kind) bool {
return kind == KindFile
}
+20
View File
@@ -0,0 +1,20 @@
package model
import "testing"
func TestFormFieldsFromRawMail(t *testing.T) {
raw := map[string]any{"subject": "Hi", "body": "Hello"}
f := FormFieldsFromRaw(KindMail, raw)
if f.Primary != "Hi" || f.Secondary != "Hello" || !f.ReadOnly {
t.Fatalf("unexpected mail fields: %+v", f)
}
}
func TestIsDocumentKind(t *testing.T) {
if !IsDocumentKind(KindFile) {
t.Fatal("file should be document")
}
if IsDocumentKind(KindTask) {
t.Fatal("task should be form")
}
}
+12
View File
@@ -50,6 +50,18 @@ const (
FocusPreview FocusPreview
) )
// PrevFocusPane cycles preview → list → menu → preview.
func PrevFocusPane(p FocusPane) FocusPane {
switch p {
case FocusMenu:
return FocusPreview
case FocusList:
return FocusMenu
default:
return FocusList
}
}
// NextFocusPane cycles menu → list → preview → menu. // NextFocusPane cycles menu → list → preview → menu.
func NextFocusPane(p FocusPane) FocusPane { func NextFocusPane(p FocusPane) FocusPane {
switch p { switch p {
-179
View File
@@ -1,179 +0,0 @@
package model
// menuNode is one row in the left navigation tree (flat slice with depth).
type menuNode struct {
label string
depth int
subject Subject // zero when branch node
expandable bool
parent int // index of parent branch, -1 for roots
}
// MenuTree is the left-pane OnlyOffice module navigator.
type MenuTree struct {
nodes []menuNode
cursor int
expanded map[int]bool
visible []int // indices into nodes, recomputed on expand/collapse
}
// DefaultMenuTree returns the standard OnlyOffice Workspace module tree.
func DefaultMenuTree() *MenuTree {
nodes := []menuNode{
{label: "Projects", depth: 0, expandable: true, parent: -1},
{label: "All projects", depth: 1, subject: SubjectProjects, parent: 0},
{label: "Tasks", depth: 1, subject: SubjectTasks, parent: 0},
{label: "Calendar", depth: 0, expandable: true, parent: -1},
{label: "Calendars", depth: 1, subject: SubjectCalendars, parent: 3},
{label: "Events", depth: 1, subject: SubjectEvents, parent: 3},
{label: "CRM", depth: 0, expandable: true, parent: -1},
{label: "Contacts", depth: 1, subject: SubjectContacts, parent: 6},
{label: "Persons", depth: 1, subject: SubjectPersons, parent: 6},
{label: "Companies", depth: 1, subject: SubjectCompanies, parent: 6},
{label: "Opportunities", depth: 1, subject: SubjectOpportunities, parent: 6},
{label: "Cases", depth: 1, subject: SubjectCases, parent: 6},
{label: "CRM Tasks", depth: 1, subject: SubjectCRMTasks, parent: 6},
{label: "Mail", depth: 0, expandable: true, parent: -1},
{label: "Inbox", depth: 1, subject: SubjectMailInbox, parent: 13},
{label: "Sent", depth: 1, subject: SubjectMailSent, parent: 13},
{label: "Drafts", depth: 1, subject: SubjectMailDrafts, parent: 13},
{label: "Trash", depth: 1, subject: SubjectMailTrash, parent: 13},
{label: "Spam", depth: 1, subject: SubjectMailSpam, parent: 13},
{label: "Documents", depth: 0, expandable: true, parent: -1},
{label: "Project files", depth: 1, subject: SubjectProjectFiles, parent: 19},
{label: "Task files", depth: 1, subject: SubjectTaskFiles, parent: 19},
{label: "Users", depth: 0, expandable: true, parent: -1},
{label: "Directory", depth: 1, subject: SubjectUsers, parent: 22},
}
m := &MenuTree{
nodes: nodes,
expanded: map[int]bool{},
}
m.rebuildVisible()
return m
}
// RootLabels returns top-level module names in order.
func (m *MenuTree) RootLabels() []string {
var out []string
for i, n := range m.nodes {
if n.depth == 0 {
out = append(out, n.label)
}
_ = i
}
return out
}
// Cursor returns the visible-row cursor index.
func (m *MenuTree) Cursor() int { return m.cursor }
// SetCursor sets the visible-row cursor.
func (m *MenuTree) SetCursor(c int) {
if c < 0 {
c = 0
}
if c >= len(m.visible) {
c = len(m.visible) - 1
}
if c < 0 {
c = 0
}
m.cursor = c
}
// MoveDown advances the menu cursor.
func (m *MenuTree) MoveDown() {
m.SetCursor(m.cursor + 1)
}
// MoveUp moves the menu cursor up.
func (m *MenuTree) MoveUp() {
m.SetCursor(m.cursor - 1)
}
// VisibleCount returns the number of visible menu rows.
func (m *MenuTree) VisibleCount() int { return len(m.visible) }
// LabelAt returns the label and depth for visible row i.
func (m *MenuTree) LabelAt(i int) (label string, depth int) {
if i < 0 || i >= len(m.visible) {
return "", 0
}
n := m.nodes[m.visible[i]]
return n.label, n.depth
}
// IsExpandable reports whether visible row i is a branch node.
func (m *MenuTree) IsExpandable(visibleIdx int) bool {
if visibleIdx < 0 || visibleIdx >= len(m.visible) {
return false
}
return m.nodes[m.visible[visibleIdx]].expandable
}
// IsExpanded reports whether the branch at visible row i is expanded.
func (m *MenuTree) IsExpanded(visibleIdx int) bool {
if visibleIdx < 0 || visibleIdx >= len(m.visible) {
return false
}
return m.expanded[m.visible[visibleIdx]]
}
// ToggleExpand expands or collapses the branch at visible row i.
func (m *MenuTree) ToggleExpand(visibleIdx int) {
if !m.IsExpandable(visibleIdx) {
return
}
idx := m.visible[visibleIdx]
if m.expanded[idx] {
delete(m.expanded, idx)
} else {
m.expanded[idx] = true
}
m.rebuildVisible()
}
// SelectIndex returns the subject for visible row i when it is a leaf.
func (m *MenuTree) SelectIndex(visibleIdx int) (Subject, bool) {
if visibleIdx < 0 || visibleIdx >= len(m.visible) {
return "", false
}
n := m.nodes[m.visible[visibleIdx]]
if n.subject == "" {
return "", false
}
return n.subject, true
}
// CurrentSubject returns the subject at the current cursor if it is a leaf.
func (m *MenuTree) CurrentSubject() (Subject, bool) {
return m.SelectIndex(m.cursor)
}
func (m *MenuTree) rebuildVisible() {
m.visible = m.visible[:0]
for i, n := range m.nodes {
if n.depth == 0 {
m.visible = append(m.visible, i)
if m.expanded[i] {
m.appendChildren(i)
}
continue
}
}
if m.cursor >= len(m.visible) {
m.cursor = len(m.visible) - 1
}
if m.cursor < 0 {
m.cursor = 0
}
}
func (m *MenuTree) appendChildren(branchIdx int) {
for i, n := range m.nodes {
if n.parent == branchIdx {
m.visible = append(m.visible, i)
}
}
}
-49
View File
@@ -1,49 +0,0 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestMenuTreeHasExpectedRoots(t *testing.T) {
tree := model.DefaultMenuTree()
roots := tree.RootLabels()
want := []string{"Projects", "Calendar", "CRM", "Mail", "Documents", "Users"}
if len(roots) != len(want) {
t.Fatalf("roots=%v want %v", roots, want)
}
for i, w := range want {
if roots[i] != w {
t.Fatalf("root[%d]=%q want %q", i, roots[i], w)
}
}
}
func TestMenuExpandCollapse(t *testing.T) {
tree := model.DefaultMenuTree()
if !tree.IsExpandable(0) {
t.Fatal("Projects should be expandable")
}
tree.ToggleExpand(0)
if !tree.IsExpanded(0) {
t.Fatal("Projects should expand")
}
tree.ToggleExpand(0)
if tree.IsExpanded(0) {
t.Fatal("Projects should collapse")
}
}
func TestMenuSelectLeafReturnsSubject(t *testing.T) {
tree := model.DefaultMenuTree()
// Expand Projects and select "All projects" (first child).
tree.ToggleExpand(0)
subj, ok := tree.SelectIndex(1)
if !ok {
t.Fatal("expected leaf subject")
}
if subj != model.SubjectProjects {
t.Fatalf("subject=%v want Projects", subj)
}
}
+209
View File
@@ -0,0 +1,209 @@
package model
// ListSpec tells the fetch layer what to load when a nav leaf is active.
type ListSpec struct {
Subject Subject
ProjectID string
TaskID string
}
// NavNode is one row in the navigation tree.
type NavNode struct {
ID string
Label string
Branch bool
List *ListSpec // set on leaves that open the center list
ParentID string
}
// NavTree is a hierarchical navigator; the center list loads only on leaves.
type NavTree struct {
nodes map[string]NavNode
roots []string
children map[string][]string
expanded map[string]bool
cursor int
visible []string
}
// DefaultNavTree returns the OnlyOffice module tree (static skeleton).
func DefaultNavTree() *NavTree {
t := &NavTree{
nodes: make(map[string]NavNode),
children: make(map[string][]string),
expanded: make(map[string]bool),
}
add := func(id, label, parent string, branch bool, list *ListSpec) {
t.nodes[id] = NavNode{ID: id, Label: label, Branch: branch, List: list, ParentID: parent}
if parent == "" {
t.roots = append(t.roots, id)
} else {
t.children[parent] = append(t.children[parent], id)
}
}
add("projects", "Projects", "", true, nil)
add("projects.browse", "Browse", "projects", true, nil)
add("projects.browse.all", "All projects", "projects.browse", false, &ListSpec{Subject: SubjectProjects})
add("projects.browse.tasks", "All tasks", "projects.browse", false, &ListSpec{Subject: SubjectTasks})
add("projects.dynamic", "By project", "projects", true, nil)
add("calendar", "Calendar", "", true, nil)
add("calendar.cals", "Calendars", "calendar", false, &ListSpec{Subject: SubjectCalendars})
add("calendar.events", "Events", "calendar", false, &ListSpec{Subject: SubjectEvents})
add("crm", "CRM", "", true, nil)
add("crm.contacts", "Contacts", "crm", false, &ListSpec{Subject: SubjectContacts})
add("crm.persons", "Persons", "crm", false, &ListSpec{Subject: SubjectPersons})
add("crm.companies", "Companies", "crm", false, &ListSpec{Subject: SubjectCompanies})
add("crm.opportunities", "Opportunities", "crm", false, &ListSpec{Subject: SubjectOpportunities})
add("crm.cases", "Cases", "crm", false, &ListSpec{Subject: SubjectCases})
add("crm.tasks", "CRM tasks", "crm", false, &ListSpec{Subject: SubjectCRMTasks})
add("mail", "Mail", "", true, nil)
add("mail.inbox", "Inbox", "mail", false, &ListSpec{Subject: SubjectMailInbox})
add("mail.sent", "Sent", "mail", false, &ListSpec{Subject: SubjectMailSent})
add("mail.drafts", "Drafts", "mail", false, &ListSpec{Subject: SubjectMailDrafts})
add("mail.trash", "Trash", "mail", false, &ListSpec{Subject: SubjectMailTrash})
add("mail.spam", "Spam", "mail", false, &ListSpec{Subject: SubjectMailSpam})
add("users", "Users", "", true, nil)
add("users.dir", "Directory", "users", false, &ListSpec{Subject: SubjectUsers})
t.rebuildVisible()
return t
}
// RootLabels returns top-level labels (for tests).
func (t *NavTree) RootLabels() []string {
out := make([]string, len(t.roots))
for i, id := range t.roots {
out[i] = t.nodes[id].Label
}
return out
}
func (t *NavTree) Cursor() int { return t.cursor }
func (t *NavTree) VisibleCount() int { return len(t.visible) }
func (t *NavTree) NodeAtVisible(i int) (NavNode, bool) {
if i < 0 || i >= len(t.visible) {
return NavNode{}, false
}
n, ok := t.nodes[t.visible[i]]
return n, ok
}
func (t *NavTree) DepthAtVisible(i int) int {
if i < 0 || i >= len(t.visible) {
return 0
}
depth := 0
id := t.visible[i]
for {
n, ok := t.nodes[id]
if !ok || n.ParentID == "" {
break
}
depth++
id = n.ParentID
}
return depth
}
func (t *NavTree) IsExpandable(i int) bool {
n, ok := t.NodeAtVisible(i)
if !ok {
return false
}
return n.Branch && len(t.children[n.ID]) > 0
}
func (t *NavTree) IsExpanded(i int) bool {
n, ok := t.NodeAtVisible(i)
if !ok {
return false
}
return t.expanded[n.ID]
}
func (t *NavTree) ToggleExpand(i int) {
n, ok := t.NodeAtVisible(i)
if !ok || !n.Branch {
return
}
if t.expanded[n.ID] {
delete(t.expanded, n.ID)
} else {
t.expanded[n.ID] = true
}
t.rebuildVisible()
}
func (t *NavTree) MoveUp() { t.SetCursor(t.cursor - 1) }
func (t *NavTree) MoveDown() { t.SetCursor(t.cursor + 1) }
func (t *NavTree) SetCursor(c int) {
if c < 0 {
c = 0
}
if c >= len(t.visible) {
c = len(t.visible) - 1
}
if c < 0 {
c = 0
}
t.cursor = c
}
// CurrentListSpec returns the list spec when the cursor is on a leaf.
func (t *NavTree) CurrentListSpec() (*ListSpec, bool) {
n, ok := t.NodeAtVisible(t.cursor)
if !ok || n.List == nil {
return nil, false
}
spec := *n.List
return &spec, true
}
// Activate expands a branch or returns leaf list spec on Enter.
func (t *NavTree) Activate() (*ListSpec, bool) {
n, ok := t.NodeAtVisible(t.cursor)
if !ok {
return nil, false
}
if n.Branch {
t.expanded[n.ID] = true
t.rebuildVisible()
return nil, false
}
if n.List != nil {
spec := *n.List
return &spec, true
}
return nil, false
}
func (t *NavTree) rebuildVisible() {
t.visible = t.visible[:0]
var walk func(id string, depth int)
walk = func(id string, depth int) {
t.visible = append(t.visible, id)
if !t.expanded[id] {
return
}
for _, child := range t.children[id] {
walk(child, depth+1)
}
}
for _, root := range t.roots {
walk(root, 0)
}
if t.cursor >= len(t.visible) {
t.cursor = len(t.visible) - 1
}
if t.cursor < 0 {
t.cursor = 0
}
}
+31
View File
@@ -0,0 +1,31 @@
package model
import "strings"
// InjectProjectNodes adds dynamic project branches under projects.dynamic.
func (t *NavTree) InjectProjectNodes(projects []Item) {
// Drop prior dynamic project.* nodes.
for id := range t.nodes {
if strings.HasPrefix(id, "project.") {
delete(t.nodes, id)
}
}
t.children["projects.dynamic"] = nil
for _, p := range projects {
pid := "project." + p.ID
t.nodes[pid] = NavNode{ID: pid, Label: p.Title, Branch: true, ParentID: "projects.dynamic"}
t.children["projects.dynamic"] = append(t.children["projects.dynamic"], pid)
taskID := pid + ".tasks"
fileID := pid + ".files"
t.nodes[taskID] = NavNode{
ID: taskID, Label: "Tasks", Branch: false, ParentID: pid,
List: &ListSpec{Subject: SubjectTasks, ProjectID: p.ID},
}
t.nodes[fileID] = NavNode{
ID: fileID, Label: "Files", Branch: false, ParentID: pid,
List: &ListSpec{Subject: SubjectProjectFiles, ProjectID: p.ID},
}
t.children[pid] = []string{taskID, fileID}
}
t.rebuildVisible()
}
+75
View File
@@ -0,0 +1,75 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestNavTreeHasExpectedRoots(t *testing.T) {
tree := model.DefaultNavTree()
roots := tree.RootLabels()
want := []string{"Projects", "Calendar", "CRM", "Mail", "Users"}
if len(roots) != len(want) {
t.Fatalf("roots=%v want %v", roots, want)
}
for i, w := range want {
if roots[i] != w {
t.Fatalf("root[%d]=%q want %q", i, roots[i], w)
}
}
}
func TestNavExpandCollapse(t *testing.T) {
tree := model.DefaultNavTree()
if !tree.IsExpandable(0) {
t.Fatal("Projects should be expandable")
}
tree.ToggleExpand(0)
if !tree.IsExpanded(0) {
t.Fatal("Projects should expand")
}
tree.ToggleExpand(0)
if tree.IsExpanded(0) {
t.Fatal("Projects should collapse")
}
}
func TestNavLeafReturnsListSpec(t *testing.T) {
tree := model.DefaultNavTree()
tree.ToggleExpand(0) // Projects
tree.ToggleExpand(1) // Browse
// Find "All projects" leaf cursor
var found int = -1
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.List != nil && n.List.Subject == model.SubjectProjects {
found = i
break
}
}
if found < 0 {
t.Fatal("all projects leaf not found")
}
tree.SetCursor(found)
spec, ok := tree.CurrentListSpec()
if !ok || spec.Subject != model.SubjectProjects {
t.Fatalf("spec=%v ok=%v", spec, ok)
}
}
func TestPrevFocusPane(t *testing.T) {
if got := model.PrevFocusPane(model.FocusList); got != model.FocusMenu {
t.Fatalf("got %v", got)
}
if got := model.PrevFocusPane(model.FocusMenu); got != model.FocusPreview {
t.Fatalf("got %v", got)
}
}
func TestActionsForContact(t *testing.T) {
acts := model.ActionsFor(model.KindContact)
if len(acts) != 1 || acts[0].ID != model.ActionDelete {
t.Fatalf("expected delete only, got %v", acts)
}
}
-89
View File
@@ -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,214 @@
//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 TestIntegrationPreviewFileFromAPI(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)
}
if len(items) == 0 {
t.Skip("no project files")
}
md, err := loader.PreviewMarkdown(ctx, items[0])
if err != nil {
t.Fatalf("PreviewMarkdown: %v", err)
}
if md == "" {
t.Fatal("empty preview")
}
if !strings.Contains(md, items[0].Title) && !strings.Contains(md, "|") && !strings.Contains(md, "```") {
t.Fatalf("unexpected file preview for %q:\n%s", items[0].Title, 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)
}
}
+492 -159
View File
@@ -2,8 +2,6 @@ package ui
import ( import (
"context" "context"
"fmt"
"os/exec"
"strings" "strings"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
@@ -12,17 +10,35 @@ import (
onlyoffice "github.com/eslider/go-onlyoffice" onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/fetch" "github.com/eslider/go-onlyoffice/cmd/office/fetch"
"github.com/eslider/go-onlyoffice/cmd/office/model" "github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
) )
type listLoadedMsg struct { type listLoadedMsg struct {
items []model.Item items []model.Item
subject model.Subject spec model.ListSpec
err error err error
} }
type previewLoadedMsg struct { type detailLoadedMsg struct {
text string item model.Item
document bool
fields model.FormFields
markdown string
err error
}
type detailSavedMsg struct {
item model.Item
title, description string
err error
}
type actionDoneMsg struct {
message string
err error
}
type navProjectsMsg struct {
projects []model.Item
err error err error
} }
@@ -30,55 +46,73 @@ type previewLoadedMsg struct {
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
focus model.FocusPane focus model.FocusPane
width int width int
height int height int
status string status string
err string err string
loading bool loading bool
preview viewport.Model menuVP viewport.Model
previewMD string listTable DataTable
ready bool detail DetailPane
showMenu bool
showList bool
showDetail bool
} }
// 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) h, w := 24, 80
return Model{ m := 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: pane · row select loads detail · Ctrl+S save · q quit",
status: "office — Tab: pane Space: select Enter: preview q: quit", height: h,
width: w,
} }
m.menuVP = viewport.New(m.paneInnerWidth(22), m.paneHeight())
m.listTable = newDataTable()
m.detail = newDetailPane()
m.showMenu, m.showList, m.showDetail = true, true, true
m.menuVP.MouseWheelEnabled = true
return m
} }
// Init implements tea.Model.
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return nil return tea.Batch(m.loadNavProjectsCmd(), m.detail.BlinkCmd())
} }
// 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.scrollFocusedPane(key) {
return m, nil
}
if m.focus == model.FocusPreview {
if cmd, handled := m.handleDetailKey(key, msg); handled {
return m, cmd
}
}
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)
@@ -87,78 +121,264 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case ActionQuit: case ActionQuit:
return m, tea.Quit return m, tea.Quit
case ActionNextPane: case ActionNextPane:
m.focus = model.NextFocusPane(m.focus) m.focus = NextVisibleFocus(m.focus, m.paneVis())
m.syncPaneFocus()
return m, nil
case ActionPrevPane:
m.focus = PrevVisibleFocus(m.focus, m.paneVis())
m.syncPaneFocus()
return m, nil return m, nil
case ActionMoveUp: case ActionMoveUp:
if m.focus == model.FocusPreview {
return m.handleDetailMove(-1)
}
m.moveUp() m.moveUp()
return m, nil m.syncFocusedPane()
return m, m.onListRowChanged()
case ActionMoveDown: case ActionMoveDown:
if m.focus == model.FocusPreview {
return m.handleDetailMove(1)
}
m.moveDown() m.moveDown()
m.syncFocusedPane()
return m, m.onListRowChanged()
case ActionMoveLeft:
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
m.detail.MoveAction(-1)
return m, nil
}
if m.focus == model.FocusList && m.hasList {
m.listTable.MoveCol(-1)
}
return m, nil
case ActionMoveRight:
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
m.detail.MoveAction(1)
return m, nil
}
if m.focus == model.FocusList && m.hasList {
m.listTable.MoveCol(1)
}
return m, nil
case ActionSort:
if m.focus == model.FocusList && m.hasList {
m.listTable.ToggleSort()
}
return m, nil return m, nil
case ActionToggleSelect: case ActionToggleSelect:
m.selection.Toggle(&m.items, m.listIdx) if m.hasList {
idx := m.listTable.ItemIndex()
if idx >= 0 {
m.selection.Toggle(&m.items, idx)
m.listTable.UpdateItems(m.items)
}
}
return m, nil
case ActionFocusDetail:
if m.hasList && m.showDetail {
m.focus = model.FocusPreview
m.syncPaneFocus()
m.detail.FocusContent()
return m, m.onListRowChanged()
}
return m, nil
case ActionToggleMenuPane:
m.togglePane(1)
return m, nil
case ActionToggleListPane:
m.togglePane(2)
return m, nil
case ActionToggleDetailPane:
m.togglePane(3)
return m, nil return m, nil
case ActionOpenPreview:
m.selection.Toggle(&m.items, m.listIdx)
return m, m.loadPreviewCmd()
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
}
}
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
} m.listTable.Clear()
m.detail.Clear()
} else {
m.err = "" m.err = ""
m.items = msg.items m.items = msg.items
m.subject = msg.subject m.listSpec = msg.spec
return m, nil m.hasList = true
case previewLoadedMsg: m.listTable.SetData(m.listSpec, m.items)
}
return m, m.onListRowChanged()
case detailLoadedMsg:
m.loading = false m.loading = false
if msg.err != nil { if msg.err != nil {
m.previewMD = fmt.Sprintf("# Error\n\n%s\n", msg.err.Error()) m.err = msg.err.Error()
m.detail.Clear()
} else { } else {
m.previewMD = msg.text pw := m.paneLayout()
w := pw.Detail - 4
if w < 20 {
w = 20
}
if msg.document {
m.detail.LoadDocument(msg.item, msg.markdown, w)
} else {
m.detail.LoadForm(msg.item, msg.fields)
}
m.detail.SetFocused(m.focus == model.FocusPreview)
m.err = ""
} }
m.renderPreview()
return m, nil return m, nil
case detailSavedMsg:
m.loading = false
if msg.err != nil {
m.err = msg.err.Error()
} else {
m.updateItemAfterSave(msg.item, msg.title, msg.description)
fields, _ := m.loader.DetailForm(context.Background(), msg.item)
fields.Primary = msg.title
fields.Secondary = msg.description
m.detail.LoadForm(msg.item, fields)
m.detail.SetFocused(m.focus == model.FocusPreview)
m.status = "Saved"
m.err = ""
}
return m, nil
case actionDoneMsg:
m.loading = 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
case tea.MouseMsg:
var cmd tea.Cmd
switch m.focus {
case model.FocusMenu:
m.menuVP, cmd = m.menuVP.Update(msg)
case model.FocusList:
cmd = m.listTable.Update(msg)
case model.FocusPreview:
cmd = m.detail.Update(msg)
}
return m, cmd
} }
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:
cmd = m.listTable.Update(msg)
case model.FocusPreview:
cmd = m.detail.Update(msg)
} }
return m, cmd return m, cmd
} }
// View implements tea.Model. func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
switch key {
case "tab", "shift+tab", "backtab":
m.detail.ToggleZone()
return nil, true
case "ctrl+s":
m.loading = true
return m.saveDetailCmd(), true
case "enter":
if m.detail.Zone() == detailZoneActions {
act, ok := m.detail.SelectedAction()
if !ok {
return nil, true
}
m.loading = true
if act.ID == model.ActionSave {
return m.saveDetailCmd(), true
}
return m.executeActionCmd(act.ID, m.detail.Item()), true
}
}
if m.detail.Zone() == detailZoneContent {
return m.detail.Update(msg), true
}
return nil, false
}
func (m Model) handleDetailMove(delta int) (Model, tea.Cmd) {
if m.detail.Zone() == detailZoneActions {
m.detail.MoveAction(delta)
return m, nil
}
if delta < 0 {
m.detail.form.FocusPrev()
} else {
m.detail.form.FocusNext()
}
return m, nil
}
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) pw := m.paneLayout()
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(menuW).Height(m.height - 2) h := m.paneHeight() + 2
listStyle := paneStyle(m.focus == model.FocusList).Width(listW).Height(m.height - 2) var parts []string
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(prevW).Height(m.height - 2) if pw.Visibility.Menu {
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(pw.Menu).Height(h)
parts = append(parts, menuStyle.Render(m.menuVP.View()))
}
if pw.Visibility.List {
listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h)
parts = append(parts, listStyle.Render(m.listTable.View()))
}
if pw.Visibility.Detail {
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(pw.Detail).Height(h)
parts = append(parts, prevStyle.Render(m.detail.View()))
}
status := m.status status := m.status
if m.loading { if m.loading {
@@ -169,152 +389,274 @@ 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, parts...) + "\n" + bar
menuStyle.Render(m.renderMenu(menuW)), }
listStyle.Render(m.renderList(listW)),
prevStyle.Render(m.preview.View()), func (m Model) paneVis() PaneVisibility {
) + "\n" + bar return PaneVisibility{Menu: m.showMenu, List: m.showList, Detail: m.showDetail}
}
func (m Model) paneLayout() PaneWidths {
return LayoutWidths(m.width, m.paneVis())
}
func (m *Model) syncPaneFocus() {
m.listTable.SetFocused(m.focus == model.FocusList && m.showList)
m.detail.SetFocused(m.focus == model.FocusPreview && m.showDetail)
}
func (m *Model) togglePane(which int) {
switch which {
case 1:
m.showMenu = !m.showMenu
case 2:
m.showList = !m.showList
case 3:
m.showDetail = !m.showDetail
}
if !m.showMenu && !m.showList && !m.showDetail {
switch which {
case 1:
m.showMenu = true
case 2:
m.showList = true
case 3:
m.showDetail = true
}
}
if !paneVisible(m.focus, m.paneVis()) {
m.focus = firstVisibleFocus(m.paneVis())
}
m.layoutViewports()
m.syncAllContent()
m.syncPaneFocus()
}
func (m Model) withList(spec model.ListSpec) (Model, tea.Cmd) {
m.listSpec = spec
m.selection.Clear()
m.hasList = false
m.focus = model.FocusList
m.listTable.SetFocused(true)
m.detail.Clear()
m.loading = true
return m, m.loadListCmd(spec)
} }
func (m *Model) layoutViewports() { func (m *Model) layoutViewports() {
_, _, prevW := LayoutWidths(m.width) pw := m.paneLayout()
h := m.height - 4 h := m.paneHeight()
if h < 4 { if pw.Visibility.Menu {
h = 4 m.menuVP.Width = m.paneInnerWidth(pw.Menu)
m.menuVP.Height = h
}
if pw.Visibility.List {
m.listTable.SetSize(m.paneInnerWidth(pw.List), h)
}
if pw.Visibility.Detail {
m.detail.SetSize(m.paneInnerWidth(pw.Detail), h)
} }
m.preview.Width = prevW - 2
m.preview.Height = h
m.renderPreview()
} }
func (m *Model) renderPreview() { func (m *Model) syncAllContent() {
_, _, prevW := LayoutWidths(m.width) m.syncMenuContent()
text, err := preview.RenderMarkdown(m.previewMD, prevW-4) m.syncListTable()
if err != nil {
text = m.previewMD
}
m.preview.SetContent(text)
} }
func (m *Model) renderMenu(w int) string { func (m *Model) syncListTable() {
if !m.hasList {
m.listTable.Clear()
return
}
m.listTable.UpdateItems(m.items)
}
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++ { menuW := m.menuVP.Width
label, depth := m.menu.LabelAt(i) if menuW < 10 {
menuW = 20
}
selectedStyle := lipgloss.NewStyle().
Background(lipgloss.Color("62")).
Foreground(lipgloss.Color("255"))
for i := 0; i < m.nav.VisibleCount(); 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 = selectedStyle.Width(menuW).Render(truncateRunes(line, menuW))
} else {
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 truncateRunes(s string, max int) string {
title := string(m.subject) if max <= 0 {
if title == "" { return s
title = "Items"
} }
var b strings.Builder r := []rune(s)
fmt.Fprintf(&b, "%s (%d)\n\n", title, len(m.items)) if len(r) <= max {
for i, it := range m.items { return s
mark := "[ ]"
if it.Selected {
mark = "[x]"
} }
cursor := " " if max <= 1 {
if i == m.listIdx && m.focus == model.FocusList { return string(r[:max])
cursor = "> "
} }
line := fmt.Sprintf("%s%s %s", cursor, mark, it.Title) return string(r[:max-1]) + "…"
if it.Subtitle != "" { }
line += " — " + it.Subtitle
func (m *Model) syncFocusedPane() {
switch m.focus {
case model.FocusMenu:
m.syncMenuContent()
case model.FocusList:
m.syncListTable()
} }
if len(line) > w-1 {
line = line[:w-2] + "…"
}
b.WriteString(line + "\n")
}
if len(m.items) == 0 && !m.loading {
b.WriteString("(empty)\n")
}
return b.String()
} }
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.listIdx-- m.listTable.MoveRow(-1)
} }
case model.FocusPreview:
m.preview.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.listIdx++ m.listTable.MoveRow(1)
} }
case model.FocusPreview:
m.preview.LineDown(1)
} }
} }
func (m *Model) loadListCmd(subject model.Subject) tea.Cmd { func syncVPToLine(vp *viewport.Model, line int) {
if line < 0 {
line = 0
}
max := vp.TotalLineCount()
if max == 0 {
vp.YOffset = 0
return
}
if line >= max {
line = max - 1
}
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
}
maxOff := max - vp.Height
if maxOff < 0 {
maxOff = 0
}
if vp.YOffset > maxOff {
vp.YOffset = maxOff
}
}
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}
} }
} }
func (m *Model) loadPreviewCmd() 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]
return func() tea.Msg { return func() tea.Msg {
ctx := context.Background() ctx := context.Background()
raw, err := m.loader.Detail(ctx, item) msg, err := m.loader.Execute(ctx, action, item, "")
if err != nil { return actionDoneMsg{message: msg, err: err}
return previewLoadedMsg{err: err}
}
md := preview.EntityMarkdown(string(item.Kind), raw)
return previewLoadedMsg{text: md}
} }
} }
func (m *Model) openVexCmd() tea.Cmd { func (m *Model) loadNavProjectsCmd() 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()
} projects, err := m.loader.LoadProjectsForNav(ctx)
return navProjectsMsg{projects: projects, err: err}
} }
}
func (m *Model) onListRowChanged() tea.Cmd {
if !m.hasList {
m.detail.Clear()
return nil return nil
}
idx := m.listTable.ItemIndex()
if idx < 0 || idx >= len(m.items) {
m.detail.Clear()
return nil
}
it := m.items[idx]
if m.detail.LoadedID() == it.ID {
return nil
}
m.loading = true
return m.loadDetailCmd(it)
}
func (m *Model) loadDetailCmd(item model.Item) tea.Cmd {
return func() tea.Msg {
ctx := context.Background()
if model.IsDocumentKind(item.Kind) {
md, err := m.loader.PreviewMarkdown(ctx, item)
return detailLoadedMsg{item: item, document: true, markdown: md, err: err}
}
fields, err := m.loader.DetailForm(ctx, item)
return detailLoadedMsg{item: item, fields: fields, err: err}
}
}
func (m *Model) saveDetailCmd() tea.Cmd {
item := m.detail.Item()
title := m.detail.form.Primary()
desc := m.detail.form.Secondary()
return func() tea.Msg {
ctx := context.Background()
err := m.loader.SaveItem(ctx, item, title, desc)
return detailSavedMsg{item: item, title: title, description: desc, err: err}
}
}
func (m *Model) updateItemAfterSave(item model.Item, title, description string) {
for i := range m.items {
if m.items[i].ID != item.ID {
continue
}
m.items[i].Title = title
if m.items[i].Raw == nil {
m.items[i].Raw = map[string]any{}
}
m.items[i].Raw["title"] = title
m.items[i].Raw["description"] = description
m.listTable.UpdateItems(m.items)
return
}
} }
func paneStyle(focused bool) lipgloss.Style { func paneStyle(focused bool) lipgloss.Style {
@@ -326,14 +668,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 "Alt+1/2/3: toggle panes · Tab: focus · v: detail · Ctrl+S: save · q: quit"
}
// OpenVex launches the vex-tui binary for a file path when available.
func OpenVex(path string) error {
if _, err := exec.LookPath("vex"); err != nil {
return fmt.Errorf("vex not found on PATH")
}
cmd := exec.Command("vex", path)
return cmd.Run()
} }
+9 -12
View File
@@ -13,30 +13,27 @@ 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)
} }
} }
func TestKeyActionPreviewNoOp(t *testing.T) { func TestKeyActionTabOnPreviewDoesNotSwitchPane(t *testing.T) {
if got := ui.KeyAction(" ", model.FocusPreview); got != ui.ActionNone { if got := ui.KeyAction("tab", model.FocusPreview); got != ui.ActionNone {
t.Fatalf("got %v", got) t.Fatalf("tab on preview should stay in pane, got %v", got)
} }
} }
func TestKeyActionTabNextPane(t *testing.T) { func TestKeyActionSortAndColumns(t *testing.T) {
if got := ui.KeyAction("tab", model.FocusList); got != ui.ActionNextPane { if got := ui.KeyAction("s", model.FocusList); got != ui.ActionSort {
t.Fatalf("got %v", got) 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.LayoutWidthsLegacy(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)
}
} }
+280
View File
@@ -0,0 +1,280 @@
package ui
import (
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
const detailContentRatio = 0.72
type detailMode int
const (
detailEmpty detailMode = iota
detailDocument
detailForm
)
type detailZone int
const (
detailZoneContent detailZone = iota
detailZoneActions
)
// DetailPane is the right column: content top, CRUD actions bottom.
type DetailPane struct {
mode detailMode
item model.Item
loadedID string
actions []model.ItemAction
actionIdx int
zone detailZone
focused bool
width int
height int
form EntityForm
docVP viewport.Model
docText string
styles detailStyles
}
type detailStyles struct {
sep lipgloss.Style
action lipgloss.Style
actionOn lipgloss.Style
actionDn lipgloss.Style
empty lipgloss.Style
}
func newDetailPane() DetailPane {
d := DetailPane{
form: newEntityForm(),
docVP: viewport.New(40, 10),
styles: newDetailStyles(),
}
d.docVP.MouseWheelEnabled = true
return d
}
func newDetailStyles() detailStyles {
btn := lipgloss.NewStyle().Padding(0, 1).MarginRight(1)
return detailStyles{
sep: lipgloss.NewStyle().Foreground(lipgloss.Color("238")),
action: btn.Foreground(lipgloss.Color("252")).Background(lipgloss.Color("236")),
actionOn: btn.Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
actionDn: btn.Foreground(lipgloss.Color("255")).Background(lipgloss.Color("52")),
empty: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
}
}
func (d *DetailPane) Clear() {
d.mode = detailEmpty
d.item = model.Item{}
d.loadedID = ""
d.actions = nil
d.actionIdx = 0
d.docText = ""
d.form.Clear()
d.docVP.SetContent("")
}
func (d *DetailPane) SetFocused(on bool) {
d.focused = on
d.form.SetFocused(on && d.zone == detailZoneContent && d.mode == detailForm)
}
func (d *DetailPane) SetSize(w, h int) {
if w < 8 {
w = 8
}
if h < 8 {
h = 8
}
d.width = w
d.height = h
contentH, _ := d.splitHeights()
d.form.SetSize(w, contentH)
d.docVP.Width = w
d.docVP.Height = contentH
}
func (d *DetailPane) splitHeights() (contentH, actionH int) {
actionH = int(float64(d.height) * (1 - detailContentRatio))
if actionH < 3 {
actionH = 3
}
if actionH > 6 {
actionH = 6
}
contentH = d.height - actionH - 1
if contentH < 4 {
contentH = 4
}
return contentH, actionH
}
func (d *DetailPane) LoadForm(item model.Item, fields model.FormFields) {
d.mode = detailForm
d.item = item
d.loadedID = item.ID
d.actions = model.ActionsFor(item.Kind)
d.actionIdx = 0
d.form.Load(item.Kind, item.ID, fields)
d.layoutContent()
}
func (d *DetailPane) LoadDocument(item model.Item, markdown string, renderWidth int) {
d.mode = detailDocument
d.item = item
d.loadedID = item.ID
d.actions = model.ActionsFor(item.Kind)
d.actionIdx = 0
d.form.Clear()
text, err := preview.RenderMarkdown(markdown, renderWidth)
if err != nil {
text = markdown
}
d.docText = text
d.docVP.SetContent(text)
d.layoutContent()
}
func (d *DetailPane) layoutContent() {
contentH, _ := d.splitHeights()
d.form.SetSize(d.width, contentH)
d.docVP.Width = d.width
d.docVP.Height = contentH
}
func (d *DetailPane) LoadedID() string { return d.loadedID }
func (d *DetailPane) Item() model.Item { return d.item }
func (d *DetailPane) Zone() detailZone { return d.zone }
func (d *DetailPane) FocusActions() {
d.zone = detailZoneActions
d.form.SetFocused(false)
}
func (d *DetailPane) FocusContent() {
d.zone = detailZoneContent
d.form.SetFocused(d.focused && d.mode == detailForm)
}
func (d *DetailPane) ToggleZone() {
if d.zone == detailZoneContent {
d.FocusActions()
} else {
d.FocusContent()
}
}
func (d *DetailPane) MoveAction(delta int) {
if len(d.actions) == 0 {
return
}
d.actionIdx = clampInt(d.actionIdx+delta, 0, len(d.actions)-1)
}
func (d *DetailPane) SelectedAction() (model.ItemAction, bool) {
if d.actionIdx < 0 || d.actionIdx >= len(d.actions) {
return model.ItemAction{}, false
}
return d.actions[d.actionIdx], true
}
func (d *DetailPane) Update(msg tea.Msg) tea.Cmd {
if !d.focused {
return nil
}
if d.zone == detailZoneContent {
switch d.mode {
case detailForm:
return d.form.Update(msg)
case detailDocument:
if _, ok := msg.(tea.KeyMsg); ok {
switch msg.(tea.KeyMsg).String() {
case "pgdown", "pgdn", "f", "ctrl+d":
d.docVP.ViewDown()
case "pgup", "b", "ctrl+u":
d.docVP.ViewUp()
}
}
var cmd tea.Cmd
d.docVP, cmd = d.docVP.Update(msg)
return cmd
}
}
return nil
}
func (d DetailPane) View() string {
if d.mode == detailEmpty {
return d.styles.empty.Render("Select a row to preview or edit.")
}
contentH, actionH := d.splitHeights()
var top string
switch d.mode {
case detailForm:
top = lipgloss.NewStyle().Width(d.width).Height(contentH).Render(d.form.View())
case detailDocument:
top = d.docVP.View()
default:
top = ""
}
sep := d.styles.sep.Width(d.width).Render(strings.Repeat("─", max(1, d.width)))
bottom := lipgloss.NewStyle().Width(d.width).Height(actionH).Render(d.renderActions())
return lipgloss.JoinVertical(lipgloss.Left, top, sep, bottom)
}
func (d DetailPane) renderActions() string {
if len(d.actions) == 0 {
hint := "No actions"
if d.focused && d.zone == detailZoneActions {
hint = lipgloss.NewStyle().Background(lipgloss.Color("238")).Render(" No actions ")
}
return d.styles.empty.Render(hint)
}
parts := make([]string, 0, len(d.actions))
for i, act := range d.actions {
style := d.styles.action
if d.focused && d.zone == detailZoneActions && i == d.actionIdx {
if act.Danger {
style = d.styles.actionDn
} else {
style = d.styles.actionOn
}
} else if act.Danger {
style = d.styles.actionDn
}
parts = append(parts, style.Render(act.Label))
}
line := strings.Join(parts, "")
if d.focused && d.zone == detailZoneActions {
line += d.styles.empty.Render(" ←/→ select · Enter run")
}
return line
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func (d DetailPane) BlinkCmd() tea.Cmd {
return entityFormBlinkCmd()
}
// NewDetailPaneForTest exposes detail pane for tests.
func NewDetailPaneForTest() DetailPane {
return newDetailPane()
}
+49
View File
@@ -0,0 +1,49 @@
package ui_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/eslider/go-onlyoffice/cmd/office/ui"
)
func TestEntityFormLoadAndView(t *testing.T) {
form := ui.NewEntityFormForTest()
form.SetSize(40, 16)
form.Load(model.KindTask, "42", model.FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: "Fix bug", Secondary: "Details here",
})
view := form.View()
if !strings.Contains(view, "Fix bug") {
t.Fatal("view missing title")
}
}
func TestDetailPaneFormAndActions(t *testing.T) {
d := ui.NewDetailPaneForTest()
d.SetSize(50, 20)
d.SetFocused(true)
d.LoadForm(model.Item{ID: "1", Kind: model.KindTask, Title: "T"}, model.FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: "Hello", Secondary: "World",
})
view := d.View()
if !strings.Contains(view, "Save") {
t.Fatal("expected Save action button")
}
if !strings.Contains(view, "Hello") {
t.Fatal("expected form content")
}
}
func TestDetailPaneDocumentMode(t *testing.T) {
d := ui.NewDetailPaneForTest()
d.SetSize(50, 20)
d.LoadDocument(model.Item{ID: "9", Kind: model.KindFile, Title: "a.txt"}, "# Doc\n\nbody", 40)
view := d.View()
if !strings.Contains(view, "Download") {
t.Fatal("expected Download action")
}
}
+223
View File
@@ -0,0 +1,223 @@
package ui
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
type entityField int
const (
entityFieldPrimary entityField = iota
entityFieldSecondary
)
// EntityForm is the top section of the detail pane for editable entities.
type EntityForm struct {
active bool
kind model.Kind
itemID string
primaryLabel string
secondaryLabel string
readOnly bool
primary textinput.Model
secondary textarea.Model
field entityField
focused bool
dirty bool
width int
height int
styles entityFormStyles
}
type entityFormStyles struct {
header lipgloss.Style
label lipgloss.Style
labelAct lipgloss.Style
}
func newEntityFormStyles() entityFormStyles {
return entityFormStyles{
header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
label: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
labelAct: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
}
}
func newEntityForm() EntityForm {
primary := textinput.New()
primary.CharLimit = 512
primary.Prompt = "> "
secondary := textarea.New()
secondary.ShowLineNumbers = false
secondary.CharLimit = 8000
secondary.Prompt = "> "
return EntityForm{
primary: primary,
secondary: secondary,
field: entityFieldPrimary,
styles: newEntityFormStyles(),
}
}
func (f *EntityForm) Active() bool { return f.active }
func (f *EntityForm) ItemID() string { return f.itemID }
func (f *EntityForm) Primary() string { return f.primary.Value() }
func (f *EntityForm) Secondary() string { return f.secondary.Value() }
func (f *EntityForm) MarkClean() { f.dirty = false }
func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormFields) {
f.active = true
f.kind = kind
f.itemID = itemID
f.primaryLabel = fields.PrimaryLabel
f.secondaryLabel = fields.SecondaryLabel
f.readOnly = fields.ReadOnly
f.primary.SetValue(fields.Primary)
f.secondary.SetValue(fields.Secondary)
f.dirty = false
f.field = entityFieldPrimary
f.applyFocus()
f.layoutFields()
}
func (f *EntityForm) Clear() {
f.active = false
f.itemID = ""
f.primary.SetValue("")
f.secondary.SetValue("")
f.dirty = false
f.primary.Blur()
f.secondary.Blur()
}
func (f *EntityForm) SetFocused(on bool) {
f.focused = on && f.active
if f.focused {
f.applyFocus()
} else {
f.primary.Blur()
f.secondary.Blur()
}
}
func (f *EntityForm) SetSize(w, h int) {
if w < 12 {
w = 12
}
if h < 6 {
h = 6
}
f.width = w
f.height = h
f.layoutFields()
}
func (f *EntityForm) layoutFields() {
if f.width == 0 {
return
}
inner := f.width - 2
if inner < 8 {
inner = 8
}
f.primary.Width = inner
secH := f.height - 6
if secH < 3 {
secH = 3
}
f.secondary.SetWidth(inner)
f.secondary.SetHeight(secH)
}
func (f *EntityForm) FocusNext() {
if f.field < entityFieldSecondary {
f.field++
}
f.applyFocus()
}
func (f *EntityForm) FocusPrev() {
if f.field > entityFieldPrimary {
f.field--
}
f.applyFocus()
}
func (f *EntityForm) applyFocus() {
if f.readOnly {
f.primary.Blur()
f.secondary.Blur()
return
}
switch f.field {
case entityFieldPrimary:
f.primary.Focus()
f.secondary.Blur()
default:
f.primary.Blur()
f.secondary.Focus()
}
}
func (f *EntityForm) Update(msg tea.Msg) tea.Cmd {
if !f.active || !f.focused || f.readOnly {
return nil
}
var cmd tea.Cmd
switch f.field {
case entityFieldPrimary:
f.primary, cmd = f.primary.Update(msg)
default:
f.secondary, cmd = f.secondary.Update(msg)
}
if _, ok := msg.(tea.KeyMsg); ok {
f.dirty = true
}
return cmd
}
func (f EntityForm) View() string {
if !f.active {
return ""
}
header := f.styles.header.Render(model.KindHeading(f.kind, f.itemID))
pLabel := f.styles.label.Render(f.primaryLabel)
if f.focused && f.field == entityFieldPrimary && !f.readOnly {
pLabel = f.styles.labelAct.Render(f.primaryLabel)
}
sLabel := f.styles.label.Render(f.secondaryLabel)
if f.focused && f.field == entityFieldSecondary && !f.readOnly {
sLabel = f.styles.labelAct.Render(f.secondaryLabel)
}
ro := ""
if f.readOnly {
ro = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" (read-only)") + "\n"
}
return strings.Join([]string{
header + ro,
pLabel,
f.primary.View(),
"",
sLabel,
f.secondary.View(),
}, "\n")
}
func entityFormBlinkCmd() tea.Cmd {
return tea.Batch(textinput.Blink, textarea.Blink)
}
// NewEntityFormForTest exposes a form for unit tests.
func NewEntityFormForTest() EntityForm {
return newEntityForm()
}
+34 -26
View File
@@ -9,12 +9,18 @@ const (
ActionNone Action = iota ActionNone Action = iota
ActionMoveUp ActionMoveUp
ActionMoveDown ActionMoveDown
ActionMoveLeft
ActionMoveRight
ActionSort
ActionToggleSelect ActionToggleSelect
ActionOpenPreview ActionFocusDetail
ActionToggleMenuPane
ActionToggleListPane
ActionToggleDetailPane
ActionNextPane ActionNextPane
ActionPrevPane
ActionRefresh ActionRefresh
ActionQuit ActionQuit
ActionOpenVex
) )
// KeyAction maps a key string and focused pane to an action. // KeyAction maps a key string and focused pane to an action.
@@ -23,47 +29,49 @@ func KeyAction(key string, pane model.FocusPane) Action {
case "q", "ctrl+c": case "q", "ctrl+c":
return ActionQuit return ActionQuit
case "tab": case "tab":
if pane != model.FocusPreview {
return ActionNextPane return ActionNextPane
}
case "shift+tab", "backtab":
if pane != model.FocusPreview {
return ActionPrevPane
}
case "r": case "r":
return ActionRefresh return ActionRefresh
case "up", "k": case "up", "k":
return ActionMoveUp return ActionMoveUp
case "down", "j": case "down", "j":
return ActionMoveDown return ActionMoveDown
case "s":
if pane == model.FocusList {
return ActionSort
}
case "left", "h":
return ActionMoveLeft
case "right", "l":
return ActionMoveRight
case " ": case " ":
if pane == model.FocusList { if pane == model.FocusList {
return ActionToggleSelect return ActionToggleSelect
} }
case "enter": case "v", "p":
if pane == model.FocusList { if pane == model.FocusList {
return ActionOpenPreview return ActionFocusDetail
}
case "v":
if pane == model.FocusList {
return ActionOpenVex
} }
case "alt+1":
return ActionToggleMenuPane
case "alt+2":
return ActionToggleListPane
case "alt+3":
return ActionToggleDetailPane
} }
return ActionNone return ActionNone
} }
// LayoutWidths splits total terminal width into menu, list, preview columns. // LayoutWidthsLegacy is kept for tests that expect the old signature.
func LayoutWidths(total int) (menu, list, preview int) { func LayoutWidthsLegacy(total int) (menu, list, preview int) {
if total < 80 { pw := LayoutWidths(total, defaultPaneVisibility())
total = 80 return pw.Menu, pw.List, pw.Detail
}
menu = total / 5
if menu < 22 {
menu = 22
}
preview = total / 3
if preview < 28 {
preview = 28
}
list = total - menu - preview - 2
if list < 24 {
list = 24
}
return menu, list, preview
} }
// ResolveMoveUp returns the action for upward navigation keys. // ResolveMoveUp returns the action for upward navigation keys.
+129
View File
@@ -0,0 +1,129 @@
package ui
import "github.com/eslider/go-onlyoffice/cmd/office/model"
// PaneVisibility controls which columns are shown.
type PaneVisibility struct {
Menu, List, Detail bool
}
// PaneWidths is outer lipgloss width per column (sums to terminal width).
type PaneWidths struct {
Menu, List, Detail int
Visibility PaneVisibility
}
func defaultPaneVisibility() PaneVisibility {
return PaneVisibility{Menu: true, List: true, Detail: true}
}
// LayoutWidths splits total terminal width evenly across visible panes.
func LayoutWidths(total int, vis PaneVisibility) PaneWidths {
if total < 1 {
total = 80
}
v := vis
n := countVisible(v)
if n == 0 {
v = defaultPaneVisibility()
n = 3
}
base := total / n
rem := total % n
out := PaneWidths{Visibility: v}
if v.Menu {
out.Menu = base
if rem > 0 {
out.Menu++
rem--
}
}
if v.List {
out.List = base
if rem > 0 {
out.List++
rem--
}
}
if v.Detail {
out.Detail = base
if rem > 0 {
out.Detail++
rem--
}
}
return out
}
func countVisible(v PaneVisibility) int {
n := 0
if v.Menu {
n++
}
if v.List {
n++
}
if v.Detail {
n++
}
return n
}
// NextVisibleFocus cycles focus forward, skipping hidden panes.
func NextVisibleFocus(cur model.FocusPane, vis PaneVisibility) model.FocusPane {
order := []model.FocusPane{model.FocusMenu, model.FocusList, model.FocusPreview}
start := 0
for i, p := range order {
if p == cur {
start = i
break
}
}
for step := 1; step <= len(order); step++ {
p := order[(start+step)%len(order)]
if paneVisible(p, vis) {
return p
}
}
return cur
}
// PrevVisibleFocus cycles focus backward, skipping hidden panes.
func PrevVisibleFocus(cur model.FocusPane, vis PaneVisibility) model.FocusPane {
order := []model.FocusPane{model.FocusMenu, model.FocusList, model.FocusPreview}
start := 0
for i, p := range order {
if p == cur {
start = i
break
}
}
for step := 1; step <= len(order); step++ {
p := order[(start-step+len(order))%len(order)]
if paneVisible(p, vis) {
return p
}
}
return cur
}
func paneVisible(p model.FocusPane, vis PaneVisibility) bool {
switch p {
case model.FocusMenu:
return vis.Menu
case model.FocusList:
return vis.List
default:
return vis.Detail
}
}
func firstVisibleFocus(vis PaneVisibility) model.FocusPane {
if vis.Menu {
return model.FocusMenu
}
if vis.List {
return model.FocusList
}
return model.FocusPreview
}
+69
View File
@@ -0,0 +1,69 @@
package ui
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestLayoutWidthsAllVisibleUsesFullWidth(t *testing.T) {
vis := PaneVisibility{Menu: true, List: true, Detail: true}
pw := LayoutWidths(120, vis)
sum := pw.Menu + pw.List + pw.Detail
if sum != 120 {
t.Fatalf("sum=%d want 120", sum)
}
}
func TestLayoutWidthsSinglePane(t *testing.T) {
pw := LayoutWidths(100, PaneVisibility{Menu: false, List: true, Detail: false})
if pw.List != 100 {
t.Fatalf("list=%d want 100", pw.List)
}
if pw.Menu != 0 || pw.Detail != 0 {
t.Fatalf("unexpected widths: %+v", pw)
}
}
func TestLayoutWidthsTwoPanes(t *testing.T) {
pw := LayoutWidths(80, PaneVisibility{Menu: true, List: false, Detail: true})
if pw.Menu+pw.Detail != 80 {
t.Fatalf("sum=%d want 80", pw.Menu+pw.Detail)
}
}
func TestNextVisibleFocusSkipsHidden(t *testing.T) {
vis := PaneVisibility{Menu: false, List: true, Detail: true}
if got := NextVisibleFocus(model.FocusList, vis); got != model.FocusPreview {
t.Fatalf("got %v", got)
}
if got := NextVisibleFocus(model.FocusPreview, vis); got != model.FocusList {
t.Fatalf("got %v", got)
}
}
func TestPrevVisibleFocusSkipsHidden(t *testing.T) {
vis := PaneVisibility{Menu: true, List: false, Detail: true}
if got := PrevVisibleFocus(model.FocusPreview, vis); got != model.FocusMenu {
t.Fatalf("got %v", got)
}
}
func TestLayoutWidthsLegacyHelper(t *testing.T) {
menu, list, preview := LayoutWidthsLegacy(120)
if menu+list+preview != 120 {
t.Fatalf("widths exceed total")
}
}
func TestKeyActionTogglePanes(t *testing.T) {
if got := KeyAction("alt+1", model.FocusList); got != ActionToggleMenuPane {
t.Fatalf("got %v", got)
}
if got := KeyAction("alt+2", model.FocusMenu); got != ActionToggleListPane {
t.Fatalf("got %v", got)
}
if got := KeyAction("alt+3", model.FocusList); got != ActionToggleDetailPane {
t.Fatalf("got %v", got)
}
}
+67
View File
@@ -0,0 +1,67 @@
package ui
import (
"github.com/charmbracelet/bubbles/viewport"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func (m *Model) scrollFocusedPane(key string) bool {
switch m.focus {
case model.FocusMenu:
return scrollViewport(&m.menuVP, key)
case model.FocusList:
switch key {
case "pgdown", "pgdn", "f", "ctrl+d":
m.listTable.PageScroll(1)
return true
case "pgup", "b", "ctrl+u":
m.listTable.PageScroll(-1)
return true
}
return false
case model.FocusPreview:
if m.detail.Zone() != detailZoneContent || m.detail.mode != detailDocument {
return false
}
return scrollViewport(&m.detail.docVP, key)
default:
return false
}
}
func (m *Model) paneHeight() int {
h := m.height - 3
if h < 3 {
h = 3
}
return h
}
func (m *Model) paneInnerWidth(outer int) int {
if outer <= 0 {
return 8
}
w := outer - 2 // border only; content fills inner box
if w < 8 {
w = 8
}
return w
}
func scrollViewport(vp *viewport.Model, key string) bool {
switch key {
case "pgdown", "pgdn", "f", "ctrl+d":
vp.ViewDown()
return true
case "pgup", "b", "ctrl+u":
vp.ViewUp()
return true
case "home", "g":
vp.GotoTop()
return true
case "end", "G":
vp.GotoBottom()
return true
}
return false
}
+485
View File
@@ -0,0 +1,485 @@
package ui
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/mattn/go-runewidth"
)
type tableStyles struct {
title lipgloss.Style
header lipgloss.Style
headerSort lipgloss.Style
cell lipgloss.Style
rowActive lipgloss.Style
colActive lipgloss.Style
cellActive lipgloss.Style
rowSelect lipgloss.Style
cellSelect lipgloss.Style
help lipgloss.Style
}
func defaultTableStyles() tableStyles {
return tableStyles{
title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("236")).Padding(0, 1),
headerSort: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("229")).Background(lipgloss.Color("62")).Padding(0, 1),
cell: lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Padding(0, 1),
rowActive: lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Background(lipgloss.Color("238")),
colActive: lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Background(lipgloss.Color("237")),
cellActive: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")),
rowSelect: lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Background(lipgloss.Color("22")),
cellSelect: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("34")),
help: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
}
}
// DataTable is a spreadsheet-style listContent table for the list pane.
type DataTable struct {
spec model.ListSpec
columns []model.TableColumn
items []model.Item
order []int
cursorRow int
cursorCol int
colScroll int
sortCol int
sortAsc bool
focused bool
ready bool
width int
height int
viewport viewport.Model
styles tableStyles
}
func newDataTable() DataTable {
t := DataTable{
sortCol: -1,
styles: defaultTableStyles(),
viewport: viewport.New(40, 10),
}
t.viewport.MouseWheelEnabled = true
return t
}
func (t *DataTable) SetSize(w, h int) {
if w < 8 {
w = 8
}
if h < 3 {
h = 3
}
t.width = w
t.height = h
t.viewport.Width = w
t.viewport.Height = h - 2 // title + header
if t.viewport.Height < 1 {
t.viewport.Height = 1
}
t.refreshViewport()
}
func (t *DataTable) SetFocused(f bool) {
t.focused = f
t.refreshViewport()
}
func (t *DataTable) SetData(spec model.ListSpec, items []model.Item) {
t.spec = spec
t.items = items
t.columns = model.BuildColumns(spec.Subject, items)
t.order = make([]int, len(items))
for i := range t.order {
t.order[i] = i
}
t.cursorRow = 0
t.cursorCol = 0
t.colScroll = 0
t.ready = true
if t.sortCol >= 0 {
t.applySort()
}
t.clampCursor()
t.refreshViewport()
}
func (t *DataTable) Clear() {
t.ready = false
t.items = nil
t.order = nil
t.columns = nil
t.cursorRow = 0
t.cursorCol = 0
t.viewport.SetContent("")
}
func (t *DataTable) ItemIndex() int {
if t.cursorRow < 0 || t.cursorRow >= len(t.order) {
return -1
}
return t.order[t.cursorRow]
}
func (t *DataTable) MoveRow(delta int) {
if !t.ready || len(t.order) == 0 {
return
}
prev := t.cursorRow
t.cursorRow = clampInt(t.cursorRow+delta, 0, len(t.order)-1)
t.syncRowScroll(prev)
t.refreshViewport()
}
func (t *DataTable) MoveCol(delta int) {
if !t.ready || len(t.columns) == 0 {
return
}
t.cursorCol = clampInt(t.cursorCol+delta, 0, len(t.columns)-1)
t.ensureColVisible()
t.refreshViewport()
}
func (t *DataTable) ToggleSort() {
if !t.ready || len(t.columns) == 0 || len(t.order) == 0 {
return
}
if t.sortCol == t.cursorCol {
t.sortAsc = !t.sortAsc
} else {
t.sortCol = t.cursorCol
t.sortAsc = true
}
t.applySort()
t.refreshViewport()
}
func (t *DataTable) applySort() {
if t.sortCol < 0 || t.sortCol >= len(t.columns) {
return
}
key := t.columns[t.sortCol].Key
sort.SliceStable(t.order, func(i, j int) bool {
a := model.CellText(t.items[t.order[i]], key)
b := model.CellText(t.items[t.order[j]], key)
if t.sortAsc {
return strings.ToLower(a) < strings.ToLower(b)
}
return strings.ToLower(a) > strings.ToLower(b)
})
}
func (t *DataTable) PageScroll(delta int) {
if delta > 0 {
t.viewport.ViewDown()
} else {
t.viewport.ViewUp()
}
}
func (t *DataTable) Update(msg tea.Msg) tea.Cmd {
if !t.focused || !t.ready {
return nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "pgdown", "pgdn", "f", "ctrl+d":
t.viewport.ViewDown()
case "pgup", "b", "ctrl+u":
t.viewport.ViewUp()
}
case tea.MouseMsg:
var cmd tea.Cmd
t.viewport, cmd = t.viewport.Update(msg)
return cmd
}
return nil
}
func (t DataTable) View() string {
if !t.ready {
return "List\n\nSelect a leaf node in the tree\n(marked with •) and press Enter.\n"
}
title := t.styles.title.Render(fmt.Sprintf("%s (%d)", t.spec.Subject, len(t.items)))
sortHint := ""
if t.sortCol >= 0 && t.sortCol < len(t.columns) {
dir := "▲"
if !t.sortAsc {
dir = "▼"
}
sortHint = t.styles.help.Render(fmt.Sprintf(" sort: %s %s", t.columns[t.sortCol].Title, dir))
}
header := t.renderHeader()
body := t.viewport.View()
content := lipgloss.JoinVertical(lipgloss.Left, title+sortHint, header, body)
return lipgloss.NewStyle().Width(t.width).Render(content)
}
func (t *DataTable) visibleLayout() (indices []int, widths map[int]int) {
widths = make(map[int]int)
if len(t.columns) == 0 || t.width <= 0 {
return nil, widths
}
indices = t.pickVisibleColumnIndices()
if len(indices) == 0 {
return indices, widths
}
minSum := 0
for _, i := range indices {
minSum += t.columns[i].Width
}
widths = distributeColumnWidths(minSum, t.width, indices, t.columns)
return indices, widths
}
func (t *DataTable) pickVisibleColumnIndices() []int {
var out []int
used := 0
for colIdx := t.colScroll; colIdx < len(t.columns); colIdx++ {
w := t.columns[colIdx].Width
if len(out) > 0 && used+w > t.width {
break
}
out = append(out, colIdx)
used += w
}
if len(out) == 0 {
colIdx := t.colScroll
if colIdx < 0 || colIdx >= len(t.columns) {
colIdx = 0
}
out = []int{colIdx}
}
return out
}
// distributeColumnWidths expands or shrinks visible columns to exactly fill total width.
func distributeColumnWidths(minSum, total int, indices []int, cols []model.TableColumn) map[int]int {
out := make(map[int]int, len(indices))
if len(indices) == 0 {
return out
}
if total < len(indices) {
total = len(indices)
}
if minSum <= 0 {
each := total / len(indices)
if each < 1 {
each = 1
}
for _, i := range indices {
out[i] = each
}
fixColumnWidthSum(out, indices, total)
return out
}
for _, i := range indices {
out[i] = cols[i].Width
}
if minSum >= total {
for _, i := range indices {
out[i] = cols[i].Width * total / minSum
if out[i] < 1 {
out[i] = 1
}
}
fixColumnWidthSum(out, indices, total)
return out
}
extra := total - minSum
flex := make([]int, 0, len(indices))
for _, i := range indices {
switch cols[i].Key {
case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "tasks":
flex = append(flex, i)
}
}
if len(flex) == 0 {
flex = append(flex, indices...)
}
flexSum := 0
for _, i := range flex {
flexSum += cols[i].Width
}
if flexSum <= 0 {
flexSum = len(flex)
}
for _, i := range flex {
out[i] += extra * cols[i].Width / flexSum
}
fixColumnWidthSum(out, indices, total)
return out
}
func fixColumnWidthSum(widths map[int]int, indices []int, total int) {
if len(indices) == 0 {
return
}
sum := 0
for _, i := range indices {
sum += widths[i]
}
widths[indices[len(indices)-1]] += total - sum
if widths[indices[len(indices)-1]] < 1 {
widths[indices[len(indices)-1]] = 1
}
}
func (t *DataTable) renderHeader() string {
indices, widths := t.visibleLayout()
cells := make([]string, 0, len(indices))
for _, colIdx := range indices {
col := t.columns[colIdx]
w := widths[colIdx]
title := col.Title
if t.sortCol == colIdx {
if t.sortAsc {
title += " ▲"
} else {
title += " ▼"
}
}
text := runewidth.Truncate(title, w, "…")
style := t.styles.header
if t.focused && colIdx == t.cursorCol {
style = t.styles.headerSort
}
cells = append(cells, style.Width(w).MaxWidth(w).Render(text))
}
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
}
func (t *DataTable) refreshViewport() {
if !t.ready {
return
}
rows := make([]string, 0, len(t.order))
for row := 0; row < len(t.order); row++ {
rows = append(rows, t.renderRow(row))
}
t.viewport.SetContent(strings.Join(rows, "\n"))
t.syncRowScroll(t.cursorRow)
}
func (t *DataTable) renderRow(row int) string {
item := t.items[t.order[row]]
selected := item.Selected
indices, widths := t.visibleLayout()
cells := make([]string, 0, len(indices))
for _, colIdx := range indices {
col := t.columns[colIdx]
w := widths[colIdx]
text := runewidth.Truncate(model.CellText(item, col.Key), w, "…")
cells = append(cells, t.styleCell(row, colIdx, selected, text, w))
}
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
}
func (t *DataTable) styleCell(row, col int, selected bool, text string, width int) string {
base := t.styles.cell.Width(width).MaxWidth(width)
isRow := row == t.cursorRow
isCol := col == t.cursorCol
isCell := t.focused && isRow && isCol
switch {
case selected && isCell:
return t.styles.cellSelect.Width(width).MaxWidth(width).Render(text)
case selected && isRow:
return t.styles.rowSelect.Width(width).MaxWidth(width).Render(text)
case selected:
return t.styles.rowSelect.Width(width).MaxWidth(width).Render(text)
case isCell:
return t.styles.cellActive.Width(width).MaxWidth(width).Render(text)
case t.focused && isRow:
return t.styles.rowActive.Width(width).MaxWidth(width).Render(text)
case t.focused && isCol:
return t.styles.colActive.Width(width).MaxWidth(width).Render(text)
default:
return base.Render(text)
}
}
func (t *DataTable) ensureColVisible() {
vis := t.pickVisibleColumnIndices()
if len(vis) == 0 {
return
}
first, last := vis[0], vis[len(vis)-1]
if t.cursorCol < first {
t.colScroll = t.cursorCol
}
if t.cursorCol > last {
t.colScroll = t.cursorCol
for t.cursorCol >= 0 {
vis = t.pickVisibleColumnIndices()
if len(vis) == 0 {
break
}
last = vis[len(vis)-1]
if t.cursorCol <= last {
break
}
if t.colScroll < len(t.columns)-1 {
t.colScroll++
} else {
break
}
}
}
}
func (t *DataTable) syncRowScroll(prev int) {
if t.cursorRow < t.viewport.YOffset {
t.viewport.YOffset = t.cursorRow
} else if t.cursorRow >= t.viewport.YOffset+t.viewport.Height {
t.viewport.YOffset = t.cursorRow - t.viewport.Height + 1
}
if t.viewport.YOffset < 0 {
t.viewport.YOffset = 0
}
maxOff := len(t.order) - t.viewport.Height
if maxOff < 0 {
maxOff = 0
}
if t.viewport.YOffset > maxOff {
t.viewport.YOffset = maxOff
}
_ = prev
}
func (t *DataTable) clampCursor() {
if len(t.order) == 0 {
t.cursorRow = 0
} else {
t.cursorRow = clampInt(t.cursorRow, 0, len(t.order)-1)
}
if len(t.columns) == 0 {
t.cursorCol = 0
} else {
t.cursorCol = clampInt(t.cursorCol, 0, len(t.columns)-1)
}
}
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// tableKeyMatches helps avoid importing key in app.go for sort-only bindings.
func (t *DataTable) UpdateItems(items []model.Item) {
t.items = items
t.clampCursor()
t.refreshViewport()
}
+98
View File
@@ -0,0 +1,98 @@
package ui
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func sampleItems() []model.Item {
return []model.Item{
{ID: "2", Title: "Bravo", Subtitle: "b", Kind: model.KindTask, Raw: map[string]any{"status": "Open"}},
{ID: "1", Title: "Alpha", Subtitle: "a", Kind: model.KindTask, Raw: map[string]any{"status": "Done"}},
}
}
func TestDataTableSortByTitle(t *testing.T) {
tbl := newDataTable()
tbl.SetSize(80, 12)
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, sampleItems())
tbl.cursorCol = indexOfColumn(tbl, "title")
tbl.ToggleSort()
if model.CellText(tbl.items[tbl.order[0]], "title") != "Alpha" {
t.Fatalf("expected Alpha first after sort, order=%v", tbl.order)
}
}
func TestDataTableMoveColScrolls(t *testing.T) {
tbl := newDataTable()
tbl.SetSize(24, 10)
items := make([]model.Item, 1)
items[0] = model.Item{ID: "1", Title: "One", Raw: map[string]any{
"alpha": "a", "beta": "b", "gamma": "c", "delta": "d",
}}
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, items)
last := len(tbl.columns) - 1
tbl.cursorCol = last
tbl.ensureColVisible()
if tbl.colScroll == 0 && last > 2 {
t.Fatalf("expected horizontal scroll for wide table, colScroll=%d", tbl.colScroll)
}
}
func TestDataTableViewHighlightsActiveCell(t *testing.T) {
tbl := newDataTable()
tbl.SetSize(80, 12)
tbl.SetFocused(true)
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, sampleItems())
tbl.cursorRow = 1
tbl.cursorCol = 2
view := tbl.View()
if !strings.Contains(view, "Alpha") {
t.Fatal("view missing row data")
}
if !strings.Contains(view, "Title") {
t.Fatal("view missing header")
}
}
func TestDataTableFillsPaneWidth(t *testing.T) {
tbl := newDataTable()
tbl.SetSize(80, 12)
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, sampleItems())
indices, widths := tbl.visibleLayout()
sum := 0
for _, i := range indices {
sum += widths[i]
}
if sum != 80 {
t.Fatalf("visible columns width sum=%d want 80", sum)
}
}
func TestDistributeColumnWidthsFillsTotal(t *testing.T) {
cols := []model.TableColumn{
{Key: "_sel", Width: 3},
{Key: "id", Width: 10},
{Key: "title", Width: 20},
}
indices := []int{0, 1, 2}
w := distributeColumnWidths(33, 80, indices, cols)
sum := w[0] + w[1] + w[2]
if sum != 80 {
t.Fatalf("sum=%d want 80", sum)
}
if w[2] <= 20 {
t.Fatalf("title should expand, got %d", w[2])
}
}
func indexOfColumn(tbl DataTable, key string) int {
for i, c := range tbl.columns {
if c.Key == key {
return i
}
}
return 0
}