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]
## [0.7.0] — 2026-06-24
### Added — `office` TUI
- **Pane layout** — default **10% / 60% / 30%** split (nav / list / detail); drag vertical borders to resize; **Alt+1/2/3** still toggles panes.
- **Filter mode** (`f`) — live filter on nav + list; right pane is the search query; **Esc** clears.
- **Mail preview** — HTML bodies rendered colorized in the terminal (glamour); read-only scrollable document pane.
- **Mail infinite scroll** — loads the next page automatically when the cursor nears the end of the list.
- **Scrollbars** — vertical scrollbar on nav, list body, and detail preview when content exceeds the viewport.
- **Calendar** — unified calendars + events leaf with **Type** column and date range (−7d … +30d).
- **Tasks** — humanized status labels and relative deadlines in the list.
- **Projects** — open/closed row colors; status toggle in the detail form; **Save** sends `responsibleId` and status via `UpdateProjectStatus`.
### Changed — `office` TUI
- Flattened navigation: **Projects**, **Tasks**, **By project**, **Calendar**, **CRM**, **Mail**, **Users** (removed Browse; Calendar no longer splits Calendars/Events).
- Middle pane truncates overflowing cells with `…`; full text on the cursor or Space-selected row.
- Detail form tab order: Title → Description → Status → Save → Delete.
- **j/k** scrolls mail/file preview and read-only detail forms; mouse wheel scrolls detail when hovering the content area.
### Added — library
- `UpdateProjectStatus` — `PUT /api/2.0/project/{id}/status` for open/closed lifecycle.
## [0.5.1] — 2026-06-24
### Added — `office` TUI
+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{
{Subject: model.SubjectProjects},
{Subject: model.SubjectTasks},
{Subject: model.SubjectCalendars},
{Subject: model.SubjectEvents},
{Subject: model.SubjectCalendar},
{Subject: model.SubjectContacts},
{Subject: model.SubjectPersons},
{Subject: model.SubjectCompanies},
+47 -28
View File
@@ -6,12 +6,14 @@ import (
"os"
"path/filepath"
"strconv"
"time"
onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// MailListPageSize is how many messages each infinite-scroll fetch loads.
const MailListPageSize = 25
// Loader fetches list items for a menu subject using the OnlyOffice client.
type Loader struct {
Client *onlyoffice.Client
@@ -30,10 +32,12 @@ func (l *Loader) List(ctx context.Context, spec model.ListSpec) ([]model.Item, e
return l.listTasksForProject(ctx, spec.ProjectID)
}
return l.listTasks(ctx)
case model.SubjectCalendar:
return l.ListCalendar(ctx)
case model.SubjectCalendars:
return l.listCalendars(ctx)
return l.ListCalendar(ctx)
case model.SubjectEvents:
return l.listEvents(ctx)
return l.ListCalendar(ctx)
case model.SubjectContacts:
return l.listContacts(ctx, nil)
case model.SubjectPersons:
@@ -49,15 +53,15 @@ func (l *Loader) List(ctx context.Context, spec model.ListSpec) ([]model.Item, e
case model.SubjectCRMTasks:
return l.listCRMTasks(ctx)
case model.SubjectMailInbox:
return l.listMail(ctx, onlyoffice.MailFolderInbox)
return l.listMail(ctx, onlyoffice.MailFolderInbox, 0)
case model.SubjectMailSent:
return l.listMail(ctx, onlyoffice.MailFolderSent)
return l.listMail(ctx, onlyoffice.MailFolderSent, 0)
case model.SubjectMailDrafts:
return l.listMail(ctx, onlyoffice.MailFolderDrafts)
return l.listMail(ctx, onlyoffice.MailFolderDrafts, 0)
case model.SubjectMailTrash:
return l.listMail(ctx, onlyoffice.MailFolderTrash)
return l.listMail(ctx, onlyoffice.MailFolderTrash, 0)
case model.SubjectMailSpam:
return l.listMail(ctx, onlyoffice.MailFolderSpam)
return l.listMail(ctx, onlyoffice.MailFolderSpam, 0)
case model.SubjectUsers:
return l.listUsers(ctx)
case model.SubjectProjectFiles:
@@ -260,6 +264,9 @@ func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) {
if p.ParticipantCount != nil {
raw["participantCount"] = *p.ParticipantCount
}
if p.Status != nil {
raw["status"] = *p.Status
}
items[i] = model.Item{
ID: id, Title: title, Kind: model.KindProject, Raw: raw,
}
@@ -275,24 +282,6 @@ func (l *Loader) listTasks(ctx context.Context) ([]model.Item, error) {
return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil
}
func (l *Loader) listCalendars(ctx context.Context) ([]model.Item, error) {
rows, err := l.Client.ListCalendars(ctx, "", "")
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindCalendar, FieldMap{IDKey: "objectId", TitleKey: "title"}), nil
}
func (l *Loader) listEvents(ctx context.Context) ([]model.Item, error) {
start := time.Now().Format("2006-01-02")
end := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
rows, err := l.Client.ListEvents(ctx, start, end)
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindEvent, FieldMap{IDKey: "objectId", TitleKey: "title", SubtitleKey: "start"}), nil
}
func (l *Loader) listContacts(ctx context.Context, companyOnly *bool) ([]model.Item, error) {
rows, err := l.Client.ListAllContacts(ctx)
if err != nil {
@@ -335,14 +324,44 @@ func (l *Loader) listCRMTasks(ctx context.Context) ([]model.Item, error) {
return ItemsFromMaps(rows, model.KindCRMTask, TaskItemFields), nil
}
func (l *Loader) listMail(ctx context.Context, folder int) ([]model.Item, error) {
rows, err := l.Client.ListMailMessages(ctx, onlyoffice.MailMessagesFilter{Folder: folder, Count: 50})
func (l *Loader) listMail(ctx context.Context, folder int, startIndex int) ([]model.Item, error) {
rows, err := l.Client.ListMailMessages(ctx, onlyoffice.MailMessagesFilter{
Folder: folder,
Count: MailListPageSize,
StartIndex: startIndex,
})
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindMail, MailItemFields), nil
}
// ListMailMore loads the next page of messages for an active mail folder list.
func (l *Loader) ListMailMore(ctx context.Context, spec model.ListSpec, startIndex int) ([]model.Item, error) {
folder, ok := mailFolderForSubject(spec.Subject)
if !ok {
return nil, fmt.Errorf("fetch: %q is not a mail folder", spec.Subject)
}
return l.listMail(ctx, folder, startIndex)
}
func mailFolderForSubject(subject model.Subject) (int, bool) {
switch subject {
case model.SubjectMailInbox:
return onlyoffice.MailFolderInbox, true
case model.SubjectMailSent:
return onlyoffice.MailFolderSent, true
case model.SubjectMailDrafts:
return onlyoffice.MailFolderDrafts, true
case model.SubjectMailTrash:
return onlyoffice.MailFolderTrash, true
case model.SubjectMailSpam:
return onlyoffice.MailFolderSpam, true
default:
return 0, false
}
}
func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) {
users, err := l.Client.GetUsers()
if err != nil {
+1 -1
View File
@@ -14,7 +14,7 @@ type FieldMap struct {
}
// TaskItemFields is the default field map for project tasks.
var TaskItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"}
var TaskItemFields = FieldMap{IDKey: "id", TitleKey: "title"}
// ProjectItemFields is the default field map for projects.
var ProjectItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"}
+33 -12
View File
@@ -10,29 +10,50 @@ import (
)
// SaveItem persists editable form fields for supported entity kinds.
func (l *Loader) SaveItem(ctx context.Context, item model.Item, title, description string) error {
func (l *Loader) SaveItem(ctx context.Context, item model.Item, fields model.FormFields) error {
if l == nil || l.Client == nil {
return fmt.Errorf("fetch: client is nil")
}
switch item.Kind {
case model.KindTask:
return l.UpdateTask(ctx, item.ID, title, description)
return l.UpdateTask(ctx, item.ID, fields.Primary, fields.Secondary)
case model.KindProject:
id, err := strconv.Atoi(item.ID)
if err != nil {
return err
}
_, err = l.Client.UpdateProject(onlyoffice.ProjectUpdateRequest{
ID: id,
Title: title,
Description: description,
})
return err
return l.saveProject(ctx, item.ID, fields)
default:
return fmt.Errorf("save not supported for %s", item.Kind)
}
}
func (l *Loader) saveProject(ctx context.Context, projectID string, fields model.FormFields) error {
id, err := strconv.Atoi(projectID)
if err != nil {
return err
}
if fields.ResponsibleID == "" {
raw, derr := l.Detail(ctx, model.Item{ID: projectID, Kind: model.KindProject})
if derr == nil {
fields.ResponsibleID = model.ResponsibleIDFromRaw(raw)
}
}
req := onlyoffice.ProjectUpdateRequest{
ID: id,
Title: fields.Primary,
Description: fields.Secondary,
}
if fields.ResponsibleID != "" {
req.ResponsibleID = fields.ResponsibleID
}
if _, err := l.Client.UpdateProject(req); err != nil {
return err
}
if fields.HasStatus {
if _, err := l.Client.UpdateProjectStatus(id, string(fields.Status)); err != nil {
return err
}
}
return nil
}
// DetailForm loads form field values for the detail pane.
func (l *Loader) DetailForm(ctx context.Context, item model.Item) (model.FormFields, error) {
raw, err := l.Detail(ctx, item)
+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 {
return buildProjectColumns(items)
}
if subject == SubjectTasks {
return buildTaskColumns(items)
}
if subject == SubjectCalendar {
return buildCalendarColumns(items)
}
cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 10},
@@ -64,10 +70,43 @@ func BuildColumns(subject Subject, items []Item) []TableColumn {
return cols
}
func buildCalendarColumns(items []Item) []TableColumn {
cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 10},
{Key: "type", Title: "Type", Width: 10},
{Key: "title", Title: "Title", Width: 28},
}
for _, key := range []string{"start", "end"} {
if columnHasData(items, key) {
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
}
}
sizeColumns(cols, items)
return cols
}
func buildTaskColumns(items []Item) []TableColumn {
cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 8},
{Key: "title", Title: "Title", Width: 28},
{Key: "status", Title: "Status", Width: 10},
}
for _, key := range []string{"deadline", "responsible"} {
if columnHasData(items, key) {
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
}
}
sizeColumns(cols, items)
return cols
}
func buildProjectColumns(items []Item) []TableColumn {
cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 8},
{Key: "status", Title: "Status", Width: 8},
{Key: "title", Title: "Title", Width: 28},
{Key: "tasks", Title: "Tasks (open/closed)", Width: 18},
{Key: "documents", Title: "Documents", Width: 11},
@@ -89,6 +128,47 @@ func CellText(it Item, key string) string {
return it.ID
case "title":
return it.Title
case "type":
return CalendarTypeLabel(it)
case "start", "end":
if it.Raw == nil {
return ""
}
if formatted := FormatCalendarDateTime(it.Raw[key]); formatted != "" {
return formatted
}
return formatAny(it.Raw[key])
case "status":
switch it.Kind {
case KindProject:
return ProjectStatusLabel(it.Raw)
case KindTask, KindCRMTask:
if it.Raw == nil {
return ""
}
return TaskStatusLabel(it.Raw["status"])
default:
if it.Raw == nil {
return ""
}
return formatAny(it.Raw["status"])
}
case "deadline":
if it.Raw == nil {
return ""
}
if rel := FormatRelativeDeadline(it.Raw["deadline"]); rel != "" {
return rel
}
return formatAny(it.Raw["deadline"])
case "responsible":
if it.Kind == KindTask || it.Kind == KindCRMTask {
return TaskResponsibleLabel(it.Raw)
}
if it.Raw == nil {
return ""
}
return formatAny(it.Raw["responsible"])
case "subtitle":
return it.Subtitle
case "tasks":
@@ -230,7 +310,7 @@ func defaultWidth(key string) int {
case "id", "status", "stage":
return 10
case "start", "end", "deadline", "date":
return 18
return 20
case "from", "to", "email", "primaryEmail":
return 22
case "description", "displayName":
+13 -2
View File
@@ -11,7 +11,7 @@ func TestBuildProjectColumns(t *testing.T) {
},
}}
cols := BuildColumns(SubjectProjects, items)
want := []string{"_sel", "id", "title", "tasks", "documents", "users"}
want := []string{"_sel", "id", "status", "title", "tasks", "documents", "users"}
if len(cols) != len(want) {
t.Fatalf("got %d columns, want %d", len(cols), len(want))
}
@@ -21,12 +21,23 @@ func TestBuildProjectColumns(t *testing.T) {
}
}
for _, c := range cols {
if c.Key == "status" || c.Key == "subtitle" {
if c.Key == "subtitle" {
t.Fatalf("unexpected column %q", c.Key)
}
}
}
func TestCellTextProjectStatus(t *testing.T) {
open := Item{Kind: KindProject, Raw: map[string]any{"status": 0}}
if got := CellText(open, "status"); got != "Open" {
t.Fatalf("got %q", got)
}
closed := Item{Kind: KindProject, Raw: map[string]any{"status": 2}}
if got := CellText(closed, "status"); got != "Closed" {
t.Fatalf("got %q", got)
}
}
func TestFormatProjectTasksOpenClosed(t *testing.T) {
raw := map[string]any{"taskCount": 3, "taskCountTotal": 8}
if got := formatProjectTasks(raw); got != "3/5" {
+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
Secondary string
ReadOnly bool
HasStatus bool
Status ProjectLifecycle
ResponsibleID string
}
// KindHeading returns a short label for the detail pane header.
@@ -80,6 +83,21 @@ func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
Primary: strRaw(raw, "title"), Secondary: strRaw(raw, "description"),
ReadOnly: true,
}
case KindProject:
title := strRaw(raw, "title")
if title == "" {
title = strRaw(raw, "name")
}
return FormFields{
PrimaryLabel: "Title",
SecondaryLabel: "Description",
Primary: title,
Secondary: strRaw(raw, "description"),
ReadOnly: false,
HasStatus: true,
Status: ProjectStatusFromAny(raw["status"]),
ResponsibleID: ResponsibleIDFromRaw(raw),
}
default:
title := strRaw(raw, "title")
if title == "" {
@@ -109,7 +127,19 @@ func strRaw(m map[string]any, key string) string {
return fmt.Sprint(m[key])
}
// IsDocumentKind is true when the right pane should show file content, not a form.
// ResponsibleIDFromRaw extracts the project responsible user id from API detail.
func ResponsibleIDFromRaw(raw map[string]any) string {
if id := strRaw(raw, "responsibleId"); id != "" {
return id
}
resp, ok := raw["responsible"].(map[string]any)
if !ok {
return ""
}
return strRaw(resp, "id")
}
// IsDocumentKind is true when the right pane should show rendered preview content, not an editable form.
func IsDocumentKind(kind Kind) bool {
return kind == KindFile
return kind == KindFile || kind == KindMail
}
+3
View File
@@ -14,6 +14,9 @@ func TestIsDocumentKind(t *testing.T) {
if !IsDocumentKind(KindFile) {
t.Fatal("file should be document")
}
if !IsDocumentKind(KindMail) {
t.Fatal("mail should be document preview")
}
if IsDocumentKind(KindTask) {
t.Fatal("task should be form")
}
+11
View File
@@ -39,8 +39,19 @@ const (
SubjectProjectFiles Subject = "project_files"
SubjectTaskFiles Subject = "task_files"
SubjectUsers Subject = "users"
SubjectCalendar Subject = "calendar"
)
// SubjectIsMail reports whether a list subject loads mailbox messages.
func SubjectIsMail(s Subject) bool {
switch s {
case SubjectMailInbox, SubjectMailSent, SubjectMailDrafts, SubjectMailTrash, SubjectMailSpam:
return true
default:
return false
}
}
// FocusPane is which column has keyboard focus.
type FocusPane int
+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
import "strings"
// ListSpec tells the fetch layer what to load when a nav leaf is active.
type ListSpec struct {
Subject Subject
@@ -24,6 +26,7 @@ type NavTree struct {
expanded map[string]bool
cursor int
visible []string
filter string
}
// DefaultNavTree returns the OnlyOffice module tree (static skeleton).
@@ -42,15 +45,11 @@ func DefaultNavTree() *NavTree {
}
}
add("projects", "Projects", "", true, nil)
add("projects.browse", "Browse", "projects", true, nil)
add("projects.browse.all", "All projects", "projects.browse", false, &ListSpec{Subject: SubjectProjects})
add("projects.browse.tasks", "All tasks", "projects.browse", false, &ListSpec{Subject: SubjectTasks})
add("projects.dynamic", "By project", "projects", true, nil)
add("projects", "Projects", "", false, &ListSpec{Subject: SubjectProjects})
add("tasks", "Tasks", "", false, &ListSpec{Subject: SubjectTasks})
add("projects.dynamic", "By project", "", true, nil)
add("calendar", "Calendar", "", true, nil)
add("calendar.cals", "Calendars", "calendar", false, &ListSpec{Subject: SubjectCalendars})
add("calendar.events", "Events", "calendar", false, &ListSpec{Subject: SubjectEvents})
add("calendar", "Calendar", "", false, &ListSpec{Subject: SubjectCalendar})
add("crm", "CRM", "", true, nil)
add("crm.contacts", "Contacts", "crm", false, &ListSpec{Subject: SubjectContacts})
@@ -185,20 +184,66 @@ func (t *NavTree) Activate() (*ListSpec, bool) {
return nil, false
}
func (t *NavTree) SetFilter(query string) {
t.filter = strings.ToLower(strings.TrimSpace(query))
t.rebuildVisible()
}
func (t *NavTree) ClearFilter() {
t.filter = ""
t.rebuildVisible()
}
func (t *NavTree) FilterQuery() string { return t.filter }
func (t *NavTree) nodeLabelMatches(id string) bool {
if t.filter == "" {
return true
}
n, ok := t.nodes[id]
if !ok {
return false
}
return strings.Contains(strings.ToLower(n.Label), t.filter)
}
func (t *NavTree) hasMatchingDescendant(id string) bool {
for _, child := range t.children[id] {
if t.nodeLabelMatches(child) || t.hasMatchingDescendant(child) {
return true
}
}
return false
}
func (t *NavTree) visibleUnderFilter(id string) bool {
if t.filter == "" {
return true
}
return t.nodeLabelMatches(id) || t.hasMatchingDescendant(id)
}
func (t *NavTree) rebuildVisible() {
t.visible = t.visible[:0]
var walk func(id string, depth int)
walk = func(id string, depth int) {
var walk func(id string)
walk = func(id string) {
if !t.visibleUnderFilter(id) {
return
}
t.visible = append(t.visible, id)
if !t.expanded[id] {
expanded := t.expanded[id]
if t.filter != "" && t.hasMatchingDescendant(id) {
expanded = true
}
if !expanded {
return
}
for _, child := range t.children[id] {
walk(child, depth+1)
walk(child)
}
}
for _, root := range t.roots {
walk(root, 0)
walk(root)
}
if t.cursor >= len(t.visible) {
t.cursor = len(t.visible) - 1
+88 -16
View File
@@ -9,7 +9,7 @@ import (
func TestNavTreeHasExpectedRoots(t *testing.T) {
tree := model.DefaultNavTree()
roots := tree.RootLabels()
want := []string{"Projects", "Calendar", "CRM", "Mail", "Users"}
want := []string{"Projects", "Tasks", "By project", "Calendar", "CRM", "Mail", "Users"}
if len(roots) != len(want) {
t.Fatalf("roots=%v want %v", roots, want)
}
@@ -22,42 +22,79 @@ func TestNavTreeHasExpectedRoots(t *testing.T) {
func TestNavExpandCollapse(t *testing.T) {
tree := model.DefaultNavTree()
if !tree.IsExpandable(0) {
t.Fatal("Projects should be expandable")
var crm int = -1
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "CRM" {
crm = i
break
}
tree.ToggleExpand(0)
if !tree.IsExpanded(0) {
t.Fatal("Projects should expand")
}
tree.ToggleExpand(0)
if tree.IsExpanded(0) {
t.Fatal("Projects should collapse")
if crm < 0 {
t.Fatal("CRM node not found")
}
if !tree.IsExpandable(crm) {
t.Fatal("CRM should be expandable")
}
tree.ToggleExpand(crm)
if !tree.IsExpanded(crm) {
t.Fatal("CRM should expand")
}
tree.ToggleExpand(crm)
if tree.IsExpanded(crm) {
t.Fatal("CRM should collapse")
}
}
func TestNavLeafReturnsListSpec(t *testing.T) {
func TestNavCalendarLeafReturnsListSpec(t *testing.T) {
tree := model.DefaultNavTree()
tree.ToggleExpand(0) // Projects
tree.ToggleExpand(1) // Browse
// Find "All projects" leaf cursor
var found int = -1
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.List != nil && n.List.Subject == model.SubjectProjects {
if ok && n.Label == "Calendar" {
found = i
break
}
}
if found < 0 {
t.Fatal("all projects leaf not found")
t.Fatal("Calendar node not found")
}
tree.SetCursor(found)
spec, ok := tree.CurrentListSpec()
if !ok || spec.Subject != model.SubjectProjects {
if !ok || spec.Subject != model.SubjectCalendar {
t.Fatalf("spec=%v ok=%v", spec, ok)
}
}
func TestNavProjectsLeafReturnsListSpec(t *testing.T) {
tree := model.DefaultNavTree()
tree.SetCursor(0)
spec, ok := tree.CurrentListSpec()
if !ok || spec.Subject != model.SubjectProjects {
t.Fatalf("projects spec=%v ok=%v", spec, ok)
}
}
func TestNavTasksLeafReturnsListSpec(t *testing.T) {
tree := model.DefaultNavTree()
var found int = -1
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "Tasks" {
found = i
break
}
}
if found < 0 {
t.Fatal("Tasks node not found")
}
tree.SetCursor(found)
spec, ok := tree.CurrentListSpec()
if !ok || spec.Subject != model.SubjectTasks {
t.Fatalf("tasks spec=%v ok=%v", spec, ok)
}
}
func TestPrevFocusPane(t *testing.T) {
if got := model.PrevFocusPane(model.FocusList); got != model.FocusMenu {
t.Fatalf("got %v", got)
@@ -67,6 +104,41 @@ func TestPrevFocusPane(t *testing.T) {
}
}
func TestNavFilterByLabel(t *testing.T) {
tree := model.DefaultNavTree()
tree.SetFilter("inbox")
found := false
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "Inbox" {
found = true
break
}
}
if !found {
t.Fatal("Inbox should remain visible when filtering mail")
}
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "Calendar" {
t.Fatal("Calendar should be hidden when filtering inbox")
}
}
}
func TestNavClearFilterRestoresTree(t *testing.T) {
tree := model.DefaultNavTree()
before := tree.VisibleCount()
tree.SetFilter("zzz-no-match")
if tree.VisibleCount() >= before {
t.Fatalf("filter should shrink visible nodes")
}
tree.ClearFilter()
if tree.VisibleCount() != before {
t.Fatalf("clear filter should restore visible count")
}
}
func TestActionsForContact(t *testing.T) {
acts := model.ActionsFor(model.KindContact)
if len(acts) != 1 || acts[0].ID != model.ActionDelete {
+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"
}
// MailMarkdown formats a mail message for preview.
// MailMarkdown formats a mail message for terminal preview (HTML body is converted to markdown).
func MailMarkdown(m map[string]any) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "subject"))
fmt.Fprintf(&b, "**From:** %s\n\n", str(m, "from"))
if to := str(m, "to"); to != "" {
fmt.Fprintf(&b, "**From:** %s\n\n", mailAddressLine(m, "from"))
if to := mailAddressLine(m, "to"); to != "" {
fmt.Fprintf(&b, "**To:** %s\n\n", to)
}
body := str(m, "body")
if body == "" {
body = str(m, "htmlBody")
if cc := mailAddressLine(m, "cc"); cc != "" {
fmt.Fprintf(&b, "**Cc:** %s\n\n", cc)
}
body = stripHTML(body)
if body != "" {
fmt.Fprintf(&b, "## Body\n\n%s\n\n", body)
if date := str(m, "date"); date != "" {
fmt.Fprintf(&b, "**Date:** %s\n\n", date)
}
if body := mailBodyMarkdown(m); body != "" {
fmt.Fprintf(&b, "---\n\n%s\n", body)
}
return strings.TrimSpace(b.String()) + "\n"
}
func mailAddressLine(m map[string]any, key string) string {
if v := str(m, key); v != "" {
return v
}
name := str(m, key+"Name")
addr := str(m, key+"Address")
switch {
case name != "" && addr != "":
return fmt.Sprintf("%s <%s>", name, addr)
case addr != "":
return addr
default:
return name
}
}
func mailBodyMarkdown(m map[string]any) string {
htmlBody := strings.TrimSpace(str(m, "htmlBody"))
plainBody := strings.TrimSpace(str(m, "body"))
for _, candidate := range []string{htmlBody, plainBody} {
if candidate == "" {
continue
}
if looksLikeHTML(candidate) {
if md, err := HTMLToMarkdown(candidate); err == nil {
if md = strings.TrimSpace(md); md != "" {
return md
}
}
}
}
if plainBody != "" {
return plainBody
}
if htmlBody != "" {
return stripHTML(htmlBody)
}
return ""
}
func looksLikeHTML(s string) bool {
return strings.Contains(s, "<") && strings.Contains(s, ">") && htmlTagRe.MatchString(s)
}
// EventMarkdown formats a calendar event for preview.
func EventMarkdown(m map[string]any) string {
var b strings.Builder
+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) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectEvents})
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectCalendar})
if err != nil {
t.Fatal(err)
}
+280 -49
View File
@@ -18,6 +18,11 @@ type listLoadedMsg struct {
err error
}
type listMoreLoadedMsg struct {
items []model.Item
err error
}
type detailLoadedMsg struct {
item model.Item
document bool
@@ -28,7 +33,7 @@ type detailLoadedMsg struct {
type detailSavedMsg struct {
item model.Item
title, description string
fields model.FormFields
err error
}
@@ -51,6 +56,8 @@ type Model struct {
hasList bool
items []model.Item
selection *model.Selection
listHasMore bool
listLoadingMore bool
focus model.FocusPane
width int
height int
@@ -63,6 +70,11 @@ type Model struct {
showMenu bool
showList bool
showDetail bool
filterActive bool
filterSearch FilterSearch
customPaneLayout bool
paneSizes PaneWidths
resize paneResizeState
}
// NewModel constructs the TUI with an authenticated client.
@@ -74,20 +86,21 @@ func NewModel(client *onlyoffice.Client) Model {
nav: model.DefaultNavTree(),
selection: model.NewSelection(),
focus: model.FocusMenu,
status: "Tab: pane · row select loads detail · Ctrl+S save · q quit",
status: "Tab: pane · f: filter · row select loads detail · Ctrl+S save · q quit",
height: h,
width: w,
}
m.menuVP = viewport.New(m.paneInnerWidth(22), m.paneHeight())
m.listTable = newDataTable()
m.detail = newDetailPane()
m.filterSearch = newFilterSearch()
m.showMenu, m.showList, m.showDetail = true, true, true
m.menuVP.MouseWheelEnabled = true
return m
}
func (m Model) Init() tea.Cmd {
return tea.Batch(m.loadNavProjectsCmd(), m.detail.BlinkCmd())
return tea.Batch(m.loadNavProjectsCmd(), m.detail.BlinkCmd(), filterSearchBlinkCmd())
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -95,12 +108,29 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
if m.customPaneLayout {
m.paneSizes = FitPaneWidths(m.width, m.paneVis(), m.paneSizes)
}
m.layoutViewports()
m.syncAllContent()
return m, nil
case tea.KeyMsg:
key := msg.String()
if key == "esc" && m.filterActive {
m.exitFilter()
return m, nil
}
if m.filterActive && m.focus == model.FocusPreview && m.showDetail {
switch key {
case "tab", "shift+tab", "backtab":
// allow pane switching while filter stays active
default:
cmd := m.filterSearch.Update(msg)
m.applyFilter()
return m, cmd
}
}
if key == "?" {
m.status = helpText()
return m, nil
@@ -108,7 +138,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.scrollFocusedPane(key) {
return m, nil
}
if m.focus == model.FocusPreview {
if m.filterActive && m.focus == model.FocusPreview {
// filter input owns keys except pane switching handled above
} else if m.focus == model.FocusPreview {
if cmd, handled := m.handleDetailKey(key, msg); handled {
return m, cmd
}
@@ -121,11 +153,19 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case ActionQuit:
return m, tea.Quit
case ActionNextPane:
prev := m.focus
m.focus = NextVisibleFocus(m.focus, m.paneVis())
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive {
m.detail.FocusFirstStop()
}
m.syncPaneFocus()
return m, nil
case ActionPrevPane:
prev := m.focus
m.focus = PrevVisibleFocus(m.focus, m.paneVis())
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive {
m.detail.FocusFirstStop()
}
m.syncPaneFocus()
return m, nil
case ActionMoveUp:
@@ -144,7 +184,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.onListRowChanged()
case ActionMoveLeft:
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
m.detail.MoveAction(-1)
m.detail.MoveTabStop(-1)
return m, nil
}
if m.focus == model.FocusList && m.hasList {
@@ -153,7 +193,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case ActionMoveRight:
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
m.detail.MoveAction(1)
m.detail.MoveTabStop(1)
return m, nil
}
if m.focus == model.FocusList && m.hasList {
@@ -167,10 +207,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case ActionToggleSelect:
if m.hasList {
idx := m.listTable.ItemIndex()
if idx >= 0 {
m.selection.Toggle(&m.items, idx)
m.listTable.UpdateItems(m.items)
if it, ok := m.listTableItem(); ok {
for i := range m.items {
if m.items[i].ID == it.ID {
m.selection.Toggle(&m.items, i)
break
}
}
m.syncListTable()
}
}
return m, nil
@@ -178,7 +222,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.hasList && m.showDetail {
m.focus = model.FocusPreview
m.syncPaneFocus()
m.detail.FocusContent()
m.detail.FocusFirstStop()
return m, m.onListRowChanged()
}
return m, nil
@@ -191,6 +235,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case ActionToggleDetailPane:
m.togglePane(3)
return m, nil
case ActionFilter:
m.enterFilter()
return m, nil
case ActionRefresh:
if m.hasList {
m.loading = true
@@ -222,10 +269,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case listLoadedMsg:
m.loading = false
m.listLoadingMore = false
if msg.err != nil {
m.err = msg.err.Error()
m.items = nil
m.hasList = false
m.listHasMore = false
m.listTable.Clear()
m.detail.Clear()
} else {
@@ -233,9 +282,32 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.items = msg.items
m.listSpec = msg.spec
m.hasList = true
m.listHasMore = model.SubjectIsMail(msg.spec.Subject) && len(msg.items) >= fetch.MailListPageSize
m.listTable.SetData(m.listSpec, m.items)
if m.filterActive {
m.applyFilter()
}
return m, m.onListRowChanged()
}
return m, tea.Batch(m.onListRowChanged(), m.maybeLoadMoreList())
case listMoreLoadedMsg:
m.listLoadingMore = false
m.listTable.SetLoadingMore(false)
if msg.err != nil {
m.err = msg.err.Error()
return m, nil
}
if len(msg.items) == 0 {
m.listHasMore = false
return m, nil
}
added := m.listTable.AppendItems(msg.items)
if added > 0 {
m.items = m.listTable.Items()
}
m.listHasMore = len(msg.items) >= fetch.MailListPageSize
m.err = ""
return m, nil
case detailLoadedMsg:
m.loading = false
@@ -263,11 +335,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.err != nil {
m.err = msg.err.Error()
} else {
m.updateItemAfterSave(msg.item, msg.title, msg.description)
fields, _ := m.loader.DetailForm(context.Background(), msg.item)
fields.Primary = msg.title
fields.Secondary = msg.description
m.detail.LoadForm(msg.item, fields)
m.updateItemAfterSave(msg.item, msg.fields)
m.detail.LoadForm(msg.item, msg.fields)
m.detail.SetFocused(m.focus == model.FocusPreview)
m.status = "Saved"
m.err = ""
@@ -296,6 +365,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tea.MouseMsg:
if m.handlePaneResizeMouse(msg) {
return m, nil
}
if cmd, handled := m.routeDetailDocumentMouse(msg); handled {
return m, cmd
}
var cmd tea.Cmd
switch m.focus {
case model.FocusMenu:
@@ -303,8 +378,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case model.FocusList:
cmd = m.listTable.Update(msg)
case model.FocusPreview:
if !m.filterActive {
cmd = m.detail.Update(msg)
}
}
return m, cmd
}
@@ -315,15 +392,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case model.FocusList:
cmd = m.listTable.Update(msg)
case model.FocusPreview:
if !m.filterActive {
cmd = m.detail.Update(msg)
}
}
return m, cmd
}
func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
switch key {
case "tab", "shift+tab", "backtab":
m.detail.ToggleZone()
reverse := key != "tab"
if reverse {
if m.detail.TabBackward() {
m.focus = PrevVisibleFocus(m.focus, m.paneVis())
}
} else if m.detail.TabForward() {
m.focus = NextVisibleFocus(m.focus, m.paneVis())
}
m.syncPaneFocus()
return nil, true
case "ctrl+s":
m.loading = true
@@ -347,16 +434,37 @@ func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
return nil, false
}
func (m *Model) routeDetailDocumentMouse(msg tea.MouseMsg) (tea.Cmd, bool) {
if m.filterActive || !m.showDetail {
return nil, false
}
if !m.detail.IsDocumentContent() && !m.detail.isReadOnlyFormContent() {
return nil, false
}
if msg.Y > m.paneHeight()+1 {
return nil, false
}
x0, x1 := DetailPaneXRange(m.paneLayout())
if x0 >= x1 || msg.X < x0 || msg.X >= x1 {
return nil, false
}
cmd := m.detail.ScrollDocumentMouse(msg)
if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown {
return cmd, true
}
return cmd, false
}
func (m Model) handleDetailMove(delta int) (Model, tea.Cmd) {
if m.detail.Zone() == detailZoneActions {
m.detail.MoveAction(delta)
if m.detail.Zone() == detailZoneContent && (m.detail.IsDocumentContent() || m.detail.isReadOnlyFormContent()) {
key := "down"
if delta < 0 {
key = "up"
}
m.detail.ScrollDocument(key)
return m, nil
}
if delta < 0 {
m.detail.form.FocusPrev()
} else {
m.detail.form.FocusNext()
}
m.detail.MoveTabStop(delta)
return m, nil
}
@@ -369,7 +477,14 @@ func (m Model) View() string {
var parts []string
if pw.Visibility.Menu {
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(pw.Menu).Height(h)
parts = append(parts, menuStyle.Render(m.menuVP.View()))
menuBody := ApplyVerticalScrollbar(
m.menuVP.View(),
m.menuVP.Width,
m.menuVP.Height,
m.menuVP.TotalLineCount(),
m.menuVP.YOffset,
)
parts = append(parts, menuStyle.Render(menuBody))
}
if pw.Visibility.List {
listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h)
@@ -377,7 +492,13 @@ func (m Model) View() string {
}
if pw.Visibility.Detail {
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(pw.Detail).Height(h)
parts = append(parts, prevStyle.Render(m.detail.View()))
var detailBody string
if m.filterActive {
detailBody = m.filterSearch.View()
} else {
detailBody = m.detail.View()
}
parts = append(parts, prevStyle.Render(detailBody))
}
status := m.status
@@ -397,11 +518,21 @@ func (m Model) paneVis() PaneVisibility {
}
func (m Model) paneLayout() PaneWidths {
return LayoutWidths(m.width, m.paneVis())
vis := m.paneVis()
if m.customPaneLayout {
return FitPaneWidths(m.width, vis, m.paneSizes)
}
return LayoutWidths(m.width, vis)
}
func (m *Model) syncPaneFocus() {
m.listTable.SetFocused(m.focus == model.FocusList && m.showList)
if m.filterActive {
m.filterSearch.SetFocused(m.focus == model.FocusPreview && m.showDetail)
m.detail.SetFocused(false)
return
}
m.filterSearch.SetFocused(false)
m.detail.SetFocused(m.focus == model.FocusPreview && m.showDetail)
}
@@ -427,6 +558,9 @@ func (m *Model) togglePane(which int) {
if !paneVisible(m.focus, m.paneVis()) {
m.focus = firstVisibleFocus(m.paneVis())
}
if m.customPaneLayout {
m.paneSizes = FitPaneWidths(m.width, m.paneVis(), m.paneSizes)
}
m.layoutViewports()
m.syncAllContent()
m.syncPaneFocus()
@@ -436,6 +570,8 @@ func (m Model) withList(spec model.ListSpec) (Model, tea.Cmd) {
m.listSpec = spec
m.selection.Clear()
m.hasList = false
m.listHasMore = false
m.listLoadingMore = false
m.focus = model.FocusList
m.listTable.SetFocused(true)
m.detail.Clear()
@@ -455,6 +591,7 @@ func (m *Model) layoutViewports() {
}
if pw.Visibility.Detail {
m.detail.SetSize(m.paneInnerWidth(pw.Detail), h)
m.filterSearch.SetSize(m.paneInnerWidth(pw.Detail), h)
}
}
@@ -468,12 +605,62 @@ func (m *Model) syncListTable() {
m.listTable.Clear()
return
}
m.listTable.UpdateItems(m.items)
items := m.items
if m.filterActive {
items = model.FilterItems(m.items, m.filterSearch.Query())
}
m.listTable.UpdateItems(items)
}
func (m *Model) enterFilter() {
if !m.showDetail {
m.showDetail = true
m.layoutViewports()
}
m.filterActive = true
m.focus = model.FocusPreview
m.syncPaneFocus()
m.applyFilter()
}
func (m *Model) exitFilter() {
m.filterActive = false
m.filterSearch.Clear()
m.nav.ClearFilter()
m.syncMenuContent()
m.syncListTable()
m.syncPaneFocus()
}
func (m *Model) applyFilter() {
q := m.filterSearch.Query()
m.nav.SetFilter(q)
m.syncMenuContent()
m.syncListTable()
}
func (m Model) listTableItem() (model.Item, bool) {
idx := m.listTable.ItemIndex()
if idx < 0 {
return model.Item{}, false
}
items := m.items
if m.filterActive {
items = model.FilterItems(m.items, m.filterSearch.Query())
}
if idx >= len(items) {
return model.Item{}, false
}
return items[idx], true
}
func (m *Model) syncMenuContent() {
var b strings.Builder
b.WriteString("Navigation\n\n")
title := "Navigation"
if m.filterActive && m.filterSearch.Query() != "" {
title += " (filtered)"
}
b.WriteString(title + "\n\n")
menuW := m.menuVP.Width
if menuW < 10 {
menuW = 20
@@ -603,21 +790,52 @@ func (m *Model) loadNavProjectsCmd() tea.Cmd {
}
func (m *Model) onListRowChanged() tea.Cmd {
if m.filterActive {
return nil
}
if !m.hasList {
m.detail.Clear()
return nil
return m.maybeLoadMoreList()
}
idx := m.listTable.ItemIndex()
if idx < 0 || idx >= len(m.items) {
it, ok := m.listTableItem()
if !ok {
m.detail.Clear()
return nil
}
it := m.items[idx]
if m.detail.LoadedID() == it.ID {
return nil
return m.maybeLoadMoreList()
}
var cmds []tea.Cmd
cmds = append(cmds, m.maybeLoadMoreList())
if m.detail.LoadedID() != it.ID {
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 {
@@ -634,31 +852,44 @@ func (m *Model) loadDetailCmd(item model.Item) tea.Cmd {
func (m *Model) saveDetailCmd() tea.Cmd {
item := m.detail.Item()
title := m.detail.form.Primary()
desc := m.detail.form.Secondary()
fields := m.detail.form.FormFields()
return func() tea.Msg {
ctx := context.Background()
err := m.loader.SaveItem(ctx, item, title, desc)
return detailSavedMsg{item: item, title: title, description: desc, err: err}
err := m.loader.SaveItem(ctx, item, fields)
return detailSavedMsg{item: item, fields: fields, err: err}
}
}
func (m *Model) updateItemAfterSave(item model.Item, title, description string) {
func (m *Model) updateItemAfterSave(item model.Item, fields model.FormFields) {
for i := range m.items {
if m.items[i].ID != item.ID {
continue
}
m.items[i].Title = title
m.items[i].Title = fields.Primary
if m.items[i].Raw == nil {
m.items[i].Raw = map[string]any{}
}
m.items[i].Raw["title"] = title
m.items[i].Raw["description"] = description
m.listTable.UpdateItems(m.items)
m.items[i].Raw["title"] = fields.Primary
m.items[i].Raw["description"] = fields.Secondary
if fields.HasStatus {
m.items[i].Raw["status"] = statusInt(fields.Status)
}
m.syncListTable()
return
}
}
func statusInt(s model.ProjectLifecycle) int {
switch s {
case model.ProjectLifecycleClosed:
return 2
case model.ProjectLifecyclePaused:
return 1
default:
return 0
}
}
func paneStyle(focused bool) lipgloss.Style {
s := lipgloss.NewStyle().Padding(0, 1)
if focused {
@@ -668,5 +899,5 @@ func paneStyle(focused bool) lipgloss.Style {
}
func helpText() string {
return "Alt+1/2/3: toggle panes · Tab: focus · v: detail · Ctrl+S: save · q: quit"
return "Alt+1/2/3: toggle panes · drag borders to resize · f: filter · Tab: fields/actions/pane · j/k: scroll mail · v: detail · Ctrl+S: save · q: quit"
}
+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) {
menu, list, preview := ui.LayoutWidthsLegacy(120)
if menu+list+preview != 120 {
+244 -21
View File
@@ -34,12 +34,14 @@ type DetailPane struct {
loadedID string
actions []model.ItemAction
actionIdx int
tabStop int
zone detailZone
focused bool
width int
height int
form EntityForm
docVP viewport.Model
formVP viewport.Model
docText string
styles detailStyles
}
@@ -56,9 +58,11 @@ func newDetailPane() DetailPane {
d := DetailPane{
form: newEntityForm(),
docVP: viewport.New(40, 10),
formVP: viewport.New(40, 10),
styles: newDetailStyles(),
}
d.docVP.MouseWheelEnabled = true
d.formVP.MouseWheelEnabled = true
return d
}
@@ -79,14 +83,112 @@ func (d *DetailPane) Clear() {
d.loadedID = ""
d.actions = nil
d.actionIdx = 0
d.tabStop = 0
d.docText = ""
d.form.Clear()
d.docVP.SetContent("")
d.formVP.SetContent("")
}
func (d *DetailPane) SetFocused(on bool) {
d.focused = on
d.form.SetFocused(on && d.zone == detailZoneContent && d.mode == detailForm)
d.applyTabStop(d.tabStop)
}
func (d *DetailPane) FocusFirstStop() {
d.tabStop = 0
d.applyTabStop(0)
}
func (d *DetailPane) maxTabStop() int {
switch d.mode {
case detailForm:
if d.form.readOnly {
if len(d.actions) == 0 {
return 0
}
return len(d.actions) - 1
}
fields := d.form.FieldCount()
if len(d.actions) == 0 {
if fields == 0 {
return 0
}
return fields - 1
}
return fields + len(d.actions) - 1
case detailDocument:
if len(d.actions) == 0 {
return 0
}
return len(d.actions)
default:
return 0
}
}
func (d *DetailPane) applyTabStop(stop int) {
if stop < 0 {
stop = 0
}
max := d.maxTabStop()
if stop > max {
stop = max
}
d.tabStop = stop
switch d.mode {
case detailForm:
if d.form.readOnly {
d.zone = detailZoneActions
d.actionIdx = stop
d.form.SetFocused(false)
return
}
fields := d.form.FieldCount()
if stop < fields {
d.zone = detailZoneContent
d.form.SetFieldIndex(stop)
d.form.SetFocused(d.focused)
return
}
d.zone = detailZoneActions
d.actionIdx = stop - fields
d.form.SetFocused(false)
case detailDocument:
if stop == 0 {
d.zone = detailZoneContent
d.form.SetFocused(false)
return
}
d.zone = detailZoneActions
d.actionIdx = stop - 1
d.form.SetFocused(false)
default:
d.zone = detailZoneContent
d.form.SetFocused(false)
}
}
// TabForward moves to the next field/button. It returns true when focus should leave the pane.
func (d *DetailPane) TabForward() bool {
max := d.maxTabStop()
if d.tabStop < max {
d.applyTabStop(d.tabStop + 1)
return false
}
d.FocusFirstStop()
return true
}
// TabBackward moves to the previous field/button. It returns true when focus should leave the pane.
func (d *DetailPane) TabBackward() bool {
if d.tabStop > 0 {
d.applyTabStop(d.tabStop - 1)
return false
}
d.applyTabStop(d.maxTabStop())
return true
}
func (d *DetailPane) SetSize(w, h int) {
@@ -102,6 +204,8 @@ func (d *DetailPane) SetSize(w, h int) {
d.form.SetSize(w, contentH)
d.docVP.Width = w
d.docVP.Height = contentH
d.formVP.Width = w
d.formVP.Height = contentH
}
func (d *DetailPane) splitHeights() (contentH, actionH int) {
@@ -125,7 +229,10 @@ func (d *DetailPane) LoadForm(item model.Item, fields model.FormFields) {
d.loadedID = item.ID
d.actions = model.ActionsFor(item.Kind)
d.actionIdx = 0
d.tabStop = 0
d.form.Load(item.Kind, item.ID, fields)
d.refreshFormViewport()
d.applyTabStop(0)
d.layoutContent()
}
@@ -135,6 +242,7 @@ func (d *DetailPane) LoadDocument(item model.Item, markdown string, renderWidth
d.loadedID = item.ID
d.actions = model.ActionsFor(item.Kind)
d.actionIdx = 0
d.tabStop = 0
d.form.Clear()
text, err := preview.RenderMarkdown(markdown, renderWidth)
if err != nil {
@@ -142,6 +250,8 @@ func (d *DetailPane) LoadDocument(item model.Item, markdown string, renderWidth
}
d.docText = text
d.docVP.SetContent(text)
d.docVP.GotoTop()
d.applyTabStop(0)
d.layoutContent()
}
@@ -150,6 +260,17 @@ func (d *DetailPane) layoutContent() {
d.form.SetSize(d.width, contentH)
d.docVP.Width = d.width
d.docVP.Height = contentH
d.formVP.Width = d.width
d.formVP.Height = contentH
d.refreshFormViewport()
}
func (d *DetailPane) refreshFormViewport() {
if d.mode != detailForm || !d.form.readOnly {
d.formVP.SetContent("")
return
}
d.formVP.SetContent(d.form.View())
}
func (d *DetailPane) LoadedID() string { return d.loadedID }
@@ -158,22 +279,45 @@ func (d *DetailPane) Item() model.Item { return d.item }
func (d *DetailPane) Zone() detailZone { return d.zone }
func (d *DetailPane) FocusActions() {
d.zone = detailZoneActions
d.form.SetFocused(false)
}
func (d *DetailPane) FocusContent() {
d.zone = detailZoneContent
d.form.SetFocused(d.focused && d.mode == detailForm)
d.applyTabStop(d.tabStop)
if d.zone == detailZoneContent && d.mode == detailForm {
d.form.SetFocused(d.focused)
}
}
func (d *DetailPane) ToggleZone() {
if d.zone == detailZoneContent {
d.FocusActions()
} else {
d.FocusContent()
func (d *DetailPane) FocusActions() {
max := d.maxTabStop()
switch d.mode {
case detailForm:
if d.form.readOnly {
d.applyTabStop(max)
return
}
if len(d.actions) > 0 {
d.applyTabStop(d.form.FieldCount())
}
case detailDocument:
if len(d.actions) > 0 {
d.applyTabStop(1)
}
}
}
// MoveTabStop moves between title, description, and action buttons without leaving the pane.
func (d *DetailPane) MoveTabStop(delta int) {
if delta == 0 {
return
}
next := d.tabStop + delta
if next < 0 {
next = 0
}
max := d.maxTabStop()
if next > max {
next = max
}
d.applyTabStop(next)
}
func (d *DetailPane) MoveAction(delta int) {
@@ -190,6 +334,62 @@ func (d *DetailPane) SelectedAction() (model.ItemAction, bool) {
return d.actions[d.actionIdx], true
}
func (d DetailPane) IsDocumentContent() bool {
return d.mode == detailDocument
}
func (d DetailPane) isReadOnlyFormContent() bool {
return d.mode == detailForm && d.form.readOnly
}
func (d DetailPane) documentContentHeight() int {
contentH, _ := d.splitHeights()
return contentH
}
// ScrollDocument scrolls the read-only document viewport (mail, file preview).
func (d *DetailPane) ScrollDocument(key string) bool {
if d.IsDocumentContent() {
return scrollViewport(&d.docVP, key)
}
if d.isReadOnlyFormContent() && d.zone == detailZoneContent {
return scrollViewport(&d.formVP, key)
}
return false
}
// ScrollDocumentMouse applies wheel events to scrollable preview content.
func (d *DetailPane) ScrollDocumentMouse(msg tea.MouseMsg) tea.Cmd {
var vp *viewport.Model
switch {
case d.IsDocumentContent():
vp = &d.docVP
case d.isReadOnlyFormContent():
vp = &d.formVP
default:
return nil
}
if msg.Y >= d.documentContentHeight() {
return nil
}
if !vp.MouseWheelEnabled || msg.Action != tea.MouseActionPress {
return nil
}
switch msg.Button {
case tea.MouseButtonWheelUp, tea.MouseButtonWheelDown:
default:
return nil
}
var cmd tea.Cmd
*vp, cmd = vp.Update(msg)
return cmd
}
// DocumentYOffset exposes scroll position for tests.
func (d DetailPane) DocumentYOffset() int {
return d.docVP.YOffset
}
func (d *DetailPane) Update(msg tea.Msg) tea.Cmd {
if !d.focused {
return nil
@@ -197,14 +397,21 @@ func (d *DetailPane) Update(msg tea.Msg) tea.Cmd {
if d.zone == detailZoneContent {
switch d.mode {
case detailForm:
if d.form.readOnly {
if key, ok := msg.(tea.KeyMsg); ok {
if scrollViewport(&d.formVP, key.String()) {
return nil
}
}
var cmd tea.Cmd
d.formVP, cmd = d.formVP.Update(msg)
return cmd
}
return d.form.Update(msg)
case detailDocument:
if _, ok := msg.(tea.KeyMsg); ok {
switch msg.(tea.KeyMsg).String() {
case "pgdown", "pgdn", "f", "ctrl+d":
d.docVP.ViewDown()
case "pgup", "b", "ctrl+u":
d.docVP.ViewUp()
if key, ok := msg.(tea.KeyMsg); ok {
if scrollViewport(&d.docVP, key.String()) {
return nil
}
}
var cmd tea.Cmd
@@ -223,9 +430,25 @@ func (d DetailPane) View() string {
var top string
switch d.mode {
case detailForm:
if d.form.readOnly {
top = ApplyVerticalScrollbar(
d.formVP.View(),
d.formVP.Width,
d.formVP.Height,
d.formVP.TotalLineCount(),
d.formVP.YOffset,
)
} else {
top = lipgloss.NewStyle().Width(d.width).Height(contentH).Render(d.form.View())
}
case detailDocument:
top = d.docVP.View()
top = ApplyVerticalScrollbar(
d.docVP.View(),
d.docVP.Width,
d.docVP.Height,
d.docVP.TotalLineCount(),
d.docVP.YOffset,
)
default:
top = ""
}
@@ -258,7 +481,7 @@ func (d DetailPane) renderActions() string {
}
line := strings.Join(parts, "")
if d.focused && d.zone == detailZoneActions {
line += d.styles.empty.Render(" ←/→ select · Enter run")
line += d.styles.empty.Render(" Tab · Enter run")
}
return line
}
+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 (
entityFieldPrimary entityField = iota
entityFieldSecondary
entityFieldStatus
)
// EntityForm is the top section of the detail pane for editable entities.
@@ -25,6 +26,9 @@ type EntityForm struct {
primaryLabel string
secondaryLabel string
readOnly bool
hasStatus bool
status model.ProjectLifecycle
responsibleID string
primary textinput.Model
secondary textarea.Model
field entityField
@@ -65,6 +69,27 @@ func newEntityForm() EntityForm {
}
}
func (f *EntityForm) FieldCount() int {
n := 2
if f.hasStatus {
n = 3
}
return n
}
func (f *EntityForm) FormFields() model.FormFields {
return model.FormFields{
PrimaryLabel: f.primaryLabel,
SecondaryLabel: f.secondaryLabel,
Primary: f.primary.Value(),
Secondary: f.secondary.Value(),
ReadOnly: f.readOnly,
HasStatus: f.hasStatus,
Status: f.status,
ResponsibleID: f.responsibleID,
}
}
func (f *EntityForm) Active() bool { return f.active }
func (f *EntityForm) ItemID() string { return f.itemID }
@@ -82,10 +107,16 @@ func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormField
f.primaryLabel = fields.PrimaryLabel
f.secondaryLabel = fields.SecondaryLabel
f.readOnly = fields.ReadOnly
f.hasStatus = fields.HasStatus
f.status = fields.Status
f.responsibleID = fields.ResponsibleID
f.primary.SetValue(fields.Primary)
f.secondary.SetValue(fields.Secondary)
f.dirty = false
f.field = entityFieldPrimary
if f.hasStatus && f.readOnly {
f.field = entityFieldStatus
}
f.applyFocus()
f.layoutFields()
}
@@ -93,6 +124,9 @@ func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormField
func (f *EntityForm) Clear() {
f.active = false
f.itemID = ""
f.hasStatus = false
f.status = model.ProjectLifecycleOpen
f.responsibleID = ""
f.primary.SetValue("")
f.secondary.SetValue("")
f.dirty = false
@@ -140,19 +174,52 @@ func (f *EntityForm) layoutFields() {
}
func (f *EntityForm) FocusNext() {
if f.field < entityFieldSecondary {
last := entityFieldSecondary
if f.hasStatus {
last = entityFieldStatus
}
if f.field < last {
f.field++
}
f.applyFocus()
}
func (f *EntityForm) FocusPrev() {
if f.field > entityFieldPrimary {
first := entityFieldPrimary
if f.field > first {
f.field--
}
f.applyFocus()
}
func (f *EntityForm) SetFieldIndex(i int) {
switch i {
case 0:
f.field = entityFieldPrimary
case 1:
f.field = entityFieldSecondary
case 2:
if f.hasStatus {
f.field = entityFieldStatus
}
default:
return
}
f.applyFocus()
}
func (f *EntityForm) cycleStatus(delta int) {
if !f.hasStatus || f.readOnly {
return
}
if delta > 0 {
f.status = f.status.Next()
} else {
f.status = f.status.Prev()
}
f.dirty = true
}
func (f *EntityForm) applyFocus() {
if f.readOnly {
f.primary.Blur()
@@ -163,9 +230,12 @@ func (f *EntityForm) applyFocus() {
case entityFieldPrimary:
f.primary.Focus()
f.secondary.Blur()
default:
case entityFieldSecondary:
f.primary.Blur()
f.secondary.Focus()
default:
f.primary.Blur()
f.secondary.Blur()
}
}
@@ -173,12 +243,27 @@ func (f *EntityForm) Update(msg tea.Msg) tea.Cmd {
if !f.active || !f.focused || f.readOnly {
return nil
}
if key, ok := msg.(tea.KeyMsg); ok && f.field == entityFieldStatus {
switch key.String() {
case "left", "h":
f.cycleStatus(-1)
return nil
case "right", "l":
f.cycleStatus(1)
return nil
case " ":
f.cycleStatus(1)
return nil
}
}
var cmd tea.Cmd
switch f.field {
case entityFieldPrimary:
f.primary, cmd = f.primary.Update(msg)
default:
case entityFieldSecondary:
f.secondary, cmd = f.secondary.Update(msg)
default:
return nil
}
if _, ok := msg.(tea.KeyMsg); ok {
f.dirty = true
@@ -199,6 +284,28 @@ func (f EntityForm) View() string {
if f.focused && f.field == entityFieldSecondary && !f.readOnly {
sLabel = f.styles.labelAct.Render(f.secondaryLabel)
}
statusBlock := ""
if f.hasStatus {
stLabel := f.styles.label.Render("Status")
if f.focused && f.field == entityFieldStatus && !f.readOnly {
stLabel = f.styles.labelAct.Render("Status")
}
valStyle := f.styles.label
if f.status == model.ProjectLifecycleClosed {
valStyle = valStyle.Foreground(lipgloss.Color("245"))
} else {
valStyle = valStyle.Foreground(lipgloss.Color("42"))
}
hint := ""
if f.focused && f.field == entityFieldStatus && !f.readOnly {
hint = f.styles.label.Render(" ←/→ toggle")
}
statusBlock = strings.Join([]string{
"",
stLabel,
valStyle.Render(f.status.Label()) + hint,
}, "\n")
}
ro := ""
if f.readOnly {
ro = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" (read-only)") + "\n"
@@ -210,6 +317,7 @@ func (f EntityForm) View() string {
"",
sLabel,
f.secondary.View(),
statusBlock,
}, "\n")
}
+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
ActionToggleListPane
ActionToggleDetailPane
ActionFilter
ActionNextPane
ActionPrevPane
ActionRefresh
@@ -38,6 +39,8 @@ func KeyAction(key string, pane model.FocusPane) Action {
}
case "r":
return ActionRefresh
case "f":
return ActionFilter
case "up", "k":
return ActionMoveUp
case "down", "j":
+37 -21
View File
@@ -17,44 +17,60 @@ func defaultPaneVisibility() PaneVisibility {
return PaneVisibility{Menu: true, List: true, Detail: true}
}
// LayoutWidths splits total terminal width evenly across visible panes.
const (
defaultMenuShare = 10
defaultListShare = 60
defaultDetailShare = 30
)
// LayoutWidths splits total terminal width across visible panes (default 10% / 60% / 30%).
func LayoutWidths(total int, vis PaneVisibility) PaneWidths {
if total < 1 {
total = 80
}
v := vis
n := countVisible(v)
if n == 0 {
if countVisible(v) == 0 {
v = defaultPaneVisibility()
n = 3
}
base := total / n
rem := total % n
out := PaneWidths{Visibility: v}
shareSum := 0
if v.Menu {
out.Menu = base
if rem > 0 {
out.Menu++
rem--
}
shareSum += defaultMenuShare
}
if v.List {
out.List = base
if rem > 0 {
out.List++
rem--
}
shareSum += defaultListShare
}
if v.Detail {
out.Detail = base
if rem > 0 {
out.Detail++
rem--
shareSum += defaultDetailShare
}
assign := func(visible bool, share int) int {
if !visible || shareSum == 0 {
return 0
}
return total * share / shareSum
}
out := PaneWidths{Visibility: v}
out.Menu = assign(v.Menu, defaultMenuShare)
out.List = assign(v.List, defaultListShare)
out.Detail = assign(v.Detail, defaultDetailShare)
fixPaneWidthSum(&out, total)
return out
}
// DetailPaneXRange returns the [start, end) column span of the detail pane.
func DetailPaneXRange(pw PaneWidths) (start, end int) {
if !pw.Visibility.Detail {
return 0, 0
}
if pw.Visibility.Menu {
start += pw.Menu
}
if pw.Visibility.List {
start += pw.List
}
end = start + pw.Detail
return start, end
}
func countVisible(v PaneVisibility) int {
n := 0
if v.Menu {
+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 {
t.Fatalf("sum=%d want 120", sum)
}
if pw.Menu != 12 || pw.List != 72 || pw.Detail != 36 {
t.Fatalf("menu=%d list=%d detail=%d want 12/72/36 (10/60/30)", pw.Menu, pw.List, pw.Detail)
}
}
func TestLayoutWidthsSinglePane(t *testing.T) {
@@ -30,6 +33,20 @@ func TestLayoutWidthsTwoPanes(t *testing.T) {
if pw.Menu+pw.Detail != 80 {
t.Fatalf("sum=%d want 80", pw.Menu+pw.Detail)
}
if pw.Menu != 20 || pw.Detail != 60 {
t.Fatalf("menu=%d detail=%d want 20/60 (10/30 of pair)", pw.Menu, pw.Detail)
}
}
func TestDetailPaneXRangeSkipsHidden(t *testing.T) {
pw := PaneWidths{
Menu: 20, List: 60, Detail: 0,
Visibility: PaneVisibility{Menu: true, List: true, Detail: false},
}
start, end := DetailPaneXRange(pw)
if start != 0 || end != 0 {
t.Fatalf("hidden detail should have empty range, got %d..%d", start, end)
}
}
func TestNextVisibleFocusSkipsHidden(t *testing.T) {
+10 -4
View File
@@ -11,7 +11,7 @@ func (m *Model) scrollFocusedPane(key string) bool {
return scrollViewport(&m.menuVP, key)
case model.FocusList:
switch key {
case "pgdown", "pgdn", "f", "ctrl+d":
case "pgdown", "pgdn", "ctrl+d":
m.listTable.PageScroll(1)
return true
case "pgup", "b", "ctrl+u":
@@ -20,10 +20,10 @@ func (m *Model) scrollFocusedPane(key string) bool {
}
return false
case model.FocusPreview:
if m.detail.Zone() != detailZoneContent || m.detail.mode != detailDocument {
if m.detail.Zone() != detailZoneContent {
return false
}
return scrollViewport(&m.detail.docVP, key)
return m.detail.ScrollDocument(key)
default:
return false
}
@@ -50,7 +50,13 @@ func (m *Model) paneInnerWidth(outer int) int {
func scrollViewport(vp *viewport.Model, key string) bool {
switch key {
case "pgdown", "pgdn", "f", "ctrl+d":
case "up", "k":
vp.LineUp(1)
return true
case "down", "j":
vp.LineDown(1)
return true
case "pgdown", "pgdn", "ctrl+d":
vp.ViewDown()
return true
case "pgup", "b", "ctrl+u":
+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
focused bool
ready bool
loadingMore bool
width int
height int
viewport viewport.Model
@@ -127,6 +128,62 @@ func (t *DataTable) ItemIndex() int {
return t.order[t.cursorRow]
}
// CursorRow is the visual row index in the current table order.
func (t DataTable) CursorRow() int {
return t.cursorRow
}
// Items returns the backing slice (after appends).
func (t DataTable) Items() []model.Item {
return t.items
}
// NearEnd reports whether the cursor is within threshold rows of the last row.
func (t DataTable) NearEnd(threshold int) bool {
if !t.ready || len(t.order) == 0 {
return false
}
return t.cursorRow >= len(t.order)-threshold
}
func (t *DataTable) SetLoadingMore(on bool) {
t.loadingMore = on
}
// AppendItems adds new rows, skipping duplicate IDs. Returns how many were added.
func (t *DataTable) AppendItems(items []model.Item) int {
if !t.ready || len(items) == 0 {
return 0
}
seen := make(map[string]struct{}, len(t.items))
for _, it := range t.items {
if it.ID != "" {
seen[it.ID] = struct{}{}
}
}
added := 0
for _, it := range items {
if it.ID != "" {
if _, ok := seen[it.ID]; ok {
continue
}
seen[it.ID] = struct{}{}
}
idx := len(t.items)
t.items = append(t.items, it)
t.order = append(t.order, idx)
added++
}
if added > 0 {
if t.sortCol >= 0 {
t.applySort()
}
t.clampCursor()
t.refreshViewport()
}
return added
}
func (t *DataTable) MoveRow(delta int) {
if !t.ready || len(t.order) == 0 {
return
@@ -190,7 +247,7 @@ func (t *DataTable) Update(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "pgdown", "pgdn", "f", "ctrl+d":
case "pgdown", "pgdn", "ctrl+d":
t.viewport.ViewDown()
case "pgup", "b", "ctrl+u":
t.viewport.ViewUp()
@@ -208,6 +265,9 @@ func (t DataTable) View() string {
return "List\n\nSelect a leaf node in the tree\n(marked with •) and press Enter.\n"
}
title := t.styles.title.Render(fmt.Sprintf("%s (%d)", t.spec.Subject, len(t.items)))
if t.loadingMore {
title += t.styles.help.Render(" …")
}
sortHint := ""
if t.sortCol >= 0 && t.sortCol < len(t.columns) {
dir := "▲"
@@ -217,7 +277,13 @@ func (t DataTable) View() string {
sortHint = t.styles.help.Render(fmt.Sprintf(" sort: %s %s", t.columns[t.sortCol].Title, dir))
}
header := t.renderHeader()
body := t.viewport.View()
body := ApplyVerticalScrollbar(
t.viewport.View(),
t.viewport.Width,
t.viewport.Height,
t.viewport.TotalLineCount(),
t.viewport.YOffset,
)
content := lipgloss.JoinVertical(lipgloss.Left, title+sortHint, header, body)
return lipgloss.NewStyle().Width(t.width).Render(content)
}
@@ -297,7 +363,7 @@ func distributeColumnWidths(minSum, total int, indices []int, cols []model.Table
flex := make([]int, 0, len(indices))
for _, i := range indices {
switch cols[i].Key {
case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "tasks":
case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "tasks", "type":
flex = append(flex, i)
}
}
@@ -332,6 +398,19 @@ func fixColumnWidthSum(widths map[int]int, indices []int, total int) {
}
}
const cellHPadding = 2 // lipgloss Padding(0, 1) on each side
func truncateCellText(text string, colWidth int) string {
inner := colWidth - cellHPadding
if inner < 1 {
inner = 1
}
if runewidth.StringWidth(text) <= inner {
return text
}
return runewidth.Truncate(text, inner, "...")
}
func (t *DataTable) renderHeader() string {
indices, widths := t.visibleLayout()
cells := make([]string, 0, len(indices))
@@ -346,7 +425,7 @@ func (t *DataTable) renderHeader() string {
title += " ▼"
}
}
text := runewidth.Truncate(title, w, "…")
text := truncateCellText(title, w)
style := t.styles.header
if t.focused && colIdx == t.cursorCol {
style = t.styles.headerSort
@@ -371,39 +450,70 @@ func (t *DataTable) refreshViewport() {
func (t *DataTable) renderRow(row int) string {
item := t.items[t.order[row]]
selected := item.Selected
fullRow := row == t.cursorRow || selected
indices, widths := t.visibleLayout()
cells := make([]string, 0, len(indices))
for _, colIdx := range indices {
col := t.columns[colIdx]
w := widths[colIdx]
text := runewidth.Truncate(model.CellText(item, col.Key), w, "…")
cells = append(cells, t.styleCell(row, colIdx, selected, text, w))
raw := strings.TrimSpace(model.CellText(item, col.Key))
text := raw
if !fullRow {
text = truncateCellText(raw, w)
}
cells = append(cells, t.styleCell(row, colIdx, col.Key, selected, item, text, w, fullRow))
}
return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
}
func (t *DataTable) styleCell(row, col int, selected bool, text string, width int) string {
base := t.styles.cell.Width(width).MaxWidth(width)
func applyCellWidth(s lipgloss.Style, width int, full bool) lipgloss.Style {
if full {
return s
}
return s.Width(width).MaxWidth(width)
}
func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item model.Item, text string, width int, fullWidth bool) string {
isRow := row == t.cursorRow
isCol := col == t.cursorCol
isCell := t.focused && isRow && isCol
var base lipgloss.Style
switch {
case selected && isCell:
return t.styles.cellSelect.Width(width).MaxWidth(width).Render(text)
base = t.styles.cellSelect
case selected && isRow:
return t.styles.rowSelect.Width(width).MaxWidth(width).Render(text)
base = t.styles.rowSelect
case selected:
return t.styles.rowSelect.Width(width).MaxWidth(width).Render(text)
base = t.styles.rowSelect
case isCell:
return t.styles.cellActive.Width(width).MaxWidth(width).Render(text)
base = t.styles.cellActive
case t.focused && isRow:
return t.styles.rowActive.Width(width).MaxWidth(width).Render(text)
base = t.styles.rowActive
case t.focused && isCol:
return t.styles.colActive.Width(width).MaxWidth(width).Render(text)
base = t.styles.colActive
default:
return base.Render(text)
base = t.styles.cell
}
base = t.applyProjectRowStyle(base, item, colKey)
return applyCellWidth(base, width, fullWidth).Render(text)
}
func (t *DataTable) applyProjectRowStyle(base lipgloss.Style, item model.Item, colKey string) lipgloss.Style {
if t.spec.Subject != model.SubjectProjects {
return base
}
open := model.ProjectIsOpen(item.Raw)
if colKey == "status" {
if open {
return base.Foreground(lipgloss.Color("42")).Bold(true)
}
return base.Foreground(lipgloss.Color("245"))
}
if open {
return base.Background(lipgloss.Color("22")).Foreground(lipgloss.Color("255"))
}
return base.Background(lipgloss.Color("238")).Foreground(lipgloss.Color("252"))
}
func (t *DataTable) ensureColVisible() {
+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"
"github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/mattn/go-runewidth"
)
func sampleItems() []model.Item {
@@ -32,7 +33,7 @@ func TestDataTableMoveColScrolls(t *testing.T) {
items[0] = model.Item{ID: "1", Title: "One", Raw: map[string]any{
"alpha": "a", "beta": "b", "gamma": "c", "delta": "d",
}}
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, items)
tbl.SetData(model.ListSpec{Subject: model.SubjectContacts}, items)
last := len(tbl.columns) - 1
tbl.cursorCol = last
tbl.ensureColVisible()
@@ -88,6 +89,55 @@ func TestDistributeColumnWidthsFillsTotal(t *testing.T) {
}
}
func TestTruncateCellText(t *testing.T) {
long := strings.Repeat("x", 40)
got := truncateCellText(long, 10)
if runewidth.StringWidth(got) > 10 {
t.Fatalf("truncated width=%d want <=10", runewidth.StringWidth(got))
}
if !strings.HasSuffix(got, "...") {
t.Fatalf("expected ellipsis suffix, got %q", got)
}
}
func TestDataTableTruncatesNonSelectedRows(t *testing.T) {
long := strings.Repeat("A", 60)
items := []model.Item{
{ID: "1", Title: "Short"},
{ID: "2", Title: long},
}
tbl := newDataTable()
tbl.SetSize(50, 10)
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, items)
tbl.cursorRow = 0
if strings.Contains(tbl.renderRow(1), long) {
t.Fatal("non-cursor row should truncate long title")
}
tbl.cursorRow = 1
if !strings.Contains(tbl.renderRow(1), long) {
t.Fatal("cursor row should show full title")
}
}
func TestDataTableShowsFullTextWhenSpaceSelected(t *testing.T) {
long := strings.Repeat("B", 60)
items := []model.Item{
{ID: "1", Title: "Short"},
{ID: "2", Title: long, Selected: true},
}
tbl := newDataTable()
tbl.SetSize(50, 10)
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, items)
tbl.cursorRow = 0
rowSelected := tbl.renderRow(1)
if !strings.Contains(rowSelected, long) {
t.Fatal("space-selected row should show full title")
}
}
func indexOfColumn(tbl DataTable, key string) int {
for i, c := range tbl.columns {
if c.Key == key {
+12
View File
@@ -204,3 +204,15 @@ func (c *Client) UpdateProject(req ProjectUpdateRequest) (*Project, error) {
Response *Project `json:"response"`
}{p})
}
// UpdateProjectStatus sets project lifecycle status (open, paused, closed).
func (c *Client) UpdateProjectStatus(id int, status string) (*Project, error) {
p := &Project{}
return p, c.Query(Request{
Uri: fmt.Sprintf("/api/2.0/project/%d/status", id),
Method: "PUT",
Body: map[string]string{"status": status},
}, &struct {
Response *Project `json:"response"`
}{p})
}