From 0ed2d9b0f5b849c71dbfcbc1ac504a2bf6c5c78a Mon Sep 17 00:00:00 2001 From: Andriy Oblivantsev Date: Thu, 25 Jun 2026 11:07:22 +0100 Subject: [PATCH] feat(office): users admin UI, table rendering, and user save fixes Add Users list/detail with ACL toggles and library people update/status APIs. Fix project table layout and ANSI-safe cell rendering, and decode array responses from people/status so saves no longer fail on JSON unmarshal. Co-authored-by: Cursor --- cmd/office/fetch/list.go | 29 +- cmd/office/fetch/save.go | 14 +- cmd/office/fetch/task.go | 47 ++- cmd/office/fetch/task_integration_test.go | 20 +- cmd/office/fetch/user.go | 131 ++++++++ cmd/office/fetch/user_test.go | 34 ++ cmd/office/fetch/users.go | 33 ++ cmd/office/model/actions.go | 9 +- cmd/office/model/columns.go | 48 ++- cmd/office/model/columns_test.go | 17 +- cmd/office/model/formfields.go | 96 +++++- cmd/office/model/formfields_task_test.go | 42 +++ cmd/office/model/nav.go | 3 +- cmd/office/model/project_status.go | 21 ++ cmd/office/model/task_status.go | 87 +++++ cmd/office/model/user_acl.go | 93 ++++++ cmd/office/model/user_acl_test.go | 43 +++ cmd/office/model/user_option.go | 7 + cmd/office/model/user_status.go | 116 +++++++ cmd/office/model/user_test.go | 58 ++++ cmd/office/ui/app.go | 326 +++++++++++++------ cmd/office/ui/detail_tab_task_test.go | 41 +++ cmd/office/ui/detail_test.go | 4 +- cmd/office/ui/entityform.go | 367 +++++++++++++++++----- cmd/office/ui/entityform_user.go | 161 ++++++++++ cmd/office/ui/entityform_user_test.go | 57 ++++ cmd/office/ui/filter.go | 13 +- cmd/office/ui/keys.go | 9 +- cmd/office/ui/layout_test.go | 52 +++ cmd/office/ui/list_toolbar.go | 352 +++++++++++++++++++++ cmd/office/ui/list_toolbar_test.go | 58 ++++ cmd/office/ui/scroll.go | 35 ++- cmd/office/ui/scroll_menu_test.go | 48 +++ cmd/office/ui/scrollbar.go | 15 +- cmd/office/ui/table.go | 232 ++++---------- cmd/office/ui/table_layout.go | 165 ++++++++++ cmd/office/ui/table_projects.go | 114 +++++++ cmd/office/ui/table_projects_test.go | 22 ++ cmd/office/ui/table_render.go | 49 +++ cmd/office/ui/table_render_test.go | 55 ++++ cmd/office/ui/table_test.go | 148 ++++++++- cmd/office/ui/table_users.go | 69 ++++ http.go | 25 +- http_test.go | 50 +++ users.go | 35 +++ 45 files changed, 3012 insertions(+), 438 deletions(-) create mode 100644 cmd/office/fetch/user.go create mode 100644 cmd/office/fetch/user_test.go create mode 100644 cmd/office/fetch/users.go create mode 100644 cmd/office/model/formfields_task_test.go create mode 100644 cmd/office/model/task_status.go create mode 100644 cmd/office/model/user_acl.go create mode 100644 cmd/office/model/user_acl_test.go create mode 100644 cmd/office/model/user_option.go create mode 100644 cmd/office/model/user_status.go create mode 100644 cmd/office/model/user_test.go create mode 100644 cmd/office/ui/detail_tab_task_test.go create mode 100644 cmd/office/ui/entityform_user.go create mode 100644 cmd/office/ui/entityform_user_test.go create mode 100644 cmd/office/ui/list_toolbar.go create mode 100644 cmd/office/ui/list_toolbar_test.go create mode 100644 cmd/office/ui/scroll_menu_test.go create mode 100644 cmd/office/ui/table_layout.go create mode 100644 cmd/office/ui/table_projects.go create mode 100644 cmd/office/ui/table_projects_test.go create mode 100644 cmd/office/ui/table_render.go create mode 100644 cmd/office/ui/table_render_test.go create mode 100644 cmd/office/ui/table_users.go create mode 100644 http_test.go diff --git a/cmd/office/fetch/list.go b/cmd/office/fetch/list.go index d5075ed..e21ddc7 100644 --- a/cmd/office/fetch/list.go +++ b/cmd/office/fetch/list.go @@ -101,6 +101,8 @@ func (l *Loader) Detail(ctx context.Context, item model.Item) (map[string]any, e return l.Client.GetTaskByID(ctx, item.ID) case model.KindProject: return l.Client.GetProjectByID(ctx, item.ID) + case model.KindUser: + return l.Client.GetUser(ctx, item.ID) default: if item.Raw != nil { return item.Raw, nil @@ -362,33 +364,6 @@ func mailFolderForSubject(subject model.Subject) (int, bool) { } } -func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) { - users, err := l.Client.GetUsers() - if err != nil { - return nil, err - } - items := make([]model.Item, len(users)) - for i, u := range users { - id := "" - if u.ID != nil { - id = fmt.Sprint(*u.ID) - } - title := "" - if u.DisplayName != nil { - title = *u.DisplayName - } - email := "" - if u.Email != nil { - email = *u.Email - } - items[i] = model.Item{ - ID: id, Title: title, Subtitle: email, Kind: model.KindUser, - Raw: map[string]any{"id": id, "displayName": title, "email": email}, - } - } - return items, nil -} - func (l *Loader) listProjectFiles(ctx context.Context, projectID string) ([]model.Item, error) { resp, err := l.Client.GetProjectFiles(ctx, projectID) if err != nil { diff --git a/cmd/office/fetch/save.go b/cmd/office/fetch/save.go index a7f8a0b..b43bdec 100644 --- a/cmd/office/fetch/save.go +++ b/cmd/office/fetch/save.go @@ -16,9 +16,11 @@ func (l *Loader) SaveItem(ctx context.Context, item model.Item, fields model.For } switch item.Kind { case model.KindTask: - return l.UpdateTask(ctx, item.ID, fields.Primary, fields.Secondary) + return l.UpdateTask(ctx, item.ID, fields) case model.KindProject: return l.saveProject(ctx, item.ID, fields) + case model.KindUser: + return l.SaveUser(ctx, item.ID, fields) default: return fmt.Errorf("save not supported for %s", item.Kind) } @@ -60,5 +62,13 @@ func (l *Loader) DetailForm(ctx context.Context, item model.Item) (model.FormFie if err != nil { return model.FormFields{}, err } - return model.FormFieldsFromRaw(item.Kind, raw), nil + fields := model.FormFieldsFromRaw(item.Kind, raw) + if item.Kind == model.KindTask { + choices, uerr := l.LoadUserChoices(ctx) + if uerr != nil { + return model.FormFields{}, uerr + } + fields.UserChoices = choices + } + return fields, nil } diff --git a/cmd/office/fetch/task.go b/cmd/office/fetch/task.go index c38e0a8..c954225 100644 --- a/cmd/office/fetch/task.go +++ b/cmd/office/fetch/task.go @@ -9,8 +9,18 @@ import ( "github.com/eslider/go-onlyoffice/cmd/office/model" ) -// UpdateTask saves title and description for a project task. -func (l *Loader) UpdateTask(ctx context.Context, taskID, title, description string) error { +// UpdateTask saves editable task fields via the typed JSON API. +func (l *Loader) UpdateTask(ctx context.Context, taskID string, fields model.FormFields) error { + return l.updateTask(ctx, taskID, fields, false) +} + +// CloseTask saves fields and sets status to closed. +func (l *Loader) CloseTask(ctx context.Context, taskID string, fields model.FormFields) error { + fields.TaskStatus = model.TaskLifecycleClosed + return l.updateTask(ctx, taskID, fields, true) +} + +func (l *Loader) updateTask(ctx context.Context, taskID string, fields model.FormFields, closing bool) error { if l == nil || l.Client == nil { return fmt.Errorf("fetch: client is nil") } @@ -18,19 +28,26 @@ func (l *Loader) UpdateTask(ctx context.Context, taskID, title, description stri if err != nil { return fmt.Errorf("task id %q: %w", taskID, err) } - _, err = l.Client.UpdateProjectTask(onlyoffice.ProjectTaskUpdateRequest{ + status := onlyoffice.ProjectTaskStatus(fields.TaskStatus) + if closing { + status = onlyoffice.ProjectTaskStatusClosed + } + req := onlyoffice.ProjectTaskUpdateRequest{ ID: id, - Title: title, - Description: description, - }) + Title: fields.Primary, + Description: fields.Secondary, + Status: status, + } + if fields.ResponsibleID != "" { + req.Responsible = []string{fields.ResponsibleID} + } else if !closing { + raw, derr := l.Detail(ctx, model.Item{ID: taskID, Kind: model.KindTask}) + if derr == nil { + if rid := model.TaskResponsibleIDFromRaw(raw); rid != "" { + req.Responsible = []string{rid} + } + } + } + _, err = l.Client.UpdateProjectTask(req) return err } - -// TaskFields loads title and description for a project task item. -func (l *Loader) TaskFields(ctx context.Context, item model.Item) (title, description string, err error) { - fields, err := l.DetailForm(ctx, item) - if err != nil { - return "", "", err - } - return fields.Primary, fields.Secondary, nil -} diff --git a/cmd/office/fetch/task_integration_test.go b/cmd/office/fetch/task_integration_test.go index cfbb700..94a60d0 100644 --- a/cmd/office/fetch/task_integration_test.go +++ b/cmd/office/fetch/task_integration_test.go @@ -20,27 +20,27 @@ func TestIntegrationUpdateTaskTitleDescription(t *testing.T) { t.Skip("no tasks") } item := items[0] - title, desc, err := loader.TaskFields(ctx, item) + fields, err := loader.DetailForm(ctx, item) if err != nil { t.Fatal(err) } - restoreTitle, restoreDesc := title, desc + restore := fields t.Cleanup(func() { - _ = loader.UpdateTask(context.Background(), item.ID, restoreTitle, restoreDesc) + _ = loader.UpdateTask(context.Background(), item.ID, restore) }) - newTitle := title + " (office TUI test)" - newDesc := desc + "\n\n_edited by office integration test_" - if err := loader.UpdateTask(ctx, item.ID, newTitle, newDesc); err != nil { + fields.Primary = fields.Primary + " (office TUI test)" + fields.Secondary = fields.Secondary + "\n\n_edited by office integration test_" + if err := loader.UpdateTask(ctx, item.ID, fields); err != nil { t.Fatal(err) } - gotTitle, gotDesc, err := loader.TaskFields(ctx, item) + got, err := loader.DetailForm(ctx, item) if err != nil { t.Fatal(err) } - if gotTitle != newTitle { - t.Fatalf("title: got %q want %q", gotTitle, newTitle) + if got.Primary != fields.Primary { + t.Fatalf("title: got %q want %q", got.Primary, fields.Primary) } - if gotDesc != newDesc { + if got.Secondary != fields.Secondary { t.Fatalf("description mismatch") } } diff --git a/cmd/office/fetch/user.go b/cmd/office/fetch/user.go new file mode 100644 index 0000000..fb91cc3 --- /dev/null +++ b/cmd/office/fetch/user.go @@ -0,0 +1,131 @@ +package fetch + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + onlyoffice "github.com/eslider/go-onlyoffice" + "github.com/eslider/go-onlyoffice/cmd/office/model" +) + +func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) { + users, err := l.Client.GetUsers() + if err != nil { + return nil, err + } + items := make([]model.Item, 0, len(users)) + for _, u := range users { + if u == nil { + continue + } + raw := userToRaw(u) + id := strMap(raw, "id") + userName := strMap(raw, "userName") + if userName == "" { + userName = strMap(raw, "email") + } + items = append(items, model.Item{ + ID: id, + Title: userName, + Kind: model.KindUser, + Raw: raw, + }) + } + return items, nil +} + +func userToRaw(u *onlyoffice.User) map[string]any { + b, err := json.Marshal(u) + if err != nil { + return map[string]any{} + } + var raw map[string]any + if err := json.Unmarshal(b, &raw); err != nil { + return map[string]any{} + } + return raw +} + +func strMap(m map[string]any, key string) string { + if m == nil { + return "" + } + if v, ok := m[key].(string); ok { + return v + } + if m[key] == nil { + return "" + } + return fmt.Sprint(m[key]) +} + +func userProfileUpdateBody(isAdmin bool, modules []string) map[string]any { + body := map[string]any{ + "isAdmin": isAdmin, + } + if !isAdmin { + body["listAdminModules"] = modules + } + return body +} + +// SaveUser persists user account settings from the detail form. +func (l *Loader) SaveUser(ctx context.Context, userID string, fields model.FormFields) error { + if l == nil || l.Client == nil { + return fmt.Errorf("fetch: client is nil") + } + raw, err := l.Client.GetUser(ctx, userID) + if err != nil { + return err + } + wasEnabled := model.UserIsEnabled(raw) + + if fields.UserEnabled && !wasEnabled { + if err := l.Client.ChangeUserStatus(ctx, userID, true); err != nil { + return err + } + } + + isAdmin, modules := fields.UserACL.APIPayload() + body := userProfileUpdateBody(isAdmin, modules) + if _, err := l.updateUserProfile(ctx, userID, body, fields.UserEnabled); err != nil { + return err + } + + if !fields.UserEnabled && wasEnabled { + if err := l.Client.ChangeUserStatus(ctx, userID, false); err != nil { + return err + } + } + + if fields.UserPassword != "" { + if err := l.Client.ChangeUserPassword(ctx, userID, fields.UserPassword); err != nil { + return err + } + } + return nil +} + +func (l *Loader) updateUserProfile(ctx context.Context, userID string, body map[string]any, wantEnabled bool) (map[string]any, error) { + out, err := l.Client.UpdateUser(ctx, userID, body) + if err == nil { + return out, nil + } + if wantEnabled && isSuspendedUserError(err) { + if actErr := l.Client.ChangeUserStatus(ctx, userID, true); actErr != nil { + return nil, err + } + return l.Client.UpdateUser(ctx, userID, body) + } + return nil, err +} + +func isSuspendedUserError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "suspended") || strings.Contains(msg, "terminated") +} diff --git a/cmd/office/fetch/user_test.go b/cmd/office/fetch/user_test.go new file mode 100644 index 0000000..b851935 --- /dev/null +++ b/cmd/office/fetch/user_test.go @@ -0,0 +1,34 @@ +package fetch + +import ( + "fmt" + "testing" +) + +func TestUserProfileUpdateBodyOmitsStatus(t *testing.T) { + body := userProfileUpdateBody(true, nil) + if _, ok := body["status"]; ok { + t.Fatal("profile update must not include status") + } + if body["isAdmin"] != true { + t.Fatal("expected isAdmin true") + } +} + +func TestUserProfileUpdateBodyPartialAdminModules(t *testing.T) { + body := userProfileUpdateBody(false, []string{"documents", "crm"}) + if body["isAdmin"] != false { + t.Fatal("expected partial admin") + } + mods, ok := body["listAdminModules"].([]string) + if !ok || len(mods) != 2 { + t.Fatalf("modules=%v", body["listAdminModules"]) + } +} + +func TestIsSuspendedUserError(t *testing.T) { + err := fmt.Errorf(`PUT JSON /api/2.0/people/x: 500 {"error":{"message":"The user is suspended"}}`) + if !isSuspendedUserError(err) { + t.Fatal("expected suspended detection") + } +} diff --git a/cmd/office/fetch/users.go b/cmd/office/fetch/users.go new file mode 100644 index 0000000..4e1f893 --- /dev/null +++ b/cmd/office/fetch/users.go @@ -0,0 +1,33 @@ +package fetch + +import ( + "context" + + "github.com/eslider/go-onlyoffice/cmd/office/model" +) + +// LoadUserChoices returns portal users for responsible pickers. +func (l *Loader) LoadUserChoices(ctx context.Context) ([]model.UserOption, error) { + if l == nil || l.Client == nil { + return nil, nil + } + users, err := l.Client.GetUsers() + if err != nil { + return nil, err + } + out := make([]model.UserOption, 0, len(users)) + for _, u := range users { + if u == nil || u.ID == nil || *u.ID == "" { + continue + } + name := "" + if u.DisplayName != nil { + name = *u.DisplayName + } + if name == "" && u.Email != nil { + name = *u.Email + } + out = append(out, model.UserOption{ID: *u.ID, Name: name}) + } + return out, nil +} diff --git a/cmd/office/model/actions.go b/cmd/office/model/actions.go index 42bd687..c3760a6 100644 --- a/cmd/office/model/actions.go +++ b/cmd/office/model/actions.go @@ -6,6 +6,7 @@ type ActionID string const ( ActionView ActionID = "view" ActionSave ActionID = "save" + ActionClose ActionID = "close" ActionDelete ActionID = "delete" ActionRefresh ActionID = "refresh" ActionDownload ActionID = "download" @@ -29,7 +30,7 @@ func ActionsFor(kind Kind) []ItemAction { case KindTask: return []ItemAction{ {ID: ActionSave, Label: "Save"}, - {ID: ActionDelete, Label: "Delete task", Danger: true}, + {ID: ActionClose, Label: "Close"}, } case KindCRMTask: return []ItemAction{ @@ -56,7 +57,11 @@ func ActionsFor(kind Kind) []ItemAction { {ID: ActionDownload, Label: "Download"}, {ID: ActionDelete, Label: "Delete file", Danger: true}, } - case KindEvent, KindCalendar, KindUser: + case KindUser: + return []ItemAction{ + {ID: ActionSave, Label: "Save"}, + } + case KindEvent, KindCalendar: return nil default: return nil diff --git a/cmd/office/model/columns.go b/cmd/office/model/columns.go index 7f0aa8c..f9cb53b 100644 --- a/cmd/office/model/columns.go +++ b/cmd/office/model/columns.go @@ -29,7 +29,6 @@ var subjectExtraKeys = map[Subject][]string{ SubjectMailDrafts: {"to", "date"}, SubjectMailTrash: {"from", "date"}, SubjectMailSpam: {"from", "date"}, - SubjectUsers: {"email", "displayName"}, } // BuildColumns derives table columns from the list subject and item payloads. @@ -37,6 +36,9 @@ func BuildColumns(subject Subject, items []Item) []TableColumn { if subject == SubjectProjects { return buildProjectColumns(items) } + if subject == SubjectUsers { + return buildUserColumns(items) + } if subject == SubjectTasks { return buildTaskColumns(items) } @@ -102,18 +104,28 @@ func buildTaskColumns(items []Item) []TableColumn { return cols } +func buildUserColumns(items []Item) []TableColumn { + _ = items + return []TableColumn{ + {Key: "_sel", Title: "βœ“", Width: 3}, + {Key: "userName", Title: "User", Width: 14}, + {Key: "registration", Title: "Registered", Width: 12}, + {Key: "status", Title: "Status", Width: 10}, + {Key: "email", Title: "Email", Width: 20}, + } +} + func buildProjectColumns(items []Item) []TableColumn { - cols := []TableColumn{ + _ = items + return []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}, - {Key: "users", Title: "Users", Width: 8}, + {Key: "status", Title: "●", Width: 10}, + {Key: "title", Title: "Title", Width: 16}, + {Key: "tasks", Title: "Tasks", Width: 11}, + {Key: "documents", Title: "Docs", Width: 7}, + {Key: "users", Title: "Users", Width: 7}, } - sizeColumns(cols, items) - return cols } // CellText returns the display string for one table cell. @@ -141,7 +153,9 @@ func CellText(it Item, key string) string { case "status": switch it.Kind { case KindProject: - return ProjectStatusLabel(it.Raw) + return ProjectStatusCell(it.Raw) + case KindUser: + return UserStatusLabel(it.Raw) case KindTask, KindCRMTask: if it.Raw == nil { return "" @@ -171,6 +185,20 @@ func CellText(it Item, key string) string { return formatAny(it.Raw["responsible"]) case "subtitle": return it.Subtitle + case "userName": + if it.Raw != nil { + if u := strRaw(it.Raw, "userName"); u != "" { + return u + } + } + return it.Title + case "registration": + return FormatUserRegistration(it.Raw) + case "email": + if it.Raw != nil { + return strRaw(it.Raw, "email") + } + return it.Subtitle case "tasks": return formatProjectTasks(it.Raw) case "documents": diff --git a/cmd/office/model/columns_test.go b/cmd/office/model/columns_test.go index 48d57f0..d36ded1 100644 --- a/cmd/office/model/columns_test.go +++ b/cmd/office/model/columns_test.go @@ -29,13 +29,26 @@ func TestBuildProjectColumns(t *testing.T) { func TestCellTextProjectStatus(t *testing.T) { open := Item{Kind: KindProject, Raw: map[string]any{"status": 0}} - if got := CellText(open, "status"); got != "Open" { + 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" { + if got := CellText(closed, "status"); got != "πŸ”΄ Closed" { t.Fatalf("got %q", got) } + paused := Item{Kind: KindProject, Raw: map[string]any{"status": 1}} + if got := CellText(paused, "status"); got != "🟑 Paused" { + t.Fatalf("got %q", got) + } +} + +func TestBuildProjectColumnsStatusHeaderIsIcon(t *testing.T) { + cols := BuildColumns(SubjectProjects, nil) + for _, c := range cols { + if c.Key == "status" && c.Title != "●" { + t.Fatalf("status header=%q want ●", c.Title) + } + } } func TestFormatProjectTasksOpenClosed(t *testing.T) { diff --git a/cmd/office/model/formfields.go b/cmd/office/model/formfields.go index 9d93848..9906123 100644 --- a/cmd/office/model/formfields.go +++ b/cmd/office/model/formfields.go @@ -11,7 +11,18 @@ type FormFields struct { ReadOnly bool HasStatus bool Status ProjectLifecycle + HasTaskStatus bool + TaskStatus TaskLifecycle + HasResponsible bool ResponsibleID string + UserChoices []UserOption + ProjectTitle string + TimingSummary string + HasUserEdit bool + UserEnabled bool + UserACL UserACLState + GroupsText string + UserPassword string } // KindHeading returns a short label for the detail pane header. @@ -62,10 +73,13 @@ func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields { ReadOnly: true, } case KindUser: + acl := UserACLFromRaw(raw) return FormFields{ - PrimaryLabel: "Name", SecondaryLabel: "Email", - Primary: strRaw(raw, "displayName"), Secondary: strRaw(raw, "email"), - ReadOnly: true, + HasUserEdit: true, + ReadOnly: false, + UserEnabled: UserIsEnabled(raw), + UserACL: acl, + GroupsText: UserGroupsText(raw), } case KindContact: name := strRaw(raw, "displayName") @@ -98,6 +112,20 @@ func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields { Status: ProjectStatusFromAny(raw["status"]), ResponsibleID: ResponsibleIDFromRaw(raw), } + case KindTask: + return FormFields{ + PrimaryLabel: "Title", + SecondaryLabel: "Description", + Primary: strRaw(raw, "title"), + Secondary: strRaw(raw, "description"), + ReadOnly: false, + HasTaskStatus: true, + TaskStatus: TaskStatusFromAny(raw["status"]), + HasResponsible: true, + ResponsibleID: TaskResponsibleIDFromRaw(raw), + ProjectTitle: TaskProjectTitle(raw), + TimingSummary: TaskTimingSummary(raw), + } default: title := strRaw(raw, "title") if title == "" { @@ -109,11 +137,71 @@ func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields { return FormFields{ PrimaryLabel: "Title", SecondaryLabel: "Description", Primary: title, Secondary: strRaw(raw, "description"), - ReadOnly: kind != KindTask && kind != KindProject, + ReadOnly: true, } } } +// TaskResponsibleIDFromRaw returns the first assignee user id from task detail. +func TaskResponsibleIDFromRaw(raw map[string]any) string { + if raw == nil { + return "" + } + if ids, ok := raw["responsibleIds"].([]any); ok && len(ids) > 0 { + return strRaw(map[string]any{"id": ids[0]}, "id") + } + if list, ok := raw["responsibles"].([]any); ok && len(list) > 0 { + if m, ok := list[0].(map[string]any); ok { + return strRaw(m, "id") + } + } + return "" +} + +// TaskProjectTitle returns the owning project title when present. +func TaskProjectTitle(raw map[string]any) string { + if raw == nil { + return "" + } + if po, ok := raw["projectOwner"].(map[string]any); ok { + if t := strRaw(po, "title"); t != "" { + return t + } + } + return strRaw(raw, "projectTitle") +} + +// TaskTimingSummary formats start and deadline for the detail form. +func TaskTimingSummary(raw map[string]any) string { + if raw == nil { + return "" + } + start := formatTaskDateLabel(raw["startDate"]) + deadline := formatTaskDateLabel(raw["deadline"]) + switch { + case start != "" && deadline != "": + return start + " β†’ " + deadline + case deadline != "": + return "Due " + deadline + case start != "": + return "From " + start + default: + return "" + } +} + +func formatTaskDateLabel(v any) string { + t, ok := parseDeadlineTime(v) + if !ok { + s := strRaw(map[string]any{"v": v}, "v") + if s == "" || s == "" { + return "" + } + return s + } + return t.Format("2006-01-02 15:04") +} + func strRaw(m map[string]any, key string) string { if m == nil { return "" diff --git a/cmd/office/model/formfields_task_test.go b/cmd/office/model/formfields_task_test.go new file mode 100644 index 0000000..624b0ad --- /dev/null +++ b/cmd/office/model/formfields_task_test.go @@ -0,0 +1,42 @@ +package model + +import "testing" + +func TestTaskStatusLifecycleCycle(t *testing.T) { + cur := TaskLifecycleOpen + cur = cur.Next() + if cur != TaskLifecycleClosed { + t.Fatalf("got %v", cur) + } + cur = cur.Prev() + if cur != TaskLifecycleOpen { + t.Fatalf("got %v", cur) + } +} + +func TestFormFieldsFromRawTask(t *testing.T) { + raw := map[string]any{ + "title": "Fix", + "description": "Details", + "status": 1, + "projectOwner": map[string]any{"title": "Alpha"}, + "startDate": "2026-06-01T10:00:00Z", + "deadline": "2026-06-10T18:00:00Z", + "responsibles": []any{ + map[string]any{"id": "u1", "displayName": "Alice"}, + }, + } + f := FormFieldsFromRaw(KindTask, raw) + if !f.HasTaskStatus || f.TaskStatus != TaskLifecycleOpen { + t.Fatalf("status: %+v", f) + } + if !f.HasResponsible || f.ResponsibleID != "u1" { + t.Fatalf("responsible: %+v", f) + } + if f.ProjectTitle != "Alpha" { + t.Fatalf("project: %q", f.ProjectTitle) + } + if f.TimingSummary == "" { + t.Fatal("expected timing summary") + } +} diff --git a/cmd/office/model/nav.go b/cmd/office/model/nav.go index c3bc7f9..05231fc 100644 --- a/cmd/office/model/nav.go +++ b/cmd/office/model/nav.go @@ -66,8 +66,7 @@ func DefaultNavTree() *NavTree { add("mail.trash", "Trash", "mail", false, &ListSpec{Subject: SubjectMailTrash}) add("mail.spam", "Spam", "mail", false, &ListSpec{Subject: SubjectMailSpam}) - add("users", "Users", "", true, nil) - add("users.dir", "Directory", "users", false, &ListSpec{Subject: SubjectUsers}) + add("users", "Users", "", false, &ListSpec{Subject: SubjectUsers}) t.rebuildVisible() return t diff --git a/cmd/office/model/project_status.go b/cmd/office/model/project_status.go index d5080b2..3da427c 100644 --- a/cmd/office/model/project_status.go +++ b/cmd/office/model/project_status.go @@ -46,6 +46,27 @@ func ProjectStatusLabel(raw map[string]any) string { return ProjectStatusFromAny(raw["status"]).Label() } +// ProjectStatusIcon returns the list-cell emoji for a project lifecycle state. +func (s ProjectLifecycle) Icon() string { + switch s { + case ProjectLifecycleClosed: + return "πŸ”΄" + case ProjectLifecyclePaused: + return "🟑" + default: + return "🟒" + } +} + +// ProjectStatusCell returns emoji and label on one line for project table cells. +func ProjectStatusCell(raw map[string]any) string { + if raw == nil { + return ProjectLifecycleOpen.Icon() + " Open" + } + s := ProjectStatusFromAny(raw["status"]) + return s.Icon() + " " + s.Label() +} + // ProjectIsOpen is true when the project is not closed. func ProjectIsOpen(raw map[string]any) bool { if raw == nil { diff --git a/cmd/office/model/task_status.go b/cmd/office/model/task_status.go new file mode 100644 index 0000000..f5322dd --- /dev/null +++ b/cmd/office/model/task_status.go @@ -0,0 +1,87 @@ +package model + +// TaskLifecycle is the OnlyOffice project task status code. +type TaskLifecycle int + +const ( + TaskLifecycleNotAccept TaskLifecycle = 0 + TaskLifecycleOpen TaskLifecycle = 1 + TaskLifecycleClosed TaskLifecycle = 2 + TaskLifecycleDisabled TaskLifecycle = 3 + TaskLifecycleUnclassified TaskLifecycle = 4 + TaskLifecycleNotInMilestone TaskLifecycle = 5 +) + +var taskLifecycleCycle = []TaskLifecycle{ + TaskLifecycleOpen, + TaskLifecycleClosed, + TaskLifecycleNotAccept, + TaskLifecycleDisabled, + TaskLifecycleUnclassified, + TaskLifecycleNotInMilestone, +} + +// TaskStatusFromAny maps API status to a lifecycle value. +func TaskStatusFromAny(v any) TaskLifecycle { + if v == nil { + return TaskLifecycleOpen + } + if s, ok := v.(string); ok { + switch taskStatusFromString(s) { + case "Open": + return TaskLifecycleOpen + case "Closed": + return TaskLifecycleClosed + case "Not accepted": + return TaskLifecycleNotAccept + case "Disabled": + return TaskLifecycleDisabled + case "Unclassified": + return TaskLifecycleUnclassified + case "Not in milestone": + return TaskLifecycleNotInMilestone + } + } + switch n := intRawVal(map[string]any{"status": v}, "status"); n { + case 0: + return TaskLifecycleNotAccept + case 2: + return TaskLifecycleClosed + case 3: + return TaskLifecycleDisabled + case 4: + return TaskLifecycleUnclassified + case 5: + return TaskLifecycleNotInMilestone + default: + return TaskLifecycleOpen + } +} + +// Label returns a human-readable status name. +func (s TaskLifecycle) Label() string { + return TaskStatusLabel(int(s)) +} + +// Next cycles task status for keyboard toggling. +func (s TaskLifecycle) Next() TaskLifecycle { + return cycleTaskLifecycle(s, 1) +} + +// Prev cycles task status backward. +func (s TaskLifecycle) Prev() TaskLifecycle { + return cycleTaskLifecycle(s, -1) +} + +func cycleTaskLifecycle(cur TaskLifecycle, delta int) TaskLifecycle { + idx := 0 + for i, v := range taskLifecycleCycle { + if v == cur { + idx = i + break + } + } + n := len(taskLifecycleCycle) + idx = (idx + delta%n + n) % n + return taskLifecycleCycle[idx] +} diff --git a/cmd/office/model/user_acl.go b/cmd/office/model/user_acl.go new file mode 100644 index 0000000..176aa1a --- /dev/null +++ b/cmd/office/model/user_acl.go @@ -0,0 +1,93 @@ +package model + +import ( + "fmt" + "strings" +) + +// UserACLDef is one administrator permission row in the user editor. +type UserACLDef struct { + Key string + Label string +} + +// UserACLDefs lists portal ACL rows (Full access + per-module grants). +var UserACLDefs = []UserACLDef{ + {Key: "full", Label: "Full access"}, + {Key: "documents", Label: "Documents"}, + {Key: "projects", Label: "Projects"}, + {Key: "crm", Label: "CRM"}, + {Key: "community", Label: "Community"}, + {Key: "people", Label: "People"}, + {Key: "sample", Label: "Sample"}, + {Key: "mail", Label: "Mail"}, +} + +// UserACLState is the editable ACL snapshot for one user. +type UserACLState struct { + FullAccess bool + Modules map[string]bool +} + +// UserACLFromRaw parses API user detail into ACL editor state. +func UserACLFromRaw(raw map[string]any) UserACLState { + modules := make(map[string]bool, len(UserACLDefs)) + for _, def := range UserACLDefs[1:] { + modules[def.Key] = false + } + if list, ok := raw["listAdminModules"].([]any); ok { + for _, v := range list { + key := strings.ToLower(strings.TrimSpace(fmt.Sprint(v))) + if key != "" { + modules[key] = true + } + } + } + full := boolRaw(raw, "isAdmin") + if full { + for k := range modules { + modules[k] = true + } + } + return UserACLState{FullAccess: full, Modules: modules} +} + +// APIPayload converts editor state to OnlyOffice update fields. +func (s UserACLState) APIPayload() (isAdmin bool, modules []string) { + if s.FullAccess { + return true, nil + } + for _, def := range UserACLDefs[1:] { + if s.Modules[def.Key] { + modules = append(modules, def.Key) + } + } + return false, modules +} + +// ACLModuleOn reports whether a module row should show as granted. +func (s UserACLState) ACLModuleOn(key string) bool { + if key == "full" { + return s.FullAccess + } + if s.FullAccess { + return true + } + return s.Modules[key] +} + +func boolRaw(m map[string]any, key string) bool { + if m == nil { + return false + } + switch v := m[key].(type) { + case bool: + return v + case float64: + return v != 0 + case int: + return v != 0 + default: + return false + } +} diff --git a/cmd/office/model/user_acl_test.go b/cmd/office/model/user_acl_test.go new file mode 100644 index 0000000..918132f --- /dev/null +++ b/cmd/office/model/user_acl_test.go @@ -0,0 +1,43 @@ +package model + +import "testing" + +func TestUserACLFromRawFullAccess(t *testing.T) { + raw := map[string]any{ + "isAdmin": true, + "listAdminModules": []any{"crm"}, + } + acl := UserACLFromRaw(raw) + if !acl.FullAccess { + t.Fatal("expected full access") + } + if !acl.ACLModuleOn("documents") { + t.Fatal("full access should show all modules on") + } +} + +func TestUserACLAPIPayloadPartial(t *testing.T) { + acl := UserACLState{ + FullAccess: false, + Modules: map[string]bool{ + "documents": true, + "projects": true, + }, + } + isAdmin, mods := acl.APIPayload() + if isAdmin { + t.Fatal("expected partial admin") + } + if len(mods) != 2 { + t.Fatalf("mods=%v", mods) + } +} + +func TestUserIsEnabled(t *testing.T) { + if !UserIsEnabled(map[string]any{"status": EmployeeStatusActive}) { + t.Fatal("active user should be enabled") + } + if UserIsEnabled(map[string]any{"status": EmployeeStatusTerminated}) { + t.Fatal("terminated user should be disabled") + } +} diff --git a/cmd/office/model/user_option.go b/cmd/office/model/user_option.go new file mode 100644 index 0000000..9ae3731 --- /dev/null +++ b/cmd/office/model/user_option.go @@ -0,0 +1,7 @@ +package model + +// UserOption is one portal user for responsible pickers. +type UserOption struct { + ID string + Name string +} diff --git a/cmd/office/model/user_status.go b/cmd/office/model/user_status.go new file mode 100644 index 0000000..ecf4d63 --- /dev/null +++ b/cmd/office/model/user_status.go @@ -0,0 +1,116 @@ +package model + +import "time" + +const ( + EmployeeStatusActive = 1 + EmployeeStatusTerminated = 2 + EmployeeStatusPending = 4 +) + +// UserIsEnabled reports whether the portal user account is active. +func UserIsEnabled(raw map[string]any) bool { + if raw == nil { + return true + } + if t, ok := raw["terminated"].(bool); ok && t { + return false + } + switch intRawVal(raw, "status") { + case EmployeeStatusTerminated: + return false + case EmployeeStatusPending: + return false + default: + return true + } +} + +// UserStatusLabel returns a short list-cell label for account state. +func UserStatusLabel(raw map[string]any) string { + if !UserIsEnabled(raw) { + return "Disabled" + } + if intRawVal(raw, "status") == EmployeeStatusPending { + return "Pending" + } + return "Active" +} + +// UserGroupsText formats group membership for the detail form. +func UserGroupsText(raw map[string]any) string { + if raw == nil { + return "β€”" + } + list, ok := raw["groups"].([]any) + if !ok || len(list) == 0 { + return "β€”" + } + names := make([]string, 0, len(list)) + for _, g := range list { + m, ok := g.(map[string]any) + if !ok { + continue + } + name := strRaw(m, "name") + if name == "" { + name = strRaw(m, "title") + } + if name != "" { + names = append(names, name) + } + } + if len(names) == 0 { + return "β€”" + } + return stringsJoin(names, ", ") +} + +func stringsJoin(parts []string, sep string) string { + if len(parts) == 0 { + return "" + } + out := parts[0] + for i := 1; i < len(parts); i++ { + out += sep + parts[i] + } + return out +} + +// FormatUserRegistration formats registration or work-from date for list cells. +func FormatUserRegistration(raw map[string]any) string { + if raw == nil { + return "" + } + for _, key := range []string{"registrationDate", "workFrom"} { + if s := formatUserDate(raw[key]); s != "" { + return s + } + } + return "" +} + +func formatUserDate(v any) string { + switch t := v.(type) { + case string: + if t == "" { + return "" + } + if parsed, err := time.Parse("2006-01-02T15:04:05.0000000-07:00", t); err == nil { + return parsed.Format("2006-01-02") + } + if parsed, err := time.Parse(time.RFC3339, t); err == nil { + return parsed.Format("2006-01-02") + } + if len(t) >= 10 { + return t[:10] + } + return t + default: + s := strRaw(map[string]any{"v": v}, "v") + if s == "" || s == "" { + return "" + } + return formatUserDate(s) + } +} diff --git a/cmd/office/model/user_test.go b/cmd/office/model/user_test.go new file mode 100644 index 0000000..0605215 --- /dev/null +++ b/cmd/office/model/user_test.go @@ -0,0 +1,58 @@ +package model_test + +import ( + "testing" + + "github.com/eslider/go-onlyoffice/cmd/office/model" +) + +func TestNavUsersLeafReturnsListSpec(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 == "Users" { + found = i + break + } + } + if found < 0 { + t.Fatal("Users node not found") + } + tree.SetCursor(found) + spec, ok := tree.CurrentListSpec() + if !ok || spec.Subject != model.SubjectUsers { + t.Fatalf("users spec=%v ok=%v", spec, ok) + } + if tree.IsExpandable(found) { + t.Fatal("Users should be a direct leaf, not a branch") + } +} + +func TestBuildUserColumnsOmitsIDAndDisplayName(t *testing.T) { + items := []model.Item{{ + ID: "1", Title: "jdoe", Kind: model.KindUser, + Raw: map[string]any{ + "id": "1", "userName": "jdoe", "displayName": "John Doe", + "email": "j@example.com", "registrationDate": "2024-01-02T00:00:00", + }, + }} + cols := model.BuildColumns(model.SubjectUsers, items) + want := []string{"_sel", "userName", "registration", "status", "email"} + if len(cols) != len(want) { + t.Fatalf("got %v", columnKeys(cols)) + } + for i, key := range want { + if cols[i].Key != key { + t.Fatalf("col[%d]=%q want %q", i, cols[i].Key, key) + } + } +} + +func columnKeys(cols []model.TableColumn) []string { + out := make([]string, len(cols)) + for i, c := range cols { + out[i] = c.Key + } + return out +} diff --git a/cmd/office/ui/app.go b/cmd/office/ui/app.go index beace13..2dd38c9 100644 --- a/cmd/office/ui/app.go +++ b/cmd/office/ui/app.go @@ -65,13 +65,12 @@ type Model struct { err string loading bool menuVP viewport.Model - listTable DataTable + listTable DataTable + listToolbar ListToolbar detail DetailPane showMenu bool showList bool showDetail bool - filterActive bool - filterSearch FilterSearch customPaneLayout bool paneSizes PaneWidths resize paneResizeState @@ -86,14 +85,14 @@ func NewModel(client *onlyoffice.Client) Model { nav: model.DefaultNavTree(), selection: model.NewSelection(), focus: model.FocusMenu, - status: "Tab: pane Β· f: filter Β· row select loads detail Β· Ctrl+S save Β· q quit", + status: "Tab: pane Β· / filter Β· Space select row Β· πŸ’ΎπŸ—‘ when selected Β· v: detail Β· q quit", height: h, width: w, } m.menuVP = viewport.New(m.paneInnerWidth(22), m.paneHeight()) m.listTable = newDataTable() + m.listToolbar = newListToolbar() m.detail = newDetailPane() - m.filterSearch = newFilterSearch() m.showMenu, m.showList, m.showDetail = true, true, true m.menuVP.MouseWheelEnabled = true return m @@ -117,20 +116,43 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: key := msg.String() - if key == "esc" && m.filterActive { - m.exitFilter() + if key == "esc" && m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable { + m.listToolbar.FocusTable() + m.syncPaneFocus() return m, nil } - if m.filterActive && m.focus == model.FocusPreview && m.showDetail { + if key == "esc" && m.focus == model.FocusList && m.listToolbar.Query() != "" { + m.listToolbar.ClearFilter() + m.nav.ClearFilter() + m.syncMenuContent() + m.syncListTable() + return m, nil + } + if m.focus == model.FocusList && m.listToolbar.Zone() == listZoneFilter { switch key { case "tab", "shift+tab", "backtab": - // allow pane switching while filter stays active + // allow pane switching default: - cmd := m.filterSearch.Update(msg) + cmd := m.listToolbar.Update(msg, m.listToolbarMeta()) m.applyFilter() return m, cmd } } + if m.focus == model.FocusList && m.listToolbar.Zone() == listZoneActions { + meta := m.listToolbarMeta() + switch key { + case "enter": + if act, ok := m.listToolbar.SelectedAction(meta); ok && m.listToolbar.IsActionEnabled(act, meta) { + return m, m.runListToolbarAction(act) + } + return m, nil + case "tab", "shift+tab", "backtab": + // allow pane switching + default: + cmd := m.listToolbar.Update(msg, meta) + return m, cmd + } + } if key == "?" { m.status = helpText() return m, nil @@ -138,9 +160,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.scrollFocusedPane(key) { return m, nil } - if m.filterActive && m.focus == model.FocusPreview { - // filter input owns keys except pane switching handled above - } else if m.focus == model.FocusPreview { + if m.focus == model.FocusPreview { if cmd, handled := m.handleDetailKey(key, msg); handled { return m, cmd } @@ -155,39 +175,60 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case ActionNextPane: prev := m.focus m.focus = NextVisibleFocus(m.focus, m.paneVis()) - if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive { + if m.focus == model.FocusPreview && prev != model.FocusPreview { m.detail.FocusFirstStop() } + if m.focus == model.FocusList && prev != model.FocusList { + m.listToolbar.FocusTable() + } 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 { + if m.focus == model.FocusPreview && prev != model.FocusPreview { m.detail.FocusFirstStop() } + if m.focus == model.FocusList && prev != model.FocusList { + m.listToolbar.FocusTable() + } m.syncPaneFocus() return m, nil case ActionMoveUp: if m.focus == model.FocusPreview { return m.handleDetailMove(-1) } + if m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable { + return m, nil + } m.moveUp() m.syncFocusedPane() - return m, m.onListRowChanged() + if m.focus == model.FocusList { + return m, m.onListRowChanged() + } + return m, nil case ActionMoveDown: if m.focus == model.FocusPreview { return m.handleDetailMove(1) } + if m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable { + return m, nil + } m.moveDown() m.syncFocusedPane() - return m, m.onListRowChanged() + if m.focus == model.FocusList { + return m, m.onListRowChanged() + } + return m, nil case ActionMoveLeft: if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions { m.detail.MoveTabStop(-1) return m, nil } - if m.focus == model.FocusList && m.hasList { + if m.focus == model.FocusMenu { + return m.handleMenuHorizontal(-1) + } + if m.focus == model.FocusList && m.hasList && m.listToolbar.Zone() == listZoneTable { m.listTable.MoveCol(-1) } return m, nil @@ -196,12 +237,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.detail.MoveTabStop(1) return m, nil } - if m.focus == model.FocusList && m.hasList { + if m.focus == model.FocusMenu { + return m.handleMenuHorizontal(1) + } + if m.focus == model.FocusList && m.hasList && m.listToolbar.Zone() == listZoneTable { m.listTable.MoveCol(1) } return m, nil case ActionSort: - if m.focus == model.FocusList && m.hasList { + if m.focus == model.FocusList && m.hasList && m.listToolbar.Zone() == listZoneTable { m.listTable.ToggleSort() } return m, nil @@ -236,7 +280,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.togglePane(3) return m, nil case ActionFilter: - m.enterFilter() + if m.hasList { + m.focus = model.FocusList + m.listToolbar.FocusFilter() + m.syncPaneFocus() + } return m, nil case ActionRefresh: if m.hasList { @@ -257,13 +305,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } m.syncMenuContent() return m, nil - case "right", "l": - if spec, ok := m.nav.CurrentListSpec(); ok { - return m.withList(*spec) - } - m.nav.Activate() - m.syncMenuContent() - return m, nil } } @@ -284,9 +325,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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() - } + m.applyFilter() } return m, tea.Batch(m.onListRowChanged(), m.maybeLoadMoreList()) @@ -337,8 +376,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.updateItemAfterSave(msg.item, msg.fields) m.detail.LoadForm(msg.item, msg.fields) + m.detail.form.MarkClean() m.detail.SetFocused(m.focus == model.FocusPreview) - m.status = "Saved" + if msg.fields.HasTaskStatus && msg.fields.TaskStatus == model.TaskLifecycleClosed { + m.status = "Closed" + } else { + m.status = "Saved" + } m.err = "" } return m, nil @@ -350,6 +394,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { m.status = msg.message m.err = "" + m.selection.Clear() if m.hasList { m.loading = true return m, m.loadListCmd(m.listSpec) @@ -376,11 +421,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case model.FocusMenu: m.menuVP, cmd = m.menuVP.Update(msg) case model.FocusList: - cmd = m.listTable.Update(msg) - case model.FocusPreview: - if !m.filterActive { - cmd = m.detail.Update(msg) + if m.listToolbar.Zone() == listZoneTable { + cmd = m.listTable.Update(msg) } + case model.FocusPreview: + cmd = m.detail.Update(msg) } return m, cmd } @@ -392,9 +437,7 @@ 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) - } + cmd = m.detail.Update(msg) } return m, cmd } @@ -425,6 +468,9 @@ func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) { if act.ID == model.ActionSave { return m.saveDetailCmd(), true } + if act.ID == model.ActionClose && m.detail.Item().Kind == model.KindTask { + return m.closeTaskCmd(), true + } return m.executeActionCmd(act.ID, m.detail.Item()), true } } @@ -435,7 +481,7 @@ func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) { } func (m *Model) routeDetailDocumentMouse(msg tea.MouseMsg) (tea.Cmd, bool) { - if m.filterActive || !m.showDetail { + if !m.showDetail { return nil, false } if !m.detail.IsDocumentContent() && !m.detail.isReadOnlyFormContent() { @@ -476,7 +522,7 @@ func (m Model) View() string { h := m.paneHeight() + 2 var parts []string if pw.Visibility.Menu { - menuStyle := paneStyle(m.focus == model.FocusMenu).Width(pw.Menu).Height(h) + menuStyle := paneStyle(m.focus == model.FocusMenu).Width(paneLipglossWidth(pw.Menu)).Height(h) menuBody := ApplyVerticalScrollbar( m.menuVP.View(), m.menuVP.Width, @@ -487,18 +533,17 @@ func (m Model) View() string { parts = append(parts, menuStyle.Render(menuBody)) } if pw.Visibility.List { - listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h) - parts = append(parts, listStyle.Render(m.listTable.View())) + listStyle := paneStyle(m.focus == model.FocusList).Width(paneLipglossWidth(pw.List)).Height(h) + meta := m.listToolbarMeta() + body := lipgloss.JoinVertical(lipgloss.Left, + m.listToolbar.View(meta), + m.listTable.View(), + ) + parts = append(parts, listStyle.Render(body)) } if pw.Visibility.Detail { - prevStyle := paneStyle(m.focus == model.FocusPreview).Width(pw.Detail).Height(h) - var detailBody string - if m.filterActive { - detailBody = m.filterSearch.View() - } else { - detailBody = m.detail.View() - } - parts = append(parts, prevStyle.Render(detailBody)) + prevStyle := paneStyle(m.focus == model.FocusPreview).Width(paneLipglossWidth(pw.Detail)).Height(h) + parts = append(parts, prevStyle.Render(m.detail.View())) } status := m.status @@ -526,16 +571,105 @@ func (m Model) paneLayout() PaneWidths { } 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 + onList := m.focus == model.FocusList && m.showList + m.listTable.SetFocused(onList && m.listToolbar.Zone() == listZoneTable) + if onList { + m.listToolbar.syncInputFocus() + } else { + m.listToolbar.filter.SetFocused(false) } - m.filterSearch.SetFocused(false) m.detail.SetFocused(m.focus == model.FocusPreview && m.showDetail) } +func (m Model) listToolbarMeta() ListToolbarMeta { + meta := ListToolbarMeta{ + Subject: m.listTable.SubjectLabel(), + Count: m.listTable.ItemCount(), + SortLabel: m.listTable.SortHint(), + LoadingMore: m.listTable.LoadingMore(), + SaveEnabled: m.canSaveFromList(), + DeleteEnabled: m.canDeleteFromList(), + } + return meta +} + +func (m Model) selectedItems() []model.Item { + var out []model.Item + for _, it := range m.items { + if it.Selected { + out = append(out, it) + } + } + return out +} + +func (m Model) canSaveFromList() bool { + selected := m.selectedItems() + if len(selected) != 1 { + return false + } + for _, act := range model.ActionsFor(selected[0].Kind) { + if act.ID == model.ActionSave && m.detail.LoadedID() == selected[0].ID { + return true + } + } + return false +} + +func (m Model) canDeleteFromList() bool { + selected := m.selectedItems() + if len(selected) == 0 { + return false + } + for _, it := range selected { + if !actionAvailable(model.ActionsFor(it.Kind), model.ActionDelete) { + return false + } + } + return true +} + +func actionAvailable(actions []model.ItemAction, id model.ActionID) bool { + for _, a := range actions { + if a.ID == id { + return true + } + } + return false +} + +func (m *Model) runListToolbarAction(action model.ActionID) tea.Cmd { + meta := m.listToolbarMeta() + if !m.listToolbar.IsActionEnabled(action, meta) { + return nil + } + switch action { + case model.ActionSave: + m.loading = true + return m.saveDetailCmd() + case model.ActionDelete: + m.loading = true + return m.deleteSelectedCmd() + default: + return nil + } +} + +func (m *Model) deleteSelectedCmd() tea.Cmd { + selected := m.selectedItems() + if len(selected) == 0 { + return nil + } + if len(selected) == 1 { + return m.executeActionCmd(model.ActionDelete, selected[0]) + } + cmds := make([]tea.Cmd, len(selected)) + for i, it := range selected { + cmds[i] = m.executeActionCmd(model.ActionDelete, it) + } + return tea.Batch(cmds...) +} + func (m *Model) togglePane(which int) { switch which { case 1: @@ -587,11 +721,12 @@ func (m *Model) layoutViewports() { m.menuVP.Height = h } if pw.Visibility.List { - m.listTable.SetSize(m.paneInnerWidth(pw.List), h) + w := m.paneInnerWidth(pw.List) + m.listToolbar.SetWidth(w) + m.listTable.SetSize(w, h-listToolbarHeight) } if pw.Visibility.Detail { m.detail.SetSize(m.paneInnerWidth(pw.Detail), h) - m.filterSearch.SetSize(m.paneInnerWidth(pw.Detail), h) } } @@ -606,34 +741,14 @@ func (m *Model) syncListTable() { return } items := m.items - if m.filterActive { - items = model.FilterItems(m.items, m.filterSearch.Query()) + if q := m.listToolbar.Query(); q != "" { + items = model.FilterItems(m.items, q) } 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() + q := m.listToolbar.Query() m.nav.SetFilter(q) m.syncMenuContent() m.syncListTable() @@ -645,8 +760,8 @@ func (m Model) listTableItem() (model.Item, bool) { return model.Item{}, false } items := m.items - if m.filterActive { - items = model.FilterItems(m.items, m.filterSearch.Query()) + if q := m.listToolbar.Query(); q != "" { + items = model.FilterItems(m.items, q) } if idx >= len(items) { return model.Item{}, false @@ -657,7 +772,7 @@ func (m Model) listTableItem() (model.Item, bool) { func (m *Model) syncMenuContent() { var b strings.Builder title := "Navigation" - if m.filterActive && m.filterSearch.Query() != "" { + if m.listToolbar.Query() != "" { title += " (filtered)" } b.WriteString(title + "\n\n") @@ -705,6 +820,23 @@ func truncateRunes(s string, max int) string { return string(r[:max-1]) + "…" } +func (m Model) handleMenuHorizontal(delta int) (Model, tea.Cmd) { + cur := m.nav.Cursor() + switch delta { + case 1: + if spec, ok := m.nav.CurrentListSpec(); ok { + return m.withList(*spec) + } + m.nav.Activate() + case -1: + if m.nav.IsExpandable(cur) && m.nav.IsExpanded(cur) { + m.nav.ToggleExpand(cur) + } + } + m.syncMenuContent() + return m, nil +} + func (m *Model) syncFocusedPane() { switch m.focus { case model.FocusMenu: @@ -790,9 +922,6 @@ func (m *Model) loadNavProjectsCmd() tea.Cmd { } func (m *Model) onListRowChanged() tea.Cmd { - if m.filterActive { - return nil - } if !m.hasList { m.detail.Clear() return m.maybeLoadMoreList() @@ -814,7 +943,7 @@ func (m *Model) onListRowChanged() tea.Cmd { const mailListLoadThreshold = 3 func (m *Model) maybeLoadMoreList() tea.Cmd { - if !m.hasList || m.listLoadingMore || !m.listHasMore || m.filterActive { + if !m.hasList || m.listLoadingMore || !m.listHasMore { return nil } if !model.SubjectIsMail(m.listSpec.Subject) { @@ -850,6 +979,17 @@ func (m *Model) loadDetailCmd(item model.Item) tea.Cmd { } } +func (m *Model) closeTaskCmd() tea.Cmd { + item := m.detail.Item() + fields := m.detail.form.FormFields() + fields.TaskStatus = model.TaskLifecycleClosed + return func() tea.Msg { + ctx := context.Background() + err := m.loader.CloseTask(ctx, item.ID, fields) + return detailSavedMsg{item: item, fields: fields, err: err} + } +} + func (m *Model) saveDetailCmd() tea.Cmd { item := m.detail.Item() fields := m.detail.form.FormFields() @@ -874,6 +1014,12 @@ func (m *Model) updateItemAfterSave(item model.Item, fields model.FormFields) { if fields.HasStatus { m.items[i].Raw["status"] = statusInt(fields.Status) } + if fields.HasTaskStatus { + m.items[i].Raw["status"] = int(fields.TaskStatus) + } + if fields.ResponsibleID != "" { + m.items[i].Raw["responsibleIds"] = []any{fields.ResponsibleID} + } m.syncListTable() return } @@ -899,5 +1045,5 @@ func paneStyle(focused bool) lipgloss.Style { } func helpText() string { - 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" + return "Alt+1/2/3: panes Β· / filter Β· Space select Β· πŸ’ΎπŸ—‘ when selected Β· v: detail Β· Ctrl+S: save Β· q: quit" } diff --git a/cmd/office/ui/detail_tab_task_test.go b/cmd/office/ui/detail_tab_task_test.go new file mode 100644 index 0000000..cd40488 --- /dev/null +++ b/cmd/office/ui/detail_tab_task_test.go @@ -0,0 +1,41 @@ +package ui + +import ( + "testing" + + "github.com/eslider/go-onlyoffice/cmd/office/model" +) + +func TestDetailTabOrderTaskForm(t *testing.T) { + d := newDetailPane() + d.SetFocused(true) + d.LoadForm(model.Item{ID: "9", Kind: model.KindTask, Title: "T"}, model.FormFields{ + PrimaryLabel: "Title", SecondaryLabel: "Description", + Primary: "Alpha", Secondary: "Beta", + HasTaskStatus: true, TaskStatus: model.TaskLifecycleOpen, + HasResponsible: true, ResponsibleID: "u1", + UserChoices: []model.UserOption{{ID: "u1", Name: "Alice"}}, + ProjectTitle: "Proj", TimingSummary: "2026-01-01 β†’ 2026-02-01", + }) + + stops := []entityField{ + entityFieldPrimary, entityFieldSecondary, entityFieldStatus, entityFieldResponsible, + } + for i, want := range stops { + if d.form.field != want { + t.Fatalf("stop %d: field=%d want %d", i, d.form.field, want) + } + if d.TabForward() { + t.Fatalf("tab from stop %d should stay in pane", i) + } + } + if d.Zone() != detailZoneActions || d.actionIdx != 0 { + t.Fatalf("expected Save action, zone=%d idx=%d", d.Zone(), d.actionIdx) + } + if d.TabForward() { + t.Fatal("tab from Save should stay") + } + if d.actionIdx != 1 { + t.Fatalf("expected Close action, idx=%d", d.actionIdx) + } +} diff --git a/cmd/office/ui/detail_test.go b/cmd/office/ui/detail_test.go index 4b4916a..c5f69d6 100644 --- a/cmd/office/ui/detail_test.go +++ b/cmd/office/ui/detail_test.go @@ -30,8 +30,8 @@ func TestDetailPaneFormAndActions(t *testing.T) { Primary: "Hello", Secondary: "World", }) view := d.View() - if !strings.Contains(view, "Save") { - t.Fatal("expected Save action button") + if !strings.Contains(view, "Save") || !strings.Contains(view, "Close") { + t.Fatal("expected Save and Close action buttons") } if !strings.Contains(view, "Hello") { t.Fatal("expected form content") diff --git a/cmd/office/ui/entityform.go b/cmd/office/ui/entityform.go index 81fd229..9421a28 100644 --- a/cmd/office/ui/entityform.go +++ b/cmd/office/ui/entityform.go @@ -16,33 +16,48 @@ const ( entityFieldPrimary entityField = iota entityFieldSecondary entityFieldStatus + entityFieldResponsible ) // EntityForm is the top section of the detail pane for editable entities. type EntityForm struct { - active bool - kind model.Kind - itemID string - primaryLabel string - secondaryLabel string - readOnly bool - hasStatus bool - status model.ProjectLifecycle - responsibleID string - primary textinput.Model - secondary textarea.Model - field entityField - focused bool - dirty bool - width int - height int - styles entityFormStyles + active bool + kind model.Kind + itemID string + primaryLabel string + secondaryLabel string + readOnly bool + hasStatus bool + status model.ProjectLifecycle + hasTaskStatus bool + taskStatus model.TaskLifecycle + hasResponsible bool + responsibleID string + userChoices []model.UserOption + responsibleIdx int + projectTitle string + timingSummary string + hasUserEdit bool + userEnabled bool + userACL model.UserACLState + groupsText string + userFieldIdx int + primary textinput.Model + secondary textarea.Model + password textinput.Model + field entityField + focused bool + dirty bool + width int + height int + styles entityFormStyles } type entityFormStyles struct { header lipgloss.Style label lipgloss.Style labelAct lipgloss.Style + meta lipgloss.Style } func newEntityFormStyles() entityFormStyles { @@ -50,6 +65,7 @@ func newEntityFormStyles() entityFormStyles { header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")), label: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), labelAct: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")), + meta: lipgloss.NewStyle().Foreground(lipgloss.Color("245")), } } @@ -61,18 +77,30 @@ func newEntityForm() EntityForm { secondary.ShowLineNumbers = false secondary.CharLimit = 8000 secondary.Prompt = "> " + password := textinput.New() + password.CharLimit = 128 + password.EchoMode = textinput.EchoPassword + password.EchoCharacter = 'β€’' + password.Prompt = "> " return EntityForm{ - primary: primary, + primary: primary, secondary: secondary, - field: entityFieldPrimary, - styles: newEntityFormStyles(), + password: password, + field: entityFieldPrimary, + styles: newEntityFormStyles(), } } func (f *EntityForm) FieldCount() int { + if f.hasUserEdit { + return f.userFieldCount() + } n := 2 - if f.hasStatus { - n = 3 + if f.hasStatus || f.hasTaskStatus { + n++ + } + if f.hasResponsible { + n++ } return n } @@ -86,7 +114,18 @@ func (f *EntityForm) FormFields() model.FormFields { ReadOnly: f.readOnly, HasStatus: f.hasStatus, Status: f.status, + HasTaskStatus: f.hasTaskStatus, + TaskStatus: f.taskStatus, + HasResponsible: f.hasResponsible, ResponsibleID: f.responsibleID, + UserChoices: f.userChoices, + ProjectTitle: f.projectTitle, + TimingSummary: f.timingSummary, + HasUserEdit: f.hasUserEdit, + UserEnabled: f.userEnabled, + UserACL: f.userACL, + GroupsText: f.groupsText, + UserPassword: f.password.Value(), } } @@ -109,29 +148,66 @@ func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormField f.readOnly = fields.ReadOnly f.hasStatus = fields.HasStatus f.status = fields.Status + f.hasTaskStatus = fields.HasTaskStatus + f.taskStatus = fields.TaskStatus + f.hasResponsible = fields.HasResponsible + f.userChoices = fields.UserChoices + f.projectTitle = fields.ProjectTitle + f.timingSummary = fields.TimingSummary f.responsibleID = fields.ResponsibleID + f.responsibleIdx = indexUserChoice(fields.UserChoices, fields.ResponsibleID) + f.hasUserEdit = fields.HasUserEdit + f.userEnabled = fields.UserEnabled + f.userACL = copyUserACLState(fields.UserACL) + f.groupsText = fields.GroupsText + f.userFieldIdx = 0 f.primary.SetValue(fields.Primary) f.secondary.SetValue(fields.Secondary) + f.password.SetValue("") f.dirty = false - f.field = entityFieldPrimary - if f.hasStatus && f.readOnly { - f.field = entityFieldStatus + if f.hasUserEdit { + f.userFieldIdx = 0 + } else { + f.field = entityFieldPrimary } f.applyFocus() f.layoutFields() } +func indexUserChoice(choices []model.UserOption, id string) int { + for i, c := range choices { + if c.ID == id { + return i + } + } + return 0 +} + func (f *EntityForm) Clear() { f.active = false f.itemID = "" f.hasStatus = false + f.hasTaskStatus = false + f.hasResponsible = false f.status = model.ProjectLifecycleOpen + f.taskStatus = model.TaskLifecycleOpen f.responsibleID = "" + f.userChoices = nil + f.responsibleIdx = 0 + f.projectTitle = "" + f.timingSummary = "" + f.hasUserEdit = false + f.userEnabled = false + f.userACL = model.UserACLState{} + f.groupsText = "" + f.userFieldIdx = 0 f.primary.SetValue("") f.secondary.SetValue("") + f.password.SetValue("") f.dirty = false f.primary.Blur() f.secondary.Blur() + f.password.Blur() } func (f *EntityForm) SetFocused(on bool) { @@ -141,6 +217,7 @@ func (f *EntityForm) SetFocused(on bool) { } else { f.primary.Blur() f.secondary.Blur() + f.password.Blur() } } @@ -165,7 +242,28 @@ func (f *EntityForm) layoutFields() { inner = 8 } f.primary.Width = inner - secH := f.height - 6 + f.password.Width = inner + if f.hasUserEdit { + return + } + metaLines := 0 + if f.projectTitle != "" { + metaLines++ + } + if f.timingSummary != "" { + metaLines++ + } + if metaLines > 0 { + metaLines++ // blank line before meta + } + extra := 6 + metaLines + if f.hasStatus || f.hasTaskStatus { + extra += 2 + } + if f.hasResponsible { + extra += 2 + } + secH := f.height - extra if secH < 3 { secH = 3 } @@ -173,34 +271,49 @@ func (f *EntityForm) layoutFields() { f.secondary.SetHeight(secH) } -func (f *EntityForm) FocusNext() { - last := entityFieldSecondary - if f.hasStatus { - last = entityFieldStatus +func (f *EntityForm) lastField() entityField { + if f.hasResponsible { + return entityFieldResponsible } - if f.field < last { + if f.hasStatus || f.hasTaskStatus { + return entityFieldStatus + } + return entityFieldSecondary +} + +func (f *EntityForm) FocusNext() { + if f.field < f.lastField() { f.field++ } f.applyFocus() } func (f *EntityForm) FocusPrev() { - first := entityFieldPrimary - if f.field > first { + if f.field > entityFieldPrimary { f.field-- } f.applyFocus() } func (f *EntityForm) SetFieldIndex(i int) { + if f.hasUserEdit { + f.setUserFieldIndex(i) + return + } switch i { case 0: f.field = entityFieldPrimary case 1: f.field = entityFieldSecondary case 2: - if f.hasStatus { + if f.hasStatus || f.hasTaskStatus { f.field = entityFieldStatus + } else if f.hasResponsible { + f.field = entityFieldResponsible + } + case 3: + if f.hasResponsible && (f.hasStatus || f.hasTaskStatus) { + f.field = entityFieldResponsible } default: return @@ -209,7 +322,19 @@ func (f *EntityForm) SetFieldIndex(i int) { } func (f *EntityForm) cycleStatus(delta int) { - if !f.hasStatus || f.readOnly { + if f.readOnly { + return + } + if f.hasTaskStatus { + if delta > 0 { + f.taskStatus = f.taskStatus.Next() + } else { + f.taskStatus = f.taskStatus.Prev() + } + f.dirty = true + return + } + if !f.hasStatus { return } if delta > 0 { @@ -220,7 +345,36 @@ func (f *EntityForm) cycleStatus(delta int) { f.dirty = true } +func (f *EntityForm) cycleResponsible(delta int) { + if !f.hasResponsible || f.readOnly || len(f.userChoices) == 0 { + return + } + n := len(f.userChoices) + f.responsibleIdx = (f.responsibleIdx + delta%n + n) % n + f.responsibleID = f.userChoices[f.responsibleIdx].ID + f.dirty = true +} + +func (f *EntityForm) responsibleLabel() string { + if f.responsibleID == "" { + return "β€”" + } + for _, c := range f.userChoices { + if c.ID == f.responsibleID { + if c.Name != "" { + return c.Name + } + return c.ID + } + } + return f.responsibleID +} + func (f *EntityForm) applyFocus() { + if f.hasUserEdit { + f.applyUserFocus() + return + } if f.readOnly { f.primary.Blur() f.secondary.Blur() @@ -243,17 +397,35 @@ 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 + if f.hasUserEdit { + return f.updateUserForm(msg) + } + if key, ok := msg.(tea.KeyMsg); ok { + switch f.field { + case 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 + } + case entityFieldResponsible: + switch key.String() { + case "left", "h": + f.cycleResponsible(-1) + return nil + case "right", "l": + f.cycleResponsible(1) + return nil + case " ": + f.cycleResponsible(1) + return nil + } } } var cmd tea.Cmd @@ -275,6 +447,9 @@ func (f EntityForm) View() string { if !f.active { return "" } + if f.hasUserEdit { + return f.userView() + } header := f.styles.header.Render(model.KindHeading(f.kind, f.itemID)) pLabel := f.styles.label.Render(f.primaryLabel) if f.focused && f.field == entityFieldPrimary && !f.readOnly { @@ -284,43 +459,95 @@ 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") - } + statusBlock := f.renderStatusBlock() + responsibleBlock := f.renderResponsibleBlock() + metaBlock := f.renderMetaBlock() ro := "" if f.readOnly { ro = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" (read-only)") + "\n" } - return strings.Join([]string{ + parts := []string{ header + ro, pLabel, f.primary.View(), "", sLabel, f.secondary.View(), - statusBlock, + } + if statusBlock != "" { + parts = append(parts, statusBlock) + } + if responsibleBlock != "" { + parts = append(parts, responsibleBlock) + } + if metaBlock != "" { + parts = append(parts, metaBlock) + } + return strings.Join(parts, "\n") +} + +func (f EntityForm) renderStatusBlock() string { + if !f.hasStatus && !f.hasTaskStatus { + return "" + } + stLabel := f.styles.label.Render("Status") + if f.focused && f.field == entityFieldStatus && !f.readOnly { + stLabel = f.styles.labelAct.Render("Status") + } + label := f.status.Label() + valStyle := f.styles.label + if f.hasTaskStatus { + label = f.taskStatus.Label() + if f.taskStatus == model.TaskLifecycleClosed { + valStyle = valStyle.Foreground(lipgloss.Color("245")) + } else if f.taskStatus == model.TaskLifecycleOpen { + valStyle = valStyle.Foreground(lipgloss.Color("42")) + } + } else 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(" ←/β†’ cycle") + } + return strings.Join([]string{"", stLabel, valStyle.Render(label) + hint}, "\n") +} + +func (f EntityForm) renderResponsibleBlock() string { + if !f.hasResponsible { + return "" + } + rLabel := f.styles.label.Render("Responsible") + if f.focused && f.field == entityFieldResponsible && !f.readOnly { + rLabel = f.styles.labelAct.Render("Responsible") + } + hint := "" + if f.focused && f.field == entityFieldResponsible && !f.readOnly && len(f.userChoices) > 0 { + hint = f.styles.label.Render(" ←/β†’ choose") + } + return strings.Join([]string{ + "", + rLabel, + f.styles.meta.Render(f.responsibleLabel()) + hint, }, "\n") } +func (f EntityForm) renderMetaBlock() string { + if f.projectTitle == "" && f.timingSummary == "" { + return "" + } + lines := []string{""} + if f.projectTitle != "" { + lines = append(lines, f.styles.label.Render("Project"), f.styles.meta.Render(f.projectTitle)) + } + if f.timingSummary != "" { + lines = append(lines, f.styles.label.Render("Timing"), f.styles.meta.Render(f.timingSummary)) + } + return strings.Join(lines, "\n") +} + func entityFormBlinkCmd() tea.Cmd { return tea.Batch(textinput.Blink, textarea.Blink) } diff --git a/cmd/office/ui/entityform_user.go b/cmd/office/ui/entityform_user.go new file mode 100644 index 0000000..0db88bc --- /dev/null +++ b/cmd/office/ui/entityform_user.go @@ -0,0 +1,161 @@ +package ui + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/eslider/go-onlyoffice/cmd/office/model" +) + +func (f *EntityForm) userFieldCount() int { + return 2 + len(model.UserACLDefs) +} + +func (f *EntityForm) userView() string { + header := f.styles.header.Render(model.KindHeading(f.kind, f.itemID)) + lines := []string{ + header, + f.styles.label.Render("ID"), + f.styles.meta.Render(f.itemID), + "", + f.renderUserEnabledRow(), + "", + f.renderUserPasswordRow(), + } + for i, def := range model.UserACLDefs { + lines = append(lines, f.renderUserACLRow(i, def.Label, f.userACL.ACLModuleOn(def.Key))) + } + lines = append(lines, "", f.styles.label.Render("Groups"), f.styles.meta.Render(f.groupsText)) + return strings.Join(lines, "\n") +} + +func (f *EntityForm) renderUserEnabledRow() string { + label := f.styles.label.Render("Account") + if f.focused && f.userFieldIdx == 0 { + label = f.styles.labelAct.Render("Account") + } + val := "Disabled" + style := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + if f.userEnabled { + val = "Enabled" + style = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + } + hint := "" + if f.focused && f.userFieldIdx == 0 { + hint = f.styles.label.Render(" space toggle") + } + return strings.Join([]string{label, style.Render(val) + hint}, "\n") +} + +func (f *EntityForm) renderUserPasswordRow() string { + label := f.styles.label.Render("Password") + if f.focused && f.userFieldIdx == 1 { + label = f.styles.labelAct.Render("Password") + } + return strings.Join([]string{label, f.password.View(), f.styles.meta.Render("(leave blank to keep)")}, "\n") +} + +func (f *EntityForm) renderUserACLRow(idx int, title string, on bool) string { + fieldIdx := idx + 2 + label := f.styles.label.Render(title) + if f.focused && f.userFieldIdx == fieldIdx { + label = f.styles.labelAct.Render(title) + } + val := "Off" + style := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) + if on { + val = "On" + style = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + } + hint := "" + if f.focused && f.userFieldIdx == fieldIdx { + hint = f.styles.label.Render(" space toggle") + } + return strings.Join([]string{label, style.Render(val) + hint}, "\n") +} + +func (f *EntityForm) setUserFieldIndex(i int) { + if i < 0 { + i = 0 + } + max := f.userFieldCount() - 1 + if i > max { + i = max + } + f.userFieldIdx = i + f.applyFocus() +} + +func (f *EntityForm) toggleUserEnabled() { + f.userEnabled = !f.userEnabled + f.dirty = true +} + +func (f *EntityForm) toggleUserACL(idx int) { + if idx < 0 || idx >= len(model.UserACLDefs) { + return + } + if idx == 0 { + f.userACL.FullAccess = !f.userACL.FullAccess + if f.userACL.FullAccess { + for k := range f.userACL.Modules { + f.userACL.Modules[k] = true + } + } + f.dirty = true + return + } + def := model.UserACLDefs[idx] + if f.userACL.FullAccess { + f.userACL.FullAccess = false + for k := range f.userACL.Modules { + f.userACL.Modules[k] = true + } + } + f.userACL.Modules[def.Key] = !f.userACL.Modules[def.Key] + f.dirty = true +} + +func (f *EntityForm) updateUserForm(msg tea.Msg) tea.Cmd { + if key, ok := msg.(tea.KeyMsg); ok { + switch f.userFieldIdx { + case 0: + switch key.String() { + case " ", "left", "right", "l", "h": + f.toggleUserEnabled() + return nil + } + case 1: + var cmd tea.Cmd + f.password, cmd = f.password.Update(msg) + f.dirty = true + return cmd + default: + switch key.String() { + case " ", "left", "right", "l", "h": + f.toggleUserACL(f.userFieldIdx - 2) + return nil + } + } + } + return nil +} + +func (f *EntityForm) applyUserFocus() { + f.primary.Blur() + f.secondary.Blur() + if f.userFieldIdx == 1 { + f.password.Focus() + } else { + f.password.Blur() + } +} + +func copyUserACLState(s model.UserACLState) model.UserACLState { + modules := make(map[string]bool, len(s.Modules)) + for k, v := range s.Modules { + modules[k] = v + } + return model.UserACLState{FullAccess: s.FullAccess, Modules: modules} +} diff --git a/cmd/office/ui/entityform_user_test.go b/cmd/office/ui/entityform_user_test.go new file mode 100644 index 0000000..8f9d75b --- /dev/null +++ b/cmd/office/ui/entityform_user_test.go @@ -0,0 +1,57 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/eslider/go-onlyoffice/cmd/office/model" +) + +func TestUserFormViewShowsIDAndACL(t *testing.T) { + f := NewEntityFormForTest() + f.SetSize(60, 30) + f.Load(model.KindUser, "42", model.FormFields{ + HasUserEdit: true, + UserEnabled: true, + UserACL: model.UserACLState{ + FullAccess: false, + Modules: map[string]bool{ + "documents": true, + }, + }, + GroupsText: "Admins, Devs", + }) + view := f.View() + for _, want := range []string{"User 42", "ID", "42", "Account", "Password", "Full access", "Documents", "Groups", "Admins"} { + if !strings.Contains(view, want) { + t.Fatalf("view missing %q:\n%s", want, view) + } + } +} + +func TestUserFormFieldCount(t *testing.T) { + f := NewEntityFormForTest() + f.Load(model.KindUser, "1", model.FormFields{HasUserEdit: true, UserACL: model.UserACLFromRaw(nil)}) + if got := f.FieldCount(); got != 2+len(model.UserACLDefs) { + t.Fatalf("field count=%d want %d", got, len(model.UserACLDefs)+2) + } +} + +func TestLayoutUserTableEmailAbsorbsWidth(t *testing.T) { + cols := model.BuildColumns(model.SubjectUsers, nil) + lay := layoutUserTable(cols, 100) + sum := 0 + emailW := 0 + for _, i := range lay.indices { + sum += lay.widths[i] + if cols[i].Key == "email" { + emailW = lay.widths[i] + } + } + if sum != 100 { + t.Fatalf("sum=%d want 100", sum) + } + if emailW < 30 { + t.Fatalf("email width=%d should absorb extra space", emailW) + } +} diff --git a/cmd/office/ui/filter.go b/cmd/office/ui/filter.go index 483152d..d6a9873 100644 --- a/cmd/office/ui/filter.go +++ b/cmd/office/ui/filter.go @@ -25,7 +25,7 @@ type filterSearchStyles struct { func newFilterSearch() FilterSearch { in := textinput.New() in.Prompt = "/ " - in.Placeholder = "Filter navigation and list…" + in.Placeholder = "Filter list…" in.CharLimit = 256 return FilterSearch{ input: in, @@ -76,6 +76,17 @@ func (f *FilterSearch) Update(msg tea.Msg) tea.Cmd { return cmd } +func (f *FilterSearch) SetInputWidth(inner int) { + if inner < 8 { + inner = 8 + } + f.input.Width = inner +} + +func (f *FilterSearch) CompactView() string { + return f.input.View() +} + func (f FilterSearch) View() string { title := f.styles.title.Render("Filter") body := lipgloss.NewStyle().Width(f.width).Render(f.input.View()) diff --git a/cmd/office/ui/keys.go b/cmd/office/ui/keys.go index b3636a5..fa0591a 100644 --- a/cmd/office/ui/keys.go +++ b/cmd/office/ui/keys.go @@ -39,8 +39,13 @@ func KeyAction(key string, pane model.FocusPane) Action { } case "r": return ActionRefresh - case "f": - return ActionFilter + case "f", "/": + if pane == model.FocusList { + return ActionFilter + } + if pane == model.FocusMenu { + return ActionFilter + } case "up", "k": return ActionMoveUp case "down", "j": diff --git a/cmd/office/ui/layout_test.go b/cmd/office/ui/layout_test.go index 82841ce..f5586fd 100644 --- a/cmd/office/ui/layout_test.go +++ b/cmd/office/ui/layout_test.go @@ -1,9 +1,14 @@ package ui import ( + "regexp" + "strings" "testing" + "github.com/charmbracelet/lipgloss" "github.com/eslider/go-onlyoffice/cmd/office/model" + "github.com/mattn/go-runewidth" + "github.com/muesli/termenv" ) func TestLayoutWidthsAllVisibleUsesFullWidth(t *testing.T) { @@ -66,6 +71,53 @@ func TestPrevVisibleFocusSkipsHidden(t *testing.T) { } } +func TestThreePaneRenderedWidthMatchesTerminal(t *testing.T) { + prev := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(prev) }) + + total := 120 + pw := LayoutWidths(total, PaneVisibility{Menu: true, List: true, Detail: true}) + h := 12 + menu := paneStyle(false).Width(paneLipglossWidth(pw.Menu)).Height(h).Render(strings.Repeat("m", paneContentWidth(pw.Menu))) + inner := paneContentWidth(pw.List) + tbl := newDataTable() + tbl.SetSize(inner, h-listToolbarHeight) + tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{ + {ID: "1", Title: "Alpha", Kind: model.KindProject, Raw: map[string]any{"status": 0}}, + }) + toolbar := newListToolbar() + toolbar.SetWidth(inner) + body := lipgloss.JoinVertical(lipgloss.Left, toolbar.View(ListToolbarMeta{Subject: "projects", Count: 1}), tbl.View()) + list := paneStyle(true).Width(paneLipglossWidth(pw.List)).Height(h).Render(body) + detail := paneStyle(false).Width(paneLipglossWidth(pw.Detail)).Height(h).Render(strings.Repeat("d", paneContentWidth(pw.Detail))) + full := lipgloss.JoinHorizontal(lipgloss.Top, menu, list, detail) + re := regexp.MustCompile(`\x1b\[[0-9;]*m`) + top := strings.Split(full, "\n")[0] + got := runewidth.StringWidth(re.ReplaceAllString(top, "")) + if got != total { + t.Fatalf("top border width=%d want %d", got, total) + } + for _, line := range strings.Split(tbl.View(), "\n") { + if strings.TrimSpace(line) == "" { + continue + } + w := runewidth.StringWidth(re.ReplaceAllString(line, "")) + if w > inner+1 { + t.Fatalf("table line width=%d want <=%d", w, inner+1) + } + } +} + +func TestPaneContentWidth(t *testing.T) { + if got := paneContentWidth(72); got != 68 { + t.Fatalf("content width=%d want 68", got) + } + if got := paneLipglossWidth(72); got != 70 { + t.Fatalf("lipgloss width=%d want 70", got) + } +} + func TestLayoutWidthsLegacyHelper(t *testing.T) { menu, list, preview := LayoutWidthsLegacy(120) if menu+list+preview != 120 { diff --git a/cmd/office/ui/list_toolbar.go b/cmd/office/ui/list_toolbar.go new file mode 100644 index 0000000..fdaa34b --- /dev/null +++ b/cmd/office/ui/list_toolbar.go @@ -0,0 +1,352 @@ +package ui + +import ( + "fmt" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/eslider/go-onlyoffice/cmd/office/model" + "github.com/mattn/go-runewidth" +) + +const listToolbarHeight = 2 + +type listZone int + +const ( + listZoneTable listZone = iota + listZoneFilter + listZoneActions +) + +// ListToolbarMeta is the navigation/sort summary shown above the filter row. +type ListToolbarMeta struct { + Subject string + Count int + SortLabel string + LoadingMore bool + SaveEnabled bool + DeleteEnabled bool +} + +// ListToolbar is the filter & navigation bar above the center table. +type ListToolbar struct { + filter FilterSearch + width int + zone listZone + actionIdx int + actionRects []toolbarBtnRect + btnX0 int + styles listToolbarStyles +} + +type toolbarBtnRect struct { + action model.ActionID + x0, x1 int +} + +type listToolbarStyles struct { + title lipgloss.Style + btn lipgloss.Style + btnOn lipgloss.Style + btnOff lipgloss.Style + btnAct lipgloss.Style + btnDn lipgloss.Style +} + +func newListToolbar() ListToolbar { + return ListToolbar{ + filter: newFilterSearch(), + styles: listToolbarStyles{ + title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")), + btn: lipgloss.NewStyle().Padding(0, 1), + btnOn: lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("255")), + btnOff: lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("238")), + btnAct: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")), + btnDn: lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("52")), + }, + } +} + +func (b *ListToolbar) SetWidth(w int) { + if w < 12 { + w = 12 + } + b.width = w + inner := w - 8 + if inner < 12 { + inner = 12 + } + b.filter.SetInputWidth(inner) +} + +func (b *ListToolbar) Zone() listZone { return b.zone } + +func (b *ListToolbar) SetZone(z listZone) { b.zone = z } + +func (b *ListToolbar) Query() string { return b.filter.Query() } + +func (b *ListToolbar) ClearFilter() { b.filter.Clear() } + +func (b *ListToolbar) syncInputFocus() { + b.filter.SetFocused(b.zone == listZoneFilter) +} + +func (b *ListToolbar) FocusFilter() { + b.zone = listZoneFilter + b.filter.SetFocused(true) +} + +func (b *ListToolbar) FocusTable() { + b.zone = listZoneTable + b.filter.SetFocused(false) +} + +func (b *ListToolbar) TabForward(meta ListToolbarMeta) { + switch b.zone { + case listZoneTable: + b.FocusFilter() + case listZoneFilter: + b.zone = listZoneActions + b.actionIdx = 0 + b.filter.SetFocused(false) + b.clampActionIdx(meta) + case listZoneActions: + if b.nextEnabledAction(meta, 1) { + return + } + b.FocusTable() + } +} + +func (b *ListToolbar) TabBackward(meta ListToolbarMeta) { + switch b.zone { + case listZoneTable: + b.zone = listZoneActions + b.actionIdx = b.lastEnabledAction(meta) + b.filter.SetFocused(false) + case listZoneActions: + if b.nextEnabledAction(meta, -1) { + return + } + b.FocusFilter() + case listZoneFilter: + b.FocusTable() + } +} + +func (b *ListToolbar) toolbarActions(meta ListToolbarMeta) []model.ActionID { + var out []model.ActionID + if meta.SaveEnabled { + out = append(out, model.ActionSave) + } + if meta.DeleteEnabled { + out = append(out, model.ActionDelete) + } + return out +} + +func (b *ListToolbar) clampActionIdx(meta ListToolbarMeta) { + acts := b.toolbarActions(meta) + if len(acts) == 0 { + b.actionIdx = 0 + return + } + if b.actionIdx >= len(acts) { + b.actionIdx = len(acts) - 1 + } + if b.actionIdx < 0 { + b.actionIdx = 0 + } +} + +func (b *ListToolbar) lastEnabledAction(meta ListToolbarMeta) int { + acts := b.toolbarActions(meta) + if len(acts) == 0 { + return 0 + } + return len(acts) - 1 +} + +func (b *ListToolbar) nextEnabledAction(meta ListToolbarMeta, delta int) bool { + acts := b.toolbarActions(meta) + if len(acts) == 0 { + return false + } + next := b.actionIdx + delta + if next >= 0 && next < len(acts) { + b.actionIdx = next + return true + } + return false +} + +func (b *ListToolbar) SelectedAction(meta ListToolbarMeta) (model.ActionID, bool) { + acts := b.toolbarActions(meta) + if b.zone != listZoneActions || len(acts) == 0 { + return "", false + } + b.clampActionIdx(meta) + return acts[b.actionIdx], true +} + +func (b *ListToolbar) MoveAction(delta int, meta ListToolbarMeta) { + if b.zone != listZoneActions { + return + } + b.nextEnabledAction(meta, delta) +} + +func (b *ListToolbar) Update(msg tea.Msg, meta ListToolbarMeta) tea.Cmd { + if b.zone == listZoneFilter { + if key, ok := msg.(tea.KeyMsg); ok { + cmd := b.filter.Update(msg) + switch key.String() { + case "tab": + b.TabForward(meta) + return cmd + case "shift+tab", "backtab": + b.TabBackward(meta) + return cmd + case "enter": + b.zone = listZoneActions + b.actionIdx = 0 + b.filter.SetFocused(false) + b.clampActionIdx(meta) + return cmd + } + return cmd + } + return b.filter.Update(msg) + } + if b.zone == listZoneActions { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.String() { + case "left", "h": + b.MoveAction(-1, meta) + case "right", "l": + b.MoveAction(1, meta) + case "tab": + b.TabForward(meta) + case "shift+tab", "backtab": + b.TabBackward(meta) + } + } + } + return nil +} + +func (b *ListToolbar) View(meta ListToolbarMeta) string { + title := b.styles.title.Render(meta.Subject) + if meta.Count > 0 || meta.Subject != "" { + title = b.styles.title.Render(formatListToolbarTitle(meta)) + } + if meta.LoadingMore { + title += lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" …") + } + + filter := b.filter.CompactView() + buttons, rects := b.renderButtons(meta) + + avail := b.width - runewidth.StringWidth(buttons) - 1 + if avail < 8 { + avail = 8 + } + b.btnX0 = avail + 1 + for i := range rects { + rects[i].x0 += b.btnX0 + rects[i].x1 += b.btnX0 + } + b.actionRects = rects + + filterLine := padDisplayWidth(filter, avail) + " " + buttons + if runewidth.StringWidth(filterLine) > b.width { + filterLine = runewidth.Truncate(filterLine, b.width, "") + } + + lines := []string{ + padANSIWidth(title, b.width), + padANSIWidth(filterLine, b.width), + } + return lipgloss.NewStyle().Width(b.width).MaxWidth(b.width).Render(strings.Join(lines, "\n")) +} + +func formatListToolbarTitle(meta ListToolbarMeta) string { + s := meta.Subject + if meta.Count > 0 { + s = fmt.Sprintf("%s (%d)", meta.Subject, meta.Count) + } + if meta.SortLabel != "" { + s += " " + meta.SortLabel + } + return s +} + +func (b *ListToolbar) renderButtons(meta ListToolbarMeta) (string, []toolbarBtnRect) { + type btn struct { + action model.ActionID + icon string + enabled bool + danger bool + } + defs := []btn{ + {model.ActionSave, "πŸ’Ύ", meta.SaveEnabled, false}, + {model.ActionDelete, "πŸ—‘", meta.DeleteEnabled, true}, + } + acts := b.toolbarActions(meta) + rects := make([]toolbarBtnRect, 0, len(defs)) + parts := make([]string, 0, len(defs)) + x := 0 + for i, d := range defs { + active := b.zone == listZoneActions && len(acts) > 0 && b.actionIdx < len(acts) && acts[b.actionIdx] == d.action + var style lipgloss.Style + switch { + case !d.enabled: + style = b.styles.btnOff + case active && d.danger: + style = b.styles.btnDn + case active: + style = b.styles.btnAct + case d.danger: + style = b.styles.btn.Foreground(lipgloss.Color("203")) + default: + style = b.styles.btnOn + } + rendered := style.Render(d.icon) + w := runewidth.StringWidth(rendered) + rects = append(rects, toolbarBtnRect{action: d.action, x0: x, x1: x + w}) + x += w + parts = append(parts, rendered) + _ = i + } + return strings.Join(parts, " "), rects +} + +func (b *ListToolbar) ActionAt(x int, meta ListToolbarMeta) (model.ActionID, bool) { + _ = meta + if x < 0 { + return "", false + } + for _, r := range b.actionRects { + if x >= r.x0 && x < r.x1 { + action := r.action + if b.IsActionEnabled(action, meta) { + return action, true + } + return "", false + } + } + return "", false +} + +func (b *ListToolbar) IsActionEnabled(action model.ActionID, meta ListToolbarMeta) bool { + switch action { + case model.ActionSave: + return meta.SaveEnabled + case model.ActionDelete: + return meta.DeleteEnabled + default: + return false + } +} diff --git a/cmd/office/ui/list_toolbar_test.go b/cmd/office/ui/list_toolbar_test.go new file mode 100644 index 0000000..a4fb35d --- /dev/null +++ b/cmd/office/ui/list_toolbar_test.go @@ -0,0 +1,58 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/eslider/go-onlyoffice/cmd/office/model" +) + +func TestListToolbarShowsFilterAndActions(t *testing.T) { + b := newListToolbar() + b.SetWidth(60) + view := b.View(ListToolbarMeta{ + Subject: "projects", + Count: 3, + }) + if !strings.Contains(view, "projects (3)") { + t.Fatalf("missing subject: %q", view) + } + if !strings.Contains(view, "πŸ’Ύ") || !strings.Contains(view, "πŸ—‘") { + t.Fatalf("missing action icons: %q", view) + } +} + +func TestListToolbarActionsEnabledFromMeta(t *testing.T) { + b := newListToolbar() + on := ListToolbarMeta{SaveEnabled: true, DeleteEnabled: true} + off := ListToolbarMeta{} + if !b.IsActionEnabled(model.ActionSave, on) { + t.Fatal("save should be enabled") + } + if b.IsActionEnabled(model.ActionSave, off) { + t.Fatal("save should be disabled") + } + if !b.IsActionEnabled(model.ActionDelete, on) { + t.Fatal("delete should be enabled") + } +} + +func TestCanSaveFromListRequiresSelection(t *testing.T) { + m := Model{ + items: []model.Item{ + {ID: "1", Title: "A", Kind: model.KindProject, Selected: true}, + }, + } + if m.canSaveFromList() { + t.Fatal("save should require matching loaded detail") + } + m.detail = newDetailPane() + m.detail.SetSize(40, 20) + m.detail.LoadForm(model.Item{ID: "1", Kind: model.KindProject}, model.FormFields{ + PrimaryLabel: "Title", SecondaryLabel: "Description", + Primary: "A", HasStatus: true, + }) + if !m.canSaveFromList() { + t.Fatal("save should be enabled with one selected row and loaded detail") + } +} diff --git a/cmd/office/ui/scroll.go b/cmd/office/ui/scroll.go index d078b5a..f3ee6ad 100644 --- a/cmd/office/ui/scroll.go +++ b/cmd/office/ui/scroll.go @@ -8,7 +8,11 @@ import ( func (m *Model) scrollFocusedPane(key string) bool { switch m.focus { case model.FocusMenu: - return scrollViewport(&m.menuVP, key) + switch key { + case "pgdown", "pgdn", "ctrl+d", "pgup", "b", "ctrl+u", "home", "g", "end", "G": + return scrollViewport(&m.menuVP, key) + } + return false case model.FocusList: switch key { case "pgdown", "pgdn", "ctrl+d": @@ -37,17 +41,40 @@ func (m *Model) paneHeight() int { return h } -func (m *Model) paneInnerWidth(outer int) int { - if outer <= 0 { +const ( + paneBorderChars = 2 // left + right border drawn outside lipgloss Width + panePaddingChars = 2 // Padding(0, 1) on paneStyle +) + +// paneLipglossWidth maps an on-screen pane width to lipgloss Style.Width. +// Bordered panes render two cells wider than the Width value. +func paneLipglossWidth(rendered int) int { + if rendered <= 0 { return 8 } - w := outer - 2 // border only; content fills inner box + w := rendered - paneBorderChars if w < 8 { w = 8 } return w } +// paneContentWidth is the usable inner width for viewports and tables. +func paneContentWidth(rendered int) int { + if rendered <= 0 { + return 8 + } + w := rendered - paneBorderChars - panePaddingChars + if w < 8 { + w = 8 + } + return w +} + +func (m *Model) paneInnerWidth(rendered int) int { + return paneContentWidth(rendered) +} + func scrollViewport(vp *viewport.Model, key string) bool { switch key { case "up", "k": diff --git a/cmd/office/ui/scroll_menu_test.go b/cmd/office/ui/scroll_menu_test.go new file mode 100644 index 0000000..25fedb7 --- /dev/null +++ b/cmd/office/ui/scroll_menu_test.go @@ -0,0 +1,48 @@ +package ui + +import ( + "testing" + + "github.com/eslider/go-onlyoffice/cmd/office/model" +) + +func TestScrollFocusedPaneMenuUpDownMovesTree(t *testing.T) { + m := NewModel(nil) + m.focus = model.FocusMenu + m.syncMenuContent() + before := m.nav.Cursor() + + if m.scrollFocusedPane("down") { + t.Fatal("down should not be consumed by menu viewport scroll") + } + m.moveDown() + m.syncFocusedPane() + if m.nav.Cursor() != before+1 { + t.Fatalf("cursor=%d want %d", m.nav.Cursor(), before+1) + } +} + +func TestScrollFocusedPaneMenuPageKeysScrollViewport(t *testing.T) { + m := NewModel(nil) + m.focus = model.FocusMenu + for i := 0; i < 40; i++ { + m.nav.MoveDown() + } + m.syncMenuContent() + if !m.scrollFocusedPane("pgup") { + t.Fatal("pgup should scroll menu viewport") + } +} + +func TestHandleMenuHorizontalRightOpensLeaf(t *testing.T) { + m := NewModel(nil) + m.focus = model.FocusMenu + // cursor starts on Projects leaf + next, cmd := m.handleMenuHorizontal(1) + if cmd == nil { + t.Fatal("expected load list command") + } + if next.listSpec.Subject != model.SubjectProjects { + t.Fatalf("expected projects list, got %+v", next.listSpec) + } +} diff --git a/cmd/office/ui/scrollbar.go b/cmd/office/ui/scrollbar.go index e808cba..a88b9e5 100644 --- a/cmd/office/ui/scrollbar.go +++ b/cmd/office/ui/scrollbar.go @@ -4,7 +4,6 @@ import ( "strings" "github.com/charmbracelet/lipgloss" - "github.com/mattn/go-runewidth" ) // ScrollbarMetrics computes thumb position for a vertical scrollbar. @@ -49,7 +48,7 @@ func ApplyVerticalScrollbar(view string, width, height, totalLines, yOffset int) thumb := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) out := make([]string, height) for row := 0; row < height; row++ { - line := padDisplayWidth(lines[row], contentWidth) + line := padANSIWidth(lines[row], contentWidth) ch := "β”‚" style := track if row >= thumbStart && row < thumbEnd { @@ -62,15 +61,5 @@ func ApplyVerticalScrollbar(view string, width, height, totalLines, yOffset int) } 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 + return padANSIWidth(line, width) } diff --git a/cmd/office/ui/table.go b/cmd/office/ui/table.go index edcbbe6..535cb02 100644 --- a/cmd/office/ui/table.go +++ b/cmd/office/ui/table.go @@ -9,7 +9,6 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/eslider/go-onlyoffice/cmd/office/model" - "github.com/mattn/go-runewidth" ) type tableStyles struct { @@ -80,7 +79,7 @@ func (t *DataTable) SetSize(w, h int) { t.width = w t.height = h t.viewport.Width = w - t.viewport.Height = h - 2 // title + header + t.viewport.Height = h - 1 // column header if t.viewport.Height < 1 { t.viewport.Height = 1 } @@ -260,155 +259,63 @@ func (t *DataTable) Update(msg tea.Msg) tea.Cmd { return nil } +func (t DataTable) LoadingMore() bool { return t.loadingMore } + func (t DataTable) View() string { if !t.ready { - return "List\n\nSelect a leaf node in the tree\n(marked with β€’) and press Enter.\n" - } - title := t.styles.title.Render(fmt.Sprintf("%s (%d)", t.spec.Subject, len(t.items))) - if t.loadingMore { - title += t.styles.help.Render(" …") - } - sortHint := "" - if t.sortCol >= 0 && t.sortCol < len(t.columns) { - dir := "β–²" - if !t.sortAsc { - dir = "β–Ό" - } - sortHint = t.styles.help.Render(fmt.Sprintf(" sort: %s %s", t.columns[t.sortCol].Title, dir)) + return "\nSelect a leaf in the tree (β€’) and press Enter.\n" } header := t.renderHeader() + if t.hasVerticalScrollbar() { + header = padANSIWidth(header, t.lineContentWidth()) + } body := ApplyVerticalScrollbar( t.viewport.View(), - t.viewport.Width, + t.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) + return lipgloss.JoinVertical(lipgloss.Left, header, body) +} + +func (t DataTable) SortHint() string { + if t.sortCol < 0 || t.sortCol >= len(t.columns) { + return "" + } + dir := "β–²" + if !t.sortAsc { + dir = "β–Ό" + } + return fmt.Sprintf("sort: %s %s", t.columns[t.sortCol].Title, dir) +} + +func (t DataTable) SubjectLabel() string { + if !t.ready { + return "List" + } + return string(t.spec.Subject) +} + +func (t DataTable) ItemCount() int { + return len(t.items) +} + +func (t DataTable) hasVerticalScrollbar() bool { + return t.ready && t.viewport.TotalLineCount() > t.viewport.Height +} + +func (t DataTable) lineContentWidth() int { + w := t.width + if t.hasVerticalScrollbar() && w > 1 { + w-- + } + return w } func (t *DataTable) visibleLayout() (indices []int, widths map[int]int) { - widths = make(map[int]int) - if len(t.columns) == 0 || t.width <= 0 { - return nil, widths - } - indices = t.pickVisibleColumnIndices() - if len(indices) == 0 { - return indices, widths - } - minSum := 0 - for _, i := range indices { - minSum += t.columns[i].Width - } - widths = distributeColumnWidths(minSum, t.width, indices, t.columns) - return indices, widths -} - -func (t *DataTable) pickVisibleColumnIndices() []int { - var out []int - used := 0 - for colIdx := t.colScroll; colIdx < len(t.columns); colIdx++ { - w := t.columns[colIdx].Width - if len(out) > 0 && used+w > t.width { - break - } - out = append(out, colIdx) - used += w - } - if len(out) == 0 { - colIdx := t.colScroll - if colIdx < 0 || colIdx >= len(t.columns) { - colIdx = 0 - } - out = []int{colIdx} - } - return out -} - -// distributeColumnWidths expands or shrinks visible columns to exactly fill total width. -func distributeColumnWidths(minSum, total int, indices []int, cols []model.TableColumn) map[int]int { - out := make(map[int]int, len(indices)) - if len(indices) == 0 { - return out - } - if total < len(indices) { - total = len(indices) - } - if minSum <= 0 { - each := total / len(indices) - if each < 1 { - each = 1 - } - for _, i := range indices { - out[i] = each - } - fixColumnWidthSum(out, indices, total) - return out - } - for _, i := range indices { - out[i] = cols[i].Width - } - if minSum >= total { - for _, i := range indices { - out[i] = cols[i].Width * total / minSum - if out[i] < 1 { - out[i] = 1 - } - } - fixColumnWidthSum(out, indices, total) - return out - } - extra := total - minSum - flex := make([]int, 0, len(indices)) - for _, i := range indices { - switch cols[i].Key { - case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "tasks", "type": - flex = append(flex, i) - } - } - if len(flex) == 0 { - flex = append(flex, indices...) - } - flexSum := 0 - for _, i := range flex { - flexSum += cols[i].Width - } - if flexSum <= 0 { - flexSum = len(flex) - } - for _, i := range flex { - out[i] += extra * cols[i].Width / flexSum - } - fixColumnWidthSum(out, indices, total) - return out -} - -func fixColumnWidthSum(widths map[int]int, indices []int, total int) { - if len(indices) == 0 { - return - } - sum := 0 - for _, i := range indices { - sum += widths[i] - } - widths[indices[len(indices)-1]] += total - sum - if widths[indices[len(indices)-1]] < 1 { - widths[indices[len(indices)-1]] = 1 - } -} - -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, "...") + lay := t.computeLayout() + return lay.indices, lay.widths } func (t *DataTable) renderHeader() string { @@ -430,7 +337,7 @@ func (t *DataTable) renderHeader() string { if t.focused && colIdx == t.cursorCol { style = t.styles.headerSort } - cells = append(cells, style.Width(w).MaxWidth(w).Render(text)) + cells = append(cells, renderTableCell(style, text, w)) } return lipgloss.JoinHorizontal(lipgloss.Left, cells...) } @@ -450,30 +357,24 @@ 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] 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)) + text := truncateCellText(raw, w) + cells = append(cells, t.styleCell(row, colIdx, col.Key, selected, item, text, w)) } return lipgloss.JoinHorizontal(lipgloss.Left, cells...) } -func applyCellWidth(s lipgloss.Style, width int, full bool) lipgloss.Style { - if full { - return s +func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item model.Item, text string, width int) string { + if t.spec.Subject == model.SubjectProjects { + style := t.projectCellStyle(row, col, colKey, selected, item) + return renderTableCell(style, text, width) } - 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 @@ -495,29 +396,16 @@ func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item m default: 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")) + return renderTableCell(base, text, width) } func (t *DataTable) ensureColVisible() { - vis := t.pickVisibleColumnIndices() + if t.spec.Subject == model.SubjectProjects || t.spec.Subject == model.SubjectUsers { + t.colScroll = 0 + return + } + contentW := t.lineContentWidth() + vis := pickVisibleColumnIndices(t.columns, t.colScroll, contentW) if len(vis) == 0 { return } @@ -528,7 +416,7 @@ func (t *DataTable) ensureColVisible() { if t.cursorCol > last { t.colScroll = t.cursorCol for t.cursorCol >= 0 { - vis = t.pickVisibleColumnIndices() + vis = pickVisibleColumnIndices(t.columns, t.colScroll, contentW) if len(vis) == 0 { break } diff --git a/cmd/office/ui/table_layout.go b/cmd/office/ui/table_layout.go new file mode 100644 index 0000000..d983d0b --- /dev/null +++ b/cmd/office/ui/table_layout.go @@ -0,0 +1,165 @@ +package ui + +import ( + "github.com/eslider/go-onlyoffice/cmd/office/model" + "github.com/mattn/go-runewidth" +) + +type columnLayout struct { + indices []int + widths map[int]int +} + +func (t *DataTable) computeLayout() columnLayout { + contentW := t.lineContentWidth() + if len(t.columns) == 0 || contentW <= 0 { + return columnLayout{widths: map[int]int{}} + } + if t.spec.Subject == model.SubjectProjects { + return layoutProjectTable(t.columns, contentW) + } + if t.spec.Subject == model.SubjectUsers { + return layoutUserTable(t.columns, contentW) + } + return layoutScrollingTable(t.columns, t.colScroll, contentW) +} + +func layoutScrollingTable(cols []model.TableColumn, colScroll, contentW int) columnLayout { + indices := pickVisibleColumnIndices(cols, colScroll, contentW) + widths := make(map[int]int, len(indices)) + if len(indices) == 0 { + return columnLayout{indices: indices, widths: widths} + } + minSum := 0 + for _, i := range indices { + minSum += cols[i].Width + } + widths = distributeColumnWidths(minSum, contentW, indices, cols) + return columnLayout{indices: indices, widths: widths} +} + +func pickVisibleColumnIndices(cols []model.TableColumn, colScroll, contentW int) []int { + var out []int + used := 0 + for colIdx := colScroll; colIdx < len(cols); colIdx++ { + w := cols[colIdx].Width + if len(out) > 0 && used+w > contentW { + break + } + out = append(out, colIdx) + used += w + } + if len(out) == 0 { + colIdx := colScroll + if colIdx < 0 || colIdx >= len(cols) { + colIdx = 0 + } + out = []int{colIdx} + } + return out +} + +// distributeColumnWidths expands or shrinks visible columns to exactly fill total width. +func distributeColumnWidths(minSum, total int, indices []int, cols []model.TableColumn) map[int]int { + out := make(map[int]int, len(indices)) + if len(indices) == 0 { + return out + } + if total < len(indices) { + total = len(indices) + } + if minSum <= 0 { + each := total / len(indices) + if each < 1 { + each = 1 + } + for _, i := range indices { + out[i] = each + } + normalizeWidthSum(out, indices, total, indices[len(indices)-1]) + return out + } + for _, i := range indices { + out[i] = cols[i].Width + } + if minSum >= total { + for _, i := range indices { + out[i] = cols[i].Width * total / minSum + if out[i] < 1 { + out[i] = 1 + } + } + normalizeWidthSum(out, indices, total, flexColumnIndex(indices, cols)) + return out + } + extra := total - minSum + flex := flexColumnIndices(indices, cols) + flexSum := 0 + for _, i := range flex { + flexSum += cols[i].Width + } + if flexSum <= 0 { + flexSum = len(flex) + } + for _, i := range flex { + out[i] += extra * cols[i].Width / flexSum + } + normalizeWidthSum(out, indices, total, flexColumnIndex(indices, cols)) + return out +} + +func flexColumnIndices(indices []int, cols []model.TableColumn) []int { + flex := make([]int, 0, len(indices)) + for _, i := range indices { + switch cols[i].Key { + case "title", "subtitle", "description", "displayName", "primaryEmail", "from", "to", "type": + flex = append(flex, i) + } + } + if len(flex) == 0 { + flex = append(flex, indices...) + } + return flex +} + +func flexColumnIndex(indices []int, cols []model.TableColumn) int { + flex := flexColumnIndices(indices, cols) + if len(flex) == 0 { + return indices[len(indices)-1] + } + for _, i := range indices { + if cols[i].Key == "title" { + return i + } + } + return flex[0] +} + +func normalizeWidthSum(widths map[int]int, indices []int, total, adjustIdx int) { + if len(indices) == 0 { + return + } + sum := 0 + for _, i := range indices { + sum += widths[i] + } + if adjustIdx < 0 { + adjustIdx = indices[len(indices)-1] + } + widths[adjustIdx] += total - sum + if widths[adjustIdx] < 1 { + widths[adjustIdx] = 1 + } +} + +const cellHPadding = 0 // padding lives in outer lipgloss styles; column width is the full cell width + +func truncateCellText(text string, colWidth int) string { + if colWidth < 1 { + colWidth = 1 + } + if runewidth.StringWidth(text) <= colWidth { + return text + } + return runewidth.Truncate(text, colWidth, "…") +} diff --git a/cmd/office/ui/table_projects.go b/cmd/office/ui/table_projects.go new file mode 100644 index 0000000..5875266 --- /dev/null +++ b/cmd/office/ui/table_projects.go @@ -0,0 +1,114 @@ +package ui + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/eslider/go-onlyoffice/cmd/office/model" +) + +const ( + projectMinTitleWidth = 12 + projectMinFixedWidth = 3 +) + +// layoutProjectTable shows every project column; the title column absorbs leftover width. +func layoutProjectTable(cols []model.TableColumn, totalW int) columnLayout { + indices := make([]int, len(cols)) + for i := range cols { + indices[i] = i + } + widths := make(map[int]int, len(cols)) + + titleIdx := -1 + fixed := 0 + for i, col := range cols { + if col.Key == "title" { + titleIdx = i + continue + } + widths[i] = col.Width + fixed += widths[i] + } + if titleIdx < 0 { + return layoutScrollingTable(cols, 0, totalW) + } + + titleW := totalW - fixed + if titleW < projectMinTitleWidth { + shrinkProjectFixedColumns(widths, indices, titleIdx, fixed+projectMinTitleWidth-totalW) + fixed = 0 + for _, i := range indices { + if i != titleIdx { + fixed += widths[i] + } + } + titleW = totalW - fixed + if titleW < projectMinTitleWidth { + titleW = projectMinTitleWidth + } + } + widths[titleIdx] = titleW + normalizeWidthSum(widths, indices, totalW, titleIdx) + return columnLayout{indices: indices, widths: widths} +} + +func shrinkProjectFixedColumns(widths map[int]int, indices []int, titleIdx, need int) { + for need > 0 { + changed := false + for _, i := range indices { + if i == titleIdx || widths[i] <= projectMinFixedWidth { + continue + } + widths[i]-- + need-- + changed = true + if need == 0 { + return + } + } + if !changed { + return + } + } +} + +func (t *DataTable) projectBaseStyle(item model.Item) lipgloss.Style { + if model.ProjectIsOpen(item.Raw) { + return lipgloss.NewStyle(). + Background(lipgloss.Color("235")). + Foreground(lipgloss.Color("108")) + } + return lipgloss.NewStyle(). + Background(lipgloss.Color("238")). + Foreground(lipgloss.Color("252")) +} + +func (t *DataTable) projectCellStyle(row, col int, colKey string, selected bool, item model.Item) lipgloss.Style { + isRow := row == t.cursorRow + isCol := col == t.cursorCol + isCell := t.focused && isRow && isCol + + switch { + case selected && isCell: + return t.styles.cellSelect + case selected && isRow: + return t.styles.rowSelect + case selected: + return t.styles.rowSelect + case isCell: + return t.styles.cellActive + case t.focused && (isRow || isCol): + return t.projectStatusStyle(colKey, item, t.projectBaseStyle(item)) + default: + return t.projectStatusStyle(colKey, item, t.projectBaseStyle(item)) + } +} + +func (t *DataTable) projectStatusStyle(colKey string, item model.Item, base lipgloss.Style) lipgloss.Style { + if colKey != "status" { + return base + } + if model.ProjectIsOpen(item.Raw) { + return base.Foreground(lipgloss.Color("42")).Bold(true) + } + return base.Foreground(lipgloss.Color("245")) +} diff --git a/cmd/office/ui/table_projects_test.go b/cmd/office/ui/table_projects_test.go new file mode 100644 index 0000000..7c15b0d --- /dev/null +++ b/cmd/office/ui/table_projects_test.go @@ -0,0 +1,22 @@ +package ui + +import ( + "testing" + + "github.com/eslider/go-onlyoffice/cmd/office/model" +) + +func TestLayoutProjectTableAllColumnsVisible(t *testing.T) { + cols := model.BuildColumns(model.SubjectProjects, nil) + lay := layoutProjectTable(cols, 80) + if len(lay.indices) != 7 { + t.Fatalf("got %d visible columns, want 7", len(lay.indices)) + } + sum := 0 + for _, i := range lay.indices { + sum += lay.widths[i] + } + if sum != 80 { + t.Fatalf("width sum=%d want 80", sum) + } +} diff --git a/cmd/office/ui/table_render.go b/cmd/office/ui/table_render.go new file mode 100644 index 0000000..c93d42b --- /dev/null +++ b/cmd/office/ui/table_render.go @@ -0,0 +1,49 @@ +package ui + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/mattn/go-runewidth" +) + +// renderTableCell renders one column using the bubbles/table pattern: +// truncate plain text, constrain with an inline inner box, then apply the outer style. +func renderTableCell(outer lipgloss.Style, text string, width int) string { + if width < 1 { + width = 1 + } + frame := outer.GetHorizontalFrameSize() + innerW := width - frame + if innerW < 1 { + innerW = 1 + } + truncated := runewidth.Truncate(text, innerW, "…") + inner := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW).Inline(true) + return outer.Render(inner.Render(truncated)) +} + +// padANSIWidth pads or truncates a styled line without breaking ANSI sequences. +func padANSIWidth(line string, width int) string { + if width < 1 { + return "" + } + w := ansi.StringWidth(line) + if w > width { + return ansi.Truncate(line, width, "") + } + if w < width { + return line + repeatSpace(width-w) + } + return line +} + +func repeatSpace(n int) string { + if n <= 0 { + return "" + } + b := make([]byte, n) + for i := range b { + b[i] = ' ' + } + return string(b) +} diff --git a/cmd/office/ui/table_render_test.go b/cmd/office/ui/table_render_test.go new file mode 100644 index 0000000..45f1f26 --- /dev/null +++ b/cmd/office/ui/table_render_test.go @@ -0,0 +1,55 @@ +package ui + +import ( + "regexp" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/eslider/go-onlyoffice/cmd/office/model" + "github.com/mattn/go-runewidth" + "github.com/muesli/termenv" +) + +func TestProjectTableHeaderFitsPaneWidth(t *testing.T) { + prev := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(prev) }) + + for _, w := range []int{68, 50, 40, 30} { + tbl := newDataTable() + tbl.SetSize(w, 10) + tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{ + {ID: "1", Title: "Alpha", Kind: model.KindProject, Raw: map[string]any{"status": 0}}, + }) + tbl.SetFocused(true) + re := regexp.MustCompile(`\x1b\[[0-9;]*m`) + header := tbl.renderHeader() + got := runewidth.StringWidth(re.ReplaceAllString(header, "")) + if got != w { + t.Fatalf("width=%d header display width=%d", w, got) + } + view := tbl.View() + line0 := strings.Split(strings.TrimSuffix(view, "\n"), "\n")[0] + gotView := runewidth.StringWidth(re.ReplaceAllString(line0, "")) + if gotView < w-1 || gotView > w+1 { + t.Fatalf("width=%d view header width=%d", w, gotView) + } + } +} + +func TestPadANSIWidthPreservesStyledLine(t *testing.T) { + prev := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(prev) }) + + line := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Render("ID") + padded := padANSIWidth(line, 10) + re := regexp.MustCompile(`\x1b\[[0-9;]*m`) + if runewidth.StringWidth(re.ReplaceAllString(padded, "")) != 10 { + t.Fatalf("expected padded width 10, got %q", padded) + } + if !strings.Contains(padded, "ID") { + t.Fatalf("padding must not destroy cell text: %q", padded) + } +} diff --git a/cmd/office/ui/table_test.go b/cmd/office/ui/table_test.go index 4394448..b328837 100644 --- a/cmd/office/ui/table_test.go +++ b/cmd/office/ui/table_test.go @@ -1,11 +1,14 @@ package ui import ( + "regexp" "strings" "testing" + "github.com/charmbracelet/lipgloss" "github.com/eslider/go-onlyoffice/cmd/office/model" "github.com/mattn/go-runewidth" + "github.com/muesli/termenv" ) func sampleItems() []model.Item { @@ -89,13 +92,121 @@ func TestDistributeColumnWidthsFillsTotal(t *testing.T) { } } +func TestProjectTableTitleAbsorbsWidth(t *testing.T) { + cols := model.BuildColumns(model.SubjectProjects, nil) + lay := layoutProjectTable(cols, 120) + if len(lay.indices) != len(cols) { + t.Fatalf("expected all %d columns visible, got %d", len(cols), len(lay.indices)) + } + sum := 0 + titleW := 0 + fixedSum := 0 + titleIdx := indexOfColumnKey(cols, "title") + for _, i := range lay.indices { + sum += lay.widths[i] + if i == titleIdx { + titleW = lay.widths[i] + continue + } + fixedSum += lay.widths[i] + } + if sum != 120 { + t.Fatalf("sum=%d want 120", sum) + } + if titleW != 120-fixedSum { + t.Fatalf("title width=%d want remainder %d", titleW, 120-fixedSum) + } + if titleW < 40 { + t.Fatalf("title should absorb extra width, got %d", titleW) + } +} + +func TestProjectTableFillsPaneWidth(t *testing.T) { + tbl := newDataTable() + tbl.SetSize(80, 12) + tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{ + {ID: "1", Title: "Alpha", Kind: model.KindProject, Raw: map[string]any{"status": 0}}, + }) + indices, widths := tbl.visibleLayout() + if len(indices) != len(tbl.columns) { + t.Fatalf("expected all columns visible, got %d/%d", len(indices), len(tbl.columns)) + } + sum := 0 + for _, i := range indices { + sum += widths[i] + } + if sum != 80 { + t.Fatalf("visible columns width sum=%d want 80", sum) + } +} + +func TestProjectRowRenderWidth(t *testing.T) { + prev := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(prev) }) + + tbl := newDataTable() + tbl.SetSize(120, 20) + tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{ + {ID: "abc12345", Title: "My Project Title", Kind: model.KindProject, Raw: map[string]any{"status": 0}}, + }) + re := regexp.MustCompile(`\x1b\[[0-9;]*m`) + _, widths := tbl.visibleLayout() + want := 0 + for _, w := range widths { + want += w + } + row := re.ReplaceAllString(tbl.renderRow(0), "") + got := runewidth.StringWidth(row) + if got < want-2 || got > want+2 { + t.Fatalf("row display width=%d want ~%d", got, want) + } +} + +func TestProjectRowStaysSingleLineWhenSelected(t *testing.T) { + prev := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(prev) }) + + long := strings.Repeat("X", 200) + tbl := newDataTable() + tbl.SetSize(120, 20) + tbl.SetData(model.ListSpec{Subject: model.SubjectProjects}, []model.Item{ + {ID: "1", Title: long, Kind: model.KindProject, Raw: map[string]any{"status": 0}, Selected: true}, + }) + if strings.Contains(tbl.renderRow(0), "\n") { + t.Fatal("selected project row must not wrap to multiple lines") + } +} + +func TestProjectRowStyleUniformBackground(t *testing.T) { + prev := lipgloss.ColorProfile() + lipgloss.SetColorProfile(termenv.TrueColor) + t.Cleanup(func() { lipgloss.SetColorProfile(prev) }) + + tbl := newDataTable() + item := model.Item{Raw: map[string]any{"status": 0}} + base := tbl.projectBaseStyle(item) + titleStyled := tbl.projectStatusStyle("title", item, base).Render("Open Proj") + statusStyled := tbl.projectStatusStyle("status", item, base).Render(model.ProjectStatusCell(item.Raw)) + if !strings.Contains(titleStyled, "\x1b[") { + t.Fatalf("title cell should be styled: %q", titleStyled) + } + if !strings.Contains(statusStyled, "\x1b[") { + t.Fatalf("status cell should be styled: %q", statusStyled) + } + if titleStyled == statusStyled { + t.Fatal("status and title cells should use different foreground styles") + } +} + 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, "...") { + if !strings.HasSuffix(got, "…") { t.Fatalf("expected ellipsis suffix, got %q", got) } } @@ -116,8 +227,26 @@ func TestDataTableTruncatesNonSelectedRows(t *testing.T) { } tbl.cursorRow = 1 - if !strings.Contains(tbl.renderRow(1), long) { - t.Fatal("cursor row should show full title") + if !strings.Contains(tbl.renderRow(1), strings.Repeat("A", 10)) { + t.Fatal("cursor row should show title text within column") + } +} + +func TestDataTableRowWidthMatchesColumns(t *testing.T) { + tbl := newDataTable() + tbl.SetSize(80, 12) + tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, sampleItems()) + tbl.SetFocused(true) + _, widths := tbl.visibleLayout() + want := 0 + for _, w := range widths { + want += w + } + re := regexp.MustCompile(`\x1b\[[0-9;]*m`) + row := re.ReplaceAllString(tbl.renderRow(0), "") + got := runewidth.StringWidth(row) + if got < want-2 || got > want+2 { + t.Fatalf("row display width=%d want ~%d", got, want) } } @@ -133,8 +262,8 @@ func TestDataTableShowsFullTextWhenSpaceSelected(t *testing.T) { tbl.cursorRow = 0 rowSelected := tbl.renderRow(1) - if !strings.Contains(rowSelected, long) { - t.Fatal("space-selected row should show full title") + if !strings.Contains(rowSelected, strings.Repeat("B", 10)) { + t.Fatal("space-selected row should show title text within column") } } @@ -146,3 +275,12 @@ func indexOfColumn(tbl DataTable, key string) int { } return 0 } + +func indexOfColumnKey(cols []model.TableColumn, key string) int { + for i, c := range cols { + if c.Key == key { + return i + } + } + return 0 +} diff --git a/cmd/office/ui/table_users.go b/cmd/office/ui/table_users.go new file mode 100644 index 0000000..7b1b9b7 --- /dev/null +++ b/cmd/office/ui/table_users.go @@ -0,0 +1,69 @@ +package ui + +import "github.com/eslider/go-onlyoffice/cmd/office/model" + +const ( + userMinEmailWidth = 16 + userMinFixedWidth = 3 +) + +// layoutUserTable shows every user column; email absorbs leftover width. +func layoutUserTable(cols []model.TableColumn, totalW int) columnLayout { + indices := make([]int, len(cols)) + for i := range cols { + indices[i] = i + } + widths := make(map[int]int, len(cols)) + + emailIdx := -1 + fixed := 0 + for i, col := range cols { + if col.Key == "email" { + emailIdx = i + continue + } + widths[i] = col.Width + fixed += widths[i] + } + if emailIdx < 0 { + return layoutScrollingTable(cols, 0, totalW) + } + + emailW := totalW - fixed + if emailW < userMinEmailWidth { + shrinkUserFixedColumns(widths, indices, emailIdx, fixed+userMinEmailWidth-totalW) + fixed = 0 + for _, i := range indices { + if i != emailIdx { + fixed += widths[i] + } + } + emailW = totalW - fixed + if emailW < userMinEmailWidth { + emailW = userMinEmailWidth + } + } + widths[emailIdx] = emailW + normalizeWidthSum(widths, indices, totalW, emailIdx) + return columnLayout{indices: indices, widths: widths} +} + +func shrinkUserFixedColumns(widths map[int]int, indices []int, emailIdx, need int) { + for need > 0 { + changed := false + for _, i := range indices { + if i == emailIdx || widths[i] <= userMinFixedWidth { + continue + } + widths[i]-- + need-- + changed = true + if need == 0 { + return + } + } + if !changed { + return + } + } +} diff --git a/http.go b/http.go index 822a4a6..3d94999 100644 --- a/http.go +++ b/http.go @@ -113,7 +113,8 @@ func (c *Client) deleteObject(ctx context.Context, path string) (map[string]any, // unmarshalResponseObject extracts the "response" field from a raw OnlyOffice // envelope and decodes it into map[string]any. Returns (nil, nil) for a null -// response and (nil, err) when the field is missing or malformed. +// response, an empty array, or scalar payloads. When the API returns a list +// (for example PUT /people/status/...), the first element is returned. func unmarshalResponseObject(raw json.RawMessage) (map[string]any, error) { resp, err := responseField(raw, "response") if err != nil { @@ -122,11 +123,25 @@ func unmarshalResponseObject(raw json.RawMessage) (map[string]any, error) { if len(resp) == 0 || string(resp) == "null" { return nil, nil } - var out map[string]any - if err := json.Unmarshal(resp, &out); err != nil { - return nil, err + switch resp[0] { + case '{': + var out map[string]any + if err := json.Unmarshal(resp, &out); err != nil { + return nil, err + } + return out, nil + case '[': + var list []map[string]any + if err := json.Unmarshal(resp, &list); err != nil { + return nil, err + } + if len(list) == 0 { + return nil, nil + } + return list[0], nil + default: + return nil, nil } - return out, nil } // getJSON issues an authenticated GET and returns the raw response body. diff --git a/http_test.go b/http_test.go new file mode 100644 index 0000000..ed51c11 --- /dev/null +++ b/http_test.go @@ -0,0 +1,50 @@ +package onlyoffice + +import ( + "encoding/json" + "testing" +) + +func TestUnmarshalResponseObjectMap(t *testing.T) { + raw := json.RawMessage(`{"response":{"id":"1","isAdmin":true}}`) + out, err := unmarshalResponseObject(raw) + if err != nil { + t.Fatal(err) + } + if out["id"] != "1" || out["isAdmin"] != true { + t.Fatalf("unexpected map: %#v", out) + } +} + +func TestUnmarshalResponseObjectArray(t *testing.T) { + raw := json.RawMessage(`{"response":[{"id":"a"},{"id":"b"}]}`) + out, err := unmarshalResponseObject(raw) + if err != nil { + t.Fatal(err) + } + if out["id"] != "a" { + t.Fatalf("expected first array element, got %#v", out) + } +} + +func TestUnmarshalResponseObjectEmptyArray(t *testing.T) { + raw := json.RawMessage(`{"response":[]}`) + out, err := unmarshalResponseObject(raw) + if err != nil { + t.Fatal(err) + } + if out != nil { + t.Fatalf("expected nil for empty array, got %#v", out) + } +} + +func TestUnmarshalResponseObjectNull(t *testing.T) { + raw := json.RawMessage(`{"response":null}`) + out, err := unmarshalResponseObject(raw) + if err != nil { + t.Fatal(err) + } + if out != nil { + t.Fatalf("expected nil, got %#v", out) + } +} diff --git a/users.go b/users.go index 0d1af46..8d759e7 100644 --- a/users.go +++ b/users.go @@ -5,6 +5,8 @@ package onlyoffice import ( "context" "encoding/json" + "fmt" + "net/url" "time" ) @@ -39,6 +41,7 @@ type User struct { MailSpace *int `json:"mailSpace,omitempty"` TalkSpace *int `json:"talkSpace,omitempty"` ProfileURL *string `json:"profileUrl,omitempty"` + RegistrationDate *time.Time `json:"registrationDate,omitempty"` Title *string `json:"title,omitempty"` Sex *string `json:"sex,omitempty"` Lead *string `json:"lead,omitempty"` @@ -71,6 +74,38 @@ func (c *Client) GetUsers() (list []*User, err error) { }{Response: &list}) } +// GetUser returns one portal user profile by ID. +func (c *Client) GetUser(ctx context.Context, userID string) (map[string]any, error) { + return c.ResponseObject(ctx, fmt.Sprintf("/api/2.0/people/%s.json", url.PathEscape(userID))) +} + +// UpdateUser updates portal user profile fields (admin ACL, etc.). Do not send +// employee status here β€” use ChangeUserStatus instead. +func (c *Client) UpdateUser(ctx context.Context, userID string, body map[string]any) (map[string]any, error) { + return c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/people/%s", url.PathEscape(userID)), body) +} + +// ChangeUserStatus activates or terminates a user via the status API. +func (c *Client) ChangeUserStatus(ctx context.Context, userID string, active bool) error { + status := "Terminated" + if active { + status = "Active" + } + _, err := c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/people/status/%s", status), map[string]any{ + "userIds": []string{userID}, + "resendAll": false, + }) + return err +} + +// ChangeUserPassword sets a new password for the user. +func (c *Client) ChangeUserPassword(ctx context.Context, userID, password string) error { + _, err := c.putJSONObject(ctx, fmt.Sprintf("/api/2.0/people/%s/password", url.PathEscape(userID)), map[string]string{ + "password": password, + }) + return err +} + // SelfUserID returns the ID of the authenticated user (people/@self), cached. func (c *Client) SelfUserID(ctx context.Context) (string, error) { if c.selfID != "" {