Compare commits

...
16 Commits
Author SHA1 Message Date
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1a93579635 chore(main): release 0.8.3 (#14)
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 38s
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-23 15:41:46 +01:00
eSliderandGitHub 76067f65d0 fix(ci): dispatch GoReleaser after release-please; allow Tests dispatch (#13)
GITHUB_TOKEN cannot trigger push:tags workflows, so Release never ran
after release-please. Dispatch release.yml explicitly when a release is
created. Add workflow_dispatch to Tests for on-demand main verification.
2026-07-23 15:38:57 +01:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
3b55de5b1d chore(main): release 0.8.2 (#12)
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.25) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 38s
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-23 15:34:27 +01:00
eSliderandGitHub 715ed23733 fix(ci): retry tag fetch in Release workflow (#11)
workflow_dispatch raced release-please: checkout of refs/tags/vX failed
because the tag was not fetched yet. Clone main first, retry-fetch the
tag, then check it out.
2026-07-23 15:31:56 +01:00
github-actions[bot]GitHubgithub-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
c7275b5c98 chore(main): release 0.8.1 (#10)
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 15:11:58 +01:00
eSliderandGitHub b9cea62d46 fix(oo): assign owner and deadline on task create (#9)
* fix(oo): assign owner and deadline on task create

CRM and project task create omitted responsibleId/responsibles, so the
portal attached a removed stub profile and CRM create without a deadline
failed or looked empty in the UI. Default both to the authenticated user
and now+14d; add crm-tasks reassign-self for backfill.

* fix(oo): collect all bad CRM tasks before reassign-self apply

Offset-0 apply stopped early when the first page was already fixed.
2026-07-23 15:01:23 +01:00
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
eSliderandCursor fab2977cb1 feat(office): mail preview, infinite scroll, pane resize, and scrollbars
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.22) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 38s
Polish the office TUI with proportional pane layout, drag resize, live
filtering, HTML mail preview, paginated mail lists, overflow scrollbars,
flattened navigation, and project save/status fixes; add UpdateProjectStatus.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 22:23:25 +01:00
eSliderandGitHub eed57a68d4 Merge pull request #4 from eSlider/release-please--branches--main--components--go-onlyoffice
Release Please / Release Please (push) Skipped
Tests / Test (Go 1.22) (push) Skipped
Tests / Test (Go stable) (push) Skipped
Release / GoReleaser (push) Failing after 37s
chore(main): release 0.6.0
2026-06-24 21:26:47 +01:00
github-actions[bot]andGitHub b2aae21390 chore(main): release 0.6.0 2026-06-24 16:36:03 +00:00
88 changed files with 5776 additions and 457 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
+13
View File
@@ -9,6 +9,7 @@ permissions:
contents: write contents: write
pull-requests: write pull-requests: write
issues: write issues: write
actions: write
jobs: jobs:
release-please: release-please:
@@ -23,6 +24,18 @@ jobs:
manifest-file: .release-please-manifest.json manifest-file: .release-please-manifest.json
token: ${{ secrets.RELEASE_PLEASE_TOKEN != '' && secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }} token: ${{ secrets.RELEASE_PLEASE_TOKEN != '' && secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
# Tags/releases created with GITHUB_TOKEN do not trigger other workflows
# (push:tags on Release.yml never fires). Dispatch GoReleaser explicitly.
- name: Trigger GoReleaser
if: ${{ steps.release.outputs.release_created == 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.release.outputs.tag_name }}
run: |
set -euo pipefail
echo "Dispatching Release workflow for $TAG"
gh workflow run release.yml --repo "${{ github.repository }}" -f "tag=${TAG}"
outputs: outputs:
release_created: ${{ steps.release.outputs.release_created }} release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }} tag_name: ${{ steps.release.outputs.tag_name }}
+19 -1
View File
@@ -18,10 +18,28 @@ jobs:
name: GoReleaser name: GoReleaser
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
# Always clone default branch first. workflow_dispatch often races with
# release-please creating the tag; fetching refs/tags/X before it exists
# fails checkout. Retry fetch, then check out the tag.
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/{0}', inputs.tag) || github.ref }}
- name: Checkout release tag
env:
TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }}
run: |
set -euo pipefail
echo "Releasing tag: $TAG"
for i in 1 2 3 4 5 6; do
if git fetch --force origin "refs/tags/${TAG}:refs/tags/${TAG}"; then
break
fi
echo "tag $TAG not visible yet (attempt $i); sleeping…"
sleep 10
done
git rev-parse "refs/tags/${TAG}"
git checkout --force "refs/tags/${TAG}"
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
+2 -1
View File
@@ -4,6 +4,7 @@ on:
push: push:
branches: [main] branches: [main]
pull_request: pull_request:
workflow_dispatch:
permissions: permissions:
contents: read contents: read
@@ -15,7 +16,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.5.0" ".": "0.8.3"
} }
+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.
+96
View File
@@ -4,6 +4,78 @@ 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.3](https://github.com/eSlider/go-onlyoffice/compare/v0.8.2...v0.8.3) (2026-07-23)
### Bug Fixes
* **ci:** dispatch GoReleaser after release-please; allow Tests dispatch ([#13](https://github.com/eSlider/go-onlyoffice/issues/13)) ([bf2fa63](https://github.com/eSlider/go-onlyoffice/commit/bf2fa637aaf0419ba5af40b25b64f9ebe24116e8))
## [0.8.2](https://github.com/eSlider/go-onlyoffice/compare/v0.8.1...v0.8.2) (2026-07-23)
### Bug Fixes
* **ci:** retry tag fetch in Release workflow ([#11](https://github.com/eSlider/go-onlyoffice/issues/11)) ([31b4aa0](https://github.com/eSlider/go-onlyoffice/commit/31b4aa0d90393e6d93513284e453b7da7e92c62d))
## [0.8.1](https://github.com/eSlider/go-onlyoffice/compare/v0.8.0...v0.8.1) (2026-07-23)
### Bug Fixes
* **oo:** assign owner and deadline on task create ([#9](https://github.com/eSlider/go-onlyoffice/issues/9)) ([f608207](https://github.com/eSlider/go-onlyoffice/commit/f60820734924284d6c40b46c79380a24573432f3))
## [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)
### Features
* **office:** table detail panes and Alt+1/2/3 layout toggles ([934af21](https://github.com/eSlider/go-onlyoffice/commit/934af21bc93a54df9f40aacb4d89468b00d7562e))
### Documentation
* CHANGELOG for office v0.5.1 release ([2985070](https://github.com/eSlider/go-onlyoffice/commit/29850701f8c9ab0c6d6427f63eaa189a1c558140))
## [0.5.0](https://github.com/eSlider/go-onlyoffice/compare/v0.4.0...v0.5.0) (2026-06-24) ## [0.5.0](https://github.com/eSlider/go-onlyoffice/compare/v0.4.0...v0.5.0) (2026-06-24)
@@ -40,6 +112,30 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [Unreleased]
## [0.7.0] — 2026-06-24
### Added — `office` TUI
- **Pane layout** — default **10% / 60% / 30%** split (nav / list / detail); drag vertical borders to resize; **Alt+1/2/3** still toggles panes.
- **Filter mode** (`f`) — live filter on nav + list; right pane is the search query; **Esc** clears.
- **Mail preview** — HTML bodies rendered colorized in the terminal (glamour); read-only scrollable document pane.
- **Mail infinite scroll** — loads the next page automatically when the cursor nears the end of the list.
- **Scrollbars** — vertical scrollbar on nav, list body, and detail preview when content exceeds the viewport.
- **Calendar** — unified calendars + events leaf with **Type** column and date range (−7d … +30d).
- **Tasks** — humanized status labels and relative deadlines in the list.
- **Projects** — open/closed row colors; status toggle in the detail form; **Save** sends `responsibleId` and status via `UpdateProjectStatus`.
### Changed — `office` TUI
- Flattened navigation: **Projects**, **Tasks**, **By project**, **Calendar**, **CRM**, **Mail**, **Users** (removed Browse; Calendar no longer splits Calendars/Events).
- Middle pane truncates overflowing cells with `…`; full text on the cursor or Space-selected row.
- Detail form tab order: Title → Description → Status → Save → Delete.
- **j/k** scrolls mail/file preview and read-only detail forms; mouse wheel scrolls detail when hovering the content area.
### Added — library
- `UpdateProjectStatus` — `PUT /api/2.0/project/{id}/status` for open/closed lifecycle.
## [0.5.1] — 2026-06-24 ## [0.5.1] — 2026-06-24
### Added — `office` TUI ### Added — `office` TUI
+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.
+53
View File
@@ -0,0 +1,53 @@
package fetch
import (
"context"
"fmt"
"time"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
// CalendarItemFields maps calendar API rows to list items.
var CalendarItemFields = FieldMap{IDKey: "objectId", TitleKey: "title"}
// CalendarItemsFromRows converts API calendar/event rows into list items.
func CalendarItemsFromRows(rows []map[string]any) []model.Item {
out := make([]model.Item, 0, len(rows))
for _, row := range rows {
kind, typeLabel := model.ClassifyCalendarRow(row)
title := str(row, "title")
if title == "" {
title = str(row, "name")
}
if title == "" {
title = "(untitled)"
}
raw := make(map[string]any, len(row)+1)
for k, v := range row {
raw[k] = v
}
raw["type"] = typeLabel
out = append(out, model.Item{
ID: idStr(row, "objectId"),
Title: title,
Kind: kind,
Raw: raw,
})
}
return out
}
// ListCalendar returns calendars and events for the upcoming date window.
func (l *Loader) ListCalendar(ctx context.Context) ([]model.Item, error) {
if l == nil || l.Client == nil {
return nil, fmt.Errorf("fetch: client is nil")
}
start := time.Now().AddDate(0, 0, -7).Format("2006-01-02")
end := time.Now().AddDate(0, 0, 30).Format("2006-01-02")
rows, err := l.Client.ListCalendars(ctx, start, end)
if err != nil {
return nil, err
}
return CalendarItemsFromRows(rows), nil
}
+1 -2
View File
@@ -39,8 +39,7 @@ func TestIntegrationListAllSubjects(t *testing.T) {
cases := []model.ListSpec{ cases := []model.ListSpec{
{Subject: model.SubjectProjects}, {Subject: model.SubjectProjects},
{Subject: model.SubjectTasks}, {Subject: model.SubjectTasks},
{Subject: model.SubjectCalendars}, {Subject: model.SubjectCalendar},
{Subject: model.SubjectEvents},
{Subject: model.SubjectContacts}, {Subject: model.SubjectContacts},
{Subject: model.SubjectPersons}, {Subject: model.SubjectPersons},
{Subject: model.SubjectCompanies}, {Subject: model.SubjectCompanies},
+45 -51
View File
@@ -6,12 +6,14 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"time"
onlyoffice "github.com/eslider/go-onlyoffice" onlyoffice "github.com/eslider/go-onlyoffice"
"github.com/eslider/go-onlyoffice/cmd/office/model" "github.com/eslider/go-onlyoffice/cmd/office/model"
) )
// MailListPageSize is how many messages each infinite-scroll fetch loads.
const MailListPageSize = 25
// Loader fetches list items for a menu subject using the OnlyOffice client. // Loader fetches list items for a menu subject using the OnlyOffice client.
type Loader struct { type Loader struct {
Client *onlyoffice.Client Client *onlyoffice.Client
@@ -30,10 +32,12 @@ func (l *Loader) List(ctx context.Context, spec model.ListSpec) ([]model.Item, e
return l.listTasksForProject(ctx, spec.ProjectID) return l.listTasksForProject(ctx, spec.ProjectID)
} }
return l.listTasks(ctx) return l.listTasks(ctx)
case model.SubjectCalendar:
return l.ListCalendar(ctx)
case model.SubjectCalendars: case model.SubjectCalendars:
return l.listCalendars(ctx) return l.ListCalendar(ctx)
case model.SubjectEvents: case model.SubjectEvents:
return l.listEvents(ctx) return l.ListCalendar(ctx)
case model.SubjectContacts: case model.SubjectContacts:
return l.listContacts(ctx, nil) return l.listContacts(ctx, nil)
case model.SubjectPersons: case model.SubjectPersons:
@@ -49,15 +53,15 @@ func (l *Loader) List(ctx context.Context, spec model.ListSpec) ([]model.Item, e
case model.SubjectCRMTasks: case model.SubjectCRMTasks:
return l.listCRMTasks(ctx) return l.listCRMTasks(ctx)
case model.SubjectMailInbox: case model.SubjectMailInbox:
return l.listMail(ctx, onlyoffice.MailFolderInbox) return l.listMail(ctx, onlyoffice.MailFolderInbox, 0)
case model.SubjectMailSent: case model.SubjectMailSent:
return l.listMail(ctx, onlyoffice.MailFolderSent) return l.listMail(ctx, onlyoffice.MailFolderSent, 0)
case model.SubjectMailDrafts: case model.SubjectMailDrafts:
return l.listMail(ctx, onlyoffice.MailFolderDrafts) return l.listMail(ctx, onlyoffice.MailFolderDrafts, 0)
case model.SubjectMailTrash: case model.SubjectMailTrash:
return l.listMail(ctx, onlyoffice.MailFolderTrash) return l.listMail(ctx, onlyoffice.MailFolderTrash, 0)
case model.SubjectMailSpam: case model.SubjectMailSpam:
return l.listMail(ctx, onlyoffice.MailFolderSpam) return l.listMail(ctx, onlyoffice.MailFolderSpam, 0)
case model.SubjectUsers: case model.SubjectUsers:
return l.listUsers(ctx) return l.listUsers(ctx)
case model.SubjectProjectFiles: case model.SubjectProjectFiles:
@@ -97,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
@@ -260,6 +266,9 @@ func (l *Loader) listProjects(ctx context.Context) ([]model.Item, error) {
if p.ParticipantCount != nil { if p.ParticipantCount != nil {
raw["participantCount"] = *p.ParticipantCount raw["participantCount"] = *p.ParticipantCount
} }
if p.Status != nil {
raw["status"] = *p.Status
}
items[i] = model.Item{ items[i] = model.Item{
ID: id, Title: title, Kind: model.KindProject, Raw: raw, ID: id, Title: title, Kind: model.KindProject, Raw: raw,
} }
@@ -275,24 +284,6 @@ func (l *Loader) listTasks(ctx context.Context) ([]model.Item, error) {
return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil return ItemsFromMaps(rows, model.KindTask, TaskItemFields), nil
} }
func (l *Loader) listCalendars(ctx context.Context) ([]model.Item, error) {
rows, err := l.Client.ListCalendars(ctx, "", "")
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindCalendar, FieldMap{IDKey: "objectId", TitleKey: "title"}), nil
}
func (l *Loader) listEvents(ctx context.Context) ([]model.Item, error) {
start := time.Now().Format("2006-01-02")
end := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
rows, err := l.Client.ListEvents(ctx, start, end)
if err != nil {
return nil, err
}
return ItemsFromMaps(rows, model.KindEvent, FieldMap{IDKey: "objectId", TitleKey: "title", SubtitleKey: "start"}), nil
}
func (l *Loader) listContacts(ctx context.Context, companyOnly *bool) ([]model.Item, error) { func (l *Loader) listContacts(ctx context.Context, companyOnly *bool) ([]model.Item, error) {
rows, err := l.Client.ListAllContacts(ctx) rows, err := l.Client.ListAllContacts(ctx)
if err != nil { if err != nil {
@@ -335,39 +326,42 @@ func (l *Loader) listCRMTasks(ctx context.Context) ([]model.Item, error) {
return ItemsFromMaps(rows, model.KindCRMTask, TaskItemFields), nil return ItemsFromMaps(rows, model.KindCRMTask, TaskItemFields), nil
} }
func (l *Loader) listMail(ctx context.Context, folder int) ([]model.Item, error) { func (l *Loader) listMail(ctx context.Context, folder int, startIndex int) ([]model.Item, error) {
rows, err := l.Client.ListMailMessages(ctx, onlyoffice.MailMessagesFilter{Folder: folder, Count: 50}) rows, err := l.Client.ListMailMessages(ctx, onlyoffice.MailMessagesFilter{
Folder: folder,
Count: MailListPageSize,
StartIndex: startIndex,
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
return ItemsFromMaps(rows, model.KindMail, MailItemFields), nil return ItemsFromMaps(rows, model.KindMail, MailItemFields), nil
} }
func (l *Loader) listUsers(ctx context.Context) ([]model.Item, error) { // ListMailMore loads the next page of messages for an active mail folder list.
users, err := l.Client.GetUsers() func (l *Loader) ListMailMore(ctx context.Context, spec model.ListSpec, startIndex int) ([]model.Item, error) {
if err != nil { folder, ok := mailFolderForSubject(spec.Subject)
return nil, err if !ok {
return nil, fmt.Errorf("fetch: %q is not a mail folder", spec.Subject)
} }
items := make([]model.Item, len(users)) return l.listMail(ctx, folder, startIndex)
for i, u := range users { }
id := ""
if u.ID != nil { func mailFolderForSubject(subject model.Subject) (int, bool) {
id = fmt.Sprint(*u.ID) switch subject {
case model.SubjectMailInbox:
return onlyoffice.MailFolderInbox, true
case model.SubjectMailSent:
return onlyoffice.MailFolderSent, true
case model.SubjectMailDrafts:
return onlyoffice.MailFolderDrafts, true
case model.SubjectMailTrash:
return onlyoffice.MailFolderTrash, true
case model.SubjectMailSpam:
return onlyoffice.MailFolderSpam, true
default:
return 0, false
} }
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) {
+1 -1
View File
@@ -14,7 +14,7 @@ type FieldMap struct {
} }
// TaskItemFields is the default field map for project tasks. // TaskItemFields is the default field map for project tasks.
var TaskItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"} var TaskItemFields = FieldMap{IDKey: "id", TitleKey: "title"}
// ProjectItemFields is the default field map for projects. // ProjectItemFields is the default field map for projects.
var ProjectItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"} var ProjectItemFields = FieldMap{IDKey: "id", TitleKey: "title", SubtitleKey: "status"}
+44 -13
View File
@@ -10,34 +10,65 @@ import (
) )
// SaveItem persists editable form fields for supported entity kinds. // SaveItem persists editable form fields for supported entity kinds.
func (l *Loader) SaveItem(ctx context.Context, item model.Item, title, description string) error { func (l *Loader) SaveItem(ctx context.Context, item model.Item, fields model.FormFields) error {
if l == nil || l.Client == nil { if l == nil || l.Client == nil {
return fmt.Errorf("fetch: client is nil") return fmt.Errorf("fetch: client is nil")
} }
switch item.Kind { switch item.Kind {
case model.KindTask: case model.KindTask:
return l.UpdateTask(ctx, item.ID, title, description) return l.UpdateTask(ctx, item.ID, fields)
case model.KindProject: case model.KindProject:
id, err := strconv.Atoi(item.ID) return l.saveProject(ctx, item.ID, fields)
if err != nil { case model.KindUser:
return err return l.SaveUser(ctx, item.ID, fields)
}
_, err = l.Client.UpdateProject(onlyoffice.ProjectUpdateRequest{
ID: id,
Title: title,
Description: description,
})
return err
default: default:
return fmt.Errorf("save not supported for %s", item.Kind) return fmt.Errorf("save not supported for %s", item.Kind)
} }
} }
func (l *Loader) saveProject(ctx context.Context, projectID string, fields model.FormFields) error {
id, err := strconv.Atoi(projectID)
if err != nil {
return err
}
if fields.ResponsibleID == "" {
raw, derr := l.Detail(ctx, model.Item{ID: projectID, Kind: model.KindProject})
if derr == nil {
fields.ResponsibleID = model.ResponsibleIDFromRaw(raw)
}
}
req := onlyoffice.ProjectUpdateRequest{
ID: id,
Title: fields.Primary,
Description: fields.Secondary,
}
if fields.ResponsibleID != "" {
req.ResponsibleID = fields.ResponsibleID
}
if _, err := l.Client.UpdateProject(req); err != nil {
return err
}
if fields.HasStatus {
if _, err := l.Client.UpdateProjectStatus(id, string(fields.Status)); err != nil {
return err
}
}
return nil
}
// DetailForm loads form field values for the detail pane. // DetailForm loads form field values for the detail pane.
func (l *Loader) DetailForm(ctx context.Context, item model.Item) (model.FormFields, error) { func (l *Loader) DetailForm(ctx context.Context, item model.Item) (model.FormFields, error) {
raw, err := l.Detail(ctx, item) raw, err := l.Detail(ctx, item)
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
} }
+52
View File
@@ -0,0 +1,52 @@
//go:build integration
package fetch_test
import (
"testing"
"time"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestIntegrationSaveProject(t *testing.T) {
loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectProjects})
if err != nil {
t.Fatal(err)
}
if len(items) == 0 {
t.Skip("no projects")
}
item := items[0]
fields, err := loader.DetailForm(ctx, item)
if err != nil {
t.Fatalf("DetailForm: %v", err)
}
if fields.ResponsibleID == "" {
t.Skip("project has no responsible id on this instance")
}
marker := " office-save-test " + time.Now().UTC().Format(time.RFC3339)
fields.Primary = fields.Primary + marker
if err := loader.SaveItem(ctx, item, fields); err != nil {
t.Fatalf("SaveItem: %v", err)
}
after, err := loader.DetailForm(ctx, item)
if err != nil {
t.Fatalf("DetailForm after save: %v", err)
}
if after.Primary != fields.Primary {
t.Fatalf("title not saved: got %q want %q", after.Primary, fields.Primary)
}
fields.Primary = stringsTrimSuffixMarker(after.Primary, marker)
if err := loader.SaveItem(ctx, item, fields); err != nil {
t.Logf("cleanup save: %v", err)
}
}
func stringsTrimSuffixMarker(s, marker string) string {
if len(s) >= len(marker) && s[len(s)-len(marker):] == marker {
return s[:len(s)-len(marker)]
}
return s
}
+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
+40
View File
@@ -0,0 +1,40 @@
package model
import "fmt"
// ClassifyCalendarRow decides whether an API row is a calendar or an event.
func ClassifyCalendarRow(raw map[string]any) (Kind, string) {
if raw == nil {
return KindCalendar, "Calendar"
}
if strRaw(raw, "start") != "" || strRaw(raw, "end") != "" {
return KindEvent, "Event"
}
if strRaw(raw, "eventType") != "" {
return KindEvent, "Event"
}
return KindCalendar, "Calendar"
}
// CalendarTypeLabel returns the type column text for a calendar list row.
func CalendarTypeLabel(it Item) string {
if it.Raw != nil {
if t := strRaw(it.Raw, "type"); t != "" {
return t
}
}
_, label := ClassifyCalendarRow(it.Raw)
return label
}
// FormatCalendarDateTime formats start/end timestamps for the calendar table.
func FormatCalendarDateTime(v any) string {
t, ok := parseDeadlineTime(v)
if !ok {
if s := fmt.Sprint(v); s != "" && s != "<nil>" {
return s
}
return ""
}
return t.Format("Jan 2 15:04")
}
+37
View File
@@ -0,0 +1,37 @@
package model
import "testing"
func TestClassifyCalendarRow(t *testing.T) {
kind, label := ClassifyCalendarRow(map[string]any{
"title": "Standup",
"start": "2026-06-24T10:00:00Z",
})
if kind != KindEvent || label != "Event" {
t.Fatalf("got kind=%s label=%q", kind, label)
}
kind, label = ClassifyCalendarRow(map[string]any{"title": "Work"})
if kind != KindCalendar || label != "Calendar" {
t.Fatalf("got kind=%s label=%q", kind, label)
}
}
func TestBuildCalendarColumnsIncludesType(t *testing.T) {
items := []Item{{
ID: "1", Title: "Meet", Kind: KindEvent,
Raw: map[string]any{"type": "Event", "start": "2026-06-24T10:00:00Z"},
}}
cols := BuildColumns(SubjectCalendar, items)
found := false
for _, c := range cols {
if c.Key == "type" {
found = true
}
if c.Key == "subtitle" {
t.Fatal("subtitle column should not appear")
}
}
if !found {
t.Fatal("expected type column")
}
}
+119 -6
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,15 @@ 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 {
return buildTaskColumns(items)
}
if subject == SubjectCalendar {
return buildCalendarColumns(items)
}
cols := []TableColumn{ cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3}, {Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 10}, {Key: "id", Title: "ID", Width: 10},
@@ -64,19 +72,62 @@ func BuildColumns(subject Subject, items []Item) []TableColumn {
return cols return cols
} }
func buildProjectColumns(items []Item) []TableColumn { func buildCalendarColumns(items []Item) []TableColumn {
cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 10},
{Key: "type", Title: "Type", Width: 10},
{Key: "title", Title: "Title", Width: 28},
}
for _, key := range []string{"start", "end"} {
if columnHasData(items, key) {
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
}
}
sizeColumns(cols, items)
return cols
}
func buildTaskColumns(items []Item) []TableColumn {
cols := []TableColumn{ cols := []TableColumn{
{Key: "_sel", Title: "✓", Width: 3}, {Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 8}, {Key: "id", Title: "ID", Width: 8},
{Key: "title", Title: "Title", Width: 28}, {Key: "title", Title: "Title", Width: 28},
{Key: "tasks", Title: "Tasks (open/closed)", Width: 18}, {Key: "status", Title: "Status", Width: 10},
{Key: "documents", Title: "Documents", Width: 11}, }
{Key: "users", Title: "Users", Width: 8}, for _, key := range []string{"deadline", "responsible"} {
if columnHasData(items, key) {
cols = append(cols, TableColumn{Key: key, Title: titleLabel(key), Width: defaultWidth(key)})
}
} }
sizeColumns(cols, items) sizeColumns(cols, items)
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 {
_ = items
return []TableColumn{
{Key: "_sel", Title: "✓", Width: 3},
{Key: "id", Title: "ID", Width: 8},
{Key: "status", Title: "●", Width: 10},
{Key: "title", Title: "Title", Width: 16},
{Key: "tasks", Title: "Tasks", Width: 11},
{Key: "documents", Title: "Docs", Width: 7},
{Key: "users", Title: "Users", Width: 7},
}
}
// CellText returns the display string for one table cell. // CellText returns the display string for one table cell.
func CellText(it Item, key string) string { func CellText(it Item, key string) string {
switch key { switch key {
@@ -89,8 +140,70 @@ func CellText(it Item, key string) string {
return it.ID return it.ID
case "title": case "title":
return it.Title return it.Title
case "type":
return CalendarTypeLabel(it)
case "start", "end":
if it.Raw == nil {
return ""
}
if formatted := FormatCalendarDateTime(it.Raw[key]); formatted != "" {
return formatted
}
return formatAny(it.Raw[key])
case "status":
switch it.Kind {
case KindProject:
return ProjectStatusCell(it.Raw)
case KindUser:
return UserStatusLabel(it.Raw)
case KindTask, KindCRMTask:
if it.Raw == nil {
return ""
}
return TaskStatusLabel(it.Raw["status"])
default:
if it.Raw == nil {
return ""
}
return formatAny(it.Raw["status"])
}
case "deadline":
if it.Raw == nil {
return ""
}
// CRM tasks use camelCase deadLine; project tasks use deadline.
rawDL := it.Raw["deadline"]
if rawDL == nil || formatAny(rawDL) == "" {
rawDL = it.Raw["deadLine"]
}
if rel := FormatRelativeDeadline(rawDL); rel != "" {
return rel
}
return formatAny(rawDL)
case "responsible":
if it.Kind == KindTask || it.Kind == KindCRMTask {
return TaskResponsibleLabel(it.Raw)
}
if it.Raw == nil {
return ""
}
return formatAny(it.Raw["responsible"])
case "subtitle": case "subtitle":
return it.Subtitle return it.Subtitle
case "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":
@@ -230,7 +343,7 @@ func defaultWidth(key string) int {
case "id", "status", "stage": case "id", "status", "stage":
return 10 return 10
case "start", "end", "deadline", "date": case "start", "end", "deadline", "date":
return 18 return 20
case "from", "to", "email", "primaryEmail": case "from", "to", "email", "primaryEmail":
return 22 return 22
case "description", "displayName": case "description", "displayName":
+26 -2
View File
@@ -11,7 +11,7 @@ func TestBuildProjectColumns(t *testing.T) {
}, },
}} }}
cols := BuildColumns(SubjectProjects, items) cols := BuildColumns(SubjectProjects, items)
want := []string{"_sel", "id", "title", "tasks", "documents", "users"} want := []string{"_sel", "id", "status", "title", "tasks", "documents", "users"}
if len(cols) != len(want) { if len(cols) != len(want) {
t.Fatalf("got %d columns, want %d", len(cols), len(want)) t.Fatalf("got %d columns, want %d", len(cols), len(want))
} }
@@ -21,12 +21,36 @@ func TestBuildProjectColumns(t *testing.T) {
} }
} }
for _, c := range cols { for _, c := range cols {
if c.Key == "status" || c.Key == "subtitle" { if c.Key == "subtitle" {
t.Fatalf("unexpected column %q", c.Key) t.Fatalf("unexpected column %q", c.Key)
} }
} }
} }
func TestCellTextProjectStatus(t *testing.T) {
open := Item{Kind: KindProject, Raw: map[string]any{"status": 0}}
if got := CellText(open, "status"); got != "🟢 Open" {
t.Fatalf("got %q", got)
}
closed := Item{Kind: KindProject, Raw: map[string]any{"status": 2}}
if got := CellText(closed, "status"); got != "🔴 Closed" {
t.Fatalf("got %q", got)
}
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) {
raw := map[string]any{"taskCount": 3, "taskCountTotal": 8} raw := map[string]any{"taskCount": 3, "taskCountTotal": 8}
if got := formatProjectTasks(raw); got != "3/5" { if got := formatProjectTasks(raw); got != "3/5" {
+34
View File
@@ -0,0 +1,34 @@
package model
import (
"strings"
)
// FilterItems returns items whose searchable text contains query (case-insensitive).
func FilterItems(items []Item, query string) []Item {
q := strings.ToLower(strings.TrimSpace(query))
if q == "" {
out := make([]Item, len(items))
copy(out, items)
return out
}
out := make([]Item, 0, len(items))
for _, it := range items {
if strings.Contains(strings.ToLower(ItemSearchText(it)), q) {
out = append(out, it)
}
}
return out
}
// ItemSearchText joins item fields used for list filtering.
func ItemSearchText(it Item) string {
var parts []string
parts = append(parts, it.ID, it.Title, it.Subtitle, string(it.Kind))
if it.Raw != nil {
for _, v := range it.Raw {
parts = append(parts, formatAny(v))
}
}
return strings.Join(parts, " ")
}
+26
View File
@@ -0,0 +1,26 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestFilterItemsByTitle(t *testing.T) {
items := []model.Item{
{ID: "1", Title: "Alpha task"},
{ID: "2", Title: "Beta task"},
}
got := model.FilterItems(items, "alpha")
if len(got) != 1 || got[0].ID != "1" {
t.Fatalf("got %#v", got)
}
}
func TestFilterItemsEmptyQueryReturnsAll(t *testing.T) {
items := []model.Item{{ID: "1", Title: "A"}, {ID: "2", Title: "B"}}
got := model.FilterItems(items, " ")
if len(got) != 2 {
t.Fatalf("got len=%d want 2", len(got))
}
}
+124 -6
View File
@@ -9,6 +9,20 @@ type FormFields struct {
Primary string Primary string
Secondary string Secondary string
ReadOnly bool ReadOnly bool
HasStatus bool
Status ProjectLifecycle
HasTaskStatus bool
TaskStatus TaskLifecycle
HasResponsible bool
ResponsibleID string
UserChoices []UserOption
ProjectTitle string
TimingSummary string
HasUserEdit bool
UserEnabled bool
UserACL UserACLState
GroupsText string
UserPassword string
} }
// KindHeading returns a short label for the detail pane header. // KindHeading returns a short label for the detail pane header.
@@ -59,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")
@@ -80,6 +97,35 @@ func FormFieldsFromRaw(kind Kind, raw map[string]any) FormFields {
Primary: strRaw(raw, "title"), Secondary: strRaw(raw, "description"), Primary: strRaw(raw, "title"), Secondary: strRaw(raw, "description"),
ReadOnly: true, ReadOnly: true,
} }
case KindProject:
title := strRaw(raw, "title")
if title == "" {
title = strRaw(raw, "name")
}
return FormFields{
PrimaryLabel: "Title",
SecondaryLabel: "Description",
Primary: title,
Secondary: strRaw(raw, "description"),
ReadOnly: false,
HasStatus: true,
Status: ProjectStatusFromAny(raw["status"]),
ResponsibleID: ResponsibleIDFromRaw(raw),
}
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 == "" {
@@ -91,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 ""
@@ -109,7 +215,19 @@ func strRaw(m map[string]any, key string) string {
return fmt.Sprint(m[key]) return fmt.Sprint(m[key])
} }
// IsDocumentKind is true when the right pane should show file content, not a form. // ResponsibleIDFromRaw extracts the project responsible user id from API detail.
func ResponsibleIDFromRaw(raw map[string]any) string {
if id := strRaw(raw, "responsibleId"); id != "" {
return id
}
resp, ok := raw["responsible"].(map[string]any)
if !ok {
return ""
}
return strRaw(resp, "id")
}
// IsDocumentKind is true when the right pane should show rendered preview content, not an editable form.
func IsDocumentKind(kind Kind) bool { func IsDocumentKind(kind Kind) bool {
return kind == KindFile return kind == KindFile || kind == KindMail
} }
+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")
}
}
+3
View File
@@ -14,6 +14,9 @@ func TestIsDocumentKind(t *testing.T) {
if !IsDocumentKind(KindFile) { if !IsDocumentKind(KindFile) {
t.Fatal("file should be document") t.Fatal("file should be document")
} }
if !IsDocumentKind(KindMail) {
t.Fatal("mail should be document preview")
}
if IsDocumentKind(KindTask) { if IsDocumentKind(KindTask) {
t.Fatal("task should be form") t.Fatal("task should be form")
} }
+11
View File
@@ -39,8 +39,19 @@ const (
SubjectProjectFiles Subject = "project_files" SubjectProjectFiles Subject = "project_files"
SubjectTaskFiles Subject = "task_files" SubjectTaskFiles Subject = "task_files"
SubjectUsers Subject = "users" SubjectUsers Subject = "users"
SubjectCalendar Subject = "calendar"
) )
// SubjectIsMail reports whether a list subject loads mailbox messages.
func SubjectIsMail(s Subject) bool {
switch s {
case SubjectMailInbox, SubjectMailSent, SubjectMailDrafts, SubjectMailTrash, SubjectMailSpam:
return true
default:
return false
}
}
// FocusPane is which column has keyboard focus. // FocusPane is which column has keyboard focus.
type FocusPane int type FocusPane int
+12
View File
@@ -0,0 +1,12 @@
package model
import "testing"
func TestSubjectIsMail(t *testing.T) {
if !SubjectIsMail(SubjectMailInbox) {
t.Fatal("inbox should be mail")
}
if SubjectIsMail(SubjectProjects) {
t.Fatal("projects should not be mail")
}
}
+59 -15
View File
@@ -1,5 +1,7 @@
package model package model
import "strings"
// ListSpec tells the fetch layer what to load when a nav leaf is active. // ListSpec tells the fetch layer what to load when a nav leaf is active.
type ListSpec struct { type ListSpec struct {
Subject Subject Subject Subject
@@ -24,6 +26,7 @@ type NavTree struct {
expanded map[string]bool expanded map[string]bool
cursor int cursor int
visible []string visible []string
filter string
} }
// DefaultNavTree returns the OnlyOffice module tree (static skeleton). // DefaultNavTree returns the OnlyOffice module tree (static skeleton).
@@ -42,15 +45,11 @@ func DefaultNavTree() *NavTree {
} }
} }
add("projects", "Projects", "", true, nil) add("projects", "Projects", "", false, &ListSpec{Subject: SubjectProjects})
add("projects.browse", "Browse", "projects", true, nil) add("tasks", "Tasks", "", false, &ListSpec{Subject: SubjectTasks})
add("projects.browse.all", "All projects", "projects.browse", false, &ListSpec{Subject: SubjectProjects}) add("projects.dynamic", "By project", "", true, nil)
add("projects.browse.tasks", "All tasks", "projects.browse", false, &ListSpec{Subject: SubjectTasks})
add("projects.dynamic", "By project", "projects", true, nil)
add("calendar", "Calendar", "", true, nil) add("calendar", "Calendar", "", false, &ListSpec{Subject: SubjectCalendar})
add("calendar.cals", "Calendars", "calendar", false, &ListSpec{Subject: SubjectCalendars})
add("calendar.events", "Events", "calendar", false, &ListSpec{Subject: SubjectEvents})
add("crm", "CRM", "", true, nil) add("crm", "CRM", "", true, nil)
add("crm.contacts", "Contacts", "crm", false, &ListSpec{Subject: SubjectContacts}) add("crm.contacts", "Contacts", "crm", false, &ListSpec{Subject: SubjectContacts})
@@ -67,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
@@ -185,20 +183,66 @@ func (t *NavTree) Activate() (*ListSpec, bool) {
return nil, false return nil, false
} }
func (t *NavTree) SetFilter(query string) {
t.filter = strings.ToLower(strings.TrimSpace(query))
t.rebuildVisible()
}
func (t *NavTree) ClearFilter() {
t.filter = ""
t.rebuildVisible()
}
func (t *NavTree) FilterQuery() string { return t.filter }
func (t *NavTree) nodeLabelMatches(id string) bool {
if t.filter == "" {
return true
}
n, ok := t.nodes[id]
if !ok {
return false
}
return strings.Contains(strings.ToLower(n.Label), t.filter)
}
func (t *NavTree) hasMatchingDescendant(id string) bool {
for _, child := range t.children[id] {
if t.nodeLabelMatches(child) || t.hasMatchingDescendant(child) {
return true
}
}
return false
}
func (t *NavTree) visibleUnderFilter(id string) bool {
if t.filter == "" {
return true
}
return t.nodeLabelMatches(id) || t.hasMatchingDescendant(id)
}
func (t *NavTree) rebuildVisible() { func (t *NavTree) rebuildVisible() {
t.visible = t.visible[:0] t.visible = t.visible[:0]
var walk func(id string, depth int) var walk func(id string)
walk = func(id string, depth int) { walk = func(id string) {
if !t.visibleUnderFilter(id) {
return
}
t.visible = append(t.visible, id) t.visible = append(t.visible, id)
if !t.expanded[id] { expanded := t.expanded[id]
if t.filter != "" && t.hasMatchingDescendant(id) {
expanded = true
}
if !expanded {
return return
} }
for _, child := range t.children[id] { for _, child := range t.children[id] {
walk(child, depth+1) walk(child)
} }
} }
for _, root := range t.roots { for _, root := range t.roots {
walk(root, 0) walk(root)
} }
if t.cursor >= len(t.visible) { if t.cursor >= len(t.visible) {
t.cursor = len(t.visible) - 1 t.cursor = len(t.visible) - 1
+88 -16
View File
@@ -9,7 +9,7 @@ import (
func TestNavTreeHasExpectedRoots(t *testing.T) { func TestNavTreeHasExpectedRoots(t *testing.T) {
tree := model.DefaultNavTree() tree := model.DefaultNavTree()
roots := tree.RootLabels() roots := tree.RootLabels()
want := []string{"Projects", "Calendar", "CRM", "Mail", "Users"} want := []string{"Projects", "Tasks", "By project", "Calendar", "CRM", "Mail", "Users"}
if len(roots) != len(want) { if len(roots) != len(want) {
t.Fatalf("roots=%v want %v", roots, want) t.Fatalf("roots=%v want %v", roots, want)
} }
@@ -22,42 +22,79 @@ func TestNavTreeHasExpectedRoots(t *testing.T) {
func TestNavExpandCollapse(t *testing.T) { func TestNavExpandCollapse(t *testing.T) {
tree := model.DefaultNavTree() tree := model.DefaultNavTree()
if !tree.IsExpandable(0) { var crm int = -1
t.Fatal("Projects should be expandable") for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "CRM" {
crm = i
break
} }
tree.ToggleExpand(0)
if !tree.IsExpanded(0) {
t.Fatal("Projects should expand")
} }
tree.ToggleExpand(0) if crm < 0 {
if tree.IsExpanded(0) { t.Fatal("CRM node not found")
t.Fatal("Projects should collapse") }
if !tree.IsExpandable(crm) {
t.Fatal("CRM should be expandable")
}
tree.ToggleExpand(crm)
if !tree.IsExpanded(crm) {
t.Fatal("CRM should expand")
}
tree.ToggleExpand(crm)
if tree.IsExpanded(crm) {
t.Fatal("CRM should collapse")
} }
} }
func TestNavLeafReturnsListSpec(t *testing.T) { func TestNavCalendarLeafReturnsListSpec(t *testing.T) {
tree := model.DefaultNavTree() tree := model.DefaultNavTree()
tree.ToggleExpand(0) // Projects
tree.ToggleExpand(1) // Browse
// Find "All projects" leaf cursor
var found int = -1 var found int = -1
for i := 0; i < tree.VisibleCount(); i++ { for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i) n, ok := tree.NodeAtVisible(i)
if ok && n.List != nil && n.List.Subject == model.SubjectProjects { if ok && n.Label == "Calendar" {
found = i found = i
break break
} }
} }
if found < 0 { if found < 0 {
t.Fatal("all projects leaf not found") t.Fatal("Calendar node not found")
} }
tree.SetCursor(found) tree.SetCursor(found)
spec, ok := tree.CurrentListSpec() spec, ok := tree.CurrentListSpec()
if !ok || spec.Subject != model.SubjectProjects { if !ok || spec.Subject != model.SubjectCalendar {
t.Fatalf("spec=%v ok=%v", spec, ok) t.Fatalf("spec=%v ok=%v", spec, ok)
} }
} }
func TestNavProjectsLeafReturnsListSpec(t *testing.T) {
tree := model.DefaultNavTree()
tree.SetCursor(0)
spec, ok := tree.CurrentListSpec()
if !ok || spec.Subject != model.SubjectProjects {
t.Fatalf("projects spec=%v ok=%v", spec, ok)
}
}
func TestNavTasksLeafReturnsListSpec(t *testing.T) {
tree := model.DefaultNavTree()
var found int = -1
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "Tasks" {
found = i
break
}
}
if found < 0 {
t.Fatal("Tasks node not found")
}
tree.SetCursor(found)
spec, ok := tree.CurrentListSpec()
if !ok || spec.Subject != model.SubjectTasks {
t.Fatalf("tasks spec=%v ok=%v", spec, ok)
}
}
func TestPrevFocusPane(t *testing.T) { func TestPrevFocusPane(t *testing.T) {
if got := model.PrevFocusPane(model.FocusList); got != model.FocusMenu { if got := model.PrevFocusPane(model.FocusList); got != model.FocusMenu {
t.Fatalf("got %v", got) t.Fatalf("got %v", got)
@@ -67,6 +104,41 @@ func TestPrevFocusPane(t *testing.T) {
} }
} }
func TestNavFilterByLabel(t *testing.T) {
tree := model.DefaultNavTree()
tree.SetFilter("inbox")
found := false
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "Inbox" {
found = true
break
}
}
if !found {
t.Fatal("Inbox should remain visible when filtering mail")
}
for i := 0; i < tree.VisibleCount(); i++ {
n, ok := tree.NodeAtVisible(i)
if ok && n.Label == "Calendar" {
t.Fatal("Calendar should be hidden when filtering inbox")
}
}
}
func TestNavClearFilterRestoresTree(t *testing.T) {
tree := model.DefaultNavTree()
before := tree.VisibleCount()
tree.SetFilter("zzz-no-match")
if tree.VisibleCount() >= before {
t.Fatalf("filter should shrink visible nodes")
}
tree.ClearFilter()
if tree.VisibleCount() != before {
t.Fatalf("clear filter should restore visible count")
}
}
func TestActionsForContact(t *testing.T) { func TestActionsForContact(t *testing.T) {
acts := model.ActionsFor(model.KindContact) acts := model.ActionsFor(model.KindContact)
if len(acts) != 1 || acts[0].ID != model.ActionDelete { if len(acts) != 1 || acts[0].ID != model.ActionDelete {
+106
View File
@@ -0,0 +1,106 @@
package model
// ProjectLifecycle is the OnlyOffice project status string accepted by the API.
type ProjectLifecycle string
const (
ProjectLifecycleOpen ProjectLifecycle = "open"
ProjectLifecyclePaused ProjectLifecycle = "paused"
ProjectLifecycleClosed ProjectLifecycle = "closed"
)
// ProjectStatusFromAny maps API status (int or string) to a lifecycle value.
func ProjectStatusFromAny(v any) ProjectLifecycle {
switch x := v.(type) {
case string:
switch ProjectLifecycle(x) {
case ProjectLifecycleOpen, ProjectLifecyclePaused, ProjectLifecycleClosed:
return ProjectLifecycle(x)
}
case float64:
return projectStatusFromInt(int(x))
case int:
return projectStatusFromInt(x)
case int64:
return projectStatusFromInt(int(x))
}
return ProjectLifecycleOpen
}
func projectStatusFromInt(n int) ProjectLifecycle {
switch n {
case 1:
return ProjectLifecyclePaused
case 2:
return ProjectLifecycleClosed
default:
return ProjectLifecycleOpen
}
}
// ProjectStatusLabel returns a short UI label for list cells.
func ProjectStatusLabel(raw map[string]any) string {
if raw == nil {
return "Open"
}
return ProjectStatusFromAny(raw["status"]).Label()
}
// ProjectStatusIcon returns the list-cell emoji for a project lifecycle state.
func (s ProjectLifecycle) Icon() string {
switch s {
case ProjectLifecycleClosed:
return "🔴"
case ProjectLifecyclePaused:
return "🟡"
default:
return "🟢"
}
}
// ProjectStatusCell returns emoji and label on one line for project table cells.
func ProjectStatusCell(raw map[string]any) string {
if raw == nil {
return ProjectLifecycleOpen.Icon() + " Open"
}
s := ProjectStatusFromAny(raw["status"])
return s.Icon() + " " + s.Label()
}
// ProjectIsOpen is true when the project is not closed.
func ProjectIsOpen(raw map[string]any) bool {
if raw == nil {
return true
}
return ProjectStatusFromAny(raw["status"]) != ProjectLifecycleClosed
}
// Label returns a human-readable status name.
func (s ProjectLifecycle) Label() string {
switch s {
case ProjectLifecycleClosed:
return "Closed"
case ProjectLifecyclePaused:
return "Paused"
default:
return "Open"
}
}
// ToggleOpenClosed flips between open and closed (paused maps to closed on toggle).
func (s ProjectLifecycle) ToggleOpenClosed() ProjectLifecycle {
if s == ProjectLifecycleClosed {
return ProjectLifecycleOpen
}
return ProjectLifecycleClosed
}
// Next cycles open → closed → open for keyboard toggling.
func (s ProjectLifecycle) Next() ProjectLifecycle {
return s.ToggleOpenClosed()
}
// Prev cycles closed → open → closed.
func (s ProjectLifecycle) Prev() ProjectLifecycle {
return s.ToggleOpenClosed()
}
+26
View File
@@ -0,0 +1,26 @@
package model_test
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestProjectStatusFromInt(t *testing.T) {
if got := model.ProjectStatusFromAny(2); got != model.ProjectLifecycleClosed {
t.Fatalf("got %q", got)
}
if got := model.ProjectStatusFromAny(0); got != model.ProjectLifecycleOpen {
t.Fatalf("got %q", got)
}
}
func TestProjectStatusToggle(t *testing.T) {
s := model.ProjectLifecycleOpen
if s.Next() != model.ProjectLifecycleClosed {
t.Fatal("expected closed")
}
if s.Next().Prev() != model.ProjectLifecycleOpen {
t.Fatal("expected open after toggle back")
}
}
+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")
}
}
+188
View File
@@ -0,0 +1,188 @@
package model
import (
"fmt"
"strconv"
"strings"
"time"
)
// TaskStatusLabel returns a human-readable project/CRM task status.
func TaskStatusLabel(v any) string {
if v == nil {
return ""
}
if s, ok := v.(string); ok {
if label := taskStatusFromString(s); label != "" {
return label
}
}
n := intRawVal(map[string]any{"status": v}, "status")
switch n {
case 0:
return "Not accepted"
case 1:
return "Open"
case 2:
return "Closed"
case 3:
return "Disabled"
case 4:
return "Unclassified"
case 5:
return "Not in milestone"
default:
if s := fmt.Sprint(v); s != "" && s != "<nil>" {
return s
}
return ""
}
}
func taskStatusFromString(s string) string {
switch strings.ToLower(strings.TrimSpace(s)) {
case "open":
return "Open"
case "closed":
return "Closed"
case "notaccept", "not accept", "not accepted":
return "Not accepted"
case "disable", "disabled":
return "Disabled"
case "unclassified":
return "Unclassified"
case "notinmilestone", "not in milestone":
return "Not in milestone"
default:
return ""
}
}
// FormatRelativeDeadline formats a deadline as "in 2 hours" or "3 days ago".
func FormatRelativeDeadline(v any) string {
return FormatRelativeDeadlineAt(v, time.Now())
}
// FormatRelativeDeadlineAt is FormatRelativeDeadline with a fixed clock (for tests).
func FormatRelativeDeadlineAt(v any, now time.Time) string {
t, ok := parseDeadlineTime(v)
if !ok {
return ""
}
return relativeTimePhrase(now, t)
}
func relativeTimePhrase(now, target time.Time) string {
d := target.Sub(now)
future := d >= 0
if !future {
d = -d
}
phrase := func(n int, unit string) string {
if n == 1 {
unit = strings.TrimSuffix(unit, "s")
}
if future {
return fmt.Sprintf("in %d %s", n, unit)
}
return fmt.Sprintf("%d %s ago", n, unit)
}
switch {
case d < time.Minute:
if future {
return "in 1 minute"
}
return "just now"
case d < time.Hour:
return phrase(int(d.Round(time.Minute)/time.Minute), "minutes")
case d < 24*time.Hour:
return phrase(int(d.Round(time.Hour)/time.Hour), "hours")
default:
return phrase(int(d.Round(24*time.Hour)/(24*time.Hour)), "days")
}
}
func parseDeadlineTime(v any) (time.Time, bool) {
if v == nil {
return time.Time{}, false
}
switch x := v.(type) {
case time.Time:
return x, true
case string:
s := strings.TrimSpace(x)
if s == "" {
return time.Time{}, false
}
formats := []string{
time.RFC3339Nano,
"2006-01-02T15:04:05.0000000-07:00",
"2006-01-02T15:04:05-07:00",
"2006-01-02T15:04:05",
"2006-01-02",
}
for _, layout := range formats {
if t, err := time.Parse(layout, s); err == nil {
return t, true
}
}
return time.Time{}, false
case float64:
return time.Unix(int64(x), 0), true
case int64:
return time.Unix(x, 0), true
case int:
return time.Unix(int64(x), 0), true
default:
return parseDeadlineTime(fmt.Sprint(v))
}
}
// TaskResponsibleLabel returns the assignee display name when present.
func TaskResponsibleLabel(raw map[string]any) string {
if raw == nil {
return ""
}
if name := personName(raw["responsible"]); name != "" {
return name
}
if list, ok := raw["responsibles"].([]any); ok {
names := make([]string, 0, len(list))
for _, entry := range list {
if name := personName(entry); name != "" {
names = append(names, name)
}
}
if len(names) > 0 {
return strings.Join(names, ", ")
}
}
if s := strRaw(raw, "responsible"); s != "" && !looksLikeID(s) {
return s
}
return ""
}
func personName(v any) string {
m, ok := v.(map[string]any)
if !ok {
return ""
}
if name := strRaw(m, "displayName"); name != "" {
if strings.EqualFold(name, "Profile has been removed") {
return ""
}
return name
}
first := strRaw(m, "firstName")
last := strRaw(m, "lastName")
return strings.TrimSpace(first + " " + last)
}
func looksLikeID(s string) bool {
if len(s) == 36 && strings.Count(s, "-") == 4 {
return true
}
_, err := strconv.Atoi(s)
return err == nil
}
+49
View File
@@ -0,0 +1,49 @@
package model
import (
"testing"
"time"
)
func TestTaskStatusLabelFromInt(t *testing.T) {
if got := TaskStatusLabel(1); got != "Open" {
t.Fatalf("got %q", got)
}
if got := TaskStatusLabel(2); got != "Closed" {
t.Fatalf("got %q", got)
}
}
func TestFormatRelativeDeadlineFuture(t *testing.T) {
now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
deadline := now.Add(2 * time.Hour)
got := FormatRelativeDeadlineAt(deadline.Format(time.RFC3339), now)
if got != "in 2 hours" {
t.Fatalf("got %q", got)
}
}
func TestFormatRelativeDeadlineDays(t *testing.T) {
now := time.Date(2026, 6, 24, 12, 0, 0, 0, time.UTC)
deadline := now.Add(26 * time.Hour)
got := FormatRelativeDeadlineAt(deadline.Format(time.RFC3339), now)
if got != "in 1 day" {
t.Fatalf("got %q", got)
}
}
func TestBuildTaskColumnsOmitsSubtitle(t *testing.T) {
items := []Item{{
ID: "1", Title: "Fix bug", Kind: KindTask, Subtitle: "1",
Raw: map[string]any{"status": 1, "deadline": "2026-06-25T12:00:00Z"},
}}
cols := BuildColumns(SubjectTasks, items)
for _, c := range cols {
if c.Key == "subtitle" {
t.Fatalf("unexpected subtitle column")
}
}
if CellText(items[0], "status") != "Open" {
t.Fatalf("status not humanized")
}
}
+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
}
+55 -9
View File
@@ -42,25 +42,71 @@ func OpportunityMarkdown(m map[string]any) string {
return strings.TrimSpace(b.String()) + "\n" return strings.TrimSpace(b.String()) + "\n"
} }
// MailMarkdown formats a mail message for preview. // MailMarkdown formats a mail message for terminal preview (HTML body is converted to markdown).
func MailMarkdown(m map[string]any) string { func MailMarkdown(m map[string]any) string {
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", str(m, "subject")) fmt.Fprintf(&b, "# %s\n\n", str(m, "subject"))
fmt.Fprintf(&b, "**From:** %s\n\n", str(m, "from")) fmt.Fprintf(&b, "**From:** %s\n\n", mailAddressLine(m, "from"))
if to := str(m, "to"); to != "" { if to := mailAddressLine(m, "to"); to != "" {
fmt.Fprintf(&b, "**To:** %s\n\n", to) fmt.Fprintf(&b, "**To:** %s\n\n", to)
} }
body := str(m, "body") if cc := mailAddressLine(m, "cc"); cc != "" {
if body == "" { fmt.Fprintf(&b, "**Cc:** %s\n\n", cc)
body = str(m, "htmlBody")
} }
body = stripHTML(body) if date := str(m, "date"); date != "" {
if body != "" { fmt.Fprintf(&b, "**Date:** %s\n\n", date)
fmt.Fprintf(&b, "## Body\n\n%s\n\n", body) }
if body := mailBodyMarkdown(m); body != "" {
fmt.Fprintf(&b, "---\n\n%s\n", body)
} }
return strings.TrimSpace(b.String()) + "\n" return strings.TrimSpace(b.String()) + "\n"
} }
func mailAddressLine(m map[string]any, key string) string {
if v := str(m, key); v != "" {
return v
}
name := str(m, key+"Name")
addr := str(m, key+"Address")
switch {
case name != "" && addr != "":
return fmt.Sprintf("%s <%s>", name, addr)
case addr != "":
return addr
default:
return name
}
}
func mailBodyMarkdown(m map[string]any) string {
htmlBody := strings.TrimSpace(str(m, "htmlBody"))
plainBody := strings.TrimSpace(str(m, "body"))
for _, candidate := range []string{htmlBody, plainBody} {
if candidate == "" {
continue
}
if looksLikeHTML(candidate) {
if md, err := HTMLToMarkdown(candidate); err == nil {
if md = strings.TrimSpace(md); md != "" {
return md
}
}
}
}
if plainBody != "" {
return plainBody
}
if htmlBody != "" {
return stripHTML(htmlBody)
}
return ""
}
func looksLikeHTML(s string) bool {
return strings.Contains(s, "<") && strings.Contains(s, ">") && htmlTagRe.MatchString(s)
}
// EventMarkdown formats a calendar event for preview. // EventMarkdown formats a calendar event for preview.
func EventMarkdown(m map[string]any) string { func EventMarkdown(m map[string]any) string {
var b strings.Builder var b strings.Builder
+58
View File
@@ -0,0 +1,58 @@
package preview_test
import (
"strings"
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/preview"
)
func TestMailMarkdownConvertsHTMLBody(t *testing.T) {
raw := map[string]any{
"subject": "Weekly update",
"from": `"Team" <team@example.com>`,
"htmlBody": `<p>Hello <strong>world</strong></p><p><a href="https://example.com">Link</a></p>`,
}
md := preview.MailMarkdown(raw)
if !strings.Contains(md, "Weekly update") {
t.Fatalf("missing subject:\n%s", md)
}
if !strings.Contains(md, "team@example.com") {
t.Fatalf("missing from:\n%s", md)
}
if strings.Contains(md, "<strong>") || strings.Contains(md, "<p>") {
t.Fatalf("expected HTML converted, got raw tags:\n%s", md)
}
if !strings.Contains(md, "world") {
t.Fatalf("missing body text:\n%s", md)
}
}
func TestMailMarkdownRendersWithColor(t *testing.T) {
md := preview.MailMarkdown(map[string]any{
"subject": "Styled",
"from": "a@b.com",
"htmlBody": `<h2>Heading</h2><p>Plain text</p>`,
})
out, err := preview.RenderMarkdown(md, 60)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(out) == "" {
t.Fatal("empty render output")
}
if !strings.Contains(out, "Heading") || !strings.Contains(out, "Plain text") {
t.Fatalf("missing rendered content:\n%s", out)
}
}
func TestMailMarkdownPlainBodyFallback(t *testing.T) {
md := preview.MailMarkdown(map[string]any{
"subject": "Hi",
"from": "a@b.com",
"body": "Just plain text",
})
if !strings.Contains(md, "Just plain text") {
t.Fatalf("missing plain body:\n%s", md)
}
}
@@ -119,7 +119,7 @@ func TestIntegrationPreviewMailFromAPI(t *testing.T) {
func TestIntegrationPreviewEventFromAPI(t *testing.T) { func TestIntegrationPreviewEventFromAPI(t *testing.T) {
loader, ctx := liveLoader(t) loader, ctx := liveLoader(t)
items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectEvents}) items, err := loader.List(ctx, model.ListSpec{Subject: model.SubjectCalendar})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
+446 -69
View File
@@ -18,6 +18,11 @@ type listLoadedMsg struct {
err error err error
} }
type listMoreLoadedMsg struct {
items []model.Item
err error
}
type detailLoadedMsg struct { type detailLoadedMsg struct {
item model.Item item model.Item
document bool document bool
@@ -28,7 +33,7 @@ type detailLoadedMsg struct {
type detailSavedMsg struct { type detailSavedMsg struct {
item model.Item item model.Item
title, description string fields model.FormFields
err error err error
} }
@@ -51,6 +56,8 @@ type Model struct {
hasList bool hasList bool
items []model.Item items []model.Item
selection *model.Selection selection *model.Selection
listHasMore bool
listLoadingMore bool
focus model.FocusPane focus model.FocusPane
width int width int
height int height int
@@ -59,10 +66,14 @@ type Model struct {
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
customPaneLayout bool
paneSizes PaneWidths
resize paneResizeState
} }
// NewModel constructs the TUI with an authenticated client. // NewModel constructs the TUI with an authenticated client.
@@ -74,12 +85,13 @@ func NewModel(client *onlyoffice.Client) Model {
nav: model.DefaultNavTree(), nav: model.DefaultNavTree(),
selection: model.NewSelection(), selection: model.NewSelection(),
focus: model.FocusMenu, focus: model.FocusMenu,
status: "Tab: pane · row select loads detail · Ctrl+S save · q quit", status: "Tab: pane · / 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.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
@@ -87,7 +99,7 @@ func NewModel(client *onlyoffice.Client) Model {
} }
func (m Model) Init() tea.Cmd { func (m Model) Init() tea.Cmd {
return tea.Batch(m.loadNavProjectsCmd(), m.detail.BlinkCmd()) return tea.Batch(m.loadNavProjectsCmd(), m.detail.BlinkCmd(), filterSearchBlinkCmd())
} }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -95,12 +107,52 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
if m.customPaneLayout {
m.paneSizes = FitPaneWidths(m.width, m.paneVis(), m.paneSizes)
}
m.layoutViewports() m.layoutViewports()
m.syncAllContent() m.syncAllContent()
return m, nil return m, nil
case tea.KeyMsg: case tea.KeyMsg:
key := msg.String() key := msg.String()
if key == "esc" && m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable {
m.listToolbar.FocusTable()
m.syncPaneFocus()
return m, nil
}
if key == "esc" && m.focus == model.FocusList && m.listToolbar.Query() != "" {
m.listToolbar.ClearFilter()
m.nav.ClearFilter()
m.syncMenuContent()
m.syncListTable()
return m, nil
}
if m.focus == model.FocusList && m.listToolbar.Zone() == listZoneFilter {
switch key {
case "tab", "shift+tab", "backtab":
// allow pane switching
default:
cmd := m.listToolbar.Update(msg, m.listToolbarMeta())
m.applyFilter()
return m, cmd
}
}
if m.focus == model.FocusList && m.listToolbar.Zone() == listZoneActions {
meta := m.listToolbarMeta()
switch key {
case "enter":
if act, ok := m.listToolbar.SelectedAction(meta); ok && m.listToolbar.IsActionEnabled(act, meta) {
return m, m.runListToolbarAction(act)
}
return m, nil
case "tab", "shift+tab", "backtab":
// allow pane switching
default:
cmd := m.listToolbar.Update(msg, meta)
return m, cmd
}
}
if key == "?" { if key == "?" {
m.status = helpText() m.status = helpText()
return m, nil return m, nil
@@ -121,56 +173,92 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case ActionQuit: case ActionQuit:
return m, tea.Quit return m, tea.Quit
case ActionNextPane: case ActionNextPane:
prev := m.focus
m.focus = NextVisibleFocus(m.focus, m.paneVis()) m.focus = NextVisibleFocus(m.focus, m.paneVis())
if m.focus == model.FocusPreview && prev != model.FocusPreview {
m.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
m.focus = PrevVisibleFocus(m.focus, m.paneVis()) m.focus = PrevVisibleFocus(m.focus, m.paneVis())
if m.focus == model.FocusPreview && prev != model.FocusPreview {
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()
if m.focus == model.FocusList {
return m, m.onListRowChanged() 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)
} }
m.moveDown() if m.focus == model.FocusList && m.listToolbar.Zone() != listZoneTable {
m.syncFocusedPane()
return m, m.onListRowChanged()
case ActionMoveLeft:
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
m.detail.MoveAction(-1)
return m, nil return m, nil
} }
if m.focus == model.FocusList && m.hasList { m.moveDown()
m.syncFocusedPane()
if m.focus == model.FocusList {
return m, m.onListRowChanged()
}
return m, nil
case ActionMoveLeft:
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
m.detail.MoveTabStop(-1)
return m, nil
}
if m.focus == model.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 ActionMoveRight: case ActionMoveRight:
if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions { if m.focus == model.FocusPreview && m.detail.Zone() == detailZoneActions {
m.detail.MoveAction(1) m.detail.MoveTabStop(1)
return m, nil return m, nil
} }
if m.focus == model.FocusList && m.hasList { if m.focus == model.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
case ActionToggleSelect: case ActionToggleSelect:
if m.hasList { if m.hasList {
idx := m.listTable.ItemIndex() if it, ok := m.listTableItem(); ok {
if idx >= 0 { for i := range m.items {
m.selection.Toggle(&m.items, idx) if m.items[i].ID == it.ID {
m.listTable.UpdateItems(m.items) m.selection.Toggle(&m.items, i)
break
}
}
m.syncListTable()
} }
} }
return m, nil return m, nil
@@ -178,7 +266,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.hasList && m.showDetail { if m.hasList && m.showDetail {
m.focus = model.FocusPreview m.focus = model.FocusPreview
m.syncPaneFocus() m.syncPaneFocus()
m.detail.FocusContent() m.detail.FocusFirstStop()
return m, m.onListRowChanged() return m, m.onListRowChanged()
} }
return m, nil return m, nil
@@ -191,6 +279,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case ActionToggleDetailPane: case ActionToggleDetailPane:
m.togglePane(3) m.togglePane(3)
return m, nil return m, nil
case ActionFilter:
if m.hasList {
m.focus = model.FocusList
m.listToolbar.FocusFilter()
m.syncPaneFocus()
}
return m, nil
case ActionRefresh: case ActionRefresh:
if m.hasList { if m.hasList {
m.loading = true m.loading = true
@@ -210,22 +305,17 @@ 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
} }
} }
case listLoadedMsg: case listLoadedMsg:
m.loading = false m.loading = false
m.listLoadingMore = false
if msg.err != nil { if msg.err != nil {
m.err = msg.err.Error() m.err = msg.err.Error()
m.items = nil m.items = nil
m.hasList = false m.hasList = false
m.listHasMore = false
m.listTable.Clear() m.listTable.Clear()
m.detail.Clear() m.detail.Clear()
} else { } else {
@@ -233,9 +323,30 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.items = msg.items m.items = msg.items
m.listSpec = msg.spec m.listSpec = msg.spec
m.hasList = true m.hasList = true
m.listHasMore = model.SubjectIsMail(msg.spec.Subject) && len(msg.items) >= fetch.MailListPageSize
m.listTable.SetData(m.listSpec, m.items) m.listTable.SetData(m.listSpec, m.items)
m.applyFilter()
} }
return m, m.onListRowChanged() return m, tea.Batch(m.onListRowChanged(), m.maybeLoadMoreList())
case listMoreLoadedMsg:
m.listLoadingMore = false
m.listTable.SetLoadingMore(false)
if msg.err != nil {
m.err = msg.err.Error()
return m, nil
}
if len(msg.items) == 0 {
m.listHasMore = false
return m, nil
}
added := m.listTable.AppendItems(msg.items)
if added > 0 {
m.items = m.listTable.Items()
}
m.listHasMore = len(msg.items) >= fetch.MailListPageSize
m.err = ""
return m, nil
case detailLoadedMsg: case detailLoadedMsg:
m.loading = false m.loading = false
@@ -263,13 +374,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.err != nil { if msg.err != nil {
m.err = msg.err.Error() m.err = msg.err.Error()
} else { } else {
m.updateItemAfterSave(msg.item, msg.title, msg.description) m.updateItemAfterSave(msg.item, msg.fields)
fields, _ := m.loader.DetailForm(context.Background(), msg.item) m.detail.LoadForm(msg.item, msg.fields)
fields.Primary = msg.title m.detail.form.MarkClean()
fields.Secondary = msg.description
m.detail.LoadForm(msg.item, fields)
m.detail.SetFocused(m.focus == model.FocusPreview) m.detail.SetFocused(m.focus == model.FocusPreview)
if msg.fields.HasTaskStatus && msg.fields.TaskStatus == model.TaskLifecycleClosed {
m.status = "Closed"
} else {
m.status = "Saved" m.status = "Saved"
}
m.err = "" m.err = ""
} }
return m, nil return m, nil
@@ -281,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)
@@ -296,12 +410,20 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case tea.MouseMsg: case tea.MouseMsg:
if m.handlePaneResizeMouse(msg) {
return m, nil
}
if cmd, handled := m.routeDetailDocumentMouse(msg); handled {
return m, cmd
}
var cmd tea.Cmd var cmd tea.Cmd
switch m.focus { switch m.focus {
case model.FocusMenu: case model.FocusMenu:
m.menuVP, cmd = m.menuVP.Update(msg) m.menuVP, cmd = m.menuVP.Update(msg)
case model.FocusList: case model.FocusList:
if m.listToolbar.Zone() == listZoneTable {
cmd = m.listTable.Update(msg) cmd = m.listTable.Update(msg)
}
case model.FocusPreview: case model.FocusPreview:
cmd = m.detail.Update(msg) cmd = m.detail.Update(msg)
} }
@@ -323,7 +445,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) { func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
switch key { switch key {
case "tab", "shift+tab", "backtab": case "tab", "shift+tab", "backtab":
m.detail.ToggleZone() reverse := key != "tab"
if reverse {
if m.detail.TabBackward() {
m.focus = PrevVisibleFocus(m.focus, m.paneVis())
}
} else if m.detail.TabForward() {
m.focus = NextVisibleFocus(m.focus, m.paneVis())
}
m.syncPaneFocus()
return nil, true return nil, true
case "ctrl+s": case "ctrl+s":
m.loading = true m.loading = true
@@ -338,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
} }
} }
@@ -347,16 +480,37 @@ func (m *Model) handleDetailKey(key string, msg tea.KeyMsg) (tea.Cmd, bool) {
return nil, false return nil, false
} }
func (m *Model) routeDetailDocumentMouse(msg tea.MouseMsg) (tea.Cmd, bool) {
if !m.showDetail {
return nil, false
}
if !m.detail.IsDocumentContent() && !m.detail.isReadOnlyFormContent() {
return nil, false
}
if msg.Y > m.paneHeight()+1 {
return nil, false
}
x0, x1 := DetailPaneXRange(m.paneLayout())
if x0 >= x1 || msg.X < x0 || msg.X >= x1 {
return nil, false
}
cmd := m.detail.ScrollDocumentMouse(msg)
if msg.Button == tea.MouseButtonWheelUp || msg.Button == tea.MouseButtonWheelDown {
return cmd, true
}
return cmd, false
}
func (m Model) handleDetailMove(delta int) (Model, tea.Cmd) { func (m Model) handleDetailMove(delta int) (Model, tea.Cmd) {
if m.detail.Zone() == detailZoneActions { if m.detail.Zone() == detailZoneContent && (m.detail.IsDocumentContent() || m.detail.isReadOnlyFormContent()) {
m.detail.MoveAction(delta) key := "down"
if delta < 0 {
key = "up"
}
m.detail.ScrollDocument(key)
return m, nil return m, nil
} }
if delta < 0 { m.detail.MoveTabStop(delta)
m.detail.form.FocusPrev()
} else {
m.detail.form.FocusNext()
}
return m, nil return m, nil
} }
@@ -368,15 +522,27 @@ 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)
parts = append(parts, menuStyle.Render(m.menuVP.View())) menuBody := ApplyVerticalScrollbar(
m.menuVP.View(),
m.menuVP.Width,
m.menuVP.Height,
m.menuVP.TotalLineCount(),
m.menuVP.YOffset,
)
parts = append(parts, menuStyle.Render(menuBody))
} }
if pw.Visibility.List { if pw.Visibility.List {
listStyle := paneStyle(m.focus == model.FocusList).Width(pw.List).Height(h) listStyle := paneStyle(m.focus == model.FocusList).Width(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)
parts = append(parts, prevStyle.Render(m.detail.View())) parts = append(parts, prevStyle.Render(m.detail.View()))
} }
@@ -397,14 +563,113 @@ func (m Model) paneVis() PaneVisibility {
} }
func (m Model) paneLayout() PaneWidths { func (m Model) paneLayout() PaneWidths {
return LayoutWidths(m.width, m.paneVis()) vis := m.paneVis()
if m.customPaneLayout {
return FitPaneWidths(m.width, vis, m.paneSizes)
}
return LayoutWidths(m.width, vis)
} }
func (m *Model) syncPaneFocus() { func (m *Model) syncPaneFocus() {
m.listTable.SetFocused(m.focus == model.FocusList && m.showList) onList := m.focus == model.FocusList && m.showList
m.listTable.SetFocused(onList && m.listToolbar.Zone() == listZoneTable)
if onList {
m.listToolbar.syncInputFocus()
} else {
m.listToolbar.filter.SetFocused(false)
}
m.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:
@@ -427,6 +692,9 @@ func (m *Model) togglePane(which int) {
if !paneVisible(m.focus, m.paneVis()) { if !paneVisible(m.focus, m.paneVis()) {
m.focus = firstVisibleFocus(m.paneVis()) m.focus = firstVisibleFocus(m.paneVis())
} }
if m.customPaneLayout {
m.paneSizes = FitPaneWidths(m.width, m.paneVis(), m.paneSizes)
}
m.layoutViewports() m.layoutViewports()
m.syncAllContent() m.syncAllContent()
m.syncPaneFocus() m.syncPaneFocus()
@@ -436,6 +704,8 @@ func (m Model) withList(spec model.ListSpec) (Model, tea.Cmd) {
m.listSpec = spec m.listSpec = spec
m.selection.Clear() m.selection.Clear()
m.hasList = false m.hasList = false
m.listHasMore = false
m.listLoadingMore = false
m.focus = model.FocusList m.focus = model.FocusList
m.listTable.SetFocused(true) m.listTable.SetFocused(true)
m.detail.Clear() m.detail.Clear()
@@ -451,7 +721,9 @@ 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)
@@ -468,12 +740,42 @@ func (m *Model) syncListTable() {
m.listTable.Clear() m.listTable.Clear()
return return
} }
m.listTable.UpdateItems(m.items) items := m.items
if q := m.listToolbar.Query(); q != "" {
items = model.FilterItems(m.items, q)
}
m.listTable.UpdateItems(items)
}
func (m *Model) applyFilter() {
q := m.listToolbar.Query()
m.nav.SetFilter(q)
m.syncMenuContent()
m.syncListTable()
}
func (m Model) listTableItem() (model.Item, bool) {
idx := m.listTable.ItemIndex()
if idx < 0 {
return model.Item{}, false
}
items := m.items
if q := m.listToolbar.Query(); q != "" {
items = model.FilterItems(m.items, q)
}
if idx >= len(items) {
return model.Item{}, false
}
return items[idx], true
} }
func (m *Model) syncMenuContent() { func (m *Model) syncMenuContent() {
var b strings.Builder var b strings.Builder
b.WriteString("Navigation\n\n") title := "Navigation"
if m.listToolbar.Query() != "" {
title += " (filtered)"
}
b.WriteString(title + "\n\n")
menuW := m.menuVP.Width menuW := m.menuVP.Width
if menuW < 10 { if menuW < 10 {
menuW = 20 menuW = 20
@@ -518,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:
@@ -605,19 +924,47 @@ func (m *Model) loadNavProjectsCmd() tea.Cmd {
func (m *Model) onListRowChanged() tea.Cmd { func (m *Model) onListRowChanged() tea.Cmd {
if !m.hasList { if !m.hasList {
m.detail.Clear() m.detail.Clear()
return nil return m.maybeLoadMoreList()
} }
idx := m.listTable.ItemIndex() it, ok := m.listTableItem()
if idx < 0 || idx >= len(m.items) { if !ok {
m.detail.Clear() m.detail.Clear()
return nil return m.maybeLoadMoreList()
}
it := m.items[idx]
if m.detail.LoadedID() == it.ID {
return nil
} }
var cmds []tea.Cmd
cmds = append(cmds, m.maybeLoadMoreList())
if m.detail.LoadedID() != it.ID {
m.loading = true m.loading = true
return m.loadDetailCmd(it) cmds = append(cmds, m.loadDetailCmd(it))
}
return tea.Batch(cmds...)
}
const mailListLoadThreshold = 3
func (m *Model) maybeLoadMoreList() tea.Cmd {
if !m.hasList || m.listLoadingMore || !m.listHasMore {
return nil
}
if !model.SubjectIsMail(m.listSpec.Subject) {
return nil
}
if !m.listTable.NearEnd(mailListLoadThreshold) {
return nil
}
m.listLoadingMore = true
m.listTable.SetLoadingMore(true)
return m.loadListMoreCmd()
}
func (m *Model) loadListMoreCmd() tea.Cmd {
start := len(m.items)
spec := m.listSpec
return func() tea.Msg {
ctx := context.Background()
items, err := m.loader.ListMailMore(ctx, spec, start)
return listMoreLoadedMsg{items: items, err: err}
}
} }
func (m *Model) loadDetailCmd(item model.Item) tea.Cmd { func (m *Model) loadDetailCmd(item model.Item) tea.Cmd {
@@ -632,33 +979,63 @@ func (m *Model) loadDetailCmd(item model.Item) tea.Cmd {
} }
} }
func (m *Model) saveDetailCmd() tea.Cmd { func (m *Model) closeTaskCmd() tea.Cmd {
item := m.detail.Item() item := m.detail.Item()
title := m.detail.form.Primary() fields := m.detail.form.FormFields()
desc := m.detail.form.Secondary() fields.TaskStatus = model.TaskLifecycleClosed
return func() tea.Msg { return func() tea.Msg {
ctx := context.Background() ctx := context.Background()
err := m.loader.SaveItem(ctx, item, title, desc) err := m.loader.CloseTask(ctx, item.ID, fields)
return detailSavedMsg{item: item, title: title, description: desc, err: err} return detailSavedMsg{item: item, fields: fields, err: err}
} }
} }
func (m *Model) updateItemAfterSave(item model.Item, title, description string) { func (m *Model) saveDetailCmd() tea.Cmd {
item := m.detail.Item()
fields := m.detail.form.FormFields()
return func() tea.Msg {
ctx := context.Background()
err := m.loader.SaveItem(ctx, item, fields)
return detailSavedMsg{item: item, fields: fields, err: err}
}
}
func (m *Model) updateItemAfterSave(item model.Item, fields model.FormFields) {
for i := range m.items { for i := range m.items {
if m.items[i].ID != item.ID { if m.items[i].ID != item.ID {
continue continue
} }
m.items[i].Title = title m.items[i].Title = fields.Primary
if m.items[i].Raw == nil { if m.items[i].Raw == nil {
m.items[i].Raw = map[string]any{} m.items[i].Raw = map[string]any{}
} }
m.items[i].Raw["title"] = title m.items[i].Raw["title"] = fields.Primary
m.items[i].Raw["description"] = description m.items[i].Raw["description"] = fields.Secondary
m.listTable.UpdateItems(m.items) if fields.HasStatus {
m.items[i].Raw["status"] = statusInt(fields.Status)
}
if fields.HasTaskStatus {
m.items[i].Raw["status"] = int(fields.TaskStatus)
}
if fields.ResponsibleID != "" {
m.items[i].Raw["responsibleIds"] = []any{fields.ResponsibleID}
}
m.syncListTable()
return return
} }
} }
func statusInt(s model.ProjectLifecycle) int {
switch s {
case model.ProjectLifecycleClosed:
return 2
case model.ProjectLifecyclePaused:
return 1
default:
return 0
}
}
func paneStyle(focused bool) lipgloss.Style { func paneStyle(focused bool) lipgloss.Style {
s := lipgloss.NewStyle().Padding(0, 1) s := lipgloss.NewStyle().Padding(0, 1)
if focused { if focused {
@@ -668,5 +1045,5 @@ func paneStyle(focused bool) lipgloss.Style {
} }
func helpText() string { func helpText() string {
return "Alt+1/2/3: toggle panes · Tab: focus · v: detail · Ctrl+S: save · q: quit" return "Alt+1/2/3: panes · / filter · Space select · 💾🗑 when selected · v: detail · Ctrl+S: save · q: quit"
} }
+6
View File
@@ -31,6 +31,12 @@ func TestKeyActionSortAndColumns(t *testing.T) {
} }
} }
func TestKeyActionFilter(t *testing.T) {
if got := ui.KeyAction("f", model.FocusList); got != ui.ActionFilter {
t.Fatalf("got %v", got)
}
}
func TestLayoutWidths(t *testing.T) { func TestLayoutWidths(t *testing.T) {
menu, list, preview := ui.LayoutWidthsLegacy(120) menu, list, preview := ui.LayoutWidthsLegacy(120)
if menu+list+preview != 120 { if menu+list+preview != 120 {
+244 -21
View File
@@ -34,12 +34,14 @@ type DetailPane struct {
loadedID string loadedID string
actions []model.ItemAction actions []model.ItemAction
actionIdx int actionIdx int
tabStop int
zone detailZone zone detailZone
focused bool focused bool
width int width int
height int height int
form EntityForm form EntityForm
docVP viewport.Model docVP viewport.Model
formVP viewport.Model
docText string docText string
styles detailStyles styles detailStyles
} }
@@ -56,9 +58,11 @@ func newDetailPane() DetailPane {
d := DetailPane{ d := DetailPane{
form: newEntityForm(), form: newEntityForm(),
docVP: viewport.New(40, 10), docVP: viewport.New(40, 10),
formVP: viewport.New(40, 10),
styles: newDetailStyles(), styles: newDetailStyles(),
} }
d.docVP.MouseWheelEnabled = true d.docVP.MouseWheelEnabled = true
d.formVP.MouseWheelEnabled = true
return d return d
} }
@@ -79,14 +83,112 @@ func (d *DetailPane) Clear() {
d.loadedID = "" d.loadedID = ""
d.actions = nil d.actions = nil
d.actionIdx = 0 d.actionIdx = 0
d.tabStop = 0
d.docText = "" d.docText = ""
d.form.Clear() d.form.Clear()
d.docVP.SetContent("") d.docVP.SetContent("")
d.formVP.SetContent("")
} }
func (d *DetailPane) SetFocused(on bool) { func (d *DetailPane) SetFocused(on bool) {
d.focused = on d.focused = on
d.form.SetFocused(on && d.zone == detailZoneContent && d.mode == detailForm) d.applyTabStop(d.tabStop)
}
func (d *DetailPane) FocusFirstStop() {
d.tabStop = 0
d.applyTabStop(0)
}
func (d *DetailPane) maxTabStop() int {
switch d.mode {
case detailForm:
if d.form.readOnly {
if len(d.actions) == 0 {
return 0
}
return len(d.actions) - 1
}
fields := d.form.FieldCount()
if len(d.actions) == 0 {
if fields == 0 {
return 0
}
return fields - 1
}
return fields + len(d.actions) - 1
case detailDocument:
if len(d.actions) == 0 {
return 0
}
return len(d.actions)
default:
return 0
}
}
func (d *DetailPane) applyTabStop(stop int) {
if stop < 0 {
stop = 0
}
max := d.maxTabStop()
if stop > max {
stop = max
}
d.tabStop = stop
switch d.mode {
case detailForm:
if d.form.readOnly {
d.zone = detailZoneActions
d.actionIdx = stop
d.form.SetFocused(false)
return
}
fields := d.form.FieldCount()
if stop < fields {
d.zone = detailZoneContent
d.form.SetFieldIndex(stop)
d.form.SetFocused(d.focused)
return
}
d.zone = detailZoneActions
d.actionIdx = stop - fields
d.form.SetFocused(false)
case detailDocument:
if stop == 0 {
d.zone = detailZoneContent
d.form.SetFocused(false)
return
}
d.zone = detailZoneActions
d.actionIdx = stop - 1
d.form.SetFocused(false)
default:
d.zone = detailZoneContent
d.form.SetFocused(false)
}
}
// TabForward moves to the next field/button. It returns true when focus should leave the pane.
func (d *DetailPane) TabForward() bool {
max := d.maxTabStop()
if d.tabStop < max {
d.applyTabStop(d.tabStop + 1)
return false
}
d.FocusFirstStop()
return true
}
// TabBackward moves to the previous field/button. It returns true when focus should leave the pane.
func (d *DetailPane) TabBackward() bool {
if d.tabStop > 0 {
d.applyTabStop(d.tabStop - 1)
return false
}
d.applyTabStop(d.maxTabStop())
return true
} }
func (d *DetailPane) SetSize(w, h int) { func (d *DetailPane) SetSize(w, h int) {
@@ -102,6 +204,8 @@ func (d *DetailPane) SetSize(w, h int) {
d.form.SetSize(w, contentH) d.form.SetSize(w, contentH)
d.docVP.Width = w d.docVP.Width = w
d.docVP.Height = contentH d.docVP.Height = contentH
d.formVP.Width = w
d.formVP.Height = contentH
} }
func (d *DetailPane) splitHeights() (contentH, actionH int) { func (d *DetailPane) splitHeights() (contentH, actionH int) {
@@ -125,7 +229,10 @@ func (d *DetailPane) LoadForm(item model.Item, fields model.FormFields) {
d.loadedID = item.ID d.loadedID = item.ID
d.actions = model.ActionsFor(item.Kind) d.actions = model.ActionsFor(item.Kind)
d.actionIdx = 0 d.actionIdx = 0
d.tabStop = 0
d.form.Load(item.Kind, item.ID, fields) d.form.Load(item.Kind, item.ID, fields)
d.refreshFormViewport()
d.applyTabStop(0)
d.layoutContent() d.layoutContent()
} }
@@ -135,6 +242,7 @@ func (d *DetailPane) LoadDocument(item model.Item, markdown string, renderWidth
d.loadedID = item.ID d.loadedID = item.ID
d.actions = model.ActionsFor(item.Kind) d.actions = model.ActionsFor(item.Kind)
d.actionIdx = 0 d.actionIdx = 0
d.tabStop = 0
d.form.Clear() d.form.Clear()
text, err := preview.RenderMarkdown(markdown, renderWidth) text, err := preview.RenderMarkdown(markdown, renderWidth)
if err != nil { if err != nil {
@@ -142,6 +250,8 @@ func (d *DetailPane) LoadDocument(item model.Item, markdown string, renderWidth
} }
d.docText = text d.docText = text
d.docVP.SetContent(text) d.docVP.SetContent(text)
d.docVP.GotoTop()
d.applyTabStop(0)
d.layoutContent() d.layoutContent()
} }
@@ -150,6 +260,17 @@ func (d *DetailPane) layoutContent() {
d.form.SetSize(d.width, contentH) d.form.SetSize(d.width, contentH)
d.docVP.Width = d.width d.docVP.Width = d.width
d.docVP.Height = contentH d.docVP.Height = contentH
d.formVP.Width = d.width
d.formVP.Height = contentH
d.refreshFormViewport()
}
func (d *DetailPane) refreshFormViewport() {
if d.mode != detailForm || !d.form.readOnly {
d.formVP.SetContent("")
return
}
d.formVP.SetContent(d.form.View())
} }
func (d *DetailPane) LoadedID() string { return d.loadedID } func (d *DetailPane) LoadedID() string { return d.loadedID }
@@ -158,22 +279,45 @@ func (d *DetailPane) Item() model.Item { return d.item }
func (d *DetailPane) Zone() detailZone { return d.zone } func (d *DetailPane) Zone() detailZone { return d.zone }
func (d *DetailPane) FocusActions() {
d.zone = detailZoneActions
d.form.SetFocused(false)
}
func (d *DetailPane) FocusContent() { func (d *DetailPane) FocusContent() {
d.zone = detailZoneContent d.applyTabStop(d.tabStop)
d.form.SetFocused(d.focused && d.mode == detailForm) if d.zone == detailZoneContent && d.mode == detailForm {
d.form.SetFocused(d.focused)
}
} }
func (d *DetailPane) ToggleZone() { func (d *DetailPane) FocusActions() {
if d.zone == detailZoneContent { max := d.maxTabStop()
d.FocusActions() switch d.mode {
} else { case detailForm:
d.FocusContent() if d.form.readOnly {
d.applyTabStop(max)
return
} }
if len(d.actions) > 0 {
d.applyTabStop(d.form.FieldCount())
}
case detailDocument:
if len(d.actions) > 0 {
d.applyTabStop(1)
}
}
}
// MoveTabStop moves between title, description, and action buttons without leaving the pane.
func (d *DetailPane) MoveTabStop(delta int) {
if delta == 0 {
return
}
next := d.tabStop + delta
if next < 0 {
next = 0
}
max := d.maxTabStop()
if next > max {
next = max
}
d.applyTabStop(next)
} }
func (d *DetailPane) MoveAction(delta int) { func (d *DetailPane) MoveAction(delta int) {
@@ -190,6 +334,62 @@ func (d *DetailPane) SelectedAction() (model.ItemAction, bool) {
return d.actions[d.actionIdx], true return d.actions[d.actionIdx], true
} }
func (d DetailPane) IsDocumentContent() bool {
return d.mode == detailDocument
}
func (d DetailPane) isReadOnlyFormContent() bool {
return d.mode == detailForm && d.form.readOnly
}
func (d DetailPane) documentContentHeight() int {
contentH, _ := d.splitHeights()
return contentH
}
// ScrollDocument scrolls the read-only document viewport (mail, file preview).
func (d *DetailPane) ScrollDocument(key string) bool {
if d.IsDocumentContent() {
return scrollViewport(&d.docVP, key)
}
if d.isReadOnlyFormContent() && d.zone == detailZoneContent {
return scrollViewport(&d.formVP, key)
}
return false
}
// ScrollDocumentMouse applies wheel events to scrollable preview content.
func (d *DetailPane) ScrollDocumentMouse(msg tea.MouseMsg) tea.Cmd {
var vp *viewport.Model
switch {
case d.IsDocumentContent():
vp = &d.docVP
case d.isReadOnlyFormContent():
vp = &d.formVP
default:
return nil
}
if msg.Y >= d.documentContentHeight() {
return nil
}
if !vp.MouseWheelEnabled || msg.Action != tea.MouseActionPress {
return nil
}
switch msg.Button {
case tea.MouseButtonWheelUp, tea.MouseButtonWheelDown:
default:
return nil
}
var cmd tea.Cmd
*vp, cmd = vp.Update(msg)
return cmd
}
// DocumentYOffset exposes scroll position for tests.
func (d DetailPane) DocumentYOffset() int {
return d.docVP.YOffset
}
func (d *DetailPane) Update(msg tea.Msg) tea.Cmd { func (d *DetailPane) Update(msg tea.Msg) tea.Cmd {
if !d.focused { if !d.focused {
return nil return nil
@@ -197,14 +397,21 @@ func (d *DetailPane) Update(msg tea.Msg) tea.Cmd {
if d.zone == detailZoneContent { if d.zone == detailZoneContent {
switch d.mode { switch d.mode {
case detailForm: case detailForm:
if d.form.readOnly {
if key, ok := msg.(tea.KeyMsg); ok {
if scrollViewport(&d.formVP, key.String()) {
return nil
}
}
var cmd tea.Cmd
d.formVP, cmd = d.formVP.Update(msg)
return cmd
}
return d.form.Update(msg) return d.form.Update(msg)
case detailDocument: case detailDocument:
if _, ok := msg.(tea.KeyMsg); ok { if key, ok := msg.(tea.KeyMsg); ok {
switch msg.(tea.KeyMsg).String() { if scrollViewport(&d.docVP, key.String()) {
case "pgdown", "pgdn", "f", "ctrl+d": return nil
d.docVP.ViewDown()
case "pgup", "b", "ctrl+u":
d.docVP.ViewUp()
} }
} }
var cmd tea.Cmd var cmd tea.Cmd
@@ -223,9 +430,25 @@ func (d DetailPane) View() string {
var top string var top string
switch d.mode { switch d.mode {
case detailForm: case detailForm:
if d.form.readOnly {
top = ApplyVerticalScrollbar(
d.formVP.View(),
d.formVP.Width,
d.formVP.Height,
d.formVP.TotalLineCount(),
d.formVP.YOffset,
)
} else {
top = lipgloss.NewStyle().Width(d.width).Height(contentH).Render(d.form.View()) top = lipgloss.NewStyle().Width(d.width).Height(contentH).Render(d.form.View())
}
case detailDocument: case detailDocument:
top = d.docVP.View() top = ApplyVerticalScrollbar(
d.docVP.View(),
d.docVP.Width,
d.docVP.Height,
d.docVP.TotalLineCount(),
d.docVP.YOffset,
)
default: default:
top = "" top = ""
} }
@@ -258,7 +481,7 @@ func (d DetailPane) renderActions() string {
} }
line := strings.Join(parts, "") line := strings.Join(parts, "")
if d.focused && d.zone == detailZoneActions { if d.focused && d.zone == detailZoneActions {
line += d.styles.empty.Render(" ←/→ select · Enter run") line += d.styles.empty.Render(" Tab · Enter run")
} }
return line return line
} }
+60
View File
@@ -0,0 +1,60 @@
package ui
import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestDetailDocumentScrollByKey(t *testing.T) {
d := newDetailPane()
d.SetSize(50, 20)
longBody := "# Mail\n\n"
for i := 0; i < 80; i++ {
longBody += "line\n"
}
d.LoadDocument(model.Item{ID: "1", Kind: model.KindMail, Title: "M"}, longBody, 40)
d.SetFocused(true)
if d.DocumentYOffset() != 0 {
t.Fatalf("expected top offset 0, got %d", d.DocumentYOffset())
}
if !d.ScrollDocument("down") {
t.Fatal("expected scroll down to succeed")
}
if d.DocumentYOffset() == 0 {
t.Fatal("expected offset after scroll down")
}
if !d.ScrollDocument("up") {
t.Fatal("expected scroll up to succeed")
}
}
func TestDetailDocumentMouseWheel(t *testing.T) {
d := newDetailPane()
d.SetSize(50, 20)
longBody := "# Mail\n\n"
for i := 0; i < 80; i++ {
longBody += "line\n"
}
d.LoadDocument(model.Item{ID: "1", Kind: model.KindMail, Title: "M"}, longBody, 40)
d.ScrollDocumentMouse(tea.MouseMsg{
X: 10, Y: 2, Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress,
})
if d.DocumentYOffset() == 0 {
t.Fatal("expected wheel down to scroll document")
}
}
func TestDetailPaneXRange(t *testing.T) {
pw := PaneWidths{
Menu: 12, List: 72, Detail: 36,
Visibility: PaneVisibility{Menu: true, List: true, Detail: true},
}
start, end := DetailPaneXRange(pw)
if start != 84 || end != 120 {
t.Fatalf("range=%d..%d want 84..120", start, end)
}
}
+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)
}
}
+65
View File
@@ -0,0 +1,65 @@
package ui
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestDetailTabOrderProjectForm(t *testing.T) {
d := newDetailPane()
d.SetFocused(true)
d.LoadForm(model.Item{ID: "1", Kind: model.KindProject, Title: "P"}, model.FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
Primary: "Alpha", Secondary: "Beta", HasStatus: true,
Status: model.ProjectLifecycleOpen,
})
if d.tabStop != 0 || d.form.field != entityFieldPrimary {
t.Fatalf("start on title, tabStop=%d field=%d", d.tabStop, d.form.field)
}
if d.TabForward() {
t.Fatal("tab from title should stay in pane")
}
if d.form.field != entityFieldSecondary {
t.Fatalf("second stop should be description, field=%d", d.form.field)
}
if d.TabForward() {
t.Fatal("tab from description should stay in pane")
}
if d.form.field != entityFieldStatus {
t.Fatalf("third stop should be status, field=%d", d.form.field)
}
if d.TabForward() {
t.Fatal("tab from status should stay in pane")
}
if d.Zone() != detailZoneActions || d.actionIdx != 0 {
t.Fatalf("fourth stop should be Save, zone=%d idx=%d", d.Zone(), d.actionIdx)
}
if d.TabForward() {
t.Fatal("tab from Save should stay in pane")
}
if d.actionIdx != 1 {
t.Fatalf("fifth stop should be Delete, idx=%d", d.actionIdx)
}
if !d.TabForward() {
t.Fatal("tab from Delete should leave pane")
}
if d.tabStop != 0 {
t.Fatalf("after leaving pane tab order resets, tabStop=%d", d.tabStop)
}
}
func TestDetailShiftTabFromTitleLeavesPane(t *testing.T) {
d := newDetailPane()
d.SetFocused(true)
d.LoadForm(model.Item{ID: "1", Kind: model.KindProject}, model.FormFields{
PrimaryLabel: "Title", SecondaryLabel: "Description",
})
if !d.TabBackward() {
t.Fatal("shift+tab from title should leave pane")
}
if d.tabStop != d.maxTabStop() {
t.Fatalf("should park on last stop, tabStop=%d max=%d", d.tabStop, d.maxTabStop())
}
}
+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")
+340 -5
View File
@@ -15,6 +15,8 @@ type entityField int
const ( const (
entityFieldPrimary entityField = iota entityFieldPrimary entityField = iota
entityFieldSecondary entityFieldSecondary
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.
@@ -25,8 +27,24 @@ type EntityForm struct {
primaryLabel string primaryLabel string
secondaryLabel string secondaryLabel string
readOnly bool readOnly bool
hasStatus bool
status model.ProjectLifecycle
hasTaskStatus bool
taskStatus model.TaskLifecycle
hasResponsible bool
responsibleID string
userChoices []model.UserOption
responsibleIdx int
projectTitle string
timingSummary string
hasUserEdit bool
userEnabled bool
userACL model.UserACLState
groupsText string
userFieldIdx int
primary textinput.Model primary textinput.Model
secondary textarea.Model secondary textarea.Model
password textinput.Model
field entityField field entityField
focused bool focused bool
dirty bool dirty bool
@@ -39,6 +57,7 @@ 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 {
@@ -46,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")),
} }
} }
@@ -57,14 +77,58 @@ 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,
password: password,
field: entityFieldPrimary, field: entityFieldPrimary,
styles: newEntityFormStyles(), styles: newEntityFormStyles(),
} }
} }
func (f *EntityForm) FieldCount() int {
if f.hasUserEdit {
return f.userFieldCount()
}
n := 2
if f.hasStatus || f.hasTaskStatus {
n++
}
if f.hasResponsible {
n++
}
return n
}
func (f *EntityForm) FormFields() model.FormFields {
return model.FormFields{
PrimaryLabel: f.primaryLabel,
SecondaryLabel: f.secondaryLabel,
Primary: f.primary.Value(),
Secondary: f.secondary.Value(),
ReadOnly: f.readOnly,
HasStatus: f.hasStatus,
Status: f.status,
HasTaskStatus: f.hasTaskStatus,
TaskStatus: f.taskStatus,
HasResponsible: f.hasResponsible,
ResponsibleID: f.responsibleID,
UserChoices: f.userChoices,
ProjectTitle: f.projectTitle,
TimingSummary: f.timingSummary,
HasUserEdit: f.hasUserEdit,
UserEnabled: f.userEnabled,
UserACL: f.userACL,
GroupsText: f.groupsText,
UserPassword: f.password.Value(),
}
}
func (f *EntityForm) Active() bool { return f.active } func (f *EntityForm) Active() bool { return f.active }
func (f *EntityForm) ItemID() string { return f.itemID } func (f *EntityForm) ItemID() string { return f.itemID }
@@ -82,22 +146,68 @@ func (f *EntityForm) Load(kind model.Kind, itemID string, fields model.FormField
f.primaryLabel = fields.PrimaryLabel f.primaryLabel = fields.PrimaryLabel
f.secondaryLabel = fields.SecondaryLabel f.secondaryLabel = fields.SecondaryLabel
f.readOnly = fields.ReadOnly f.readOnly = fields.ReadOnly
f.hasStatus = fields.HasStatus
f.status = fields.Status
f.hasTaskStatus = fields.HasTaskStatus
f.taskStatus = fields.TaskStatus
f.hasResponsible = fields.HasResponsible
f.userChoices = fields.UserChoices
f.projectTitle = fields.ProjectTitle
f.timingSummary = fields.TimingSummary
f.responsibleID = fields.ResponsibleID
f.responsibleIdx = indexUserChoice(fields.UserChoices, fields.ResponsibleID)
f.hasUserEdit = fields.HasUserEdit
f.userEnabled = fields.UserEnabled
f.userACL = copyUserACLState(fields.UserACL)
f.groupsText = fields.GroupsText
f.userFieldIdx = 0
f.primary.SetValue(fields.Primary) f.primary.SetValue(fields.Primary)
f.secondary.SetValue(fields.Secondary) f.secondary.SetValue(fields.Secondary)
f.password.SetValue("")
f.dirty = false f.dirty = false
if f.hasUserEdit {
f.userFieldIdx = 0
} else {
f.field = entityFieldPrimary 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.hasTaskStatus = false
f.hasResponsible = false
f.status = model.ProjectLifecycleOpen
f.taskStatus = model.TaskLifecycleOpen
f.responsibleID = ""
f.userChoices = nil
f.responsibleIdx = 0
f.projectTitle = ""
f.timingSummary = ""
f.hasUserEdit = false
f.userEnabled = false
f.userACL = model.UserACLState{}
f.groupsText = ""
f.userFieldIdx = 0
f.primary.SetValue("") f.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) {
@@ -107,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()
} }
} }
@@ -131,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
} }
@@ -139,8 +271,18 @@ func (f *EntityForm) layoutFields() {
f.secondary.SetHeight(secH) f.secondary.SetHeight(secH)
} }
func (f *EntityForm) lastField() entityField {
if f.hasResponsible {
return entityFieldResponsible
}
if f.hasStatus || f.hasTaskStatus {
return entityFieldStatus
}
return entityFieldSecondary
}
func (f *EntityForm) FocusNext() { func (f *EntityForm) FocusNext() {
if f.field < entityFieldSecondary { if f.field < f.lastField() {
f.field++ f.field++
} }
f.applyFocus() f.applyFocus()
@@ -153,7 +295,86 @@ func (f *EntityForm) FocusPrev() {
f.applyFocus() f.applyFocus()
} }
func (f *EntityForm) SetFieldIndex(i int) {
if f.hasUserEdit {
f.setUserFieldIndex(i)
return
}
switch i {
case 0:
f.field = entityFieldPrimary
case 1:
f.field = entityFieldSecondary
case 2:
if f.hasStatus || f.hasTaskStatus {
f.field = entityFieldStatus
} else if f.hasResponsible {
f.field = entityFieldResponsible
}
case 3:
if f.hasResponsible && (f.hasStatus || f.hasTaskStatus) {
f.field = entityFieldResponsible
}
default:
return
}
f.applyFocus()
}
func (f *EntityForm) cycleStatus(delta int) {
if f.readOnly {
return
}
if f.hasTaskStatus {
if delta > 0 {
f.taskStatus = f.taskStatus.Next()
} else {
f.taskStatus = f.taskStatus.Prev()
}
f.dirty = true
return
}
if !f.hasStatus {
return
}
if delta > 0 {
f.status = f.status.Next()
} else {
f.status = f.status.Prev()
}
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()
@@ -163,9 +384,12 @@ func (f *EntityForm) applyFocus() {
case entityFieldPrimary: case entityFieldPrimary:
f.primary.Focus() f.primary.Focus()
f.secondary.Blur() f.secondary.Blur()
default: case entityFieldSecondary:
f.primary.Blur() f.primary.Blur()
f.secondary.Focus() f.secondary.Focus()
default:
f.primary.Blur()
f.secondary.Blur()
} }
} }
@@ -173,12 +397,45 @@ 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 f.hasUserEdit {
return f.updateUserForm(msg)
}
if key, ok := msg.(tea.KeyMsg); ok {
switch f.field {
case entityFieldStatus:
switch key.String() {
case "left", "h":
f.cycleStatus(-1)
return nil
case "right", "l":
f.cycleStatus(1)
return nil
case " ":
f.cycleStatus(1)
return nil
}
case entityFieldResponsible:
switch key.String() {
case "left", "h":
f.cycleResponsible(-1)
return nil
case "right", "l":
f.cycleResponsible(1)
return nil
case " ":
f.cycleResponsible(1)
return nil
}
}
}
var cmd tea.Cmd var cmd tea.Cmd
switch f.field { switch f.field {
case entityFieldPrimary: case entityFieldPrimary:
f.primary, cmd = f.primary.Update(msg) f.primary, cmd = f.primary.Update(msg)
default: case entityFieldSecondary:
f.secondary, cmd = f.secondary.Update(msg) f.secondary, cmd = f.secondary.Update(msg)
default:
return nil
} }
if _, ok := msg.(tea.KeyMsg); ok { if _, ok := msg.(tea.KeyMsg); ok {
f.dirty = true f.dirty = true
@@ -190,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 {
@@ -199,20 +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 := f.renderStatusBlock()
responsibleBlock := f.renderResponsibleBlock()
metaBlock := f.renderMetaBlock()
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(),
}
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)
}
}
+99
View File
@@ -0,0 +1,99 @@
package ui
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// FilterSearch is the right-pane query input for nav/list filtering.
type FilterSearch struct {
input textinput.Model
width int
height int
focused bool
styles filterSearchStyles
}
type filterSearchStyles struct {
title lipgloss.Style
hint lipgloss.Style
}
func newFilterSearch() FilterSearch {
in := textinput.New()
in.Prompt = "/ "
in.Placeholder = "Filter list…"
in.CharLimit = 256
return FilterSearch{
input: in,
styles: filterSearchStyles{
title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")),
hint: lipgloss.NewStyle().Foreground(lipgloss.Color("241")),
},
}
}
func (f *FilterSearch) Query() string { return f.input.Value() }
func (f *FilterSearch) SetFocused(on bool) {
f.focused = on
if on {
f.input.Focus()
} else {
f.input.Blur()
}
}
func (f *FilterSearch) SetSize(w, h int) {
if w < 12 {
w = 12
}
if h < 6 {
h = 6
}
f.width = w
f.height = h
inner := w - 2
if inner < 8 {
inner = 8
}
f.input.Width = inner
}
func (f *FilterSearch) Clear() {
f.input.SetValue("")
}
func (f *FilterSearch) Update(msg tea.Msg) tea.Cmd {
if !f.focused {
return nil
}
var cmd tea.Cmd
f.input, cmd = f.input.Update(msg)
return cmd
}
func (f *FilterSearch) SetInputWidth(inner int) {
if inner < 8 {
inner = 8
}
f.input.Width = inner
}
func (f *FilterSearch) CompactView() string {
return f.input.View()
}
func (f FilterSearch) View() string {
title := f.styles.title.Render("Filter")
body := lipgloss.NewStyle().Width(f.width).Render(f.input.View())
hint := f.styles.hint.Render("Filters left nav and center table · Esc clear · f focus")
return strings.Join([]string{title, "", body, "", hint}, "\n")
}
func filterSearchBlinkCmd() tea.Cmd {
return textinput.Blink
}
+8
View File
@@ -17,6 +17,7 @@ const (
ActionToggleMenuPane ActionToggleMenuPane
ActionToggleListPane ActionToggleListPane
ActionToggleDetailPane ActionToggleDetailPane
ActionFilter
ActionNextPane ActionNextPane
ActionPrevPane ActionPrevPane
ActionRefresh ActionRefresh
@@ -38,6 +39,13 @@ func KeyAction(key string, pane model.FocusPane) Action {
} }
case "r": case "r":
return ActionRefresh return ActionRefresh
case "f", "/":
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":
+37 -21
View File
@@ -17,44 +17,60 @@ func defaultPaneVisibility() PaneVisibility {
return PaneVisibility{Menu: true, List: true, Detail: true} return PaneVisibility{Menu: true, List: true, Detail: true}
} }
// LayoutWidths splits total terminal width evenly across visible panes. const (
defaultMenuShare = 10
defaultListShare = 60
defaultDetailShare = 30
)
// LayoutWidths splits total terminal width across visible panes (default 10% / 60% / 30%).
func LayoutWidths(total int, vis PaneVisibility) PaneWidths { func LayoutWidths(total int, vis PaneVisibility) PaneWidths {
if total < 1 { if total < 1 {
total = 80 total = 80
} }
v := vis v := vis
n := countVisible(v) if countVisible(v) == 0 {
if n == 0 {
v = defaultPaneVisibility() v = defaultPaneVisibility()
n = 3
} }
base := total / n shareSum := 0
rem := total % n
out := PaneWidths{Visibility: v}
if v.Menu { if v.Menu {
out.Menu = base shareSum += defaultMenuShare
if rem > 0 {
out.Menu++
rem--
}
} }
if v.List { if v.List {
out.List = base shareSum += defaultListShare
if rem > 0 {
out.List++
rem--
}
} }
if v.Detail { if v.Detail {
out.Detail = base shareSum += defaultDetailShare
if rem > 0 {
out.Detail++
rem--
} }
assign := func(visible bool, share int) int {
if !visible || shareSum == 0 {
return 0
} }
return total * share / shareSum
}
out := PaneWidths{Visibility: v}
out.Menu = assign(v.Menu, defaultMenuShare)
out.List = assign(v.List, defaultListShare)
out.Detail = assign(v.Detail, defaultDetailShare)
fixPaneWidthSum(&out, total)
return out return out
} }
// DetailPaneXRange returns the [start, end) column span of the detail pane.
func DetailPaneXRange(pw PaneWidths) (start, end int) {
if !pw.Visibility.Detail {
return 0, 0
}
if pw.Visibility.Menu {
start += pw.Menu
}
if pw.Visibility.List {
start += pw.List
}
end = start + pw.Detail
return start, end
}
func countVisible(v PaneVisibility) int { func countVisible(v PaneVisibility) int {
n := 0 n := 0
if v.Menu { if v.Menu {
+203
View File
@@ -0,0 +1,203 @@
package ui
import (
tea "github.com/charmbracelet/bubbletea"
)
const (
minPaneOuterWidth = 14
resizeHitSlop = 1
)
type paneResizeState struct {
active bool
divider int
anchorX int
anchorPW PaneWidths
}
// FitPaneWidths scales stored pane widths to fill total width for visible panes.
func FitPaneWidths(total int, vis PaneVisibility, sizes PaneWidths) PaneWidths {
if total < 1 {
total = 80
}
out := PaneWidths{Visibility: vis}
if countVisible(vis) == 0 {
return LayoutWidths(total, vis)
}
sum := 0
if vis.Menu {
sum += sizes.Menu
}
if vis.List {
sum += sizes.List
}
if vis.Detail {
sum += sizes.Detail
}
if sum <= 0 {
return LayoutWidths(total, vis)
}
assign := func(visible bool, stored int) int {
if !visible {
return 0
}
return stored * total / sum
}
out.Menu = assign(vis.Menu, sizes.Menu)
out.List = assign(vis.List, sizes.List)
out.Detail = assign(vis.Detail, sizes.Detail)
fixPaneWidthSum(&out, total)
return out
}
func fixPaneWidthSum(pw *PaneWidths, total int) {
if pw == nil || total < 1 {
return
}
type slot struct {
visible bool
width *int
}
slots := []slot{
{pw.Visibility.Menu, &pw.Menu},
{pw.Visibility.List, &pw.List},
{pw.Visibility.Detail, &pw.Detail},
}
sum := 0
var last *int
for _, s := range slots {
if !s.visible {
continue
}
sum += *s.width
last = s.width
}
if last == nil {
return
}
*last += total - sum
if *last < minPaneOuterWidth {
*last = minPaneOuterWidth
}
}
// DividerPositions returns x coordinates of vertical pane separators.
func DividerPositions(pw PaneWidths) []int {
var out []int
x := 0
if pw.Visibility.Menu {
x += pw.Menu
if pw.Visibility.List || pw.Visibility.Detail {
out = append(out, x)
}
}
if pw.Visibility.List {
x += pw.List
if pw.Visibility.Detail {
out = append(out, x)
}
}
return out
}
// DividerAt returns the divider index under x, or -1.
func DividerAt(x int, pw PaneWidths) int {
for i, pos := range DividerPositions(pw) {
if x >= pos-resizeHitSlop && x <= pos+resizeHitSlop {
return i
}
}
return -1
}
// DragPaneDivider adjusts adjacent pane widths by deltaX pixels.
func DragPaneDivider(pw PaneWidths, divider, deltaX int) (PaneWidths, bool) {
if deltaX == 0 {
return pw, false
}
changed := false
switch divider {
case 0:
if pw.Visibility.Menu && pw.Visibility.List {
if resizePair(&pw.Menu, &pw.List, deltaX) {
changed = true
}
} else if pw.Visibility.Menu && pw.Visibility.Detail {
if resizePair(&pw.Menu, &pw.Detail, deltaX) {
changed = true
}
}
case 1:
if pw.Visibility.List && pw.Visibility.Detail {
if resizePair(&pw.List, &pw.Detail, deltaX) {
changed = true
}
}
}
return pw, changed
}
func resizePair(left, right *int, delta int) bool {
if left == nil || right == nil {
return false
}
newLeft := *left + delta
newRight := *right - delta
if newLeft < minPaneOuterWidth || newRight < minPaneOuterWidth {
return false
}
*left = newLeft
*right = newRight
return true
}
func (m Model) paneResizeMaxY() int {
return m.paneHeight() + 1
}
func (m *Model) handlePaneResizeMouse(msg tea.MouseMsg) bool {
if msg.Y >= m.paneResizeMaxY() {
if m.resize.active && msg.Action == tea.MouseActionRelease {
m.resize.active = false
return true
}
return false
}
pw := m.paneLayout()
switch msg.Action {
case tea.MouseActionPress:
if msg.Button != tea.MouseButtonLeft {
return false
}
if div := DividerAt(msg.X, pw); div >= 0 {
m.paneSizes = pw
m.customPaneLayout = true
m.resize = paneResizeState{
active: true,
divider: div,
anchorX: msg.X,
anchorPW: pw,
}
return true
}
case tea.MouseActionRelease:
if m.resize.active {
m.resize.active = false
return true
}
case tea.MouseActionMotion:
if m.resize.active {
delta := msg.X - m.resize.anchorX
next, ok := DragPaneDivider(m.resize.anchorPW, m.resize.divider, delta)
if ok {
m.paneSizes = next
m.layoutViewports()
m.syncAllContent()
}
return true
}
}
return m.resize.active
}
+41
View File
@@ -0,0 +1,41 @@
package ui
import "testing"
func TestDividerAtBetweenMenuAndList(t *testing.T) {
pw := PaneWidths{
Menu: 30, List: 50, Detail: 40,
Visibility: PaneVisibility{Menu: true, List: true, Detail: true},
}
if got := DividerAt(30, pw); got != 0 {
t.Fatalf("divider at 30=%d want 0", got)
}
if got := DividerAt(80, pw); got != 1 {
t.Fatalf("divider at 80=%d want 1", got)
}
}
func TestDragPaneDividerRespectsMinimum(t *testing.T) {
pw := PaneWidths{
Menu: 40, List: 40, Detail: 40,
Visibility: PaneVisibility{Menu: true, List: true, Detail: true},
}
next, ok := DragPaneDivider(pw, 0, -30)
if ok {
t.Fatalf("expected resize blocked by minimum, got %+v", next)
}
next, ok = DragPaneDivider(pw, 0, 5)
if !ok || next.Menu != 45 || next.List != 35 {
t.Fatalf("resize failed: ok=%v %+v", ok, next)
}
}
func TestFitPaneWidthsScalesToTotal(t *testing.T) {
vis := PaneVisibility{Menu: true, List: true, Detail: true}
sizes := PaneWidths{Menu: 20, List: 30, Detail: 50, Visibility: vis}
out := FitPaneWidths(120, vis, sizes)
sum := out.Menu + out.List + out.Detail
if sum != 120 {
t.Fatalf("sum=%d want 120", sum)
}
}
+69
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) {
@@ -13,6 +18,9 @@ func TestLayoutWidthsAllVisibleUsesFullWidth(t *testing.T) {
if sum != 120 { if sum != 120 {
t.Fatalf("sum=%d want 120", sum) t.Fatalf("sum=%d want 120", sum)
} }
if pw.Menu != 12 || pw.List != 72 || pw.Detail != 36 {
t.Fatalf("menu=%d list=%d detail=%d want 12/72/36 (10/60/30)", pw.Menu, pw.List, pw.Detail)
}
} }
func TestLayoutWidthsSinglePane(t *testing.T) { func TestLayoutWidthsSinglePane(t *testing.T) {
@@ -30,6 +38,20 @@ func TestLayoutWidthsTwoPanes(t *testing.T) {
if pw.Menu+pw.Detail != 80 { if pw.Menu+pw.Detail != 80 {
t.Fatalf("sum=%d want 80", pw.Menu+pw.Detail) t.Fatalf("sum=%d want 80", pw.Menu+pw.Detail)
} }
if pw.Menu != 20 || pw.Detail != 60 {
t.Fatalf("menu=%d detail=%d want 20/60 (10/30 of pair)", pw.Menu, pw.Detail)
}
}
func TestDetailPaneXRangeSkipsHidden(t *testing.T) {
pw := PaneWidths{
Menu: 20, List: 60, Detail: 0,
Visibility: PaneVisibility{Menu: true, List: true, Detail: false},
}
start, end := DetailPaneXRange(pw)
if start != 0 || end != 0 {
t.Fatalf("hidden detail should have empty range, got %d..%d", start, end)
}
} }
func TestNextVisibleFocusSkipsHidden(t *testing.T) { func TestNextVisibleFocusSkipsHidden(t *testing.T) {
@@ -49,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")
}
}
+40 -7
View File
@@ -8,10 +8,14 @@ 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:
switch key {
case "pgdown", "pgdn", "ctrl+d", "pgup", "b", "ctrl+u", "home", "g", "end", "G":
return scrollViewport(&m.menuVP, key) return scrollViewport(&m.menuVP, key)
}
return false
case model.FocusList: case model.FocusList:
switch key { switch key {
case "pgdown", "pgdn", "f", "ctrl+d": case "pgdown", "pgdn", "ctrl+d":
m.listTable.PageScroll(1) m.listTable.PageScroll(1)
return true return true
case "pgup", "b", "ctrl+u": case "pgup", "b", "ctrl+u":
@@ -20,10 +24,10 @@ func (m *Model) scrollFocusedPane(key string) bool {
} }
return false return false
case model.FocusPreview: case model.FocusPreview:
if m.detail.Zone() != detailZoneContent || m.detail.mode != detailDocument { if m.detail.Zone() != detailZoneContent {
return false return false
} }
return scrollViewport(&m.detail.docVP, key) return m.detail.ScrollDocument(key)
default: default:
return false return false
} }
@@ -37,20 +41,49 @@ 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 "pgdown", "pgdn", "f", "ctrl+d": case "up", "k":
vp.LineUp(1)
return true
case "down", "j":
vp.LineDown(1)
return true
case "pgdown", "pgdn", "ctrl+d":
vp.ViewDown() vp.ViewDown()
return true return true
case "pgup", "b", "ctrl+u": case "pgup", "b", "ctrl+u":
+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)
}
}
+65
View File
@@ -0,0 +1,65 @@
package ui
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
// ScrollbarMetrics computes thumb position for a vertical scrollbar.
func ScrollbarMetrics(totalLines, viewportHeight, yOffset int) (show bool, thumbStart, thumbEnd int) {
if totalLines <= viewportHeight || viewportHeight < 1 {
return false, 0, 0
}
maxOff := totalLines - viewportHeight
if maxOff < 0 {
maxOff = 0
}
thumbSize := viewportHeight * viewportHeight / totalLines
if thumbSize < 1 {
thumbSize = 1
}
if thumbSize > viewportHeight {
thumbSize = viewportHeight
}
travel := viewportHeight - thumbSize
thumbStart = 0
if maxOff > 0 {
thumbStart = yOffset * travel / maxOff
}
return true, thumbStart, thumbStart + thumbSize
}
// ApplyVerticalScrollbar draws a 1-column scrollbar on the right when content overflows.
func ApplyVerticalScrollbar(view string, width, height, totalLines, yOffset int) string {
show, thumbStart, thumbEnd := ScrollbarMetrics(totalLines, height, yOffset)
if !show || width < 2 {
return view
}
contentWidth := width - 1
lines := strings.Split(view, "\n")
for len(lines) < height {
lines = append(lines, "")
}
if len(lines) > height {
lines = lines[:height]
}
track := lipgloss.NewStyle().Foreground(lipgloss.Color("238"))
thumb := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
out := make([]string, height)
for row := 0; row < height; row++ {
line := padANSIWidth(lines[row], contentWidth)
ch := "│"
style := track
if row >= thumbStart && row < thumbEnd {
ch = "█"
style = thumb
}
out[row] = line + style.Render(ch)
}
return strings.Join(out, "\n")
}
func padDisplayWidth(line string, width int) string {
return padANSIWidth(line, width)
}
+32
View File
@@ -0,0 +1,32 @@
package ui
import (
"strings"
"testing"
)
func TestScrollbarMetricsHiddenWhenFits(t *testing.T) {
show, _, _ := ScrollbarMetrics(5, 10, 0)
if show {
t.Fatal("expected no scrollbar when content fits")
}
}
func TestScrollbarMetricsVisibleWhenOverflow(t *testing.T) {
show, start, end := ScrollbarMetrics(100, 10, 50)
if !show || end <= start {
t.Fatalf("expected thumb, got %d..%d", start, end)
}
}
func TestApplyVerticalScrollbarAddsColumn(t *testing.T) {
view := "line1\nline2\nline3"
out := ApplyVerticalScrollbar(view, 12, 3, 10, 0)
lines := strings.Split(out, "\n")
if len(lines) != 3 {
t.Fatalf("lines=%d", len(lines))
}
if !strings.HasSuffix(lines[0], "█") && !strings.HasSuffix(lines[0], "│") {
t.Fatalf("expected scrollbar glyph: %q", lines[0])
}
}
+132 -134
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 {
@@ -53,6 +52,7 @@ type DataTable struct {
sortAsc bool sortAsc bool
focused bool focused bool
ready bool ready bool
loadingMore bool
width int width int
height int height int
viewport viewport.Model viewport viewport.Model
@@ -79,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
} }
@@ -127,6 +127,62 @@ func (t *DataTable) ItemIndex() int {
return t.order[t.cursorRow] return t.order[t.cursorRow]
} }
// CursorRow is the visual row index in the current table order.
func (t DataTable) CursorRow() int {
return t.cursorRow
}
// Items returns the backing slice (after appends).
func (t DataTable) Items() []model.Item {
return t.items
}
// NearEnd reports whether the cursor is within threshold rows of the last row.
func (t DataTable) NearEnd(threshold int) bool {
if !t.ready || len(t.order) == 0 {
return false
}
return t.cursorRow >= len(t.order)-threshold
}
func (t *DataTable) SetLoadingMore(on bool) {
t.loadingMore = on
}
// AppendItems adds new rows, skipping duplicate IDs. Returns how many were added.
func (t *DataTable) AppendItems(items []model.Item) int {
if !t.ready || len(items) == 0 {
return 0
}
seen := make(map[string]struct{}, len(t.items))
for _, it := range t.items {
if it.ID != "" {
seen[it.ID] = struct{}{}
}
}
added := 0
for _, it := range items {
if it.ID != "" {
if _, ok := seen[it.ID]; ok {
continue
}
seen[it.ID] = struct{}{}
}
idx := len(t.items)
t.items = append(t.items, it)
t.order = append(t.order, idx)
added++
}
if added > 0 {
if t.sortCol >= 0 {
t.applySort()
}
t.clampCursor()
t.refreshViewport()
}
return added
}
func (t *DataTable) MoveRow(delta int) { func (t *DataTable) MoveRow(delta int) {
if !t.ready || len(t.order) == 0 { if !t.ready || len(t.order) == 0 {
return return
@@ -190,7 +246,7 @@ func (t *DataTable) Update(msg tea.Msg) tea.Cmd {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.KeyMsg: case tea.KeyMsg:
switch msg.String() { switch msg.String() {
case "pgdown", "pgdn", "f", "ctrl+d": case "pgdown", "pgdn", "ctrl+d":
t.viewport.ViewDown() t.viewport.ViewDown()
case "pgup", "b", "ctrl+u": case "pgup", "b", "ctrl+u":
t.viewport.ViewUp() t.viewport.ViewUp()
@@ -203,133 +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"
}
header := t.renderHeader()
if t.hasVerticalScrollbar() {
header = padANSIWidth(header, t.lineContentWidth())
}
body := ApplyVerticalScrollbar(
t.viewport.View(),
t.width,
t.viewport.Height,
t.viewport.TotalLineCount(),
t.viewport.YOffset,
)
return lipgloss.JoinVertical(lipgloss.Left, header, body)
}
func (t DataTable) SortHint() string {
if t.sortCol < 0 || t.sortCol >= len(t.columns) {
return ""
} }
title := t.styles.title.Render(fmt.Sprintf("%s (%d)", t.spec.Subject, len(t.items)))
sortHint := ""
if t.sortCol >= 0 && t.sortCol < len(t.columns) {
dir := "▲" dir := "▲"
if !t.sortAsc { if !t.sortAsc {
dir = "▼" dir = "▼"
} }
sortHint = t.styles.help.Render(fmt.Sprintf(" sort: %s %s", t.columns[t.sortCol].Title, dir)) return fmt.Sprintf("sort: %s %s", t.columns[t.sortCol].Title, dir)
}
func (t DataTable) SubjectLabel() string {
if !t.ready {
return "List"
} }
header := t.renderHeader() return string(t.spec.Subject)
body := t.viewport.View() }
content := lipgloss.JoinVertical(lipgloss.Left, title+sortHint, header, body)
return lipgloss.NewStyle().Width(t.width).Render(content) 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":
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
}
} }
func (t *DataTable) renderHeader() string { func (t *DataTable) renderHeader() string {
@@ -346,12 +332,12 @@ func (t *DataTable) renderHeader() string {
title += " ▼" title += " ▼"
} }
} }
text := runewidth.Truncate(title, w, "…") text := truncateCellText(title, w)
style := t.styles.header style := t.styles.header
if t.focused && colIdx == t.cursorCol { if t.focused && colIdx == t.cursorCol {
style = t.styles.headerSort style = t.styles.headerSort
} }
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...)
} }
@@ -376,38 +362,50 @@ func (t *DataTable) renderRow(row int) string {
for _, colIdx := range indices { for _, colIdx := range indices {
col := t.columns[colIdx] col := t.columns[colIdx]
w := widths[colIdx] w := widths[colIdx]
text := runewidth.Truncate(model.CellText(item, col.Key), w, "…") raw := strings.TrimSpace(model.CellText(item, col.Key))
cells = append(cells, t.styleCell(row, colIdx, selected, text, w)) text := truncateCellText(raw, w)
cells = append(cells, t.styleCell(row, colIdx, col.Key, selected, item, text, w))
} }
return lipgloss.JoinHorizontal(lipgloss.Left, cells...) return lipgloss.JoinHorizontal(lipgloss.Left, cells...)
} }
func (t *DataTable) styleCell(row, col int, selected bool, text string, width int) string { func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item model.Item, text string, width int) string {
base := t.styles.cell.Width(width).MaxWidth(width) if t.spec.Subject == model.SubjectProjects {
style := t.projectCellStyle(row, col, colKey, selected, item)
return renderTableCell(style, text, width)
}
isRow := row == t.cursorRow isRow := row == t.cursorRow
isCol := col == t.cursorCol isCol := col == t.cursorCol
isCell := t.focused && isRow && isCol isCell := t.focused && isRow && isCol
var base lipgloss.Style
switch { switch {
case selected && isCell: case selected && isCell:
return t.styles.cellSelect.Width(width).MaxWidth(width).Render(text) base = t.styles.cellSelect
case selected && isRow: case selected && isRow:
return t.styles.rowSelect.Width(width).MaxWidth(width).Render(text) base = t.styles.rowSelect
case selected: case selected:
return t.styles.rowSelect.Width(width).MaxWidth(width).Render(text) base = t.styles.rowSelect
case isCell: case isCell:
return t.styles.cellActive.Width(width).MaxWidth(width).Render(text) base = t.styles.cellActive
case t.focused && isRow: case t.focused && isRow:
return t.styles.rowActive.Width(width).MaxWidth(width).Render(text) base = t.styles.rowActive
case t.focused && isCol: case t.focused && isCol:
return t.styles.colActive.Width(width).MaxWidth(width).Render(text) base = t.styles.colActive
default: default:
return base.Render(text) base = t.styles.cell
} }
return renderTableCell(base, text, width)
} }
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
} }
@@ -418,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
} }
+40
View File
@@ -0,0 +1,40 @@
package ui
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestDataTableAppendItemsDedupes(t *testing.T) {
tbl := newDataTable()
tbl.SetSize(40, 10)
tbl.SetData(model.ListSpec{Subject: model.SubjectMailInbox}, []model.Item{
{ID: "1", Title: "A", Kind: model.KindMail},
})
added := tbl.AppendItems([]model.Item{
{ID: "1", Title: "dup", Kind: model.KindMail},
{ID: "2", Title: "B", Kind: model.KindMail},
})
if added != 1 || len(tbl.Items()) != 2 {
t.Fatalf("added=%d len=%d", added, len(tbl.Items()))
}
}
func TestDataTableNearEnd(t *testing.T) {
tbl := newDataTable()
tbl.SetSize(40, 10)
items := make([]model.Item, 10)
for i := range items {
items[i] = model.Item{ID: string(rune('0' + i)), Title: "x", Kind: model.KindMail}
}
tbl.SetData(model.ListSpec{Subject: model.SubjectMailInbox}, items)
tbl.cursorRow = 7
if !tbl.NearEnd(3) {
t.Fatal("expected near end")
}
tbl.cursorRow = 0
if tbl.NearEnd(3) {
t.Fatal("expected not near end")
}
}
+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)
}
}
+193 -1
View File
@@ -1,10 +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/muesli/termenv"
) )
func sampleItems() []model.Item { func sampleItems() []model.Item {
@@ -32,7 +36,7 @@ func TestDataTableMoveColScrolls(t *testing.T) {
items[0] = model.Item{ID: "1", Title: "One", Raw: map[string]any{ items[0] = model.Item{ID: "1", Title: "One", Raw: map[string]any{
"alpha": "a", "beta": "b", "gamma": "c", "delta": "d", "alpha": "a", "beta": "b", "gamma": "c", "delta": "d",
}} }}
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, items) tbl.SetData(model.ListSpec{Subject: model.SubjectContacts}, items)
last := len(tbl.columns) - 1 last := len(tbl.columns) - 1
tbl.cursorCol = last tbl.cursorCol = last
tbl.ensureColVisible() tbl.ensureColVisible()
@@ -88,6 +92,185 @@ 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) {
long := strings.Repeat("x", 40)
got := truncateCellText(long, 10)
if runewidth.StringWidth(got) > 10 {
t.Fatalf("truncated width=%d want <=10", runewidth.StringWidth(got))
}
if !strings.HasSuffix(got, "…") {
t.Fatalf("expected ellipsis suffix, got %q", got)
}
}
func TestDataTableTruncatesNonSelectedRows(t *testing.T) {
long := strings.Repeat("A", 60)
items := []model.Item{
{ID: "1", Title: "Short"},
{ID: "2", Title: long},
}
tbl := newDataTable()
tbl.SetSize(50, 10)
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, items)
tbl.cursorRow = 0
if strings.Contains(tbl.renderRow(1), long) {
t.Fatal("non-cursor row should truncate long title")
}
tbl.cursorRow = 1
if !strings.Contains(tbl.renderRow(1), strings.Repeat("A", 10)) {
t.Fatal("cursor row should show title text within column")
}
}
func TestDataTableRowWidthMatchesColumns(t *testing.T) {
tbl := newDataTable()
tbl.SetSize(80, 12)
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, sampleItems())
tbl.SetFocused(true)
_, widths := tbl.visibleLayout()
want := 0
for _, w := range widths {
want += w
}
re := regexp.MustCompile(`\x1b\[[0-9;]*m`)
row := re.ReplaceAllString(tbl.renderRow(0), "")
got := runewidth.StringWidth(row)
if got < want-2 || got > want+2 {
t.Fatalf("row display width=%d want ~%d", got, want)
}
}
func TestDataTableShowsFullTextWhenSpaceSelected(t *testing.T) {
long := strings.Repeat("B", 60)
items := []model.Item{
{ID: "1", Title: "Short"},
{ID: "2", Title: long, Selected: true},
}
tbl := newDataTable()
tbl.SetSize(50, 10)
tbl.SetData(model.ListSpec{Subject: model.SubjectTasks}, items)
tbl.cursorRow = 0
rowSelected := tbl.renderRow(1)
if !strings.Contains(rowSelected, strings.Repeat("B", 10)) {
t.Fatal("space-selected row should show title text within column")
}
}
func indexOfColumn(tbl DataTable, key string) int { func indexOfColumn(tbl DataTable, key string) int {
for i, c := range tbl.columns { for i, c := range tbl.columns {
if c.Key == key { if c.Key == key {
@@ -96,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
}
+103 -3
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -17,6 +18,7 @@ func init() {
rootCmd.AddCommand(crmTasksCmd) rootCmd.AddCommand(crmTasksCmd)
crmTasksCmd.AddCommand(crmTasksListCmd()) crmTasksCmd.AddCommand(crmTasksListCmd())
crmTasksCmd.AddCommand(crmTasksCreateCmd()) crmTasksCmd.AddCommand(crmTasksCreateCmd())
crmTasksCmd.AddCommand(crmTasksReassignSelfCmd())
crmTasksCmd.AddCommand(crmTasksDeleteCmd()) crmTasksCmd.AddCommand(crmTasksDeleteCmd())
crmTasksCmd.AddCommand(crmTasksCategoriesCmd()) crmTasksCmd.AddCommand(crmTasksCategoriesCmd())
} }
@@ -38,7 +40,7 @@ func crmTasksListCmd() *cobra.Command {
if outputFormat == "table" { if outputFormat == "table" {
fmt.Printf("total: %d (shown: %d)\n", total, len(list)) fmt.Printf("total: %d (shown: %d)\n", total, len(list))
} }
printTable([]string{"id", "title", "deadline", "isClosed", "categoryID"}, list) printTable([]string{"id", "title", "deadLine", "isClosed", "categoryID"}, list)
return nil return nil
}, },
} }
@@ -53,7 +55,7 @@ func crmTasksCreateCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "create TITLE", Use: "create TITLE",
Aliases: []string{"add"}, Aliases: []string{"add"},
Short: "Create a CRM task", Short: "Create a CRM task (assigns self + deadline default now+14d)",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd) c, err := newOO(cmd)
@@ -68,7 +70,7 @@ func crmTasksCreateCmd() *cobra.Command {
return nil return nil
}, },
} }
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline (YYYY-MM-DD or ISO8601)") cmd.Flags().StringVar(&deadline, "deadline", "", "deadline (YYYY-MM-DD or ISO8601); default now+14d")
cmd.Flags().IntVar(&categoryID, "category", 0, "category id (see `oo crm-tasks categories`)") cmd.Flags().IntVar(&categoryID, "category", 0, "category id (see `oo crm-tasks categories`)")
cmd.Flags().IntVar(&contactID, "contact", 0, "contact id") cmd.Flags().IntVar(&contactID, "contact", 0, "contact id")
cmd.Flags().StringVar(&entityType, "entity-type", "", "opportunity|case|contact") cmd.Flags().StringVar(&entityType, "entity-type", "", "opportunity|case|contact")
@@ -77,6 +79,104 @@ func crmTasksCreateCmd() *cobra.Command {
return cmd return cmd
} }
func crmTaskNeedsOwner(t map[string]any) (need bool, name string) {
resp, _ := t["responsible"].(map[string]any)
rid, _ := resp["id"].(string)
name, _ = resp["displayName"].(string)
need = rid == "" || strings.EqualFold(name, "Profile has been removed")
return need, name
}
func crmTaskCategoryID(t map[string]any) int {
cat, ok := t["category"].(map[string]any)
if !ok {
return 0
}
switch v := cat["id"].(type) {
case float64:
return int(v)
case int:
return v
default:
return 0
}
}
func crmTasksReassignSelfCmd() *cobra.Command {
var apply bool
var count, max int
cmd := &cobra.Command{
Use: "reassign-self",
Short: "Reassign CRM tasks with missing/removed owner to the authenticated user",
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newOO(cmd)
if err != nil {
return err
}
uid, err := c.SelfUserID(cmd.Context())
if err != nil {
return err
}
type item struct {
id, title, deadline, ownerName string
categoryID int
}
var todos []item
scanned := 0
for start := 0; ; start += count {
list, total, err := c.ListCRMTasks(cmd.Context(), count, start)
if err != nil {
return err
}
for _, t := range list {
scanned++
need, name := crmTaskNeedsOwner(t)
if !need {
continue
}
title, _ := t["title"].(string)
dl, _ := t["deadLine"].(string)
todos = append(todos, item{
id: fmt.Sprint(t["id"]), title: title, deadline: dl,
ownerName: name, categoryID: crmTaskCategoryID(t),
})
if max > 0 && len(todos) >= max {
break
}
}
if (max > 0 && len(todos) >= max) || start+count >= total || len(list) == 0 {
break
}
}
fixed := 0
for _, t := range todos {
fmt.Printf(" task %s %q owner=%q → %s\n", t.id, t.title, t.ownerName, uid)
if !apply {
fixed++
continue
}
if _, err := c.UpdateCRMTask(cmd.Context(), t.id, t.title, t.deadline, t.categoryID, uid); err != nil {
return fmt.Errorf("update %s: %w", t.id, err)
}
fixed++
}
mode := "DRY-RUN"
if apply {
mode = "APPLIED"
}
fmt.Printf("%s: scanned=%d reassigned=%d (self=%s)\n", mode, scanned, fixed, uid)
return nil
},
}
cmd.Flags().BoolVar(&apply, "apply", false, "write changes (default dry-run)")
cmd.Flags().IntVar(&count, "count", 100, "page size")
cmd.Flags().IntVar(&max, "max", 0, "stop after N candidates (0 = all)")
return cmd
}
func crmTasksDeleteCmd() *cobra.Command { func crmTasksDeleteCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "delete TASK_ID [TASK_ID...]", Use: "delete TASK_ID [TASK_ID...]",
+1 -1
View File
@@ -110,7 +110,7 @@ func taskCreateCmd() *cobra.Command {
} }
cmd.Flags().StringVarP(&project, "project", "p", "", "project id (default $OO_PROJECT_ID)") cmd.Flags().StringVarP(&project, "project", "p", "", "project id (default $OO_PROJECT_ID)")
cmd.Flags().StringVar(&desc, "description", "", "description") cmd.Flags().StringVar(&desc, "description", "", "description")
cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD") cmd.Flags().StringVar(&deadline, "deadline", "", "deadline YYYY-MM-DD (default now+14d; always assigned to you)")
cmd.Flags().StringVar(&prio, "priority", "normal", "high|normal|low") cmd.Flags().StringVar(&prio, "priority", "normal", "high|normal|low")
return cmd return cmd
} }
+38
View File
@@ -11,6 +11,7 @@ import (
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"time"
) )
// ListContacts returns a page of CRM contacts and the total count. // ListContacts returns a page of CRM contacts and the total count.
@@ -491,11 +492,25 @@ func (c *Client) ListCRMTasks(ctx context.Context, count, startIndex int) ([]map
} }
// CreateCRMTask creates a CRM task (reminder) attached to an entity. // CreateCRMTask creates a CRM task (reminder) attached to an entity.
// Empty deadline defaults to now+14d (API requires a deadline). Empty
// responsibleID falls back to the authenticated user — without this the
// portal assigns a stub "Profile has been removed" owner.
func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, categoryID, contactID int, entityType string, entityID int, description string) (map[string]any, error) { func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, categoryID, contactID int, entityType string, entityID int, description string) (map[string]any, error) {
if deadline == "" {
deadline = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02T15:04:05")
}
if categoryID == 0 {
categoryID = 2 // Opportunity — matches applications sync
}
uid, err := c.SelfUserID(ctx)
if err != nil {
return nil, fmt.Errorf("CreateCRMTask: resolve self: %w", err)
}
fields := url.Values{} fields := url.Values{}
fields.Set("title", title) fields.Set("title", title)
fields.Set("deadline", deadline) fields.Set("deadline", deadline)
fields.Set("categoryId", strconv.Itoa(categoryID)) fields.Set("categoryId", strconv.Itoa(categoryID))
fields.Set("responsibleId", uid)
if contactID != 0 { if contactID != 0 {
fields.Set("contactId", strconv.Itoa(contactID)) fields.Set("contactId", strconv.Itoa(contactID))
} }
@@ -511,6 +526,29 @@ func (c *Client) CreateCRMTask(ctx context.Context, title, deadline string, cate
return c.postFormObject(ctx, "/api/2.0/crm/task.json", fields) return c.postFormObject(ctx, "/api/2.0/crm/task.json", fields)
} }
// UpdateCRMTask updates title, deadline, category, and responsible on a CRM task.
// Empty responsibleID falls back to SelfUserID; empty deadline is left unchanged
// by the API only if omitted — callers should pass deadLine when known.
func (c *Client) UpdateCRMTask(ctx context.Context, id, title, deadline string, categoryID int, responsibleID string) (map[string]any, error) {
if responsibleID == "" {
uid, err := c.SelfUserID(ctx)
if err != nil {
return nil, fmt.Errorf("UpdateCRMTask: resolve self: %w", err)
}
responsibleID = uid
}
fields := url.Values{}
fields.Set("title", title)
fields.Set("responsibleId", responsibleID)
if deadline != "" {
fields.Set("deadline", deadline)
}
if categoryID != 0 {
fields.Set("categoryId", strconv.Itoa(categoryID))
}
return c.putFormObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)), fields)
}
// DeleteCRMTask removes a CRM task by id. // DeleteCRMTask removes a CRM task by id.
func (c *Client) DeleteCRMTask(ctx context.Context, id string) (map[string]any, error) { func (c *Client) DeleteCRMTask(ctx context.Context, id string) (map[string]any, error) {
return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id))) return c.deleteObject(ctx, fmt.Sprintf("/api/2.0/crm/task/%s.json", url.PathEscape(id)))
+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=
+16 -1
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
} }
switch resp[0] {
case '{':
var out map[string]any var out map[string]any
if err := json.Unmarshal(resp, &out); err != nil { if err := json.Unmarshal(resp, &out); err != nil {
return nil, err return nil, err
} }
return out, nil 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
}
} }
// 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)
}
}
+12
View File
@@ -204,3 +204,15 @@ func (c *Client) UpdateProject(req ProjectUpdateRequest) (*Project, error) {
Response *Project `json:"response"` Response *Project `json:"response"`
}{p}) }{p})
} }
// UpdateProjectStatus sets project lifecycle status (open, paused, closed).
func (c *Client) UpdateProjectStatus(id int, status string) (*Project, error) {
p := &Project{}
return p, c.Query(Request{
Uri: fmt.Sprintf("/api/2.0/project/%d/status", id),
Method: "PUT",
Body: map[string]string{"status": status},
}, &struct {
Response *Project `json:"response"`
}{p})
}
+11 -2
View File
@@ -233,17 +233,26 @@ func (c *Client) GetTaskByID(ctx context.Context, taskID string) (map[string]any
// AddTask creates a task via the form-encoded endpoint (no milestone/start). // AddTask creates a task via the form-encoded endpoint (no milestone/start).
// Prefer the typed CreateProjectTask for new code; AddTask is kept for // Prefer the typed CreateProjectTask for new code; AddTask is kept for
// parity with the Python reference tooling. // parity with the Python reference tooling.
//
// Always assigns the authenticated user as responsible (form field
// "responsibles"). Empty deadline defaults to today+14 days.
func (c *Client) AddTask(ctx context.Context, projectID, title, description string, priority int, deadline string) (map[string]any, error) { func (c *Client) AddTask(ctx context.Context, projectID, title, description string, priority int, deadline string) (map[string]any, error) {
if projectID == "" { if projectID == "" {
projectID = c.defaults.ProjectID projectID = c.defaults.ProjectID
} }
if deadline == "" {
deadline = time.Now().Add(14 * 24 * time.Hour).Format("2006-01-02")
}
uid, err := c.SelfUserID(ctx)
if err != nil {
return nil, fmt.Errorf("AddTask: resolve self: %w", err)
}
fields := url.Values{} fields := url.Values{}
fields.Set("title", title) fields.Set("title", title)
fields.Set("description", description) fields.Set("description", description)
fields.Set("priority", strconv.Itoa(priority)) fields.Set("priority", strconv.Itoa(priority))
if deadline != "" {
fields.Set("deadline", deadline) fields.Set("deadline", deadline)
} fields.Set("responsibles", uid)
return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/project/%s/task.json", url.PathEscape(projectID)), fields) return c.postFormObject(ctx, fmt.Sprintf("/api/2.0/project/%s/task.json", url.PathEscape(projectID)), fields)
} }
+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 != "" {