Compare commits

...
1 Commits
Author SHA1 Message Date
eSliderandCursor fab2977cb1 feat(office): mail preview, infinite scroll, pane resize, and scrollbars
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.22) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 38s
Polish the office TUI with proportional pane layout, drag resize, live
filtering, HTML mail preview, paginated mail lists, overflow scrollbars,
flattened navigation, and project save/status fixes; add UpdateProjectStatus.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 22:23:25 +01:00
45 changed files with 2611 additions and 206 deletions
+24
View File
@@ -52,6 +52,30 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [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 ## [0.5.1] — 2026-06-24
### Added — `office` TUI ### Added — `office` TUI
+53
View File
@@ -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
}
+1 -2
View File
@@ -39,8 +39,7 @@ func TestIntegrationListAllSubjects(t *testing.T) {
cases := []model.ListSpec{ cases := []model.ListSpec{
{Subject: model.SubjectProjects}, {Subject: model.SubjectProjects},
{Subject: model.SubjectTasks}, {Subject: model.SubjectTasks},
{Subject: model.SubjectCalendars}, {Subject: model.SubjectCalendar},
{Subject: model.SubjectEvents},
{Subject: model.SubjectContacts}, {Subject: model.SubjectContacts},
{Subject: model.SubjectPersons}, {Subject: model.SubjectPersons},
{Subject: model.SubjectCompanies}, {Subject: model.SubjectCompanies},
+47 -28
View File
@@ -6,12 +6,14 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"time"
onlyoffice "github.com/eslider/go-onlyoffice" onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model" "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. // Loader fetches list items for a menu subject using the OnlyOffice client.
type Loader struct { type Loader struct {
Client *onlyoffice.Client 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.listTasksForProject(ctx, spec.ProjectID)
} }
return l.listTasks(ctx) return l.listTasks(ctx)
case model.SubjectCalendar:
return l.ListCalendar(ctx)
case model.SubjectCalendars: case model.SubjectCalendars:
return l.listCalendars(ctx) return l.ListCalendar(ctx)
case model.SubjectEvents: case model.SubjectEvents:
return l.listEvents(ctx) return l.ListCalendar(ctx)
case model.SubjectContacts: case model.SubjectContacts:
return l.listContacts(ctx, nil) return l.listContacts(ctx, nil)
case model.SubjectPersons: case model.SubjectPersons:
@@ -49,15 +53,15 @@ func (l *Loader) List(ctx context.Context, spec model.ListSpec) ([]model.Item, e
case model.SubjectCRMTasks: case model.SubjectCRMTasks:
return l.listCRMTasks(ctx) return l.listCRMTasks(ctx)
case model.SubjectMailInbox: case model.SubjectMailInbox:
return l.listMail(ctx, onlyoffice.MailFolderInbox) return l.listMail(ctx, onlyoffice.MailFolderInbox, 0)
case model.SubjectMailSent: case model.SubjectMailSent:
return l.listMail(ctx, onlyoffice.MailFolderSent) return l.listMail(ctx, onlyoffice.MailFolderSent, 0)
case model.SubjectMailDrafts: case model.SubjectMailDrafts:
return l.listMail(ctx, onlyoffice.MailFolderDrafts) return l.listMail(ctx, onlyoffice.MailFolderDrafts, 0)
case model.SubjectMailTrash: case model.SubjectMailTrash:
return l.listMail(ctx, onlyoffice.MailFolderTrash) return l.listMail(ctx, onlyoffice.MailFolderTrash, 0)
case model.SubjectMailSpam: case model.SubjectMailSpam:
return l.listMail(ctx, onlyoffice.MailFolderSpam) return l.listMail(ctx, onlyoffice.MailFolderSpam, 0)
case model.SubjectUsers: case model.SubjectUsers:
return l.listUsers(ctx) return l.listUsers(ctx)
case model.SubjectProjectFiles: case model.SubjectProjectFiles:
@@ -260,6 +264,9 @@ func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) {
if p.ParticipantCount != nil { if p.ParticipantCount != nil {
raw["participantCount"] = *p.ParticipantCount raw["participantCount"] = *p.ParticipantCount
} }
if p.Status != nil {
raw["status"] = *p.Status
}
items[i] = model.Item{ items[i] = model.Item{
ID: id, Title: title, Kind: model.KindProject, Raw: raw, 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 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) { func (l *Loader) listContacts(ctx context.Context, companyOnly *bool) ([]model.Item, error) {
rows, err := l.Client.ListAllContacts(ctx) rows, err := l.Client.ListAllContacts(ctx)
if err != nil { if err != nil {
@@ -335,14 +324,44 @@ func (l *Loader) listCRMTasks(ctx context.Context) ([]model.Item, error) {
return ItemsFromMaps(rows, model.KindCRMTask, TaskItemFields), nil return ItemsFromMaps(rows, model.KindCRMTask, TaskItemFields), nil
} }
func (l *Loader) listMail(ctx context.Context, folder int) ([]model.Item, error) { 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: 50}) rows, err := l.Client.ListMailMessages(ctx, onlyoffice.MailMessagesFilter{
Folder: folder,
Count: MailListPageSize,
StartIndex: startIndex,
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
return ItemsFromMaps(rows, model.KindMail, MailItemFields), nil 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) { func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) {
users, err := l.Client.GetUsers() users, err := l.Client.GetUsers()
if err != nil { if err != nil {
+1 -1
View File
@@ -14,7 +14,7 @@ type FieldMap struct {
} }
// TaskItemFields is the default field map for project tasks. // 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. // ProjectItemFields is the default field map for projects.
var ProjectItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"} var ProjectItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"}
+33 -12
View File
@@ -10,29 +10,50 @@ import (
) )
// SaveItem persists editable form fields for supported entity kinds. // 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 { if l == nil || l.Client == nil {
return fmt.Errorf("fetch: client is nil") return fmt.Errorf("fetch: client is nil")
} }
switch item.Kind { switch item.Kind {
case model.KindTask: case model.KindTask:
return l.UpdateTask(ctx, item.ID, title, description) return l.UpdateTask(ctx, item.ID, fields.Primary, fields.Secondary)
case model.KindProject: case model.KindProject:
id, err := strconv.Atoi(item.ID) return l.saveProject(ctx, item.ID, fields)
if err != nil {
return err
}
_, err = l.Client.UpdateProject(onlyoffice.ProjectUpdateRequest{
ID: id,
Title: title,
Description: description,
})
return err
default: default:
return fmt.Errorf("save not supported for %s", item.Kind) 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. // DetailForm loads form field values for the detail pane.
func (l *Loader) DetailForm(ctx context.Context, item model.Item) (model.FormFields, error) { func (l *Loader) DetailForm(ctx context.Context, item model.Item) (model.FormFields, error) {
raw, err := l.Detail(ctx, item) raw, err := l.Detail(ctx, item)
+52
View File
@@ -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
}
+40
View File
@@ -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 != "<nil>" {
return s
}
return ""
}
return t.Format("Jan 2 15:04")
}
+37
View File
@@ -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")
}
}
+81 -1
View File
@@ -37,6 +37,12 @@ func BuildColumns(subject Subject, items []Item) []TableColumn {
if subject == SubjectProjects { if subject == SubjectProjects {
return buildProjectColumns(items) return buildProjectColumns(items)
} }
if subject == SubjectTasks {
return buildTaskColumns(items)
}
if subject == SubjectCalendar {
return buildCalendarColumns(items)
}
cols := []TableColumn{ cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3}, {Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 10}, {Key: "id", Title: "ID", Width: 10},
@@ -64,10 +70,43 @@ func BuildColumns(subject Subject, items []Item) []TableColumn {
return cols 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 { func buildProjectColumns(items []Item) []TableColumn {
cols := []TableColumn{ cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3}, {Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 8}, {Key: "id", Title: "ID", Width: 8},
{Key: "status", Title: "Status", Width: 8},
{Key: "title", Title: "Title", Width: 28}, {Key: "title", Title: "Title", Width: 28},
{Key: "tasks", Title: "Tasks (open/closed)", Width: 18}, {Key: "tasks", Title: "Tasks (open/closed)", Width: 18},
{Key: "documents", Title: "Documents", Width: 11}, {Key: "documents", Title: "Documents", Width: 11},
@@ -89,6 +128,47 @@ func CellText(it Item, key string) string {
return it.ID return it.ID
case "title": case "title":
return it.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": case "subtitle":
return it.Subtitle return it.Subtitle
case "tasks": case "tasks":
@@ -230,7 +310,7 @@ func defaultWidth(key string) int {
case "id", "status", "stage": case "id", "status", "stage":
return 10 return 10
case "start", "end", "deadline", "date": case "start", "end", "deadline", "date":
return 18 return 20
case "from", "to", "email", "primaryEmail": case "from", "to", "email", "primaryEmail":
return 22 return 22
case "description", "displayName": case "description", "displayName":
+13 -2
View File
@@ -11,7 +11,7 @@ func TestBuildProjectColumns(t *testing.T) {
}, },
}} }}
cols := BuildColumns(SubjectProjects, items) 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) { if len(cols) != len(want) {
t.Fatalf("got %d columns, want %d", 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 { for _, c := range cols {
if c.Key == "status" || c.Key == "subtitle" { if c.Key == "subtitle" {
t.Fatalf("unexpected column %q", c.Key) 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) { func TestFormatProjectTasksOpenClosed(t *testing.T) {
raw := map[string]any{"taskCount": 3, "taskCountTotal": 8} raw := map[string]any{"taskCount": 3, "taskCountTotal": 8}
if got := formatProjectTasks(raw); got != "3/5" { if got := formatProjectTasks(raw); got != "3/5" {
+34
View File
@@ -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, " ")
}
+26
View File
@@ -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))
}
}
+32 -2
View File
@@ -9,6 +9,9 @@ type FormFields struct {
Primary string Primary string
Secondary string Secondary string
ReadOnly bool ReadOnly bool
HasStatus bool
Status ProjectLifecycle
ResponsibleID string
} }
// KindHeading returns a short label for the detail pane header. // 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"), Primary: strRaw(raw, "title"), Secondary: strRaw(raw, "description"),
ReadOnly: true, 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: default:
title := strRaw(raw, "title") title := strRaw(raw, "title")
if title == "" { if title == "" {
@@ -109,7 +127,19 @@ func strRaw(m map[string]any, key string) string {
return fmt.Sprint(m[key]) 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 { func IsDocumentKind(kind Kind) bool {
return kind == KindFile return kind == KindFile || kind == KindMail
} }
+3
View File
@@ -14,6 +14,9 @@ func TestIsDocumentKind(t *testing.T) {
if !IsDocumentKind(KindFile) { if !IsDocumentKind(KindFile) {
t.Fatal("file should be document") t.Fatal("file should be document")
} }
if !IsDocumentKind(KindMail) {
t.Fatal("mail should be document preview")
}
if IsDocumentKind(KindTask) { if IsDocumentKind(KindTask) {
t.Fatal("task should be form") t.Fatal("task should be form")
} }
+11
View File
@@ -39,8 +39,19 @@ const (
SubjectProjectFiles Subject = "project_files" SubjectProjectFiles Subject = "project_files"
SubjectTaskFiles Subject = "task_files" SubjectTaskFiles Subject = "task_files"
SubjectUsers Subject = "users" 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. // FocusPane is which column has keyboard focus.
type FocusPane int type FocusPane int
+12
View File
@@ -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")
}
}
+58 -13
View File
@@ -1,5 +1,7 @@
package model package model
import "strings"
// ListSpec tells the fetch layer what to load when a nav leaf is active. // ListSpec tells the fetch layer what to load when a nav leaf is active.
type ListSpec struct { type ListSpec struct {
Subject Subject Subject Subject
@@ -24,6 +26,7 @@ type NavTree struct {
expanded map[string]bool expanded map[string]bool
cursor int cursor int
visible []string visible []string
filter string
} }
// DefaultNavTree returns the OnlyOffice module tree (static skeleton). // DefaultNavTree returns the OnlyOffice module tree (static skeleton).
@@ -42,15 +45,11 @@ func DefaultNavTree() *NavTree {
} }
} }
add("projects", "Projects", "", true, nil) add("projects", "Projects", "", false, &ListSpec{Subject: SubjectProjects})
add("projects.browse", "Browse", "projects", true, nil) add("tasks", "Tasks", "", false, &ListSpec{Subject: SubjectTasks})
add("projects.browse.all", "All projects", "projects.browse", false, &ListSpec{Subject: SubjectProjects}) add("projects.dynamic", "By project", "", true, nil)
add("projects.browse.tasks", "All tasks", "projects.browse", false, &ListSpec{Subject: SubjectTasks})
add("projects.dynamic", "By project", "projects", true, nil)
add("calendar", "Calendar", "", true, nil) add("calendar", "Calendar", "", false, &ListSpec{Subject: SubjectCalendar})
add("calendar.cals", "Calendars", "calendar", false, &ListSpec{Subject: SubjectCalendars})
add("calendar.events", "Events", "calendar", false, &ListSpec{Subject: SubjectEvents})
add("crm", "CRM", "", true, nil) add("crm", "CRM", "", true, nil)
add("crm.contacts", "Contacts", "crm", false, &ListSpec{Subject: SubjectContacts}) add("crm.contacts", "Contacts", "crm", false, &ListSpec{Subject: SubjectContacts})
@@ -185,20 +184,66 @@ func (t *NavTree) Activate() (*ListSpec, bool) {
return nil, false 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() { func (t *NavTree) rebuildVisible() {
t.visible = t.visible[:0] t.visible = t.visible[:0]
var walk func(id string, depth int) var walk func(id string)
walk = func(id string, depth int) { walk = func(id string) {
if !t.visibleUnderFilter(id) {
return
}
t.visible = append(t.visible, id) 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 return
} }
for _, child := range t.children[id] { for _, child := range t.children[id] {
walk(child, depth+1) walk(child)
} }
} }
for _, root := range t.roots { for _, root := range t.roots {
walk(root, 0) walk(root)
} }
if t.cursor >= len(t.visible) { if t.cursor >= len(t.visible) {
t.cursor = len(t.visible) - 1 t.cursor = len(t.visible) - 1
+88 -16
View File
@@ -9,7 +9,7 @@ import (
func TestNavTreeHasExpectedRoots(t *testing.T) { func TestNavTreeHasExpectedRoots(t *testing.T) {
tree := model.DefaultNavTree() tree := model.DefaultNavTree()
roots := tree.RootLabels() 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) { if len(roots) != len(want) {
t.Fatalf("roots=%v want %v", roots, want) t.Fatalf("roots=%v want %v", roots, want)
} }
@@ -22,42 +22,79 @@ func TestNavTreeHasExpectedRoots(t *testing.T) {
func TestNavExpandCollapse(t *testing.T) { func TestNavExpandCollapse(t *testing.T) {
tree := model.DefaultNavTree() tree := model.DefaultNavTree()
if !tree.IsExpandable(0) { var crm int = -1
t.Fatal("Projects should be expandable") 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")
} }
tree.ToggleExpand(0) if crm < 0 {
if tree.IsExpanded(0) { t.Fatal("CRM node not found")
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 := model.DefaultNavTree()
tree.ToggleExpand(0) // Projects
tree.ToggleExpand(1) // Browse
// Find "All projects" leaf cursor
var found int = -1 var found int = -1
for i := 0; i < tree.VisibleCount(); i++ { for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i) n, ok := tree.NodeAtVisible(i)
if ok && n.List != nil && n.List.Subject == model.SubjectProjects { if ok && n.Label == "Calendar" {
found = i found = i
break break
} }
} }
if found < 0 { if found < 0 {
t.Fatal("all projects leaf not found") t.Fatal("Calendar node not found")
} }
tree.SetCursor(found) tree.SetCursor(found)
spec, ok := tree.CurrentListSpec() spec, ok := tree.CurrentListSpec()
if !ok || spec.Subject != model.SubjectProjects { if !ok || spec.Subject != model.SubjectCalendar {
t.Fatalf("spec=%v ok=%v", spec, ok) 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) { func TestPrevFocusPane(t *testing.T) {
if got := model.PrevFocusPane(model.FocusList); got != model.FocusMenu { if got := model.PrevFocusPane(model.FocusList); got != model.FocusMenu {
t.Fatalf("got %v", got) 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) { func TestActionsForContact(t *testing.T) {
acts := model.ActionsFor(model.KindContact) acts := model.ActionsFor(model.KindContact)
if len(acts) != 1 || acts[0].ID != model.ActionDelete { if len(acts) != 1 || acts[0].ID != model.ActionDelete {
+85
View File
@@ -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()
}
+26
View File
@@ -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")
}
}
+185
View File
@@ -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 != "<nil>" {
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
}
+49
View File
@@ -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")
}
}
+55 -9
View File
@@ -42,25 +42,71 @@ func OpportunityMarkdown(m map[string]any) string {
return strings.TrimSpace(b.String()) + "\n" 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 { func MailMarkdown(m map[string]any) string {
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "subject")) fmt.Fprintf(&b, "# %s\n\n", str(m, "subject"))
fmt.Fprintf(&b, "**From:** %s\n\n", str(m, "from")) fmt.Fprintf(&b, "**From:** %s\n\n", mailAddressLine(m, "from"))
if to := str(m, "to"); to != "" { if to := mailAddressLine(m, "to"); to != "" {
fmt.Fprintf(&b, "**To:** %s\n\n", to) fmt.Fprintf(&b, "**To:** %s\n\n", to)
} }
body := str(m, "body") if cc := mailAddressLine(m, "cc"); cc != "" {
if body == "" { fmt.Fprintf(&b, "**Cc:** %s\n\n", cc)
body = str(m, "htmlBody")
} }
body = stripHTML(body) if date := str(m, "date"); date != "" {
if body != "" { fmt.Fprintf(&b, "**Date:** %s\n\n", date)
fmt.Fprintf(&b, "## Body\n\n%s\n\n", body) }
if body := mailBodyMarkdown(m); body != "" {
fmt.Fprintf(&b, "---\n\n%s\n", body)
} }
return strings.TrimSpace(b.String()) + "\n" 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. // EventMarkdown formats a calendar event for preview.
func EventMarkdown(m map[string]any) string { func EventMarkdown(m map[string]any) string {
var b strings.Builder var b strings.Builder
+58
View File
@@ -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" <team@example.com>`,
"htmlBody": `<p>Hello <strong>world</strong></p><p><a href="https://example.com">Link</a></p>`,
}
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, "<strong>") || strings.Contains(md, "<p>") {
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": `<h2>Heading</h2><p>Plain text</p>`,
})
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)
}
}
@@ -119,7 +119,7 @@ func TestIntegrationPreviewMailFromAPI(t *testing.T) {
func TestIntegrationPreviewEventFromAPI(t *testing.T) { func TestIntegrationPreviewEventFromAPI(t *testing.T) {
loader, ctx := liveLoader(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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+280 -49
View File
@@ -18,6 +18,11 @@ type listLoadedMsg struct {
err error err error
} }
type listMoreLoadedMsg struct {
items []model.Item
err error
}
type detailLoadedMsg struct { type detailLoadedMsg struct {
item model.Item item model.Item
document bool document bool
@@ -28,7 +33,7 @@ type detailLoadedMsg struct {
type detailSavedMsg struct { type detailSavedMsg struct {
item model.Item item model.Item
title, description string fields model.FormFields
err error err error
} }
@@ -51,6 +56,8 @@ type Model struct {
hasList bool hasList bool
items []model.Item items []model.Item
selection *model.Selection selection *model.Selection
listHasMore bool
listLoadingMore bool
focus model.FocusPane focus model.FocusPane
width int width int
height int height int
@@ -63,6 +70,11 @@ type Model struct {
showMenu bool showMenu bool
showList bool showList bool
showDetail bool showDetail bool
filterActive bool
filterSearch FilterSearch
customPaneLayout bool
paneSizes PaneWidths
resize paneResizeState
} }
// NewModel constructs the TUI with an authenticated client. // NewModel constructs the TUI with an authenticated client.
@@ -74,20 +86,21 @@ func NewModel(client *onlyoffice.Client) Model {
nav: model.DefaultNavTree(), nav: model.DefaultNavTree(),
selection: model.NewSelection(), selection: model.NewSelection(),
focus: model.FocusMenu, 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, height: h,
width: w, width: w,
} }
m.menuVP = viewport.New(m.paneInnerWidth(22), m.paneHeight()) m.menuVP = viewport.New(m.paneInnerWidth(22), m.paneHeight())
m.listTable = newDataTable() m.listTable = newDataTable()
m.detail = newDetailPane() m.detail = newDetailPane()
m.filterSearch = newFilterSearch()
m.showMenu, m.showList, m.showDetail = true, true, true m.showMenu, m.showList, m.showDetail = true, true, true
m.menuVP.MouseWheelEnabled = true m.menuVP.MouseWheelEnabled = true
return m return m
} }
func (m Model) Init() tea.Cmd { 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) { 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: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
if m.customPaneLayout {
m.paneSizes = FitPaneWidths(m.width, m.paneVis(), m.paneSizes)
}
m.layoutViewports() m.layoutViewports()
m.syncAllContent() m.syncAllContent()
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyMsg:
key := msg.String() 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 == "?" { if key == "?" {
m.status = helpText() m.status = helpText()
return m, nil return m, nil
@@ -108,7 +138,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.scrollFocusedPane(key) { if m.scrollFocusedPane(key) {
return m, nil 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 { if cmd, handled := m.handleDetailKey(key, msg); handled {
return m, cmd return m, cmd
} }
@@ -121,11 +153,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case ActionQuit: case ActionQuit:
return m, tea.Quit return m, tea.Quit
case ActionNextPane: case ActionNextPane:
prev := m.focus
m.focus = NextVisibleFocus(m.focus, m.paneVis()) m.focus = NextVisibleFocus(m.focus, m.paneVis())
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive {
m.detail.FocusFirstStop()
}
m.syncPaneFocus() m.syncPaneFocus()
return m, nil return m, nil
case ActionPrevPane: case ActionPrevPane:
prev := m.focus
m.focus = PrevVisibleFocus(m.focus, m.paneVis()) m.focus = PrevVisibleFocus(m.focus, m.paneVis())
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive {
m.detail.FocusFirstStop()
}
m.syncPaneFocus() m.syncPaneFocus()
return m, nil return m, nil
case ActionMoveUp: case ActionMoveUp:
@@ -144,7 +184,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.onListRowChanged() return m, m.onListRowChanged()
case ActionMoveLeft: case ActionMoveLeft:
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions { if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
m.detail.MoveAction(-1) m.detail.MoveTabStop(-1)
return m, nil return m, nil
} }
if m.focus == model.FocusList && m.hasList { 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 return m, nil
case ActionMoveRight: case ActionMoveRight:
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions { if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
m.detail.MoveAction(1) m.detail.MoveTabStop(1)
return m, nil return m, nil
} }
if m.focus == model.FocusList && m.hasList { 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 return m, nil
case ActionToggleSelect: case ActionToggleSelect:
if m.hasList { if m.hasList {
idx := m.listTable.ItemIndex() if it, ok := m.listTableItem(); ok {
if idx >= 0 { for i := range m.items {
m.selection.Toggle(&m.items, idx) if m.items[i].ID == it.ID {
m.listTable.UpdateItems(m.items) m.selection.Toggle(&m.items, i)
break
}
}
m.syncListTable()
} }
} }
return m, nil return m, nil
@@ -178,7 +222,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.hasList && m.showDetail { if m.hasList && m.showDetail {
m.focus = model.FocusPreview m.focus = model.FocusPreview
m.syncPaneFocus() m.syncPaneFocus()
m.detail.FocusContent() m.detail.FocusFirstStop()
return m, m.onListRowChanged() return m, m.onListRowChanged()
} }
return m, nil return m, nil
@@ -191,6 +235,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case ActionToggleDetailPane: case ActionToggleDetailPane:
m.togglePane(3) m.togglePane(3)
return m, nil return m, nil
case ActionFilter:
m.enterFilter()
return m, nil
case ActionRefresh: case ActionRefresh:
if m.hasList { if m.hasList {
m.loading = true m.loading = true
@@ -222,10 +269,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case listLoadedMsg: case listLoadedMsg:
m.loading = false m.loading = false
m.listLoadingMore = false
if msg.err != nil { if msg.err != nil {
m.err = msg.err.Error() m.err = msg.err.Error()
m.items = nil m.items = nil
m.hasList = false m.hasList = false
m.listHasMore = false
m.listTable.Clear() m.listTable.Clear()
m.detail.Clear() m.detail.Clear()
} else { } else {
@@ -233,9 +282,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.items = msg.items m.items = msg.items
m.listSpec = msg.spec m.listSpec = msg.spec
m.hasList = true m.hasList = true
m.listHasMore = model.SubjectIsMail(msg.spec.Subject) && len(msg.items) >= fetch.MailListPageSize
m.listTable.SetData(m.listSpec, m.items) 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: case detailLoadedMsg:
m.loading = false m.loading = false
@@ -263,11 +335,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.err != nil { if msg.err != nil {
m.err = msg.err.Error() m.err = msg.err.Error()
} else { } else {
m.updateItemAfterSave(msg.item, msg.title, msg.description) m.updateItemAfterSave(msg.item, msg.fields)
fields, _ := m.loader.DetailForm(context.Background(), msg.item) m.detail.LoadForm(msg.item, msg.fields)
fields.Primary = msg.title
fields.Secondary = msg.description
m.detail.LoadForm(msg.item, fields)
m.detail.SetFocused(m.focus == model.FocusPreview) m.detail.SetFocused(m.focus == model.FocusPreview)
m.status = "Saved" m.status = "Saved"
m.err = "" m.err = ""
@@ -296,6 +365,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case tea.MouseMsg: case tea.MouseMsg:
if m.handlePaneResizeMouse(msg) {
return m, nil
}
if cmd, handled := m.routeDetailDocumentMouse(msg); handled {
return m, cmd
}
var cmd tea.Cmd var cmd tea.Cmd
switch m.focus { switch m.focus {
case model.FocusMenu: case model.FocusMenu:
@@ -303,8 +378,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case model.FocusList: case model.FocusList:
cmd = m.listTable.Update(msg) cmd = m.listTable.Update(msg)
case model.FocusPreview: case model.FocusPreview:
if !m.filterActive {
cmd = m.detail.Update(msg) cmd = m.detail.Update(msg)
} }
}
return m, cmd return m, cmd
} }
@@ -315,15 +392,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case model.FocusList: case model.FocusList:
cmd = m.listTable.Update(msg) cmd = m.listTable.Update(msg)
case model.FocusPreview: case model.FocusPreview:
if !m.filterActive {
cmd = m.detail.Update(msg) cmd = m.detail.Update(msg)
} }
}
return m, cmd return m, cmd
} }
func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) { func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
switch key { switch key {
case "tab", "shift+tab", "backtab": 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 return nil, true
case "ctrl+s": case "ctrl+s":
m.loading = true m.loading = true
@@ -347,16 +434,37 @@ func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
return nil, false 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) { func (m Model) handleDetailMove(delta int) (Model, tea.Cmd) {
if m.detail.Zone() == detailZoneActions { if m.detail.Zone() == detailZoneContent && (m.detail.IsDocumentContent() || m.detail.isReadOnlyFormContent()) {
m.detail.MoveAction(delta) key := "down"
if delta < 0 {
key = "up"
}
m.detail.ScrollDocument(key)
return m, nil return m, nil
} }
if delta < 0 { m.detail.MoveTabStop(delta)
m.detail.form.FocusPrev()
} else {
m.detail.form.FocusNext()
}
return m, nil return m, nil
} }
@@ -369,7 +477,14 @@ func (m Model) View() string {
var parts []string var parts []string
if pw.Visibility.Menu { if pw.Visibility.Menu {
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(pw.Menu).Height(h) 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 { if pw.Visibility.List {
listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h) listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h)
@@ -377,7 +492,13 @@ func (m Model) View() string {
} }
if pw.Visibility.Detail { if pw.Visibility.Detail {
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(pw.Detail).Height(h) 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 status := m.status
@@ -397,11 +518,21 @@ func (m Model) paneVis() PaneVisibility {
} }
func (m Model) paneLayout() PaneWidths { 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() { func (m *Model) syncPaneFocus() {
m.listTable.SetFocused(m.focus == model.FocusList && m.showList) 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) 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()) { if !paneVisible(m.focus, m.paneVis()) {
m.focus = firstVisibleFocus(m.paneVis()) m.focus = firstVisibleFocus(m.paneVis())
} }
if m.customPaneLayout {
m.paneSizes = FitPaneWidths(m.width, m.paneVis(), m.paneSizes)
}
m.layoutViewports() m.layoutViewports()
m.syncAllContent() m.syncAllContent()
m.syncPaneFocus() m.syncPaneFocus()
@@ -436,6 +570,8 @@ func (m Model) withList(spec model.ListSpec) (Model, tea.Cmd) {
m.listSpec = spec m.listSpec = spec
m.selection.Clear() m.selection.Clear()
m.hasList = false m.hasList = false
m.listHasMore = false
m.listLoadingMore = false
m.focus = model.FocusList m.focus = model.FocusList
m.listTable.SetFocused(true) m.listTable.SetFocused(true)
m.detail.Clear() m.detail.Clear()
@@ -455,6 +591,7 @@ func (m *Model) layoutViewports() {
} }
if pw.Visibility.Detail { if pw.Visibility.Detail {
m.detail.SetSize(m.paneInnerWidth(pw.Detail), h) 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() m.listTable.Clear()
return 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() { func (m *Model) syncMenuContent() {
var b strings.Builder 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 menuW := m.menuVP.Width
if menuW < 10 { if menuW < 10 {
menuW = 20 menuW = 20
@@ -603,21 +790,52 @@ func (m *Model) loadNavProjectsCmd() tea.Cmd {
} }
func (m *Model) onListRowChanged() tea.Cmd { func (m *Model) onListRowChanged() tea.Cmd {
if m.filterActive {
return nil
}
if !m.hasList { if !m.hasList {
m.detail.Clear() m.detail.Clear()
return nil return m.maybeLoadMoreList()
} }
idx := m.listTable.ItemIndex() it, ok := m.listTableItem()
if idx < 0 || idx >= len(m.items) { if !ok {
m.detail.Clear() m.detail.Clear()
return nil return m.maybeLoadMoreList()
}
it := m.items[idx]
if m.detail.LoadedID() == it.ID {
return nil
} }
var cmds []tea.Cmd
cmds = append(cmds, m.maybeLoadMoreList())
if m.detail.LoadedID() != it.ID {
m.loading = true m.loading = true
return m.loadDetailCmd(it) 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
}
if !model.SubjectIsMail(m.listSpec.Subject) {
return nil
}
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 { 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 { func (m *Model) saveDetailCmd() tea.Cmd {
item := m.detail.Item() item := m.detail.Item()
title := m.detail.form.Primary() fields := m.detail.form.FormFields()
desc := m.detail.form.Secondary()
return func() tea.Msg { return func() tea.Msg {
ctx := context.Background() ctx := context.Background()
err := m.loader.SaveItem(ctx, item, title, desc) err := m.loader.SaveItem(ctx, item, fields)
return detailSavedMsg{item: item, title: title, description: desc, err: err} 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 { for i := range m.items {
if m.items[i].ID != item.ID { if m.items[i].ID != item.ID {
continue continue
} }
m.items[i].Title = title m.items[i].Title = fields.Primary
if m.items[i].Raw == nil { if m.items[i].Raw == nil {
m.items[i].Raw = map[string]any{} m.items[i].Raw = map[string]any{}
} }
m.items[i].Raw["title"] = title m.items[i].Raw["title"] = fields.Primary
m.items[i].Raw["description"] = description m.items[i].Raw["description"] = fields.Secondary
m.listTable.UpdateItems(m.items) if fields.HasStatus {
m.items[i].Raw["status"] = statusInt(fields.Status)
}
m.syncListTable()
return 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 { func paneStyle(focused bool) lipgloss.Style {
s := lipgloss.NewStyle().Padding(0, 1) s := lipgloss.NewStyle().Padding(0, 1)
if focused { if focused {
@@ -668,5 +899,5 @@ func paneStyle(focused bool) lipgloss.Style {
} }
func helpText() string { 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"
} }
+6
View File
@@ -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) { func TestLayoutWidths(t *testing.T) {
menu, list, preview := ui.LayoutWidthsLegacy(120) menu, list, preview := ui.LayoutWidthsLegacy(120)
if menu+list+preview != 120 { if menu+list+preview != 120 {
+244 -21
View File
@@ -34,12 +34,14 @@ type DetailPane struct {
loadedID string loadedID string
actions []model.ItemAction actions []model.ItemAction
actionIdx int actionIdx int
tabStop int
zone detailZone zone detailZone
focused bool focused bool
width int width int
height int height int
form EntityForm form EntityForm
docVP viewport.Model docVP viewport.Model
formVP viewport.Model
docText string docText string
styles detailStyles styles detailStyles
} }
@@ -56,9 +58,11 @@ func newDetailPane() DetailPane {
d := DetailPane{ d := DetailPane{
form: newEntityForm(), form: newEntityForm(),
docVP: viewport.New(40, 10), docVP: viewport.New(40, 10),
formVP: viewport.New(40, 10),
styles: newDetailStyles(), styles: newDetailStyles(),
} }
d.docVP.MouseWheelEnabled = true d.docVP.MouseWheelEnabled = true
d.formVP.MouseWheelEnabled = true
return d return d
} }
@@ -79,14 +83,112 @@ func (d *DetailPane) Clear() {
d.loadedID = "" d.loadedID = ""
d.actions = nil d.actions = nil
d.actionIdx = 0 d.actionIdx = 0
d.tabStop = 0
d.docText = "" d.docText = ""
d.form.Clear() d.form.Clear()
d.docVP.SetContent("") d.docVP.SetContent("")
d.formVP.SetContent("")
} }
func (d *DetailPane) SetFocused(on bool) { func (d *DetailPane) SetFocused(on bool) {
d.focused = on 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) { func (d *DetailPane) SetSize(w, h int) {
@@ -102,6 +204,8 @@ func (d *DetailPane) SetSize(w, h int) {
d.form.SetSize(w, contentH) d.form.SetSize(w, contentH)
d.docVP.Width = w d.docVP.Width = w
d.docVP.Height = contentH d.docVP.Height = contentH
d.formVP.Width = w
d.formVP.Height = contentH
} }
func (d *DetailPane) splitHeights() (contentH, actionH int) { 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.loadedID = item.ID
d.actions = model.ActionsFor(item.Kind) d.actions = model.ActionsFor(item.Kind)
d.actionIdx = 0 d.actionIdx = 0
d.tabStop = 0
d.form.Load(item.Kind, item.ID, fields) d.form.Load(item.Kind, item.ID, fields)
d.refreshFormViewport()
d.applyTabStop(0)
d.layoutContent() d.layoutContent()
} }
@@ -135,6 +242,7 @@ func (d *DetailPane) LoadDocument(item model.Item, markdown string, renderWidth
d.loadedID = item.ID d.loadedID = item.ID
d.actions = model.ActionsFor(item.Kind) d.actions = model.ActionsFor(item.Kind)
d.actionIdx = 0 d.actionIdx = 0
d.tabStop = 0
d.form.Clear() d.form.Clear()
text, err := preview.RenderMarkdown(markdown, renderWidth) text, err := preview.RenderMarkdown(markdown, renderWidth)
if err != nil { if err != nil {
@@ -142,6 +250,8 @@ func (d *DetailPane) LoadDocument(item model.Item, markdown string, renderWidth
} }
d.docText = text d.docText = text
d.docVP.SetContent(text) d.docVP.SetContent(text)
d.docVP.GotoTop()
d.applyTabStop(0)
d.layoutContent() d.layoutContent()
} }
@@ -150,6 +260,17 @@ func (d *DetailPane) layoutContent() {
d.form.SetSize(d.width, contentH) d.form.SetSize(d.width, contentH)
d.docVP.Width = d.width d.docVP.Width = d.width
d.docVP.Height = contentH 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 } func (d *DetailPane) LoadedID() string { return d.loadedID }
@@ -158,22 +279,45 @@ func (d *DetailPane) Item() model.Item { return d.item }
func (d *DetailPane) Zone() detailZone { return d.zone } func (d *DetailPane) Zone() detailZone { return d.zone }
func (d *DetailPane) FocusActions() {
d.zone = detailZoneActions
d.form.SetFocused(false)
}
func (d *DetailPane) FocusContent() { func (d *DetailPane) FocusContent() {
d.zone = detailZoneContent d.applyTabStop(d.tabStop)
d.form.SetFocused(d.focused && d.mode == detailForm) if d.zone == detailZoneContent && d.mode == detailForm {
d.form.SetFocused(d.focused)
}
} }
func (d *DetailPane) ToggleZone() { func (d *DetailPane) FocusActions() {
if d.zone == detailZoneContent { max := d.maxTabStop()
d.FocusActions() switch d.mode {
} else { case detailForm:
d.FocusContent() 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) { func (d *DetailPane) MoveAction(delta int) {
@@ -190,6 +334,62 @@ func (d *DetailPane) SelectedAction() (model.ItemAction, bool) {
return d.actions[d.actionIdx], true 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 { func (d *DetailPane) Update(msg tea.Msg) tea.Cmd {
if !d.focused { if !d.focused {
return nil return nil
@@ -197,14 +397,21 @@ func (d *DetailPane) Update(msg tea.Msg) tea.Cmd {
if d.zone == detailZoneContent { if d.zone == detailZoneContent {
switch d.mode { switch d.mode {
case detailForm: 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) return d.form.Update(msg)
case detailDocument: case detailDocument:
if _, ok := msg.(tea.KeyMsg); ok { if key, ok := msg.(tea.KeyMsg); ok {
switch msg.(tea.KeyMsg).String() { if scrollViewport(&d.docVP, key.String()) {
case "pgdown", "pgdn", "f", "ctrl+d": return nil
d.docVP.ViewDown()
case "pgup", "b", "ctrl+u":
d.docVP.ViewUp()
} }
} }
var cmd tea.Cmd var cmd tea.Cmd
@@ -223,9 +430,25 @@ func (d DetailPane) View() string {
var top string var top string
switch d.mode { switch d.mode {
case detailForm: case detailForm:
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()) top = lipgloss.NewStyle().Width(d.width).Height(contentH).Render(d.form.View())
}
case detailDocument: case detailDocument:
top = d.docVP.View() top = ApplyVerticalScrollbar(
d.docVP.View(),
d.docVP.Width,
d.docVP.Height,
d.docVP.TotalLineCount(),
d.docVP.YOffset,
)
default: default:
top = "" top = ""
} }
@@ -258,7 +481,7 @@ func (d DetailPane) renderActions() string {
} }
line := strings.Join(parts, "") line := strings.Join(parts, "")
if d.focused && d.zone == detailZoneActions { if d.focused && d.zone == detailZoneActions {
line += d.styles.empty.Render(" ←/→ select · Enter run") line += d.styles.empty.Render(" Tab · Enter run")
} }
return line return line
} }
+60
View File
@@ -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)
}
}
+65
View File
@@ -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())
}
}
+112 -4
View File
@@ -15,6 +15,7 @@ type entityField int
const ( const (
entityFieldPrimary entityField = iota entityFieldPrimary entityField = iota
entityFieldSecondary entityFieldSecondary
entityFieldStatus
) )
// EntityForm is the top section of the detail pane for editable entities. // EntityForm is the top section of the detail pane for editable entities.
@@ -25,6 +26,9 @@ type EntityForm struct {
primaryLabel string primaryLabel string
secondaryLabel string secondaryLabel string
readOnly bool readOnly bool
hasStatus bool
status model.ProjectLifecycle
responsibleID string
primary textinput.Model primary textinput.Model
secondary textarea.Model secondary textarea.Model
field entityField 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) Active() bool { return f.active }
func (f *EntityForm) ItemID() string { return f.itemID } 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.primaryLabel = fields.PrimaryLabel
f.secondaryLabel = fields.SecondaryLabel f.secondaryLabel = fields.SecondaryLabel
f.readOnly = fields.ReadOnly f.readOnly = fields.ReadOnly
f.hasStatus = fields.HasStatus
f.status = fields.Status
f.responsibleID = fields.ResponsibleID
f.primary.SetValue(fields.Primary) f.primary.SetValue(fields.Primary)
f.secondary.SetValue(fields.Secondary) f.secondary.SetValue(fields.Secondary)
f.dirty = false f.dirty = false
f.field = entityFieldPrimary f.field = entityFieldPrimary
if f.hasStatus && f.readOnly {
f.field = entityFieldStatus
}
f.applyFocus() f.applyFocus()
f.layoutFields() f.layoutFields()
} }
@@ -93,6 +124,9 @@ func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormField
func (f *EntityForm) Clear() { func (f *EntityForm) Clear() {
f.active = false f.active = false
f.itemID = "" f.itemID = ""
f.hasStatus = false
f.status = model.ProjectLifecycleOpen
f.responsibleID = ""
f.primary.SetValue("") f.primary.SetValue("")
f.secondary.SetValue("") f.secondary.SetValue("")
f.dirty = false f.dirty = false
@@ -140,19 +174,52 @@ func (f *EntityForm) layoutFields() {
} }
func (f *EntityForm) FocusNext() { func (f *EntityForm) FocusNext() {
if f.field < entityFieldSecondary { last := entityFieldSecondary
if f.hasStatus {
last = entityFieldStatus
}
if f.field < last {
f.field++ f.field++
} }
f.applyFocus() f.applyFocus()
} }
func (f *EntityForm) FocusPrev() { func (f *EntityForm) FocusPrev() {
if f.field > entityFieldPrimary { first := entityFieldPrimary
if f.field > first {
f.field-- f.field--
} }
f.applyFocus() 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() { func (f *EntityForm) applyFocus() {
if f.readOnly { if f.readOnly {
f.primary.Blur() f.primary.Blur()
@@ -163,9 +230,12 @@ func (f *EntityForm) applyFocus() {
case entityFieldPrimary: case entityFieldPrimary:
f.primary.Focus() f.primary.Focus()
f.secondary.Blur() f.secondary.Blur()
default: case entityFieldSecondary:
f.primary.Blur() f.primary.Blur()
f.secondary.Focus() 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 { if !f.active || !f.focused || f.readOnly {
return nil 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 var cmd tea.Cmd
switch f.field { switch f.field {
case entityFieldPrimary: case entityFieldPrimary:
f.primary, cmd = f.primary.Update(msg) f.primary, cmd = f.primary.Update(msg)
default: case entityFieldSecondary:
f.secondary, cmd = f.secondary.Update(msg) f.secondary, cmd = f.secondary.Update(msg)
default:
return nil
} }
if _, ok := msg.(tea.KeyMsg); ok { if _, ok := msg.(tea.KeyMsg); ok {
f.dirty = true f.dirty = true
@@ -199,6 +284,28 @@ func (f EntityForm) View() string {
if f.focused && f.field == entityFieldSecondary && !f.readOnly { if f.focused && f.field == entityFieldSecondary && !f.readOnly {
sLabel = f.styles.labelAct.Render(f.secondaryLabel) 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 := "" ro := ""
if f.readOnly { if f.readOnly {
ro = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" (read-only)") + "\n" ro = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" (read-only)") + "\n"
@@ -210,6 +317,7 @@ func (f EntityForm) View() string {
"", "",
sLabel, sLabel,
f.secondary.View(), f.secondary.View(),
statusBlock,
}, "\n") }, "\n")
} }
+88
View File
@@ -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
}
+3
View File
@@ -17,6 +17,7 @@ const (
ActionToggleMenuPane ActionToggleMenuPane
ActionToggleListPane ActionToggleListPane
ActionToggleDetailPane ActionToggleDetailPane
ActionFilter
ActionNextPane ActionNextPane
ActionPrevPane ActionPrevPane
ActionRefresh ActionRefresh
@@ -38,6 +39,8 @@ func KeyAction(key string, pane model.FocusPane) Action {
} }
case "r": case "r":
return ActionRefresh return ActionRefresh
case "f":
return ActionFilter
case "up", "k": case "up", "k":
return ActionMoveUp return ActionMoveUp
case "down", "j": case "down", "j":
+37 -21
View File
@@ -17,44 +17,60 @@ func defaultPaneVisibility() PaneVisibility {
return PaneVisibility{Menu: true, List: true, Detail: true} 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 { func LayoutWidths(total int, vis PaneVisibility) PaneWidths {
if total < 1 { if total < 1 {
total = 80 total = 80
} }
v := vis v := vis
n := countVisible(v) if countVisible(v) == 0 {
if n == 0 {
v = defaultPaneVisibility() v = defaultPaneVisibility()
n = 3
} }
base := total / n shareSum := 0
rem := total % n
out := PaneWidths{Visibility: v}
if v.Menu { if v.Menu {
out.Menu = base shareSum += defaultMenuShare
if rem > 0 {
out.Menu++
rem--
}
} }
if v.List { if v.List {
out.List = base shareSum += defaultListShare
if rem > 0 {
out.List++
rem--
}
} }
if v.Detail { if v.Detail {
out.Detail = base shareSum += defaultDetailShare
if rem > 0 {
out.Detail++
rem--
} }
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 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 { func countVisible(v PaneVisibility) int {
n := 0 n := 0
if v.Menu { if v.Menu {
+203
View File
@@ -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
}
+41
View File
@@ -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)
}
}
+17
View File
@@ -13,6 +13,9 @@ func TestLayoutWidthsAllVisibleUsesFullWidth(t *testing.T) {
if sum != 120 { if sum != 120 {
t.Fatalf("sum=%d want 120", sum) 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) { func TestLayoutWidthsSinglePane(t *testing.T) {
@@ -30,6 +33,20 @@ func TestLayoutWidthsTwoPanes(t *testing.T) {
if pw.Menu+pw.Detail != 80 { if pw.Menu+pw.Detail != 80 {
t.Fatalf("sum=%d want 80", pw.Menu+pw.Detail) 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) { func TestNextVisibleFocusSkipsHidden(t *testing.T) {
+10 -4
View File
@@ -11,7 +11,7 @@ func (m *Model) scrollFocusedPane(key string) bool {
return scrollViewport(&m.menuVP, key) return scrollViewport(&m.menuVP, key)
case model.FocusList: case model.FocusList:
switch key { switch key {
case "pgdown", "pgdn", "f", "ctrl+d": case "pgdown", "pgdn", "ctrl+d":
m.listTable.PageScroll(1) m.listTable.PageScroll(1)
return true return true
case "pgup", "b", "ctrl+u": case "pgup", "b", "ctrl+u":
@@ -20,10 +20,10 @@ func (m *Model) scrollFocusedPane(key string) bool {
} }
return false return false
case model.FocusPreview: case model.FocusPreview:
if m.detail.Zone() != detailZoneContent || m.detail.mode != detailDocument { if m.detail.Zone() != detailZoneContent {
return false return false
} }
return scrollViewport(&m.detail.docVP, key) return m.detail.ScrollDocument(key)
default: default:
return false return false
} }
@@ -50,7 +50,13 @@ func (m *Model) paneInnerWidth(outer int) int {
func scrollViewport(vp *viewport.Model, key string) bool { func scrollViewport(vp *viewport.Model, key string) bool {
switch key { 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() vp.ViewDown()
return true return true
case "pgup", "b", "ctrl+u": case "pgup", "b", "ctrl+u":
+76
View File
@@ -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
}
+32
View File
@@ -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])
}
}
+125 -15
View File
@@ -53,6 +53,7 @@ type DataTable struct {
sortAsc bool sortAsc bool
focused bool focused bool
ready bool ready bool
loadingMore bool
width int width int
height int height int
viewport viewport.Model viewport viewport.Model
@@ -127,6 +128,62 @@ func (t *DataTable) ItemIndex() int {
return t.order[t.cursorRow] 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) { func (t *DataTable) MoveRow(delta int) {
if !t.ready || len(t.order) == 0 { if !t.ready || len(t.order) == 0 {
return return
@@ -190,7 +247,7 @@ func (t *DataTable) Update(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "pgdown", "pgdn", "f", "ctrl+d": case "pgdown", "pgdn", "ctrl+d":
t.viewport.ViewDown() t.viewport.ViewDown()
case "pgup", "b", "ctrl+u": case "pgup", "b", "ctrl+u":
t.viewport.ViewUp() 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" 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))) title := t.styles.title.Render(fmt.Sprintf("%s (%d)", t.spec.Subject, len(t.items)))
if t.loadingMore {
title += t.styles.help.Render(" …")
}
sortHint := "" sortHint := ""
if t.sortCol >= 0 && t.sortCol < len(t.columns) { if t.sortCol >= 0 && t.sortCol < len(t.columns) {
dir := "▲" 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)) sortHint = t.styles.help.Render(fmt.Sprintf(" sort: %s %s", t.columns[t.sortCol].Title, dir))
} }
header := t.renderHeader() 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) content := lipgloss.JoinVertical(lipgloss.Left, title+sortHint, header, body)
return lipgloss.NewStyle().Width(t.width).Render(content) 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)) flex := make([]int, 0, len(indices))
for _, i := range indices { for _, i := range indices {
switch cols[i].Key { 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) 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 { func (t *DataTable) renderHeader() string {
indices, widths := t.visibleLayout() indices, widths := t.visibleLayout()
cells := make([]string, 0, len(indices)) cells := make([]string, 0, len(indices))
@@ -346,7 +425,7 @@ func (t *DataTable) renderHeader() string {
title += " ▼" title += " ▼"
} }
} }
text := runewidth.Truncate(title, w, "…") text := truncateCellText(title, w)
style := t.styles.header style := t.styles.header
if t.focused && colIdx == t.cursorCol { if t.focused && colIdx == t.cursorCol {
style = t.styles.headerSort style = t.styles.headerSort
@@ -371,39 +450,70 @@ func (t *DataTable) refreshViewport() {
func (t *DataTable) renderRow(row int) string { func (t *DataTable) renderRow(row int) string {
item := t.items[t.order[row]] item := t.items[t.order[row]]
selected := item.Selected selected := item.Selected
fullRow := row == t.cursorRow || selected
indices, widths := t.visibleLayout() indices, widths := t.visibleLayout()
cells := make([]string, 0, len(indices)) cells := make([]string, 0, len(indices))
for _, colIdx := range indices { for _, colIdx := range indices {
col := t.columns[colIdx] col := t.columns[colIdx]
w := widths[colIdx] w := widths[colIdx]
text := runewidth.Truncate(model.CellText(item, col.Key), w, "…") raw := strings.TrimSpace(model.CellText(item, col.Key))
cells = append(cells, t.styleCell(row, colIdx, selected, text, w)) 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...) return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
} }
func (t *DataTable) styleCell(row, col int, selected bool, text string, width int) string { func applyCellWidth(s lipgloss.Style, width int, full bool) lipgloss.Style {
base := t.styles.cell.Width(width).MaxWidth(width) 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 isRow := row == t.cursorRow
isCol := col == t.cursorCol isCol := col == t.cursorCol
isCell := t.focused && isRow && isCol isCell := t.focused && isRow && isCol
var base lipgloss.Style
switch { switch {
case selected && isCell: case selected && isCell:
return t.styles.cellSelect.Width(width).MaxWidth(width).Render(text) base = t.styles.cellSelect
case selected && isRow: case selected && isRow:
return t.styles.rowSelect.Width(width).MaxWidth(width).Render(text) base = t.styles.rowSelect
case selected: case selected:
return t.styles.rowSelect.Width(width).MaxWidth(width).Render(text) base = t.styles.rowSelect
case isCell: case isCell:
return t.styles.cellActive.Width(width).MaxWidth(width).Render(text) base = t.styles.cellActive
case t.focused && isRow: case t.focused && isRow:
return t.styles.rowActive.Width(width).MaxWidth(width).Render(text) base = t.styles.rowActive
case t.focused && isCol: case t.focused && isCol:
return t.styles.colActive.Width(width).MaxWidth(width).Render(text) base = t.styles.colActive
default: 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() { func (t *DataTable) ensureColVisible() {
+40
View File
@@ -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")
}
}
+51 -1
View File
@@ -5,6 +5,7 @@ import (
"testing" "testing"
"github.com/eslider/go-onlyoffice/cmd/office/model" "github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/mattn/go-runewidth"
) )
func sampleItems() []model.Item { 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{ items[0] = model.Item{ID: "1", Title: "One", Raw: map[string]any{
"alpha": "a", "beta": "b", "gamma": "c", "delta": "d", "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 last := len(tbl.columns) - 1
tbl.cursorCol = last tbl.cursorCol = last
tbl.ensureColVisible() 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 { func indexOfColumn(tbl DataTable, key string) int {
for i, c := range tbl.columns { for i, c := range tbl.columns {
if c.Key == key { if c.Key == key {
+12
View File
@@ -204,3 +204,15 @@ func (c *Client) UpdateProject(req ProjectUpdateRequest) (*Project, error) {
Response *Project `json:"response"` Response *Project `json:"response"`
}{p}) }{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})
}