Compare commits

..
7 Commits
Author SHA1 Message Date
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
e3c706aefe chore(main): release 0.8.0 (#8)
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 37s
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-23 14:48:32 +01:00
eSliderandGitHub 6ea76c64c3 Merge pull request #5 from eSlider/release-please--branches--main--components--go-onlyoffice
chore(main): release 0.7.0
2026-07-23 14:46:30 +01:00
github-actions[bot]andGitHub 8804bd3030 chore(main): release 0.7.0 2026-07-23 13:46:17 +00:00
eSliderandGitHub c8b30a36ee Merge pull request #7 from eSlider/fix/oo-applications-discover
fix(oo): skip junk dirs in applications Discover
2026-07-23 14:45:57 +01:00
eSlider 3969d045cc fix(oo): skip junk dirs in applications Discover
Stop inventing CRM companies from node_modules/tools README trees.
Only sync dossier-like folders (source-NNN-…) and invent Company from
the slug token after the numeric id.
2026-07-23 14:43:45 +01:00
eSliderandCursor 09e242d7c3 refactor(office): generalize DataTable layout and document TUI table skill
Unify project/user flex column layout into layoutFlexTable with per-subject
policy in model. Add office-tui-table skill and AGENTS notes; fix CI go mod
tidy drift and test matrix Go version to match go.mod 1.25.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 11:21:20 +01:00
eSliderandCursor 0ed2d9b0f5 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 <cursoragent@cursor.com>
2026-06-25 11:07:22 +01:00
54 changed files with 3138 additions and 444 deletions
+75
View File
@@ -0,0 +1,75 @@
---
name: office-tui-table
description: >-
Build and fix Bubble Tea list tables in cmd/office (DataTable, column layout,
lipgloss cell rendering, pane width). Use when changing office center-pane
tables, column widths, ANSI styling, or list toolbar alignment.
---
# office TUI table (`cmd/office/ui`)
## Architecture
| Piece | File | Role |
|-------|------|------|
| `DataTable` | `table.go` | cursor, sort, viewport, header/row render |
| Column layout | `table_layout.go` | flex vs horizontal-scroll policies |
| Flex policy | `model/table_layout.go` | per-subject flex column key + min width |
| Cell render | `table_render.go` | bubbles/table pattern + ANSI-safe padding |
| Subject row style | `table_projects.go` | optional per-subject cell styles |
| Pane sizing | `scroll.go` | `paneContentWidth`, `paneLipglossWidth` |
| Scrollbar pad | `scrollbar.go` | `padANSIWidth` for styled lines |
## Layout policies
1. **Flex (all columns visible)** — register in `model.TableFlexLayoutFor`:
- fixed columns keep `TableColumn.Width`
- one `FlexColumnKey` (e.g. `title`, `email`) absorbs remainder
- `layoutFlexTable` shrinks fixed cols down to `minFixedColumnWidth` before flex min
2. **Scrolling (default)** — `layoutScrollingTable`:
- horizontal `colScroll`; subset of columns visible
- `distributeColumnWidths` fills pane width
Add a new flex subject: one entry in `TableFlexLayoutFor` only — do **not** copy `layoutProjectTable`/`layoutUserTable` per subject.
## Cell rendering (required pattern)
Never `style.Width(w).Padding(0,1).Render(text)` on styled cells — padding doubles display width and breaks `JoinHorizontal`.
Use `renderTableCell` from `table_render.go` (matches `charmbracelet/bubbles/table`):
```go
renderTableCell(outerStyle, plainText, columnWidth)
```
- truncate **plain** text with `runewidth.Truncate` inside `renderTableCell`
- inner box: `Width(innerW).Inline(true)` where `innerW = width - outer.GetHorizontalFrameSize()`
- outer style carries padding, background, foreground
## ANSI rules
- **Never** `runewidth.Truncate` / `padDisplayWidth` on strings that already contain lipgloss ANSI codes — corrupts escapes and shows garbage like lone `ID`.
- Use `padANSIWidth` (`charmbracelet/x/ansi`) for styled lines (headers, toolbar, scrollbar rows).
- Do not wrap an already full-width styled header in another `lipgloss.Width()` — pad once.
## Pane width
Lipgloss bordered panes render **2 cells wider** than `Style.Width`:
- `paneLipglossWidth(rendered) = rendered - 2` for `.Width()` on pane style
- `paneContentWidth(rendered) = rendered - 4` for table/viewport inner size (border + padding)
Pass `paneContentWidth(pw.List)` to `DataTable.SetSize` and list toolbar.
## Tests to add when touching tables
- `layoutFlexTable` width sum equals pane width
- `TestProjectTableHeaderFitsPaneWidth` / per-line width at 68, 40, 30
- `TestPadANSIWidthPreservesStyledLine`
- `TestThreePaneRenderedWidthMatchesTerminal` top border + table lines ≤ inner width
## Anti-patterns
- Per-subject duplicate layout files (`table_foo.go` with copy-pasted shrink loops)
- Skipping truncation on selected rows (causes multi-line rows in viewport)
- Using `bubbles/table` widget directly — office needs column cursor, multi-select, subject-specific row styles
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
go: ["1.22", "stable"] go: ["1.25", "stable"]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "0.6.0" ".": "0.8.0"
} }
+1
View File
@@ -17,6 +17,7 @@ Canonical Go client for OnlyOffice Workspace (Projects + Calendar + CRM) and the
- `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm_tasks.go`, `apps.go` — one file per subject (or per subject facet), each registers in `init()`. - `calendar.go`, `projects.go`, `projects_files.go`, `tasks.go`, `tasks_files.go`, `users.go`, `contacts.go`, `opportunities.go`, `cases.go`, `crm_tasks.go`, `apps.go` — one file per subject (or per subject facet), each registers in `init()`.
- CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library. - CLI-only deps (`spf13/cobra`, `joho/godotenv`) stay out of the library.
- **TUI — `cmd/office/` as `package main`.** Bubble Tea three-pane browser (module tree, selectable list, markdown preview). Reuses `cmd/internal/bootstrap` for env/auth and the root `onlyoffice` library for all API calls. UI logic in `cmd/office/ui/`; preview/formatting in `cmd/office/preview/`; list loaders in `cmd/office/fetch/`. - **TUI — `cmd/office/` as `package main`.** Bubble Tea three-pane browser (module tree, selectable list, markdown preview). Reuses `cmd/internal/bootstrap` for env/auth and the root `onlyoffice` library for all API calls. UI logic in `cmd/office/ui/`; preview/formatting in `cmd/office/preview/`; list loaders in `cmd/office/fetch/`.
- **List table (`DataTable`)** — `cmd/office/ui/table*.go`. Column layout policies live in `cmd/office/model/table_layout.go` (`TableFlexLayoutFor`); cell rendering uses the bubbles/table inline pattern in `table_render.go` (`renderTableCell`, `padANSIWidth`). See `.cursor/skills/office-tui-table/SKILL.md` before changing center-pane tables.
- **Shared bootstrap — `cmd/internal/bootstrap/`.** `LoadEnv()` + `NewClient(ctx)` extracted from `oo`; both binaries import it. - **Shared bootstrap — `cmd/internal/bootstrap/`.** `LoadEnv()` + `NewClient(ctx)` extracted from `oo`; both binaries import it.
- **Applications sync — `cmd/oo/applications/`.** README→CRM bridge, CV-specific; kept under `cmd/oo/` so it's clear it's internal to the binary, not a library feature. - **Applications sync — `cmd/oo/applications/`.** README→CRM bridge, CV-specific; kept under `cmd/oo/` so it's clear it's internal to the binary, not a library feature.
+39
View File
@@ -4,6 +4,45 @@ All notable changes to this project are documented here. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.8.0](https://github.com/eSlider/go-onlyoffice/compare/v0.7.0...v0.8.0) (2026-07-23)
### Features
* **office:** users admin UI, table rendering, and user save fixes ([40b74eb](https://github.com/eSlider/go-onlyoffice/commit/40b74eb8abf4d63793a2cb9e59bd899734368b7e))
* **search:** add produktor SearXNG JSON client ([79b1a6b](https://github.com/eSlider/go-onlyoffice/commit/79b1a6b2c397c2a8f8aeef7e2d8e6d81e0a93db2))
* **search:** add produktor SearXNG JSON client ([bf144f2](https://github.com/eSlider/go-onlyoffice/commit/bf144f2bb37c44aa37ff33c6b03f59d7b5023dc7))
### Bug Fixes
* **oo:** skip junk dirs in applications Discover ([470ac93](https://github.com/eSlider/go-onlyoffice/commit/470ac938b1e388a17aabc5146cd62070dbb00587))
* **oo:** skip junk dirs in applications Discover ([830f0c7](https://github.com/eSlider/go-onlyoffice/commit/830f0c7208f94521a59dfe6f96d496509e6b29e9))
### Code Refactoring
* **office:** generalize DataTable layout and document TUI table skill ([38932b0](https://github.com/eSlider/go-onlyoffice/commit/38932b0e0eb9696c41a48ad74c943034ebed59e5))
## [0.7.0](https://github.com/eSlider/go-onlyoffice/compare/v0.6.0...v0.7.0) (2026-07-23)
### Features
* **office:** mail preview, infinite scroll, pane resize, and scrollbars ([72844df](https://github.com/eSlider/go-onlyoffice/commit/72844dfe243e04c61ea9bc4db66c55abc08d3ae8))
* **office:** users admin UI, table rendering, and user save fixes ([40b74eb](https://github.com/eSlider/go-onlyoffice/commit/40b74eb8abf4d63793a2cb9e59bd899734368b7e))
### Bug Fixes
* **oo:** skip junk dirs in applications Discover ([470ac93](https://github.com/eSlider/go-onlyoffice/commit/470ac938b1e388a17aabc5146cd62070dbb00587))
* **oo:** skip junk dirs in applications Discover ([830f0c7](https://github.com/eSlider/go-onlyoffice/commit/830f0c7208f94521a59dfe6f96d496509e6b29e9))
### Code Refactoring
* **office:** generalize DataTable layout and document TUI table skill ([38932b0](https://github.com/eSlider/go-onlyoffice/commit/38932b0e0eb9696c41a48ad74c943034ebed59e5))
## [0.6.0](https://github.com/eSlider/go-onlyoffice/compare/v0.5.0...v0.6.0) (2026-06-24) ## [0.6.0](https://github.com/eSlider/go-onlyoffice/compare/v0.5.0...v0.6.0) (2026-06-24)
+5 -1
View File
@@ -751,7 +751,11 @@ oo applications sync --path ./applications/2026 --verbose
oo applications sync --path ./applications/2026 --apply --verbose oo applications sync --path ./applications/2026 --apply --verbose
``` ```
Each `README.md` is parsed for company, role, email, phone, LinkedIn, etc. Each dossier `README.md` is parsed for company, role, email, phone, LinkedIn, etc.
Discovery walks `--path` and **skips** junk trees (`node_modules`, `tools`, `.venv`,
`pdfs`, …). Only folders that look like CV application slugs (`source-NNN-…`, or
long hyphenated dossiers) are synced — not npm package READMEs.
The sync creates or finds contacts, opens a deal, adds members, and appends a The sync creates or finds contacts, opens a deal, adds members, and appends a
history note. Re-running is safe: duplicate members and duplicate deal titles history note. Re-running is safe: duplicate members and duplicate deal titles
are skipped when already present. are skipped when already present.
+2 -27
View File
@@ -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) return l.Client.GetTaskByID(ctx, item.ID)
case model.KindProject: case model.KindProject:
return l.Client.GetProjectByID(ctx, item.ID) return l.Client.GetProjectByID(ctx, item.ID)
case model.KindUser:
return l.Client.GetUser(ctx, item.ID)
default: default:
if item.Raw != nil { if item.Raw != nil {
return 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) { func (l *Loader) listProjectFiles(ctx context.Context, projectID string) ([]model.Item, error) {
resp, err := l.Client.GetProjectFiles(ctx, projectID) resp, err := l.Client.GetProjectFiles(ctx, projectID)
if err != nil { if err != nil {
+12 -2
View File
@@ -16,9 +16,11 @@ func (l *Loader) SaveItem(ctx context.Context, item model.Item, fields model.For
} }
switch item.Kind { switch item.Kind {
case model.KindTask: case model.KindTask:
return l.UpdateTask(ctx, item.ID, fields.Primary, fields.Secondary) return l.UpdateTask(ctx, item.ID, fields)
case model.KindProject: case model.KindProject:
return l.saveProject(ctx, item.ID, fields) return l.saveProject(ctx, item.ID, fields)
case model.KindUser:
return l.SaveUser(ctx, item.ID, fields)
default: default:
return fmt.Errorf("save not supported for %s", item.Kind) 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 { if err != nil {
return model.FormFields{}, err 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
} }
+32 -15
View File
@@ -9,8 +9,18 @@ import (
"github.com/eslider/go-onlyoffice/cmd/office/model" "github.com/eslider/go-onlyoffice/cmd/office/model"
) )
// UpdateTask saves title and description for a project task. // UpdateTask saves editable task fields via the typed JSON API.
func (l *Loader) UpdateTask(ctx context.Context, taskID, title, description string) error { 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 { if l == nil || l.Client == nil {
return fmt.Errorf("fetch: client is 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 { if err != nil {
return fmt.Errorf("task id %q: %w", taskID, err) 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, ID: id,
Title: title, Title: fields.Primary,
Description: description, 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 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
}
+10 -10
View File
@@ -20,27 +20,27 @@ func TestIntegrationUpdateTaskTitleDescription(t *testing.T) {
t.Skip("no tasks") t.Skip("no tasks")
} }
item := items[0] item := items[0]
title, desc, err := loader.TaskFields(ctx, item) fields, err := loader.DetailForm(ctx, item)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
restoreTitle, restoreDesc := title, desc restore := fields
t.Cleanup(func() { t.Cleanup(func() {
_ = loader.UpdateTask(context.Background(), item.ID, restoreTitle, restoreDesc) _ = loader.UpdateTask(context.Background(), item.ID, restore)
}) })
newTitle := title + " (office TUI test)" fields.Primary = fields.Primary + " (office TUI test)"
newDesc := desc + "\n\n_edited by office integration test_" fields.Secondary = fields.Secondary + "\n\n_edited by office integration test_"
if err := loader.UpdateTask(ctx, item.ID, newTitle, newDesc); err != nil { if err := loader.UpdateTask(ctx, item.ID, fields); err != nil {
t.Fatal(err) t.Fatal(err)
} }
gotTitle, gotDesc, err := loader.TaskFields(ctx, item) got, err := loader.DetailForm(ctx, item)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if gotTitle != newTitle { if got.Primary != fields.Primary {
t.Fatalf("title: got %q want %q", gotTitle, newTitle) t.Fatalf("title: got %q want %q", got.Primary, fields.Primary)
} }
if gotDesc != newDesc { if got.Secondary != fields.Secondary {
t.Fatalf("description mismatch") t.Fatalf("description mismatch")
} }
} }
+131
View File
@@ -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")
}
+34
View File
@@ -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")
}
}
+33
View File
@@ -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
}
+7 -2
View File
@@ -6,6 +6,7 @@ type ActionID string
const ( const (
ActionView ActionID = "view" ActionView ActionID = "view"
ActionSave ActionID = "save" ActionSave ActionID = "save"
ActionClose ActionID = "close"
ActionDelete ActionID = "delete" ActionDelete ActionID = "delete"
ActionRefresh ActionID = "refresh" ActionRefresh ActionID = "refresh"
ActionDownload ActionID = "download" ActionDownload ActionID = "download"
@@ -29,7 +30,7 @@ func ActionsFor(kind Kind) []ItemAction {
case KindTask: case KindTask:
return []ItemAction{ return []ItemAction{
{ID: ActionSave, Label: "Save"}, {ID: ActionSave, Label: "Save"},
{ID: ActionDelete, Label: "Delete task", Danger: true}, {ID: ActionClose, Label: "Close"},
} }
case KindCRMTask: case KindCRMTask:
return []ItemAction{ return []ItemAction{
@@ -56,7 +57,11 @@ func ActionsFor(kind Kind) []ItemAction {
{ID: ActionDownload, Label: "Download"}, {ID: ActionDownload, Label: "Download"},
{ID: ActionDelete, Label: "Delete file", Danger: true}, {ID: ActionDelete, Label: "Delete file", Danger: true},
} }
case KindEvent, KindCalendar, KindUser: case KindUser:
return []ItemAction{
{ID: ActionSave, Label: "Save"},
}
case KindEvent, KindCalendar:
return nil return nil
default: default:
return nil return nil
+38 -10
View File
@@ -29,7 +29,6 @@ var subjectExtraKeys = map[Subject][]string{
SubjectMailDrafts: {"to", "date"}, SubjectMailDrafts: {"to", "date"},
SubjectMailTrash: {"from", "date"}, SubjectMailTrash: {"from", "date"},
SubjectMailSpam: {"from", "date"}, SubjectMailSpam: {"from", "date"},
SubjectUsers: {"email", "displayName"},
} }
// BuildColumns derives table columns from the list subject and item payloads. // 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 { if subject == SubjectProjects {
return buildProjectColumns(items) return buildProjectColumns(items)
} }
if subject == SubjectUsers {
return buildUserColumns(items)
}
if subject == SubjectTasks { if subject == SubjectTasks {
return buildTaskColumns(items) return buildTaskColumns(items)
} }
@@ -102,18 +104,28 @@ func buildTaskColumns(items []Item) []TableColumn {
return cols 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 { func buildProjectColumns(items []Item) []TableColumn {
cols := []TableColumn{ _ = items
return []TableColumn{
{Key: "_sel", Title: "✓", Width: 3}, {Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 8}, {Key: "id", Title: "ID", Width: 8},
{Key: "status", Title: "Status", Width: 8}, {Key: "status", Title: "●", Width: 10},
{Key: "title", Title: "Title", Width: 28}, {Key: "title", Title: "Title", Width: 16},
{Key: "tasks", Title: "Tasks (open/closed)", Width: 18}, {Key: "tasks", Title: "Tasks", Width: 11},
{Key: "documents", Title: "Documents", Width: 11}, {Key: "documents", Title: "Docs", Width: 7},
{Key: "users", Title: "Users", Width: 8}, {Key: "users", Title: "Users", Width: 7},
} }
sizeColumns(cols, items)
return cols
} }
// CellText returns the display string for one table cell. // CellText returns the display string for one table cell.
@@ -141,7 +153,9 @@ func CellText(it Item, key string) string {
case "status": case "status":
switch it.Kind { switch it.Kind {
case KindProject: case KindProject:
return ProjectStatusLabel(it.Raw) return ProjectStatusCell(it.Raw)
case KindUser:
return UserStatusLabel(it.Raw)
case KindTask, KindCRMTask: case KindTask, KindCRMTask:
if it.Raw == nil { if it.Raw == nil {
return "" return ""
@@ -171,6 +185,20 @@ func CellText(it Item, key string) string {
return formatAny(it.Raw["responsible"]) return formatAny(it.Raw["responsible"])
case "subtitle": case "subtitle":
return it.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": case "tasks":
return formatProjectTasks(it.Raw) return formatProjectTasks(it.Raw)
case "documents": case "documents":
+15 -2
View File
@@ -29,13 +29,26 @@ func TestBuildProjectColumns(t *testing.T) {
func TestCellTextProjectStatus(t *testing.T) { func TestCellTextProjectStatus(t *testing.T) {
open := Item{Kind: KindProject, Raw: map[string]any{"status": 0}} 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) t.Fatalf("got %q", got)
} }
closed := Item{Kind: KindProject, Raw: map[string]any{"status": 2}} 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) 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) { func TestFormatProjectTasksOpenClosed(t *testing.T) {
+92 -4
View File
@@ -11,7 +11,18 @@ type FormFields struct {
ReadOnly bool ReadOnly bool
HasStatus bool HasStatus bool
Status ProjectLifecycle Status ProjectLifecycle
HasTaskStatus bool
TaskStatus TaskLifecycle
HasResponsible bool
ResponsibleID string 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. // 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, ReadOnly: true,
} }
case KindUser: case KindUser:
acl := UserACLFromRaw(raw)
return FormFields{ return FormFields{
PrimaryLabel: "Name", SecondaryLabel: "Email", HasUserEdit: true,
Primary: strRaw(raw, "displayName"), Secondary: strRaw(raw, "email"), ReadOnly: false,
ReadOnly: true, UserEnabled: UserIsEnabled(raw),
UserACL: acl,
GroupsText: UserGroupsText(raw),
} }
case KindContact: case KindContact:
name := strRaw(raw, "displayName") name := strRaw(raw, "displayName")
@@ -98,6 +112,20 @@ func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
Status: ProjectStatusFromAny(raw["status"]), Status: ProjectStatusFromAny(raw["status"]),
ResponsibleID: ResponsibleIDFromRaw(raw), 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: default:
title := strRaw(raw, "title") title := strRaw(raw, "title")
if title == "" { if title == "" {
@@ -109,11 +137,71 @@ func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
return FormFields{ return FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description", PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: title, Secondary: strRaw(raw, "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 == "<nil>" {
return ""
}
return s
}
return t.Format("2006-01-02 15:04")
}
func strRaw(m map[string]any, key string) string { func strRaw(m map[string]any, key string) string {
if m == nil { if m == nil {
return "" return ""
+42
View File
@@ -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")
}
}
+1 -2
View File
@@ -66,8 +66,7 @@ func DefaultNavTree() *NavTree {
add("mail.trash", "Trash", "mail", false, &ListSpec{Subject: SubjectMailTrash}) add("mail.trash", "Trash", "mail", false, &ListSpec{Subject: SubjectMailTrash})
add("mail.spam", "Spam", "mail", false, &ListSpec{Subject: SubjectMailSpam}) add("mail.spam", "Spam", "mail", false, &ListSpec{Subject: SubjectMailSpam})
add("users", "Users", "", true, nil) add("users", "Users", "", false, &ListSpec{Subject: SubjectUsers})
add("users.dir", "Directory", "users", false, &ListSpec{Subject: SubjectUsers})
t.rebuildVisible() t.rebuildVisible()
return t return t
+21
View File
@@ -46,6 +46,27 @@ func ProjectStatusLabel(raw map[string]any) string {
return ProjectStatusFromAny(raw["status"]).Label() 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. // ProjectIsOpen is true when the project is not closed.
func ProjectIsOpen(raw map[string]any) bool { func ProjectIsOpen(raw map[string]any) bool {
if raw == nil { if raw == nil {
+20
View File
@@ -0,0 +1,20 @@
package model
// TableFlexLayout describes a list table where every column stays visible and
// one flex column absorbs leftover pane width.
type TableFlexLayout struct {
FlexColumnKey string
MinFlexWidth int
}
// TableFlexLayoutFor returns a flex layout policy for subjects that show all columns.
func TableFlexLayoutFor(subject Subject) (TableFlexLayout, bool) {
switch subject {
case SubjectProjects:
return TableFlexLayout{FlexColumnKey: "title", MinFlexWidth: 12}, true
case SubjectUsers:
return TableFlexLayout{FlexColumnKey: "email", MinFlexWidth: 16}, true
default:
return TableFlexLayout{}, false
}
}
+18
View File
@@ -0,0 +1,18 @@
package model
import "testing"
func TestTableFlexLayoutFor(t *testing.T) {
proj, ok := TableFlexLayoutFor(SubjectProjects)
if !ok || proj.FlexColumnKey != "title" || proj.MinFlexWidth != 12 {
t.Fatalf("projects layout=%+v ok=%v", proj, ok)
}
users, ok := TableFlexLayoutFor(SubjectUsers)
if !ok || users.FlexColumnKey != "email" {
t.Fatalf("users layout=%+v", users)
}
_, ok = TableFlexLayoutFor(SubjectTasks)
if ok {
t.Fatal("tasks should use scrolling layout")
}
}
+87
View File
@@ -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]
}
+93
View File
@@ -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
}
}
+43
View File
@@ -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")
}
}
+7
View File
@@ -0,0 +1,7 @@
package model
// UserOption is one portal user for responsible pickers.
type UserOption struct {
ID string
Name string
}
+116
View File
@@ -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 == "<nil>" {
return ""
}
return formatUserDate(s)
}
}
+58
View File
@@ -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
}
+236 -90
View File
@@ -65,13 +65,12 @@ type Model struct {
err string err string
loading bool loading bool
menuVP viewport.Model menuVP viewport.Model
listTable DataTable listTable DataTable
listToolbar ListToolbar
detail DetailPane detail DetailPane
showMenu bool showMenu bool
showList bool showList bool
showDetail bool showDetail bool
filterActive bool
filterSearch FilterSearch
customPaneLayout bool customPaneLayout bool
paneSizes PaneWidths paneSizes PaneWidths
resize paneResizeState resize paneResizeState
@@ -86,14 +85,14 @@ func NewModel(client *onlyoffice.Client) Model {
nav: model.DefaultNavTree(), nav: model.DefaultNavTree(),
selection: model.NewSelection(), selection: model.NewSelection(),
focus: model.FocusMenu, focus: model.FocusMenu,
status: "Tab: pane · 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, height: h,
width: w, width: w,
} }
m.menuVP = viewport.New(m.paneInnerWidth(22), m.paneHeight()) m.menuVP = viewport.New(m.paneInnerWidth(22), m.paneHeight())
m.listTable = newDataTable() m.listTable = newDataTable()
m.listToolbar = newListToolbar()
m.detail = newDetailPane() m.detail = newDetailPane()
m.filterSearch = newFilterSearch()
m.showMenu, m.showList, m.showDetail = true, true, true m.showMenu, m.showList, m.showDetail = true, true, true
m.menuVP.MouseWheelEnabled = true m.menuVP.MouseWheelEnabled = true
return m return m
@@ -117,20 +116,43 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg: case tea.KeyMsg:
key := msg.String() key := msg.String()
if key == "esc" && m.filterActive { if key == "esc" && m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable {
m.exitFilter() m.listToolbar.FocusTable()
m.syncPaneFocus()
return m, nil 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 { switch key {
case "tab", "shift+tab", "backtab": case "tab", "shift+tab", "backtab":
// allow pane switching while filter stays active // allow pane switching
default: default:
cmd := m.filterSearch.Update(msg) cmd := m.listToolbar.Update(msg, m.listToolbarMeta())
m.applyFilter() m.applyFilter()
return m, cmd 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 == "?" { if key == "?" {
m.status = helpText() m.status = helpText()
return m, nil return m, nil
@@ -138,9 +160,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.scrollFocusedPane(key) { if m.scrollFocusedPane(key) {
return m, nil return m, nil
} }
if m.filterActive && m.focus == model.FocusPreview { if m.focus == model.FocusPreview {
// filter input owns keys except pane switching handled above
} else if m.focus == model.FocusPreview {
if cmd, handled := m.handleDetailKey(key, msg); handled { if cmd, handled := m.handleDetailKey(key, msg); handled {
return m, cmd return m, cmd
} }
@@ -155,39 +175,60 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case ActionNextPane: case ActionNextPane:
prev := m.focus prev := m.focus
m.focus = NextVisibleFocus(m.focus, m.paneVis()) m.focus = NextVisibleFocus(m.focus, m.paneVis())
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive { if m.focus == model.FocusPreview && prev != model.FocusPreview {
m.detail.FocusFirstStop() m.detail.FocusFirstStop()
} }
if m.focus == model.FocusList && prev != model.FocusList {
m.listToolbar.FocusTable()
}
m.syncPaneFocus() m.syncPaneFocus()
return m, nil return m, nil
case ActionPrevPane: case ActionPrevPane:
prev := m.focus prev := m.focus
m.focus = PrevVisibleFocus(m.focus, m.paneVis()) m.focus = PrevVisibleFocus(m.focus, m.paneVis())
if m.focus == model.FocusPreview && prev != model.FocusPreview && !m.filterActive { if m.focus == model.FocusPreview && prev != model.FocusPreview {
m.detail.FocusFirstStop() m.detail.FocusFirstStop()
} }
if m.focus == model.FocusList && prev != model.FocusList {
m.listToolbar.FocusTable()
}
m.syncPaneFocus() m.syncPaneFocus()
return m, nil return m, nil
case ActionMoveUp: case ActionMoveUp:
if m.focus == model.FocusPreview { if m.focus == model.FocusPreview {
return m.handleDetailMove(-1) return m.handleDetailMove(-1)
} }
if m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable {
return m, nil
}
m.moveUp() m.moveUp()
m.syncFocusedPane() m.syncFocusedPane()
return m, m.onListRowChanged() if m.focus == model.FocusList {
return m, m.onListRowChanged()
}
return m, nil
case ActionMoveDown: case ActionMoveDown:
if m.focus == model.FocusPreview { if m.focus == model.FocusPreview {
return m.handleDetailMove(1) return m.handleDetailMove(1)
} }
if m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable {
return m, nil
}
m.moveDown() m.moveDown()
m.syncFocusedPane() m.syncFocusedPane()
return m, m.onListRowChanged() if m.focus == model.FocusList {
return m, m.onListRowChanged()
}
return m, nil
case ActionMoveLeft: case ActionMoveLeft:
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions { if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
m.detail.MoveTabStop(-1) m.detail.MoveTabStop(-1)
return m, nil 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) m.listTable.MoveCol(-1)
} }
return m, nil return m, nil
@@ -196,12 +237,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.detail.MoveTabStop(1) m.detail.MoveTabStop(1)
return m, nil 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) m.listTable.MoveCol(1)
} }
return m, nil return m, nil
case ActionSort: case ActionSort:
if m.focus == model.FocusList && m.hasList { if m.focus == model.FocusList && m.hasList && m.listToolbar.Zone() == listZoneTable {
m.listTable.ToggleSort() m.listTable.ToggleSort()
} }
return m, nil return m, nil
@@ -236,7 +280,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.togglePane(3) m.togglePane(3)
return m, nil return m, nil
case ActionFilter: case ActionFilter:
m.enterFilter() if m.hasList {
m.focus = model.FocusList
m.listToolbar.FocusFilter()
m.syncPaneFocus()
}
return m, nil return m, nil
case ActionRefresh: case ActionRefresh:
if m.hasList { if m.hasList {
@@ -257,13 +305,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.syncMenuContent() m.syncMenuContent()
return m, nil 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.hasList = true
m.listHasMore = model.SubjectIsMail(msg.spec.Subject) && len(msg.items) >= fetch.MailListPageSize m.listHasMore = model.SubjectIsMail(msg.spec.Subject) && len(msg.items) >= fetch.MailListPageSize
m.listTable.SetData(m.listSpec, m.items) m.listTable.SetData(m.listSpec, m.items)
if m.filterActive { m.applyFilter()
m.applyFilter()
}
} }
return m, tea.Batch(m.onListRowChanged(), m.maybeLoadMoreList()) return m, tea.Batch(m.onListRowChanged(), m.maybeLoadMoreList())
@@ -337,8 +376,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else { } else {
m.updateItemAfterSave(msg.item, msg.fields) m.updateItemAfterSave(msg.item, msg.fields)
m.detail.LoadForm(msg.item, msg.fields) m.detail.LoadForm(msg.item, msg.fields)
m.detail.form.MarkClean()
m.detail.SetFocused(m.focus == model.FocusPreview) 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 = "" m.err = ""
} }
return m, nil return m, nil
@@ -350,6 +394,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else { } else {
m.status = msg.message m.status = msg.message
m.err = "" m.err = ""
m.selection.Clear()
if m.hasList { if m.hasList {
m.loading = true m.loading = true
return m, m.loadListCmd(m.listSpec) return m, m.loadListCmd(m.listSpec)
@@ -376,11 +421,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case model.FocusMenu: case model.FocusMenu:
m.menuVP, cmd = m.menuVP.Update(msg) m.menuVP, cmd = m.menuVP.Update(msg)
case model.FocusList: case model.FocusList:
cmd = m.listTable.Update(msg) if m.listToolbar.Zone() == listZoneTable {
case model.FocusPreview: cmd = m.listTable.Update(msg)
if !m.filterActive {
cmd = m.detail.Update(msg)
} }
case model.FocusPreview:
cmd = m.detail.Update(msg)
} }
return m, cmd return m, cmd
} }
@@ -392,9 +437,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case model.FocusList: case model.FocusList:
cmd = m.listTable.Update(msg) cmd = m.listTable.Update(msg)
case model.FocusPreview: case model.FocusPreview:
if !m.filterActive { cmd = m.detail.Update(msg)
cmd = m.detail.Update(msg)
}
} }
return m, cmd return m, cmd
} }
@@ -425,6 +468,9 @@ func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
if act.ID == model.ActionSave { if act.ID == model.ActionSave {
return m.saveDetailCmd(), true 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 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) { func (m *Model) routeDetailDocumentMouse(msg tea.MouseMsg) (tea.Cmd, bool) {
if m.filterActive || !m.showDetail { if !m.showDetail {
return nil, false return nil, false
} }
if !m.detail.IsDocumentContent() && !m.detail.isReadOnlyFormContent() { if !m.detail.IsDocumentContent() && !m.detail.isReadOnlyFormContent() {
@@ -476,7 +522,7 @@ func (m Model) View() string {
h := m.paneHeight() + 2 h := m.paneHeight() + 2
var parts []string var parts []string
if pw.Visibility.Menu { if pw.Visibility.Menu {
menuStyle := paneStyle(m.focus == model.FocusMenu).Width(pw.Menu).Height(h) menuStyle := paneStyle(m.focus == model.FocusMenu).Width(paneLipglossWidth(pw.Menu)).Height(h)
menuBody := ApplyVerticalScrollbar( menuBody := ApplyVerticalScrollbar(
m.menuVP.View(), m.menuVP.View(),
m.menuVP.Width, m.menuVP.Width,
@@ -487,18 +533,17 @@ func (m Model) View() string {
parts = append(parts, menuStyle.Render(menuBody)) parts = append(parts, menuStyle.Render(menuBody))
} }
if pw.Visibility.List { if pw.Visibility.List {
listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h) listStyle := paneStyle(m.focus == model.FocusList).Width(paneLipglossWidth(pw.List)).Height(h)
parts = append(parts, listStyle.Render(m.listTable.View())) 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 { if pw.Visibility.Detail {
prevStyle := paneStyle(m.focus == model.FocusPreview).Width(pw.Detail).Height(h) prevStyle := paneStyle(m.focus == model.FocusPreview).Width(paneLipglossWidth(pw.Detail)).Height(h)
var detailBody string parts = append(parts, prevStyle.Render(m.detail.View()))
if m.filterActive {
detailBody = m.filterSearch.View()
} else {
detailBody = m.detail.View()
}
parts = append(parts, prevStyle.Render(detailBody))
} }
status := m.status status := m.status
@@ -526,16 +571,105 @@ func (m Model) paneLayout() PaneWidths {
} }
func (m *Model) syncPaneFocus() { func (m *Model) syncPaneFocus() {
m.listTable.SetFocused(m.focus == model.FocusList && m.showList) onList := m.focus == model.FocusList && m.showList
if m.filterActive { m.listTable.SetFocused(onList && m.listToolbar.Zone() == listZoneTable)
m.filterSearch.SetFocused(m.focus == model.FocusPreview && m.showDetail) if onList {
m.detail.SetFocused(false) m.listToolbar.syncInputFocus()
return } else {
m.listToolbar.filter.SetFocused(false)
} }
m.filterSearch.SetFocused(false)
m.detail.SetFocused(m.focus == model.FocusPreview && m.showDetail) 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) { func (m *Model) togglePane(which int) {
switch which { switch which {
case 1: case 1:
@@ -587,11 +721,12 @@ func (m *Model) layoutViewports() {
m.menuVP.Height = h m.menuVP.Height = h
} }
if pw.Visibility.List { 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 { if pw.Visibility.Detail {
m.detail.SetSize(m.paneInnerWidth(pw.Detail), h) m.detail.SetSize(m.paneInnerWidth(pw.Detail), h)
m.filterSearch.SetSize(m.paneInnerWidth(pw.Detail), h)
} }
} }
@@ -606,34 +741,14 @@ func (m *Model) syncListTable() {
return return
} }
items := m.items items := m.items
if m.filterActive { if q := m.listToolbar.Query(); q != "" {
items = model.FilterItems(m.items, m.filterSearch.Query()) items = model.FilterItems(m.items, q)
} }
m.listTable.UpdateItems(items) 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() { func (m *Model) applyFilter() {
q := m.filterSearch.Query() q := m.listToolbar.Query()
m.nav.SetFilter(q) m.nav.SetFilter(q)
m.syncMenuContent() m.syncMenuContent()
m.syncListTable() m.syncListTable()
@@ -645,8 +760,8 @@ func (m Model) listTableItem() (model.Item, bool) {
return model.Item{}, false return model.Item{}, false
} }
items := m.items items := m.items
if m.filterActive { if q := m.listToolbar.Query(); q != "" {
items = model.FilterItems(m.items, m.filterSearch.Query()) items = model.FilterItems(m.items, q)
} }
if idx >= len(items) { if idx >= len(items) {
return model.Item{}, false return model.Item{}, false
@@ -657,7 +772,7 @@ func (m Model) listTableItem() (model.Item, bool) {
func (m *Model) syncMenuContent() { func (m *Model) syncMenuContent() {
var b strings.Builder var b strings.Builder
title := "Navigation" title := "Navigation"
if m.filterActive && m.filterSearch.Query() != "" { if m.listToolbar.Query() != "" {
title += " (filtered)" title += " (filtered)"
} }
b.WriteString(title + "\n\n") b.WriteString(title + "\n\n")
@@ -705,6 +820,23 @@ func truncateRunes(s string, max int) string {
return string(r[:max-1]) + "…" 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() { func (m *Model) syncFocusedPane() {
switch m.focus { switch m.focus {
case model.FocusMenu: case model.FocusMenu:
@@ -790,9 +922,6 @@ func (m *Model) loadNavProjectsCmd() tea.Cmd {
} }
func (m *Model) onListRowChanged() tea.Cmd { func (m *Model) onListRowChanged() tea.Cmd {
if m.filterActive {
return nil
}
if !m.hasList { if !m.hasList {
m.detail.Clear() m.detail.Clear()
return m.maybeLoadMoreList() return m.maybeLoadMoreList()
@@ -814,7 +943,7 @@ func (m *Model) onListRowChanged() tea.Cmd {
const mailListLoadThreshold = 3 const mailListLoadThreshold = 3
func (m *Model) maybeLoadMoreList() tea.Cmd { func (m *Model) maybeLoadMoreList() tea.Cmd {
if !m.hasList || m.listLoadingMore || !m.listHasMore || m.filterActive { if !m.hasList || m.listLoadingMore || !m.listHasMore {
return nil return nil
} }
if !model.SubjectIsMail(m.listSpec.Subject) { 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 { func (m *Model) saveDetailCmd() tea.Cmd {
item := m.detail.Item() item := m.detail.Item()
fields := m.detail.form.FormFields() fields := m.detail.form.FormFields()
@@ -874,6 +1014,12 @@ func (m *Model) updateItemAfterSave(item model.Item, fields model.FormFields) {
if fields.HasStatus { if fields.HasStatus {
m.items[i].Raw["status"] = statusInt(fields.Status) 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() m.syncListTable()
return return
} }
@@ -899,5 +1045,5 @@ func paneStyle(focused bool) lipgloss.Style {
} }
func helpText() string { 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"
} }
+41
View File
@@ -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)
}
}
+2 -2
View File
@@ -30,8 +30,8 @@ func TestDetailPaneFormAndActions(t *testing.T) {
Primary: "Hello", Secondary: "World", Primary: "Hello", Secondary: "World",
}) })
view := d.View() view := d.View()
if !strings.Contains(view, "Save") { if !strings.Contains(view, "Save") || !strings.Contains(view, "Close") {
t.Fatal("expected Save action button") t.Fatal("expected Save and Close action buttons")
} }
if !strings.Contains(view, "Hello") { if !strings.Contains(view, "Hello") {
t.Fatal("expected form content") t.Fatal("expected form content")
+297 -70
View File
@@ -16,33 +16,48 @@ const (
entityFieldPrimary entityField = iota entityFieldPrimary entityField = iota
entityFieldSecondary entityFieldSecondary
entityFieldStatus entityFieldStatus
entityFieldResponsible
) )
// EntityForm is the top section of the detail pane for editable entities. // EntityForm is the top section of the detail pane for editable entities.
type EntityForm struct { type EntityForm struct {
active bool active bool
kind model.Kind kind model.Kind
itemID string itemID string
primaryLabel string primaryLabel string
secondaryLabel string secondaryLabel string
readOnly bool readOnly bool
hasStatus bool hasStatus bool
status model.ProjectLifecycle status model.ProjectLifecycle
responsibleID string hasTaskStatus bool
primary textinput.Model taskStatus model.TaskLifecycle
secondary textarea.Model hasResponsible bool
field entityField responsibleID string
focused bool userChoices []model.UserOption
dirty bool responsibleIdx int
width int projectTitle string
height int timingSummary string
styles entityFormStyles 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 { type entityFormStyles struct {
header lipgloss.Style header lipgloss.Style
label lipgloss.Style label lipgloss.Style
labelAct lipgloss.Style labelAct lipgloss.Style
meta lipgloss.Style
} }
func newEntityFormStyles() entityFormStyles { func newEntityFormStyles() entityFormStyles {
@@ -50,6 +65,7 @@ func newEntityFormStyles() entityFormStyles {
header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")), header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
label: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), label: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
labelAct: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Background(lipgloss.Color("62")), 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.ShowLineNumbers = false
secondary.CharLimit = 8000 secondary.CharLimit = 8000
secondary.Prompt = "> " secondary.Prompt = "> "
password := textinput.New()
password.CharLimit = 128
password.EchoMode = textinput.EchoPassword
password.EchoCharacter = '•'
password.Prompt = "> "
return EntityForm{ return EntityForm{
primary: primary, primary: primary,
secondary: secondary, secondary: secondary,
field: entityFieldPrimary, password: password,
styles: newEntityFormStyles(), field: entityFieldPrimary,
styles: newEntityFormStyles(),
} }
} }
func (f *EntityForm) FieldCount() int { func (f *EntityForm) FieldCount() int {
if f.hasUserEdit {
return f.userFieldCount()
}
n := 2 n := 2
if f.hasStatus { if f.hasStatus || f.hasTaskStatus {
n = 3 n++
}
if f.hasResponsible {
n++
} }
return n return n
} }
@@ -86,7 +114,18 @@ func (f *EntityForm) FormFields() model.FormFields {
ReadOnly: f.readOnly, ReadOnly: f.readOnly,
HasStatus: f.hasStatus, HasStatus: f.hasStatus,
Status: f.status, Status: f.status,
HasTaskStatus: f.hasTaskStatus,
TaskStatus: f.taskStatus,
HasResponsible: f.hasResponsible,
ResponsibleID: f.responsibleID, 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.readOnly = fields.ReadOnly
f.hasStatus = fields.HasStatus f.hasStatus = fields.HasStatus
f.status = fields.Status 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.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.primary.SetValue(fields.Primary)
f.secondary.SetValue(fields.Secondary) f.secondary.SetValue(fields.Secondary)
f.password.SetValue("")
f.dirty = false f.dirty = false
f.field = entityFieldPrimary if f.hasUserEdit {
if f.hasStatus && f.readOnly { f.userFieldIdx = 0
f.field = entityFieldStatus } else {
f.field = entityFieldPrimary
} }
f.applyFocus() f.applyFocus()
f.layoutFields() 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() { func (f *EntityForm) Clear() {
f.active = false f.active = false
f.itemID = "" f.itemID = ""
f.hasStatus = false f.hasStatus = false
f.hasTaskStatus = false
f.hasResponsible = false
f.status = model.ProjectLifecycleOpen f.status = model.ProjectLifecycleOpen
f.taskStatus = model.TaskLifecycleOpen
f.responsibleID = "" 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.primary.SetValue("")
f.secondary.SetValue("") f.secondary.SetValue("")
f.password.SetValue("")
f.dirty = false f.dirty = false
f.primary.Blur() f.primary.Blur()
f.secondary.Blur() f.secondary.Blur()
f.password.Blur()
} }
func (f *EntityForm) SetFocused(on bool) { func (f *EntityForm) SetFocused(on bool) {
@@ -141,6 +217,7 @@ func (f *EntityForm) SetFocused(on bool) {
} else { } else {
f.primary.Blur() f.primary.Blur()
f.secondary.Blur() f.secondary.Blur()
f.password.Blur()
} }
} }
@@ -165,7 +242,28 @@ func (f *EntityForm) layoutFields() {
inner = 8 inner = 8
} }
f.primary.Width = inner 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 { if secH < 3 {
secH = 3 secH = 3
} }
@@ -173,34 +271,49 @@ func (f *EntityForm) layoutFields() {
f.secondary.SetHeight(secH) f.secondary.SetHeight(secH)
} }
func (f *EntityForm) FocusNext() { func (f *EntityForm) lastField() entityField {
last := entityFieldSecondary if f.hasResponsible {
if f.hasStatus { return entityFieldResponsible
last = entityFieldStatus
} }
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.field++
} }
f.applyFocus() f.applyFocus()
} }
func (f *EntityForm) FocusPrev() { func (f *EntityForm) FocusPrev() {
first := entityFieldPrimary if f.field > entityFieldPrimary {
if f.field > first {
f.field-- f.field--
} }
f.applyFocus() f.applyFocus()
} }
func (f *EntityForm) SetFieldIndex(i int) { func (f *EntityForm) SetFieldIndex(i int) {
if f.hasUserEdit {
f.setUserFieldIndex(i)
return
}
switch i { switch i {
case 0: case 0:
f.field = entityFieldPrimary f.field = entityFieldPrimary
case 1: case 1:
f.field = entityFieldSecondary f.field = entityFieldSecondary
case 2: case 2:
if f.hasStatus { if f.hasStatus || f.hasTaskStatus {
f.field = entityFieldStatus f.field = entityFieldStatus
} else if f.hasResponsible {
f.field = entityFieldResponsible
}
case 3:
if f.hasResponsible && (f.hasStatus || f.hasTaskStatus) {
f.field = entityFieldResponsible
} }
default: default:
return return
@@ -209,7 +322,19 @@ func (f *EntityForm) SetFieldIndex(i int) {
} }
func (f *EntityForm) cycleStatus(delta 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 return
} }
if delta > 0 { if delta > 0 {
@@ -220,7 +345,36 @@ func (f *EntityForm) cycleStatus(delta int) {
f.dirty = true 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() { func (f *EntityForm) applyFocus() {
if f.hasUserEdit {
f.applyUserFocus()
return
}
if f.readOnly { if f.readOnly {
f.primary.Blur() f.primary.Blur()
f.secondary.Blur() f.secondary.Blur()
@@ -243,17 +397,35 @@ func (f *EntityForm) Update(msg tea.Msg) tea.Cmd {
if !f.active || !f.focused || f.readOnly { if !f.active || !f.focused || f.readOnly {
return nil return nil
} }
if key, ok := msg.(tea.KeyMsg); ok && f.field == entityFieldStatus { if f.hasUserEdit {
switch key.String() { return f.updateUserForm(msg)
case "left", "h": }
f.cycleStatus(-1) if key, ok := msg.(tea.KeyMsg); ok {
return nil switch f.field {
case "right", "l": case entityFieldStatus:
f.cycleStatus(1) switch key.String() {
return nil case "left", "h":
case " ": f.cycleStatus(-1)
f.cycleStatus(1) return nil
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 var cmd tea.Cmd
@@ -275,6 +447,9 @@ func (f EntityForm) View() string {
if !f.active { if !f.active {
return "" return ""
} }
if f.hasUserEdit {
return f.userView()
}
header := f.styles.header.Render(model.KindHeading(f.kind, f.itemID)) header := f.styles.header.Render(model.KindHeading(f.kind, f.itemID))
pLabel := f.styles.label.Render(f.primaryLabel) pLabel := f.styles.label.Render(f.primaryLabel)
if f.focused && f.field == entityFieldPrimary && !f.readOnly { 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 { if f.focused && f.field == entityFieldSecondary && !f.readOnly {
sLabel = f.styles.labelAct.Render(f.secondaryLabel) sLabel = f.styles.labelAct.Render(f.secondaryLabel)
} }
statusBlock := "" statusBlock := f.renderStatusBlock()
if f.hasStatus { responsibleBlock := f.renderResponsibleBlock()
stLabel := f.styles.label.Render("Status") metaBlock := f.renderMetaBlock()
if f.focused && f.field == entityFieldStatus && !f.readOnly {
stLabel = f.styles.labelAct.Render("Status")
}
valStyle := f.styles.label
if f.status == model.ProjectLifecycleClosed {
valStyle = valStyle.Foreground(lipgloss.Color("245"))
} else {
valStyle = valStyle.Foreground(lipgloss.Color("42"))
}
hint := ""
if f.focused && f.field == entityFieldStatus && !f.readOnly {
hint = f.styles.label.Render(" ←/→ toggle")
}
statusBlock = strings.Join([]string{
"",
stLabel,
valStyle.Render(f.status.Label()) + hint,
}, "\n")
}
ro := "" ro := ""
if f.readOnly { if f.readOnly {
ro = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" (read-only)") + "\n" ro = lipgloss.NewStyle().Foreground(lipgloss.Color("241")).Render(" (read-only)") + "\n"
} }
return strings.Join([]string{ parts := []string{
header + ro, header + ro,
pLabel, pLabel,
f.primary.View(), f.primary.View(),
"", "",
sLabel, sLabel,
f.secondary.View(), 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") }, "\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 { func entityFormBlinkCmd() tea.Cmd {
return tea.Batch(textinput.Blink, textarea.Blink) return tea.Batch(textinput.Blink, textarea.Blink)
} }
+161
View File
@@ -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}
}
+61
View File
@@ -0,0 +1,61 @@
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)
flex, ok := model.TableFlexLayoutFor(model.SubjectUsers)
if !ok {
t.Fatal("expected flex layout")
}
lay := layoutFlexTable(cols, 100, flex.FlexColumnKey, flex.MinFlexWidth)
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)
}
}
+12 -1
View File
@@ -25,7 +25,7 @@ type filterSearchStyles struct {
func newFilterSearch() FilterSearch { func newFilterSearch() FilterSearch {
in := textinput.New() in := textinput.New()
in.Prompt = "/ " in.Prompt = "/ "
in.Placeholder = "Filter navigation and list…" in.Placeholder = "Filter list…"
in.CharLimit = 256 in.CharLimit = 256
return FilterSearch{ return FilterSearch{
input: in, input: in,
@@ -76,6 +76,17 @@ func (f *FilterSearch) Update(msg tea.Msg) tea.Cmd {
return 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 { func (f FilterSearch) View() string {
title := f.styles.title.Render("Filter") title := f.styles.title.Render("Filter")
body := lipgloss.NewStyle().Width(f.width).Render(f.input.View()) body := lipgloss.NewStyle().Width(f.width).Render(f.input.View())
+7 -2
View File
@@ -39,8 +39,13 @@ func KeyAction(key string, pane model.FocusPane) Action {
} }
case "r": case "r":
return ActionRefresh return ActionRefresh
case "f": case "f", "/":
return ActionFilter if pane == model.FocusList {
return ActionFilter
}
if pane == model.FocusMenu {
return ActionFilter
}
case "up", "k": case "up", "k":
return ActionMoveUp return ActionMoveUp
case "down", "j": case "down", "j":
+52
View File
@@ -1,9 +1,14 @@
package ui package ui
import ( import (
"regexp"
"strings"
"testing" "testing"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model" "github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/mattn/go-runewidth"
"github.com/muesli/termenv"
) )
func TestLayoutWidthsAllVisibleUsesFullWidth(t *testing.T) { 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) { func TestLayoutWidthsLegacyHelper(t *testing.T) {
menu, list, preview := LayoutWidthsLegacy(120) menu, list, preview := LayoutWidthsLegacy(120)
if menu+list+preview != 120 { if menu+list+preview != 120 {
+352
View File
@@ -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
}
}
+58
View File
@@ -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")
}
}
+31 -4
View File
@@ -8,7 +8,11 @@ import (
func (m *Model) scrollFocusedPane(key string) bool { func (m *Model) scrollFocusedPane(key string) bool {
switch m.focus { switch m.focus {
case model.FocusMenu: 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: case model.FocusList:
switch key { switch key {
case "pgdown", "pgdn", "ctrl+d": case "pgdown", "pgdn", "ctrl+d":
@@ -37,17 +41,40 @@ func (m *Model) paneHeight() int {
return h return h
} }
func (m *Model) paneInnerWidth(outer int) int { const (
if outer <= 0 { 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 return 8
} }
w := outer - 2 // border only; content fills inner box w := rendered - paneBorderChars
if w < 8 { if w < 8 {
w = 8 w = 8
} }
return w 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 { func scrollViewport(vp *viewport.Model, key string) bool {
switch key { switch key {
case "up", "k": case "up", "k":
+48
View File
@@ -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)
}
}
+2 -13
View File
@@ -4,7 +4,6 @@ import (
"strings" "strings"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
) )
// ScrollbarMetrics computes thumb position for a vertical scrollbar. // 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")) thumb := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
out := make([]string, height) out := make([]string, height)
for row := 0; row < height; row++ { for row := 0; row < height; row++ {
line := padDisplayWidth(lines[row], contentWidth) line := padANSIWidth(lines[row], contentWidth)
ch := "│" ch := "│"
style := track style := track
if row >= thumbStart && row < thumbEnd { 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 { func padDisplayWidth(line string, width int) string {
if width < 1 { return padANSIWidth(line, width)
return ""
}
w := runewidth.StringWidth(line)
if w > width {
return runewidth.Truncate(line, width, "")
}
if w < width {
line += strings.Repeat(" ", width-w)
}
return line
} }
+60 -172
View File
@@ -9,7 +9,6 @@ import (
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model" "github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/mattn/go-runewidth"
) )
type tableStyles struct { type tableStyles struct {
@@ -80,7 +79,7 @@ func (t *DataTable) SetSize(w, h int) {
t.width = w t.width = w
t.height = h t.height = h
t.viewport.Width = w t.viewport.Width = w
t.viewport.Height = h - 2 // title + header t.viewport.Height = h - 1 // column header
if t.viewport.Height < 1 { if t.viewport.Height < 1 {
t.viewport.Height = 1 t.viewport.Height = 1
} }
@@ -260,155 +259,63 @@ func (t *DataTable) Update(msg tea.Msg) tea.Cmd {
return nil return nil
} }
func (t DataTable) LoadingMore() bool { return t.loadingMore }
func (t DataTable) View() string { func (t DataTable) View() string {
if !t.ready { if !t.ready {
return "List\n\nSelect a leaf node in the tree\n(marked with •) and press Enter.\n" return "\nSelect a leaf in the tree (•) 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))
} }
header := t.renderHeader() header := t.renderHeader()
if t.hasVerticalScrollbar() {
header = padANSIWidth(header, t.lineContentWidth())
}
body := ApplyVerticalScrollbar( body := ApplyVerticalScrollbar(
t.viewport.View(), t.viewport.View(),
t.viewport.Width, t.width,
t.viewport.Height, t.viewport.Height,
t.viewport.TotalLineCount(), t.viewport.TotalLineCount(),
t.viewport.YOffset, t.viewport.YOffset,
) )
content := lipgloss.JoinVertical(lipgloss.Left, title+sortHint, header, body) return lipgloss.JoinVertical(lipgloss.Left, header, body)
return lipgloss.NewStyle().Width(t.width).Render(content) }
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) { func (t *DataTable) visibleLayout() (indices []int, widths map[int]int) {
widths = make(map[int]int) lay := t.computeLayout()
if len(t.columns) == 0 || t.width <= 0 { return lay.indices, lay.widths
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, "...")
} }
func (t *DataTable) renderHeader() string { func (t *DataTable) renderHeader() string {
@@ -430,7 +337,7 @@ func (t *DataTable) renderHeader() string {
if t.focused && colIdx == t.cursorCol { if t.focused && colIdx == t.cursorCol {
style = t.styles.headerSort 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...) return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
} }
@@ -450,30 +357,24 @@ func (t *DataTable) refreshViewport() {
func (t *DataTable) renderRow(row int) string { func (t *DataTable) renderRow(row int) string {
item := t.items[t.order[row]] item := t.items[t.order[row]]
selected := item.Selected selected := item.Selected
fullRow := row == t.cursorRow || selected
indices, widths := t.visibleLayout() indices, widths := t.visibleLayout()
cells := make([]string, 0, len(indices)) cells := make([]string, 0, len(indices))
for _, colIdx := range indices { for _, colIdx := range indices {
col := t.columns[colIdx] col := t.columns[colIdx]
w := widths[colIdx] w := widths[colIdx]
raw := strings.TrimSpace(model.CellText(item, col.Key)) raw := strings.TrimSpace(model.CellText(item, col.Key))
text := raw text := truncateCellText(raw, w)
if !fullRow { cells = append(cells, t.styleCell(row, colIdx, col.Key, selected, item, text, w))
text = truncateCellText(raw, w)
}
cells = append(cells, t.styleCell(row, colIdx, col.Key, selected, item, text, w, fullRow))
} }
return lipgloss.JoinHorizontal(lipgloss.Left, cells...) return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
} }
func applyCellWidth(s lipgloss.Style, width int, full bool) lipgloss.Style { func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item model.Item, text string, width int) string {
if full { if t.spec.Subject == model.SubjectProjects {
return s 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 isRow := row == t.cursorRow
isCol := col == t.cursorCol isCol := col == t.cursorCol
isCell := t.focused && isRow && isCol isCell := t.focused && isRow && isCol
@@ -495,29 +396,16 @@ func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item m
default: default:
base = t.styles.cell base = t.styles.cell
} }
base = t.applyProjectRowStyle(base, item, colKey) return renderTableCell(base, text, width)
return applyCellWidth(base, width, fullWidth).Render(text)
}
func (t *DataTable) applyProjectRowStyle(base lipgloss.Style, item model.Item, colKey string) lipgloss.Style {
if t.spec.Subject != model.SubjectProjects {
return base
}
open := model.ProjectIsOpen(item.Raw)
if colKey == "status" {
if open {
return base.Foreground(lipgloss.Color("42")).Bold(true)
}
return base.Foreground(lipgloss.Color("245"))
}
if open {
return base.Background(lipgloss.Color("22")).Foreground(lipgloss.Color("255"))
}
return base.Background(lipgloss.Color("238")).Foreground(lipgloss.Color("252"))
} }
func (t *DataTable) ensureColVisible() { func (t *DataTable) ensureColVisible() {
vis := t.pickVisibleColumnIndices() if flex, ok := model.TableFlexLayoutFor(t.spec.Subject); ok && flex.FlexColumnKey != "" {
t.colScroll = 0
return
}
contentW := t.lineContentWidth()
vis := pickVisibleColumnIndices(t.columns, t.colScroll, contentW)
if len(vis) == 0 { if len(vis) == 0 {
return return
} }
@@ -528,7 +416,7 @@ func (t *DataTable) ensureColVisible() {
if t.cursorCol > last { if t.cursorCol > last {
t.colScroll = t.cursorCol t.colScroll = t.cursorCol
for t.cursorCol >= 0 { for t.cursorCol >= 0 {
vis = t.pickVisibleColumnIndices() vis = pickVisibleColumnIndices(t.columns, t.colScroll, contentW)
if len(vis) == 0 { if len(vis) == 0 {
break break
} }
+225
View File
@@ -0,0 +1,225 @@
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 flex, ok := model.TableFlexLayoutFor(t.spec.Subject); ok {
return layoutFlexTable(t.columns, contentW, flex.FlexColumnKey, flex.MinFlexWidth)
}
return layoutScrollingTable(t.columns, t.colScroll, contentW)
}
const minFixedColumnWidth = 3
// layoutFlexTable shows every column; flexKey absorbs leftover width.
func layoutFlexTable(cols []model.TableColumn, totalW int, flexKey string, minFlexWidth int) columnLayout {
indices := make([]int, len(cols))
for i := range cols {
indices[i] = i
}
widths := make(map[int]int, len(cols))
flexIdx := -1
fixed := 0
for i, col := range cols {
if col.Key == flexKey {
flexIdx = i
continue
}
widths[i] = col.Width
fixed += widths[i]
}
if flexIdx < 0 {
return layoutScrollingTable(cols, 0, totalW)
}
flexW := totalW - fixed
if flexW < minFlexWidth {
shrinkFixedColumns(widths, indices, flexIdx, fixed+minFlexWidth-totalW)
fixed = 0
for _, i := range indices {
if i != flexIdx {
fixed += widths[i]
}
}
flexW = totalW - fixed
if flexW < minFlexWidth {
flexW = minFlexWidth
}
}
widths[flexIdx] = flexW
normalizeWidthSum(widths, indices, totalW, flexIdx)
return columnLayout{indices: indices, widths: widths}
}
func shrinkFixedColumns(widths map[int]int, indices []int, flexIdx, need int) {
for need > 0 {
changed := false
for _, i := range indices {
if i == flexIdx || widths[i] <= minFixedColumnWidth {
continue
}
widths[i]--
need--
changed = true
if need == 0 {
return
}
}
if !changed {
return
}
}
}
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, "…")
}
+49
View File
@@ -0,0 +1,49 @@
package ui
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestLayoutFlexTableAllProjectColumnsVisible(t *testing.T) {
cols := model.BuildColumns(model.SubjectProjects, nil)
flex, ok := model.TableFlexLayoutFor(model.SubjectProjects)
if !ok {
t.Fatal("expected flex layout for projects")
}
lay := layoutFlexTable(cols, 80, flex.FlexColumnKey, flex.MinFlexWidth)
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)
}
}
func TestLayoutFlexTableUserEmailAbsorbsWidth(t *testing.T) {
cols := model.BuildColumns(model.SubjectUsers, nil)
flex, ok := model.TableFlexLayoutFor(model.SubjectUsers)
if !ok {
t.Fatal("expected flex layout for users")
}
lay := layoutFlexTable(cols, 100, flex.FlexColumnKey, flex.MinFlexWidth)
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 < 20 {
t.Fatalf("email width=%d should absorb remainder", emailW)
}
}
+48
View File
@@ -0,0 +1,48 @@
package ui
import (
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
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"))
}
+49
View File
@@ -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)
}
+55
View File
@@ -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)
}
}
+147 -5
View File
@@ -1,11 +1,14 @@
package ui package ui
import ( import (
"regexp"
"strings" "strings"
"testing" "testing"
"github.com/charmbracelet/lipgloss"
"github.com/eslider/go-onlyoffice/cmd/office/model" "github.com/eslider/go-onlyoffice/cmd/office/model"
"github.com/mattn/go-runewidth" "github.com/mattn/go-runewidth"
"github.com/muesli/termenv"
) )
func sampleItems() []model.Item { func sampleItems() []model.Item {
@@ -89,13 +92,125 @@ func TestDistributeColumnWidthsFillsTotal(t *testing.T) {
} }
} }
func TestProjectTableTitleAbsorbsWidth(t *testing.T) {
cols := model.BuildColumns(model.SubjectProjects, nil)
flex, ok := model.TableFlexLayoutFor(model.SubjectProjects)
if !ok {
t.Fatal("expected flex layout")
}
lay := layoutFlexTable(cols, 120, flex.FlexColumnKey, flex.MinFlexWidth)
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) { func TestTruncateCellText(t *testing.T) {
long := strings.Repeat("x", 40) long := strings.Repeat("x", 40)
got := truncateCellText(long, 10) got := truncateCellText(long, 10)
if runewidth.StringWidth(got) > 10 { if runewidth.StringWidth(got) > 10 {
t.Fatalf("truncated width=%d want <=10", runewidth.StringWidth(got)) 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) t.Fatalf("expected ellipsis suffix, got %q", got)
} }
} }
@@ -116,8 +231,26 @@ func TestDataTableTruncatesNonSelectedRows(t *testing.T) {
} }
tbl.cursorRow = 1 tbl.cursorRow = 1
if !strings.Contains(tbl.renderRow(1), long) { if !strings.Contains(tbl.renderRow(1), strings.Repeat("A", 10)) {
t.Fatal("cursor row should show full title") 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 +266,8 @@ func TestDataTableShowsFullTextWhenSpaceSelected(t *testing.T) {
tbl.cursorRow = 0 tbl.cursorRow = 0
rowSelected := tbl.renderRow(1) rowSelected := tbl.renderRow(1)
if !strings.Contains(rowSelected, long) { if !strings.Contains(rowSelected, strings.Repeat("B", 10)) {
t.Fatal("space-selected row should show full title") t.Fatal("space-selected row should show title text within column")
} }
} }
@@ -146,3 +279,12 @@ func indexOfColumn(tbl DataTable, key string) int {
} }
return 0 return 0
} }
func indexOfColumnKey(cols []model.TableColumn, key string) int {
for i, c := range cols {
if c.Key == key {
return i
}
}
return 0
}
+4 -3
View File
@@ -8,18 +8,21 @@ require (
github.com/charmbracelet/bubbletea v0.25.0 github.com/charmbracelet/bubbletea v0.25.0
github.com/charmbracelet/glamour v0.8.0 github.com/charmbracelet/glamour v0.8.0
github.com/charmbracelet/lipgloss v0.12.1 github.com/charmbracelet/lipgloss v0.12.1
github.com/charmbracelet/x/ansi v0.1.4
github.com/eslider/go-xls/v2 v2.1.0 github.com/eslider/go-xls/v2 v2.1.0
github.com/google/go-querystring v1.2.0 github.com/google/go-querystring v1.2.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mattn/go-runewidth v0.0.15
github.com/muesli/termenv v0.16.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
) )
require ( require (
github.com/JohannesKaufmann/dom v0.3.1 // indirect github.com/JohannesKaufmann/dom v0.3.1 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/x/ansi v0.1.4 // indirect
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/dlclark/regexp2 v1.11.0 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
@@ -27,12 +30,10 @@ require (
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect github.com/spf13/pflag v1.0.9 // indirect
github.com/yuin/goldmark v1.8.2 // indirect github.com/yuin/goldmark v1.8.2 // indirect
+2
View File
@@ -8,6 +8,8 @@ github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
+20 -5
View File
@@ -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 // unmarshalResponseObject extracts the "response" field from a raw OnlyOffice
// envelope and decodes it into map[string]any. Returns (nil, nil) for a null // 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) { func unmarshalResponseObject(raw json.RawMessage) (map[string]any, error) {
resp, err := responseField(raw, "response") resp, err := responseField(raw, "response")
if err != nil { if err != nil {
@@ -122,11 +123,25 @@ func unmarshalResponseObject(raw json.RawMessage) (map[string]any, error) {
if len(resp) == 0 || string(resp) == "null" { if len(resp) == 0 || string(resp) == "null" {
return nil, nil return nil, nil
} }
var out map[string]any switch resp[0] {
if err := json.Unmarshal(resp, &out); err != nil { case '{':
return nil, err 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. // getJSON issues an authenticated GET and returns the raw response body.
+50
View File
@@ -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)
}
}
+35
View File
@@ -5,6 +5,8 @@ package onlyoffice
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"net/url"
"time" "time"
) )
@@ -39,6 +41,7 @@ type User struct {
MailSpace *int `json:"mailSpace,omitempty"` MailSpace *int `json:"mailSpace,omitempty"`
TalkSpace *int `json:"talkSpace,omitempty"` TalkSpace *int `json:"talkSpace,omitempty"`
ProfileURL *string `json:"profileUrl,omitempty"` ProfileURL *string `json:"profileUrl,omitempty"`
RegistrationDate *time.Time `json:"registrationDate,omitempty"`
Title *string `json:"title,omitempty"` Title *string `json:"title,omitempty"`
Sex *string `json:"sex,omitempty"` Sex *string `json:"sex,omitempty"`
Lead *string `json:"lead,omitempty"` Lead *string `json:"lead,omitempty"`
@@ -71,6 +74,38 @@ func (c *Client) GetUsers() (list []*User, err error) {
}{Response: &list}) }{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. // SelfUserID returns the ID of the authenticated user (people/@self), cached.
func (c *Client) SelfUserID(ctx context.Context) (string, error) { func (c *Client) SelfUserID(ctx context.Context) (string, error) {
if c.selfID != "" { if c.selfID != "" {