Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eed57a68d4 | ||
|
|
b2aae21390 | ||
|
|
3d4aa10cf9 | ||
|
|
3b47d1400b | ||
|
|
a413d409b5 | ||
|
|
cac0ffa2a0 | ||
|
|
cad68f64c6 | ||
|
|
4046f4c46a | ||
|
|
6d4df4edee |
@@ -3,6 +3,12 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Git tag to release (e.g. v0.4.0)
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -15,6 +21,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.4.0"
|
||||
".": "0.6.0"
|
||||
}
|
||||
|
||||
+29
-45
@@ -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
|
||||
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)
|
||||
|
||||
|
||||
@@ -33,55 +52,20 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.5.1] — 2026-06-24
|
||||
|
||||
### Added — `office` TUI
|
||||
|
||||
- **`cmd/office`** — Bubble Tea terminal UI mirroring OnlyOffice Workspace:
|
||||
three-pane layout (module tree, multi-select list, markdown preview).
|
||||
Install: `go install github.com/eslider/go-onlyoffice/cmd/office@latest`.
|
||||
- **`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`).
|
||||
- **Alt+1 / Alt+2 / Alt+3** — show/hide left (nav), middle (table), and right (detail) panes.
|
||||
- Visible panes share **100% terminal width** evenly; pane content fills its column.
|
||||
- Tab focus skips hidden panes.
|
||||
|
||||
### Added — library
|
||||
### Changed — `office` TUI
|
||||
|
||||
- **CRM dedupe & cleanup** — `crm_normalize.go`, `crm_dedupe_plan.go`, `crm_dedupe.go`:
|
||||
merge duplicate companies/persons/deals by normalized name; remove duplicate
|
||||
contact-info rows and opportunity members (including same `displayName` with
|
||||
different ids); fix malformed deal titles (` @ Company` → `Company`).
|
||||
`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.
|
||||
- Split detail pane: form/document top (~72%), CRUD action bar bottom.
|
||||
- Middle pane: multi-column table with sort, selection, and full-width columns.
|
||||
- Project list columns: ID, Title, Tasks (open/closed), Documents, Users.
|
||||
- Row selection auto-loads detail; files show document preview, entities show forms.
|
||||
|
||||
## [0.6.0] - 2026-04-24
|
||||
|
||||
|
||||
@@ -575,13 +575,17 @@ office
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `Tab` | Switch pane (menu → list → preview) |
|
||||
| `↑↓` / `j` / `k` | Navigate |
|
||||
| `Space` / `Enter` | Toggle selection on list row |
|
||||
| `Enter` | Load preview for focused row |
|
||||
| `Tab` | Next pane (menu → list → preview) |
|
||||
| `Shift+Tab` | Previous pane |
|
||||
| `↑↓` / `j` / `k` | Navigate / scroll within focused pane |
|
||||
| `Enter` / `→` | Expand tree branch or open leaf list |
|
||||
| `Space` | Toggle multi-select on list row |
|
||||
| `Enter` / `a` | Action menu (view, delete, download, …) |
|
||||
| `r` | Refresh current list |
|
||||
| `q` | Quit |
|
||||
|
||||
Navigate the **tree** on the left: expand modules (`▸`/`▾`), drill to a **leaf** (marked `•`) — the center list loads only at the last level. Under **Projects → By project**, live projects appear as subnodes with **Tasks** and **Files** leaves.
|
||||
|
||||
Optional env for DOCX preview via Document Server (see [`.env.example`](.env.example)):
|
||||
|
||||
```bash
|
||||
@@ -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
|
||||
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
|
||||
go test -tags=integration ./cmd/office/fetch/...
|
||||
go test -tags=integration ./cmd/office/fetch/... ./cmd/office/preview/...
|
||||
```
|
||||
|
||||
**Project / task documents (`oo`):**
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
//go:build integration
|
||||
|
||||
package fetch_test
|
||||
|
||||
import (
|
||||
@@ -12,34 +10,99 @@ import (
|
||||
"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") == "" {
|
||||
t.Skip("ONLYOFFICE_URL not set")
|
||||
}
|
||||
client, err := bootstrap.NewClient(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
if os.Getenv("ONLYOFFICE_USER") == "" && os.Getenv("ONLYOFFICE_NAME") == "" {
|
||||
t.Skip("ONLYOFFICE_USER not set")
|
||||
}
|
||||
loader := &fetch.Loader{Client: client}
|
||||
items, err := loader.List(context.Background(), model.SubjectProjects)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
if os.Getenv("ONLYOFFICE_PASS") == "" && os.Getenv("ONLYOFFICE_PASSWORD") == "" {
|
||||
t.Skip("ONLYOFFICE_PASS not set")
|
||||
}
|
||||
t.Logf("projects: %d", len(items))
|
||||
}
|
||||
|
||||
func TestIntegrationListMailInbox(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
loader := &fetch.Loader{Client: client}
|
||||
items, err := loader.List(context.Background(), model.SubjectMailInbox)
|
||||
return &fetch.Loader{Client: client}, context.Background()
|
||||
}
|
||||
|
||||
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 {
|
||||
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
@@ -3,6 +3,9 @@ package fetch
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
@@ -14,15 +17,18 @@ type Loader struct {
|
||||
Client *onlyoffice.Client
|
||||
}
|
||||
|
||||
// List returns items for the given subject.
|
||||
func (l *Loader) List(ctx context.Context, subject model.Subject) ([]model.Item, error) {
|
||||
// List returns items for the given list spec (nav leaf).
|
||||
func (l *Loader) List(ctx context.Context, spec model.ListSpec) ([]model.Item, error) {
|
||||
if l == nil || l.Client == nil {
|
||||
return nil, fmt.Errorf("fetch: client is nil")
|
||||
}
|
||||
switch subject {
|
||||
switch spec.Subject {
|
||||
case model.SubjectProjects:
|
||||
return l.listProjects(ctx)
|
||||
case model.SubjectTasks:
|
||||
if spec.ProjectID != "" {
|
||||
return l.listTasksForProject(ctx, spec.ProjectID)
|
||||
}
|
||||
return l.listTasks(ctx)
|
||||
case model.SubjectCalendars:
|
||||
return l.listCalendars(ctx)
|
||||
@@ -55,12 +61,27 @@ func (l *Loader) List(ctx context.Context, subject model.Subject) ([]model.Item,
|
||||
case model.SubjectUsers:
|
||||
return l.listUsers(ctx)
|
||||
case model.SubjectProjectFiles:
|
||||
return l.listProjectFiles(ctx)
|
||||
case model.SubjectTaskFiles:
|
||||
return nil, fmt.Errorf("select a task in Tasks first (task files need task id)")
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported subject %q", subject)
|
||||
pid := spec.ProjectID
|
||||
if pid == "" {
|
||||
pid = onlyoffice.GetEnvironmentDefaults().ProjectID
|
||||
}
|
||||
if pid == "" {
|
||||
return nil, fmt.Errorf("set ONLYOFFICE_PROJECT_ID or pick a project in the tree")
|
||||
}
|
||||
return l.listProjectFiles(ctx, pid)
|
||||
case model.SubjectTaskFiles:
|
||||
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.
|
||||
@@ -72,7 +93,7 @@ func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, e
|
||||
return l.Client.GetContact(ctx, item.ID)
|
||||
case model.KindMail:
|
||||
return l.Client.GetMailMessage(ctx, item.ID)
|
||||
case model.KindTask:
|
||||
case model.KindTask, model.KindCRMTask:
|
||||
return l.Client.GetTaskByID(ctx, item.ID)
|
||||
case model.KindProject:
|
||||
return l.Client.GetProjectByID(ctx, item.ID)
|
||||
@@ -84,6 +105,130 @@ func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, e
|
||||
}
|
||||
}
|
||||
|
||||
// Execute runs a user-selected action on an item.
|
||||
func (l *Loader) Execute(ctx context.Context, action model.ActionID, item model.Item, destPath string) (string, error) {
|
||||
switch action {
|
||||
case model.ActionView:
|
||||
return "view", nil
|
||||
case model.ActionDelete:
|
||||
return l.executeDelete(ctx, item)
|
||||
case model.ActionDownload:
|
||||
return l.executeDownload(ctx, item, destPath)
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported action %q", action)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Loader) executeDelete(ctx context.Context, item model.Item) (string, error) {
|
||||
switch item.Kind {
|
||||
case model.KindProject:
|
||||
id, err := strconv.Atoi(item.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := l.Client.DeleteProject(id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted project %s", item.Title), nil
|
||||
case model.KindTask:
|
||||
if _, err := l.Client.DeleteTask(ctx, item.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted task %s", item.Title), nil
|
||||
case model.KindContact:
|
||||
if _, err := l.Client.DeleteContact(ctx, item.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted contact %s", item.Title), nil
|
||||
case model.KindOpportunity:
|
||||
if _, err := l.Client.DeleteOpportunity(ctx, item.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted deal %s", item.Title), nil
|
||||
case model.KindCase:
|
||||
if _, err := l.Client.DeleteCase(ctx, item.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted case %s", item.Title), nil
|
||||
case model.KindCRMTask:
|
||||
if _, err := l.Client.DeleteCRMTask(ctx, item.ID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted CRM task %s", item.Title), nil
|
||||
case model.KindMail:
|
||||
id, err := strconv.Atoi(item.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := l.Client.RemoveMailMessages(ctx, id); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted message %s", item.Title), nil
|
||||
case model.KindFile:
|
||||
id, err := strconv.Atoi(item.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := l.Client.DeleteFiles(ctx, []int{id}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Deleted file %s", item.Title), nil
|
||||
default:
|
||||
return "", fmt.Errorf("delete not supported for %s", item.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Loader) executeDownload(ctx context.Context, item model.Item, destPath string) (string, error) {
|
||||
if item.Kind != model.KindFile {
|
||||
return "", fmt.Errorf("download only for files")
|
||||
}
|
||||
if destPath == "" {
|
||||
destPath = filepath.Join(os.TempDir(), "office", item.Title)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
f, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
if _, err := l.Client.DownloadFile(ctx, item.ID, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Downloaded to %s", destPath), nil
|
||||
}
|
||||
|
||||
func (l *Loader) listTasksForProject(ctx context.Context, projectID string) ([]model.Item, error) {
|
||||
rows, err := l.Client.ListTasks(ctx, projectID, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil
|
||||
}
|
||||
|
||||
func (l *Loader) listTaskFiles(ctx context.Context, taskID string) ([]model.Item, error) {
|
||||
files, err := l.Client.GetTaskFiles(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var items []model.Item
|
||||
for _, f := range files {
|
||||
id, title := "", ""
|
||||
if f.ID != nil {
|
||||
id = f.ID.String()
|
||||
}
|
||||
if f.Title != nil {
|
||||
title = *f.Title
|
||||
}
|
||||
items = append(items, model.Item{
|
||||
ID: id, Title: title, Kind: model.KindFile,
|
||||
Raw: map[string]any{"id": id, "title": title},
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) {
|
||||
projects, err := l.Client.GetProjects()
|
||||
if err != nil {
|
||||
@@ -99,13 +244,24 @@ func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) {
|
||||
if p.Title != nil {
|
||||
title = *p.Title
|
||||
}
|
||||
status := ""
|
||||
if p.Status != nil {
|
||||
status = fmt.Sprint(*p.Status)
|
||||
raw := map[string]any{
|
||||
"id": id,
|
||||
"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{
|
||||
ID: id, Title: title, Subtitle: status, Kind: model.KindProject,
|
||||
Raw: map[string]any{"id": id, "title": title, "status": status},
|
||||
ID: id, Title: title, Kind: model.KindProject, Raw: raw,
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
@@ -214,14 +370,8 @@ func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) {
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (l *Loader) listProjectFiles(ctx context.Context) ([]model.Item, error) {
|
||||
def := l.Client // need project id from defaults
|
||||
_ = def
|
||||
pid := onlyoffice.GetEnvironmentDefaults().ProjectID
|
||||
if pid == "" {
|
||||
return nil, fmt.Errorf("set ONLYOFFICE_PROJECT_ID for project files")
|
||||
}
|
||||
resp, err := l.Client.GetProjectFiles(ctx, pid)
|
||||
func (l *Loader) listProjectFiles(ctx context.Context, projectID string) ([]model.Item, error) {
|
||||
resp, err := l.Client.GetProjectFiles(ctx, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ func ItemsFromMaps(rows []map[string]any, kind model.Kind, fields FieldMap) []mo
|
||||
if title == "" {
|
||||
title = str(row, "name")
|
||||
}
|
||||
if title == "" {
|
||||
title = "(untitled)"
|
||||
}
|
||||
raw := row
|
||||
out[i] = model.Item{
|
||||
ID: idStr(row, fields.IDKey),
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package fetch_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func TestItemsFromMaps(t *testing.T) {
|
||||
rows := []map[string]any{
|
||||
{"id": float64(1), "title": "Alpha", "status": "Open"},
|
||||
{"id": float64(2), "title": "Beta", "status": "Closed"},
|
||||
}
|
||||
items := fetch.ItemsFromMaps(rows, model.KindTask, fetch.TaskItemFields)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("len=%d", len(items))
|
||||
}
|
||||
if items[0].ID != "1" || items[0].Title != "Alpha" {
|
||||
t.Fatalf("item0=%+v", items[0])
|
||||
}
|
||||
if items[0].Kind != model.KindTask {
|
||||
t.Fatalf("kind=%v", items[0].Kind)
|
||||
}
|
||||
if items[1].Subtitle != "Closed" {
|
||||
t.Fatalf("subtitle=%q", items[1].Subtitle)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -26,7 +26,7 @@ func main() {
|
||||
}
|
||||
|
||||
m := ui.NewModel(client)
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,18 @@ const (
|
||||
FocusPreview
|
||||
)
|
||||
|
||||
// PrevFocusPane cycles preview → list → menu → preview.
|
||||
func PrevFocusPane(p FocusPane) FocusPane {
|
||||
switch p {
|
||||
case FocusMenu:
|
||||
return FocusPreview
|
||||
case FocusList:
|
||||
return FocusMenu
|
||||
default:
|
||||
return FocusList
|
||||
}
|
||||
}
|
||||
|
||||
// NextFocusPane cycles menu → list → preview → menu.
|
||||
func NextFocusPane(p FocusPane) FocusPane {
|
||||
switch p {
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
package model
|
||||
|
||||
// menuNode is one row in the left navigation tree (flat slice with depth).
|
||||
type menuNode struct {
|
||||
label string
|
||||
depth int
|
||||
subject Subject // zero when branch node
|
||||
expandable bool
|
||||
parent int // index of parent branch, -1 for roots
|
||||
}
|
||||
|
||||
// MenuTree is the left-pane OnlyOffice module navigator.
|
||||
type MenuTree struct {
|
||||
nodes []menuNode
|
||||
cursor int
|
||||
expanded map[int]bool
|
||||
visible []int // indices into nodes, recomputed on expand/collapse
|
||||
}
|
||||
|
||||
// DefaultMenuTree returns the standard OnlyOffice Workspace module tree.
|
||||
func DefaultMenuTree() *MenuTree {
|
||||
nodes := []menuNode{
|
||||
{label: "Projects", depth: 0, expandable: true, parent: -1},
|
||||
{label: "All projects", depth: 1, subject: SubjectProjects, parent: 0},
|
||||
{label: "Tasks", depth: 1, subject: SubjectTasks, parent: 0},
|
||||
{label: "Calendar", depth: 0, expandable: true, parent: -1},
|
||||
{label: "Calendars", depth: 1, subject: SubjectCalendars, parent: 3},
|
||||
{label: "Events", depth: 1, subject: SubjectEvents, parent: 3},
|
||||
{label: "CRM", depth: 0, expandable: true, parent: -1},
|
||||
{label: "Contacts", depth: 1, subject: SubjectContacts, parent: 6},
|
||||
{label: "Persons", depth: 1, subject: SubjectPersons, parent: 6},
|
||||
{label: "Companies", depth: 1, subject: SubjectCompanies, parent: 6},
|
||||
{label: "Opportunities", depth: 1, subject: SubjectOpportunities, parent: 6},
|
||||
{label: "Cases", depth: 1, subject: SubjectCases, parent: 6},
|
||||
{label: "CRM Tasks", depth: 1, subject: SubjectCRMTasks, parent: 6},
|
||||
{label: "Mail", depth: 0, expandable: true, parent: -1},
|
||||
{label: "Inbox", depth: 1, subject: SubjectMailInbox, parent: 13},
|
||||
{label: "Sent", depth: 1, subject: SubjectMailSent, parent: 13},
|
||||
{label: "Drafts", depth: 1, subject: SubjectMailDrafts, parent: 13},
|
||||
{label: "Trash", depth: 1, subject: SubjectMailTrash, parent: 13},
|
||||
{label: "Spam", depth: 1, subject: SubjectMailSpam, parent: 13},
|
||||
{label: "Documents", depth: 0, expandable: true, parent: -1},
|
||||
{label: "Project files", depth: 1, subject: SubjectProjectFiles, parent: 19},
|
||||
{label: "Task files", depth: 1, subject: SubjectTaskFiles, parent: 19},
|
||||
{label: "Users", depth: 0, expandable: true, parent: -1},
|
||||
{label: "Directory", depth: 1, subject: SubjectUsers, parent: 22},
|
||||
}
|
||||
m := &MenuTree{
|
||||
nodes: nodes,
|
||||
expanded: map[int]bool{},
|
||||
}
|
||||
m.rebuildVisible()
|
||||
return m
|
||||
}
|
||||
|
||||
// RootLabels returns top-level module names in order.
|
||||
func (m *MenuTree) RootLabels() []string {
|
||||
var out []string
|
||||
for i, n := range m.nodes {
|
||||
if n.depth == 0 {
|
||||
out = append(out, n.label)
|
||||
}
|
||||
_ = i
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Cursor returns the visible-row cursor index.
|
||||
func (m *MenuTree) Cursor() int { return m.cursor }
|
||||
|
||||
// SetCursor sets the visible-row cursor.
|
||||
func (m *MenuTree) SetCursor(c int) {
|
||||
if c < 0 {
|
||||
c = 0
|
||||
}
|
||||
if c >= len(m.visible) {
|
||||
c = len(m.visible) - 1
|
||||
}
|
||||
if c < 0 {
|
||||
c = 0
|
||||
}
|
||||
m.cursor = c
|
||||
}
|
||||
|
||||
// MoveDown advances the menu cursor.
|
||||
func (m *MenuTree) MoveDown() {
|
||||
m.SetCursor(m.cursor + 1)
|
||||
}
|
||||
|
||||
// MoveUp moves the menu cursor up.
|
||||
func (m *MenuTree) MoveUp() {
|
||||
m.SetCursor(m.cursor - 1)
|
||||
}
|
||||
|
||||
// VisibleCount returns the number of visible menu rows.
|
||||
func (m *MenuTree) VisibleCount() int { return len(m.visible) }
|
||||
|
||||
// LabelAt returns the label and depth for visible row i.
|
||||
func (m *MenuTree) LabelAt(i int) (label string, depth int) {
|
||||
if i < 0 || i >= len(m.visible) {
|
||||
return "", 0
|
||||
}
|
||||
n := m.nodes[m.visible[i]]
|
||||
return n.label, n.depth
|
||||
}
|
||||
|
||||
// IsExpandable reports whether visible row i is a branch node.
|
||||
func (m *MenuTree) IsExpandable(visibleIdx int) bool {
|
||||
if visibleIdx < 0 || visibleIdx >= len(m.visible) {
|
||||
return false
|
||||
}
|
||||
return m.nodes[m.visible[visibleIdx]].expandable
|
||||
}
|
||||
|
||||
// IsExpanded reports whether the branch at visible row i is expanded.
|
||||
func (m *MenuTree) IsExpanded(visibleIdx int) bool {
|
||||
if visibleIdx < 0 || visibleIdx >= len(m.visible) {
|
||||
return false
|
||||
}
|
||||
return m.expanded[m.visible[visibleIdx]]
|
||||
}
|
||||
|
||||
// ToggleExpand expands or collapses the branch at visible row i.
|
||||
func (m *MenuTree) ToggleExpand(visibleIdx int) {
|
||||
if !m.IsExpandable(visibleIdx) {
|
||||
return
|
||||
}
|
||||
idx := m.visible[visibleIdx]
|
||||
if m.expanded[idx] {
|
||||
delete(m.expanded, idx)
|
||||
} else {
|
||||
m.expanded[idx] = true
|
||||
}
|
||||
m.rebuildVisible()
|
||||
}
|
||||
|
||||
// SelectIndex returns the subject for visible row i when it is a leaf.
|
||||
func (m *MenuTree) SelectIndex(visibleIdx int) (Subject, bool) {
|
||||
if visibleIdx < 0 || visibleIdx >= len(m.visible) {
|
||||
return "", false
|
||||
}
|
||||
n := m.nodes[m.visible[visibleIdx]]
|
||||
if n.subject == "" {
|
||||
return "", false
|
||||
}
|
||||
return n.subject, true
|
||||
}
|
||||
|
||||
// CurrentSubject returns the subject at the current cursor if it is a leaf.
|
||||
func (m *MenuTree) CurrentSubject() (Subject, bool) {
|
||||
return m.SelectIndex(m.cursor)
|
||||
}
|
||||
|
||||
func (m *MenuTree) rebuildVisible() {
|
||||
m.visible = m.visible[:0]
|
||||
for i, n := range m.nodes {
|
||||
if n.depth == 0 {
|
||||
m.visible = append(m.visible, i)
|
||||
if m.expanded[i] {
|
||||
m.appendChildren(i)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if m.cursor >= len(m.visible) {
|
||||
m.cursor = len(m.visible) - 1
|
||||
}
|
||||
if m.cursor < 0 {
|
||||
m.cursor = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MenuTree) appendChildren(branchIdx int) {
|
||||
for i, n := range m.nodes {
|
||||
if n.parent == branchIdx {
|
||||
m.visible = append(m.visible, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func TestMenuTreeHasExpectedRoots(t *testing.T) {
|
||||
tree := model.DefaultMenuTree()
|
||||
roots := tree.RootLabels()
|
||||
want := []string{"Projects", "Calendar", "CRM", "Mail", "Documents", "Users"}
|
||||
if len(roots) != len(want) {
|
||||
t.Fatalf("roots=%v want %v", roots, want)
|
||||
}
|
||||
for i, w := range want {
|
||||
if roots[i] != w {
|
||||
t.Fatalf("root[%d]=%q want %q", i, roots[i], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuExpandCollapse(t *testing.T) {
|
||||
tree := model.DefaultMenuTree()
|
||||
if !tree.IsExpandable(0) {
|
||||
t.Fatal("Projects should be expandable")
|
||||
}
|
||||
tree.ToggleExpand(0)
|
||||
if !tree.IsExpanded(0) {
|
||||
t.Fatal("Projects should expand")
|
||||
}
|
||||
tree.ToggleExpand(0)
|
||||
if tree.IsExpanded(0) {
|
||||
t.Fatal("Projects should collapse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenuSelectLeafReturnsSubject(t *testing.T) {
|
||||
tree := model.DefaultMenuTree()
|
||||
// Expand Projects and select "All projects" (first child).
|
||||
tree.ToggleExpand(0)
|
||||
subj, ok := tree.SelectIndex(1)
|
||||
if !ok {
|
||||
t.Fatal("expected leaf subject")
|
||||
}
|
||||
if subj != model.SubjectProjects {
|
||||
t.Fatalf("subject=%v want Projects", subj)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package model
|
||||
|
||||
// ListSpec tells the fetch layer what to load when a nav leaf is active.
|
||||
type ListSpec struct {
|
||||
Subject Subject
|
||||
ProjectID string
|
||||
TaskID string
|
||||
}
|
||||
|
||||
// NavNode is one row in the navigation tree.
|
||||
type NavNode struct {
|
||||
ID string
|
||||
Label string
|
||||
Branch bool
|
||||
List *ListSpec // set on leaves that open the center list
|
||||
ParentID string
|
||||
}
|
||||
|
||||
// NavTree is a hierarchical navigator; the center list loads only on leaves.
|
||||
type NavTree struct {
|
||||
nodes map[string]NavNode
|
||||
roots []string
|
||||
children map[string][]string
|
||||
expanded map[string]bool
|
||||
cursor int
|
||||
visible []string
|
||||
}
|
||||
|
||||
// DefaultNavTree returns the OnlyOffice module tree (static skeleton).
|
||||
func DefaultNavTree() *NavTree {
|
||||
t := &NavTree{
|
||||
nodes: make(map[string]NavNode),
|
||||
children: make(map[string][]string),
|
||||
expanded: make(map[string]bool),
|
||||
}
|
||||
add := func(id, label, parent string, branch bool, list *ListSpec) {
|
||||
t.nodes[id] = NavNode{ID: id, Label: label, Branch: branch, List: list, ParentID: parent}
|
||||
if parent == "" {
|
||||
t.roots = append(t.roots, id)
|
||||
} else {
|
||||
t.children[parent] = append(t.children[parent], id)
|
||||
}
|
||||
}
|
||||
|
||||
add("projects", "Projects", "", true, nil)
|
||||
add("projects.browse", "Browse", "projects", true, nil)
|
||||
add("projects.browse.all", "All projects", "projects.browse", false, &ListSpec{Subject: SubjectProjects})
|
||||
add("projects.browse.tasks", "All tasks", "projects.browse", false, &ListSpec{Subject: SubjectTasks})
|
||||
add("projects.dynamic", "By project", "projects", true, nil)
|
||||
|
||||
add("calendar", "Calendar", "", true, nil)
|
||||
add("calendar.cals", "Calendars", "calendar", false, &ListSpec{Subject: SubjectCalendars})
|
||||
add("calendar.events", "Events", "calendar", false, &ListSpec{Subject: SubjectEvents})
|
||||
|
||||
add("crm", "CRM", "", true, nil)
|
||||
add("crm.contacts", "Contacts", "crm", false, &ListSpec{Subject: SubjectContacts})
|
||||
add("crm.persons", "Persons", "crm", false, &ListSpec{Subject: SubjectPersons})
|
||||
add("crm.companies", "Companies", "crm", false, &ListSpec{Subject: SubjectCompanies})
|
||||
add("crm.opportunities", "Opportunities", "crm", false, &ListSpec{Subject: SubjectOpportunities})
|
||||
add("crm.cases", "Cases", "crm", false, &ListSpec{Subject: SubjectCases})
|
||||
add("crm.tasks", "CRM tasks", "crm", false, &ListSpec{Subject: SubjectCRMTasks})
|
||||
|
||||
add("mail", "Mail", "", true, nil)
|
||||
add("mail.inbox", "Inbox", "mail", false, &ListSpec{Subject: SubjectMailInbox})
|
||||
add("mail.sent", "Sent", "mail", false, &ListSpec{Subject: SubjectMailSent})
|
||||
add("mail.drafts", "Drafts", "mail", false, &ListSpec{Subject: SubjectMailDrafts})
|
||||
add("mail.trash", "Trash", "mail", false, &ListSpec{Subject: SubjectMailTrash})
|
||||
add("mail.spam", "Spam", "mail", false, &ListSpec{Subject: SubjectMailSpam})
|
||||
|
||||
add("users", "Users", "", true, nil)
|
||||
add("users.dir", "Directory", "users", false, &ListSpec{Subject: SubjectUsers})
|
||||
|
||||
t.rebuildVisible()
|
||||
return t
|
||||
}
|
||||
|
||||
// RootLabels returns top-level labels (for tests).
|
||||
func (t *NavTree) RootLabels() []string {
|
||||
out := make([]string, len(t.roots))
|
||||
for i, id := range t.roots {
|
||||
out[i] = t.nodes[id].Label
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *NavTree) Cursor() int { return t.cursor }
|
||||
|
||||
func (t *NavTree) VisibleCount() int { return len(t.visible) }
|
||||
|
||||
func (t *NavTree) NodeAtVisible(i int) (NavNode, bool) {
|
||||
if i < 0 || i >= len(t.visible) {
|
||||
return NavNode{}, false
|
||||
}
|
||||
n, ok := t.nodes[t.visible[i]]
|
||||
return n, ok
|
||||
}
|
||||
|
||||
func (t *NavTree) DepthAtVisible(i int) int {
|
||||
if i < 0 || i >= len(t.visible) {
|
||||
return 0
|
||||
}
|
||||
depth := 0
|
||||
id := t.visible[i]
|
||||
for {
|
||||
n, ok := t.nodes[id]
|
||||
if !ok || n.ParentID == "" {
|
||||
break
|
||||
}
|
||||
depth++
|
||||
id = n.ParentID
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
func (t *NavTree) IsExpandable(i int) bool {
|
||||
n, ok := t.NodeAtVisible(i)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return n.Branch && len(t.children[n.ID]) > 0
|
||||
}
|
||||
|
||||
func (t *NavTree) IsExpanded(i int) bool {
|
||||
n, ok := t.NodeAtVisible(i)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return t.expanded[n.ID]
|
||||
}
|
||||
|
||||
func (t *NavTree) ToggleExpand(i int) {
|
||||
n, ok := t.NodeAtVisible(i)
|
||||
if !ok || !n.Branch {
|
||||
return
|
||||
}
|
||||
if t.expanded[n.ID] {
|
||||
delete(t.expanded, n.ID)
|
||||
} else {
|
||||
t.expanded[n.ID] = true
|
||||
}
|
||||
t.rebuildVisible()
|
||||
}
|
||||
|
||||
func (t *NavTree) MoveUp() { t.SetCursor(t.cursor - 1) }
|
||||
func (t *NavTree) MoveDown() { t.SetCursor(t.cursor + 1) }
|
||||
|
||||
func (t *NavTree) SetCursor(c int) {
|
||||
if c < 0 {
|
||||
c = 0
|
||||
}
|
||||
if c >= len(t.visible) {
|
||||
c = len(t.visible) - 1
|
||||
}
|
||||
if c < 0 {
|
||||
c = 0
|
||||
}
|
||||
t.cursor = c
|
||||
}
|
||||
|
||||
// CurrentListSpec returns the list spec when the cursor is on a leaf.
|
||||
func (t *NavTree) CurrentListSpec() (*ListSpec, bool) {
|
||||
n, ok := t.NodeAtVisible(t.cursor)
|
||||
if !ok || n.List == nil {
|
||||
return nil, false
|
||||
}
|
||||
spec := *n.List
|
||||
return &spec, true
|
||||
}
|
||||
|
||||
// Activate expands a branch or returns leaf list spec on Enter.
|
||||
func (t *NavTree) Activate() (*ListSpec, bool) {
|
||||
n, ok := t.NodeAtVisible(t.cursor)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if n.Branch {
|
||||
t.expanded[n.ID] = true
|
||||
t.rebuildVisible()
|
||||
return nil, false
|
||||
}
|
||||
if n.List != nil {
|
||||
spec := *n.List
|
||||
return &spec, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (t *NavTree) rebuildVisible() {
|
||||
t.visible = t.visible[:0]
|
||||
var walk func(id string, depth int)
|
||||
walk = func(id string, depth int) {
|
||||
t.visible = append(t.visible, id)
|
||||
if !t.expanded[id] {
|
||||
return
|
||||
}
|
||||
for _, child := range t.children[id] {
|
||||
walk(child, depth+1)
|
||||
}
|
||||
}
|
||||
for _, root := range t.roots {
|
||||
walk(root, 0)
|
||||
}
|
||||
if t.cursor >= len(t.visible) {
|
||||
t.cursor = len(t.visible) - 1
|
||||
}
|
||||
if t.cursor < 0 {
|
||||
t.cursor = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package model
|
||||
|
||||
import "strings"
|
||||
|
||||
// InjectProjectNodes adds dynamic project branches under projects.dynamic.
|
||||
func (t *NavTree) InjectProjectNodes(projects []Item) {
|
||||
// Drop prior dynamic project.* nodes.
|
||||
for id := range t.nodes {
|
||||
if strings.HasPrefix(id, "project.") {
|
||||
delete(t.nodes, id)
|
||||
}
|
||||
}
|
||||
t.children["projects.dynamic"] = nil
|
||||
for _, p := range projects {
|
||||
pid := "project." + p.ID
|
||||
t.nodes[pid] = NavNode{ID: pid, Label: p.Title, Branch: true, ParentID: "projects.dynamic"}
|
||||
t.children["projects.dynamic"] = append(t.children["projects.dynamic"], pid)
|
||||
taskID := pid + ".tasks"
|
||||
fileID := pid + ".files"
|
||||
t.nodes[taskID] = NavNode{
|
||||
ID: taskID, Label: "Tasks", Branch: false, ParentID: pid,
|
||||
List: &ListSpec{Subject: SubjectTasks, ProjectID: p.ID},
|
||||
}
|
||||
t.nodes[fileID] = NavNode{
|
||||
ID: fileID, Label: "Files", Branch: false, ParentID: pid,
|
||||
List: &ListSpec{Subject: SubjectProjectFiles, ProjectID: p.ID},
|
||||
}
|
||||
t.children[pid] = []string{taskID, fileID}
|
||||
}
|
||||
t.rebuildVisible()
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package model_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
)
|
||||
|
||||
func TestNavTreeHasExpectedRoots(t *testing.T) {
|
||||
tree := model.DefaultNavTree()
|
||||
roots := tree.RootLabels()
|
||||
want := []string{"Projects", "Calendar", "CRM", "Mail", "Users"}
|
||||
if len(roots) != len(want) {
|
||||
t.Fatalf("roots=%v want %v", roots, want)
|
||||
}
|
||||
for i, w := range want {
|
||||
if roots[i] != w {
|
||||
t.Fatalf("root[%d]=%q want %q", i, roots[i], w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavExpandCollapse(t *testing.T) {
|
||||
tree := model.DefaultNavTree()
|
||||
if !tree.IsExpandable(0) {
|
||||
t.Fatal("Projects should be expandable")
|
||||
}
|
||||
tree.ToggleExpand(0)
|
||||
if !tree.IsExpanded(0) {
|
||||
t.Fatal("Projects should expand")
|
||||
}
|
||||
tree.ToggleExpand(0)
|
||||
if tree.IsExpanded(0) {
|
||||
t.Fatal("Projects should collapse")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavLeafReturnsListSpec(t *testing.T) {
|
||||
tree := model.DefaultNavTree()
|
||||
tree.ToggleExpand(0) // Projects
|
||||
tree.ToggleExpand(1) // Browse
|
||||
// Find "All projects" leaf cursor
|
||||
var found int = -1
|
||||
for i := 0; i < tree.VisibleCount(); i++ {
|
||||
n, ok := tree.NodeAtVisible(i)
|
||||
if ok && n.List != nil && n.List.Subject == model.SubjectProjects {
|
||||
found = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if found < 0 {
|
||||
t.Fatal("all projects leaf not found")
|
||||
}
|
||||
tree.SetCursor(found)
|
||||
spec, ok := tree.CurrentListSpec()
|
||||
if !ok || spec.Subject != model.SubjectProjects {
|
||||
t.Fatalf("spec=%v ok=%v", spec, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrevFocusPane(t *testing.T) {
|
||||
if got := model.PrevFocusPane(model.FocusList); got != model.FocusMenu {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
if got := model.PrevFocusPane(model.FocusMenu); got != model.FocusPreview {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionsForContact(t *testing.T) {
|
||||
acts := model.ActionsFor(model.KindContact)
|
||||
if len(acts) != 1 || acts[0].ID != model.ActionDelete {
|
||||
t.Fatalf("expected delete only, got %v", acts)
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package preview_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
||||
)
|
||||
|
||||
func TestContactMarkdownCompany(t *testing.T) {
|
||||
md := preview.ContactMarkdown(map[string]any{
|
||||
"displayName": "Acme GmbH",
|
||||
"isCompany": true,
|
||||
"about": "Widget supplier",
|
||||
})
|
||||
if !strings.Contains(md, "Acme GmbH") {
|
||||
t.Fatalf("title missing: %q", md)
|
||||
}
|
||||
if !strings.Contains(md, "Company") {
|
||||
t.Fatalf("type label missing: %q", md)
|
||||
}
|
||||
if !strings.Contains(md, "Widget supplier") {
|
||||
t.Fatalf("about missing: %q", md)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpportunityMarkdown(t *testing.T) {
|
||||
md := preview.OpportunityMarkdown(map[string]any{
|
||||
"title": "Big Deal",
|
||||
"description": "Annual contract",
|
||||
"bidValue": float64(50000),
|
||||
"bidCurrency": "EUR",
|
||||
})
|
||||
if !strings.Contains(md, "Big Deal") {
|
||||
t.Fatalf("title missing: %q", md)
|
||||
}
|
||||
if !strings.Contains(md, "50000") || !strings.Contains(md, "EUR") {
|
||||
t.Fatalf("bid missing: %q", md)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailMarkdown(t *testing.T) {
|
||||
md := preview.MailMarkdown(map[string]any{
|
||||
"subject": "Hello",
|
||||
"from": "Alice <alice@example.com>",
|
||||
"body": "<p>Hi there</p>",
|
||||
})
|
||||
if !strings.Contains(md, "Hello") {
|
||||
t.Fatalf("subject missing: %q", md)
|
||||
}
|
||||
if !strings.Contains(md, "alice@example.com") {
|
||||
t.Fatalf("from missing: %q", md)
|
||||
}
|
||||
if strings.Contains(md, "<p>") {
|
||||
t.Fatalf("html not stripped: %q", md)
|
||||
}
|
||||
if !strings.Contains(md, "Hi there") {
|
||||
t.Fatalf("body text missing: %q", md)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventMarkdown(t *testing.T) {
|
||||
md := preview.EventMarkdown(map[string]any{
|
||||
"title": "Standup",
|
||||
"start": "2026-06-24T09:00:00",
|
||||
"end": "2026-06-24T09:15:00",
|
||||
})
|
||||
if !strings.Contains(md, "Standup") {
|
||||
t.Fatalf("title missing: %q", md)
|
||||
}
|
||||
if !strings.Contains(md, "2026-06-24") {
|
||||
t.Fatalf("dates missing: %q", md)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskMarkdown(t *testing.T) {
|
||||
md := preview.TaskMarkdown(map[string]any{
|
||||
"title": "Deploy",
|
||||
"status": "Open",
|
||||
"deadline": "2026-07-01",
|
||||
"description": "Roll out v2",
|
||||
})
|
||||
if !strings.Contains(md, "Deploy") {
|
||||
t.Fatalf("title missing: %q", md)
|
||||
}
|
||||
if !strings.Contains(md, "Open") {
|
||||
t.Fatalf("status missing: %q", md)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
@@ -2,8 +2,6 @@ package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
@@ -12,17 +10,35 @@ import (
|
||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
||||
)
|
||||
|
||||
type listLoadedMsg struct {
|
||||
items []model.Item
|
||||
subject model.Subject
|
||||
spec model.ListSpec
|
||||
err error
|
||||
}
|
||||
|
||||
type previewLoadedMsg struct {
|
||||
text string
|
||||
type detailLoadedMsg struct {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -30,55 +46,73 @@ type previewLoadedMsg struct {
|
||||
type Model struct {
|
||||
client *onlyoffice.Client
|
||||
loader *fetch.Loader
|
||||
menu *model.MenuTree
|
||||
nav *model.NavTree
|
||||
listSpec model.ListSpec
|
||||
hasList bool
|
||||
items []model.Item
|
||||
selection *model.Selection
|
||||
subject model.Subject
|
||||
listIdx int
|
||||
focus model.FocusPane
|
||||
width int
|
||||
height int
|
||||
status string
|
||||
err string
|
||||
loading bool
|
||||
preview viewport.Model
|
||||
previewMD string
|
||||
ready bool
|
||||
menuVP viewport.Model
|
||||
listTable DataTable
|
||||
detail DetailPane
|
||||
showMenu bool
|
||||
showList bool
|
||||
showDetail bool
|
||||
}
|
||||
|
||||
// NewModel constructs the TUI with an authenticated client.
|
||||
func NewModel(client *onlyoffice.Client) Model {
|
||||
vp := viewport.New(40, 20)
|
||||
return Model{
|
||||
h, w := 24, 80
|
||||
m := Model{
|
||||
client: client,
|
||||
loader: &fetch.Loader{Client: client},
|
||||
menu: model.DefaultMenuTree(),
|
||||
nav: model.DefaultNavTree(),
|
||||
selection: model.NewSelection(),
|
||||
focus: model.FocusMenu,
|
||||
preview: vp,
|
||||
status: "office — Tab: pane Space: select Enter: preview q: quit",
|
||||
status: "Tab: pane · row select loads detail · Ctrl+S save · 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 {
|
||||
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) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.layoutViewports()
|
||||
m.syncAllContent()
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
key := msg.String()
|
||||
if key == "?" {
|
||||
m.status = helpText()
|
||||
return m, nil
|
||||
}
|
||||
if m.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)
|
||||
if action == ActionNone && (key == "up" || key == "k") {
|
||||
action = ResolveMoveUp(m.focus)
|
||||
@@ -87,78 +121,264 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case ActionQuit:
|
||||
return m, tea.Quit
|
||||
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
|
||||
case ActionMoveUp:
|
||||
if m.focus == model.FocusPreview {
|
||||
return m.handleDetailMove(-1)
|
||||
}
|
||||
m.moveUp()
|
||||
return m, nil
|
||||
m.syncFocusedPane()
|
||||
return m, m.onListRowChanged()
|
||||
case ActionMoveDown:
|
||||
if m.focus == model.FocusPreview {
|
||||
return m.handleDetailMove(1)
|
||||
}
|
||||
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
|
||||
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
|
||||
case ActionOpenPreview:
|
||||
m.selection.Toggle(&m.items, m.listIdx)
|
||||
return m, m.loadPreviewCmd()
|
||||
case ActionRefresh:
|
||||
if m.hasList {
|
||||
m.loading = true
|
||||
return m, m.loadListCmd(m.subject)
|
||||
case ActionOpenVex:
|
||||
return m, m.openVexCmd()
|
||||
}
|
||||
if m.focus == model.FocusMenu && (key == "enter" || key == " ") {
|
||||
if subj, ok := m.menu.CurrentSubject(); ok {
|
||||
m.subject = subj
|
||||
m.selection.Clear()
|
||||
m.listIdx = 0
|
||||
m.focus = model.FocusList
|
||||
m.loading = true
|
||||
return m, m.loadListCmd(subj)
|
||||
}
|
||||
if m.menu.IsExpandable(m.menu.Cursor()) {
|
||||
m.menu.ToggleExpand(m.menu.Cursor())
|
||||
return m, m.loadListCmd(m.listSpec)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.focus == model.FocusMenu {
|
||||
switch key {
|
||||
case " ":
|
||||
m.nav.ToggleExpand(m.nav.Cursor())
|
||||
m.syncMenuContent()
|
||||
return m, nil
|
||||
case "enter":
|
||||
if spec, ok := m.nav.Activate(); ok {
|
||||
return m.withList(*spec)
|
||||
}
|
||||
m.syncMenuContent()
|
||||
return m, nil
|
||||
case "right", "l":
|
||||
if spec, ok := m.nav.CurrentListSpec(); ok {
|
||||
return m.withList(*spec)
|
||||
}
|
||||
m.nav.Activate()
|
||||
m.syncMenuContent()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
case listLoadedMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
m.err = msg.err.Error()
|
||||
m.items = nil
|
||||
return m, nil
|
||||
}
|
||||
m.hasList = false
|
||||
m.listTable.Clear()
|
||||
m.detail.Clear()
|
||||
} else {
|
||||
m.err = ""
|
||||
m.items = msg.items
|
||||
m.subject = msg.subject
|
||||
return m, nil
|
||||
case previewLoadedMsg:
|
||||
m.listSpec = msg.spec
|
||||
m.hasList = true
|
||||
m.listTable.SetData(m.listSpec, m.items)
|
||||
}
|
||||
return m, m.onListRowChanged()
|
||||
|
||||
case detailLoadedMsg:
|
||||
m.loading = false
|
||||
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 {
|
||||
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
|
||||
|
||||
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
|
||||
if m.focus == model.FocusPreview {
|
||||
m.preview, cmd = m.preview.Update(msg)
|
||||
switch m.focus {
|
||||
case model.FocusMenu:
|
||||
m.menuVP, cmd = m.menuVP.Update(msg)
|
||||
case model.FocusList:
|
||||
cmd = m.listTable.Update(msg)
|
||||
case model.FocusPreview:
|
||||
cmd = m.detail.Update(msg)
|
||||
}
|
||||
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 {
|
||||
if m.width == 0 {
|
||||
return "Loading…\n"
|
||||
}
|
||||
menuW, listW, prevW := LayoutWidths(m.width)
|
||||
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(menuW).Height(m.height - 2)
|
||||
listStyle := paneStyle(m.focus == model.FocusList).Width(listW).Height(m.height - 2)
|
||||
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(prevW).Height(m.height - 2)
|
||||
pw := m.paneLayout()
|
||||
h := m.paneHeight() + 2
|
||||
var parts []string
|
||||
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
|
||||
if m.loading {
|
||||
@@ -169,152 +389,274 @@ func (m Model) View() string {
|
||||
}
|
||||
bar := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(status)
|
||||
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
menuStyle.Render(m.renderMenu(menuW)),
|
||||
listStyle.Render(m.renderList(listW)),
|
||||
prevStyle.Render(m.preview.View()),
|
||||
) + "\n" + bar
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, parts...) + "\n" + bar
|
||||
}
|
||||
|
||||
func (m Model) paneVis() PaneVisibility {
|
||||
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() {
|
||||
_, _, prevW := LayoutWidths(m.width)
|
||||
h := m.height - 4
|
||||
if h < 4 {
|
||||
h = 4
|
||||
pw := m.paneLayout()
|
||||
h := m.paneHeight()
|
||||
if pw.Visibility.Menu {
|
||||
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() {
|
||||
_, _, prevW := LayoutWidths(m.width)
|
||||
text, err := preview.RenderMarkdown(m.previewMD, prevW-4)
|
||||
if err != nil {
|
||||
text = m.previewMD
|
||||
}
|
||||
m.preview.SetContent(text)
|
||||
func (m *Model) syncAllContent() {
|
||||
m.syncMenuContent()
|
||||
m.syncListTable()
|
||||
}
|
||||
|
||||
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
|
||||
b.WriteString("Modules\n\n")
|
||||
for i := 0; i < m.menu.VisibleCount(); i++ {
|
||||
label, depth := m.menu.LabelAt(i)
|
||||
b.WriteString("Navigation\n\n")
|
||||
menuW := m.menuVP.Width
|
||||
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)
|
||||
line := prefix + label
|
||||
if m.menu.IsExpandable(i) {
|
||||
if m.menu.IsExpanded(i) {
|
||||
line = prefix + "▾ " + label
|
||||
line := prefix + n.Label
|
||||
if m.nav.IsExpandable(i) {
|
||||
if m.nav.IsExpanded(i) {
|
||||
line = prefix + "▾ " + n.Label
|
||||
} 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 {
|
||||
line = "> " + line
|
||||
} else {
|
||||
line = " " + line
|
||||
}
|
||||
if len(line) > w-1 {
|
||||
line = line[:w-2] + "…"
|
||||
if i == m.nav.Cursor() && m.focus == model.FocusMenu {
|
||||
line = selectedStyle.Width(menuW).Render(truncateRunes(line, menuW))
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
return b.String()
|
||||
m.menuVP.SetContent(b.String())
|
||||
syncVPToLine(&m.menuVP, m.nav.Cursor()+2)
|
||||
}
|
||||
|
||||
func (m *Model) renderList(w int) string {
|
||||
title := string(m.subject)
|
||||
if title == "" {
|
||||
title = "Items"
|
||||
func truncateRunes(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s (%d)\n\n", title, len(m.items))
|
||||
for i, it := range m.items {
|
||||
mark := "[ ]"
|
||||
if it.Selected {
|
||||
mark = "[x]"
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
cursor := " "
|
||||
if i == m.listIdx && m.focus == model.FocusList {
|
||||
cursor = "> "
|
||||
if max <= 1 {
|
||||
return string(r[:max])
|
||||
}
|
||||
line := fmt.Sprintf("%s%s %s", cursor, mark, it.Title)
|
||||
if it.Subtitle != "" {
|
||||
line += " — " + it.Subtitle
|
||||
return string(r[:max-1]) + "…"
|
||||
}
|
||||
|
||||
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() {
|
||||
switch m.focus {
|
||||
case model.FocusMenu:
|
||||
m.menu.MoveUp()
|
||||
m.nav.MoveUp()
|
||||
case model.FocusList:
|
||||
if m.listIdx > 0 {
|
||||
m.listIdx--
|
||||
if m.hasList {
|
||||
m.listTable.MoveRow(-1)
|
||||
}
|
||||
case model.FocusPreview:
|
||||
m.preview.LineUp(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) moveDown() {
|
||||
switch m.focus {
|
||||
case model.FocusMenu:
|
||||
m.menu.MoveDown()
|
||||
m.nav.MoveDown()
|
||||
case model.FocusList:
|
||||
if m.listIdx < len(m.items)-1 {
|
||||
m.listIdx++
|
||||
if m.hasList {
|
||||
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 {
|
||||
ctx := context.Background()
|
||||
items, err := m.loader.List(ctx, subject)
|
||||
return listLoadedMsg{items: items, subject: subject, err: err}
|
||||
items, err := m.loader.List(ctx, spec)
|
||||
return listLoadedMsg{items: items, spec: spec, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) loadPreviewCmd() tea.Cmd {
|
||||
if m.listIdx < 0 || m.listIdx >= len(m.items) {
|
||||
return nil
|
||||
}
|
||||
item := m.items[m.listIdx]
|
||||
func (m *Model) executeActionCmd(action model.ActionID, item model.Item) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
raw, err := m.loader.Detail(ctx, item)
|
||||
if err != nil {
|
||||
return previewLoadedMsg{err: err}
|
||||
}
|
||||
md := preview.EntityMarkdown(string(item.Kind), raw)
|
||||
return previewLoadedMsg{text: md}
|
||||
msg, err := m.loader.Execute(ctx, action, item, "")
|
||||
return actionDoneMsg{message: msg, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) openVexCmd() tea.Cmd {
|
||||
if m.listIdx < 0 || m.listIdx >= len(m.items) {
|
||||
return nil
|
||||
}
|
||||
item := m.items[m.listIdx]
|
||||
if item.Kind != model.KindFile {
|
||||
func (m *Model) loadNavProjectsCmd() tea.Cmd {
|
||||
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
|
||||
}
|
||||
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 {
|
||||
@@ -326,14 +668,5 @@ func paneStyle(focused bool) lipgloss.Style {
|
||||
}
|
||||
|
||||
func helpText() string {
|
||||
return "Tab: pane | j/k: move | Space: select | Enter: preview | r: refresh | v: vex | q: quit"
|
||||
}
|
||||
|
||||
// OpenVex launches the vex-tui binary for a file path when available.
|
||||
func OpenVex(path string) error {
|
||||
if _, err := exec.LookPath("vex"); err != nil {
|
||||
return fmt.Errorf("vex not found on PATH")
|
||||
}
|
||||
cmd := exec.Command("vex", path)
|
||||
return cmd.Run()
|
||||
return "Alt+1/2/3: toggle panes · Tab: focus · v: detail · Ctrl+S: save · q: quit"
|
||||
}
|
||||
|
||||
@@ -13,30 +13,27 @@ func TestKeyActionMenuDown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyActionListToggleSelect(t *testing.T) {
|
||||
if got := ui.KeyAction(" ", model.FocusList); got != ui.ActionToggleSelect {
|
||||
func TestKeyActionShiftTabPrevPane(t *testing.T) {
|
||||
if got := ui.KeyAction("shift+tab", model.FocusList); got != ui.ActionPrevPane {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyActionPreviewNoOp(t *testing.T) {
|
||||
if got := ui.KeyAction(" ", model.FocusPreview); got != ui.ActionNone {
|
||||
t.Fatalf("got %v", got)
|
||||
func TestKeyActionTabOnPreviewDoesNotSwitchPane(t *testing.T) {
|
||||
if got := ui.KeyAction("tab", model.FocusPreview); got != ui.ActionNone {
|
||||
t.Fatalf("tab on preview should stay in pane, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyActionTabNextPane(t *testing.T) {
|
||||
if got := ui.KeyAction("tab", model.FocusList); got != ui.ActionNextPane {
|
||||
func TestKeyActionSortAndColumns(t *testing.T) {
|
||||
if got := ui.KeyAction("s", model.FocusList); got != ui.ActionSort {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutWidths(t *testing.T) {
|
||||
menu, list, preview := ui.LayoutWidths(120)
|
||||
if menu+list+preview > 120 {
|
||||
menu, list, preview := ui.LayoutWidthsLegacy(120)
|
||||
if menu+list+preview != 120 {
|
||||
t.Fatalf("widths exceed total: %d+%d+%d", menu, list, preview)
|
||||
}
|
||||
if menu < 20 || list < 30 || preview < 30 {
|
||||
t.Fatalf("widths too narrow: %d %d %d", menu, list, preview)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -9,12 +9,18 @@ const (
|
||||
ActionNone Action = iota
|
||||
ActionMoveUp
|
||||
ActionMoveDown
|
||||
ActionMoveLeft
|
||||
ActionMoveRight
|
||||
ActionSort
|
||||
ActionToggleSelect
|
||||
ActionOpenPreview
|
||||
ActionFocusDetail
|
||||
ActionToggleMenuPane
|
||||
ActionToggleListPane
|
||||
ActionToggleDetailPane
|
||||
ActionNextPane
|
||||
ActionPrevPane
|
||||
ActionRefresh
|
||||
ActionQuit
|
||||
ActionOpenVex
|
||||
)
|
||||
|
||||
// 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":
|
||||
return ActionQuit
|
||||
case "tab":
|
||||
if pane != model.FocusPreview {
|
||||
return ActionNextPane
|
||||
}
|
||||
case "shift+tab", "backtab":
|
||||
if pane != model.FocusPreview {
|
||||
return ActionPrevPane
|
||||
}
|
||||
case "r":
|
||||
return ActionRefresh
|
||||
case "up", "k":
|
||||
return ActionMoveUp
|
||||
case "down", "j":
|
||||
return ActionMoveDown
|
||||
case "s":
|
||||
if pane == model.FocusList {
|
||||
return ActionSort
|
||||
}
|
||||
case "left", "h":
|
||||
return ActionMoveLeft
|
||||
case "right", "l":
|
||||
return ActionMoveRight
|
||||
case " ":
|
||||
if pane == model.FocusList {
|
||||
return ActionToggleSelect
|
||||
}
|
||||
case "enter":
|
||||
case "v", "p":
|
||||
if pane == model.FocusList {
|
||||
return ActionOpenPreview
|
||||
}
|
||||
case "v":
|
||||
if pane == model.FocusList {
|
||||
return ActionOpenVex
|
||||
return ActionFocusDetail
|
||||
}
|
||||
case "alt+1":
|
||||
return ActionToggleMenuPane
|
||||
case "alt+2":
|
||||
return ActionToggleListPane
|
||||
case "alt+3":
|
||||
return ActionToggleDetailPane
|
||||
}
|
||||
return ActionNone
|
||||
}
|
||||
|
||||
// LayoutWidths splits total terminal width into menu, list, preview columns.
|
||||
func LayoutWidths(total int) (menu, list, preview int) {
|
||||
if total < 80 {
|
||||
total = 80
|
||||
}
|
||||
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
|
||||
// LayoutWidthsLegacy is kept for tests that expect the old signature.
|
||||
func LayoutWidthsLegacy(total int) (menu, list, preview int) {
|
||||
pw := LayoutWidths(total, defaultPaneVisibility())
|
||||
return pw.Menu, pw.List, pw.Detail
|
||||
}
|
||||
|
||||
// ResolveMoveUp returns the action for upward navigation keys.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user