From fab2977cb12634a532d29d505fca2e8bd7b9a185 Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Wed, 24 Jun 2026 22:23:11 +0100 Subject: [PATCH] feat(office): mail preview, infinite scroll, pane resize, and scrollbars Polish the office TUI with proportional pane layout, drag resize, live filtering, HTML mail preview, paginated mail lists, overflow scrollbars, flattened navigation, and project save/status fixes; add UpdateProjectStatus. Co-authored-by: Cursor --- CHANGELOG.md | 24 ++ cmd/office/fetch/calendar.go | 53 +++ cmd/office/fetch/fetch_integration_test.go | 3 +- cmd/office/fetch/list.go | 75 ++-- cmd/office/fetch/map.go | 2 +- cmd/office/fetch/save.go | 45 ++- cmd/office/fetch/save_integration_test.go | 52 +++ cmd/office/model/calendar_display.go | 40 +++ cmd/office/model/calendar_display_test.go | 37 ++ cmd/office/model/columns.go | 82 ++++- cmd/office/model/columns_test.go | 15 +- cmd/office/model/filter.go | 34 ++ cmd/office/model/filter_test.go | 26 ++ cmd/office/model/formfields.go | 34 +- cmd/office/model/formfields_test.go | 3 + cmd/office/model/item.go | 11 + cmd/office/model/item_mail_test.go | 12 + cmd/office/model/nav.go | 71 +++- cmd/office/model/nav_test.go | 104 +++++- cmd/office/model/project_status.go | 85 +++++ cmd/office/model/project_status_test.go | 26 ++ cmd/office/model/task_display.go | 185 ++++++++++ cmd/office/model/task_display_test.go | 49 +++ cmd/office/preview/entity.go | 64 +++- cmd/office/preview/entity_test.go | 58 +++ .../preview/preview_integration_test.go | 2 +- cmd/office/ui/app.go | 333 +++++++++++++++--- cmd/office/ui/app_test.go | 6 + cmd/office/ui/detail.go | 267 ++++++++++++-- cmd/office/ui/detail_scroll_test.go | 60 ++++ cmd/office/ui/detail_tab_test.go | 65 ++++ cmd/office/ui/entityform.go | 116 +++++- cmd/office/ui/filter.go | 88 +++++ cmd/office/ui/keys.go | 3 + cmd/office/ui/layout.go | 60 ++-- cmd/office/ui/layout_resize.go | 203 +++++++++++ cmd/office/ui/layout_resize_test.go | 41 +++ cmd/office/ui/layout_test.go | 17 + cmd/office/ui/scroll.go | 14 +- cmd/office/ui/scrollbar.go | 76 ++++ cmd/office/ui/scrollbar_test.go | 32 ++ cmd/office/ui/table.go | 140 +++++++- cmd/office/ui/table_append_test.go | 40 +++ cmd/office/ui/table_test.go | 52 ++- projects.go | 12 + 45 files changed, 2611 insertions(+), 206 deletions(-) create mode 100644 cmd/office/fetch/calendar.go create mode 100644 cmd/office/fetch/save_integration_test.go create mode 100644 cmd/office/model/calendar_display.go create mode 100644 cmd/office/model/calendar_display_test.go create mode 100644 cmd/office/model/filter.go create mode 100644 cmd/office/model/filter_test.go create mode 100644 cmd/office/model/item_mail_test.go create mode 100644 cmd/office/model/project_status.go create mode 100644 cmd/office/model/project_status_test.go create mode 100644 cmd/office/model/task_display.go create mode 100644 cmd/office/model/task_display_test.go create mode 100644 cmd/office/preview/entity_test.go create mode 100644 cmd/office/ui/detail_scroll_test.go create mode 100644 cmd/office/ui/detail_tab_test.go create mode 100644 cmd/office/ui/filter.go create mode 100644 cmd/office/ui/layout_resize.go create mode 100644 cmd/office/ui/layout_resize_test.go create mode 100644 cmd/office/ui/scrollbar.go create mode 100644 cmd/office/ui/scrollbar_test.go create mode 100644 cmd/office/ui/table_append_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 04db0aa..dcc3346 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,30 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.7.0] — 2026-06-24 + +### Added — `office` TUI + +- **Pane layout** — default **10% / 60% / 30%** split (nav / list / detail); drag vertical borders to resize; **Alt+1/2/3** still toggles panes. +- **Filter mode** (`f`) — live filter on nav + list; right pane is the search query; **Esc** clears. +- **Mail preview** — HTML bodies rendered colorized in the terminal (glamour); read-only scrollable document pane. +- **Mail infinite scroll** — loads the next page automatically when the cursor nears the end of the list. +- **Scrollbars** — vertical scrollbar on nav, list body, and detail preview when content exceeds the viewport. +- **Calendar** — unified calendars + events leaf with **Type** column and date range (−7d … +30d). +- **Tasks** — humanized status labels and relative deadlines in the list. +- **Projects** — open/closed row colors; status toggle in the detail form; **Save** sends `responsibleId` and status via `UpdateProjectStatus`. + +### Changed — `office` TUI + +- Flattened navigation: **Projects**, **Tasks**, **By project**, **Calendar**, **CRM**, **Mail**, **Users** (removed Browse; Calendar no longer splits Calendars/Events). +- Middle pane truncates overflowing cells with `…`; full text on the cursor or Space-selected row. +- Detail form tab order: Title → Description → Status → Save → Delete. +- **j/k** scrolls mail/file preview and read-only detail forms; mouse wheel scrolls detail when hovering the content area. + +### Added — library + +- `UpdateProjectStatus` — `PUT /api/2.0/project/{id}/status` for open/closed lifecycle. + ## [0.5.1] — 2026-06-24 ### Added — `office` TUI diff --git a/cmd/office/fetch/calendar.go b/cmd/office/fetch/calendar.go new file mode 100644 index 0000000..15e0e89 --- /dev/null +++ b/cmd/office/fetch/calendar.go @@ -0,0 +1,53 @@ +package fetch + +import ( + "context" + "fmt" + "time" + + "github.com/eslider/go-onlyoffice/cmd/office/model" +) + +// CalendarItemFields maps calendar API rows to list items. +var CalendarItemFields = FieldMap{IDKey: "objectId", TitleKey: "title"} + +// CalendarItemsFromRows converts API calendar/event rows into list items. +func CalendarItemsFromRows(rows []map[string]any) []model.Item { + out := make([]model.Item, 0, len(rows)) + for _, row := range rows { + kind, typeLabel := model.ClassifyCalendarRow(row) + title := str(row, "title") + if title == "" { + title = str(row, "name") + } + if title == "" { + title = "(untitled)" + } + raw := make(map[string]any, len(row)+1) + for k, v := range row { + raw[k] = v + } + raw["type"] = typeLabel + out = append(out, model.Item{ + ID: idStr(row, "objectId"), + Title: title, + Kind: kind, + Raw: raw, + }) + } + return out +} + +// ListCalendar returns calendars and events for the upcoming date window. +func (l *Loader) ListCalendar(ctx context.Context) ([]model.Item, error) { + if l == nil || l.Client == nil { + return nil, fmt.Errorf("fetch: client is nil") + } + start := time.Now().AddDate(0, 0, -7).Format("2006-01-02") + end := time.Now().AddDate(0, 0, 30).Format("2006-01-02") + rows, err := l.Client.ListCalendars(ctx, start, end) + if err != nil { + return nil, err + } + return CalendarItemsFromRows(rows), nil +} diff --git a/cmd/office/fetch/fetch_integration_test.go b/cmd/office/fetch/fetch_integration_test.go index ba0aea3..947f1fc 100644 --- a/cmd/office/fetch/fetch_integration_test.go +++ b/cmd/office/fetch/fetch_integration_test.go @@ -39,8 +39,7 @@ func TestIntegrationListAllSubjects(t *testing.T) { cases := []model.ListSpec{ {Subject: model.SubjectProjects}, {Subject: model.SubjectTasks}, - {Subject: model.SubjectCalendars}, - {Subject: model.SubjectEvents}, + {Subject: model.SubjectCalendar}, {Subject: model.SubjectContacts}, {Subject: model.SubjectPersons}, {Subject: model.SubjectCompanies}, diff --git a/cmd/office/fetch/list.go b/cmd/office/fetch/list.go index 27ebb83..d5075ed 100644 --- a/cmd/office/fetch/list.go +++ b/cmd/office/fetch/list.go @@ -6,12 +6,14 @@ import ( "os" "path/filepath" "strconv" - "time" onlyoffice "github.com/eslider/go-onlyoffice" "github.com/eslider/go-onlyoffice/cmd/office/model" ) +// MailListPageSize is how many messages each infinite-scroll fetch loads. +const MailListPageSize = 25 + // Loader fetches list items for a menu subject using the OnlyOffice client. type Loader struct { Client *onlyoffice.Client @@ -30,10 +32,12 @@ func (l *Loader) List(ctx context.Context, spec model.ListSpec) ([]model.Item, e return l.listTasksForProject(ctx, spec.ProjectID) } return l.listTasks(ctx) + case model.SubjectCalendar: + return l.ListCalendar(ctx) case model.SubjectCalendars: - return l.listCalendars(ctx) + return l.ListCalendar(ctx) case model.SubjectEvents: - return l.listEvents(ctx) + return l.ListCalendar(ctx) case model.SubjectContacts: return l.listContacts(ctx, nil) case model.SubjectPersons: @@ -49,15 +53,15 @@ func (l *Loader) List(ctx context.Context, spec model.ListSpec) ([]model.Item, e case model.SubjectCRMTasks: return l.listCRMTasks(ctx) case model.SubjectMailInbox: - return l.listMail(ctx, onlyoffice.MailFolderInbox) + return l.listMail(ctx, onlyoffice.MailFolderInbox, 0) case model.SubjectMailSent: - return l.listMail(ctx, onlyoffice.MailFolderSent) + return l.listMail(ctx, onlyoffice.MailFolderSent, 0) case model.SubjectMailDrafts: - return l.listMail(ctx, onlyoffice.MailFolderDrafts) + return l.listMail(ctx, onlyoffice.MailFolderDrafts, 0) case model.SubjectMailTrash: - return l.listMail(ctx, onlyoffice.MailFolderTrash) + return l.listMail(ctx, onlyoffice.MailFolderTrash, 0) case model.SubjectMailSpam: - return l.listMail(ctx, onlyoffice.MailFolderSpam) + return l.listMail(ctx, onlyoffice.MailFolderSpam, 0) case model.SubjectUsers: return l.listUsers(ctx) case model.SubjectProjectFiles: @@ -260,6 +264,9 @@ func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) { if p.ParticipantCount != nil { raw["participantCount"] = *p.ParticipantCount } + if p.Status != nil { + raw["status"] = *p.Status + } items[i] = model.Item{ ID: id, Title: title, Kind: model.KindProject, Raw: raw, } @@ -275,24 +282,6 @@ func (l *Loader) listTasks(ctx context.Context) ([]model.Item, error) { return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil } -func (l *Loader) listCalendars(ctx context.Context) ([]model.Item, error) { - rows, err := l.Client.ListCalendars(ctx, "", "") - if err != nil { - return nil, err - } - return ItemsFromMaps(rows, model.KindCalendar, FieldMap{IDKey: "objectId", TitleKey: "title"}), nil -} - -func (l *Loader) listEvents(ctx context.Context) ([]model.Item, error) { - start := time.Now().Format("2006-01-02") - end := time.Now().AddDate(0, 0, 7).Format("2006-01-02") - rows, err := l.Client.ListEvents(ctx, start, end) - if err != nil { - return nil, err - } - return ItemsFromMaps(rows, model.KindEvent, FieldMap{IDKey: "objectId", TitleKey: "title", SubtitleKey: "start"}), nil -} - func (l *Loader) listContacts(ctx context.Context, companyOnly *bool) ([]model.Item, error) { rows, err := l.Client.ListAllContacts(ctx) if err != nil { @@ -335,14 +324,44 @@ func (l *Loader) listCRMTasks(ctx context.Context) ([]model.Item, error) { return ItemsFromMaps(rows, model.KindCRMTask, TaskItemFields), nil } -func (l *Loader) listMail(ctx context.Context, folder int) ([]model.Item, error) { - rows, err := l.Client.ListMailMessages(ctx, onlyoffice.MailMessagesFilter{Folder: folder, Count: 50}) +func (l *Loader) listMail(ctx context.Context, folder int, startIndex int) ([]model.Item, error) { + rows, err := l.Client.ListMailMessages(ctx, onlyoffice.MailMessagesFilter{ + Folder: folder, + Count: MailListPageSize, + StartIndex: startIndex, + }) if err != nil { return nil, err } return ItemsFromMaps(rows, model.KindMail, MailItemFields), nil } +// ListMailMore loads the next page of messages for an active mail folder list. +func (l *Loader) ListMailMore(ctx context.Context, spec model.ListSpec, startIndex int) ([]model.Item, error) { + folder, ok := mailFolderForSubject(spec.Subject) + if !ok { + return nil, fmt.Errorf("fetch: %q is not a mail folder", spec.Subject) + } + return l.listMail(ctx, folder, startIndex) +} + +func mailFolderForSubject(subject model.Subject) (int, bool) { + switch subject { + case model.SubjectMailInbox: + return onlyoffice.MailFolderInbox, true + case model.SubjectMailSent: + return onlyoffice.MailFolderSent, true + case model.SubjectMailDrafts: + return onlyoffice.MailFolderDrafts, true + case model.SubjectMailTrash: + return onlyoffice.MailFolderTrash, true + case model.SubjectMailSpam: + return onlyoffice.MailFolderSpam, true + default: + return 0, false + } +} + func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) { users, err := l.Client.GetUsers() if err != nil { diff --git a/cmd/office/fetch/map.go b/cmd/office/fetch/map.go index d19bdd0..13cf92e 100644 --- a/cmd/office/fetch/map.go +++ b/cmd/office/fetch/map.go @@ -14,7 +14,7 @@ type FieldMap struct { } // TaskItemFields is the default field map for project tasks. -var TaskItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"} +var TaskItemFields = FieldMap{IDKey: "id", TitleKey: "title"} // ProjectItemFields is the default field map for projects. var ProjectItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"} diff --git a/cmd/office/fetch/save.go b/cmd/office/fetch/save.go index 5fbc6c5..a7f8a0b 100644 --- a/cmd/office/fetch/save.go +++ b/cmd/office/fetch/save.go @@ -10,29 +10,50 @@ import ( ) // SaveItem persists editable form fields for supported entity kinds. -func (l *Loader) SaveItem(ctx context.Context, item model.Item, title, description string) error { +func (l *Loader) SaveItem(ctx context.Context, item model.Item, fields model.FormFields) 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) + return l.UpdateTask(ctx, item.ID, fields.Primary, fields.Secondary) 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 + return l.saveProject(ctx, item.ID, fields) default: return fmt.Errorf("save not supported for %s", item.Kind) } } +func (l *Loader) saveProject(ctx context.Context, projectID string, fields model.FormFields) error { + id, err := strconv.Atoi(projectID) + if err != nil { + return err + } + if fields.ResponsibleID == "" { + raw, derr := l.Detail(ctx, model.Item{ID: projectID, Kind: model.KindProject}) + if derr == nil { + fields.ResponsibleID = model.ResponsibleIDFromRaw(raw) + } + } + req := onlyoffice.ProjectUpdateRequest{ + ID: id, + Title: fields.Primary, + Description: fields.Secondary, + } + if fields.ResponsibleID != "" { + req.ResponsibleID = fields.ResponsibleID + } + if _, err := l.Client.UpdateProject(req); err != nil { + return err + } + if fields.HasStatus { + if _, err := l.Client.UpdateProjectStatus(id, string(fields.Status)); err != nil { + return err + } + } + return nil +} + // 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) diff --git a/cmd/office/fetch/save_integration_test.go b/cmd/office/fetch/save_integration_test.go new file mode 100644 index 0000000..0b7cd16 --- /dev/null +++ b/cmd/office/fetch/save_integration_test.go @@ -0,0 +1,52 @@ +//go:build integration + +package fetch_test + +import ( + "testing" + "time" + + "github.com/eslider/go-onlyoffice/cmd/office/model" +) + +func TestIntegrationSaveProject(t *testing.T) { + loader, ctx := liveLoader(t) + items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjects}) + if err != nil { + t.Fatal(err) + } + if len(items) == 0 { + t.Skip("no projects") + } + item := items[0] + fields, err := loader.DetailForm(ctx, item) + if err != nil { + t.Fatalf("DetailForm: %v", err) + } + if fields.ResponsibleID == "" { + t.Skip("project has no responsible id on this instance") + } + marker := " office-save-test " + time.Now().UTC().Format(time.RFC3339) + fields.Primary = fields.Primary + marker + if err := loader.SaveItem(ctx, item, fields); err != nil { + t.Fatalf("SaveItem: %v", err) + } + after, err := loader.DetailForm(ctx, item) + if err != nil { + t.Fatalf("DetailForm after save: %v", err) + } + if after.Primary != fields.Primary { + t.Fatalf("title not saved: got %q want %q", after.Primary, fields.Primary) + } + fields.Primary = stringsTrimSuffixMarker(after.Primary, marker) + if err := loader.SaveItem(ctx, item, fields); err != nil { + t.Logf("cleanup save: %v", err) + } +} + +func stringsTrimSuffixMarker(s, marker string) string { + if len(s) >= len(marker) && s[len(s)-len(marker):] == marker { + return s[:len(s)-len(marker)] + } + return s +} diff --git a/cmd/office/model/calendar_display.go b/cmd/office/model/calendar_display.go new file mode 100644 index 0000000..0e86f14 --- /dev/null +++ b/cmd/office/model/calendar_display.go @@ -0,0 +1,40 @@ +package model + +import "fmt" + +// ClassifyCalendarRow decides whether an API row is a calendar or an event. +func ClassifyCalendarRow(raw map[string]any) (Kind, string) { + if raw == nil { + return KindCalendar, "Calendar" + } + if strRaw(raw, "start") != "" || strRaw(raw, "end") != "" { + return KindEvent, "Event" + } + if strRaw(raw, "eventType") != "" { + return KindEvent, "Event" + } + return KindCalendar, "Calendar" +} + +// CalendarTypeLabel returns the type column text for a calendar list row. +func CalendarTypeLabel(it Item) string { + if it.Raw != nil { + if t := strRaw(it.Raw, "type"); t != "" { + return t + } + } + _, label := ClassifyCalendarRow(it.Raw) + return label +} + +// FormatCalendarDateTime formats start/end timestamps for the calendar table. +func FormatCalendarDateTime(v any) string { + t, ok := parseDeadlineTime(v) + if !ok { + if s := fmt.Sprint(v); s != "" && s != "" { + return s + } + return "" + } + return t.Format("Jan 2 15:04") +} diff --git a/cmd/office/model/calendar_display_test.go b/cmd/office/model/calendar_display_test.go new file mode 100644 index 0000000..162d48a --- /dev/null +++ b/cmd/office/model/calendar_display_test.go @@ -0,0 +1,37 @@ +package model + +import "testing" + +func TestClassifyCalendarRow(t *testing.T) { + kind, label := ClassifyCalendarRow(map[string]any{ + "title": "Standup", + "start": "2026-06-24T10:00:00Z", + }) + if kind != KindEvent || label != "Event" { + t.Fatalf("got kind=%s label=%q", kind, label) + } + kind, label = ClassifyCalendarRow(map[string]any{"title": "Work"}) + if kind != KindCalendar || label != "Calendar" { + t.Fatalf("got kind=%s label=%q", kind, label) + } +} + +func TestBuildCalendarColumnsIncludesType(t *testing.T) { + items := []Item{{ + ID: "1", Title: "Meet", Kind: KindEvent, + Raw: map[string]any{"type": "Event", "start": "2026-06-24T10:00:00Z"}, + }} + cols := BuildColumns(SubjectCalendar, items) + found := false + for _, c := range cols { + if c.Key == "type" { + found = true + } + if c.Key == "subtitle" { + t.Fatal("subtitle column should not appear") + } + } + if !found { + t.Fatal("expected type column") + } +} diff --git a/cmd/office/model/columns.go b/cmd/office/model/columns.go index d86a6c3..7f0aa8c 100644 --- a/cmd/office/model/columns.go +++ b/cmd/office/model/columns.go @@ -37,6 +37,12 @@ func BuildColumns(subject Subject, items []Item) []TableColumn { if subject == SubjectProjects { return buildProjectColumns(items) } + if subject == SubjectTasks { + return buildTaskColumns(items) + } + if subject == SubjectCalendar { + return buildCalendarColumns(items) + } cols := []TableColumn{ {Key: "_sel", Title: "✓", Width: 3}, {Key: "id", Title: "ID", Width: 10}, @@ -64,10 +70,43 @@ func BuildColumns(subject Subject, items []Item) []TableColumn { return cols } +func buildCalendarColumns(items []Item) []TableColumn { + cols := []TableColumn{ + {Key: "_sel", Title: "✓", Width: 3}, + {Key: "id", Title: "ID", Width: 10}, + {Key: "type", Title: "Type", Width: 10}, + {Key: "title", Title: "Title", Width: 28}, + } + for _, key := range []string{"start", "end"} { + if columnHasData(items, key) { + cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)}) + } + } + sizeColumns(cols, items) + return cols +} + +func buildTaskColumns(items []Item) []TableColumn { + cols := []TableColumn{ + {Key: "_sel", Title: "✓", Width: 3}, + {Key: "id", Title: "ID", Width: 8}, + {Key: "title", Title: "Title", Width: 28}, + {Key: "status", Title: "Status", Width: 10}, + } + for _, key := range []string{"deadline", "responsible"} { + if columnHasData(items, key) { + 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: "status", Title: "Status", Width: 8}, {Key: "title", Title: "Title", Width: 28}, {Key: "tasks", Title: "Tasks (open/closed)", Width: 18}, {Key: "documents", Title: "Documents", Width: 11}, @@ -89,6 +128,47 @@ func CellText(it Item, key string) string { return it.ID case "title": return it.Title + case "type": + return CalendarTypeLabel(it) + case "start", "end": + if it.Raw == nil { + return "" + } + if formatted := FormatCalendarDateTime(it.Raw[key]); formatted != "" { + return formatted + } + return formatAny(it.Raw[key]) + case "status": + switch it.Kind { + case KindProject: + return ProjectStatusLabel(it.Raw) + case KindTask, KindCRMTask: + if it.Raw == nil { + return "" + } + return TaskStatusLabel(it.Raw["status"]) + default: + if it.Raw == nil { + return "" + } + return formatAny(it.Raw["status"]) + } + case "deadline": + if it.Raw == nil { + return "" + } + if rel := FormatRelativeDeadline(it.Raw["deadline"]); rel != "" { + return rel + } + return formatAny(it.Raw["deadline"]) + case "responsible": + if it.Kind == KindTask || it.Kind == KindCRMTask { + return TaskResponsibleLabel(it.Raw) + } + if it.Raw == nil { + return "" + } + return formatAny(it.Raw["responsible"]) case "subtitle": return it.Subtitle case "tasks": @@ -230,7 +310,7 @@ func defaultWidth(key string) int { case "id", "status", "stage": return 10 case "start", "end", "deadline", "date": - return 18 + return 20 case "from", "to", "email", "primaryEmail": return 22 case "description", "displayName": diff --git a/cmd/office/model/columns_test.go b/cmd/office/model/columns_test.go index df09ae6..48d57f0 100644 --- a/cmd/office/model/columns_test.go +++ b/cmd/office/model/columns_test.go @@ -11,7 +11,7 @@ func TestBuildProjectColumns(t *testing.T) { }, }} cols := BuildColumns(SubjectProjects, items) - want := []string{"_sel", "id", "title", "tasks", "documents", "users"} + want := []string{"_sel", "id", "status", "title", "tasks", "documents", "users"} if len(cols) != len(want) { t.Fatalf("got %d columns, want %d", len(cols), len(want)) } @@ -21,12 +21,23 @@ func TestBuildProjectColumns(t *testing.T) { } } for _, c := range cols { - if c.Key == "status" || c.Key == "subtitle" { + if c.Key == "subtitle" { t.Fatalf("unexpected column %q", c.Key) } } } +func TestCellTextProjectStatus(t *testing.T) { + open := Item{Kind: KindProject, Raw: map[string]any{"status": 0}} + if got := CellText(open, "status"); got != "Open" { + t.Fatalf("got %q", got) + } + closed := Item{Kind: KindProject, Raw: map[string]any{"status": 2}} + if got := CellText(closed, "status"); got != "Closed" { + t.Fatalf("got %q", got) + } +} + func TestFormatProjectTasksOpenClosed(t *testing.T) { raw := map[string]any{"taskCount": 3, "taskCountTotal": 8} if got := formatProjectTasks(raw); got != "3/5" { diff --git a/cmd/office/model/filter.go b/cmd/office/model/filter.go new file mode 100644 index 0000000..7fcc2d2 --- /dev/null +++ b/cmd/office/model/filter.go @@ -0,0 +1,34 @@ +package model + +import ( + "strings" +) + +// FilterItems returns items whose searchable text contains query (case-insensitive). +func FilterItems(items []Item, query string) []Item { + q := strings.ToLower(strings.TrimSpace(query)) + if q == "" { + out := make([]Item, len(items)) + copy(out, items) + return out + } + out := make([]Item, 0, len(items)) + for _, it := range items { + if strings.Contains(strings.ToLower(ItemSearchText(it)), q) { + out = append(out, it) + } + } + return out +} + +// ItemSearchText joins item fields used for list filtering. +func ItemSearchText(it Item) string { + var parts []string + parts = append(parts, it.ID, it.Title, it.Subtitle, string(it.Kind)) + if it.Raw != nil { + for _, v := range it.Raw { + parts = append(parts, formatAny(v)) + } + } + return strings.Join(parts, " ") +} diff --git a/cmd/office/model/filter_test.go b/cmd/office/model/filter_test.go new file mode 100644 index 0000000..6d9f1a7 --- /dev/null +++ b/cmd/office/model/filter_test.go @@ -0,0 +1,26 @@ +package model_test + +import ( + "testing" + + "github.com/eslider/go-onlyoffice/cmd/office/model" +) + +func TestFilterItemsByTitle(t *testing.T) { + items := []model.Item{ + {ID: "1", Title: "Alpha task"}, + {ID: "2", Title: "Beta task"}, + } + got := model.FilterItems(items, "alpha") + if len(got) != 1 || got[0].ID != "1" { + t.Fatalf("got %#v", got) + } +} + +func TestFilterItemsEmptyQueryReturnsAll(t *testing.T) { + items := []model.Item{{ID: "1", Title: "A"}, {ID: "2", Title: "B"}} + got := model.FilterItems(items, " ") + if len(got) != 2 { + t.Fatalf("got len=%d want 2", len(got)) + } +} diff --git a/cmd/office/model/formfields.go b/cmd/office/model/formfields.go index 6e23f11..9d93848 100644 --- a/cmd/office/model/formfields.go +++ b/cmd/office/model/formfields.go @@ -9,6 +9,9 @@ type FormFields struct { Primary string Secondary string ReadOnly bool + HasStatus bool + Status ProjectLifecycle + ResponsibleID string } // KindHeading returns a short label for the detail pane header. @@ -80,6 +83,21 @@ func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields { Primary: strRaw(raw, "title"), Secondary: strRaw(raw, "description"), ReadOnly: true, } + case KindProject: + title := strRaw(raw, "title") + if title == "" { + title = strRaw(raw, "name") + } + return FormFields{ + PrimaryLabel: "Title", + SecondaryLabel: "Description", + Primary: title, + Secondary: strRaw(raw, "description"), + ReadOnly: false, + HasStatus: true, + Status: ProjectStatusFromAny(raw["status"]), + ResponsibleID: ResponsibleIDFromRaw(raw), + } default: title := strRaw(raw, "title") if title == "" { @@ -109,7 +127,19 @@ func strRaw(m map[string]any, key string) string { return fmt.Sprint(m[key]) } -// IsDocumentKind is true when the right pane should show file content, not a form. +// ResponsibleIDFromRaw extracts the project responsible user id from API detail. +func ResponsibleIDFromRaw(raw map[string]any) string { + if id := strRaw(raw, "responsibleId"); id != "" { + return id + } + resp, ok := raw["responsible"].(map[string]any) + if !ok { + return "" + } + return strRaw(resp, "id") +} + +// IsDocumentKind is true when the right pane should show rendered preview content, not an editable form. func IsDocumentKind(kind Kind) bool { - return kind == KindFile + return kind == KindFile || kind == KindMail } diff --git a/cmd/office/model/formfields_test.go b/cmd/office/model/formfields_test.go index 1a19334..2e63484 100644 --- a/cmd/office/model/formfields_test.go +++ b/cmd/office/model/formfields_test.go @@ -14,6 +14,9 @@ func TestIsDocumentKind(t *testing.T) { if !IsDocumentKind(KindFile) { t.Fatal("file should be document") } + if !IsDocumentKind(KindMail) { + t.Fatal("mail should be document preview") + } if IsDocumentKind(KindTask) { t.Fatal("task should be form") } diff --git a/cmd/office/model/item.go b/cmd/office/model/item.go index a325216..f522000 100644 --- a/cmd/office/model/item.go +++ b/cmd/office/model/item.go @@ -39,8 +39,19 @@ const ( SubjectProjectFiles Subject = "project_files" SubjectTaskFiles Subject = "task_files" SubjectUsers Subject = "users" + SubjectCalendar Subject = "calendar" ) +// SubjectIsMail reports whether a list subject loads mailbox messages. +func SubjectIsMail(s Subject) bool { + switch s { + case SubjectMailInbox, SubjectMailSent, SubjectMailDrafts, SubjectMailTrash, SubjectMailSpam: + return true + default: + return false + } +} + // FocusPane is which column has keyboard focus. type FocusPane int diff --git a/cmd/office/model/item_mail_test.go b/cmd/office/model/item_mail_test.go new file mode 100644 index 0000000..766c72b --- /dev/null +++ b/cmd/office/model/item_mail_test.go @@ -0,0 +1,12 @@ +package model + +import "testing" + +func TestSubjectIsMail(t *testing.T) { + if !SubjectIsMail(SubjectMailInbox) { + t.Fatal("inbox should be mail") + } + if SubjectIsMail(SubjectProjects) { + t.Fatal("projects should not be mail") + } +} diff --git a/cmd/office/model/nav.go b/cmd/office/model/nav.go index 97a3ba0..c3bc7f9 100644 --- a/cmd/office/model/nav.go +++ b/cmd/office/model/nav.go @@ -1,5 +1,7 @@ package model +import "strings" + // ListSpec tells the fetch layer what to load when a nav leaf is active. type ListSpec struct { Subject Subject @@ -24,6 +26,7 @@ type NavTree struct { expanded map[string]bool cursor int visible []string + filter string } // DefaultNavTree returns the OnlyOffice module tree (static skeleton). @@ -42,15 +45,11 @@ func DefaultNavTree() *NavTree { } } - add("projects", "Projects", "", true, nil) - add("projects.browse", "Browse", "projects", true, nil) - add("projects.browse.all", "All projects", "projects.browse", false, &ListSpec{Subject: SubjectProjects}) - add("projects.browse.tasks", "All tasks", "projects.browse", false, &ListSpec{Subject: SubjectTasks}) - add("projects.dynamic", "By project", "projects", true, nil) + add("projects", "Projects", "", false, &ListSpec{Subject: SubjectProjects}) + add("tasks", "Tasks", "", false, &ListSpec{Subject: SubjectTasks}) + add("projects.dynamic", "By project", "", true, nil) - add("calendar", "Calendar", "", true, nil) - add("calendar.cals", "Calendars", "calendar", false, &ListSpec{Subject: SubjectCalendars}) - add("calendar.events", "Events", "calendar", false, &ListSpec{Subject: SubjectEvents}) + add("calendar", "Calendar", "", false, &ListSpec{Subject: SubjectCalendar}) add("crm", "CRM", "", true, nil) add("crm.contacts", "Contacts", "crm", false, &ListSpec{Subject: SubjectContacts}) @@ -185,20 +184,66 @@ func (t *NavTree) Activate() (*ListSpec, bool) { return nil, false } +func (t *NavTree) SetFilter(query string) { + t.filter = strings.ToLower(strings.TrimSpace(query)) + t.rebuildVisible() +} + +func (t *NavTree) ClearFilter() { + t.filter = "" + t.rebuildVisible() +} + +func (t *NavTree) FilterQuery() string { return t.filter } + +func (t *NavTree) nodeLabelMatches(id string) bool { + if t.filter == "" { + return true + } + n, ok := t.nodes[id] + if !ok { + return false + } + return strings.Contains(strings.ToLower(n.Label), t.filter) +} + +func (t *NavTree) hasMatchingDescendant(id string) bool { + for _, child := range t.children[id] { + if t.nodeLabelMatches(child) || t.hasMatchingDescendant(child) { + return true + } + } + return false +} + +func (t *NavTree) visibleUnderFilter(id string) bool { + if t.filter == "" { + return true + } + return t.nodeLabelMatches(id) || t.hasMatchingDescendant(id) +} + func (t *NavTree) rebuildVisible() { t.visible = t.visible[:0] - var walk func(id string, depth int) - walk = func(id string, depth int) { + var walk func(id string) + walk = func(id string) { + if !t.visibleUnderFilter(id) { + return + } t.visible = append(t.visible, id) - if !t.expanded[id] { + expanded := t.expanded[id] + if t.filter != "" && t.hasMatchingDescendant(id) { + expanded = true + } + if !expanded { return } for _, child := range t.children[id] { - walk(child, depth+1) + walk(child) } } for _, root := range t.roots { - walk(root, 0) + walk(root) } if t.cursor >= len(t.visible) { t.cursor = len(t.visible) - 1 diff --git a/cmd/office/model/nav_test.go b/cmd/office/model/nav_test.go index 194805a..b747ca2 100644 --- a/cmd/office/model/nav_test.go +++ b/cmd/office/model/nav_test.go @@ -9,7 +9,7 @@ import ( func TestNavTreeHasExpectedRoots(t *testing.T) { tree := model.DefaultNavTree() roots := tree.RootLabels() - want := []string{"Projects", "Calendar", "CRM", "Mail", "Users"} + want := []string{"Projects", "Tasks", "By project", "Calendar", "CRM", "Mail", "Users"} if len(roots) != len(want) { t.Fatalf("roots=%v want %v", roots, want) } @@ -22,42 +22,79 @@ func TestNavTreeHasExpectedRoots(t *testing.T) { func TestNavExpandCollapse(t *testing.T) { tree := model.DefaultNavTree() - if !tree.IsExpandable(0) { - t.Fatal("Projects should be expandable") + var crm int = -1 + for i := 0; i < tree.VisibleCount(); i++ { + n, ok := tree.NodeAtVisible(i) + if ok && n.Label == "CRM" { + crm = i + break + } } - tree.ToggleExpand(0) - if !tree.IsExpanded(0) { - t.Fatal("Projects should expand") + if crm < 0 { + t.Fatal("CRM node not found") } - tree.ToggleExpand(0) - if tree.IsExpanded(0) { - t.Fatal("Projects should collapse") + if !tree.IsExpandable(crm) { + t.Fatal("CRM should be expandable") + } + tree.ToggleExpand(crm) + if !tree.IsExpanded(crm) { + t.Fatal("CRM should expand") + } + tree.ToggleExpand(crm) + if tree.IsExpanded(crm) { + t.Fatal("CRM should collapse") } } -func TestNavLeafReturnsListSpec(t *testing.T) { +func TestNavCalendarLeafReturnsListSpec(t *testing.T) { tree := model.DefaultNavTree() - tree.ToggleExpand(0) // Projects - tree.ToggleExpand(1) // Browse - // Find "All projects" leaf cursor var found int = -1 for i := 0; i < tree.VisibleCount(); i++ { n, ok := tree.NodeAtVisible(i) - if ok && n.List != nil && n.List.Subject == model.SubjectProjects { + if ok && n.Label == "Calendar" { found = i break } } if found < 0 { - t.Fatal("all projects leaf not found") + t.Fatal("Calendar node not found") } tree.SetCursor(found) spec, ok := tree.CurrentListSpec() - if !ok || spec.Subject != model.SubjectProjects { + if !ok || spec.Subject != model.SubjectCalendar { t.Fatalf("spec=%v ok=%v", spec, ok) } } +func TestNavProjectsLeafReturnsListSpec(t *testing.T) { + tree := model.DefaultNavTree() + tree.SetCursor(0) + spec, ok := tree.CurrentListSpec() + if !ok || spec.Subject != model.SubjectProjects { + t.Fatalf("projects spec=%v ok=%v", spec, ok) + } +} + +func TestNavTasksLeafReturnsListSpec(t *testing.T) { + tree := model.DefaultNavTree() + var found int = -1 + for i := 0; i < tree.VisibleCount(); i++ { + n, ok := tree.NodeAtVisible(i) + if ok && n.Label == "Tasks" { + found = i + break + } + } + if found < 0 { + t.Fatal("Tasks node not found") + } + tree.SetCursor(found) + spec, ok := tree.CurrentListSpec() + if !ok || spec.Subject != model.SubjectTasks { + t.Fatalf("tasks spec=%v ok=%v", spec, ok) + } +} + func TestPrevFocusPane(t *testing.T) { if got := model.PrevFocusPane(model.FocusList); got != model.FocusMenu { t.Fatalf("got %v", got) @@ -67,6 +104,41 @@ func TestPrevFocusPane(t *testing.T) { } } +func TestNavFilterByLabel(t *testing.T) { + tree := model.DefaultNavTree() + tree.SetFilter("inbox") + found := false + for i := 0; i < tree.VisibleCount(); i++ { + n, ok := tree.NodeAtVisible(i) + if ok && n.Label == "Inbox" { + found = true + break + } + } + if !found { + t.Fatal("Inbox should remain visible when filtering mail") + } + for i := 0; i < tree.VisibleCount(); i++ { + n, ok := tree.NodeAtVisible(i) + if ok && n.Label == "Calendar" { + t.Fatal("Calendar should be hidden when filtering inbox") + } + } +} + +func TestNavClearFilterRestoresTree(t *testing.T) { + tree := model.DefaultNavTree() + before := tree.VisibleCount() + tree.SetFilter("zzz-no-match") + if tree.VisibleCount() >= before { + t.Fatalf("filter should shrink visible nodes") + } + tree.ClearFilter() + if tree.VisibleCount() != before { + t.Fatalf("clear filter should restore visible count") + } +} + func TestActionsForContact(t *testing.T) { acts := model.ActionsFor(model.KindContact) if len(acts) != 1 || acts[0].ID != model.ActionDelete { diff --git a/cmd/office/model/project_status.go b/cmd/office/model/project_status.go new file mode 100644 index 0000000..d5080b2 --- /dev/null +++ b/cmd/office/model/project_status.go @@ -0,0 +1,85 @@ +package model + +// ProjectLifecycle is the OnlyOffice project status string accepted by the API. +type ProjectLifecycle string + +const ( + ProjectLifecycleOpen ProjectLifecycle = "open" + ProjectLifecyclePaused ProjectLifecycle = "paused" + ProjectLifecycleClosed ProjectLifecycle = "closed" +) + +// ProjectStatusFromAny maps API status (int or string) to a lifecycle value. +func ProjectStatusFromAny(v any) ProjectLifecycle { + switch x := v.(type) { + case string: + switch ProjectLifecycle(x) { + case ProjectLifecycleOpen, ProjectLifecyclePaused, ProjectLifecycleClosed: + return ProjectLifecycle(x) + } + case float64: + return projectStatusFromInt(int(x)) + case int: + return projectStatusFromInt(x) + case int64: + return projectStatusFromInt(int(x)) + } + return ProjectLifecycleOpen +} + +func projectStatusFromInt(n int) ProjectLifecycle { + switch n { + case 1: + return ProjectLifecyclePaused + case 2: + return ProjectLifecycleClosed + default: + return ProjectLifecycleOpen + } +} + +// ProjectStatusLabel returns a short UI label for list cells. +func ProjectStatusLabel(raw map[string]any) string { + if raw == nil { + return "Open" + } + return ProjectStatusFromAny(raw["status"]).Label() +} + +// ProjectIsOpen is true when the project is not closed. +func ProjectIsOpen(raw map[string]any) bool { + if raw == nil { + return true + } + return ProjectStatusFromAny(raw["status"]) != ProjectLifecycleClosed +} + +// Label returns a human-readable status name. +func (s ProjectLifecycle) Label() string { + switch s { + case ProjectLifecycleClosed: + return "Closed" + case ProjectLifecyclePaused: + return "Paused" + default: + return "Open" + } +} + +// ToggleOpenClosed flips between open and closed (paused maps to closed on toggle). +func (s ProjectLifecycle) ToggleOpenClosed() ProjectLifecycle { + if s == ProjectLifecycleClosed { + return ProjectLifecycleOpen + } + return ProjectLifecycleClosed +} + +// Next cycles open → closed → open for keyboard toggling. +func (s ProjectLifecycle) Next() ProjectLifecycle { + return s.ToggleOpenClosed() +} + +// Prev cycles closed → open → closed. +func (s ProjectLifecycle) Prev() ProjectLifecycle { + return s.ToggleOpenClosed() +} diff --git a/cmd/office/model/project_status_test.go b/cmd/office/model/project_status_test.go new file mode 100644 index 0000000..9c17a28 --- /dev/null +++ b/cmd/office/model/project_status_test.go @@ -0,0 +1,26 @@ +package model_test + +import ( + "testing" + + "github.com/eslider/go-onlyoffice/cmd/office/model" +) + +func TestProjectStatusFromInt(t *testing.T) { + if got := model.ProjectStatusFromAny(2); got != model.ProjectLifecycleClosed { + t.Fatalf("got %q", got) + } + if got := model.ProjectStatusFromAny(0); got != model.ProjectLifecycleOpen { + t.Fatalf("got %q", got) + } +} + +func TestProjectStatusToggle(t *testing.T) { + s := model.ProjectLifecycleOpen + if s.Next() != model.ProjectLifecycleClosed { + t.Fatal("expected closed") + } + if s.Next().Prev() != model.ProjectLifecycleOpen { + t.Fatal("expected open after toggle back") + } +} diff --git a/cmd/office/model/task_display.go b/cmd/office/model/task_display.go new file mode 100644 index 0000000..4008dde --- /dev/null +++ b/cmd/office/model/task_display.go @@ -0,0 +1,185 @@ +package model + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// TaskStatusLabel returns a human-readable project/CRM task status. +func TaskStatusLabel(v any) string { + if v == nil { + return "" + } + if s, ok := v.(string); ok { + if label := taskStatusFromString(s); label != "" { + return label + } + } + n := intRawVal(map[string]any{"status": v}, "status") + switch n { + case 0: + return "Not accepted" + case 1: + return "Open" + case 2: + return "Closed" + case 3: + return "Disabled" + case 4: + return "Unclassified" + case 5: + return "Not in milestone" + default: + if s := fmt.Sprint(v); s != "" && s != "" { + return s + } + return "" + } +} + +func taskStatusFromString(s string) string { + switch strings.ToLower(strings.TrimSpace(s)) { + case "open": + return "Open" + case "closed": + return "Closed" + case "notaccept", "not accept", "not accepted": + return "Not accepted" + case "disable", "disabled": + return "Disabled" + case "unclassified": + return "Unclassified" + case "notinmilestone", "not in milestone": + return "Not in milestone" + default: + return "" + } +} + +// FormatRelativeDeadline formats a deadline as "in 2 hours" or "3 days ago". +func FormatRelativeDeadline(v any) string { + return FormatRelativeDeadlineAt(v, time.Now()) +} + +// FormatRelativeDeadlineAt is FormatRelativeDeadline with a fixed clock (for tests). +func FormatRelativeDeadlineAt(v any, now time.Time) string { + t, ok := parseDeadlineTime(v) + if !ok { + return "" + } + return relativeTimePhrase(now, t) +} + +func relativeTimePhrase(now, target time.Time) string { + d := target.Sub(now) + future := d >= 0 + if !future { + d = -d + } + phrase := func(n int, unit string) string { + if n == 1 { + unit = strings.TrimSuffix(unit, "s") + } + if future { + return fmt.Sprintf("in %d %s", n, unit) + } + return fmt.Sprintf("%d %s ago", n, unit) + } + switch { + case d < time.Minute: + if future { + return "in 1 minute" + } + return "just now" + case d < time.Hour: + return phrase(int(d.Round(time.Minute)/time.Minute), "minutes") + case d < 24*time.Hour: + return phrase(int(d.Round(time.Hour)/time.Hour), "hours") + default: + return phrase(int(d.Round(24*time.Hour)/(24*time.Hour)), "days") + } +} + +func parseDeadlineTime(v any) (time.Time, bool) { + if v == nil { + return time.Time{}, false + } + switch x := v.(type) { + case time.Time: + return x, true + case string: + s := strings.TrimSpace(x) + if s == "" { + return time.Time{}, false + } + formats := []string{ + time.RFC3339Nano, + "2006-01-02T15:04:05.0000000-07:00", + "2006-01-02T15:04:05-07:00", + "2006-01-02T15:04:05", + "2006-01-02", + } + for _, layout := range formats { + if t, err := time.Parse(layout, s); err == nil { + return t, true + } + } + return time.Time{}, false + case float64: + return time.Unix(int64(x), 0), true + case int64: + return time.Unix(x, 0), true + case int: + return time.Unix(int64(x), 0), true + default: + return parseDeadlineTime(fmt.Sprint(v)) + } +} + +// TaskResponsibleLabel returns the assignee display name when present. +func TaskResponsibleLabel(raw map[string]any) string { + if raw == nil { + return "" + } + if name := personName(raw["responsible"]); name != "" { + return name + } + if list, ok := raw["responsibles"].([]any); ok { + names := make([]string, 0, len(list)) + for _, entry := range list { + if name := personName(entry); name != "" { + names = append(names, name) + } + } + if len(names) > 0 { + return strings.Join(names, ", ") + } + } + if s := strRaw(raw, "responsible"); s != "" && !looksLikeID(s) { + return s + } + return "" +} + +func personName(v any) string { + m, ok := v.(map[string]any) + if !ok { + return "" + } + if name := strRaw(m, "displayName"); name != "" { + return name + } + first := strRaw(m, "firstName") + last := strRaw(m, "lastName") + return strings.TrimSpace(first + " " + last) +} + +func looksLikeID(s string) bool { + if len(s) == 36 && strings.Count(s, "-") == 4 { + return true + } + _, err := strconv.Atoi(s) + return err == nil +} diff --git a/cmd/office/model/task_display_test.go b/cmd/office/model/task_display_test.go new file mode 100644 index 0000000..ce32cc2 --- /dev/null +++ b/cmd/office/model/task_display_test.go @@ -0,0 +1,49 @@ +package model + +import ( + "testing" + "time" +) + +func TestTaskStatusLabelFromInt(t *testing.T) { + if got := TaskStatusLabel(1); got != "Open" { + t.Fatalf("got %q", got) + } + if got := TaskStatusLabel(2); got != "Closed" { + t.Fatalf("got %q", got) + } +} + +func TestFormatRelativeDeadlineFuture(t *testing.T) { + now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) + deadline := now.Add(2 * time.Hour) + got := FormatRelativeDeadlineAt(deadline.Format(time.RFC3339), now) + if got != "in 2 hours" { + t.Fatalf("got %q", got) + } +} + +func TestFormatRelativeDeadlineDays(t *testing.T) { + now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC) + deadline := now.Add(26 * time.Hour) + got := FormatRelativeDeadlineAt(deadline.Format(time.RFC3339), now) + if got != "in 1 day" { + t.Fatalf("got %q", got) + } +} + +func TestBuildTaskColumnsOmitsSubtitle(t *testing.T) { + items := []Item{{ + ID: "1", Title: "Fix bug", Kind: KindTask, Subtitle: "1", + Raw: map[string]any{"status": 1, "deadline": "2026-06-25T12:00:00Z"}, + }} + cols := BuildColumns(SubjectTasks, items) + for _, c := range cols { + if c.Key == "subtitle" { + t.Fatalf("unexpected subtitle column") + } + } + if CellText(items[0], "status") != "Open" { + t.Fatalf("status not humanized") + } +} diff --git a/cmd/office/preview/entity.go b/cmd/office/preview/entity.go index 6fdfc87..9f62706 100644 --- a/cmd/office/preview/entity.go +++ b/cmd/office/preview/entity.go @@ -42,25 +42,71 @@ func OpportunityMarkdown(m map[string]any) string { return strings.TrimSpace(b.String()) + "\n" } -// MailMarkdown formats a mail message for preview. +// MailMarkdown formats a mail message for terminal preview (HTML body is converted to markdown). func MailMarkdown(m map[string]any) string { var b strings.Builder fmt.Fprintf(&b, "# %s\n\n", str(m, "subject")) - fmt.Fprintf(&b, "**From:** %s\n\n", str(m, "from")) - if to := str(m, "to"); to != "" { + fmt.Fprintf(&b, "**From:** %s\n\n", mailAddressLine(m, "from")) + if to := mailAddressLine(m, "to"); to != "" { fmt.Fprintf(&b, "**To:** %s\n\n", to) } - body := str(m, "body") - if body == "" { - body = str(m, "htmlBody") + if cc := mailAddressLine(m, "cc"); cc != "" { + fmt.Fprintf(&b, "**Cc:** %s\n\n", cc) } - body = stripHTML(body) - if body != "" { - fmt.Fprintf(&b, "## Body\n\n%s\n\n", body) + if date := str(m, "date"); date != "" { + fmt.Fprintf(&b, "**Date:** %s\n\n", date) + } + if body := mailBodyMarkdown(m); body != "" { + fmt.Fprintf(&b, "---\n\n%s\n", body) } return strings.TrimSpace(b.String()) + "\n" } +func mailAddressLine(m map[string]any, key string) string { + if v := str(m, key); v != "" { + return v + } + name := str(m, key+"Name") + addr := str(m, key+"Address") + switch { + case name != "" && addr != "": + return fmt.Sprintf("%s <%s>", name, addr) + case addr != "": + return addr + default: + return name + } +} + +func mailBodyMarkdown(m map[string]any) string { + htmlBody := strings.TrimSpace(str(m, "htmlBody")) + plainBody := strings.TrimSpace(str(m, "body")) + + for _, candidate := range []string{htmlBody, plainBody} { + if candidate == "" { + continue + } + if looksLikeHTML(candidate) { + if md, err := HTMLToMarkdown(candidate); err == nil { + if md = strings.TrimSpace(md); md != "" { + return md + } + } + } + } + if plainBody != "" { + return plainBody + } + if htmlBody != "" { + return stripHTML(htmlBody) + } + return "" +} + +func looksLikeHTML(s string) bool { + return strings.Contains(s, "<") && strings.Contains(s, ">") && htmlTagRe.MatchString(s) +} + // EventMarkdown formats a calendar event for preview. func EventMarkdown(m map[string]any) string { var b strings.Builder diff --git a/cmd/office/preview/entity_test.go b/cmd/office/preview/entity_test.go new file mode 100644 index 0000000..2c1c769 --- /dev/null +++ b/cmd/office/preview/entity_test.go @@ -0,0 +1,58 @@ +package preview_test + +import ( + "strings" + "testing" + + "github.com/eslider/go-onlyoffice/cmd/office/preview" +) + +func TestMailMarkdownConvertsHTMLBody(t *testing.T) { + raw := map[string]any{ + "subject": "Weekly update", + "from": `"Team" `, + "htmlBody": `

