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>
This commit is contained in:
2026-06-25 11:21:20 +01:00
co-authored by Cursor
parent 0ed2d9b0f5
commit 09e242d7c3
15 changed files with 246 additions and 169 deletions
+75
View File
@@ -0,0 +1,75 @@
---
name: office-tui-table
description: >-
Build and fix Bubble Tea list tables in cmd/office (DataTable, column layout,
lipgloss cell rendering, pane width). Use when changing office center-pane
tables, column widths, ANSI styling, or list toolbar alignment.
---
# office TUI table (`cmd/office/ui`)
## Architecture
| Piece | File | Role |
|-------|------|------|
| `DataTable` | `table.go` | cursor, sort, viewport, header/row render |
| Column layout | `table_layout.go` | flex vs horizontal-scroll policies |
| Flex policy | `model/table_layout.go` | per-subject flex column key + min width |
| Cell render | `table_render.go` | bubbles/table pattern + ANSI-safe padding |
| Subject row style | `table_projects.go` | optional per-subject cell styles |
| Pane sizing | `scroll.go` | `paneContentWidth`, `paneLipglossWidth` |
| Scrollbar pad | `scrollbar.go` | `padANSIWidth` for styled lines |
## Layout policies
1. **Flex (all columns visible)** — register in `model.TableFlexLayoutFor`:
- fixed columns keep `TableColumn.Width`
- one `FlexColumnKey` (e.g. `title`, `email`) absorbs remainder
- `layoutFlexTable` shrinks fixed cols down to `minFixedColumnWidth` before flex min
2. **Scrolling (default)** — `layoutScrollingTable`:
- horizontal `colScroll`; subset of columns visible
- `distributeColumnWidths` fills pane width
Add a new flex subject: one entry in `TableFlexLayoutFor` only — do **not** copy `layoutProjectTable`/`layoutUserTable` per subject.
## Cell rendering (required pattern)
Never `style.Width(w).Padding(0,1).Render(text)` on styled cells — padding doubles display width and breaks `JoinHorizontal`.
Use `renderTableCell` from `table_render.go` (matches `charmbracelet/bubbles/table`):
```go
renderTableCell(outerStyle, plainText, columnWidth)
```
- truncate **plain** text with `runewidth.Truncate` inside `renderTableCell`
- inner box: `Width(innerW).Inline(true)` where `innerW = width - outer.GetHorizontalFrameSize()`
- outer style carries padding, background, foreground
## ANSI rules
- **Never** `runewidth.Truncate` / `padDisplayWidth` on strings that already contain lipgloss ANSI codes — corrupts escapes and shows garbage like lone `ID`.
- Use `padANSIWidth` (`charmbracelet/x/ansi`) for styled lines (headers, toolbar, scrollbar rows).
- Do not wrap an already full-width styled header in another `lipgloss.Width()` — pad once.
## Pane width
Lipgloss bordered panes render **2 cells wider** than `Style.Width`:
- `paneLipglossWidth(rendered) = rendered - 2` for `.Width()` on pane style
- `paneContentWidth(rendered) = rendered - 4` for table/viewport inner size (border + padding)
Pass `paneContentWidth(pw.List)` to `DataTable.SetSize` and list toolbar.
## Tests to add when touching tables
- `layoutFlexTable` width sum equals pane width
- `TestProjectTableHeaderFitsPaneWidth` / per-line width at 68, 40, 30
- `TestPadANSIWidthPreservesStyledLine`
- `TestThreePaneRenderedWidthMatchesTerminal` top border + table lines ≤ inner width
## Anti-patterns
- Per-subject duplicate layout files (`table_foo.go` with copy-pasted shrink loops)
- Skipping truncation on selected rows (causes multi-line rows in viewport)
- Using `bubbles/table` widget directly — office needs column cursor, multi-select, subject-specific row styles
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
go: ["1.22", "stable"] go: ["1.25", "stable"]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
+1
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.
+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")
}
}
+5 -1
View File
@@ -39,7 +39,11 @@ func TestUserFormFieldCount(t *testing.T) {
func TestLayoutUserTableEmailAbsorbsWidth(t *testing.T) { func TestLayoutUserTableEmailAbsorbsWidth(t *testing.T) {
cols := model.BuildColumns(model.SubjectUsers, nil) cols := model.BuildColumns(model.SubjectUsers, nil)
lay := layoutUserTable(cols, 100) flex, ok := model.TableFlexLayoutFor(model.SubjectUsers)
if !ok {
t.Fatal("expected flex layout")
}
lay := layoutFlexTable(cols, 100, flex.FlexColumnKey, flex.MinFlexWidth)
sum := 0 sum := 0
emailW := 0 emailW := 0
for _, i := range lay.indices { for _, i := range lay.indices {
+1 -1
View File
@@ -400,7 +400,7 @@ func (t *DataTable) styleCell(row, col int, colKey string, selected bool, item m
} }
func (t *DataTable) ensureColVisible() { func (t *DataTable) ensureColVisible() {
if t.spec.Subject == model.SubjectProjects || t.spec.Subject == model.SubjectUsers { if flex, ok := model.TableFlexLayoutFor(t.spec.Subject); ok && flex.FlexColumnKey != "" {
t.colScroll = 0 t.colScroll = 0
return return
} }
+65 -5
View File
@@ -15,15 +15,75 @@ func (t *DataTable) computeLayout() columnLayout {
if len(t.columns) == 0 || contentW <= 0 { if len(t.columns) == 0 || contentW <= 0 {
return columnLayout{widths: map[int]int{}} return columnLayout{widths: map[int]int{}}
} }
if t.spec.Subject == model.SubjectProjects { if flex, ok := model.TableFlexLayoutFor(t.spec.Subject); ok {
return layoutProjectTable(t.columns, contentW) return layoutFlexTable(t.columns, contentW, flex.FlexColumnKey, flex.MinFlexWidth)
}
if t.spec.Subject == model.SubjectUsers {
return layoutUserTable(t.columns, contentW)
} }
return layoutScrollingTable(t.columns, t.colScroll, contentW) 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 { func layoutScrollingTable(cols []model.TableColumn, colScroll, contentW int) columnLayout {
indices := pickVisibleColumnIndices(cols, colScroll, contentW) indices := pickVisibleColumnIndices(cols, colScroll, contentW)
widths := make(map[int]int, len(indices)) widths := make(map[int]int, len(indices))
+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)
}
}
-66
View File
@@ -5,72 +5,6 @@ import (
"github.com/eslider/go-onlyoffice/cmd/office/model" "github.com/eslider/go-onlyoffice/cmd/office/model"
) )
const (
projectMinTitleWidth = 12
projectMinFixedWidth = 3
)
// layoutProjectTable shows every project column; the title column absorbs leftover width.
func layoutProjectTable(cols []model.TableColumn, totalW int) columnLayout {
indices := make([]int, len(cols))
for i := range cols {
indices[i] = i
}
widths := make(map[int]int, len(cols))
titleIdx := -1
fixed := 0
for i, col := range cols {
if col.Key == "title" {
titleIdx = i
continue
}
widths[i] = col.Width
fixed += widths[i]
}
if titleIdx < 0 {
return layoutScrollingTable(cols, 0, totalW)
}
titleW := totalW - fixed
if titleW < projectMinTitleWidth {
shrinkProjectFixedColumns(widths, indices, titleIdx, fixed+projectMinTitleWidth-totalW)
fixed = 0
for _, i := range indices {
if i != titleIdx {
fixed += widths[i]
}
}
titleW = totalW - fixed
if titleW < projectMinTitleWidth {
titleW = projectMinTitleWidth
}
}
widths[titleIdx] = titleW
normalizeWidthSum(widths, indices, totalW, titleIdx)
return columnLayout{indices: indices, widths: widths}
}
func shrinkProjectFixedColumns(widths map[int]int, indices []int, titleIdx, need int) {
for need > 0 {
changed := false
for _, i := range indices {
if i == titleIdx || widths[i] <= projectMinFixedWidth {
continue
}
widths[i]--
need--
changed = true
if need == 0 {
return
}
}
if !changed {
return
}
}
}
func (t *DataTable) projectBaseStyle(item model.Item) lipgloss.Style { func (t *DataTable) projectBaseStyle(item model.Item) lipgloss.Style {
if model.ProjectIsOpen(item.Raw) { if model.ProjectIsOpen(item.Raw) {
return lipgloss.NewStyle(). return lipgloss.NewStyle().
-22
View File
@@ -1,22 +0,0 @@
package ui
import (
"testing"
"github.com/eslider/go-onlyoffice/cmd/office/model"
)
func TestLayoutProjectTableAllColumnsVisible(t *testing.T) {
cols := model.BuildColumns(model.SubjectProjects, nil)
lay := layoutProjectTable(cols, 80)
if len(lay.indices) != 7 {
t.Fatalf("got %d visible columns, want 7", len(lay.indices))
}
sum := 0
for _, i := range lay.indices {
sum += lay.widths[i]
}
if sum != 80 {
t.Fatalf("width sum=%d want 80", sum)
}
}
+5 -1
View File
@@ -94,7 +94,11 @@ func TestDistributeColumnWidthsFillsTotal(t *testing.T) {
func TestProjectTableTitleAbsorbsWidth(t *testing.T) { func TestProjectTableTitleAbsorbsWidth(t *testing.T) {
cols := model.BuildColumns(model.SubjectProjects, nil) cols := model.BuildColumns(model.SubjectProjects, nil)
lay := layoutProjectTable(cols, 120) 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) { if len(lay.indices) != len(cols) {
t.Fatalf("expected all %d columns visible, got %d", len(cols), len(lay.indices)) t.Fatalf("expected all %d columns visible, got %d", len(cols), len(lay.indices))
} }
-69
View File
@@ -1,69 +0,0 @@
package ui
import "github.com/eslider/go-onlyoffice/cmd/office/model"
const (
userMinEmailWidth = 16
userMinFixedWidth = 3
)
// layoutUserTable shows every user column; email absorbs leftover width.
func layoutUserTable(cols []model.TableColumn, totalW int) columnLayout {
indices := make([]int, len(cols))
for i := range cols {
indices[i] = i
}
widths := make(map[int]int, len(cols))
emailIdx := -1
fixed := 0
for i, col := range cols {
if col.Key == "email" {
emailIdx = i
continue
}
widths[i] = col.Width
fixed += widths[i]
}
if emailIdx < 0 {
return layoutScrollingTable(cols, 0, totalW)
}
emailW := totalW - fixed
if emailW < userMinEmailWidth {
shrinkUserFixedColumns(widths, indices, emailIdx, fixed+userMinEmailWidth-totalW)
fixed = 0
for _, i := range indices {
if i != emailIdx {
fixed += widths[i]
}
}
emailW = totalW - fixed
if emailW < userMinEmailWidth {
emailW = userMinEmailWidth
}
}
widths[emailIdx] = emailW
normalizeWidthSum(widths, indices, totalW, emailIdx)
return columnLayout{indices: indices, widths: widths}
}
func shrinkUserFixedColumns(widths map[int]int, indices []int, emailIdx, need int) {
for need > 0 {
changed := false
for _, i := range indices {
if i == emailIdx || widths[i] <= userMinFixedWidth {
continue
}
widths[i]--
need--
changed = true
if need == 0 {
return
}
}
if !changed {
return
}
}
}
+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=