Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eed57a68d4 | ||
|
|
b2aae21390 | ||
|
|
3d4aa10cf9 | ||
|
|
3b47d1400b |
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.5.0"
|
".": "0.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-46
@@ -4,6 +4,18 @@ All notable changes to this project are documented here. The format is based on
|
|||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
|
||||||
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.6.0](https://github.com/eSlider/go-onlyoffice/compare/v0.5.0...v0.6.0) (2026-06-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **office:** table detail panes and Alt+1/2/3 layout toggles ([934af21](https://github.com/eSlider/go-onlyoffice/commit/934af21bc93a54df9f40aacb4d89468b00d7562e))
|
||||||
|
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* CHANGELOG for office v0.5.1 release ([2985070](https://github.com/eSlider/go-onlyoffice/commit/29850701f8c9ab0c6d6427f63eaa189a1c558140))
|
||||||
|
|
||||||
## [0.5.0](https://github.com/eSlider/go-onlyoffice/compare/v0.4.0...v0.5.0) (2026-06-24)
|
## [0.5.0](https://github.com/eSlider/go-onlyoffice/compare/v0.4.0...v0.5.0) (2026-06-24)
|
||||||
|
|
||||||
|
|
||||||
@@ -40,56 +52,20 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.5.1] — 2026-06-24
|
||||||
|
|
||||||
### Added — `office` TUI
|
### Added — `office` TUI
|
||||||
|
|
||||||
- **`cmd/office`** — Bubble Tea terminal UI mirroring OnlyOffice Workspace:
|
- **Alt+1 / Alt+2 / Alt+3** — show/hide left (nav), middle (table), and right (detail) panes.
|
||||||
three-pane layout (module tree, multi-select list, markdown preview).
|
- Visible panes share **100% terminal width** evenly; pane content fills its column.
|
||||||
Install: `go install github.com/eslider/go-onlyoffice/cmd/office@latest`.
|
- Tab focus skips hidden panes.
|
||||||
- **`cmd/internal/bootstrap`** — shared env loading and client auth for `oo`
|
|
||||||
and `office`.
|
|
||||||
- Integration tests under `cmd/office/fetch/` and `cmd/office/preview/`
|
|
||||||
(`-tags=integration`) hit the live OnlyOffice API — no fixture maps or
|
|
||||||
httptest vendor mocks. Unit tests remain for pure UI/formatting helpers only.
|
|
||||||
|
|
||||||
### Added — library
|
### Changed — `office` TUI
|
||||||
|
|
||||||
- **CRM dedupe & cleanup** — `crm_normalize.go`, `crm_dedupe_plan.go`, `crm_dedupe.go`:
|
- Split detail pane: form/document top (~72%), CRUD action bar bottom.
|
||||||
merge duplicate companies/persons/deals by normalized name; remove duplicate
|
- Middle pane: multi-column table with sort, selection, and full-width columns.
|
||||||
contact-info rows and opportunity members (including same `displayName` with
|
- Project list columns: ID, Title, Tasks (open/closed), Documents, Users.
|
||||||
different ids); fix malformed deal titles (` @ Company` → `Company`).
|
- Row selection auto-loads detail; files show document preview, entities show forms.
|
||||||
`MergeContacts`, `UpdateOpportunityTitle`, `ListAllContacts`,
|
|
||||||
`ListAllOpportunities`, `DeleteContactInfo`, `RemoveOpportunityMember`,
|
|
||||||
`IsOpportunityMember`, `HasContactInfo`, `CleanupCRM`.
|
|
||||||
- Slogan-aware grouping: `StripSloganSuffix`, `CompanyGroupingKey` merge names
|
|
||||||
like `Affirm` and `Affirm — Fraud Engineering` (companies, deals, members).
|
|
||||||
- **Workspace mail** — [`mails.go`](mails.go): `ListMailAccounts`, `ListMailFolders`,
|
|
||||||
`ListMailMessages`, `GetMailMessage`, `RemoveMailMessages` against OnlyOffice
|
|
||||||
Mail addon (`/api/2.0/mail/*`).
|
|
||||||
- [`crm_integration_test.go`](crm_integration_test.go) — live merge, rename,
|
|
||||||
dedupe smoke tests.
|
|
||||||
|
|
||||||
### Added — CLI
|
|
||||||
|
|
||||||
- `oo mails accounts|folders|list|get|delete` — OnlyOffice Workspace mail (same `ONLYOFFICE_*` creds).
|
|
||||||
- `oo companies dedupe|dedupe-persons`, `oo persons dedupe`, `oo contacts dedupe-info`,
|
|
||||||
`oo opportunities dedupe|dedupe-members|fix-titles`, `oo crm cleanup`.
|
|
||||||
|
|
||||||
### Added — CI/CD
|
|
||||||
|
|
||||||
- GitHub Actions: `test.yml` (vet, unit tests, `oo` build), `release-please.yml`
|
|
||||||
(semver from conventional commits), `release.yml` (GoReleaser binaries for `oo`).
|
|
||||||
|
|
||||||
### Changed — CLI
|
|
||||||
|
|
||||||
- README: expanded **oo CLI use cases** (CRM cleanup, applications sync, mail, contacts).
|
|
||||||
- `oo applications sync` — empty position uses company-only deal title; skips
|
|
||||||
duplicate opportunity members; paginated find-or-create lookups.
|
|
||||||
- `oo` now loads OnlyOffice credentials only from `.env` in the current
|
|
||||||
working directory.
|
|
||||||
- Produktor.io shorthand env names are accepted by the CLI dotenv loader:
|
|
||||||
`OO_URL` → `ONLYOFFICE_URL`, `OO_USER` → `ONLYOFFICE_USER`, and
|
|
||||||
`OO_PASS` → `ONLYOFFICE_PASS`. Canonical `ONLYOFFICE_*` values still win
|
|
||||||
when already set in the process environment.
|
|
||||||
|
|
||||||
## [0.6.0] - 2026-04-24
|
## [0.6.0] - 2026-04-24
|
||||||
|
|
||||||
|
|||||||
@@ -244,13 +244,24 @@ func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) {
|
|||||||
if p.Title != nil {
|
if p.Title != nil {
|
||||||
title = *p.Title
|
title = *p.Title
|
||||||
}
|
}
|
||||||
status := ""
|
raw := map[string]any{
|
||||||
if p.Status != nil {
|
"id": id,
|
||||||
status = fmt.Sprint(*p.Status)
|
"title": title,
|
||||||
|
}
|
||||||
|
if p.TaskCount != nil {
|
||||||
|
raw["taskCount"] = *p.TaskCount
|
||||||
|
}
|
||||||
|
if p.TaskCountTotal != nil {
|
||||||
|
raw["taskCountTotal"] = *p.TaskCountTotal
|
||||||
|
}
|
||||||
|
if p.DocumentsCount != nil {
|
||||||
|
raw["documentsCount"] = *p.DocumentsCount
|
||||||
|
}
|
||||||
|
if p.ParticipantCount != nil {
|
||||||
|
raw["participantCount"] = *p.ParticipantCount
|
||||||
}
|
}
|
||||||
items[i] = model.Item{
|
items[i] = model.Item{
|
||||||
ID: id, Title: title, Subtitle: status, Kind: model.KindProject,
|
ID: id, Title: title, Kind: model.KindProject, Raw: raw,
|
||||||
Raw: map[string]any{"id": id, "title": title, "status": status},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
|
|||||||
@@ -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)
|
m := ui.NewModel(client)
|
||||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
+12
-12
@@ -5,6 +5,7 @@ type ActionID string
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
ActionView ActionID = "view"
|
ActionView ActionID = "view"
|
||||||
|
ActionSave ActionID = "save"
|
||||||
ActionDelete ActionID = "delete"
|
ActionDelete ActionID = "delete"
|
||||||
ActionRefresh ActionID = "refresh"
|
ActionRefresh ActionID = "refresh"
|
||||||
ActionDownload ActionID = "download"
|
ActionDownload ActionID = "download"
|
||||||
@@ -17,48 +18,47 @@ type ItemAction struct {
|
|||||||
Danger bool
|
Danger bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActionsFor returns available operations for an item kind.
|
// ActionsFor returns available operations for an item kind (detail pane action bar).
|
||||||
func ActionsFor(kind Kind) []ItemAction {
|
func ActionsFor(kind Kind) []ItemAction {
|
||||||
switch kind {
|
switch kind {
|
||||||
case KindProject:
|
case KindProject:
|
||||||
return []ItemAction{
|
return []ItemAction{
|
||||||
{ID: ActionView, Label: "View details"},
|
{ID: ActionSave, Label: "Save"},
|
||||||
{ID: ActionDelete, Label: "Delete project", Danger: true},
|
{ID: ActionDelete, Label: "Delete", Danger: true},
|
||||||
}
|
}
|
||||||
case KindTask, KindCRMTask:
|
case KindTask:
|
||||||
return []ItemAction{
|
return []ItemAction{
|
||||||
{ID: ActionView, Label: "View details"},
|
{ID: ActionSave, Label: "Save"},
|
||||||
{ID: ActionDelete, Label: "Delete task", Danger: true},
|
{ID: ActionDelete, Label: "Delete task", Danger: true},
|
||||||
}
|
}
|
||||||
|
case KindCRMTask:
|
||||||
|
return []ItemAction{
|
||||||
|
{ID: ActionDelete, Label: "Delete", Danger: true},
|
||||||
|
}
|
||||||
case KindContact:
|
case KindContact:
|
||||||
return []ItemAction{
|
return []ItemAction{
|
||||||
{ID: ActionView, Label: "View details"},
|
|
||||||
{ID: ActionDelete, Label: "Delete contact", Danger: true},
|
{ID: ActionDelete, Label: "Delete contact", Danger: true},
|
||||||
}
|
}
|
||||||
case KindOpportunity:
|
case KindOpportunity:
|
||||||
return []ItemAction{
|
return []ItemAction{
|
||||||
{ID: ActionView, Label: "View details"},
|
|
||||||
{ID: ActionDelete, Label: "Delete deal", Danger: true},
|
{ID: ActionDelete, Label: "Delete deal", Danger: true},
|
||||||
}
|
}
|
||||||
case KindCase:
|
case KindCase:
|
||||||
return []ItemAction{
|
return []ItemAction{
|
||||||
{ID: ActionView, Label: "View details"},
|
|
||||||
{ID: ActionDelete, Label: "Delete case", Danger: true},
|
{ID: ActionDelete, Label: "Delete case", Danger: true},
|
||||||
}
|
}
|
||||||
case KindMail:
|
case KindMail:
|
||||||
return []ItemAction{
|
return []ItemAction{
|
||||||
{ID: ActionView, Label: "Read message"},
|
|
||||||
{ID: ActionDelete, Label: "Delete message", Danger: true},
|
{ID: ActionDelete, Label: "Delete message", Danger: true},
|
||||||
}
|
}
|
||||||
case KindFile:
|
case KindFile:
|
||||||
return []ItemAction{
|
return []ItemAction{
|
||||||
{ID: ActionView, Label: "Preview file"},
|
|
||||||
{ID: ActionDownload, Label: "Download"},
|
{ID: ActionDownload, Label: "Download"},
|
||||||
{ID: ActionDelete, Label: "Delete file", Danger: true},
|
{ID: ActionDelete, Label: "Delete file", Danger: true},
|
||||||
}
|
}
|
||||||
case KindEvent, KindCalendar, KindUser:
|
case KindEvent, KindCalendar, KindUser:
|
||||||
return []ItemAction{{ID: ActionView, Label: "View details"}}
|
return nil
|
||||||
default:
|
default:
|
||||||
return []ItemAction{{ID: ActionView, Label: "View details"}}
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,7 +69,7 @@ func TestPrevFocusPane(t *testing.T) {
|
|||||||
|
|
||||||
func TestActionsForContact(t *testing.T) {
|
func TestActionsForContact(t *testing.T) {
|
||||||
acts := model.ActionsFor(model.KindContact)
|
acts := model.ActionsFor(model.KindContact)
|
||||||
if len(acts) < 2 {
|
if len(acts) != 1 || acts[0].ID != model.ActionDelete {
|
||||||
t.Fatalf("expected view+delete, got %d", len(acts))
|
t.Fatalf("expected delete only, got %v", acts)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,6 +151,31 @@ func TestIntegrationPreviewTaskFromAPI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestIntegrationPreviewCSVFromDownloadedFile(t *testing.T) {
|
||||||
skipWithoutLiveAPI(t)
|
skipWithoutLiveAPI(t)
|
||||||
if os.Getenv("ONLYOFFICE_PROJECT_ID") == "" {
|
if os.Getenv("ONLYOFFICE_PROJECT_ID") == "" {
|
||||||
|
|||||||
+373
-222
@@ -2,7 +2,6 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
@@ -11,7 +10,6 @@ import (
|
|||||||
onlyoffice "github.com/eslider/go-onlyoffice"
|
onlyoffice "github.com/eslider/go-onlyoffice"
|
||||||
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
|
"github.com/eslider/go-onlyoffice/cmd/office/fetch"
|
||||||
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
"github.com/eslider/go-onlyoffice/cmd/office/model"
|
||||||
"github.com/eslider/go-onlyoffice/cmd/office/preview"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type listLoadedMsg struct {
|
type listLoadedMsg struct {
|
||||||
@@ -20,9 +18,18 @@ type listLoadedMsg struct {
|
|||||||
err error
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
type previewLoadedMsg struct {
|
type detailLoadedMsg struct {
|
||||||
text string
|
item model.Item
|
||||||
err error
|
document bool
|
||||||
|
fields model.FormFields
|
||||||
|
markdown string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type detailSavedMsg struct {
|
||||||
|
item model.Item
|
||||||
|
title, description string
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
type actionDoneMsg struct {
|
type actionDoneMsg struct {
|
||||||
@@ -44,7 +51,6 @@ type Model struct {
|
|||||||
hasList bool
|
hasList bool
|
||||||
items []model.Item
|
items []model.Item
|
||||||
selection *model.Selection
|
selection *model.Selection
|
||||||
listIdx int
|
|
||||||
focus model.FocusPane
|
focus model.FocusPane
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
@@ -52,29 +58,36 @@ type Model struct {
|
|||||||
err string
|
err string
|
||||||
loading bool
|
loading bool
|
||||||
menuVP viewport.Model
|
menuVP viewport.Model
|
||||||
listVP viewport.Model
|
listTable DataTable
|
||||||
previewVP viewport.Model
|
detail DetailPane
|
||||||
previewMD string
|
showMenu bool
|
||||||
// Action menu overlay on list pane
|
showList bool
|
||||||
actionMode bool
|
showDetail bool
|
||||||
actionIdx int
|
|
||||||
itemActions []model.ItemAction
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewModel constructs the TUI with an authenticated client.
|
// NewModel constructs the TUI with an authenticated client.
|
||||||
func NewModel(client *onlyoffice.Client) Model {
|
func NewModel(client *onlyoffice.Client) Model {
|
||||||
return Model{
|
h, w := 24, 80
|
||||||
|
m := Model{
|
||||||
client: client,
|
client: client,
|
||||||
loader: &fetch.Loader{Client: client},
|
loader: &fetch.Loader{Client: client},
|
||||||
nav: model.DefaultNavTree(),
|
nav: model.DefaultNavTree(),
|
||||||
selection: model.NewSelection(),
|
selection: model.NewSelection(),
|
||||||
focus: model.FocusMenu,
|
focus: model.FocusMenu,
|
||||||
status: "Tab/Shift+Tab: pane · Enter: open/activate · a: actions · 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
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
return m.loadNavProjectsCmd()
|
return tea.Batch(m.loadNavProjectsCmd(), m.detail.BlinkCmd())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
@@ -92,8 +105,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.status = helpText()
|
m.status = helpText()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if m.actionMode && m.focus == model.FocusList {
|
if m.scrollFocusedPane(key) {
|
||||||
return m.handleActionKey(key)
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.focus == model.FocusPreview {
|
||||||
|
if cmd, handled := m.handleDetailKey(key, msg); handled {
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
}
|
}
|
||||||
action := KeyAction(key, m.focus)
|
action := KeyAction(key, m.focus)
|
||||||
if action == ActionNone && (key == "up" || key == "k") {
|
if action == ActionNone && (key == "up" || key == "k") {
|
||||||
@@ -103,36 +121,75 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case ActionQuit:
|
case ActionQuit:
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case ActionNextPane:
|
case ActionNextPane:
|
||||||
m.focus = model.NextFocusPane(m.focus)
|
m.focus = NextVisibleFocus(m.focus, m.paneVis())
|
||||||
|
m.syncPaneFocus()
|
||||||
return m, nil
|
return m, nil
|
||||||
case ActionPrevPane:
|
case ActionPrevPane:
|
||||||
m.focus = model.PrevFocusPane(m.focus)
|
m.focus = PrevVisibleFocus(m.focus, m.paneVis())
|
||||||
|
m.syncPaneFocus()
|
||||||
return m, nil
|
return m, nil
|
||||||
case ActionMoveUp:
|
case ActionMoveUp:
|
||||||
|
if m.focus == model.FocusPreview {
|
||||||
|
return m.handleDetailMove(-1)
|
||||||
|
}
|
||||||
m.moveUp()
|
m.moveUp()
|
||||||
m.syncAllContent()
|
m.syncFocusedPane()
|
||||||
return m, nil
|
return m, m.onListRowChanged()
|
||||||
case ActionMoveDown:
|
case ActionMoveDown:
|
||||||
|
if m.focus == model.FocusPreview {
|
||||||
|
return m.handleDetailMove(1)
|
||||||
|
}
|
||||||
m.moveDown()
|
m.moveDown()
|
||||||
m.syncAllContent()
|
m.syncFocusedPane()
|
||||||
|
return m, m.onListRowChanged()
|
||||||
|
case ActionMoveLeft:
|
||||||
|
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
|
||||||
|
m.detail.MoveAction(-1)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.focus == model.FocusList && m.hasList {
|
||||||
|
m.listTable.MoveCol(-1)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case ActionMoveRight:
|
||||||
|
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
|
||||||
|
m.detail.MoveAction(1)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if m.focus == model.FocusList && m.hasList {
|
||||||
|
m.listTable.MoveCol(1)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
case ActionSort:
|
||||||
|
if m.focus == model.FocusList && m.hasList {
|
||||||
|
m.listTable.ToggleSort()
|
||||||
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case ActionToggleSelect:
|
case ActionToggleSelect:
|
||||||
if m.hasList {
|
if m.hasList {
|
||||||
m.selection.Toggle(&m.items, m.listIdx)
|
idx := m.listTable.ItemIndex()
|
||||||
m.syncListContent()
|
if idx >= 0 {
|
||||||
|
m.selection.Toggle(&m.items, idx)
|
||||||
|
m.listTable.UpdateItems(m.items)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case ActionOpenActions:
|
case ActionFocusDetail:
|
||||||
if m.hasList && len(m.items) > 0 {
|
if m.hasList && m.showDetail {
|
||||||
m.openActionMenu()
|
m.focus = model.FocusPreview
|
||||||
m.syncListContent()
|
m.syncPaneFocus()
|
||||||
|
m.detail.FocusContent()
|
||||||
|
return m, m.onListRowChanged()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case ActionOpenPreview:
|
case ActionToggleMenuPane:
|
||||||
if m.hasList {
|
m.togglePane(1)
|
||||||
m.selection.Toggle(&m.items, m.listIdx)
|
return m, nil
|
||||||
return m, m.loadPreviewCmd()
|
case ActionToggleListPane:
|
||||||
}
|
m.togglePane(2)
|
||||||
|
return m, nil
|
||||||
|
case ActionToggleDetailPane:
|
||||||
|
m.togglePane(3)
|
||||||
return m, nil
|
return m, nil
|
||||||
case ActionRefresh:
|
case ActionRefresh:
|
||||||
if m.hasList {
|
if m.hasList {
|
||||||
@@ -162,11 +219,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.focus == model.FocusList && key == "enter" && m.hasList {
|
|
||||||
m.openActionMenu()
|
|
||||||
m.syncListContent()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
case listLoadedMsg:
|
case listLoadedMsg:
|
||||||
m.loading = false
|
m.loading = false
|
||||||
@@ -174,29 +226,56 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.err = msg.err.Error()
|
m.err = msg.err.Error()
|
||||||
m.items = nil
|
m.items = nil
|
||||||
m.hasList = false
|
m.hasList = false
|
||||||
|
m.listTable.Clear()
|
||||||
|
m.detail.Clear()
|
||||||
} else {
|
} else {
|
||||||
m.err = ""
|
m.err = ""
|
||||||
m.items = msg.items
|
m.items = msg.items
|
||||||
m.listSpec = msg.spec
|
m.listSpec = msg.spec
|
||||||
m.hasList = true
|
m.hasList = true
|
||||||
m.listIdx = 0
|
m.listTable.SetData(m.listSpec, m.items)
|
||||||
}
|
}
|
||||||
m.syncListContent()
|
return m, m.onListRowChanged()
|
||||||
return m, nil
|
|
||||||
|
|
||||||
case previewLoadedMsg:
|
case detailLoadedMsg:
|
||||||
m.loading = false
|
m.loading = false
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.previewMD = fmt.Sprintf("# Error\n\n%s\n", msg.err.Error())
|
m.err = msg.err.Error()
|
||||||
|
m.detail.Clear()
|
||||||
} else {
|
} else {
|
||||||
m.previewMD = msg.text
|
pw := m.paneLayout()
|
||||||
|
w := pw.Detail - 4
|
||||||
|
if w < 20 {
|
||||||
|
w = 20
|
||||||
|
}
|
||||||
|
if msg.document {
|
||||||
|
m.detail.LoadDocument(msg.item, msg.markdown, w)
|
||||||
|
} else {
|
||||||
|
m.detail.LoadForm(msg.item, msg.fields)
|
||||||
|
}
|
||||||
|
m.detail.SetFocused(m.focus == model.FocusPreview)
|
||||||
|
m.err = ""
|
||||||
|
}
|
||||||
|
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 = ""
|
||||||
}
|
}
|
||||||
m.syncPreviewContent()
|
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case actionDoneMsg:
|
case actionDoneMsg:
|
||||||
m.loading = false
|
m.loading = false
|
||||||
m.actionMode = false
|
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
m.err = msg.err.Error()
|
m.err = msg.err.Error()
|
||||||
} else {
|
} else {
|
||||||
@@ -215,6 +294,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.syncMenuContent()
|
m.syncMenuContent()
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
|
case tea.MouseMsg:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
switch m.focus {
|
||||||
|
case model.FocusMenu:
|
||||||
|
m.menuVP, cmd = m.menuVP.Update(msg)
|
||||||
|
case model.FocusList:
|
||||||
|
cmd = m.listTable.Update(msg)
|
||||||
|
case model.FocusPreview:
|
||||||
|
cmd = m.detail.Update(msg)
|
||||||
|
}
|
||||||
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
@@ -222,22 +313,72 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case model.FocusMenu:
|
case model.FocusMenu:
|
||||||
m.menuVP, cmd = m.menuVP.Update(msg)
|
m.menuVP, cmd = m.menuVP.Update(msg)
|
||||||
case model.FocusList:
|
case model.FocusList:
|
||||||
m.listVP, cmd = m.listVP.Update(msg)
|
cmd = m.listTable.Update(msg)
|
||||||
case model.FocusPreview:
|
case model.FocusPreview:
|
||||||
m.previewVP, cmd = m.previewVP.Update(msg)
|
cmd = m.detail.Update(msg)
|
||||||
}
|
}
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
|
||||||
|
switch key {
|
||||||
|
case "tab", "shift+tab", "backtab":
|
||||||
|
m.detail.ToggleZone()
|
||||||
|
return nil, true
|
||||||
|
case "ctrl+s":
|
||||||
|
m.loading = true
|
||||||
|
return m.saveDetailCmd(), true
|
||||||
|
case "enter":
|
||||||
|
if m.detail.Zone() == detailZoneActions {
|
||||||
|
act, ok := m.detail.SelectedAction()
|
||||||
|
if !ok {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
m.loading = true
|
||||||
|
if act.ID == model.ActionSave {
|
||||||
|
return m.saveDetailCmd(), true
|
||||||
|
}
|
||||||
|
return m.executeActionCmd(act.ID, m.detail.Item()), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.detail.Zone() == detailZoneContent {
|
||||||
|
return m.detail.Update(msg), true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) handleDetailMove(delta int) (Model, tea.Cmd) {
|
||||||
|
if m.detail.Zone() == detailZoneActions {
|
||||||
|
m.detail.MoveAction(delta)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if delta < 0 {
|
||||||
|
m.detail.form.FocusPrev()
|
||||||
|
} else {
|
||||||
|
m.detail.form.FocusNext()
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m Model) View() string {
|
func (m Model) View() string {
|
||||||
if m.width == 0 {
|
if m.width == 0 {
|
||||||
return "Loading…\n"
|
return "Loading…\n"
|
||||||
}
|
}
|
||||||
menuW, listW, prevW := LayoutWidths(m.width)
|
pw := m.paneLayout()
|
||||||
h := m.height - 2
|
h := m.paneHeight() + 2
|
||||||
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(menuW).Height(h)
|
var parts []string
|
||||||
listStyle := paneStyle(m.focus == model.FocusList).Width(listW).Height(h)
|
if pw.Visibility.Menu {
|
||||||
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(prevW).Height(h)
|
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(pw.Menu).Height(h)
|
||||||
|
parts = append(parts, menuStyle.Render(m.menuVP.View()))
|
||||||
|
}
|
||||||
|
if pw.Visibility.List {
|
||||||
|
listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h)
|
||||||
|
parts = append(parts, listStyle.Render(m.listTable.View()))
|
||||||
|
}
|
||||||
|
if pw.Visibility.Detail {
|
||||||
|
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(pw.Detail).Height(h)
|
||||||
|
parts = append(parts, prevStyle.Render(m.detail.View()))
|
||||||
|
}
|
||||||
|
|
||||||
status := m.status
|
status := m.status
|
||||||
if m.loading {
|
if m.loading {
|
||||||
@@ -248,93 +389,98 @@ func (m Model) View() string {
|
|||||||
}
|
}
|
||||||
bar := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(status)
|
bar := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(status)
|
||||||
|
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top,
|
return lipgloss.JoinHorizontal(lipgloss.Top, parts...) + "\n" + bar
|
||||||
menuStyle.Render(m.menuVP.View()),
|
}
|
||||||
listStyle.Render(m.listVP.View()),
|
|
||||||
prevStyle.Render(m.previewVP.View()),
|
func (m Model) paneVis() PaneVisibility {
|
||||||
) + "\n" + bar
|
return PaneVisibility{Menu: m.showMenu, List: m.showList, Detail: m.showDetail}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m Model) paneLayout() PaneWidths {
|
||||||
|
return LayoutWidths(m.width, m.paneVis())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) syncPaneFocus() {
|
||||||
|
m.listTable.SetFocused(m.focus == model.FocusList && m.showList)
|
||||||
|
m.detail.SetFocused(m.focus == model.FocusPreview && m.showDetail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) togglePane(which int) {
|
||||||
|
switch which {
|
||||||
|
case 1:
|
||||||
|
m.showMenu = !m.showMenu
|
||||||
|
case 2:
|
||||||
|
m.showList = !m.showList
|
||||||
|
case 3:
|
||||||
|
m.showDetail = !m.showDetail
|
||||||
|
}
|
||||||
|
if !m.showMenu && !m.showList && !m.showDetail {
|
||||||
|
switch which {
|
||||||
|
case 1:
|
||||||
|
m.showMenu = true
|
||||||
|
case 2:
|
||||||
|
m.showList = true
|
||||||
|
case 3:
|
||||||
|
m.showDetail = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !paneVisible(m.focus, m.paneVis()) {
|
||||||
|
m.focus = firstVisibleFocus(m.paneVis())
|
||||||
|
}
|
||||||
|
m.layoutViewports()
|
||||||
|
m.syncAllContent()
|
||||||
|
m.syncPaneFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) withList(spec model.ListSpec) (Model, tea.Cmd) {
|
func (m Model) withList(spec model.ListSpec) (Model, tea.Cmd) {
|
||||||
m.listSpec = spec
|
m.listSpec = spec
|
||||||
m.selection.Clear()
|
m.selection.Clear()
|
||||||
m.listIdx = 0
|
|
||||||
m.hasList = false
|
m.hasList = false
|
||||||
m.actionMode = false
|
|
||||||
m.focus = model.FocusList
|
m.focus = model.FocusList
|
||||||
|
m.listTable.SetFocused(true)
|
||||||
|
m.detail.Clear()
|
||||||
m.loading = true
|
m.loading = true
|
||||||
return m, m.loadListCmd(spec)
|
return m, m.loadListCmd(spec)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) openActionMenu() {
|
|
||||||
if m.listIdx < 0 || m.listIdx >= len(m.items) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
m.itemActions = model.ActionsFor(m.items[m.listIdx].Kind)
|
|
||||||
m.actionIdx = 0
|
|
||||||
m.actionMode = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) handleActionKey(key string) (tea.Model, tea.Cmd) {
|
|
||||||
switch key {
|
|
||||||
case "esc":
|
|
||||||
m.actionMode = false
|
|
||||||
m.syncListContent()
|
|
||||||
return m, nil
|
|
||||||
case "up", "k":
|
|
||||||
if m.actionIdx > 0 {
|
|
||||||
m.actionIdx--
|
|
||||||
}
|
|
||||||
m.syncListContent()
|
|
||||||
return m, nil
|
|
||||||
case "down", "j":
|
|
||||||
if m.actionIdx < len(m.itemActions)-1 {
|
|
||||||
m.actionIdx++
|
|
||||||
}
|
|
||||||
m.syncListContent()
|
|
||||||
return m, nil
|
|
||||||
case "enter":
|
|
||||||
if m.listIdx < 0 || m.listIdx >= len(m.items) || m.actionIdx >= len(m.itemActions) {
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
act := m.itemActions[m.actionIdx]
|
|
||||||
item := m.items[m.listIdx]
|
|
||||||
m.actionMode = false
|
|
||||||
if act.ID == model.ActionView {
|
|
||||||
return m, m.loadPreviewCmd()
|
|
||||||
}
|
|
||||||
m.loading = true
|
|
||||||
return m, m.executeActionCmd(act.ID, item)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) layoutViewports() {
|
func (m *Model) layoutViewports() {
|
||||||
menuW, listW, prevW := LayoutWidths(m.width)
|
pw := m.paneLayout()
|
||||||
h := m.height - 4
|
h := m.paneHeight()
|
||||||
if h < 4 {
|
if pw.Visibility.Menu {
|
||||||
h = 4
|
m.menuVP.Width = m.paneInnerWidth(pw.Menu)
|
||||||
|
m.menuVP.Height = h
|
||||||
}
|
}
|
||||||
inner := func(w int) int {
|
if pw.Visibility.List {
|
||||||
if w > 4 {
|
m.listTable.SetSize(m.paneInnerWidth(pw.List), h)
|
||||||
return w - 2
|
}
|
||||||
}
|
if pw.Visibility.Detail {
|
||||||
return w
|
m.detail.SetSize(m.paneInnerWidth(pw.Detail), h)
|
||||||
}
|
}
|
||||||
m.menuVP = viewport.New(inner(menuW), h)
|
|
||||||
m.listVP = viewport.New(inner(listW), h)
|
|
||||||
m.previewVP = viewport.New(inner(prevW), h)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) syncAllContent() {
|
func (m *Model) syncAllContent() {
|
||||||
m.syncMenuContent()
|
m.syncMenuContent()
|
||||||
m.syncListContent()
|
m.syncListTable()
|
||||||
m.syncPreviewContent()
|
}
|
||||||
|
|
||||||
|
func (m *Model) syncListTable() {
|
||||||
|
if !m.hasList {
|
||||||
|
m.listTable.Clear()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.listTable.UpdateItems(m.items)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) syncMenuContent() {
|
func (m *Model) syncMenuContent() {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("Navigation\n\n")
|
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++ {
|
for i := 0; i < m.nav.VisibleCount(); i++ {
|
||||||
n, _ := m.nav.NodeAtVisible(i)
|
n, _ := m.nav.NodeAtVisible(i)
|
||||||
depth := m.nav.DepthAtVisible(i)
|
depth := m.nav.DepthAtVisible(i)
|
||||||
@@ -350,9 +496,7 @@ func (m *Model) syncMenuContent() {
|
|||||||
line = prefix + "• " + n.Label
|
line = prefix + "• " + n.Label
|
||||||
}
|
}
|
||||||
if i == m.nav.Cursor() && m.focus == model.FocusMenu {
|
if i == m.nav.Cursor() && m.focus == model.FocusMenu {
|
||||||
line = "> " + line
|
line = selectedStyle.Width(menuW).Render(truncateRunes(line, menuW))
|
||||||
} else {
|
|
||||||
line = " " + line
|
|
||||||
}
|
}
|
||||||
b.WriteString(line + "\n")
|
b.WriteString(line + "\n")
|
||||||
}
|
}
|
||||||
@@ -360,78 +504,63 @@ func (m *Model) syncMenuContent() {
|
|||||||
syncVPToLine(&m.menuVP, m.nav.Cursor()+2)
|
syncVPToLine(&m.menuVP, m.nav.Cursor()+2)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) syncListContent() {
|
func truncateRunes(s string, max int) string {
|
||||||
var b strings.Builder
|
if max <= 0 {
|
||||||
if !m.hasList {
|
return s
|
||||||
b.WriteString("List\n\n")
|
|
||||||
b.WriteString("Select a leaf node in the tree\n")
|
|
||||||
b.WriteString("(marked with •) and press Enter.\n")
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(&b, "%s (%d)\n\n", m.listSpec.Subject, len(m.items))
|
|
||||||
if m.actionMode {
|
|
||||||
item := m.items[m.listIdx]
|
|
||||||
fmt.Fprintf(&b, "Actions for: %s\n\n", item.Title)
|
|
||||||
for i, act := range m.itemActions {
|
|
||||||
cursor := " "
|
|
||||||
if i == m.actionIdx {
|
|
||||||
cursor = "> "
|
|
||||||
}
|
|
||||||
label := act.Label
|
|
||||||
if act.Danger {
|
|
||||||
label = "⚠ " + label
|
|
||||||
}
|
|
||||||
fmt.Fprintf(&b, "%s%s\n", cursor, label)
|
|
||||||
}
|
|
||||||
b.WriteString("\nEnter: run · Esc: cancel\n")
|
|
||||||
} else {
|
|
||||||
for i, it := range m.items {
|
|
||||||
mark := "[ ]"
|
|
||||||
if it.Selected {
|
|
||||||
mark = "[x]"
|
|
||||||
}
|
|
||||||
cursor := " "
|
|
||||||
if i == m.listIdx && m.focus == model.FocusList {
|
|
||||||
cursor = "> "
|
|
||||||
}
|
|
||||||
line := fmt.Sprintf("%s%s %s", cursor, mark, it.Title)
|
|
||||||
if it.Subtitle != "" {
|
|
||||||
line += " — " + it.Subtitle
|
|
||||||
}
|
|
||||||
b.WriteString(line + "\n")
|
|
||||||
}
|
|
||||||
if len(m.items) == 0 && !m.loading {
|
|
||||||
b.WriteString("(empty)\n")
|
|
||||||
}
|
|
||||||
b.WriteString("\nEnter/a: actions · Space: select\n")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
m.listVP.SetContent(b.String())
|
r := []rune(s)
|
||||||
line := m.listIdx + 3
|
if len(r) <= max {
|
||||||
if m.actionMode {
|
return s
|
||||||
line = m.actionIdx + 4
|
|
||||||
}
|
}
|
||||||
syncVPToLine(&m.listVP, line)
|
if max <= 1 {
|
||||||
|
return string(r[:max])
|
||||||
|
}
|
||||||
|
return string(r[:max-1]) + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) syncPreviewContent() {
|
func (m *Model) syncFocusedPane() {
|
||||||
menuW, listW, prevW := LayoutWidths(m.width)
|
switch m.focus {
|
||||||
_ = menuW
|
case model.FocusMenu:
|
||||||
_ = listW
|
m.syncMenuContent()
|
||||||
w := prevW - 4
|
case model.FocusList:
|
||||||
if w < 20 {
|
m.syncListTable()
|
||||||
w = 20
|
|
||||||
}
|
}
|
||||||
text, err := preview.RenderMarkdown(m.previewMD, w)
|
}
|
||||||
if err != nil {
|
|
||||||
text = m.previewMD
|
func (m *Model) moveUp() {
|
||||||
|
switch m.focus {
|
||||||
|
case model.FocusMenu:
|
||||||
|
m.nav.MoveUp()
|
||||||
|
case model.FocusList:
|
||||||
|
if m.hasList {
|
||||||
|
m.listTable.MoveRow(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) moveDown() {
|
||||||
|
switch m.focus {
|
||||||
|
case model.FocusMenu:
|
||||||
|
m.nav.MoveDown()
|
||||||
|
case model.FocusList:
|
||||||
|
if m.hasList {
|
||||||
|
m.listTable.MoveRow(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
m.previewVP.SetContent(text)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncVPToLine(vp *viewport.Model, line int) {
|
func syncVPToLine(vp *viewport.Model, line int) {
|
||||||
if line < 0 {
|
if line < 0 {
|
||||||
line = 0
|
line = 0
|
||||||
}
|
}
|
||||||
|
max := vp.TotalLineCount()
|
||||||
|
if max == 0 {
|
||||||
|
vp.YOffset = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if line >= max {
|
||||||
|
line = max - 1
|
||||||
|
}
|
||||||
if line < vp.YOffset {
|
if line < vp.YOffset {
|
||||||
vp.YOffset = line
|
vp.YOffset = line
|
||||||
} else if line >= vp.YOffset+vp.Height {
|
} else if line >= vp.YOffset+vp.Height {
|
||||||
@@ -440,31 +569,12 @@ func syncVPToLine(vp *viewport.Model, line int) {
|
|||||||
if vp.YOffset < 0 {
|
if vp.YOffset < 0 {
|
||||||
vp.YOffset = 0
|
vp.YOffset = 0
|
||||||
}
|
}
|
||||||
}
|
maxOff := max - vp.Height
|
||||||
|
if maxOff < 0 {
|
||||||
func (m *Model) moveUp() {
|
maxOff = 0
|
||||||
switch m.focus {
|
|
||||||
case model.FocusMenu:
|
|
||||||
m.nav.MoveUp()
|
|
||||||
case model.FocusList:
|
|
||||||
if m.hasList && !m.actionMode && m.listIdx > 0 {
|
|
||||||
m.listIdx--
|
|
||||||
}
|
|
||||||
case model.FocusPreview:
|
|
||||||
m.previewVP.LineUp(1)
|
|
||||||
}
|
}
|
||||||
}
|
if vp.YOffset > maxOff {
|
||||||
|
vp.YOffset = maxOff
|
||||||
func (m *Model) moveDown() {
|
|
||||||
switch m.focus {
|
|
||||||
case model.FocusMenu:
|
|
||||||
m.nav.MoveDown()
|
|
||||||
case model.FocusList:
|
|
||||||
if m.hasList && !m.actionMode && m.listIdx < len(m.items)-1 {
|
|
||||||
m.listIdx++
|
|
||||||
}
|
|
||||||
case model.FocusPreview:
|
|
||||||
m.previewVP.LineDown(1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,22 +586,6 @@ func (m *Model) loadListCmd(spec model.ListSpec) tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) loadPreviewCmd() tea.Cmd {
|
|
||||||
if m.listIdx < 0 || m.listIdx >= len(m.items) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
item := m.items[m.listIdx]
|
|
||||||
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}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) executeActionCmd(action model.ActionID, item model.Item) tea.Cmd {
|
func (m *Model) executeActionCmd(action model.ActionID, item model.Item) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -508,6 +602,63 @@ func (m *Model) loadNavProjectsCmd() tea.Cmd {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func paneStyle(focused bool) lipgloss.Style {
|
||||||
s := lipgloss.NewStyle().Padding(0, 1)
|
s := lipgloss.NewStyle().Padding(0, 1)
|
||||||
if focused {
|
if focused {
|
||||||
@@ -517,5 +668,5 @@ func paneStyle(focused bool) lipgloss.Style {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func helpText() string {
|
func helpText() string {
|
||||||
return "Tab/Shift+Tab: pane · ↑↓/jk: scroll · Enter: open leaf/actions · Space: select · a: actions · r: refresh · q: quit"
|
return "Alt+1/2/3: toggle panes · Tab: focus · v: detail · Ctrl+S: save · q: quit"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,26 +17,23 @@ func TestKeyActionShiftTabPrevPane(t *testing.T) {
|
|||||||
if got := ui.KeyAction("shift+tab", model.FocusList); got != ui.ActionPrevPane {
|
if got := ui.KeyAction("shift+tab", model.FocusList); got != ui.ActionPrevPane {
|
||||||
t.Fatalf("got %v", got)
|
t.Fatalf("got %v", got)
|
||||||
}
|
}
|
||||||
if got := ui.KeyAction("backtab", model.FocusPreview); got != ui.ActionPrevPane {
|
}
|
||||||
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) {
|
func TestKeyActionSortAndColumns(t *testing.T) {
|
||||||
if got := ui.KeyAction("tab", model.FocusList); got != ui.ActionNextPane {
|
if got := ui.KeyAction("s", model.FocusList); got != ui.ActionSort {
|
||||||
t.Fatalf("got %v", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestKeyActionOpenActions(t *testing.T) {
|
|
||||||
if got := ui.KeyAction("a", model.FocusList); got != ui.ActionOpenActions {
|
|
||||||
t.Fatalf("got %v", got)
|
t.Fatalf("got %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLayoutWidths(t *testing.T) {
|
func TestLayoutWidths(t *testing.T) {
|
||||||
menu, list, preview := ui.LayoutWidths(120)
|
menu, list, preview := ui.LayoutWidthsLegacy(120)
|
||||||
if menu+list+preview > 120 {
|
if menu+list+preview != 120 {
|
||||||
t.Fatalf("widths exceed total: %d+%d+%d", menu, list, preview)
|
t.Fatalf("widths exceed total: %d+%d+%d", menu, list, preview)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
+33
-33
@@ -9,14 +9,18 @@ const (
|
|||||||
ActionNone Action = iota
|
ActionNone Action = iota
|
||||||
ActionMoveUp
|
ActionMoveUp
|
||||||
ActionMoveDown
|
ActionMoveDown
|
||||||
|
ActionMoveLeft
|
||||||
|
ActionMoveRight
|
||||||
|
ActionSort
|
||||||
ActionToggleSelect
|
ActionToggleSelect
|
||||||
ActionOpenPreview
|
ActionFocusDetail
|
||||||
ActionOpenActions
|
ActionToggleMenuPane
|
||||||
|
ActionToggleListPane
|
||||||
|
ActionToggleDetailPane
|
||||||
ActionNextPane
|
ActionNextPane
|
||||||
ActionPrevPane
|
ActionPrevPane
|
||||||
ActionRefresh
|
ActionRefresh
|
||||||
ActionQuit
|
ActionQuit
|
||||||
ActionOpenVex
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// KeyAction maps a key string and focused pane to an action.
|
// KeyAction maps a key string and focused pane to an action.
|
||||||
@@ -25,53 +29,49 @@ func KeyAction(key string, pane model.FocusPane) Action {
|
|||||||
case "q", "ctrl+c":
|
case "q", "ctrl+c":
|
||||||
return ActionQuit
|
return ActionQuit
|
||||||
case "tab":
|
case "tab":
|
||||||
return ActionNextPane
|
if pane != model.FocusPreview {
|
||||||
|
return ActionNextPane
|
||||||
|
}
|
||||||
case "shift+tab", "backtab":
|
case "shift+tab", "backtab":
|
||||||
return ActionPrevPane
|
if pane != model.FocusPreview {
|
||||||
|
return ActionPrevPane
|
||||||
|
}
|
||||||
case "r":
|
case "r":
|
||||||
return ActionRefresh
|
return ActionRefresh
|
||||||
case "up", "k":
|
case "up", "k":
|
||||||
return ActionMoveUp
|
return ActionMoveUp
|
||||||
case "down", "j":
|
case "down", "j":
|
||||||
return ActionMoveDown
|
return ActionMoveDown
|
||||||
|
case "s":
|
||||||
|
if pane == model.FocusList {
|
||||||
|
return ActionSort
|
||||||
|
}
|
||||||
|
case "left", "h":
|
||||||
|
return ActionMoveLeft
|
||||||
|
case "right", "l":
|
||||||
|
return ActionMoveRight
|
||||||
case " ":
|
case " ":
|
||||||
if pane == model.FocusList {
|
if pane == model.FocusList {
|
||||||
return ActionToggleSelect
|
return ActionToggleSelect
|
||||||
}
|
}
|
||||||
case "a":
|
case "v", "p":
|
||||||
if pane == model.FocusList {
|
if pane == model.FocusList {
|
||||||
return ActionOpenActions
|
return ActionFocusDetail
|
||||||
}
|
|
||||||
case "enter":
|
|
||||||
if pane == model.FocusList {
|
|
||||||
return ActionOpenPreview
|
|
||||||
}
|
|
||||||
case "v":
|
|
||||||
if pane == model.FocusList {
|
|
||||||
return ActionOpenVex
|
|
||||||
}
|
}
|
||||||
|
case "alt+1":
|
||||||
|
return ActionToggleMenuPane
|
||||||
|
case "alt+2":
|
||||||
|
return ActionToggleListPane
|
||||||
|
case "alt+3":
|
||||||
|
return ActionToggleDetailPane
|
||||||
}
|
}
|
||||||
return ActionNone
|
return ActionNone
|
||||||
}
|
}
|
||||||
|
|
||||||
// LayoutWidths splits total terminal width into menu, list, preview columns.
|
// LayoutWidthsLegacy is kept for tests that expect the old signature.
|
||||||
func LayoutWidths(total int) (menu, list, preview int) {
|
func LayoutWidthsLegacy(total int) (menu, list, preview int) {
|
||||||
if total < 80 {
|
pw := LayoutWidths(total, defaultPaneVisibility())
|
||||||
total = 80
|
return pw.Menu, pw.List, pw.Detail
|
||||||
}
|
|
||||||
menu = total / 5
|
|
||||||
if menu < 22 {
|
|
||||||
menu = 22
|
|
||||||
}
|
|
||||||
preview = total / 3
|
|
||||||
if preview < 28 {
|
|
||||||
preview = 28
|
|
||||||
}
|
|
||||||
list = total - menu - preview - 2
|
|
||||||
if list < 24 {
|
|
||||||
list = 24
|
|
||||||
}
|
|
||||||
return menu, list, preview
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveMoveUp returns the action for upward navigation keys.
|
// ResolveMoveUp returns the action for upward navigation keys.
|
||||||
|
|||||||
@@ -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