Hello world

Link

`, + } + md := preview.MailMarkdown(raw) + if !strings.Contains(md, "Weekly update") { + t.Fatalf("missing subject:\n%s", md) + } + if !strings.Contains(md, "team@example.com") { + t.Fatalf("missing from:\n%s", md) + } + if strings.Contains(md, "") || strings.Contains(md, "

") { + t.Fatalf("expected HTML converted, got raw tags:\n%s", md) + } + if !strings.Contains(md, "world") { + t.Fatalf("missing body text:\n%s", md) + } +} + +func TestMailMarkdownRendersWithColor(t *testing.T) { + md := preview.MailMarkdown(map[string]any{ + "subject": "Styled", + "from": "a@b.com", + "htmlBody": `

Heading

Plain text

`, + }) + out, err := preview.RenderMarkdown(md, 60) + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(out) == "" { + t.Fatal("empty render output") + } + if !strings.Contains(out, "Heading") || !strings.Contains(out, "Plain text") { + t.Fatalf("missing rendered content:\n%s", out) + } +} + +func TestMailMarkdownPlainBodyFallback(t *testing.T) { + md := preview.MailMarkdown(map[string]any{ + "subject": "Hi", + "from": "a@b.com", + "body": "Just plain text", + }) + if !strings.Contains(md, "Just plain text") { + t.Fatalf("missing plain body:\n%s", md) + } +} diff --git a/cmd/office/preview/preview_integration_test.go b/cmd/office/preview/preview_integration_test.go index 7930a30..e71907c 100644 --- a/cmd/office/preview/preview_integration_test.go +++ b/cmd/office/preview/preview_integration_test.go @@ -119,7 +119,7 @@ func TestIntegrationPreviewMailFromAPI(t *testing.T) { func TestIntegrationPreviewEventFromAPI(t *testing.T) { loader, ctx := liveLoader(t) - items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectEvents}) + items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectCalendar}) if err != nil { t.Fatal(err) } diff --git a/cmd/office/ui/app.go b/cmd/office/ui/app.go index 6626347..beace13 100644 --- a/cmd/office/ui/app.go +++ b/cmd/office/ui/app.go @@ -18,6 +18,11 @@ type listLoadedMsg struct { err error } +type listMoreLoadedMsg struct { + items []model.Item + err error +} + type detailLoadedMsg struct { item model.Item document bool @@ -27,9 +32,9 @@ type detailLoadedMsg struct { } type detailSavedMsg struct { - item model.Item - title, description string - err error + item model.Item + fields model.FormFields + err error } type actionDoneMsg struct { @@ -51,6 +56,8 @@ type Model struct { hasList bool items []model.Item selection *model.Selection + listHasMore bool + listLoadingMore bool focus model.FocusPane width int height int @@ -63,6 +70,11 @@ type Model struct { showMenu bool showList bool showDetail bool + filterActive bool + filterSearch FilterSearch + customPaneLayout bool + paneSizes PaneWidths + resize paneResizeState } // NewModel constructs the TUI with an authenticated client. @@ -74,20 +86,21 @@ func NewModel(client *onlyoffice.Client) Model { nav: model.DefaultNavTree(), selection: model.NewSelection(), focus: model.FocusMenu, - status: "Tab: pane · row select loads detail · Ctrl+S save · q quit", + status: "Tab: pane · f: filter · 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.filterSearch = newFilterSearch() m.showMenu, m.showList, m.showDetail = true, true, true m.menuVP.MouseWheelEnabled = true return m } func (m Model) Init() tea.Cmd { - return tea.Batch(m.loadNavProjectsCmd(), m.detail.BlinkCmd()) + return tea.Batch(m.loadNavProjectsCmd(), m.detail.BlinkCmd(), filterSearchBlinkCmd()) } func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -95,12 +108,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height + if m.customPaneLayout { + m.paneSizes = FitPaneWidths(m.width, m.paneVis(), m.paneSizes) + } m.layoutViewports() m.syncAllContent() return m, nil case tea.KeyMsg: key := msg.String() + if key == "esc" && m.filterActive { + m.exitFilter() + return m, nil + } + if m.filterActive && m.focus == model.FocusPreview && m.showDetail { + switch key { + case "tab", "shift+tab", "backtab": + // allow pane switching while filter stays active + default: + cmd := m.filterSearch.Update(msg) + m.applyFilter() + return m, cmd + } + } if key == "?" { m.status = helpText() return m, nil @@ -108,7 +138,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.scrollFocusedPane(key) { return m, nil } - if m.focus == model.FocusPreview { + if m.filterActive && m.focus == model.FocusPreview { + // filter input owns keys except pane switching handled above + } else if m.focus == model.FocusPreview { if cmd, handled := m.handleDetailKey(key, msg); handled { return m, cmd } @@ -121,11 +153,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ActionQuit: return m, tea.Quit case ActionNextPane: + prev := m.focus m.focus = NextVisibleFocus(m.focus, m.paneVis()) + if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive { + m.detail.FocusFirstStop() + } m.syncPaneFocus() return m, nil case ActionPrevPane: + prev := m.focus m.focus = PrevVisibleFocus(m.focus, m.paneVis()) + if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive { + m.detail.FocusFirstStop() + } m.syncPaneFocus() return m, nil case ActionMoveUp: @@ -144,7 +184,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.onListRowChanged() case ActionMoveLeft: if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions { - m.detail.MoveAction(-1) + m.detail.MoveTabStop(-1) return m, nil } if m.focus == model.FocusList && m.hasList { @@ -153,7 +193,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case ActionMoveRight: if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions { - m.detail.MoveAction(1) + m.detail.MoveTabStop(1) return m, nil } if m.focus == model.FocusList && m.hasList { @@ -167,10 +207,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case ActionToggleSelect: if m.hasList { - idx := m.listTable.ItemIndex() - if idx >= 0 { - m.selection.Toggle(&m.items, idx) - m.listTable.UpdateItems(m.items) + if it, ok := m.listTableItem(); ok { + for i := range m.items { + if m.items[i].ID == it.ID { + m.selection.Toggle(&m.items, i) + break + } + } + m.syncListTable() } } return m, nil @@ -178,7 +222,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.hasList && m.showDetail { m.focus = model.FocusPreview m.syncPaneFocus() - m.detail.FocusContent() + m.detail.FocusFirstStop() return m, m.onListRowChanged() } return m, nil @@ -191,6 +235,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ActionToggleDetailPane: m.togglePane(3) return m, nil + case ActionFilter: + m.enterFilter() + return m, nil case ActionRefresh: if m.hasList { m.loading = true @@ -222,10 +269,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case listLoadedMsg: m.loading = false + m.listLoadingMore = false if msg.err != nil { m.err = msg.err.Error() m.items = nil m.hasList = false + m.listHasMore = false m.listTable.Clear() m.detail.Clear() } else { @@ -233,9 +282,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.items = msg.items m.listSpec = msg.spec m.hasList = true + m.listHasMore = model.SubjectIsMail(msg.spec.Subject) && len(msg.items) >= fetch.MailListPageSize m.listTable.SetData(m.listSpec, m.items) + if m.filterActive { + m.applyFilter() + } } - return m, m.onListRowChanged() + return m, tea.Batch(m.onListRowChanged(), m.maybeLoadMoreList()) + + case listMoreLoadedMsg: + m.listLoadingMore = false + m.listTable.SetLoadingMore(false) + if msg.err != nil { + m.err = msg.err.Error() + return m, nil + } + if len(msg.items) == 0 { + m.listHasMore = false + return m, nil + } + added := m.listTable.AppendItems(msg.items) + if added > 0 { + m.items = m.listTable.Items() + } + m.listHasMore = len(msg.items) >= fetch.MailListPageSize + m.err = "" + return m, nil case detailLoadedMsg: m.loading = false @@ -263,11 +335,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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.updateItemAfterSave(msg.item, msg.fields) + m.detail.LoadForm(msg.item, msg.fields) m.detail.SetFocused(m.focus == model.FocusPreview) m.status = "Saved" m.err = "" @@ -296,6 +365,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case tea.MouseMsg: + if m.handlePaneResizeMouse(msg) { + return m, nil + } + if cmd, handled := m.routeDetailDocumentMouse(msg); handled { + return m, cmd + } var cmd tea.Cmd switch m.focus { case model.FocusMenu: @@ -303,7 +378,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case model.FocusList: cmd = m.listTable.Update(msg) case model.FocusPreview: - cmd = m.detail.Update(msg) + if !m.filterActive { + cmd = m.detail.Update(msg) + } } return m, cmd } @@ -315,7 +392,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case model.FocusList: cmd = m.listTable.Update(msg) case model.FocusPreview: - cmd = m.detail.Update(msg) + if !m.filterActive { + cmd = m.detail.Update(msg) + } } return m, cmd } @@ -323,7 +402,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) { switch key { case "tab", "shift+tab", "backtab": - m.detail.ToggleZone() + reverse := key != "tab" + if reverse { + if m.detail.TabBackward() { + m.focus = PrevVisibleFocus(m.focus, m.paneVis()) + } + } else if m.detail.TabForward() { + m.focus = NextVisibleFocus(m.focus, m.paneVis()) + } + m.syncPaneFocus() return nil, true case "ctrl+s": m.loading = true @@ -347,16 +434,37 @@ func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) { return nil, false } +func (m *Model) routeDetailDocumentMouse(msg tea.MouseMsg) (tea.Cmd, bool) { + if m.filterActive || !m.showDetail { + return nil, false + } + if !m.detail.IsDocumentContent() && !m.detail.isReadOnlyFormContent() { + return nil, false + } + if msg.Y > m.paneHeight()+1 { + return nil, false + } + x0, x1 := DetailPaneXRange(m.paneLayout()) + if x0 >= x1 || msg.X < x0 || msg.X >= x1 { + return nil, false + } + cmd := m.detail.ScrollDocumentMouse(msg) + if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown { + return cmd, true + } + return cmd, false +} + func (m Model) handleDetailMove(delta int) (Model, tea.Cmd) { - if m.detail.Zone() == detailZoneActions { - m.detail.MoveAction(delta) + if m.detail.Zone() == detailZoneContent && (m.detail.IsDocumentContent() || m.detail.isReadOnlyFormContent()) { + key := "down" + if delta < 0 { + key = "up" + } + m.detail.ScrollDocument(key) return m, nil } - if delta < 0 { - m.detail.form.FocusPrev() - } else { - m.detail.form.FocusNext() - } + m.detail.MoveTabStop(delta) return m, nil } @@ -369,7 +477,14 @@ func (m Model) View() string { 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())) + menuBody := ApplyVerticalScrollbar( + m.menuVP.View(), + m.menuVP.Width, + m.menuVP.Height, + m.menuVP.TotalLineCount(), + m.menuVP.YOffset, + ) + parts = append(parts, menuStyle.Render(menuBody)) } if pw.Visibility.List { listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h) @@ -377,7 +492,13 @@ func (m Model) View() string { } if pw.Visibility.Detail { prevStyle := paneStyle(m.focus == model.FocusPreview).Width(pw.Detail).Height(h) - parts = append(parts, prevStyle.Render(m.detail.View())) + var detailBody string + if m.filterActive { + detailBody = m.filterSearch.View() + } else { + detailBody = m.detail.View() + } + parts = append(parts, prevStyle.Render(detailBody)) } status := m.status @@ -397,11 +518,21 @@ func (m Model) paneVis() PaneVisibility { } func (m Model) paneLayout() PaneWidths { - return LayoutWidths(m.width, m.paneVis()) + vis := m.paneVis() + if m.customPaneLayout { + return FitPaneWidths(m.width, vis, m.paneSizes) + } + return LayoutWidths(m.width, vis) } func (m *Model) syncPaneFocus() { m.listTable.SetFocused(m.focus == model.FocusList && m.showList) + if m.filterActive { + m.filterSearch.SetFocused(m.focus == model.FocusPreview && m.showDetail) + m.detail.SetFocused(false) + return + } + m.filterSearch.SetFocused(false) m.detail.SetFocused(m.focus == model.FocusPreview && m.showDetail) } @@ -427,6 +558,9 @@ func (m *Model) togglePane(which int) { if !paneVisible(m.focus, m.paneVis()) { m.focus = firstVisibleFocus(m.paneVis()) } + if m.customPaneLayout { + m.paneSizes = FitPaneWidths(m.width, m.paneVis(), m.paneSizes) + } m.layoutViewports() m.syncAllContent() m.syncPaneFocus() @@ -436,6 +570,8 @@ func (m Model) withList(spec model.ListSpec) (Model, tea.Cmd) { m.listSpec = spec m.selection.Clear() m.hasList = false + m.listHasMore = false + m.listLoadingMore = false m.focus = model.FocusList m.listTable.SetFocused(true) m.detail.Clear() @@ -455,6 +591,7 @@ func (m *Model) layoutViewports() { } if pw.Visibility.Detail { m.detail.SetSize(m.paneInnerWidth(pw.Detail), h) + m.filterSearch.SetSize(m.paneInnerWidth(pw.Detail), h) } } @@ -468,12 +605,62 @@ func (m *Model) syncListTable() { m.listTable.Clear() return } - m.listTable.UpdateItems(m.items) + items := m.items + if m.filterActive { + items = model.FilterItems(m.items, m.filterSearch.Query()) + } + m.listTable.UpdateItems(items) +} + +func (m *Model) enterFilter() { + if !m.showDetail { + m.showDetail = true + m.layoutViewports() + } + m.filterActive = true + m.focus = model.FocusPreview + m.syncPaneFocus() + m.applyFilter() +} + +func (m *Model) exitFilter() { + m.filterActive = false + m.filterSearch.Clear() + m.nav.ClearFilter() + m.syncMenuContent() + m.syncListTable() + m.syncPaneFocus() +} + +func (m *Model) applyFilter() { + q := m.filterSearch.Query() + m.nav.SetFilter(q) + m.syncMenuContent() + m.syncListTable() +} + +func (m Model) listTableItem() (model.Item, bool) { + idx := m.listTable.ItemIndex() + if idx < 0 { + return model.Item{}, false + } + items := m.items + if m.filterActive { + items = model.FilterItems(m.items, m.filterSearch.Query()) + } + if idx >= len(items) { + return model.Item{}, false + } + return items[idx], true } func (m *Model) syncMenuContent() { var b strings.Builder - b.WriteString("Navigation\n\n") + title := "Navigation" + if m.filterActive && m.filterSearch.Query() != "" { + title += " (filtered)" + } + b.WriteString(title + "\n\n") menuW := m.menuVP.Width if menuW < 10 { menuW = 20 @@ -603,21 +790,52 @@ func (m *Model) loadNavProjectsCmd() tea.Cmd { } func (m *Model) onListRowChanged() tea.Cmd { + if m.filterActive { + return nil + } if !m.hasList { m.detail.Clear() - return nil + return m.maybeLoadMoreList() } - idx := m.listTable.ItemIndex() - if idx < 0 || idx >= len(m.items) { + it, ok := m.listTableItem() + if !ok { m.detail.Clear() + return m.maybeLoadMoreList() + } + var cmds []tea.Cmd + cmds = append(cmds, m.maybeLoadMoreList()) + if m.detail.LoadedID() != it.ID { + m.loading = true + cmds = append(cmds, m.loadDetailCmd(it)) + } + return tea.Batch(cmds...) +} + +const mailListLoadThreshold = 3 + +func (m *Model) maybeLoadMoreList() tea.Cmd { + if !m.hasList || m.listLoadingMore || !m.listHasMore || m.filterActive { return nil } - it := m.items[idx] - if m.detail.LoadedID() == it.ID { + if !model.SubjectIsMail(m.listSpec.Subject) { return nil } - m.loading = true - return m.loadDetailCmd(it) + if !m.listTable.NearEnd(mailListLoadThreshold) { + return nil + } + m.listLoadingMore = true + m.listTable.SetLoadingMore(true) + return m.loadListMoreCmd() +} + +func (m *Model) loadListMoreCmd() tea.Cmd { + start := len(m.items) + spec := m.listSpec + return func() tea.Msg { + ctx := context.Background() + items, err := m.loader.ListMailMore(ctx, spec, start) + return listMoreLoadedMsg{items: items, err: err} + } } func (m *Model) loadDetailCmd(item model.Item) tea.Cmd { @@ -634,31 +852,44 @@ func (m *Model) loadDetailCmd(item model.Item) tea.Cmd { func (m *Model) saveDetailCmd() tea.Cmd { item := m.detail.Item() - title := m.detail.form.Primary() - desc := m.detail.form.Secondary() + fields := m.detail.form.FormFields() 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} + err := m.loader.SaveItem(ctx, item, fields) + return detailSavedMsg{item: item, fields: fields, err: err} } } -func (m *Model) updateItemAfterSave(item model.Item, title, description string) { +func (m *Model) updateItemAfterSave(item model.Item, fields model.FormFields) { for i := range m.items { if m.items[i].ID != item.ID { continue } - m.items[i].Title = title + m.items[i].Title = fields.Primary 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) + m.items[i].Raw["title"] = fields.Primary + m.items[i].Raw["description"] = fields.Secondary + if fields.HasStatus { + m.items[i].Raw["status"] = statusInt(fields.Status) + } + m.syncListTable() return } } +func statusInt(s model.ProjectLifecycle) int { + switch s { + case model.ProjectLifecycleClosed: + return 2 + case model.ProjectLifecyclePaused: + return 1 + default: + return 0 + } +} + func paneStyle(focused bool) lipgloss.Style { s := lipgloss.NewStyle().Padding(0, 1) if focused { @@ -668,5 +899,5 @@ func paneStyle(focused bool) lipgloss.Style { } func helpText() string { - return "Alt+1/2/3: toggle panes · Tab: focus · v: detail · Ctrl+S: save · q: quit" + return "Alt+1/2/3: toggle panes · drag borders to resize · f: filter · Tab: fields/actions/pane · j/k: scroll mail · v: detail · Ctrl+S: save · q: quit" } diff --git a/cmd/office/ui/app_test.go b/cmd/office/ui/app_test.go index 9040193..53dfb1d 100644 --- a/cmd/office/ui/app_test.go +++ b/cmd/office/ui/app_test.go @@ -31,6 +31,12 @@ func TestKeyActionSortAndColumns(t *testing.T) { } } +func TestKeyActionFilter(t *testing.T) { + if got := ui.KeyAction("f", model.FocusList); got != ui.ActionFilter { + t.Fatalf("got %v", got) + } +} + func TestLayoutWidths(t *testing.T) { menu, list, preview := ui.LayoutWidthsLegacy(120) if menu+list+preview != 120 { diff --git a/cmd/office/ui/detail.go b/cmd/office/ui/detail.go index 7bcd8c2..3d678e4 100644 --- a/cmd/office/ui/detail.go +++ b/cmd/office/ui/detail.go @@ -34,12 +34,14 @@ type DetailPane struct { loadedID string actions []model.ItemAction actionIdx int + tabStop int zone detailZone focused bool width int height int form EntityForm docVP viewport.Model + formVP viewport.Model docText string styles detailStyles } @@ -56,9 +58,11 @@ func newDetailPane() DetailPane { d := DetailPane{ form: newEntityForm(), docVP: viewport.New(40, 10), + formVP: viewport.New(40, 10), styles: newDetailStyles(), } d.docVP.MouseWheelEnabled = true + d.formVP.MouseWheelEnabled = true return d } @@ -79,14 +83,112 @@ func (d *DetailPane) Clear() { d.loadedID = "" d.actions = nil d.actionIdx = 0 + d.tabStop = 0 d.docText = "" d.form.Clear() d.docVP.SetContent("") + d.formVP.SetContent("") } func (d *DetailPane) SetFocused(on bool) { d.focused = on - d.form.SetFocused(on && d.zone == detailZoneContent && d.mode == detailForm) + d.applyTabStop(d.tabStop) +} + +func (d *DetailPane) FocusFirstStop() { + d.tabStop = 0 + d.applyTabStop(0) +} + +func (d *DetailPane) maxTabStop() int { + switch d.mode { + case detailForm: + if d.form.readOnly { + if len(d.actions) == 0 { + return 0 + } + return len(d.actions) - 1 + } + fields := d.form.FieldCount() + if len(d.actions) == 0 { + if fields == 0 { + return 0 + } + return fields - 1 + } + return fields + len(d.actions) - 1 + case detailDocument: + if len(d.actions) == 0 { + return 0 + } + return len(d.actions) + default: + return 0 + } +} + +func (d *DetailPane) applyTabStop(stop int) { + if stop < 0 { + stop = 0 + } + max := d.maxTabStop() + if stop > max { + stop = max + } + d.tabStop = stop + + switch d.mode { + case detailForm: + if d.form.readOnly { + d.zone = detailZoneActions + d.actionIdx = stop + d.form.SetFocused(false) + return + } + fields := d.form.FieldCount() + if stop < fields { + d.zone = detailZoneContent + d.form.SetFieldIndex(stop) + d.form.SetFocused(d.focused) + return + } + d.zone = detailZoneActions + d.actionIdx = stop - fields + d.form.SetFocused(false) + case detailDocument: + if stop == 0 { + d.zone = detailZoneContent + d.form.SetFocused(false) + return + } + d.zone = detailZoneActions + d.actionIdx = stop - 1 + d.form.SetFocused(false) + default: + d.zone = detailZoneContent + d.form.SetFocused(false) + } +} + +// TabForward moves to the next field/button. It returns true when focus should leave the pane. +func (d *DetailPane) TabForward() bool { + max := d.maxTabStop() + if d.tabStop < max { + d.applyTabStop(d.tabStop + 1) + return false + } + d.FocusFirstStop() + return true +} + +// TabBackward moves to the previous field/button. It returns true when focus should leave the pane. +func (d *DetailPane) TabBackward() bool { + if d.tabStop > 0 { + d.applyTabStop(d.tabStop - 1) + return false + } + d.applyTabStop(d.maxTabStop()) + return true } func (d *DetailPane) SetSize(w, h int) { @@ -102,6 +204,8 @@ func (d *DetailPane) SetSize(w, h int) { d.form.SetSize(w, contentH) d.docVP.Width = w d.docVP.Height = contentH + d.formVP.Width = w + d.formVP.Height = contentH } func (d *DetailPane) splitHeights() (contentH, actionH int) { @@ -125,7 +229,10 @@ func (d *DetailPane) LoadForm(item model.Item, fields model.FormFields) { d.loadedID = item.ID d.actions = model.ActionsFor(item.Kind) d.actionIdx = 0 + d.tabStop = 0 d.form.Load(item.Kind, item.ID, fields) + d.refreshFormViewport() + d.applyTabStop(0) d.layoutContent() } @@ -135,6 +242,7 @@ func (d *DetailPane) LoadDocument(item model.Item, markdown string, renderWidth d.loadedID = item.ID d.actions = model.ActionsFor(item.Kind) d.actionIdx = 0 + d.tabStop = 0 d.form.Clear() text, err := preview.RenderMarkdown(markdown, renderWidth) if err != nil { @@ -142,6 +250,8 @@ func (d *DetailPane) LoadDocument(item model.Item, markdown string, renderWidth } d.docText = text d.docVP.SetContent(text) + d.docVP.GotoTop() + d.applyTabStop(0) d.layoutContent() } @@ -150,6 +260,17 @@ func (d *DetailPane) layoutContent() { d.form.SetSize(d.width, contentH) d.docVP.Width = d.width d.docVP.Height = contentH + d.formVP.Width = d.width + d.formVP.Height = contentH + d.refreshFormViewport() +} + +func (d *DetailPane) refreshFormViewport() { + if d.mode != detailForm || !d.form.readOnly { + d.formVP.SetContent("") + return + } + d.formVP.SetContent(d.form.View()) } func (d *DetailPane) LoadedID() string { return d.loadedID } @@ -158,24 +279,47 @@ 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) + d.applyTabStop(d.tabStop) + if d.zone == detailZoneContent && d.mode == detailForm { + d.form.SetFocused(d.focused) + } } -func (d *DetailPane) ToggleZone() { - if d.zone == detailZoneContent { - d.FocusActions() - } else { - d.FocusContent() +func (d *DetailPane) FocusActions() { + max := d.maxTabStop() + switch d.mode { + case detailForm: + if d.form.readOnly { + d.applyTabStop(max) + return + } + if len(d.actions) > 0 { + d.applyTabStop(d.form.FieldCount()) + } + case detailDocument: + if len(d.actions) > 0 { + d.applyTabStop(1) + } } } +// MoveTabStop moves between title, description, and action buttons without leaving the pane. +func (d *DetailPane) MoveTabStop(delta int) { + if delta == 0 { + return + } + next := d.tabStop + delta + if next < 0 { + next = 0 + } + max := d.maxTabStop() + if next > max { + next = max + } + d.applyTabStop(next) +} + func (d *DetailPane) MoveAction(delta int) { if len(d.actions) == 0 { return @@ -190,6 +334,62 @@ func (d *DetailPane) SelectedAction() (model.ItemAction, bool) { return d.actions[d.actionIdx], true } +func (d DetailPane) IsDocumentContent() bool { + return d.mode == detailDocument +} + +func (d DetailPane) isReadOnlyFormContent() bool { + return d.mode == detailForm && d.form.readOnly +} + +func (d DetailPane) documentContentHeight() int { + contentH, _ := d.splitHeights() + return contentH +} + +// ScrollDocument scrolls the read-only document viewport (mail, file preview). +func (d *DetailPane) ScrollDocument(key string) bool { + if d.IsDocumentContent() { + return scrollViewport(&d.docVP, key) + } + if d.isReadOnlyFormContent() && d.zone == detailZoneContent { + return scrollViewport(&d.formVP, key) + } + return false +} + +// ScrollDocumentMouse applies wheel events to scrollable preview content. +func (d *DetailPane) ScrollDocumentMouse(msg tea.MouseMsg) tea.Cmd { + var vp *viewport.Model + switch { + case d.IsDocumentContent(): + vp = &d.docVP + case d.isReadOnlyFormContent(): + vp = &d.formVP + default: + return nil + } + if msg.Y >= d.documentContentHeight() { + return nil + } + if !vp.MouseWheelEnabled || msg.Action != tea.MouseActionPress { + return nil + } + switch msg.Button { + case tea.MouseButtonWheelUp, tea.MouseButtonWheelDown: + default: + return nil + } + var cmd tea.Cmd + *vp, cmd = vp.Update(msg) + return cmd +} + +// DocumentYOffset exposes scroll position for tests. +func (d DetailPane) DocumentYOffset() int { + return d.docVP.YOffset +} + func (d *DetailPane) Update(msg tea.Msg) tea.Cmd { if !d.focused { return nil @@ -197,14 +397,21 @@ func (d *DetailPane) Update(msg tea.Msg) tea.Cmd { if d.zone == detailZoneContent { switch d.mode { case detailForm: + if d.form.readOnly { + if key, ok := msg.(tea.KeyMsg); ok { + if scrollViewport(&d.formVP, key.String()) { + return nil + } + } + var cmd tea.Cmd + d.formVP, cmd = d.formVP.Update(msg) + return cmd + } 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() + if key, ok := msg.(tea.KeyMsg); ok { + if scrollViewport(&d.docVP, key.String()) { + return nil } } var cmd tea.Cmd @@ -223,9 +430,25 @@ func (d DetailPane) View() string { var top string switch d.mode { case detailForm: - top = lipgloss.NewStyle().Width(d.width).Height(contentH).Render(d.form.View()) + if d.form.readOnly { + top = ApplyVerticalScrollbar( + d.formVP.View(), + d.formVP.Width, + d.formVP.Height, + d.formVP.TotalLineCount(), + d.formVP.YOffset, + ) + } else { + top = lipgloss.NewStyle().Width(d.width).Height(contentH).Render(d.form.View()) + } case detailDocument: - top = d.docVP.View() + top = ApplyVerticalScrollbar( + d.docVP.View(), + d.docVP.Width, + d.docVP.Height, + d.docVP.TotalLineCount(), + d.docVP.YOffset, + ) default: top = "" } @@ -258,7 +481,7 @@ func (d DetailPane) renderActions() string { } line := strings.Join(parts, "") if d.focused && d.zone == detailZoneActions { - line += d.styles.empty.Render(" ←/→ select · Enter run") + line += d.styles.empty.Render(" Tab · Enter run") } return line } diff --git a/cmd/office/ui/detail_scroll_test.go b/cmd/office/ui/detail_scroll_test.go new file mode 100644 index 0000000..bbf9a4f --- /dev/null +++ b/cmd/office/ui/detail_scroll_test.go @@ -0,0 +1,60 @@ +package ui + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/eslider/go-onlyoffice/cmd/office/model" +) + +func TestDetailDocumentScrollByKey(t *testing.T) { + d := newDetailPane() + d.SetSize(50, 20) + longBody := "# Mail\n\n" + for i := 0; i < 80; i++ { + longBody += "line\n" + } + d.LoadDocument(model.Item{ID: "1", Kind: model.KindMail, Title: "M"}, longBody, 40) + d.SetFocused(true) + + if d.DocumentYOffset() != 0 { + t.Fatalf("expected top offset 0, got %d", d.DocumentYOffset()) + } + if !d.ScrollDocument("down") { + t.Fatal("expected scroll down to succeed") + } + if d.DocumentYOffset() == 0 { + t.Fatal("expected offset after scroll down") + } + if !d.ScrollDocument("up") { + t.Fatal("expected scroll up to succeed") + } +} + +func TestDetailDocumentMouseWheel(t *testing.T) { + d := newDetailPane() + d.SetSize(50, 20) + longBody := "# Mail\n\n" + for i := 0; i < 80; i++ { + longBody += "line\n" + } + d.LoadDocument(model.Item{ID: "1", Kind: model.KindMail, Title: "M"}, longBody, 40) + + d.ScrollDocumentMouse(tea.MouseMsg{ + X: 10, Y: 2, Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress, + }) + if d.DocumentYOffset() == 0 { + t.Fatal("expected wheel down to scroll document") + } +} + +func TestDetailPaneXRange(t *testing.T) { + pw := PaneWidths{ + Menu: 12, List: 72, Detail: 36, + Visibility: PaneVisibility{Menu: true, List: true, Detail: true}, + } + start, end := DetailPaneXRange(pw) + if start != 84 || end != 120 { + t.Fatalf("range=%d..%d want 84..120", start, end) + } +} diff --git a/cmd/office/ui/detail_tab_test.go b/cmd/office/ui/detail_tab_test.go new file mode 100644 index 0000000..f6a2208 --- /dev/null +++ b/cmd/office/ui/detail_tab_test.go @@ -0,0 +1,65 @@ +package ui + +import ( + "testing" + + "github.com/eslider/go-onlyoffice/cmd/office/model" +) + +func TestDetailTabOrderProjectForm(t *testing.T) { + d := newDetailPane() + d.SetFocused(true) + d.LoadForm(model.Item{ID: "1", Kind: model.KindProject, Title: "P"}, model.FormFields{ + PrimaryLabel: "Title", SecondaryLabel: "Description", + Primary: "Alpha", Secondary: "Beta", HasStatus: true, + Status: model.ProjectLifecycleOpen, + }) + + if d.tabStop != 0 || d.form.field != entityFieldPrimary { + t.Fatalf("start on title, tabStop=%d field=%d", d.tabStop, d.form.field) + } + if d.TabForward() { + t.Fatal("tab from title should stay in pane") + } + if d.form.field != entityFieldSecondary { + t.Fatalf("second stop should be description, field=%d", d.form.field) + } + if d.TabForward() { + t.Fatal("tab from description should stay in pane") + } + if d.form.field != entityFieldStatus { + t.Fatalf("third stop should be status, field=%d", d.form.field) + } + if d.TabForward() { + t.Fatal("tab from status should stay in pane") + } + if d.Zone() != detailZoneActions || d.actionIdx != 0 { + t.Fatalf("fourth stop should be Save, zone=%d idx=%d", d.Zone(), d.actionIdx) + } + if d.TabForward() { + t.Fatal("tab from Save should stay in pane") + } + if d.actionIdx != 1 { + t.Fatalf("fifth stop should be Delete, idx=%d", d.actionIdx) + } + if !d.TabForward() { + t.Fatal("tab from Delete should leave pane") + } + if d.tabStop != 0 { + t.Fatalf("after leaving pane tab order resets, tabStop=%d", d.tabStop) + } +} + +func TestDetailShiftTabFromTitleLeavesPane(t *testing.T) { + d := newDetailPane() + d.SetFocused(true) + d.LoadForm(model.Item{ID: "1", Kind: model.KindProject}, model.FormFields{ + PrimaryLabel: "Title", SecondaryLabel: "Description", + }) + if !d.TabBackward() { + t.Fatal("shift+tab from title should leave pane") + } + if d.tabStop != d.maxTabStop() { + t.Fatalf("should park on last stop, tabStop=%d max=%d", d.tabStop, d.maxTabStop()) + } +} diff --git a/cmd/office/ui/entityform.go b/cmd/office/ui/entityform.go index fbdc882..81fd229 100644 --- a/cmd/office/ui/entityform.go +++ b/cmd/office/ui/entityform.go @@ -15,6 +15,7 @@ type entityField int const ( entityFieldPrimary entityField = iota entityFieldSecondary + entityFieldStatus ) // EntityForm is the top section of the detail pane for editable entities. @@ -25,6 +26,9 @@ type EntityForm struct { primaryLabel string secondaryLabel string readOnly bool + hasStatus bool + status model.ProjectLifecycle + responsibleID string primary textinput.Model secondary textarea.Model field entityField @@ -65,6 +69,27 @@ func newEntityForm() EntityForm { } } +func (f *EntityForm) FieldCount() int { + n := 2 + if f.hasStatus { + n = 3 + } + return n +} + +func (f *EntityForm) FormFields() model.FormFields { + return model.FormFields{ + PrimaryLabel: f.primaryLabel, + SecondaryLabel: f.secondaryLabel, + Primary: f.primary.Value(), + Secondary: f.secondary.Value(), + ReadOnly: f.readOnly, + HasStatus: f.hasStatus, + Status: f.status, + ResponsibleID: f.responsibleID, + } +} + func (f *EntityForm) Active() bool { return f.active } func (f *EntityForm) ItemID() string { return f.itemID } @@ -82,10 +107,16 @@ func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormField f.primaryLabel = fields.PrimaryLabel f.secondaryLabel = fields.SecondaryLabel f.readOnly = fields.ReadOnly + f.hasStatus = fields.HasStatus + f.status = fields.Status + f.responsibleID = fields.ResponsibleID f.primary.SetValue(fields.Primary) f.secondary.SetValue(fields.Secondary) f.dirty = false f.field = entityFieldPrimary + if f.hasStatus && f.readOnly { + f.field = entityFieldStatus + } f.applyFocus() f.layoutFields() } @@ -93,6 +124,9 @@ func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormField func (f *EntityForm) Clear() { f.active = false f.itemID = "" + f.hasStatus = false + f.status = model.ProjectLifecycleOpen + f.responsibleID = "" f.primary.SetValue("") f.secondary.SetValue("") f.dirty = false @@ -140,19 +174,52 @@ func (f *EntityForm) layoutFields() { } func (f *EntityForm) FocusNext() { - if f.field < entityFieldSecondary { + last := entityFieldSecondary + if f.hasStatus { + last = entityFieldStatus + } + if f.field < last { f.field++ } f.applyFocus() } func (f *EntityForm) FocusPrev() { - if f.field > entityFieldPrimary { + first := entityFieldPrimary + if f.field > first { f.field-- } f.applyFocus() } +func (f *EntityForm) SetFieldIndex(i int) { + switch i { + case 0: + f.field = entityFieldPrimary + case 1: + f.field = entityFieldSecondary + case 2: + if f.hasStatus { + f.field = entityFieldStatus + } + default: + return + } + f.applyFocus() +} + +func (f *EntityForm) cycleStatus(delta int) { + if !f.hasStatus || f.readOnly { + return + } + if delta > 0 { + f.status = f.status.Next() + } else { + f.status = f.status.Prev() + } + f.dirty = true +} + func (f *EntityForm) applyFocus() { if f.readOnly { f.primary.Blur() @@ -163,9 +230,12 @@ func (f *EntityForm) applyFocus() { case entityFieldPrimary: f.primary.Focus() f.secondary.Blur() - default: + case entityFieldSecondary: f.primary.Blur() f.secondary.Focus() + default: + f.primary.Blur() + f.secondary.Blur() } } @@ -173,12 +243,27 @@ func (f *EntityForm) Update(msg tea.Msg) tea.Cmd { if !f.active || !f.focused || f.readOnly { return nil } + if key, ok := msg.(tea.KeyMsg); ok && f.field == entityFieldStatus { + switch key.String() { + case "left", "h": + f.cycleStatus(-1) + return nil + case "right", "l": + f.cycleStatus(1) + return nil + case " ": + f.cycleStatus(1) + return nil + } + } var cmd tea.Cmd switch f.field { case entityFieldPrimary: f.primary, cmd = f.primary.Update(msg) - default: + case entityFieldSecondary: f.secondary, cmd = f.secondary.Update(msg) + default: + return nil } if _, ok := msg.(tea.KeyMsg); ok { f.dirty = true @@ -199,6 +284,28 @@ func (f EntityForm) View() string { if f.focused && f.field == entityFieldSecondary && !f.readOnly { sLabel = f.styles.labelAct.Render(f.secondaryLabel) } + statusBlock := "" + if f.hasStatus { + stLabel := f.styles.label.Render("Status") + if f.focused && f.field == entityFieldStatus && !f.readOnly { + stLabel = f.styles.labelAct.Render("Status") + } + valStyle := f.styles.label + if f.status == model.ProjectLifecycleClosed { + valStyle = valStyle.Foreground(lipgloss.Color("245")) + } else { + valStyle = valStyle.Foreground(lipgloss.Color("42")) + } + hint := "" + if f.focused && f.field == entityFieldStatus && !f.readOnly { + hint = f.styles.label.Render(" ←/→ toggle") + } + statusBlock = strings.Join([]string{ + "", + stLabel, + valStyle.Render(f.status.Label()) + hint, + }, "\n") + } ro := "" if f.readOnly { ro = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" (read-only)") + "\n" @@ -210,6 +317,7 @@ func (f EntityForm) View() string { "", sLabel, f.secondary.View(), + statusBlock, }, "\n") } diff --git a/cmd/office/ui/filter.go b/cmd/office/ui/filter.go new file mode 100644 index 0000000..483152d --- /dev/null +++ b/cmd/office/ui/filter.go @@ -0,0 +1,88 @@ +package ui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// FilterSearch is the right-pane query input for nav/list filtering. +type FilterSearch struct { + input textinput.Model + width int + height int + focused bool + styles filterSearchStyles +} + +type filterSearchStyles struct { + title lipgloss.Style + hint lipgloss.Style +} + +func newFilterSearch() FilterSearch { + in := textinput.New() + in.Prompt = "/ " + in.Placeholder = "Filter navigation and list…" + in.CharLimit = 256 + return FilterSearch{ + input: in, + styles: filterSearchStyles{ + title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")), + hint: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), + }, + } +} + +func (f *FilterSearch) Query() string { return f.input.Value() } + +func (f *FilterSearch) SetFocused(on bool) { + f.focused = on + if on { + f.input.Focus() + } else { + f.input.Blur() + } +} + +func (f *FilterSearch) SetSize(w, h int) { + if w < 12 { + w = 12 + } + if h < 6 { + h = 6 + } + f.width = w + f.height = h + inner := w - 2 + if inner < 8 { + inner = 8 + } + f.input.Width = inner +} + +func (f *FilterSearch) Clear() { + f.input.SetValue("") +} + +func (f *FilterSearch) Update(msg tea.Msg) tea.Cmd { + if !f.focused { + return nil + } + var cmd tea.Cmd + f.input, cmd = f.input.Update(msg) + return cmd +} + +func (f FilterSearch) View() string { + title := f.styles.title.Render("Filter") + body := lipgloss.NewStyle().Width(f.width).Render(f.input.View()) + hint := f.styles.hint.Render("Filters left nav and center table · Esc clear · f focus") + return strings.Join([]string{title, "", body, "", hint}, "\n") +} + +func filterSearchBlinkCmd() tea.Cmd { + return textinput.Blink +} diff --git a/cmd/office/ui/keys.go b/cmd/office/ui/keys.go index feb0629..b3636a5 100644 --- a/cmd/office/ui/keys.go +++ b/cmd/office/ui/keys.go @@ -17,6 +17,7 @@ const ( ActionToggleMenuPane ActionToggleListPane ActionToggleDetailPane + ActionFilter ActionNextPane ActionPrevPane ActionRefresh @@ -38,6 +39,8 @@ func KeyAction(key string, pane model.FocusPane) Action { } case "r": return ActionRefresh + case "f": + return ActionFilter case "up", "k": return ActionMoveUp case "down", "j": diff --git a/cmd/office/ui/layout.go b/cmd/office/ui/layout.go index b213399..d5bffee 100644 --- a/cmd/office/ui/layout.go +++ b/cmd/office/ui/layout.go @@ -17,44 +17,60 @@ func defaultPaneVisibility() PaneVisibility { return PaneVisibility{Menu: true, List: true, Detail: true} } -// LayoutWidths splits total terminal width evenly across visible panes. +const ( + defaultMenuShare = 10 + defaultListShare = 60 + defaultDetailShare = 30 +) + +// LayoutWidths splits total terminal width across visible panes (default 10% / 60% / 30%). func LayoutWidths(total int, vis PaneVisibility) PaneWidths { if total < 1 { total = 80 } v := vis - n := countVisible(v) - if n == 0 { + if countVisible(v) == 0 { v = defaultPaneVisibility() - n = 3 } - base := total / n - rem := total % n - out := PaneWidths{Visibility: v} + shareSum := 0 if v.Menu { - out.Menu = base - if rem > 0 { - out.Menu++ - rem-- - } + shareSum += defaultMenuShare } if v.List { - out.List = base - if rem > 0 { - out.List++ - rem-- - } + shareSum += defaultListShare } if v.Detail { - out.Detail = base - if rem > 0 { - out.Detail++ - rem-- - } + shareSum += defaultDetailShare } + assign := func(visible bool, share int) int { + if !visible || shareSum == 0 { + return 0 + } + return total * share / shareSum + } + out := PaneWidths{Visibility: v} + out.Menu = assign(v.Menu, defaultMenuShare) + out.List = assign(v.List, defaultListShare) + out.Detail = assign(v.Detail, defaultDetailShare) + fixPaneWidthSum(&out, total) return out } +// DetailPaneXRange returns the [start, end) column span of the detail pane. +func DetailPaneXRange(pw PaneWidths) (start, end int) { + if !pw.Visibility.Detail { + return 0, 0 + } + if pw.Visibility.Menu { + start += pw.Menu + } + if pw.Visibility.List { + start += pw.List + } + end = start + pw.Detail + return start, end +} + func countVisible(v PaneVisibility) int { n := 0 if v.Menu { diff --git a/cmd/office/ui/layout_resize.go b/cmd/office/ui/layout_resize.go new file mode 100644 index 0000000..4062ca9 --- /dev/null +++ b/cmd/office/ui/layout_resize.go @@ -0,0 +1,203 @@ +package ui + +import ( + tea "github.com/charmbracelet/bubbletea" +) + +const ( + minPaneOuterWidth = 14 + resizeHitSlop = 1 +) + +type paneResizeState struct { + active bool + divider int + anchorX int + anchorPW PaneWidths +} + +// FitPaneWidths scales stored pane widths to fill total width for visible panes. +func FitPaneWidths(total int, vis PaneVisibility, sizes PaneWidths) PaneWidths { + if total < 1 { + total = 80 + } + out := PaneWidths{Visibility: vis} + if countVisible(vis) == 0 { + return LayoutWidths(total, vis) + } + sum := 0 + if vis.Menu { + sum += sizes.Menu + } + if vis.List { + sum += sizes.List + } + if vis.Detail { + sum += sizes.Detail + } + if sum <= 0 { + return LayoutWidths(total, vis) + } + assign := func(visible bool, stored int) int { + if !visible { + return 0 + } + return stored * total / sum + } + out.Menu = assign(vis.Menu, sizes.Menu) + out.List = assign(vis.List, sizes.List) + out.Detail = assign(vis.Detail, sizes.Detail) + fixPaneWidthSum(&out, total) + return out +} + +func fixPaneWidthSum(pw *PaneWidths, total int) { + if pw == nil || total < 1 { + return + } + type slot struct { + visible bool + width *int + } + slots := []slot{ + {pw.Visibility.Menu, &pw.Menu}, + {pw.Visibility.List, &pw.List}, + {pw.Visibility.Detail, &pw.Detail}, + } + sum := 0 + var last *int + for _, s := range slots { + if !s.visible { + continue + } + sum += *s.width + last = s.width + } + if last == nil { + return + } + *last += total - sum + if *last < minPaneOuterWidth { + *last = minPaneOuterWidth + } +} + +// DividerPositions returns x coordinates of vertical pane separators. +func DividerPositions(pw PaneWidths) []int { + var out []int + x := 0 + if pw.Visibility.Menu { + x += pw.Menu + if pw.Visibility.List || pw.Visibility.Detail { + out = append(out, x) + } + } + if pw.Visibility.List { + x += pw.List + if pw.Visibility.Detail { + out = append(out, x) + } + } + return out +} + +// DividerAt returns the divider index under x, or -1. +func DividerAt(x int, pw PaneWidths) int { + for i, pos := range DividerPositions(pw) { + if x >= pos-resizeHitSlop && x <= pos+resizeHitSlop { + return i + } + } + return -1 +} + +// DragPaneDivider adjusts adjacent pane widths by deltaX pixels. +func DragPaneDivider(pw PaneWidths, divider, deltaX int) (PaneWidths, bool) { + if deltaX == 0 { + return pw, false + } + changed := false + switch divider { + case 0: + if pw.Visibility.Menu && pw.Visibility.List { + if resizePair(&pw.Menu, &pw.List, deltaX) { + changed = true + } + } else if pw.Visibility.Menu && pw.Visibility.Detail { + if resizePair(&pw.Menu, &pw.Detail, deltaX) { + changed = true + } + } + case 1: + if pw.Visibility.List && pw.Visibility.Detail { + if resizePair(&pw.List, &pw.Detail, deltaX) { + changed = true + } + } + } + return pw, changed +} + +func resizePair(left, right *int, delta int) bool { + if left == nil || right == nil { + return false + } + newLeft := *left + delta + newRight := *right - delta + if newLeft < minPaneOuterWidth || newRight < minPaneOuterWidth { + return false + } + *left = newLeft + *right = newRight + return true +} + +func (m Model) paneResizeMaxY() int { + return m.paneHeight() + 1 +} + +func (m *Model) handlePaneResizeMouse(msg tea.MouseMsg) bool { + if msg.Y >= m.paneResizeMaxY() { + if m.resize.active && msg.Action == tea.MouseActionRelease { + m.resize.active = false + return true + } + return false + } + + pw := m.paneLayout() + switch msg.Action { + case tea.MouseActionPress: + if msg.Button != tea.MouseButtonLeft { + return false + } + if div := DividerAt(msg.X, pw); div >= 0 { + m.paneSizes = pw + m.customPaneLayout = true + m.resize = paneResizeState{ + active: true, + divider: div, + anchorX: msg.X, + anchorPW: pw, + } + return true + } + case tea.MouseActionRelease: + if m.resize.active { + m.resize.active = false + return true + } + case tea.MouseActionMotion: + if m.resize.active { + delta := msg.X - m.resize.anchorX + next, ok := DragPaneDivider(m.resize.anchorPW, m.resize.divider, delta) + if ok { + m.paneSizes = next + m.layoutViewports() + m.syncAllContent() + } + return true + } + } + return m.resize.active +} diff --git a/cmd/office/ui/layout_resize_test.go b/cmd/office/ui/layout_resize_test.go new file mode 100644 index 0000000..e7bdacc --- /dev/null +++ b/cmd/office/ui/layout_resize_test.go @@ -0,0 +1,41 @@ +package ui + +import "testing" + +func TestDividerAtBetweenMenuAndList(t *testing.T) { + pw := PaneWidths{ + Menu: 30, List: 50, Detail: 40, + Visibility: PaneVisibility{Menu: true, List: true, Detail: true}, + } + if got := DividerAt(30, pw); got != 0 { + t.Fatalf("divider at 30=%d want 0", got) + } + if got := DividerAt(80, pw); got != 1 { + t.Fatalf("divider at 80=%d want 1", got) + } +} + +func TestDragPaneDividerRespectsMinimum(t *testing.T) { + pw := PaneWidths{ + Menu: 40, List: 40, Detail: 40, + Visibility: PaneVisibility{Menu: true, List: true, Detail: true}, + } + next, ok := DragPaneDivider(pw, 0, -30) + if ok { + t.Fatalf("expected resize blocked by minimum, got %+v", next) + } + next, ok = DragPaneDivider(pw, 0, 5) + if !ok || next.Menu != 45 || next.List != 35 { + t.Fatalf("resize failed: ok=%v %+v", ok, next) + } +} + +func TestFitPaneWidthsScalesToTotal(t *testing.T) { + vis := PaneVisibility{Menu: true, List: true, Detail: true} + sizes := PaneWidths{Menu: 20, List: 30, Detail: 50, Visibility: vis} + out := FitPaneWidths(120, vis, sizes) + sum := out.Menu + out.List + out.Detail + if sum != 120 { + t.Fatalf("sum=%d want 120", sum) + } +} diff --git a/cmd/office/ui/layout_test.go b/cmd/office/ui/layout_test.go index dd6a3e2..82841ce 100644 --- a/cmd/office/ui/layout_test.go +++ b/cmd/office/ui/layout_test.go @@ -13,6 +13,9 @@ func TestLayoutWidthsAllVisibleUsesFullWidth(t *testing.T) { if sum != 120 { t.Fatalf("sum=%d want 120", sum) } + if pw.Menu != 12 || pw.List != 72 || pw.Detail != 36 { + t.Fatalf("menu=%d list=%d detail=%d want 12/72/36 (10/60/30)", pw.Menu, pw.List, pw.Detail) + } } func TestLayoutWidthsSinglePane(t *testing.T) { @@ -30,6 +33,20 @@ func TestLayoutWidthsTwoPanes(t *testing.T) { if pw.Menu+pw.Detail != 80 { t.Fatalf("sum=%d want 80", pw.Menu+pw.Detail) } + if pw.Menu != 20 || pw.Detail != 60 { + t.Fatalf("menu=%d detail=%d want 20/60 (10/30 of pair)", pw.Menu, pw.Detail) + } +} + +func TestDetailPaneXRangeSkipsHidden(t *testing.T) { + pw := PaneWidths{ + Menu: 20, List: 60, Detail: 0, + Visibility: PaneVisibility{Menu: true, List: true, Detail: false}, + } + start, end := DetailPaneXRange(pw) + if start != 0 || end != 0 { + t.Fatalf("hidden detail should have empty range, got %d..%d", start, end) + } } func TestNextVisibleFocusSkipsHidden(t *testing.T) { diff --git a/cmd/office/ui/scroll.go b/cmd/office/ui/scroll.go index 19cd0ec..d078b5a 100644 --- a/cmd/office/ui/scroll.go +++ b/cmd/office/ui/scroll.go @@ -11,7 +11,7 @@ func (m *Model) scrollFocusedPane(key string) bool { return scrollViewport(&m.menuVP, key) case model.FocusList: switch key { - case "pgdown", "pgdn", "f", "ctrl+d": + case "pgdown", "pgdn", "ctrl+d": m.listTable.PageScroll(1) return true case "pgup", "b", "ctrl+u": @@ -20,10 +20,10 @@ func (m *Model) scrollFocusedPane(key string) bool { } return false case model.FocusPreview: - if m.detail.Zone() != detailZoneContent || m.detail.mode != detailDocument { + if m.detail.Zone() != detailZoneContent { return false } - return scrollViewport(&m.detail.docVP, key) + return m.detail.ScrollDocument(key) default: return false } @@ -50,7 +50,13 @@ func (m *Model) paneInnerWidth(outer int) int { func scrollViewport(vp *viewport.Model, key string) bool { switch key { - case "pgdown", "pgdn", "f", "ctrl+d": + case "up", "k": + vp.LineUp(1) + return true + case "down", "j": + vp.LineDown(1) + return true + case "pgdown", "pgdn", "ctrl+d": vp.ViewDown() return true case "pgup", "b", "ctrl+u": diff --git a/cmd/office/ui/scrollbar.go b/cmd/office/ui/scrollbar.go new file mode 100644 index 0000000..e808cba --- /dev/null +++ b/cmd/office/ui/scrollbar.go @@ -0,0 +1,76 @@ +package ui + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/mattn/go-runewidth" +) + +// ScrollbarMetrics computes thumb position for a vertical scrollbar. +func ScrollbarMetrics(totalLines, viewportHeight, yOffset int) (show bool, thumbStart, thumbEnd int) { + if totalLines <= viewportHeight || viewportHeight < 1 { + return false, 0, 0 + } + maxOff := totalLines - viewportHeight + if maxOff < 0 { + maxOff = 0 + } + thumbSize := viewportHeight * viewportHeight / totalLines + if thumbSize < 1 { + thumbSize = 1 + } + if thumbSize > viewportHeight { + thumbSize = viewportHeight + } + travel := viewportHeight - thumbSize + thumbStart = 0 + if maxOff > 0 { + thumbStart = yOffset * travel / maxOff + } + return true, thumbStart, thumbStart + thumbSize +} + +// ApplyVerticalScrollbar draws a 1-column scrollbar on the right when content overflows. +func ApplyVerticalScrollbar(view string, width, height, totalLines, yOffset int) string { + show, thumbStart, thumbEnd := ScrollbarMetrics(totalLines, height, yOffset) + if !show || width < 2 { + return view + } + contentWidth := width - 1 + lines := strings.Split(view, "\n") + for len(lines) < height { + lines = append(lines, "") + } + if len(lines) > height { + lines = lines[:height] + } + track := lipgloss.NewStyle().Foreground(lipgloss.Color("238")) + thumb := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + out := make([]string, height) + for row := 0; row < height; row++ { + line := padDisplayWidth(lines[row], contentWidth) + ch := "│" + style := track + if row >= thumbStart && row < thumbEnd { + ch = "█" + style = thumb + } + out[row] = line + style.Render(ch) + } + return strings.Join(out, "\n") +} + +func padDisplayWidth(line string, width int) string { + if width < 1 { + return "" + } + w := runewidth.StringWidth(line) + if w > width { + return runewidth.Truncate(line, width, "") + } + if w < width { + line += strings.Repeat(" ", width-w) + } + return line +} diff --git a/cmd/office/ui/scrollbar_test.go b/cmd/office/ui/scrollbar_test.go new file mode 100644 index 0000000..87e37c0 --- /dev/null +++ b/cmd/office/ui/scrollbar_test.go @@ -0,0 +1,32 @@ +package ui + +import ( + "strings" + "testing" +) + +func TestScrollbarMetricsHiddenWhenFits(t *testing.T) { + show, _, _ := ScrollbarMetrics(5, 10, 0) + if show { + t.Fatal("expected no scrollbar when content fits") + } +} + +func TestScrollbarMetricsVisibleWhenOverflow(t *testing.T) { + show, start, end := ScrollbarMetrics(100, 10, 50) + if !show || end <= start { + t.Fatalf("expected thumb, got %d..%d", start, end) + } +} + +func TestApplyVerticalScrollbarAddsColumn(t *testing.T) { + view := "line1\nline2\nline3" + out := ApplyVerticalScrollbar(view, 12, 3, 10, 0) + lines := strings.Split(out, "\n") + if len(lines) != 3 { + t.Fatalf("lines=%d", len(lines)) + } + if !strings.HasSuffix(lines[0], "█") && !strings.HasSuffix(lines[0], "│") { + t.Fatalf("expected scrollbar glyph: %q", lines[0]) + } +} diff --git a/cmd/office/ui/table.go b/cmd/office/ui/table.go index 77442d4..edcbbe6 100644 --- a/cmd/office/ui/table.go +++ b/cmd/office/ui/table.go @@ -53,6 +53,7 @@ type DataTable struct { sortAsc bool focused bool ready bool + loadingMore bool width int height int viewport viewport.Model @@ -127,6 +128,62 @@ func (t *DataTable) ItemIndex() int { return t.order[t.cursorRow] } +// CursorRow is the visual row index in the current table order. +func (t DataTable) CursorRow() int { + return t.cursorRow +} + +// Items returns the backing slice (after appends). +func (t DataTable) Items() []model.Item { + return t.items +} + +// NearEnd reports whether the cursor is within threshold rows of the last row. +func (t DataTable) NearEnd(threshold int) bool { + if !t.ready || len(t.order) == 0 { + return false + } + return t.cursorRow >= len(t.order)-threshold +} + +func (t *DataTable) SetLoadingMore(on bool) { + t.loadingMore = on +} + +// AppendItems adds new rows, skipping duplicate IDs. Returns how many were added. +func (t *DataTable) AppendItems(items []model.Item) int { + if !t.ready || len(items) == 0 { + return 0 + } + seen := make(map[string]struct{}, len(t.items)) + for _, it := range t.items { + if it.ID != "" { + seen[it.ID] = struct{}{} + } + } + added := 0 + for _, it := range items { + if it.ID != "" { + if _, ok := seen[it.ID]; ok { + continue + } + seen[it.ID] = struct{}{} + } + idx := len(t.items) + t.items = append(t.items, it) + t.order = append(t.order, idx) + added++ + } + if added > 0 { + if t.sortCol >= 0 { + t.applySort() + } + t.clampCursor() + t.refreshViewport() + } + return added +} + func (t *DataTable) MoveRow(delta int) { if !t.ready || len(t.order) == 0 { return @@ -190,7 +247,7 @@ func (t *DataTable) Update(msg tea.Msg) tea.Cmd { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { - case "pgdown", "pgdn", "f", "ctrl+d": + case "pgdown", "pgdn", "ctrl+d": t.viewport.ViewDown() case "pgup", "b", "ctrl+u": t.viewport.ViewUp() @@ -208,6 +265,9 @@ func (t DataTable) View() string { 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))) + if t.loadingMore { + title += t.styles.help.Render(" …") + } sortHint := "" if t.sortCol >= 0 && t.sortCol < len(t.columns) { dir := "▲" @@ -217,7 +277,13 @@ func (t DataTable) View() string { sortHint = t.styles.help.Render(fmt.Sprintf(" sort: %s %s", t.columns[t.sortCol].Title, dir)) } header := t.renderHeader() - body := t.viewport.View() + body := ApplyVerticalScrollbar( + t.viewport.View(), + t.viewport.Width, + t.viewport.Height, + t.viewport.TotalLineCount(), + t.viewport.YOffset, + ) content := lipgloss.JoinVertical(lipgloss.Left, title+sortHint, header, body) return lipgloss.NewStyle().Width(t.width).Render(content) } @@ -297,7 +363,7 @@ func distributeColumnWidths(minSum, total int, indices []int, cols []model.Table flex := make([]int, 0, len(indices)) for _, i := range indices { switch cols[i].Key { - case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "tasks": + case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "tasks", "type": flex = append(flex, i) } } @@ -332,6 +398,19 @@ func fixColumnWidthSum(widths map[int]int, indices []int, total int) { } } +const cellHPadding = 2 // lipgloss Padding(0, 1) on each side + +func truncateCellText(text string, colWidth int) string { + inner := colWidth - cellHPadding + if inner < 1 { + inner = 1 + } + if runewidth.StringWidth(text) <= inner { + return text + } + return runewidth.Truncate(text, inner, "...") +} + func (t *DataTable) renderHeader() string { indices, widths := t.visibleLayout() cells := make([]string, 0, len(indices)) @@ -346,7 +425,7 @@ func (t *DataTable) renderHeader() string { title += " ▼" } } - text := runewidth.Truncate(title, w, "…") + text := truncateCellText(title, w) style := t.styles.header if t.focused && colIdx == t.cursorCol { style = t.styles.headerSort @@ -371,39 +450,70 @@ func (t *DataTable) refreshViewport() { func (t *DataTable) renderRow(row int) string { item := t.items[t.order[row]] selected := item.Selected + fullRow := row == t.cursorRow || 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)) + raw := strings.TrimSpace(model.CellText(item, col.Key)) + text := raw + if !fullRow { + text = truncateCellText(raw, w) + } + cells = append(cells, t.styleCell(row, colIdx, col.Key, selected, item, text, w, fullRow)) } 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) +func applyCellWidth(s lipgloss.Style, width int, full bool) lipgloss.Style { + if full { + return s + } + return s.Width(width).MaxWidth(width) +} + +func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item model.Item, text string, width int, fullWidth bool) string { isRow := row == t.cursorRow isCol := col == t.cursorCol isCell := t.focused && isRow && isCol + var base lipgloss.Style switch { case selected && isCell: - return t.styles.cellSelect.Width(width).MaxWidth(width).Render(text) + base = t.styles.cellSelect case selected && isRow: - return t.styles.rowSelect.Width(width).MaxWidth(width).Render(text) + base = t.styles.rowSelect case selected: - return t.styles.rowSelect.Width(width).MaxWidth(width).Render(text) + base = t.styles.rowSelect case isCell: - return t.styles.cellActive.Width(width).MaxWidth(width).Render(text) + base = t.styles.cellActive case t.focused && isRow: - return t.styles.rowActive.Width(width).MaxWidth(width).Render(text) + base = t.styles.rowActive case t.focused && isCol: - return t.styles.colActive.Width(width).MaxWidth(width).Render(text) + base = t.styles.colActive default: - return base.Render(text) + base = t.styles.cell } + base = t.applyProjectRowStyle(base, item, colKey) + return applyCellWidth(base, width, fullWidth).Render(text) +} + +func (t *DataTable) applyProjectRowStyle(base lipgloss.Style, item model.Item, colKey string) lipgloss.Style { + if t.spec.Subject != model.SubjectProjects { + return base + } + open := model.ProjectIsOpen(item.Raw) + if colKey == "status" { + if open { + return base.Foreground(lipgloss.Color("42")).Bold(true) + } + return base.Foreground(lipgloss.Color("245")) + } + if open { + return base.Background(lipgloss.Color("22")).Foreground(lipgloss.Color("255")) + } + return base.Background(lipgloss.Color("238")).Foreground(lipgloss.Color("252")) } func (t *DataTable) ensureColVisible() { diff --git a/cmd/office/ui/table_append_test.go b/cmd/office/ui/table_append_test.go new file mode 100644 index 0000000..c8f1521 --- /dev/null +++ b/cmd/office/ui/table_append_test.go @@ -0,0 +1,40 @@ +package ui + +import ( + "testing" + + "github.com/eslider/go-onlyoffice/cmd/office/model" +) + +func TestDataTableAppendItemsDedupes(t *testing.T) { + tbl := newDataTable() + tbl.SetSize(40, 10) + tbl.SetData(model.ListSpec{Subject: model.SubjectMailInbox}, []model.Item{ + {ID: "1", Title: "A", Kind: model.KindMail}, + }) + added := tbl.AppendItems([]model.Item{ + {ID: "1", Title: "dup", Kind: model.KindMail}, + {ID: "2", Title: "B", Kind: model.KindMail}, + }) + if added != 1 || len(tbl.Items()) != 2 { + t.Fatalf("added=%d len=%d", added, len(tbl.Items())) + } +} + +func TestDataTableNearEnd(t *testing.T) { + tbl := newDataTable() + tbl.SetSize(40, 10) + items := make([]model.Item, 10) + for i := range items { + items[i] = model.Item{ID: string(rune('0' + i)), Title: "x", Kind: model.KindMail} + } + tbl.SetData(model.ListSpec{Subject: model.SubjectMailInbox}, items) + tbl.cursorRow = 7 + if !tbl.NearEnd(3) { + t.Fatal("expected near end") + } + tbl.cursorRow = 0 + if tbl.NearEnd(3) { + t.Fatal("expected not near end") + } +} diff --git a/cmd/office/ui/table_test.go b/cmd/office/ui/table_test.go index ca8d8a1..4394448 100644 --- a/cmd/office/ui/table_test.go +++ b/cmd/office/ui/table_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/eslider/go-onlyoffice/cmd/office/model" + "github.com/mattn/go-runewidth" ) func sampleItems() []model.Item { @@ -32,7 +33,7 @@ func TestDataTableMoveColScrolls(t *testing.T) { 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) + tbl.SetData(model.ListSpec{Subject: model.SubjectContacts}, items) last := len(tbl.columns) - 1 tbl.cursorCol = last tbl.ensureColVisible() @@ -88,6 +89,55 @@ func TestDistributeColumnWidthsFillsTotal(t *testing.T) { } } +func TestTruncateCellText(t *testing.T) { + long := strings.Repeat("x", 40) + got := truncateCellText(long, 10) + if runewidth.StringWidth(got) > 10 { + t.Fatalf("truncated width=%d want <=10", runewidth.StringWidth(got)) + } + if !strings.HasSuffix(got, "...") { + t.Fatalf("expected ellipsis suffix, got %q", got) + } +} + +func TestDataTableTruncatesNonSelectedRows(t *testing.T) { + long := strings.Repeat("A", 60) + items := []model.Item{ + {ID: "1", Title: "Short"}, + {ID: "2", Title: long}, + } + tbl := newDataTable() + tbl.SetSize(50, 10) + tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, items) + tbl.cursorRow = 0 + + if strings.Contains(tbl.renderRow(1), long) { + t.Fatal("non-cursor row should truncate long title") + } + + tbl.cursorRow = 1 + if !strings.Contains(tbl.renderRow(1), long) { + t.Fatal("cursor row should show full title") + } +} + +func TestDataTableShowsFullTextWhenSpaceSelected(t *testing.T) { + long := strings.Repeat("B", 60) + items := []model.Item{ + {ID: "1", Title: "Short"}, + {ID: "2", Title: long, Selected: true}, + } + tbl := newDataTable() + tbl.SetSize(50, 10) + tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, items) + tbl.cursorRow = 0 + + rowSelected := tbl.renderRow(1) + if !strings.Contains(rowSelected, long) { + t.Fatal("space-selected row should show full title") + } +} + func indexOfColumn(tbl DataTable, key string) int { for i, c := range tbl.columns { if c.Key == key { diff --git a/projects.go b/projects.go index 1b822f3..8e1b0cf 100644 --- a/projects.go +++ b/projects.go @@ -204,3 +204,15 @@ func (c *Client) UpdateProject(req ProjectUpdateRequest) (*Project, error) { Response *Project `json:"response"` }{p}) } + +// UpdateProjectStatus sets project lifecycle status (open, paused, closed). +func (c *Client) UpdateProjectStatus(id int, status string) (*Project, error) { + p := &Project{} + return p, c.Query(Request{ + Uri: fmt.Sprintf("/api/2.0/project/%d/status", id), + Method: "PUT", + Body: map[string]string{"status": status}, + }, &struct { + Response *Project `json:"response"` + }{p}) +}