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}) +}