From 934af21bc93a54df9f40aacb4d89468b00d7562e Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Wed, 24 Jun 2026 17:33:40 +0100 Subject: [PATCH] feat(office): table detail panes and Alt+1/2/3 layout toggles Add spreadsheet-style list, split detail pane with forms and document preview, and Alt+1/2/3 to show/hide panes with even width distribution. Co-authored-by: Cursor --- CHANGELOG.md | 17 + cmd/office/fetch/list.go | 21 +- cmd/office/fetch/preview.go | 43 ++ cmd/office/fetch/save.go | 43 ++ cmd/office/fetch/task.go | 36 ++ cmd/office/fetch/task_integration_test.go | 68 ++ cmd/office/main.go | 2 +- cmd/office/model/actions.go | 24 +- cmd/office/model/columns.go | 287 +++++++++ cmd/office/model/columns_test.go | 55 ++ cmd/office/model/formfields.go | 115 ++++ cmd/office/model/formfields_test.go | 20 + cmd/office/model/nav_test.go | 4 +- .../preview/preview_integration_test.go | 25 + cmd/office/ui/app.go | 595 +++++++++++------- cmd/office/ui/app_test.go | 21 +- cmd/office/ui/detail.go | 280 +++++++++ cmd/office/ui/detail_test.go | 49 ++ cmd/office/ui/entityform.go | 223 +++++++ cmd/office/ui/keys.go | 66 +- cmd/office/ui/layout.go | 129 ++++ cmd/office/ui/layout_test.go | 69 ++ cmd/office/ui/scroll.go | 67 ++ cmd/office/ui/table.go | 485 ++++++++++++++ cmd/office/ui/table_test.go | 98 +++ 25 files changed, 2555 insertions(+), 287 deletions(-) create mode 100644 cmd/office/fetch/preview.go create mode 100644 cmd/office/fetch/save.go create mode 100644 cmd/office/fetch/task.go create mode 100644 cmd/office/fetch/task_integration_test.go create mode 100644 cmd/office/model/columns.go create mode 100644 cmd/office/model/columns_test.go create mode 100644 cmd/office/model/formfields.go create mode 100644 cmd/office/model/formfields_test.go create mode 100644 cmd/office/ui/detail.go create mode 100644 cmd/office/ui/detail_test.go create mode 100644 cmd/office/ui/entityform.go create mode 100644 cmd/office/ui/layout.go create mode 100644 cmd/office/ui/layout_test.go create mode 100644 cmd/office/ui/scroll.go create mode 100644 cmd/office/ui/table.go create mode 100644 cmd/office/ui/table_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index efcc633..dea201d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,8 +40,25 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.5.0] — 2026-06-24 + ### Added — `office` TUI +- **Alt+1 / Alt+2 / Alt+3** — show/hide left (nav), middle (table), and right (detail) panes. +- Visible panes share **100% terminal width** evenly; pane content fills its column. +- Tab focus skips hidden panes. + +### Changed — `office` TUI + +- Split detail pane: form/document top (~72%), CRUD action bar bottom. +- Middle pane: multi-column table with sort, selection, and full-width columns. +- Project list columns: ID, Title, Tasks (open/closed), Documents, Users. +- Row selection auto-loads detail; files show document preview, entities show forms. + +## [Unreleased — archived] + +### Added — `office` TUI (0.4.0) + - **`cmd/office`** — Bubble Tea terminal UI mirroring OnlyOffice Workspace: three-pane layout (module tree, multi-select list, markdown preview). Install: `go install github.com/eslider/go-onlyoffice/cmd/office@latest`. diff --git a/cmd/office/fetch/list.go b/cmd/office/fetch/list.go index ab17a7b..27ebb83 100644 --- a/cmd/office/fetch/list.go +++ b/cmd/office/fetch/list.go @@ -244,13 +244,24 @@ func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) { if p.Title != nil { title = *p.Title } - status := "" - if p.Status != nil { - status = fmt.Sprint(*p.Status) + raw := map[string]any{ + "id": id, + "title": title, + } + if p.TaskCount != nil { + raw["taskCount"] = *p.TaskCount + } + if p.TaskCountTotal != nil { + raw["taskCountTotal"] = *p.TaskCountTotal + } + if p.DocumentsCount != nil { + raw["documentsCount"] = *p.DocumentsCount + } + if p.ParticipantCount != nil { + raw["participantCount"] = *p.ParticipantCount } items[i] = model.Item{ - ID: id, Title: title, Subtitle: status, Kind: model.KindProject, - Raw: map[string]any{"id": id, "title": title, "status": status}, + ID: id, Title: title, Kind: model.KindProject, Raw: raw, } } return items, nil diff --git a/cmd/office/fetch/preview.go b/cmd/office/fetch/preview.go new file mode 100644 index 0000000..0a868fa --- /dev/null +++ b/cmd/office/fetch/preview.go @@ -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()) +} diff --git a/cmd/office/fetch/save.go b/cmd/office/fetch/save.go new file mode 100644 index 0000000..5fbc6c5 --- /dev/null +++ b/cmd/office/fetch/save.go @@ -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 +} diff --git a/cmd/office/fetch/task.go b/cmd/office/fetch/task.go new file mode 100644 index 0000000..c38e0a8 --- /dev/null +++ b/cmd/office/fetch/task.go @@ -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 +} diff --git a/cmd/office/fetch/task_integration_test.go b/cmd/office/fetch/task_integration_test.go new file mode 100644 index 0000000..cfbb700 --- /dev/null +++ b/cmd/office/fetch/task_integration_test.go @@ -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 +} diff --git a/cmd/office/main.go b/cmd/office/main.go index d586802..bccd612 100644 --- a/cmd/office/main.go +++ b/cmd/office/main.go @@ -26,7 +26,7 @@ func main() { } m := ui.NewModel(client) - p := tea.NewProgram(m, tea.WithAltScreen()) + p := tea.NewProgram(m, tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/cmd/office/model/actions.go b/cmd/office/model/actions.go index 3ec606f..42bd687 100644 --- a/cmd/office/model/actions.go +++ b/cmd/office/model/actions.go @@ -5,6 +5,7 @@ type ActionID string const ( ActionView ActionID = "view" + ActionSave ActionID = "save" ActionDelete ActionID = "delete" ActionRefresh ActionID = "refresh" ActionDownload ActionID = "download" @@ -17,48 +18,47 @@ type ItemAction struct { 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 { switch kind { case KindProject: return []ItemAction{ - {ID: ActionView, Label: "View details"}, - {ID: ActionDelete, Label: "Delete project", Danger: true}, + {ID: ActionSave, Label: "Save"}, + {ID: ActionDelete, Label: "Delete", Danger: true}, } - case KindTask, KindCRMTask: + case KindTask: return []ItemAction{ - {ID: ActionView, Label: "View details"}, + {ID: ActionSave, Label: "Save"}, {ID: ActionDelete, Label: "Delete task", Danger: true}, } + case KindCRMTask: + return []ItemAction{ + {ID: ActionDelete, Label: "Delete", Danger: true}, + } case KindContact: return []ItemAction{ - {ID: ActionView, Label: "View details"}, {ID: ActionDelete, Label: "Delete contact", Danger: true}, } case KindOpportunity: return []ItemAction{ - {ID: ActionView, Label: "View details"}, {ID: ActionDelete, Label: "Delete deal", Danger: true}, } case KindCase: return []ItemAction{ - {ID: ActionView, Label: "View details"}, {ID: ActionDelete, Label: "Delete case", Danger: true}, } case KindMail: return []ItemAction{ - {ID: ActionView, Label: "Read message"}, {ID: ActionDelete, Label: "Delete message", Danger: true}, } case KindFile: return []ItemAction{ - {ID: ActionView, Label: "Preview file"}, {ID: ActionDownload, Label: "Download"}, {ID: ActionDelete, Label: "Delete file", Danger: true}, } case KindEvent, KindCalendar, KindUser: - return []ItemAction{{ID: ActionView, Label: "View details"}} + return nil default: - return []ItemAction{{ID: ActionView, Label: "View details"}} + return nil } } diff --git a/cmd/office/model/columns.go b/cmd/office/model/columns.go new file mode 100644 index 0000000..d86a6c3 --- /dev/null +++ b/cmd/office/model/columns.go @@ -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) + } +} diff --git a/cmd/office/model/columns_test.go b/cmd/office/model/columns_test.go new file mode 100644 index 0000000..df09ae6 --- /dev/null +++ b/cmd/office/model/columns_test.go @@ -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) + } +} diff --git a/cmd/office/model/formfields.go b/cmd/office/model/formfields.go new file mode 100644 index 0000000..6e23f11 --- /dev/null +++ b/cmd/office/model/formfields.go @@ -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 +} diff --git a/cmd/office/model/formfields_test.go b/cmd/office/model/formfields_test.go new file mode 100644 index 0000000..1a19334 --- /dev/null +++ b/cmd/office/model/formfields_test.go @@ -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") + } +} diff --git a/cmd/office/model/nav_test.go b/cmd/office/model/nav_test.go index 576690a..194805a 100644 --- a/cmd/office/model/nav_test.go +++ b/cmd/office/model/nav_test.go @@ -69,7 +69,7 @@ func TestPrevFocusPane(t *testing.T) { func TestActionsForContact(t *testing.T) { acts := model.ActionsFor(model.KindContact) - if len(acts) < 2 { - t.Fatalf("expected view+delete, got %d", len(acts)) + if len(acts) != 1 || acts[0].ID != model.ActionDelete { + t.Fatalf("expected delete only, got %v", acts) } } diff --git a/cmd/office/preview/preview_integration_test.go b/cmd/office/preview/preview_integration_test.go index d3ef9d8..7930a30 100644 --- a/cmd/office/preview/preview_integration_test.go +++ b/cmd/office/preview/preview_integration_test.go @@ -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) { skipWithoutLiveAPI(t) if os.Getenv("ONLYOFFICE_PROJECT_ID") == "" { diff --git a/cmd/office/ui/app.go b/cmd/office/ui/app.go index 574e5c2..6626347 100644 --- a/cmd/office/ui/app.go +++ b/cmd/office/ui/app.go @@ -2,7 +2,6 @@ package ui import ( "context" - "fmt" "strings" "github.com/charmbracelet/bubbles/viewport" @@ -11,7 +10,6 @@ import ( onlyoffice "github.com/eslider/go-onlyoffice" "github.com/eslider/go-onlyoffice/cmd/office/fetch" "github.com/eslider/go-onlyoffice/cmd/office/model" - "github.com/eslider/go-onlyoffice/cmd/office/preview" ) type listLoadedMsg struct { @@ -20,9 +18,18 @@ type listLoadedMsg struct { err error } -type previewLoadedMsg struct { - text string - err error +type detailLoadedMsg struct { + item model.Item + document bool + fields model.FormFields + markdown string + err error +} + +type detailSavedMsg struct { + item model.Item + title, description string + err error } type actionDoneMsg struct { @@ -44,7 +51,6 @@ type Model struct { hasList bool items []model.Item selection *model.Selection - listIdx int focus model.FocusPane width int height int @@ -52,29 +58,36 @@ type Model struct { err string loading bool menuVP viewport.Model - listVP viewport.Model - previewVP viewport.Model - previewMD string - // Action menu overlay on list pane - actionMode bool - actionIdx int - itemActions []model.ItemAction + listTable DataTable + detail DetailPane + showMenu bool + showList bool + showDetail bool } // NewModel constructs the TUI with an authenticated client. func NewModel(client *onlyoffice.Client) Model { - return Model{ + h, w := 24, 80 + m := Model{ client: client, loader: &fetch.Loader{Client: client}, nav: model.DefaultNavTree(), selection: model.NewSelection(), 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 { - return m.loadNavProjectsCmd() + return tea.Batch(m.loadNavProjectsCmd(), m.detail.BlinkCmd()) } 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() return m, nil } - if m.actionMode && m.focus == model.FocusList { - return m.handleActionKey(key) + if m.scrollFocusedPane(key) { + return m, nil + } + if m.focus == model.FocusPreview { + if cmd, handled := m.handleDetailKey(key, msg); handled { + return m, cmd + } } action := KeyAction(key, m.focus) if action == ActionNone && (key == "up" || key == "k") { @@ -103,36 +121,75 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ActionQuit: return m, tea.Quit case ActionNextPane: - m.focus = model.NextFocusPane(m.focus) + m.focus = NextVisibleFocus(m.focus, m.paneVis()) + m.syncPaneFocus() return m, nil case ActionPrevPane: - m.focus = model.PrevFocusPane(m.focus) + m.focus = PrevVisibleFocus(m.focus, m.paneVis()) + m.syncPaneFocus() return m, nil case ActionMoveUp: + if m.focus == model.FocusPreview { + return m.handleDetailMove(-1) + } m.moveUp() - m.syncAllContent() - return m, nil + m.syncFocusedPane() + return m, m.onListRowChanged() case ActionMoveDown: + if m.focus == model.FocusPreview { + return m.handleDetailMove(1) + } 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 case ActionToggleSelect: if m.hasList { - m.selection.Toggle(&m.items, m.listIdx) - m.syncListContent() + idx := m.listTable.ItemIndex() + if idx >= 0 { + m.selection.Toggle(&m.items, idx) + m.listTable.UpdateItems(m.items) + } } return m, nil - case ActionOpenActions: - if m.hasList && len(m.items) > 0 { - m.openActionMenu() - m.syncListContent() + case ActionFocusDetail: + if m.hasList && m.showDetail { + m.focus = model.FocusPreview + m.syncPaneFocus() + m.detail.FocusContent() + return m, m.onListRowChanged() } return m, nil - case ActionOpenPreview: - if m.hasList { - m.selection.Toggle(&m.items, m.listIdx) - return m, m.loadPreviewCmd() - } + case ActionToggleMenuPane: + m.togglePane(1) + return m, nil + case ActionToggleListPane: + m.togglePane(2) + return m, nil + case ActionToggleDetailPane: + m.togglePane(3) return m, nil case ActionRefresh: if m.hasList { @@ -162,11 +219,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } } - if m.focus == model.FocusList && key == "enter" && m.hasList { - m.openActionMenu() - m.syncListContent() - return m, nil - } case listLoadedMsg: m.loading = false @@ -174,29 +226,56 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.err = msg.err.Error() m.items = nil m.hasList = false + m.listTable.Clear() + m.detail.Clear() } else { m.err = "" m.items = msg.items m.listSpec = msg.spec m.hasList = true - m.listIdx = 0 + m.listTable.SetData(m.listSpec, m.items) } - m.syncListContent() - return m, nil + return m, m.onListRowChanged() - case previewLoadedMsg: + case detailLoadedMsg: m.loading = false if msg.err != nil { - m.previewMD = fmt.Sprintf("# Error\n\n%s\n", msg.err.Error()) + m.err = msg.err.Error() + m.detail.Clear() } else { - m.previewMD = msg.text + pw := m.paneLayout() + w := pw.Detail - 4 + if w < 20 { + w = 20 + } + if msg.document { + m.detail.LoadDocument(msg.item, msg.markdown, w) + } else { + m.detail.LoadForm(msg.item, msg.fields) + } + m.detail.SetFocused(m.focus == model.FocusPreview) + m.err = "" + } + 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 case actionDoneMsg: m.loading = false - m.actionMode = false if msg.err != nil { m.err = msg.err.Error() } else { @@ -215,6 +294,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.syncMenuContent() } return m, nil + + case tea.MouseMsg: + var cmd tea.Cmd + switch m.focus { + case model.FocusMenu: + m.menuVP, cmd = m.menuVP.Update(msg) + case model.FocusList: + cmd = m.listTable.Update(msg) + case model.FocusPreview: + cmd = m.detail.Update(msg) + } + return m, cmd } var cmd tea.Cmd @@ -222,22 +313,72 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case model.FocusMenu: m.menuVP, cmd = m.menuVP.Update(msg) case model.FocusList: - m.listVP, cmd = m.listVP.Update(msg) + cmd = m.listTable.Update(msg) case model.FocusPreview: - m.previewVP, cmd = m.previewVP.Update(msg) + cmd = m.detail.Update(msg) } 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 { if m.width == 0 { return "Loading…\n" } - menuW, listW, prevW := LayoutWidths(m.width) - h := m.height - 2 - menuStyle := paneStyle(m.focus == model.FocusMenu).Width(menuW).Height(h) - listStyle := paneStyle(m.focus == model.FocusList).Width(listW).Height(h) - prevStyle := paneStyle(m.focus == model.FocusPreview).Width(prevW).Height(h) + pw := m.paneLayout() + h := m.paneHeight() + 2 + var parts []string + if pw.Visibility.Menu { + menuStyle := paneStyle(m.focus == model.FocusMenu).Width(pw.Menu).Height(h) + parts = append(parts, menuStyle.Render(m.menuVP.View())) + } + if pw.Visibility.List { + listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h) + parts = append(parts, listStyle.Render(m.listTable.View())) + } + if pw.Visibility.Detail { + prevStyle := paneStyle(m.focus == model.FocusPreview).Width(pw.Detail).Height(h) + parts = append(parts, prevStyle.Render(m.detail.View())) + } status := m.status if m.loading { @@ -248,93 +389,98 @@ func (m Model) View() string { } bar := lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(status) - return lipgloss.JoinHorizontal(lipgloss.Top, - menuStyle.Render(m.menuVP.View()), - listStyle.Render(m.listVP.View()), - prevStyle.Render(m.previewVP.View()), - ) + "\n" + bar + return lipgloss.JoinHorizontal(lipgloss.Top, parts...) + "\n" + bar +} + +func (m Model) paneVis() PaneVisibility { + return PaneVisibility{Menu: m.showMenu, List: m.showList, Detail: m.showDetail} +} + +func (m Model) paneLayout() PaneWidths { + return LayoutWidths(m.width, m.paneVis()) +} + +func (m *Model) syncPaneFocus() { + m.listTable.SetFocused(m.focus == model.FocusList && m.showList) + m.detail.SetFocused(m.focus == model.FocusPreview && m.showDetail) +} + +func (m *Model) togglePane(which int) { + switch which { + case 1: + m.showMenu = !m.showMenu + case 2: + m.showList = !m.showList + case 3: + m.showDetail = !m.showDetail + } + if !m.showMenu && !m.showList && !m.showDetail { + switch which { + case 1: + m.showMenu = true + case 2: + m.showList = true + case 3: + m.showDetail = true + } + } + if !paneVisible(m.focus, m.paneVis()) { + m.focus = firstVisibleFocus(m.paneVis()) + } + m.layoutViewports() + m.syncAllContent() + m.syncPaneFocus() } func (m Model) withList(spec model.ListSpec) (Model, tea.Cmd) { m.listSpec = spec m.selection.Clear() - m.listIdx = 0 m.hasList = false - m.actionMode = false m.focus = model.FocusList + m.listTable.SetFocused(true) + m.detail.Clear() m.loading = true return m, m.loadListCmd(spec) } -func (m *Model) openActionMenu() { - if m.listIdx < 0 || m.listIdx >= len(m.items) { - return - } - m.itemActions = model.ActionsFor(m.items[m.listIdx].Kind) - m.actionIdx = 0 - m.actionMode = true -} - -func (m *Model) handleActionKey(key string) (tea.Model, tea.Cmd) { - switch key { - case "esc": - m.actionMode = false - m.syncListContent() - return m, nil - case "up", "k": - if m.actionIdx > 0 { - m.actionIdx-- - } - m.syncListContent() - return m, nil - case "down", "j": - if m.actionIdx < len(m.itemActions)-1 { - m.actionIdx++ - } - m.syncListContent() - return m, nil - case "enter": - if m.listIdx < 0 || m.listIdx >= len(m.items) || m.actionIdx >= len(m.itemActions) { - return m, nil - } - act := m.itemActions[m.actionIdx] - item := m.items[m.listIdx] - m.actionMode = false - if act.ID == model.ActionView { - return m, m.loadPreviewCmd() - } - m.loading = true - return m, m.executeActionCmd(act.ID, item) - } - return m, nil -} - func (m *Model) layoutViewports() { - menuW, listW, prevW := LayoutWidths(m.width) - h := m.height - 4 - if h < 4 { - h = 4 + pw := m.paneLayout() + h := m.paneHeight() + if pw.Visibility.Menu { + m.menuVP.Width = m.paneInnerWidth(pw.Menu) + m.menuVP.Height = h } - inner := func(w int) int { - if w > 4 { - return w - 2 - } - return w + if pw.Visibility.List { + m.listTable.SetSize(m.paneInnerWidth(pw.List), h) + } + if pw.Visibility.Detail { + m.detail.SetSize(m.paneInnerWidth(pw.Detail), h) } - m.menuVP = viewport.New(inner(menuW), h) - m.listVP = viewport.New(inner(listW), h) - m.previewVP = viewport.New(inner(prevW), h) } func (m *Model) syncAllContent() { m.syncMenuContent() - m.syncListContent() - m.syncPreviewContent() + m.syncListTable() +} + +func (m *Model) syncListTable() { + if !m.hasList { + m.listTable.Clear() + return + } + m.listTable.UpdateItems(m.items) } func (m *Model) syncMenuContent() { var b strings.Builder b.WriteString("Navigation\n\n") + menuW := m.menuVP.Width + if menuW < 10 { + menuW = 20 + } + selectedStyle := lipgloss.NewStyle(). + Background(lipgloss.Color("62")). + Foreground(lipgloss.Color("255")) for i := 0; i < m.nav.VisibleCount(); i++ { n, _ := m.nav.NodeAtVisible(i) depth := m.nav.DepthAtVisible(i) @@ -350,9 +496,7 @@ func (m *Model) syncMenuContent() { line = prefix + "• " + n.Label } if i == m.nav.Cursor() && m.focus == model.FocusMenu { - line = "> " + line - } else { - line = " " + line + line = selectedStyle.Width(menuW).Render(truncateRunes(line, menuW)) } b.WriteString(line + "\n") } @@ -360,78 +504,63 @@ func (m *Model) syncMenuContent() { syncVPToLine(&m.menuVP, m.nav.Cursor()+2) } -func (m *Model) syncListContent() { - var b strings.Builder - if !m.hasList { - b.WriteString("List\n\n") - b.WriteString("Select a leaf node in the tree\n") - b.WriteString("(marked with •) and press Enter.\n") - } else { - fmt.Fprintf(&b, "%s (%d)\n\n", m.listSpec.Subject, len(m.items)) - if m.actionMode { - item := m.items[m.listIdx] - fmt.Fprintf(&b, "Actions for: %s\n\n", item.Title) - for i, act := range m.itemActions { - cursor := " " - if i == m.actionIdx { - cursor = "> " - } - label := act.Label - if act.Danger { - label = "⚠ " + label - } - fmt.Fprintf(&b, "%s%s\n", cursor, label) - } - b.WriteString("\nEnter: run · Esc: cancel\n") - } else { - for i, it := range m.items { - mark := "[ ]" - if it.Selected { - mark = "[x]" - } - cursor := " " - if i == m.listIdx && m.focus == model.FocusList { - cursor = "> " - } - line := fmt.Sprintf("%s%s %s", cursor, mark, it.Title) - if it.Subtitle != "" { - line += " — " + it.Subtitle - } - b.WriteString(line + "\n") - } - if len(m.items) == 0 && !m.loading { - b.WriteString("(empty)\n") - } - b.WriteString("\nEnter/a: actions · Space: select\n") - } +func truncateRunes(s string, max int) string { + if max <= 0 { + return s } - m.listVP.SetContent(b.String()) - line := m.listIdx + 3 - if m.actionMode { - line = m.actionIdx + 4 + r := []rune(s) + if len(r) <= max { + return s } - syncVPToLine(&m.listVP, line) + if max <= 1 { + return string(r[:max]) + } + return string(r[:max-1]) + "…" } -func (m *Model) syncPreviewContent() { - menuW, listW, prevW := LayoutWidths(m.width) - _ = menuW - _ = listW - w := prevW - 4 - if w < 20 { - w = 20 +func (m *Model) syncFocusedPane() { + switch m.focus { + case model.FocusMenu: + m.syncMenuContent() + case model.FocusList: + m.syncListTable() } - 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) { if line < 0 { line = 0 } + max := vp.TotalLineCount() + if max == 0 { + vp.YOffset = 0 + return + } + if line >= max { + line = max - 1 + } if line < vp.YOffset { vp.YOffset = line } else if line >= vp.YOffset+vp.Height { @@ -440,31 +569,12 @@ func syncVPToLine(vp *viewport.Model, line int) { if vp.YOffset < 0 { vp.YOffset = 0 } -} - -func (m *Model) moveUp() { - 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) + maxOff := max - vp.Height + if maxOff < 0 { + maxOff = 0 } -} - -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) + if vp.YOffset > maxOff { + vp.YOffset = maxOff } } @@ -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 { return func() tea.Msg { 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 { s := lipgloss.NewStyle().Padding(0, 1) if focused { @@ -517,5 +668,5 @@ func paneStyle(focused bool) lipgloss.Style { } 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" } diff --git a/cmd/office/ui/app_test.go b/cmd/office/ui/app_test.go index 6edb539..9040193 100644 --- a/cmd/office/ui/app_test.go +++ b/cmd/office/ui/app_test.go @@ -17,26 +17,23 @@ func TestKeyActionShiftTabPrevPane(t *testing.T) { if got := ui.KeyAction("shift+tab", model.FocusList); got != ui.ActionPrevPane { 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) { - if got := ui.KeyAction("tab", model.FocusList); got != ui.ActionNextPane { - t.Fatalf("got %v", got) - } -} - -func TestKeyActionOpenActions(t *testing.T) { - if got := ui.KeyAction("a", model.FocusList); got != ui.ActionOpenActions { +func TestKeyActionSortAndColumns(t *testing.T) { + if got := ui.KeyAction("s", model.FocusList); got != ui.ActionSort { t.Fatalf("got %v", got) } } func TestLayoutWidths(t *testing.T) { - menu, list, preview := ui.LayoutWidths(120) - if menu+list+preview > 120 { + menu, list, preview := ui.LayoutWidthsLegacy(120) + if menu+list+preview != 120 { t.Fatalf("widths exceed total: %d+%d+%d", menu, list, preview) } } diff --git a/cmd/office/ui/detail.go b/cmd/office/ui/detail.go new file mode 100644 index 0000000..7bcd8c2 --- /dev/null +++ b/cmd/office/ui/detail.go @@ -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() +} diff --git a/cmd/office/ui/detail_test.go b/cmd/office/ui/detail_test.go new file mode 100644 index 0000000..4b4916a --- /dev/null +++ b/cmd/office/ui/detail_test.go @@ -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") + } +} diff --git a/cmd/office/ui/entityform.go b/cmd/office/ui/entityform.go new file mode 100644 index 0000000..fbdc882 --- /dev/null +++ b/cmd/office/ui/entityform.go @@ -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() +} diff --git a/cmd/office/ui/keys.go b/cmd/office/ui/keys.go index a69b9aa..feb0629 100644 --- a/cmd/office/ui/keys.go +++ b/cmd/office/ui/keys.go @@ -9,14 +9,18 @@ const ( ActionNone Action = iota ActionMoveUp ActionMoveDown + ActionMoveLeft + ActionMoveRight + ActionSort ActionToggleSelect - ActionOpenPreview - ActionOpenActions + ActionFocusDetail + ActionToggleMenuPane + ActionToggleListPane + ActionToggleDetailPane ActionNextPane ActionPrevPane ActionRefresh ActionQuit - ActionOpenVex ) // 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": return ActionQuit case "tab": - return ActionNextPane + if pane != model.FocusPreview { + return ActionNextPane + } case "shift+tab", "backtab": - return ActionPrevPane + if pane != model.FocusPreview { + return ActionPrevPane + } case "r": return ActionRefresh case "up", "k": return ActionMoveUp case "down", "j": return ActionMoveDown + case "s": + if pane == model.FocusList { + return ActionSort + } + case "left", "h": + return ActionMoveLeft + case "right", "l": + return ActionMoveRight case " ": if pane == model.FocusList { return ActionToggleSelect } - case "a": + case "v", "p": if pane == model.FocusList { - return ActionOpenActions - } - case "enter": - if pane == model.FocusList { - return ActionOpenPreview - } - case "v": - if pane == model.FocusList { - return ActionOpenVex + return ActionFocusDetail } + case "alt+1": + return ActionToggleMenuPane + case "alt+2": + return ActionToggleListPane + case "alt+3": + return ActionToggleDetailPane } return ActionNone } -// LayoutWidths splits total terminal width into menu, list, preview columns. -func LayoutWidths(total int) (menu, list, preview int) { - if total < 80 { - total = 80 - } - menu = total / 5 - if menu < 22 { - menu = 22 - } - preview = total / 3 - if preview < 28 { - preview = 28 - } - list = total - menu - preview - 2 - if list < 24 { - list = 24 - } - return menu, list, preview +// LayoutWidthsLegacy is kept for tests that expect the old signature. +func LayoutWidthsLegacy(total int) (menu, list, preview int) { + pw := LayoutWidths(total, defaultPaneVisibility()) + return pw.Menu, pw.List, pw.Detail } // ResolveMoveUp returns the action for upward navigation keys. diff --git a/cmd/office/ui/layout.go b/cmd/office/ui/layout.go new file mode 100644 index 0000000..b213399 --- /dev/null +++ b/cmd/office/ui/layout.go @@ -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 +} diff --git a/cmd/office/ui/layout_test.go b/cmd/office/ui/layout_test.go new file mode 100644 index 0000000..dd6a3e2 --- /dev/null +++ b/cmd/office/ui/layout_test.go @@ -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) + } +} diff --git a/cmd/office/ui/scroll.go b/cmd/office/ui/scroll.go new file mode 100644 index 0000000..19cd0ec --- /dev/null +++ b/cmd/office/ui/scroll.go @@ -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 +} diff --git a/cmd/office/ui/table.go b/cmd/office/ui/table.go new file mode 100644 index 0000000..77442d4 --- /dev/null +++ b/cmd/office/ui/table.go @@ -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() +} diff --git a/cmd/office/ui/table_test.go b/cmd/office/ui/table_test.go new file mode 100644 index 0000000..ca8d8a1 --- /dev/null +++ b/cmd/office/ui/table_test.go @@ -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 +}